ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++并发编程:从共享内存到Actor与CSP模型实战

C++并发编程:从共享内存到Actor与CSP模型实战 并发编程系列写到第九篇感觉是一个分水岭。前面几篇我们一直在跟线程、锁、条件变量、原子变量这些“硬骨头”较劲理解数据竞争、死锁、饥饿这些老生常谈的问题。而 Actor 和 CSP 这两兄弟一登场画风突然变了线程之间不通过共享变量交换数据了改为互相“发消息”锁变成了信箱、通道这样的通信原语代码逻辑不再是一堆mutex包着临界区而是一段段 “收到消息后该怎么处理” 的状态机。这个转变对很多 C 开发者来说既是出路也是坎出路在于天然规避了绝大多数锁竞争和死锁的噩梦坎在于你需要彻底改变对“并发”的建模方式从“对共享内存加锁”变成“对通信过程做设计”。这篇文章我结合这几年在 C 项目里落地 Actor 和 CSP 的实践经验把这套并发设计模式掰开揉碎讲一遍。内容完全基于 C11 到 C20 的标准库实现不依赖任何第三方网络库所有代码都能直接编译运行。看完你可以回答三个问题Actor 和 CSP 到底在解决什么问题它们各自怎么用标准 C 实现项目里到底选哪个、怎么避坑1. 从共享内存到消息传递为什么会走到 Actor 和 CSP1.1 锁和共享内存模型的成本上限一直以来C 并发编程的经典套路就是“多线程 互斥量 共享状态”。线程 A 往队列里写数据线程 B 从队列里读数据中间用一把锁保护队列不被同时操作。这个模型本身没什么问题小规模并发场景下也够用但一旦线程数量变多、业务链路变长三个麻烦就开始浮现。第一是锁竞争导致的性能衰减。随着线程数增长锁的争抢会越来越激烈线程大量时间花在阻塞和唤醒上而不是干活本身。我用std::atomic做过一个简单的计数器压测4 线程时性能几乎是线性扩展的到 32 线程时提升已经非常有限换成std::mutex保护就更惨带上锁的计数器 16 线程时的吞吐量甚至不如单线程。这个问题的根源在于共享内存模型要求所有线程对同一块数据拥有一致的视图为了维护这个一致性锁机制不得不在性能上付出代价。第二是死锁和活锁的隐蔽性。加了锁只是开始锁的获取顺序、生命周期、异常安全全是坑。经典死锁发生在两个线程各持有一把锁、又同时等待对方的锁时这类问题在单元测试里很难复现因为出现时机依赖线程调度的运气。一旦上了生产环境、负载量突然变大死锁就“准时”出现了。第三是最难办的共享状态导致业务逻辑难以推导。比如一个订单对象被多个线程同时读取和修改你要证明某个时刻它的状态是合法的得把所有访问它的代码路径全部捋一遍。这本质上回到了“程序员手动证明并发正确性”的蛮荒时代只要改动稍微复杂漏掉一个临界区就埋了一个定时炸弹。1.2 消息传递模型的降维打击Actor 和 CSP 给出了一个完全不同的答案不共享内存所有数据通过消息传递。注意措辞——本质不是“不加锁”而是“不需要锁”。每个执行体只拥有自己的私有状态别的执行体无法直接读写这份状态只能通过给它发送消息来间接请求状态变更。这样一来数据竞争在模型层面就被消灭了而不只是在代码层面被小心翼翼地规避。这类模型的另一个隐含好处是伸缩性。因为执行体之间只通过消息耦合各个并发单元可以被分散到不同的操作系统线程、不同的 CPU 核心甚至不同的物理机器上只要消息协议一致底层调度方式可以灵活替换。这也是为什么游戏服务器、即时通讯后端、分布式调度系统都偏好 Actor因为这种架构天然适合横向扩展。1.3 Actor 和 CSP 的同与不同很多初学者把 Actor 和 CSP 当成一回事因为它们都强调“通过通信来共享而非通过共享来通信”。实际上两个模型的侧重点完全不同。Actor 模型关注的是“独立的执行实体”。每个 Actor 有自己的状态、自己的信箱mailbox和一套行为逻辑收到消息后按定义好的行为处理处理完可能发新消息给其他 Actor也可能创建新的 Actor。Actor 之间是完全隔离的不存在“共享通道”A 给 B 发消息B 可能根本没听说过 A。CSP 模型关注的是“连接执行体的通道”。它不强调执行体之间的隔离通道本身是显式的第一公民两个执行体通常叫 process/goroutine通过通道同步地传递数据。通道可以关闭、可以带缓冲、可以被多个执行体共享。与 Actor 的信箱不同CSP 的通道是成对衔接的“谁和谁通过这条通道通信”从一开始就是确定的。套用到工程实践上可以这样理解Actor 型架构像一家公司员工之间不直接窥探彼此的工作台有需求就发邮件消息收到邮件的人自己决定怎么处理、是否需要转给其他人CSP 型架构更像流水线每个工位之间用传送带通道连接上游只往传送带上放件下游只从传送带上取件传送带本身是双方约定的物理连接。2. Actor 模型实战用标准 C 打造一个最简单的 Actor 框架2.1 从需求倒推 Actor 的最小实现在 C 里要使用 Actor 模型你可以直接引入 CAF、SObjectizer 这样的第三方库。但如果只想弄懂原理或者项目里不想引入庞大的依赖完全可以用标准库自己封装一个不到一百行的轻量版。我自己的做法是先明确三个必备能力。第一每个 Actor 需要有一个独立的消息循环不断从自己的信箱中取消息并处理。注意不是每条消息新建一个线程而是 Actor 自身绑定一个线程或者在某个线程池上轮转消息只是塞进队列。第二Actor 需要暴露一个线程安全的“投递”接口其他线程可以随时把消息放进这个信箱且投递动作不被阻塞太久。这要求信箱本身有锁和条件变量保护但锁的粒度只需要覆盖“入队”和“取队首”非常短小。第三Actor 需要支持优雅退出。析构时不能直接砍线程因为可能有其他 Actor 正在向它投递消息必须先把投递接口标记为“已关闭/已停用”再让消息循环把剩余消息处理完或者放弃剩余消息后自然退出。2.2 一个可运行的极简 Actor 类下面这个实现采用了两层结构一个通用的mailbox负责管理消息队列的同步一个actor_base提供注册、启动、停止的基础能力。为简单起见消息统一封装成std::functionvoid()也就是投递一个“回调任务”。它足够灵活可以捕获业务需要的所有参数。#include atomic #include condition_variable #include functional #include mutex #include queue #include thread #include vector template typename T class basic_mailbox { public: void post(T item) { { std::lock_guardstd::mutex lock(m_mutex); if (m_closed) return; m_queue.push(std::move(item)); } m_cv.notify_one(); } bool pop(T out) { std::unique_lockstd::mutex lock(m_mutex); m_cv.wait(lock, [this] { return m_closed || !m_queue.empty(); }); if (!m_queue.empty()) { out std::move(m_queue.front()); m_queue.pop(); return true; } return false; } void close() { { std::lock_guardstd::mutex lock(m_mutex); m_closed true; } m_cv.notify_all(); } private: std::mutex m_mutex; std::condition_variable m_cv; std::queueT m_queue; bool m_closed false; }; class actor_base { public: using message std::functionvoid(); explicit actor_base(std::string name) : m_name(std::move(name)) {} void start() { m_thread std::thread([this] { run(); }); } void send(message msg) { m_mailbox.post(std::move(msg)); } void stop() { m_mailbox.close(); if (m_thread.joinable()) m_thread.join(); } virtual ~actor_base() default; protected: virtual void handle(const message msg) { msg(); } private: void run() { message msg; while (m_mailbox.pop(msg)) { handle(msg); } } std::string m_name; basic_mailboxmessage m_mailbox; std::thread m_thread; };用法非常直观写一个子类在start()之后通过send向它投递 lambda 任务。比如一个订单处理 Actorclass order_actor : public actor_base { public: using actor_base::actor_base; protected: void handle(const message msg) override { // 实际项目中这里可以按消息类型走状态机 msg(); // 处理完可以继续给别的 actor 发消息 } }; int main() { order_actor orders(orders); orders.start(); orders.send([i 0]() mutable { std::cout processing order i std::endl; }); orders.send([] { std::cout order finished std::endl; }); orders.stop(); }需要注意stop()会等待当前消息队列清空。如果你希望停止时丢弃所有未处理消息只需要在pop返回 false 时直接退出也就是把close设置后队列里残留的消息清空即可。我通常选择“清空功能 丢弃残留”的策略而不是“处理完所有消息”因为 Actor 停往往意味着业务已经结束残留消息大多是无效的过期请求留着处理反而浪费时间。2.3 生命周期管理和内存安全问题Actor 模型在 C 里落地时最大的坑不是发消息而是生命周期的管理。Actor 之间可能互相持有一方地址并不断投递消息如果其中一个被销毁了另一个还继续向旧地址发送消息程序就悬垂指针访问了这在多线程下很难稳定复现比普通堆越界更隐蔽。我在项目中通常会遵循两条规则。第一Actor 本身用std::shared_ptr管理而消息内部不持有原始指针只持有std::weak_ptr。收到消息后先lock()检查对方是否还活着如果死了就直接放弃。第二所有 Actor 统一在一个管理器中注册由管理器统一负责 shutdown 顺序。停止时要遵守“先停止上游、再停止下游”的原则避免上游正在给已经停止的下游发消息。第二条规则在析构顺序上尤其重要。我曾经在日志系统和业务系统之间踩过坑业务 Actor 在析构时还想往日志 Actor 发一条“我要退了”的消息但日志 Actor 因为析构顺序靠前已经销毁业务 Actor 直接崩掉。解决办法是让日志 Actor 的生命周期全局最长所有依赖它的 Actor 先退它最后退。这套顺序确定之后整个进程结束时不会出现任何消息发送到销毁对象的报错。3. CSP 模型实战用条件变量封装一个 channel3.1 共享内存变成显式通信CSP 模型在 C 标准库里没有直接对应的“channel”类型但实现一个并不复杂。Go 语言的chan提供了两个核心能力发送者向通道写数据、接收者从通道读数据并且通道默认是同步的——发送者会阻塞直到接收者真正取走数据或者带缓冲区的通道可以在容量未满前不阻塞。C 里用std::mutex加std::condition_variable完全可以还原这两个语义。我自己写过一个channelT支持单生产者单消费者、也支持多生产者多消费者模式。它内部维护一个循环缓冲区或者std::deque容量为capacity。当无缓冲时capacity 0发送必须等待接收者就绪有缓冲时发送方只在缓冲区满时阻塞等待接收方只在缓冲区空时阻塞等待。3.2 一个可复用的 channel 实现这里给出一个相对完整且线程安全的实现。它既支持无缓冲同步模式也支持有缓冲异步模式对外提供send和recv两个接口另一个try_send/try_recv版本则用于非阻塞场景。#include condition_variable #include deque #include mutex #include optional template typename T class channel { public: explicit channel(size_t capacity 0) : m_capacity(capacity) {} void send(T value) { { std::unique_lockstd::mutex lock(m_mutex); m_not_full.wait(lock, [this] { return m_closed || m_queue.size() m_capacity; }); if (m_closed) throw std::runtime_error(send on closed channel); m_queue.push_back(std::move(value)); } m_not_empty.notify_one(); } std::optionalT recv() { std::unique_lockstd::mutex lock(m_mutex); m_not_empty.wait(lock, [this] { return m_closed || !m_queue.empty(); }); if (m_queue.empty()) return std::nullopt; T value std::move(m_queue.front()); m_queue.pop_front(); lock.unlock(); m_not_full.notify_one(); return value; } void close() { { std::lock_guardstd::mutex lock(m_mutex); m_closed true; } m_not_empty.notify_all(); m_not_full.notify_all(); } bool closed() const { std::lock_guardstd::mutex lock(m_mutex); return m_closed; } private: size_t m_capacity; std::dequeT m_queue; mutable std::mutex m_mutex; std::condition_variable m_not_empty; std::condition_variable m_not_full; bool m_closed false; };注意capacity必须保证至少大于 0。如果你需要无缓冲通道直接把capacity设置为 0m_queue.size() m_capacity永远不满足所以发送者必须等接收者调用recv且队列腾出位置后才能 push。这个语义完全对标 Go 的无缓冲 channel 同步握手。3.3 用 std::jthread 和 stop_token 管理 CSP 工作线程C20 引入了std::jthread它在析构时自动request_stop()并join()配合std::stop_token可以优雅地终止工作线程。这个能力跟 CSP 的 “process” 概念是绝配——每一个工作线程就是一个 process线程通过 channel 与上下游通信停止信号则通过std::stop_token传递。一个典型的流水线工作线程可以这样写#include atomic #include iostream #include thread #include vector #include channel.h void worker(std::stop_token st, channelint input, channelint output) { while (!st.stop_requested()) { auto value input.recv(); if (!value) break; // channel 被关闭且队列为空 int result (*value) * 2; try { output.send(result); } catch (const std::runtime_error) { break; // 下游已关闭 } } output.close(); }这里 stop_token 保证了线程能在不再需要时被及时通知退出。与传统的std::thread相比std::jthread最大的好处是你再也不用手动写“设置一个原子标志位 条件变量唤醒”的整套退出机制了。std::stop_token就是标准库替我们封装好的“通知退出信号”配合 channel 的 close 语义整个流水线可以在毫秒级内完成优雅停运。4. 选型与架构什么场景用 Actor什么场景用 CSP4.1 从业务连接形态判断模型我见过不少团队在选型时把两套方案混着用效果往往不错——前提是搞清楚各自的擅长临域。要不要拆分场景、怎么拆很大程度上看业务并发单元的连接形态。如果系统里的并发单元之间呈现“多对多、动态发现、随机路由”的关系比如聊天系统中任意两个在线用户可能直接发消息那么 Actor 更合适。因为 Actor 天然把每个用户封装成独立的执行实体消息通过用户 ID 找到目标信箱不需要像 CSP 那样事先建立一条显式通道否则用户之间的连接关系会爆炸。如果系统里的并发单元形成一个固定的数据流拓扑比如“网络接收 → 协议解析 → 业务逻辑 → 落库”每个环节只跟上下游打交道数据按顺序流动那么 CSP 模式更清晰。channel 就是现成的数据管道哪个环节慢了就阻塞消费者整个流水线自带流量控制和背压业务代码看起来是一条直线调试时也能顺着 channel 一个节点一个节点地排查。下面我从几个维度列了个对比表方便决策维度Actor 模型CSP 模型通信方式消息投递到对方信箱不显式建立连接通过显式 channel 连接耦合关系发送方和接收方解耦通过 ID 寻址发送方和接收方在通道层面强关联消息存储每个 Actor 自带信箱通常有界/无界队列channel 自带缓冲可 0 或 N背压处理信箱满时丢弃或阻塞由 Actor 自行决定channel 满时发送方自动阻塞拓扑弹性支持动态创建、动态路由适合网格型系统适合流水线、星型固定拓扑故障隔离单个 Actor 崩溃影响面小可重启后恢复消息通道关闭影响上下游需精心设计关闭协议C 实现成本需要自己管理生命周期和信箱策略channel 本身少但流程编排较繁琐4.2 一个混合场景日志处理流水线实操中我常用的一个混合架构是业务 Actor 通过网络接口接收用户请求经过加工后把结果通过 channel 交给下游落库线程。这相当于 Actor 负责高并发的随机请求分发CSP 负责把高吞吐的数据流按顺序写出。这样做的好处非常明显。上游 Actor 之间松耦合新增一种业务类型只需要新增一个 Actor管理成本低下游写入数据库的并发线程不希望被随机的请求打乱顺序所以把需要顺序化写入的数据放进带缓冲 channel由单一消费者线程按顺序取出并写入保证 IO 的有序性。channel 的缓冲还能吸收上游的流量尖峰避免了数据库频繁承受写入抖动。实现伪代码类似channelint write_queue(1024); actor_base upstream(request_in); writer_process db_writer(std::stop_token{}, write_queue); upstream.start(); upstream.send([write_queue] { int result compute(); write_queue.send(result); // 业务结果进入通道 }); db_writer.start();关键点在write_queue的容量设置。我一般倾向设置一个合理的上限而不是无界队列否则上游流量过大时队列会无限膨胀内存迟早爆炸。容量设 1024 或 4096 时上游发送会自然阻塞起到背压作用系统整体吞吐量虽然被瓶颈线程限制但不会出现内存失控这在生产环境中远比“看起来很流水”重要。4.3 什么时候该避开 Actor 和 CSP不是所有并发问题都适合套用这两种模式。如果只是短暂地让多个线程并行计算一段数组、然后用std::async拿结果锁和原子变量仍然是最直接的手段。消息传递是有成本的东西哪怕封装得再好每一次 send/recv 都涉及至少一次锁操作、一次内存拷贝或移动跟std::atomic的极限性能相比还是慢了一个数量级。另外如果并发单元之间确实需要频繁读写同一份复杂状态比如共享一个大缓存、一个红黑树把锁加上反而比改成 Actor 之间互相发“修改指令”要简单。Actor 模式强行化会引入大量消息类型定义和状态同步逻辑复杂度往往超过锁本身这是“为了模式而模式”的典型反例。一般我的判断依据是共享状态的生命周期短、访问频繁就用锁共享状态的生命周期长、需要跨节点迁移或需要故障恢复再考虑 Actor 或 CSP。5. 实际项目中的常见问题与排查技巧5.1 死锁通道互相等待的经典场景CSP 模型虽然规避了“多把锁顺序不一致”的死锁却引入了另一种死锁通道成环。A 线程往 B 通道发送数据同时等待 B 线程往 A 通道发送数据如果两个通道都没有缓冲双方都阻塞在 send 上不会有人来 recv就死锁了。排查方法很简单打印每个线程当前阻塞的位置和通道的空满状态。我习惯在 channel 内部加周期性的状态快照日志记录“当前生产者数、消费者数、队列长度、是否有线程阻塞在 not_full/not_empty”上死锁出现时一查日志就能定位环。规避手段则是在设计阶段就给每个通道规定好方向禁止出现同层线程之间互相收发数据的环。如果有跨层反馈需求通常新增一个独立的反馈通道或者改用 Actor 信箱避免在一条链路上形成双向依赖。5.2 Actor 消息积压与丢消息问题Actor 信箱使用无界队列时高并发下内存无上限增长最终触发 OOM。有界队列又引出新的问题信箱满了之后新消息该丢还是该等实际项目里我通常采取“按优先级分类”的策略控制类消息永远直接入队不阻塞业务类消息若队列满则丢弃并告警。因为业务消息往往只是最新状态对旧状态的覆盖丢掉一条过期的请求并不会影响最终一致性而控制类消息比如 shutdown必须及时处理。如果你确实不能丢消息但又不想因为队列满而阻塞发送方可以考虑改用带有“等待并重试”的发送循环发送方检测到队列满时短暂 sleep 后重试并允许通过std::atomic统计重试次数作为系统过载的早期信号。不要在生产环境直接采用无限等待那样一旦接收方卡死发送方全部挂起故障会迅速传染。5.3 性能调优队列警力、调度、内存拷贝无论是 Actor 信箱还是 CSP 的 channel性能瓶颈通常不在锁上而在内存拷贝。每一条消息如果在投递时被复制多次高 QPS 下拷贝开销会非常吓人。解决方向是尽量让消息类型不可拷贝删除拷贝构造和拷贝赋值强制走移动语义如果有条件直接用std::unique_ptrT当消息体只传递指针避免深拷贝。队列容量对吞吐量也有明显影响。我实测过对于无缓冲 channel吞吐量主要受线程调度延迟影响不适合高频小消息场景给 channel 加缓冲后吞吐量可以成倍提升容量越大吞吐越高但延迟也越高。所以压测时要给业务设置一个“最大可接受延迟”然后反推最优容量而不是一味地往大调。线程调度方面如果系统中 Actor/CSP 协作线程的数量超过了硬件核心数频繁的上下文切换会成为新的瓶颈。我一般会让活跃协作线程数限制在硬件并发数以内其余业务消息排队等待。这一步对整体 QPS 的收益非常显著很多时候比优化消息类型的移动语义更立竿见影。5.4 关于 std::async 与 std::future 的诱惑我见过不少从 Java/C# 转 C 的开发者天然喜欢用std::async来实现类似 Actor/CSP 的“后台任务”理由是写起来简单。这里要泼一盆冷水std::async创建的 future 在析构时如果还没就绪会阻塞等待任务完成这个行为在交互式并发场景里常常造成意外的性能劣化和隐藏的阻塞。std::async本质上是“任务分发”而不是“执行体之间长期协作的通道”。它适合一次性任务池调度不适合表达 Actor 的长期存活状态或 CSP 的持续数据流。如果你的 Worker 需要常驻并响应多次消息老老实实用前面几节的 mailbox/channel 模式不要试图用一组std::async去模拟否则很快就会陷入每个异步任务都自带一份状态、共享状态却还要加锁的泥潭。这一点是很多 C 并发新手最容易踩的“隐形成本”陷阱。6. 实操总结与两种模式的长远价值6.1 代码组织层面的建议在项目里落地 Actor 或 CSP除了并发正确性还有一个容易被忽略的优势代码组织更加健康。消息类型可以集中定义成独立的头文件Actor 的行为逻辑集中在handle方法中channel 的上下游关系通过构造函数或注册函数明确建立。这样代码的可读性比“共享变量 锁 条件变量”大杂烩高出一大截团队新人上手也能很快定位消息流。我个人的工程习惯是把所有消息类型放进message.hpp用强类型枚举区分每个 Actor 或 channel 的工作线程有一个清晰的“从哪收、往哪发”的声明文档单元测试中直接构造消息对象验证业务逻辑不启动真实线程也能覆盖大部分分支。这个做法让并发模块的测试门槛大幅降低很多逻辑错误在单测阶段就能暴露而不是等集成阶段随机复现。6.2 两种模式对未来架构的铺垫从长远看Actor 模型和 CSP 模型的抽象能力不止于解决本机的多线程问题。Actor 的消息寻址机制可以直接映射到分布式环境中的远程调用本地发一个消息是进信箱远程发一个消息是走网络协议两者在 API 层是统一的CSP 的 channel 概念也能对应到消息队列中间件本地的 channel 可以扩展成分散在不同机器上的 Topic生产者/消费者的语义保持一致。我见过不少底层是 gRPC 或类 RPC 的框架在业务逻辑层就是完全按 Actor/CSP 的思维编写的这也让项目在需要跨机器扩展时不至于把并发代码推倒重来。当语言本身已经提供了充足的并发原语真正的瓶颈往往是我们对并发结构的认知。Actor 和 CSP 之所以被称为设计模式不是因为它们被写进 GoF 那本经典书里而是因为它们在无数次工程实践中被证明是表达“并发协作”的最可靠抽象。即使以后 C 标准库加入了官方的 channel 或 actor 支持核心思想也不会变不共享状态只传递消息。理解这一点才是这篇教程最有价值的地方。我个人经过几个项目的锤炼现在的首选方案是消息拓扑明确、顺序性要求高的链路走 CSP channel业务实体动态性强、需要按 ID 随机路由的部分用 Actor 信箱思想来建模然后让这两者在一个线程池上和谐共处。你可以从一个最小案例开始试水比如让 3 个 Actor 互相转发消息或者让一个 4 级流水线 channel 处理大量任务跑起来之后你就会发现并发问题虽然不会完全消失但大部分让你挠头的“随机崩溃”在消息传递模型里都变得很有章法可循。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进