Tomasz Lewowski 在他的评论中提出并且我已经扩展的建议非常简单,并且基于以下观察:std::vector 上的 push_back 可能需要重新分配后备存储并复制 (或者,最好是移动)元素。这构成了需要同步的关键部分。
对于下一个示例,假设我们想要一个向量填充前 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)。请注意,仅当 j mod p = 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 {...});
... ... ...
这可能没有您想象的那么糟糕,因为虽然动态内存分配不是免费的,但 sizeof 和 std::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::move是the one from the algorithms library。)
这种方法具有最理想的内存访问模式,因为numbersA、numbersB 和numbersC 正在写入完全独立分配的内存。当然,我们必须做额外的顺序工作来加入中间结果。请注意,效率在很大程度上取决于这样一个事实,即与查找/创建元素的成本相比,移动分配元素的成本可以忽略不计。至少如上所述,代码还假定您的类型具有廉价的默认构造函数。当然,如果您的类型不是这种情况,您可以再次使用智能指针。
我希望这为您提供了足够的想法来优化您的问题。
如果您以前从未使用过智能指针,请查看“RAII and smart pointers in C++” 并查看标准库的dynamic memory management library。上面显示的技术当然也适用于std::vector<Foo *>,但我们不再在现代 C++ 中使用像这样的资源拥有原始指针。