同步push_back和std :: thread

时间:2015-01-11 13:30:51

标签: c++ multithreading c++11 vector synchronization

我的代码

void build(std::vector<RKD <DivisionSpace> >& roots, ...) {
  try {
    // using a local lock_guard to lock mtx guarantees unlocking on destruction / exception:
    std::lock_guard<std::mutex> lck (mtx);
    roots.push_back(RKD<DivisionSpace>(...));
  }
  catch (const std::bad_alloc&) {
    std::cout << "[exception caught when constructing tree]\n";
    return;
  }
}

现在,实际工作应该是连续进行,而不是并行进行。

RKD的构造函数可以与RKD的其他构造函数并行运行。但是,在std::Vector中推回对象是一个关键部分,对吗?

我要构建的对象的数量是已知的。在实践中它将在[2,16]范围内。理论上它可以是任何正数。

另外,我对它们将被插入容器的顺序并不感兴趣。

所以我可以这样做:

RKD tree = RKD(...);
mutex_lock(...);
roots.push_back(tree);

然而,这意味着复制,不是吗?

如何使我的代码并行?


由于this回答,我决定使用锁(而不仅仅是互斥锁)。

2 个答案:

答案 0 :(得分:7)

Tomasz Lewowski在评论中提出并且我已经扩展的建议非常简单,并且基于以下观察:push_back上的std::vector可能需要重新分配支持存储和复制(或者,最好是移动)元素。这构成了一个需要同步的关键部分。

对于下一个例子,假设我们想要一个向前填充前12个素数的向量,但我们不关心它们的排序。 (我刚刚对这里的数字进行了硬编码,但假设它们是通过一些昂贵的计算得到的,这些计算是并行的。)在下面的场景中存在危险的竞争条件。

std::vector<int> numbers {};  // an empty vector

// thread A             // thread B             // thread C

numbers.push_back( 2);  numbers.push_back(11);  numbers.push_back(23);
numbers.push_back( 3);  numbers.push_back(13);  numbers.push_back(27);
numbers.push_back( 5);  numbers.push_back(17);  numbers.push_back(29);
numbers.push_back( 7);  numbers.push_back(19);  numbers.push_back(31);

push_back还有另一个问题。如果两个线程同时调用它,它们都将尝试在同一索引处构造一个具有潜在灾难性后果的对象。因此,在分叉线程之前,reserve(n)没有解决问题。

但是,由于您事先了解了元素的数量,因此您只需分配到std::vector内的特定位置,而无需更改其大小。如果不更改大小,则没有关键部分。因此,在以下情况中没有竞争。

std::vector<int> numbers(12);  // 12 elements initialized with 0

// thread A          // thread B          // thread C

numbers[ 0] =  2;    numbers[ 1] =  3;    numbers[ 2] =  5;
numbers[ 3] =  7;    numbers[ 4] = 11;    numbers[ 5] = 13;
numbers[ 6] = 17;    numbers[ 7] = 19;    numbers[ 8] = 23;
numbers[ 9] = 29;    numbers[10] = 31;    numbers[11] = 37;

当然,如果两个线程都试图写入相同的索引,那么竞争将再次出现。幸运的是,在实践中防范这一点并不困难。如果你的向量有 n 个元素并且你有 p 个线程,那么线程 i 只会写入元素[ i n / p ,( i + 1) n / p )。请注意,这比使用线程 i 仅在 j mod p = <的情况下写入索引 j 的元素更可取。 i> i 因为它导致更少的缓存失效。所以上面例子中的访问模式是次优的,最好是这样的。

std::vector<int> numbers(12);  // 12 elements initialized with 0

// thread A          // thread B          // thread C

numbers[ 0] =  2;    numbers[ 4] = 11;    numbers[ 8] = 23;
numbers[ 1] =  3;    numbers[ 5] = 13;    numbers[ 9] = 29;
numbers[ 2] =  5;    numbers[ 6] = 17;    numbers[10] = 31;
numbers[ 3] =  7;    numbers[ 7] = 19;    numbers[11] = 37;

到目前为止一切顺利。但是如果你没有std::vector<int>而是std::vector<Foo>怎么办?如果Foo没有默认构造函数,那么

std::vector<Foo> numbers(10);

无效。即使它有一个,创建许多昂贵的默认构造对象只是为了在不检索值的情况下很快重新分配它们将是令人发指的。

当然,大多数设计良好的类应该有一个非常便宜的默认构造函数。例如,std::string默认构造为空字符串,不需要内存分配。一个好的实现会将默认构造字符串的成本降低到

std::memset(this, 0, sizeof(std::string));

如果编译器足够聪明,可以确定我们正在分配和初始化整个std::vector<std::string>(n),那么它可能能够进一步优化这一点,只需一次调用

std::calloc(n, sizeof(std::string));

因此,如果有可能使Foo便宜地默认构造和可分配,那么你就完成了。但是,如果事情变得困难,可以通过将其移到堆中来避免此问题。智能指针是廉价的可默认构造的,所以

std::vector<std::unique_ptr<Foo>> foos(n);

最终将减少为

std::calloc(n, sizeof(std::unique_ptr<Foo>));

没有你对Foo做任何事情。当然,这种便利是以每个元素的动态内存分配为代价的。

std::vector<std::unique_ptr<Foo>> foos(n);

// thread A                    // thread B                           // thread C

foos[0].reset(new Foo {...});  foos[n / 3 + 0].reset(new Foo {...});  foos[2 * n / 3 + 0].reset(new Foo {...});
foos[1].reset(new Foo {...});  foos[n / 3 + 1].reset(new Foo {...});  foos[2 * n / 3 + 1].reset(new Foo {...});
foos[2].reset(new Foo {...});  foos[n / 3 + 2].reset(new Foo {...});  foos[2 * n / 3 + 2].reset(new Foo {...});
...                            ...                                    ...

这可能没有你想象的那么糟糕,因为动态内存分配不是免费的,sizeofstd::unique_ptr非常小,所以如果sizeof(Foo)很大,你会得到更紧凑的向量的加值,迭代更快。这一切当然取决于您打算如何使用您的数据。

如果你事先不知道元素的确切数量或者害怕你会搞乱索引,还有另一种方法:让每个线程填充自己的向量并在最后合并它们。继续素数的例子,我们得到了这个。

std::vector<int> numbersA {};  // private store for thread A
std::vector<int> numbersB {};  // private store for thread B
std::vector<int> numbersC {};  // private store for thread C

// thread A              // thread B              // thread C

numbersA.push_back( 2);  numbersB.push_back(11);  numbersC.push_back(23);
numbersA.push_back( 3);  numbersB.push_back(13);  numbersC.push_back(27);
numbersA.push_back( 5);  numbersB.push_back(17);  numbersC.push_back(29);
numbersA.push_back( 7);  numbersB.push_back(21);  numbersC.push_back(31);

// Back on the main thread after A, B and C are joined:

std::vector<int> numbers(
    numbersA.size() + numbersB.size() + numbersC.size());
auto pos = numbers.begin();
pos = std::move(numbersA.begin(), numbersA.end(), pos);
pos = std::move(numbersB.begin(), numbersB.end(), pos);
pos = std::move(numbersC.begin(), numbersC.end(), pos);
assert(pos == numbers.end());

// Now dispose of numbersA, numbersB and numbersC as soon as possible
// in order to release their no longer needed memory.

(上述代码中使用的std::movethe one from the algorithms library。)

这种方法具有最理想的内存访问模式,因为numbersAnumbersBnumbersC正在写入完全独立分配的内存。当然,我们必须进行额外的顺序加入中间结果的工作。请注意,效率很大程度上依赖于移动分配元素的成本与查找/创建元素的成本相比可忽略不计的事实。至少如上所述,代码还假定您的类型具有廉价的默认构造函数。当然,如果你的类型不是这种情况,你可以再次使用智能指针。

我希望这为您提供了足够的想法来优化您的问题。

如果您以前从未使用智能指针,请查看“RAII and smart pointers in C++并查看标准库的dynamic memory management library。上面显示的技术当然也适用于std::vector<Foo *>,但我们不再使用现代C ++中的资源拥有这样的原始指针。

答案 1 :(得分:3)

问题似乎是你的构造函数正在做很多工作,这会破坏构造和容器插入的各种库约定。

通过将插入与创建分离来解决它。

以下代码非常类似于@ 5gon12eder建议的代码,但它不会“强迫”您更改对象的位置。

在我的小演示中

  • 我们使用一个原始的内存区域,这个区域完全没有初始化(这对于vector来说是不可能的,其中插入意味着初始化),所以不是“规范”

    std::array<RKD, 500> rkd_buffer; // OR
    std::vector<RKD> rkd_buffer(500); // OR even
    std::unique_ptr<RKD[]> rkd_buffer(new RKD[500]);
    

    我们将使用自定义组合:

    std::unique_ptr<RKD[N], decltype(&::free)> rkd_buffer(
        static_cast<RKD(*)[N]>(::malloc(sizeof(RKD) * N)),
        ::free
    );
    
  • 然后我们创建一些线程(示例中为5个)来构造所有元素。这些项目就是就地构建的,它们各自的析构函数将在程序出口

  • 中调用
  • 因此,在rkd_buffer超出范围之前,所有项目都已完全初始化是至关重要的(join在此确保这一点。)
  • 线程可以通过不同方式同步:构造可以例如通过工作队列调度到线程池,其中条件变量,promises,线程障碍(来自boost)或甚至只是原子共享计数器可用于协调。

    所有这些选择本质上与使建筑并行运行的任务无关,所以我会把它留给你的想象(或其他SO答案)

<强> Live On Coliru

struct RKD {
    RKD() { this_thread::sleep_for(chrono::milliseconds(rand() % 100)); } // expensive
};

int main() {
    static const int N         = 500;
    static const int ChunkSize = 100;
    std::unique_ptr<RKD[N], decltype(&::free)> rkd_buffer(static_cast<RKD(*)[N]>(::malloc(sizeof(RKD) * N)), ::free);

    vector<thread> group;
    for (int chunk = 0; chunk < N/ChunkSize; chunk += ChunkSize)
        group.emplace_back([&] { 
            for (int i=chunk * ChunkSize; i<(ChunkSize + chunk*ChunkSize); ++i)
                new (rkd_buffer.get() + i) RKD;
        });

    for (auto& t:group) if (t.joinable()) t.join();

    // we are responsible for destructing, since we also took responsibility for construction
    for (RKD& v : *rkd_buffer)
        v.~RKD();
}

你可以看到有5个线程划分500个结构。每个构造(平均)约50ms,因此所花费的总时间应为100 * 50ms~ = 5s。事实上,这正是发生的事情:

real    0m5.193s
user    0m0.004s
sys 0m0.000s