Где мы можем использовать std:: барьер над std::latch?

Недавно я услышал новые стандартные функции C++, которые:

  1. станд:: защелка
  2. станд:: барьер

Я не могу понять, в каких ситуациях они применимы и полезны друг для друга.

  • Если кто-то может привести пример того, как правильно использовать каждый из них, это было бы очень полезно.

0 ответов

Очень короткий ответ

На самом деле они преследуют совсем другие цели:

  • Барьеры полезны, когда у вас есть куча потоков и вы хотите синхронизировать их сразу, например, чтобы сделать что-то, что работает со всеми их данными одновременно.
  • Защелки полезны, если у вас есть несколько рабочих элементов, и вы хотите знать, когда все они были обработаны, и вам не обязательно интересно, какой поток (-ы) их обработал.

Гораздо более длинный ответ

Барьеры и защелки часто используются, когда у вас есть пул рабочих потоков, которые выполняют некоторую обработку, и очередь рабочих элементов, которая используется совместно. Это не единственная ситуация, в которой они используются, но она очень распространена и помогает проиллюстрировать различия. Вот пример кода, который настраивает такие потоки, как это:

const size_t worker_count = 7; // or whatever
std::vector<std::thread> workers;
std::vector<Proc> procs(worker_count);
Queue<std::function<void(Proc&)>> queue;
for (size_t i = 0; i < worker_count; ++i) {
    workers.push_back(std::thread(
        [p = &procs[i], &queue]() {
            while (auto fn = queue.pop_back()) {
                fn(*p);
            }
        }
    ));
}

Я предположил, что в этом примере существуют два типа:

  • Proc: тип, специфичный для вашего приложения, который содержит данные и логику, необходимые для обработки рабочих элементов. Ссылка на один передается каждой функции обратного вызова, выполняемой в пуле потоков.
  • Queue: потокобезопасная блокирующая очередь. В стандартной библиотеке C++ нет ничего подобного (что несколько удивительно), но есть много библиотек с открытым исходным кодом, содержащих их, например, FollyMPMCQueue или moodycamel::ConcurrentQueue, или вы можете создать менее модный самостоятельно с std::mutex, std::condition_variable а также std::deque (есть много примеров, как это сделать, если вы для них погуглите).

Защелка

Защелка часто используется для ожидания завершения некоторых рабочих элементов, которые вы помещаете в очередь, как правило, чтобы вы могли проверить результат.

std::vector<WorkItem> work = get_work();
std::latch latch(work.size());
for (WorkItem& work_item : work) {
    queue.push_back([&work_item, &latch](Proc& proc) {
        proc.do_work(work_item);
        latch.count_down();
    });
}
latch.wait();
// Inspect the completed work

Как это работает:

  1. В конечном итоге потоки вытеснят рабочие элементы из очереди, возможно, с несколькими потоками в пуле, обрабатывающими разные рабочие элементы одновременно.
  2. По завершении каждого рабочего элемента latch.count_down() вызывается, эффективно уменьшая внутренний счетчик, начавшийся в work.size().
  3. Когда все рабочие элементы завершены, этот счетчик достигает нуля, и в этот момент latch.wait() возвращается, и поток-производитель знает, что все рабочие элементы были обработаны.

Примечания:

  • Счетчик защелок - это количество рабочих элементов, которые будут обработаны, а не количество рабочих потоков.
  • В count_down()Метод может вызываться ноль раз, один раз или несколько раз в каждом потоке, и это число может быть различным для разных потоков. Например, даже если вы отправите 7 сообщений в 7 потоков, может оказаться, что все 7 элементов будут обрабатываться в одном и том же потоке (а не по одному для каждого потока), и это нормально.
  • Другие несвязанные рабочие элементы могут чередоваться с этими (например, потому что они были помещены в очередь другими потоками-производителями), и снова это нормально.
  • В принципе возможно, что latch.wait()не будет вызываться до тех пор, пока все рабочие потоки не завершат обработку всех рабочих элементов. (Это своего рода странное состояние, на которое следует обратить внимание при написании многопоточного кода.) Но это нормально, это не состояние гонки:latch.wait() просто немедленно вернется в этом случае.
  • Альтернативой использованию защелки является наличие другой очереди, помимо показанной здесь, которая содержит результат работы элементов. Обратный вызов пула потоков помещает результаты в эту очередь, в то время как поток-производитель извлекает результаты из нее. В основном он идет в противоположном направленииqueueв этом коде. Это тоже вполне допустимая стратегия, на самом деле она более распространена, но есть и другие ситуации, когда защелка более полезна.

Барьер

Барьер часто используется, чтобы все потоки ожидали одновременно, чтобы данные, связанные со всеми потоками, могли обрабатываться одновременно.

typedef Fn std::function<void()>;
Fn completionFn = [&procs]() {
    // Do something with the whole vector of Proc objects
};
auto barrier = std::make_shared<std::barrier<Fn>>(worker_count, completionFn);
auto workerFn = [barrier](Proc&) {
    barrier->count_down_and_wait();
};
for (size_t i = 0; i < worker_count; ++i) {
    queue.push_back(workerFn);
}

Как это работает:

  1. Все рабочие потоки откроют один из этих workerFn предметы вне очереди и звоните barrier.count_down_and_wait().
  2. Когда все будут ждать, один из них позвонит completionFn() а остальные продолжают ждать.
  3. Как только эта функция завершится, все они вернутся из count_down_and_wait() и будьте свободны выталкивать другие, не связанные, рабочие элементы из очереди.

Примечания:

  • Здесь счетчик барьеров - это количество рабочих потоков.
  • Гарантируется, что в каждом потоке появится ровно один workerFnвне очереди и обработайте это. Как только поток вытащил один из очереди, он будет ждать вbarrier.count_down_and_wait() пока все остальные копии workerFn были отключены другими потоками, поэтому нет никаких шансов, что он откроет еще один.
  • Я использовал общий указатель на барьер, так что он будет автоматически уничтожен, как только все рабочие элементы будут выполнены. Это не было проблемой с защелкой, потому что там мы могли просто сделать ее локальной переменной в функции потока-производителя, потому что она ждет, пока рабочие потоки не воспользуются защелкой (она вызываетlatch.wait()). Здесь поток-производитель не ждет барьера, поэтому нам нужно управлять памятью по-другому.
  • Если вы хотите, чтобы исходный поток-производитель ждал, пока барьер не будет завершен, это нормально, он может вызвать count_down_and_wait() тоже, но вам, очевидно, нужно будет пройти worker_count + 1конструктору барьера. (И тогда вам не нужно использовать общий указатель для барьера.)
  • Если в очередь одновременно помещаются и другие рабочие элементы, это тоже нормально, хотя это потенциально приведет к потере времени, так как некоторые потоки будут просто сидеть там, ожидая, пока возникнет барьер, в то время как другие потоки будут отвлекаться на другую работу, прежде чем они приобрести барьер.

!!! ОПАСНОСТЬ!!!

Последний пункт о том, что другая работа, помещаемая в очередь, является "прекрасной", - это только в том случае, если эта другая работа также не использует барьер! Если у вас есть два разных потока-производителя, помещающие рабочие элементы с барьером в одну и ту же очередь, и эти элементы чередуются, тогда некоторые потоки будут ждать на одном барьере, а другие - на другом, и ни один из них никогда не достигнет требуемого счетчика ожидания - DEADLOCK. Один из способов избежать этого - всегда использовать такие барьеры только из одного потока или даже когда-либо использовать только один барьер во всей вашей программе (это звучит чрезмерно, но на самом деле это довольно распространенная стратегия, поскольку барьеры часто используются для одного - инициализация времени при запуске). Другой вариант, если используемая вами очередь потоков поддерживает это, - атомарно поместить все рабочие элементы для барьера в очередь сразу, чтобы они никогда не чередовались с другими рабочими элементами. (Это не сработает сmoodycamel queue, который поддерживает одновременную отправку нескольких элементов, но не гарантирует, что они не будут чередоваться с элементами, отправленными другими потоками.)

Барьер без функции завершения

На тот момент, когда вы задали этот вопрос, предлагаемый экспериментальный API не поддерживал функции завершения. Даже текущий API, по крайней мере, позволяет не использовать их, поэтому я подумал, что должен показать пример того, как можно использовать барьеры и таким образом.

auto barrier = std::make_shared<std::barrier<>>(worker_count);
auto workerMainFn = [&procs, barrier](Proc&) {
    barrier->count_down_and_wait();
    // Do something with the whole vector of Proc objects
    barrier->count_down_and_wait();
};
auto workerOtherFn = [barrier](Proc&) {
    barrier->count_down_and_wait();  // Wait for work to start
    barrier->count_down_and_wait();  // Wait for work to finish
}
queue.push_back(std::move(workerMainFn));
for (size_t i = 0; i < worker_count - 1; ++i) {
    queue.push_back(workerOtherFn);
}

Как это работает:

Ключевая идея - дважды ждать барьера в каждом потоке и выполнять промежуточную работу. Первые ожидания имеют ту же цель, что и предыдущий пример: они гарантируют, что все предыдущие рабочие элементы в очереди будут завершены до начала этой работы. Второе ожидание гарантирует, что любые последующие элементы в очереди не начнутся, пока эта работа не будет завершена.

Примечания:

Примечания в основном такие же, как и в предыдущем примере барьера, но есть некоторые отличия:

  • Одно отличие состоит в том, что, поскольку барьер не привязан к конкретной функции завершения, более вероятно, что вы можете использовать его для нескольких пользователей, как мы это сделали в примере с защелкой, избегая использования общего указателя.
  • Этот пример показывает, что использование барьера без функции завершения намного сложнее, но это просто потому, что эта ситуация не очень подходит для них. Иногда все, что вам нужно, - это добраться до барьера. Например, хотя мы инициализировали очередь перед запуском потоков, возможно, у вас есть очередь для каждого потока, но инициализированная в функциях выполнения потоков. В этом случае, возможно, барьер просто означает, что очереди были инициализированы и готовы для других потоков передавать сообщения друг другу. В этом случае вы можете использовать барьер без функции завершения, не ожидая его дважды, как это.
  • Вы можете использовать для этого защелку, позвонив count_down() а потом wait() на месте count_down_and_wait(). Но использование барьера имеет больше смысла, потому что вызов комбинированной функции немного проще и потому, что использование барьера лучше передает ваше намерение будущим читателям кода.
  • В любом случае предыдущее предупреждение "ОПАСНОСТЬ" по-прежнему действует.
Другие вопросы по тегам