C++20的std::ranges出来之后,我被人问得最多的一个问题是:这套东西到底能不能配合多线程用?标题里的“算法线程”四个字,听着像某个新调度库,但拆开看其实就是三件事——ranges算法能不能直接并行跑、ranges管道放进线程里安不安全、多线程环境里应该怎么组织数据。这篇文章我就把这三件事一次讲透,结合我实际踩过的坑,给出能直接抄的代码和思路。
先说结论:std::ranges 本身并没有提供 execution policy 参数,也就是说你没法写std::ranges::transform(std::execution::par, ...)。但你完全可以用并行标准算法当执行骨架,用 ranges 视图当数据描述,两者配合起来反而更好用。只是在动手之前,必须先搞清楚视图的生命周期和线程安全边界,否则一看就懂、一跑就崩。下面我把“为什么”和“怎么做”都拆给你看。
1. 先把“算法线程”这个说法拆清楚
1.1 std::ranges 到底比传统算法多给了什么
std::ranges 最大的变化不是多了几十个算法,而是把“数据”和“操作”的表述方式统一了。以前你写排序、查找、变换,脑子里想的是迭代器区间:begin 到 end,一堆 iterator 来回倒腾。ranges 让你直接操作“一个范围”本身,配合视图管道可以写出接近业务语言的数据处理链。
举一个最简单的例子,传统写法:
std::vector<double> v = load_data(); std::vector<double> filtered; for (const auto& x : v) { if (x > 0.0) { filtered.push_back(std::sqrt(x)); } }ranges 写法可以是这样:
auto view = v | std::views::filter([](double x) { return x > 0.0; }) | std::views::transform([](double x) { return std::sqrt(x); });这两段代码表达的结果是一样的,但第二段是惰性求值:管道构建的时候什么都没算,只有当你去遍历 view 的时候,filter 和 transform 才一个元素一个元素地执行。这个“惰性”是整个问题的核心,也是很多人把 ranges 和多线程结合时翻车的根源。
视图不拥有数据,它只是对原始数据的描述。你拷贝一个 view 是廉价的,因为它内部通常只存一个底层迭代器加上一些函数对象。但这也就意味着,你必须保证 view 背后引用的容器在遍历时仍然活着。这一点放进多线程环境后会被放大成很隐蔽的 bug。
1.2 所谓“线程”,有三种完全不同的含义
标题里的“算法线程”不是一个标准术语,我见过的三种主流理解是:
第一,并行执行策略。也就是 C++17 引入的 std::execution::par、par_unseq,配合 std::for_each、std::transform 这类算法使用。它们负责把区间切块塞给线程池。问题是 std::ranges 的算法都没接执行策略,所以你需要的是一座桥。
第二,手动多线程切块。就是自己把数据范围切成 N 段,用 std::thread、std::jthread 或 std::async 分别处理。这种方式最灵活,但切块别切到视图的迭代器上,而是切底层容器的索引范围,再各自构建管道,这样你才能保证每个线程看到的是互不重叠的数据。
第三,线程安全边界。如果你让多个线程只读地遍历同一个底层容器,那么每个线程各自创建自己的视图管道是安全的;但如果大家一起操作同一个视图对象、一起移动同一个迭代器,那就是数据竞争,纯未定义行为。
这三种理解都会在后文出现,但你要先记住一个关键事实:视图能否用于并行场景,很大程度上取决于底层容器和管道组合后产生的迭代器类别。filter 出来的迭代器是 forward 级别,而大多数并行策略要求 random access,所以 filter 不能直接扔给 par 算法用。transform 通常能保持底层迭代器的随机访问能力,因此它反而是最适合做并行化的视图。
2. 进入多线程前必须守住的底线:视图生命周期
2.1 视图不拥有数据,只拥有“对数据的描述”
很多人第一次写 ranges 管道时,都会下意识觉得 view 是一份数据快照。实际上它连指针都不是,它是“如何从底层数据里产出元素”的规则。你去拷贝一个 filter_view,拷贝的是内部那个迭代器和谓词,数据本体没有复制。
这带来的直接后果是:如果底层容器在这个视图被遍历之前销毁,那么遍历就是悬垂访问。尤其是惰性求值这种特性,让问题变得特别迷惑——你在函数里构建管道、返回管道,函数退出后容器释放了,但管道还在,等你在另一个线程里真正 for 循环遍历它,crash 已经写好了。
一个非常典型的错误代码:
auto bad_view() { std::vector<int> local{1, 2, 3}; return local | std::views::transform([](int x) { return x * 2; }); } int main() { auto v = bad_view(); for (int x : v) {} // undefined behavior,local 已销毁 }编译是能通过的,因为类型的生命周期和所有权语义没有体现在接口里。你要么按值返回 owning_view,要么把容器的生命周期延长到视图使用期。std::ranges::owning_view 就是干这个的,它把容器“抱住”,跟着 view 一起移动。但 owning_view 不是万能钥匙,它的语义是转移所有权,如果你同时还有很多线程引用原容器,那就等于制造了两个拥有者,更容易乱。我自己的经验是:能用引用区间共享的地方就别 owning_view,真正需要返回临时容器构造的管道时才用它。
2.2 线程安全的分层边界:什么能共享,什么不能碰
问“视图是线程安全的吗”其实是个伪问题,线程安全永远取决于对底层数据的并发访问模式。我把常见场景分成三类,便于你对照自己代码属于哪一档:
第一档:只读共享。多个线程同时只读同一个容器,各自独立构建自己的视图管道,只负责遍历和计算结果。这是最安全的应用方式,基本可以放心用,前提是全程没有线程修改容器元素。读并发不需要加锁,也不会有数据竞争。
第二档:可写但分片隔离。每个线程只写自己负责区间的元素,例如线程 i 只写 output[i]。只要区间不重叠,这也是安全的,但要注意伪共享问题,后面专门讲。
第三档:真正共享可变状态。比如多个线程都往同一个锁容器插入,或者递增同一个共享计数器。这时必须用互斥锁或原子变量,并且任何让容器 reallocate 的操作都不能在遍历视图的同时发生,否则迭代器立刻失效,所有正在遍历的线程直接踏入未定义行为。
我在项目里最常遇到的问题是容器动态增长。某个线程在处理数据时往底层 vector 里 push_back,另一个线程的 view 正遍历这个 vector。表面上看只是多了一个元素,实际上 push_back 可能触发整体搬迁,原来的迭代器全部失效,正在遍历的线程读到的是野指针。解决方式很简单:要么预先 reserve 足够容量,要么把增长操作放在所有遍历线程结束之后,要么换用带并发安全的数据结构。
3. 三种可行的并行化实操方案
3.1 用并行标准算法做执行骨架,视图做数据描述
这是最推荐的第一种姿势。思路是:用 std::ranges 视图表达“要做什么”,用带 execution policy 的 std 算法去执行。因为 ranges 算法不带执行策略,而传统算法又可以接受任意迭代器,所以直接拿 view 的 begin、end 去喂给 std::transform、std::for_each 即可。
代码如下:
#include <algorithm> #include <execution> #include <ranges> #include <vector> #include <cmath> std::vector<double> input = load_large_data(); auto pipeline = std::views::transform(input, [](double v) { // 模拟一个耗时计算 double acc = 0.0; for (int k = 0; k < 1000; ++k) { acc += std::sin(v + k); } return acc / 1000.0; }); std::vector<double> output(input.size()); std::transform(std::execution::par_unseq, pipeline.begin(), pipeline.end(), output.begin(), [](double x) { return x; });这里 transform 视图会对输入进行惰性映射,pipeline.begin() 和 end() 暴露给并行算法之后,底层执行框架会把它切块交给工作线程。transform 视图的迭代器在底层是随机访问迭代器时,仍保留随机访问属性,所以它满足 par_unseq 的要求。如果你换成 filter 视图,这里就会出问题——filter 的迭代器基本上做不到随机访问,并行算法没法高效切块,甚至编译期就失败。
这个方案适合“每个输入元素对应一个输出元素”的 map 型任务,比如图像处理、向量归一化、数值积分里的并行采样。它对代码的侵入最小,性能也很稳。
3.2 手动切块:drop/take或chunk视图分发给线程
如果你的任务不是简单的 map,而是需要每个线程处理一段数据并做局部归约,那就得走手动切块的路线。C++20 没有标准的 chunk 视图,C++23 提供了 views::chunk,但在 C++20 代码里我都是用 drop 和 take 组合来切。
#include <ranges> #include <vector> #include <thread> #include <future> #include <numeric> std::vector<double> data = load_large_data(); constexpr size_t kThreads = 4; size_t block = (data.size() + kThreads - 1) / kThreads; std::vector<std::future<double>> futures; for (size_t t = 0; t < kThreads; ++t) { size_t begin = t * block; size_t end = std::min(data.size(), begin + block); futures.emplace_back(std::async(std::launch::async, [&data, begin, end, t] { auto sliced = data | std::views::drop(begin) | std::views::take(end - begin) | std::views::transform([](double v) { return std::exp(v) * std::log(1.0 + std::abs(v)); }); double local_sum = 0.0; for (double x : sliced) { local_sum += x; } return local_sum; })); } double total = 0.0; for (auto& f : futures) { total += f.get(); }注意几个细节。第一,切块用的是索引区间 begin/end,而不是在视图上再切,这保证了每个线程处理的底层数据互不重叠。第二,每个线程内部自己构造管道,没有共享同一个视图对象,这样就不会有迭代器竞争。第三,归约结果用局部变量累积,最后在主线程合并,避免多线程同时写一个 double 造成数据竞争。
有人可能会问:为什么不用 views::chunk 直接生成子区间?C++23 的 chunk_view 确实更简洁,但它的迭代器类型往往不支持直接拷贝传给 std::thread,而且 chunk 内部的子范围生命周期比普通容器范围更难把握。我试过之后还是觉得 drop/take 组合更方便控制边界,工程上更稳。
在这个模式里,如果你把 filter 放在 drop/take 之后,要特别小心随机访问能力的丢失。drop 和 take 在底层容器是随机访问时通常还能保留较好的迭代器类别,但一旦中间插了 filter,整个链就退化成单向遍历,你按索引切好的块并不能保证每块数量一致,反而会出现某些线程干得飞快、某些线程几乎空转的情况。
3.3 无随机访问时:物化后再并行,或每个线程独立建管道
filter、take_while 这类惰性过滤视图有一个共同特点:你无法通过迭代器加减进行随机跳跃。也就是说,你不能指望把这类视图直接交给 par 算法,也不能用索引切块后直接定位。这时最常见的手段是把过滤结果“物化”成一个普通容器,然后再并行处理。
物化听起来浪费内存,但在 filter 后并行确实是性价比最高的方案。因为 filter 本身就是一次性扫描,你把它物化进一个新 vector 后,这个 vector 是连续内存、随机访问,后续的所有并行处理都顺畅了。内存开销是一次性的,换来的是并行度和代码可维护性。
std::vector<int> source = load_data(); // 先把符合条件的数据物化出来 std::vector<int> candidates; auto filtered = source | std::views::filter([](int x) { return x >= 0; }); std::ranges::copy(filtered, std::back_inserter(candidates)); // 物化后就是普通 vector,随机访问,随便并行 std::vector<long long> transformed(candidates.size()); std::transform(std::execution::par, candidates.begin(), candidates.end(), transformed.begin(), heavy_compute_fn);如果不想引入物化容器的额外开销,也还有一条路:让每个线程独立遍历同一容器,但用不同策略分散计算。比如数据带有自然键或分片字段,每个线程只负责处理自己分片中元素,其他元素跳过。这样虽然没有内存复制,但每个线程都要完整扫描整个容器,CPU 缓存命中率会差一些。我一般只在内存极其敏感、且分片字段本身能做索引分区时采用,比如按哈希桶拆分。
在我实际做过的数值计算任务里,绝大多数场景都是“先过滤、再并行映射”这种结构。用物化方案之后,代码量其实比裸手写同步逻辑少很多,而且后期调整分块数量特别方便。它是三种姿势里最不容易出错的一种。
4. 常见问题与排查技巧实录
4.1 悬垂视图:惰性求值在多线程下的定时炸弹
悬垂视图是最难排查的一类问题,因为它在单线程简单场景下可能不会崩,放在多线程反而随机崩。典型症状是:程序有时正常,有时在一个 for 循环里对某个不可描述地址执行谓词,然后直接 segfault。
我印象很深的一次是这个形状的代码:
auto build_pipeline(const std::vector<double>& data) { return data | std::views::filter([](double v) { return v > 1.0; }) | std::views::transform([](double v) { return v * v; }); } void thread_func() { std::vector<double> local = get_data(); auto p = build_pipeline(local); std::async([p]() mutable { // 这里 local 已经销毁,p 内部引用的是悬垂迭代器 long long sum = 0; for (double x : p) sum += (long long)x; return sum; }).get(); }关键是 lambda 捕获了 p,而 p 捕获的是 local 的迭代器。std::async 真正开始执行时,local 早就在函数结束点析构了。单线程同步执行时,一切都在作用域内,不容易暴露;一有线程调度延迟,就立刻翻车。
排查思路也简单,不要把视图的生命周期和容器分离开。如果必须从函数返回带临时数据源的计算链,就把数据一并放进 owning_view。如果数据是全局或外部传入的,就把容器的生命周期用注释明确写出来,甚至可以在调试代码里给容器加一个 alive 标志,在 view 析构时检查引用计数。工程上,我最推荐的稳妥做法是:让所有视图都在底层容器所在作用域内构造和使用,不让它跨越线程边界传递。
4.2 迭代器类型不满足并行算法要求
我见过好多次这种编译错误:把 filter_view 的 begin/end 传给 std::transform 加 par 策略时,编译输出一长串模板错误,核心信息是迭代器不满足 random access。很多人这时候会自然想到用 views::common 转一下,但这是一个非常大的误区。
views::common 解决的是“迭代器类型和哨兵类型不统一”的问题,它统一类型,但不提升能力。filter_view 的迭代器再怎么包装,也不会变成随机访问。要让并行算法真正跑起来,要么换成 transform、iota 这类能保持随机访问的视图,要么把 filter 结果物化。
我自己的做法是事先列了一张表,写代码前先判断当前管道组合到底属于哪一类:
| 视图组合 | 迭代器能力 | 能否用于par/unseq |
|---|---|---|
| transform(底层随机访问) | 随机访问 | 可以 |
| filter(任意底层) | 单向/前向 | 不可以 |
| iota_view | 随机访问 | 可以 |
| drop/take(底层随机访问) | 通常随机访问 | 可以 |
| chunk(C++23) | 前进但子区间迭代器复杂 | 建议物化/独立处理 |
| common视图 | 取决于底层 | 只是类型统一,不提升能力 |
我建议你在每个并行函数前,先想一下“迭代器能不能用下标跳到任意位置”。能,就可以交给线程池;不能,就物化或换表达方式。这个判断比记住任何细节都重要。
4.3 数据竞争与锁的使用经验
多线程里最常见的 bug 不是算法不对,而是多个线程通过 lambda 捕获了同一个可变对象。ranges 管道的 lambda 往往写在视图里,更容易让人忽略并发风险。比如你想统计所有大于阈值的元素个数,直接在 transform 的 lambda 里让一个 shared counter 自增,这一定是错的。
// 错误示范:多个线程同时执行 lambda,counter 是数据竞争 int counter = 0; std::for_each(std::execution::par, data.begin(), data.end(), [&](double x) { if (x > 0.0) ++counter; });就算你把这个 lambda 放进 ranges 管道里,用 std::ranges::for_each 单线程跑没问题,一旦换成并行执行,counter 就成了竞争点。正确做法是用 std::atomic 或局部归约。能用局部归约尽量用局部归约,因为原子操作在高并发下有缓存一致性瓶颈,归约版本只需要在最后合并 N 个局部值。
另一个容易踩的坑是输出流的锁。线程里打印调试信息,cout 和 cerr 你如果直接输出,即使加了锁,也会因为频繁同步把并行性能拖垮。经验做法是在每个线程内把日志拼成字符串,最后统一打印,或者直接关掉调试输出,只保留局部聚合汇总。这听起来像常识,但我在性能调优时真的见过一加日志性能掉 80% 的情况。
4.4 块大小与负载均衡的工程经验
最后一个坑不是逻辑错误,而是性能陷阱。很多初学者把数据和线程数一比一切块,每线程一块,然后就不管了。碰到 transform 这种固定耗时任务可能还行,但碰到 filter 这类任务,块大小不均匀会直接导致某个线程忙碌、其他线程闲置。
我给一个工作经验:块大小不要按元素个数来算,要按内存和任务粒度来算。元素很大,比如一个结构体几百字节,块就要小一些,避免线程每次只处理几个元素;元素很小,比如 int,块就可以大一些,让每个线程处理几百数千个元素,以摊薄线程调度的开销。负载不均衡时,可以把数据切成比线程数多 2 到 4 倍的块,然后用一个原子计数器让线程动态领取块。这样做出来的负载均衡远比固定块好。
伪共享是另一个隐蔽的性能杀手。如果你让两个线程往同一个 vector 的相邻位置写结果,它们实际会竞争同一条 cache line。线程 A 改 output[i],线程 B 改 output[i+1],物理上两个变量在同一个 64 字节缓存行里,互相导致缓存失效,性能能掉 5-10 倍。解决方式是给每个线程一段连续输出区,或者用局部 buffer 最后拷贝。这个坑在 ranges 管道中尤其容易踩,因为 view 表达式让“数据布局”藏起来了,你很难一眼看出两个线程是不是在写相邻内存。我在写并行结果收集代码时,会刻意让每个线程维护独立局部容器,最后再 merge,而不是直接用索引往共享 vector 里写。
我最后分享一个自己的习惯。写这类 ranges 加多线程代码之前,我先在注释里写清楚三句话:数据归谁所有,谁负责写入,视图在哪个线程里触发求值。大部分 crash 和诡异死锁,都是这三句话没理清导致的。std::ranges 让数据处理表达变得很漂亮,但它的漂亮是建立在“你心里还绷着一根生命周期和线程安全的弦”之上。把这根弦绷住了,ranges 管道在多线程里用起来反而比传统手写循环更从容。