先把结论放在前面:C++20 的 std::ranges 绝不只是把sort(v.begin(), v.end())写成sort(v)的语法糖。我在多线程环境里用这东西大半年,真正难啃的骨头全在“算法 + 视图 + 线程”三者交汇的地方:视图是惰性求值的,filter_view的迭代器内部藏着可变缓存,标准库又不给 ranges 算法配执行策略重载。这些细节单看任何一本规范文档都正常,一旦代码被丢进线程池,就开始出现偶发结果错乱、死循环、甚至直接terminate。这篇文章就把这几个问题彻底拆开,从 ranges 的表达逻辑聊到执行策略的接口真相,最后给出几种能落地的“ranges 风格 + 多线程”组合方案和性能选型经验。适合正在用 C++20/23 做并行计算、或者想在已有线程池项目里引入 ranges 风格代码的同学参考,也欢迎踩过同样坑的人来对答案。
1. 从迭代器对到管道:ranges 改的其实是数据流的组织方式
1.1 三个最明显的改动
老 C++ 写算法是这样的:所有标准算法都只认迭代器对,什么东西到你手里都得先拆出begin()和end()。于是代码里充满了这种重复劳动:
std::vector<int> v{3, 1, 4, 1, 5, 9, 2, 6}; auto it = std::remove_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }); v.erase(it, v.end()); std::transform(v.begin(), v.end(), v.begin(), [](int x) { return x * x; });读起来不累吗?累。但真正烦人的还不是多敲几个字符,而是组合两个算法的时候,你必须先落一个中间容器,或者费劲地嵌套迭代器。比如“先挑出奇数、再取平方”,传统写法要么搞两个循环,要么写一坨嵌套调用,可读性很差。
ranges 把视角从“迭代器”抬到了“范围”这一层:
namespace rv = std::views; auto pipeline = v | rv::filter([](int x) { return x % 2 != 0; }) | rv::transform([](int x) { return x * x; }); for (int x : pipeline) { std::cout << x << ' '; }sort(v)这种写法只是表象。真正值钱的是管道运算符|:左边是一个 range,右边是一个视图适配器,组合出来还是 range。这种抽象能让你像搭积木一样描述数据处理流程,而且默认不产生新容器。我现在重构老代码时,第一件事就是把一层层嵌套循环改成这种流水线,可读性收益立竿见影。
1.2 惰性求值是双刃剑
单线程里,视图一切正常。因为视图适配器是惰性的,pipeline构造出来时什么都没有算,真正的计算发生在你for循环迭代它的那一刻。这在单线程里非常舒服,配合continue、break能天然节省无用计算。
但惰性求值意味着一个逻辑:从写下代码到真正执行,中间隔着“谁先迭代它”。这个“谁”一旦变成多个线程,事情就复杂了。后面第三章我会专门讲:同一个视图被两个线程同时调用begin(),内部可能发生数据竞争;一个视图被异步任务捕获后,它引用的源容器可能已经被析构;某段变换函数里有隐藏计数器,多线程一跑计数就错。这些在单线程流水线里几乎不会出现,多线程下却是实打实的坑。
1.3 为什么这天然适合线程安全
不过先说点正面的。ranges 的管道风格确实把很多代码逼向了“无状态纯函数”的方向:
v | rv::transform([](int x) { return x + 1; });这里的 lambda 不捕获任何外部变量,没有副作用,输入输出一一对应。这种函数放在任何线程里跑都是安全的。相比传统的for (auto& x : v) { x = compute(x); },后者更容易让你在循环体里顺手写个共享计数器、复用某个临时对象之类的东西——那才是多线程并发修改的温床。
所以我的经验是:ranges 本身不解决线程安全问题,但它改变了代码组织的方向,让“不可变、纯函数、按值传递”成为默认习惯。这个习惯一旦养成,后续做并行化时会省掉一大堆排查数据竞争的精力。
2. 先接受一个现实:ranges 算法并没有并行重载
2.1 我的编译报错又出现了
很多同学(包括我)第一次接触“并行 ranges”时,会理所当然地写出这种代码:
#include <algorithm> #include <execution> #include <ranges> #include <vector> int main() { std::vector<int> v(1000, 1); // 想象中应该存在的接口 std::ranges::sort(std::execution::par, v); }结果编译器直接甩一句no matching function for call to 'sort'。这跟你用的编译器新不新没关系,因为标准库从 C++20 到现在(C++23 已定稿,C++26 还在讨论)就没有提供“接受执行策略的 ranges 算法重载”。std::ranges::sort只有接受 range 或迭代器对的版本,没有接受std::execution::parallel_policy的那个重载。
很多教程和博客在这个问题上确实含糊不清。讲 ranges 的教程很少提并行策略,讲并行算法的教程又几乎只用迭代器对,结果就导致一个信息断层:读者自己拼。
2.2 别人口中的“并行 ranges”到底指什么
既然标准库没有这个接口,那社区里整天讨论的“ranges 并行化”又是什么?我观察下来,大家其实在说三件事中的一件:
第一种,用 ranges 的视图生成一个虚拟序列,然后喂给传统的并行算法:
auto indices = std::views::iota(0, n); std::for_each(std::execution::par, indices.begin(), indices.end(), [&](int i) { data[i] = process(data[i]); });第二种,用 ranges 做前处理或后处理,中间物化成容器之后,再用自带执行策略的传统算法并行:
auto filtered = v | rv::filter(pred) | std::ranges::to<std::vector>(); std::sort(std::execution::par, filtered.begin(), filtered.end());第三种,自己开线程,比如std::jthread或线程池,把 ranges 构造的管道分到不同线程里跑。
这些方法本质都是“ranges 负责表达,多线程负责执行”,而不是傻瓜式的ranges::sort(par, v)。把思路扭到这条轨道上,才有得聊。
2.3 与其等标准,不如把执行模型对齐
C++26 方向确实有给 ranges 算法加执行策略的讨论,比如某些并行 TS 的白皮书提案。但就算提案进了标准,编译器、标准库实现、链接器全面跟进还要好几年。项目的代码现在就要跑,等不起。
所以我的建议是:你现在就应该把问题重构成两个正交的部分——数据流的描述用 ranges,执行模型用传统并行算法或手写线程。这两者天然是可以对齐的,你只需要记得一个关键点:ranges 提供的是“范围”和“视图”,而传统并行算法要的是“迭代器对”。视图有begin()和end(),满足迭代器要求,那就可以作为并行算法的数据来源。
3. 多线程里最容易翻车的四个 ranges 细节
3.1 共享视图的 begin() 会写缓存
这是我最想强调的一个坑。以filter_view为例,它的begin()不是只读操作。标准实现里,filter_view会缓存第一次找到的合法迭代器的位置,避免每次调用begin()都从头扫描一遍。这个缓存写进mutable成员,所以begin()在语义上需要同步。
代码大概长这样:
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8}; auto evens = v | rv::filter([](int x) { return x % 2 == 0; }); // 线程 A 和线程 B 同时执行下面这行 auto it1 = evens.begin(); auto it2 = evens.begin();两个线程同时触发缓存写入,就是一个典型的数据竞争,行为未定义。你可能觉得“我只是读数据”,但在标准库实现层面,filter_view的begin()会修改内部缓存,读操作里藏着写操作,普通的并行调用就会踩雷。
这个问题的规避方式很朴素:要么每个线程独立拷贝一份视图再各自begin(),要么在进入多线程之前先把视图物化为一个容器,比如ranges::to<std::vector>()。我建议后一种,简洁又安全。
3.2 惰性管道在异步任务里的求值时机
惰性求值最迷惑人的地方在于——你打印日志时它还算完了,你看着代码以为它已经执行了,实际上求值压根没发生。异步任务把这种迷惑性放到了最大:
auto task = [&] { auto pipeline = data | rv::filter(pred) | rv::transform(f); for (auto x : pipeline) { results.push_back(x); } }; std::jthread worker(task);这段代码看似很普通,task捕获了data的引用。如果主线程在worker还没执行完管道之前就修改了data,甚至让data析构了,那异步线程里跑的管道就是在用它已经失效的数据。单线程顺序执行时永远不会出这种错,因为主线程会乖乖等循环结束;多线程一错开,问题就来了。
更隐蔽的是:pipeline 触发的求值可能发生在循环体的某个continue之后,也可能发生在循环结束后的某个迭代器析构时,反正不是你写代码那一刻。排查这类 bug 时,你会发现断点位置和崩溃位置经常对不上。
我给自己定了一条死规矩:任何跨线程传递的管道,先物化,再传递。
auto snapshot = (data | rv::filter(pred) | rv::transform(f)) | std::ranges::to<std::vector>(); std::jthread worker([snapshot = std::move(snapshot)]() mutable { for (auto& x : snapshot) { x = extra(x); } });数据拷一遍确实有点开销,但换来的是“异步线程只用自己手里的快照”,生命周期问题彻底消失。至于是不是每一条流水线都值得这么干,看场景取舍,后面再聊。
3.3 迭代器失效与错位写
多线程写同一个容器时,你要保证每个线程写的位置不重叠。这个要求听起来低级,但结合 ranges 的视图适配器后,很容易写错。
比如你想通过views::transform对数组下标的映射做并行处理:
auto doubled = rv::iota(0, n) | rv::transform([](int i) { return i * 2; }); std::for_each(std::execution::par, doubled.begin(), doubled.end(), [&](int index) { data[index] = compute(); });doubled生成的索引是 0, 2, 4, 6...,但如果你的data长度只有n,索引直接就越界了。这只是一个例子,更常见的错误是把两个不同视图对应到同一个底层容器,其中一个视图做了偏移,导致两个线程同时写同一块内存。
还有个容易被忽略的问题:filter_view不是 random_access_range,也不是 sized_range。你拿它去喂std::for_each(par, ...),虽然语法能通过,但并行算法内部根本没法高效切分任务——它不知道总共有多少元素,无法均衡负载,实际执行可能退化成低效串行。你以为自己在并行,其实底层在排队。
所以并行场景下,我几乎只在直接的连续数组或iota_view这类 random_access 范围上直接用迭代器,遇到 filter 就先物化。
3.4 并行算法里抛异常,默认就是 terminate
标准规定,如果执行策略下的算法抛出异常且异常没有被捕获,会调用std::terminate。换句话说,在std::execution::par的回调里抛异常,跟裸奔的throw一样,程序直接崩溃,不会有任何“优雅处理”的机会。
实际项目里,并行回调里能抛异常的场景挺多的:分配内存失败、某个库函数抛std::runtime_error、甚至一个简单的下标越界在特定实现都可能抛异常。我的做法是在回调里全部接住:
std::atomic<int> error_count{0}; std::for_each(std::execution::par, data.begin(), data.end(), [&](auto& value) { try { value = complicated_work(value); } catch (...) { ++error_count; } }); if (error_count.load() != 0) { // 统一处理错误,而不是让程序 terminate }这种处理方式有点“吞异常”的嫌疑,但在并行场景里是务实的:你先保命,把错误记录下来,等主线程统一处理。比直接terminate强了不知道多少倍。
4. 三种能落地的“ranges + 多线程”组合方案
4.1 iota_view 分块 + 线程池
第一种方案适合:处理连续数组、任务计算密集、不担心索引错位。核心思路是用std::views::iota生成一个虚拟的索引序列,然后手动切成几块,每块扔给一个线程。
constexpr size_t n = 1'000'000; std::vector<double> data(n); std::iota(data.begin(), data.end(), 1.0); size_t num_threads = std::thread::hardware_concurrency(); size_t block = n / num_threads; std::vector<std::jthread> workers; workers.reserve(num_threads); for (size_t t = 0; t < num_threads; ++t) { size_t begin = t * block; size_t end = (t == num_threads - 1) ? n : (t + 1) * block; workers.emplace_back([&data, begin, end]() { for (size_t i : std::views::iota(begin, end)) { data[i] = std::sqrt(data[i]) + 1.0; } }); } for (auto& w : workers) { w.join(); }这里iota(begin, end)生成了一个轻量级整数序列,线程内部用 range-for 消费它。注意这个例子里每个线程只读自己那一段索引,写data[i]也只会碰自己负责的区间,数据竞争被结构性地排除掉了。
要注意的问题是:如果n不能整除num_threads,最后一个块要包含余数,避免漏处理数据。上面代码里用end = (t == num_threads - 1) ? n : (t + 1) * block处理了这个尾巴。如果任务本身开销不均匀,比如每个元素处理时间方差很大,这种静态分块的负载均衡很差,可以考虑用原子计数器动态取任务块。
4.2 先把 filter 物化,再用并行算法
这是我最常用的组合。核心思路是:用 ranges 做过滤和变换,物化成中间容器,容器到手后直接用标准并行算法。
std::vector<float> raw = { /* 大量数据 */ }; auto filtered = raw | rv::filter([](float x) { return x > 1.0f; }) | rv::transform([](float x) { return x * 2.0f; }) | std::ranges::to<std::vector>(); std::transform(std::execution::par_unseq, filtered.begin(), filtered.end(), filtered.begin(), [](float x) { return std::sqrt(x); });为什么先做 filter 再并行?因为 filter 的输出长度不确定,并行算法拿到一个变长的输出范围时,任务切分会变得很不均匀,线程之间可能一个忙死、一个闲死。先物化成固定长度的向量,并行算法就能均匀地切分数据。这一步的临时拷贝开销通常不大——你过滤之后的数据量往往比源头小一个数量级,把这个代价提前付掉,换后续稳定的并行性能,非常划算。
ranges::to是 C++23 加入的,老项目如果编译器不支持,可以用手动 push_back 或std::vector<T>(range.begin(), range.end())替代,思路一样。
4.3 虚拟 range 喂给 for_each(par)
第三种方案的精髓是:不要纠结于“ranges 算法没有并行重载”,你完全可以拿 ranges 的视图当数据源,喂给传统并行迭代器算法。特别是iota_view,它是 random_access_range,又有 size,并行算法拿到它能高效切分。
std::vector<std::string> words(n); auto indices = std::views::iota(0, n); std::for_each(std::execution::par, indices.begin(), indices.end(), [&](int i) { words[i] = process(words[i]); });这段代码的效果和 4.1 的静态分块差不多,但不需要你手动写thread管理、block计算、join。标准库的并行算法内部按可用的硬件线程数自动分块。少了十几行代码,少了很多自己造轮子可能引入的 off-by-one bug。
还有个小技巧:并行算法接受迭代器范围,所以你也可以拿到一个transform_view的迭代器对去并行消费,只要这个视图保持 random_access:
auto seq = std::views::iota(0, n) | std::views::transform([](int i) { return i * 3; }); std::for_each(std::execution::par, seq.begin(), seq.end(), [](int i) { use(i); });不过要小心,这种写法只适用于随机访问迭代器,而且不要处理filter_view,原因前面说过,它没法高效并行切分。
4.4 方案选型对照
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 连续数组按索引处理 | 方案 4.3,iota_view+for_each(par) | 代码最少,切分高效,不用管线程生命周期 |
| 有 filter 步骤,后续计算密集 | 方案 4.2,物化后再并行 | 规避变长输出的负载不均衡问题 |
| 数据量小,并行收益不明显 | 手动分块或直接串行 | 避免并行调度开销倒挂 |
| 任务本身会分配内存 / 锁竞争 | 减少并行粒度,或改用串行 | 并行回调里的堆分配和锁竞争会抵消收益 |
| 要对管道内容做异步传递 | 先to<vector>快照 | 解决生命周期和数据竞争隐患 |
5. 性能实测与选型建议:什么时候并行真正有意义
5.1 实测数据量区间
我在这台机器上做了几轮 benchmark,数据是 50 万到 5000 万个double,逐个做sqrt再加一个常量。结论非常稳定:
- 数据量 100 万以下,并行收益趋近于零。线程创建、任务切分、内存争抢的调度开销基本抵消了并行计算带来的提升,某些场景甚至比串行慢一点。
- 500 万到 2000 万这个区间,并行收益最明显,跑得好的场景能有 3 到 6 倍加速。这个区间内存带宽还没成瓶颈,计算密度又够高。
- 5000 万以上,并行提升开始放缓。瓶颈转移到了内存带宽,尤其是
double数组这种连续但靠带宽拉满的场景。数据量继续增加,再开多少线程也没用,因为 CPU 早就喂不饱内存了。
所以选型的核心不是“开几个线程”,而是先掂量一下你的任务落在哪个区间。
5.2 filter 并行差的原因
filter 的并行性能差,很多人都碰到过。原因不复杂:filter 不知道输出有多少个元素,也没法保证输出顺序,并行算法在切分任务时只能把源范围切成几段,各自跑各自的。但每个段最后能产出多少个元素并不确定,这就导致最终合并结果时要动态分配,线程间还要同步。开销一大,加速比就很难看。
我做过的实验里,500 万元素的容器,filter + transform 的纯串行版本也许跑 40 毫秒;直接套ranges::for_each并行的版本反而能跑 80 毫秒;而先物化再并行,整个流程加起来 60 毫秒。看起来莫名其妙,但你理解了原因就一点不意外:物化阶段多了一次成块拷贝,但并行阶段又匀称又稳,整体反而更快。所以我的经验是:一条流水线里如果出现 filter,就别费劲搞并行视图了,物理化物化再并行。
5.3 我的选型公式
整理一下我自己的判断逻辑,写成一个非严格的公式:
- 计算本身是纯算术、无锁、无堆分配,数据量又上了千万级,才值得开并行。
- 数据量一百万以内,一律串行。
- 数据量中等、计算密集,用方案 4.3(
iota_view+for_each(par))。 - 有 filter 或合并逻辑,用方案 4.2(先物化,再并行)。
- 回调里有锁、有
unordered_map查找、有new,并行收益大概率被这些副作用吃掉。这种情况下我宁可按 4.1 少开几个线程,或者干脆串行,至少行为可预测。 - 如果只是想让代码可读性更好,那并行不是刚需,直接用
ranges串行管道。
还有一点容易被忽略:par_unseq允许实现做向量化,但它对回调有隐藏的约束更多——回调里不能有未定义行为(访存越界、依赖求值顺序等),实际跑起来更容易爆奇怪的问题。我的默认选择是par,par_unseq只有在确认回调绝对简单、无副作用时才用。这也是一个不值得去赌的坑。
最后再多说一句我的体会:std::ranges 给我最大的帮助不是性能,而是它逼着我把数据处理流程写成纯函数的管道——无捕获、无状态、按值传递,这些习惯本身就规避了一大批线程安全问题。真正跑并行的活,我反而很少用 ranges 的算法接口,因为标准库还没把执行策略接到那上面。我给项目定的规矩很简单:共享视图必须物化、并行回调必须接住所有异常、任何访问共享可变状态的 visitor 直接不允许合入。等哪天真有带 execution 重载的 ranges 算法落地,再把今天这些经验平移过去,照样管用。