关于多线程:在C / C ++中实现工作窃取队列?

Implementation of a work stealing queue in C/C++?

我正在寻找C/CPP中窃取工作队列的适当实现。我环顾了谷歌,但没有发现任何有用的东西。

也许有人熟悉一个好的开源实现?(我不喜欢执行从原始学术论文中提取的伪代码)。


理论上实施"偷工减料"并不难。您需要一组队列,其中包含通过组合计算和生成其他任务来完成更多工作的任务。您需要对队列进行原子访问,以将新生成的任务放入这些队列中。最后,您需要一个过程,每个任务在结束时调用该过程,以便为执行任务的线程找到更多的工作;该过程需要在工作队列中查找工作。

大多数这样的窃取工作的系统都假设有少量线程(通常由真正的处理器核心备份),并且每个线程只有一个工作队列。然后你首先尝试从你自己的队列中窃取工作,如果它是空的,那么试着从其他人那里窃取。棘手的是要知道要查找哪些队列;连续扫描它们以查找工作非常昂贵,并且可能在查找工作的线程之间产生大量的争用。

到目前为止,这都是非常普通的东西,主要有12个例外:1)切换上下文(例如,设置处理器上下文寄存器,例如"堆栈")不能用纯C或C++表示。您可以通过同意在特定于目标平台的机器代码中编写包的一部分来解决这个问题。2)对多处理器的队列的原子访问不能仅在C或C++中完成(忽略德克尔的算法),因此需要使用汇编语言同步原语(如x86锁XCH或比较和交换)对这些代码进行编码。现在,在安全访问后更新queuse所涉及的代码并不是很复杂,您可以很容易地用几行C来编写它。

不过,我想你会发现,试图用混合汇编程序在C和C++中编写这样一个包仍然是非常低效的,最终你会在汇编程序中对整个事情进行编码。所有剩下的都是C/C++兼容的入口点:-}

我为我们的Parlanse并行编程语言做了这项工作,它提供了一个任意大量并行计算的想法,可以在任何时刻进行实时和交互(同步)。它是在X86的后台实现的,每个CPU只有一个线程,而实现完全是在汇编程序中完成的。窃取代码的工作可能总共有1000行,这是一个棘手的代码,因为您希望它在非争用情况下速度非常快。

在C和C++的软膏中,当你创建一个代表工作的任务时,你分配了多少个堆栈空间?串行C/C++程序通过简单地分配一个线性堆栈的巨大数量(例如,10MB)来避免这个问题,并且没有人关心浪费了多少堆栈空间。但是,如果您可以创建数千个任务,并让它们都在特定的时间内运行,那么您就不能合理地为每个任务分配10MB。因此,现在您需要静态地确定任务需要多少堆栈空间(图灵硬),或者您需要分配堆栈块(例如,每个函数调用),这是广泛可用的C/C++编译器不做的(例如,您可能使用的那个)。最后一条出路是限制任务创建,使其在任何时刻都限制在几百个,并在活动的任务中复用几百个真正巨大的堆栈。如果任务可以互锁/挂起状态,则不能执行最后一个操作,因为您将进入阈值。因此,只有当任务只进行计算时,才能这样做。这似乎是一个相当严重的限制。

对于parlane,我们构建了一个编译器,为每个函数调用在堆上分配激活记录。


没有免费午餐。

请看一下偷纸的原作。这篇论文很难理解。我知道这篇论文包含理论证据而不是伪代码。然而,没有比TBB更简单的版本了。如果有的话,它不会提供最佳性能。窃取工作本身会产生一定的开销,因此优化和技巧非常重要。尤其是,出列必须是线程安全的。实现高度可扩展和低开销的同步具有挑战性。

我真想知道你为什么需要它。我认为适当的实现意味着像tbb和cilk。同样,偷工减料也很难实施。


看看英特尔的线程构建块。

http://www.threadingbuildingblocks.org网站/


如果你正在寻找一个独立的工作窃取队列类实现在C++上建立在pToX或Booo::线程,好运,据我所知,没有一个。

然而,正如其他人所说,CILK、TBB和微软的PPL都在幕后进行工作测试。

问题是要使用工作测试队列还是要实现工作测试队列?如果你只想使用一个,那么上面的选择是很好的起点,简单地在其中任何一个选项中安排一个"任务"就足够了。

正如BlueRaja所说,PPL中的任务组和结构化任务组将做到这一点,同时也注意到这些类在最新版本的英特尔TBB中也有提供。并行循环(parallel_for,parallel_for_each)也通过工作测试实现。

如果您必须查看源代码而不是使用实现,那么tbb是OpenSource,而Microsoft将源代码发送给它的CRT,这样您就可以进行洞穴探险了。

您也可以在Joe Duffy的博客上查看C_实现(但它是C_实现,内存模型不同)。

-里克


有一种工具可以很优雅地完成它。这是一个真正有效的方法,在很短的时间内使你的计划谈判。

CILK项目

HPC Challenge Award

Our Cilk entry for the HPC Challenge
Class 2 award won the 2006 award for
``Best Combination of Elegance and
Performance''. The award was made at
SC'06 in Tampa on November 14 2006.


此开源库https://github.com/cpp-taskflow/cpp-taskflow自2018年12月起支持工作窃取线程池。

看看WorkStealingQueue类,它实现了工作窃取队列,如本文"动态循环工作窃取deque"所述,SPAA,2015。


尽管OpenMP被称为递归并行,但它可能非常支持窃取工作。

OpenMP论坛帖子

The OpenMP specification defines tasking constructs (which can be nested, so are very suitable for recursive parallelism) but does not specify the details of how they how they are implemented. OpenMP implementations, including gcc, typically use some form of work stealing for tasks, though the exact algorithm (and the resulting performance) may vary!

#pragma omp task#pragma omp taskwait

更新

《行动中C++并发》的第9章描述了如何实现"池线程的工作窃取"。我自己没有读过/实现过它,但看起来并不太难。


我所发现的这种窃取工作的算法的最接近的实现是karl filip fax_n.src/report/comparison所说的wool。


PPL的结构化"任务组"类使用工作窃取队列来实现它。如果您需要一个用于线程处理的WSQ,我建议您这样做。如果你真的在寻找源代码,我不知道代码是在ppl.h中给出的,还是有预编译的对象;我今晚回家的时候必须检查一下。


我把这个C项目移植到C++。

扩展阵列时,原始的Steal可能会经历脏读。我试图修复这个bug,但最终还是让步了,因为我实际上并不需要一个动态增长的堆栈。与试图分配空间不同,Push方法只返回false。然后,调用者可以执行自旋等待,即while(!stack->Push(value)){}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#pragma once
#include

  // A lock-free stack.
  // Push = single producer
  // Pop = single consumer (same thread as push)
  // Steal = multiple consumer

  // All methods, including Push, may fail. Re-issue the request
  // if that occurs (spinwait).

  template<class T, size_t capacity = 131072>
  class WorkStealingStack {

  public:
    inline WorkStealingStack() {
      _top = 1;
      _bottom = 1;
    }

    WorkStealingStack(const WorkStealingStack&) = delete;

    inline ~WorkStealingStack()
    {

    }

    // Single producer
    inline bool Push(const T& item) {
      auto oldtop = _top.load(std::memory_order_relaxed);
      auto oldbottom = _bottom.load(std::memory_order_relaxed);
      auto numtasks = oldbottom - oldtop;

      if (
        oldbottom > oldtop && // size_t is unsigned, validate the result is positive
        numtasks >= capacity - 1) {
        // The caller can decide what to do, they will probably spinwait.
        return false;
      }

      _values[oldbottom % capacity].store(item, std::memory_order_relaxed);
      _bottom.fetch_add(1, std::memory_order_release);
      return true;
    }

    // Single consumer
    inline bool Pop(T& result) {

      size_t oldtop, oldbottom, newtop, newbottom, ot;

      oldbottom = _bottom.fetch_sub(1, std::memory_order_release);
      ot = oldtop = _top.load(std::memory_order_acquire);
      newtop = oldtop + 1;
      newbottom = oldbottom - 1;

      // Bottom has wrapped around.
      if (oldbottom < oldtop) {
        _bottom.store(oldtop, std::memory_order_relaxed);
        return false;
      }

      // The queue is empty.
      if (oldbottom == oldtop) {
        _bottom.fetch_add(1, std::memory_order_release);
        return false;
      }

      // Make sure that we are not contending for the item.
      if (newbottom == oldtop) {
        auto ret = _values[newbottom % capacity].load(std::memory_order_relaxed);
        if (!_top.compare_exchange_strong(oldtop, newtop, std::memory_order_acquire)) {
          _bottom.fetch_add(1, std::memory_order_release);
          return false;
        }
        else {
          result = ret;
          _bottom.store(newtop, std::memory_order_release);
          return true;
        }
      }

      // It's uncontended.
      result = _values[newbottom % capacity].load(std::memory_order_acquire);
      return true;
    }

    // Multiple consumer.
    inline bool Steal(T& result) {
      size_t oldtop, newtop, oldbottom;

      oldtop = _top.load(std::memory_order_acquire);
      oldbottom = _bottom.load(std::memory_order_relaxed);
      newtop = oldtop + 1;

      if (oldbottom <= oldtop)
        return false;

      // Make sure that we are not contending for the item.
      if (!_top.compare_exchange_strong(oldtop, newtop, std::memory_order_acquire)) {
        return false;
      }

      result = _values[oldtop % capacity].load(std::memory_order_relaxed);
      return true;
    }

  private:

    // Circular array
    std::atomic<T> _values[capacity];
    std::atomic<size_t> _top; // queue
    std::atomic<size_t> _bottom; // stack
  };

完整的GIST(包括单元测试)。我只在一个强大的体系结构(x86/64)上运行测试,因此,如果您尝试在neon/ppc上使用它,那么就弱体系结构而言,您的里程可能会有所不同。


把你的工作任务分解成更小的单位会首先消除窃取工作的必要性吗?


不知道这对你有什么帮助,但是看看这篇关于AMD开发者网络的文章,它很简单,但是应该给你一些有用的东西。


我不认为Jobswarm使用偷工作,但这是第一步。我不知道还有其他开放源代码库用于此目的。