☰
C++ ranges 与多线程:组合式视图并行化的三种实践与踩坑
2026/10/21 6:42:24 网站建设 项目流程

C++20 把std::ranges端上桌的时候,不少人第一反应是:以后算法再也不用跟begin/end那套迭代器死磕了。可真把std::ranges算法和“多线程”放到一个句子里,事情就没那么美了。很多读者会想当然地认为 ranges 算法能像某些语言里的并行迭代器一样,直接扔给线程池去跑,结果一编译才发现std::for_each(std::execution::par, ...)和std::ranges::for_each是两套完全不同的接口,视图还是惰性求值的,拉进线程里稍不注意就踩出几个隐蔽的坑。这篇文章就围绕“std::ranges 算法 + 线程”这个组合深入讲清楚:哪些限制是设计上就注定的,哪些规则是实际工程里绕不开的,以及怎么在现有标准下把 ranges 的组合式风格和真正的多线程并行结合到一块。适合正在做 C++20/23 重构、或者想在老代码里引入现代 C++ 风格的开发同学参考。

1. 先掰清楚:ranges 到底给算法带来了什么不同

1.1 组合式算法读起来是什么样的

老派写法里,你想对一组数据先做变换、再过滤、最后求和,最常见的姿势是写一长串循环:

std::vector<int> v{...}; long long sum = 0; for (int x : v) { int s = x * x; if (s > 0) { sum += s; } }

这套代码本身没毛病,但逻辑一旦复杂,循环体就堆成一大坨,而且很难复用。std::ranges的思路是换个角度:把数据管道化,让每一步变换都变成视图上的一个“适配器”,然后直接像流水线一样连起来:

auto result = v | std::views::transform([](int x) { return x * x; }) | std::views::filter([](int s) { return s > 0; }); long long sum = 0; for (int x : result) sum += x;

transform负责映射,filter负责筛选,整个过程没有产生临时 vector,也没有手工管理中间状态。这就是 ranges 的核心价值:描述数据流,而不是手写每一步搬运。

1.2 惰性求值是一把双刃剑

上面那个result视图,看起来像是在“那一刻”算好了结果,实际上它什么都没算。每个元素只在真正被遍历到的时候,才流经各个适配器。这个性质叫惰性求值。

惰性求值带来两个直接后果。第一个是高效的组合能力:一百万条数据在视图管道里流转,不会因为中间多套了一层 filter 就多分配一百万次内存。第二个后果就和多线程有关了——回调函数被调用的时机不再是“同步、单点”的,而是完全由遍历方决定的。当你把这个视图交给一个并行执行环境时,transform里的 lambda 就可能出现在任意工作线程里、以任意顺序被调用。这就意味着写 ranges 管道的习惯必须随之改变:不能在 lambda 里随意捕获一个共享计数器,然后天真地认为它只会被串行访问。

2. ranges 算法天生不带“并行”,这是设计取舍

2.1 为什么没有std::ranges::for_each(execution::par, ...)

很多刚从 C++17 转过来的人会到处找std::ranges::for_each(std::execution::par, ...)这种重载,然后发现标准库里根本没有。这不是委员会的疏忽,而是设计路径上的分岔。

C++17 引入的并行算法(带execution::par的那套)是在经典算法基础上加执行策略参数,走的是“老算法打补丁”的路线。而 C++20 的 ranges 是另一套体系,它自带组合用法、哨兵类型、视图概念,设计目标是替代传统迭代器对。如果把执行策略直接塞进 ranges 算法里,会遇到一个很拧巴的问题:视图是惰性的,执行策略又是关于“何时、何地执行”的,这两者语义上天然冲突。一个filter_view到底是在过滤的那一刻并行,还是在最终消费的那一刻并行?边界非常难定义。所以标准委员会干脆选择先不碰这个交叉点,让 ranges 算法保持纯串行、确定性、可组合的特征,并行交给执行策略那套老接口去承担。

这个选择给实际开发带来的影响是:你想并行就老老实实走std::execution,或者自己分块投递给线程池;不要指望std::ranges::sort一个函数之间变成并行版本。

2.2 ranges 算法的线程安全底线

std::ranges算法虽然没有执行策略参数,但它的线程安全约束其实是清晰的。标准里要求,传给 ranges 算法的函数对象(谓词、变换函数、比较器)在执行期间不得修改正在被遍历的元素,不得使迭代器失效,也不得引入未定义行为。这句话放到多线程背景下,就等于说:标准库不保证你的回调被并发调用时安全,它把同步责任完全交回给你。

我见过一个很典型的翻车代码:有人用std::views::transform给一批对象加序号,在 lambda 里捕获了一个共享的int counter,串行跑没问题,后来为了提升性能改成用std::async拉多个线程,每个线程遍历同一份视图的不同分块,一瞬间就出现数据竞争。cout 一打印,序号乱成一片。问题根源不是 ranges 错了,而是写的人没有意识到:视图是可以被拷贝复制到多个线程的,lambda 捕获的共享状态却不会因此自动加锁。理解这个底线,后面所有的并行技巧才有讨论的意义。

3. 把 ranges 真正放到多线程里跑的三种姿势

既然 ranges 算法本身不负责并行,那怎么把“组合式视图”和“多线程”揉在一起,就成了纯工程问题。我实际项目里用得比较多的就三种。

3.1 姿势一:views::chunk 分块 + std::async(重点推荐)

C++23 给 ranges 增加了一个特别实用的适配器:std::views::chunk。它不是算法,只是一个视图,作用是把源 range 切成若干个固定大小的块。这个“切块”动作正好是多线程任务的天然边界:每个线程拿一块,互不干扰。

#include <ranges> #include <vector> #include <future> #include <thread> double parallel_filter_square_sum(const std::vector<double>& v) { size_t n = std::thread::hardware_concurrency(); if (n == 0) n = 2; size_t block = (v.size() + n - 1) / n; // 向上取整 std::vector<std::future<double>> futures; auto blocks = v | std::views::chunk(block); for (auto&& chunk : blocks) { futures.push_back(std::async(std::launch::async, [chunk]() mutable { double local = 0.0; for (double x : chunk) { if (x > 0.0) local += x * x; } return local; })); } double total = 0.0; for (auto& f : futures) total += f.get(); return total; }

这里有两个特别容易出错的细节值得单独说。

第一,chunk在范围 for 里拿到的每个对象是从blocks这个视图解引用出来的子视图。lambda 里必须按值捕获这个 chunk,让每个线程持有自己独立的视图副本。lambda 函数体里会对 chunk 做遍历,迭代器状态会变化,所以需要加mutable。如果你图省事写[&chunk],把引用捕获进去,那所有线程共享同一个视图对象,函数对象内部的自增、解引用操作就直接构成数据竞争。

第二,整个v的生命周期必须覆盖所有异步任务。尽管 lambda 按值捕获了 chunk,但 chunk 内部保存的迭代器本质上是 vector 的迭代器,它指向v的数据。如果调用方在std::future::get()之前就把v析构了,迭代器就悬垂了。所以这个函数放在实际工程里,建议v用std::shared_ptr<std::vector<double>>持有,或者保证所有线程 join/future 都 wait 完毕再释放数据。

views::chunk是 C++23 才有的东西,不是所有编译器默认都支持。项目还锁在 C++20 的话,用 5.4 节的手工drop + take方案替代即可。

3.2 姿势二:views::common 桥接传统并行算法

如果你不想自己起线程、不想管分块,依然有办法把 ranges 组合出来的视图喂给标准并行算法。关键角色是std::views::common。

传统并行算法长这样:std::for_each(std::execution::par, first, last, func),它要的是两个同类型的迭代器。但 ranges 里很多视图的“迭代器”和“哨兵”不是同一个类型,比如filter_view的迭代器类型和哨兵类型就不一样。直接扔给传统算法会编译失败。views::common的作用很简单:把一个 range 统一成“迭代器类型和哨兵类型相同”的 common range。

auto view = v | std::views::transform([](double x) { return x * x; }) | std::views::filter([](double x) { return x > 0.0; }) | std::views::common; std::atomic<size_t> count{0}; std::for_each(std::execution::par, view.begin(), view.end(), [&count](double x) { count.fetch_add(1, std::memory_order_relaxed); });

这个写法能跑通的前提是:view至少得是forward_range。因为std::execution::par要求迭代器满足前向迭代器要求,vector源上的 transform/filter 组合是满足的。如果源是一个单遍输入流(比如istream_view),并行策略就不适用了。

不过要留意一个工程上很常见的坑:并行回调里别随便往std::vector里push_back。push_back本身不是线程安全的,即使你在回调里只推入自己算出的一个数,多线程同时执行也会崩。我在同事的代码里见过在 transform 回调里直接results.push_back(x)的写法,跑起来偶发崩溃,排查半天。正确做法是预先分配好输出数组,靠索引并行写入,或者在最外层加锁后再收集结果。ranges 的common只是解决了“类型能不能传进去”的问题,解决不了“回调有没有数据竞争”的问题。这里common真正的价值是让‘评估每一条数据’这个动作并行化,副作用得自己管好。

3.3 姿势三:线程内部使用 ranges 算法,外部做任务切分

第三种姿势其实最粗暴:多线程的任务调度不靠 ranges,但每个线程内部的业务逻辑大胆用 ranges。比如外部有一个大数组,按索引切成 N 段,每个 worker 线程处理自己那段时,内部的过滤、排序、归并逻辑都用std::ranges来写。

void worker(const std::vector<double>& data, size_t lo, size_t hi) { auto part = std::span(data).subspan(lo, hi - lo); auto evens = part | std::views::filter([](double x) { return std::fmod(x, 2.0) == 0.0; }); double local = 0.0; for (double x : evens) local += x; // 结果再通过别的方式收集 }

这种写法胜在“分工清晰”:线程池或 async 负责切分和调度,ranges 负责每个任务内部的表达。对团队协作来说,并行框架和业务逻辑解耦开了。性能上可能不如手写最优调度,但代码可维护性高得多,项目里如果有一堆处理逻辑类似的 worker,用这种方式能明显减少重复循环。

4. 实战:一个求和任务的四种写法对比

为了直观对比,我拿一个非常典型的场景来测:一个 1000 万个 double 元素的 vector,求“大于 0 的元素的平方和”。在模拟项目 X 里,我用四种方式实现,结论基本能代表大多数类似场景。

4.1 四种实现怎么选

实现方式关键代码可读性是否真并行主要限制
传统串行循环手写 for + if低否无
ranges 串行管道transform/filter 组合高否无
chunk + async分块后线程各自算中高是需要 C++23,注意生命周期和拷贝捕获
common + parcommon 桥接 execution中是必须 forward_range,回调内不能有额外副作用

在我的测试机器上(不贴具体硬件型号,普通八核台式机),1000 万数据规模的串行版本大约在 35 毫秒上下,chunk + async 和 common + par 两个并行版本能压到 10 到 15 毫秒,大概有 2 到 3 倍提升。并没有出现大家幻想的“八线程就是八倍加速”,原因是这个计算太简单,瓶颈很快转移到内存带宽而不是 CPU 计算。所以如果遇到类似场景,别一上来就堆线程,先确定热点到底在数据搬运还是计算上。

4.2 直接能抄的完成版实现

基于 chunk + async 的完整版本,我在 3.1 已经给出了核心代码。实际要用时,把它调整成更通用的模板函数也不难:

template <std::ranges::range R, typename Func> auto parallel_map_sum(const R& data, Func&& func) { using T = std::invoke_result_t<Func&, std::ranges::range_reference_t<R>>; size_t n = std::thread::hardware_concurrency(); if (n == 0) n = 2; size_t block = (data.size() + n - 1) / n; std::vector<std::future<T>> futures; auto blocks = data | std::views::chunk(block); for (auto&& chunk : blocks) { futures.push_back(std::async(std::launch::async, [chunk, &func = std::as_const(func)]() mutable { T local{}; for (auto&& x : chunk) local += func(x); return local; })); } T total{}; for (auto& f : futures) total += f.get(); return total; }

这个模板有一点容易忽视:func本身被引用捕获,但这个函数对象必须是线程安全的。如果func内部带有可变的计数值,依然会竞争。通用工具函数最容易把责任转嫁给调用方,所以我一般会在注释里写清楚:回调不得修改共享状态。

4.3 为什么并行提升上不去

很多读者测这种纯数据求和会发现,并行代码放大数组规模后,性能提升从 3 倍慢慢掉到 1.5 倍。因为当数据量超过 CPU 缓存容量后,内存带宽就是天花板了。8 个线程同时从内存里搬数据,争抢的是同一条内存通道。这个现象对 ranges 并行实践很重要:并行版本主要应该在“计算密度高”的任务上收益明显,比如每个元素要经过复杂结构体遍历、矩阵运算、字符串处理之类。如果只做x * x + if,并行性带来的收益很快被带宽限制吃掉。这不是 ranges 的问题,是并行计算本身的物理规律。

5. 踩坑实录:ranges 与多线程的常见问题

实践了一轮之后,我把几个最典型的坑整理成速查表,刚上手的人照着自查基本能避开大多数事故。

5.1 悬垂视图比悬垂指针更隐蔽

普通指针悬垂了,跑起来大概率段错误,比较显眼。视图悬垂了,可能很长时间表现正常,因为数据所在内存还没被复用。典型场景是函数返回一个依赖局部容器的视图:

auto bad_view() { std::vector<int> local{1, 2, 3}; return local | std::views::filter([](int x) { return x > 1; }); // local 销毁后,返回的视图内部保存着指向 local 的迭代器 }

如果这个视图后来被投递到异步线程里遍历,轻则垃圾数据,重则随机崩溃。C++23 里的std::ranges::owning_view可以帮我们“持有”数据源,它内部按值存一份 range 数据,生命周期随视图走。但注意owning_view解决的是“值传递”场景,如果数据源想被多个线程共享,还是用shared_ptr管理底层容器更直接。线程不是问题,生命周期才是问题。

5.2 共享可变状态的竞态

前面提过的counter++是典型例子,再举一个更隐蔽的:谓词内部用了局部静态变量,比如“只处理前 N 个满足条件的元素”。单线程没问题,多线程并行遍历同一视图时,局部静态变量被所有线程共享,未定义行为就来了。

用线程互斥量或者std::atomic确实能消除数据竞争,但又有副作用:锁竞争会严重拖慢并行性能。我一般建议重新设计任务边界,把“全局任务计数”改成“每线程局部计数,最后汇总”。这样既线程安全又不需要锁。

5.3 单遍视图不能跨线程分裂

std::ranges里有一部分视图是单遍的,典型代表是istream_view、generator,还有从输入迭代器构建的视图。它们只能按顺序消费一次,不能从头再遍历。当你试图用views::chunk切分这类视图时,编译期通常就报错了,因为chunk要求源 range 至少是forward_range。

即便编译过了,也千万别把同一个单遍视图的迭代器拷贝到多个线程里同时推进。例如基于std::cin的流视图,底层就是同一个文件流或标准输入流,多个线程并发读,状态完全不可控。这类任务想并行只能先落盘或先整体读入内存,再交给 ranges 组合。

5.4 C++20 没有 views::chunk 怎么办

views::chunk是 C++23 才正式进标准的,如果项目还在 C++20,可以用drop和take组合出手工分块:

for (size_t i = 0; i < n; ++i) { size_t start = i * block; if (start >= v.size()) break; auto sub = v | std::views::drop(start) | std::views::take(block); futures.push_back(std::async(std::launch::async, [sub]() mutable { double local = 0.0; for (double x : sub) { if (x > 0.0) local += x * x; } return local; })); }

drop会跳过前面的元素,take只取接下来的 block 个元素。两个适配器组合出来的视图同样惰性、同样可复制,迭代器在块内是独立的。性能上和原生chunk基本没差别,唯一的缺点是多了一层视图包装,日常用没问题。

还要注意不同编译器对 C++23 视图支持程度不一致。有些编译器在-std=c++23下已经实现了chunk,有些还欠火候。遇到编译错误别怀疑是自己代码写错,先查特性支持表。

5.5 用 try 一下:并行数据竞争检测工具

排查 ranges 加多线程的数据竞争,光靠人眼非常累,我强烈建议用线程消毒器工具。对于 GCC 环境的项目,可以在编译命令里加-fsanitize=thread,运行时会报告数据竞争的具体位置,包括哪里写入、哪里读取。很多隐蔽竞态,比如视图 lambda 里捕获共享变量、回调里 push_back,运行时很快就会被抓出来。虽然并行代码优化后性能可能和还原不一致,但作为排查工具,它比写一堆日志高效得多。

6. 我个人在实际操作中的一点体会

绕了一大圈,真正让我觉得顺手的是这样一套组合拳:先用 ranges 把业务逻辑写成清晰的管道,然后用 chunk 或 drop+take 把数据切成块,再把每个块按值投给线程池或 async,最后在 worker 内部继续用 ranges 算法处理块内逻辑。这样既保留了管道式代码的可读性,又没有强行让标准库承担它设计上不打算承担的并行调度职责。

另外一个值得继续关注的方向是 C++26 的发送器/接收器模型,那个模型天然支持异步和并行任务组合,未来 ranges 视图和它是一个很自然的互补关系。但在那个标准真正普及之前,现阶段最稳妥、最可维护的做法,仍然是一手用 ranges 表达数据流,一手用线程/异步负责并行。两者各自守住自己的边界,代码才不容易跑着跑着就崩成玄学。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询