我们在哪里可以使用std :: barrier而不是std :: latch?

时间:2018-02-26 10:12:18

标签: c++ visual-c++ std c++-experimental

我最近听说过新的c ++标准功能:

  1. std::latch
  2. std::barrier
  3. 我无法弄明白,在哪种情况下,它们适用于彼此并且非常有用。

    • 如果有人可以提出一个如何明智地使用每一个的例子,那将会非常有帮助。

1 个答案:

答案 0 :(得分:6)

很简短的答案

它们实际上是针对完全不同的目标

  • 当您有一堆线程并且想要一次在它们之间进行同步时,例如,要立即执行对所有数据的操作,屏障就很有用。
  • 如果您有很多工作项,并且想知道什么时候处理完了,并且不一定对哪个线程处理了它们,则闩锁很有用。

更长的答案

当您具有执行某些处理的工作线程池以及在它们之间共享的工作项队列时,通常使用屏障和闩锁。这不是使用它们的唯一情况,但这是一种非常常见的情况,确实有助于说明它们之间的差异。这是一些示例代码,它们可以设置如下这样的线程:

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 ++标准库中没有这样的东西(有点令人惊讶),但是有很多包含它们的开源库。 Folly MPMCQueuemoodycamel::ConcurrentQueue,或者您可以使用std::mutexstd::condition_variablestd::deque自己打造一个不太喜欢的人(如果有很多示例,您是Google的用户。

闩锁

闩锁通常用于等待您推送到队列中的某些工作项全部完成,以便您可以检查结果。

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传递给屏障的构造函数。 (然后,您无需为障碍使用共享指针。)
  • 如果将其他工作项同时推送到队列中,也可以,尽管这可能会浪费时间,因为某些线程只会坐在那里等待获取障碍,而其他线程会分散其他线程的注意力。在获得障碍之前就努力工作。

!!!危险!!!

只有在其他工作也没有使用障碍的情况下,关于其他工作才被“罚款”的最后一个要点是!如果您有两个不同的生产者线程,将带有屏障的工作项放在同一队列中,并且这些项目是交错的,则某些线程将在一个屏障上等待,而其他线程在另一个屏障上等待,而任何线程都将达不到所需的等待计数-< strong> DEADLOCK 。避免这种情况的一种方法是仅在单个线程中使用这种屏障,或者甚至在整个程序中仅使用一种屏障(这听起来很极端,但实际上是相当普遍的策略,因为屏障通常用于启动时间初始化)。如果您正在使用的线程队列支持该方法,则另一种选择是将所有用于屏障的工作项立即自动推送到队列中,这样它们就不会与任何其他工作项交错。 (这不适用于moodycamel队列,该队列支持一次推送多个项目,但不能保证它们不会与其他线程推送的项目相互关联。)

没有完成功能的障碍

在您问这个问题时,建议的实验性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);
}

工作原理:

关键思想是在每个线程中等待两次屏障,然后在两者之间进行工作。第一次等待与前面的示例具有相同的目的:它们确保在开始此工作之前,队列中所有较早的工作项都已完成。第二次等待确保队列中的所有后续项目在此工作完成之前不会开始。

注释:

注释与前面的障碍示例大致相同,但有一些区别:

  • 一个区别是,由于障碍没有绑定到特定的完成函数,因此您更有可能在多次使用之间共享它,就像我们在闩锁示例中所做的那样,避免了使用共享指针。
  • li>
  • 该示例使使用没有完成功能的障碍看起来更加灵活,但这仅仅是因为这种情况不太适合他们。有时,您所需要的只是达到障碍。例如,虽然我们在线程启动之前初始化了一个队列,但是也许每个线程都有一个队列,但是在线程的运行函数中进行了初始化。在这种情况下,障碍可能只是表示队列已初始化,并且已准备好供其他线程相互传递消息。在这种情况下,您可以使用没有完成功能的屏障,而无需像这样等待两次。
  • 您实际上可以为此使用闩锁,依次调用count_down()wait()来代替count_down_and_wait()。但是使用屏障更有意义,这既是因为调用组合函数更简单,又是因为使用屏障将您的意图更好地传达给了将来的代码读者。
  • 无论如何,之前的“危险”警告仍然适用。