c++高并发解决方案

接入与开发

给 C++ 开发者梳理高并发常见做法与选型思路,从线程模型、无锁队列到批量聚合与压测调优,落到能直接照着做的清单。

写 C++ 服务的人多半都遇到过这种场面:本地压测跑得挺好,一上线连接数一涨,CPU 没跑满但延迟曲线开始抖,日志里全是超时。这时候光加机器没用,得先判断瓶颈在哪个层。这篇按「先定模型、再选原语、最后压出来验」的顺序,把常见方案和适用边界讲一遍,给一份能对着排查的清单。

写 C++ 服务的人多半都遇到过这种场面:本地压测跑得挺好,一上线连接数一涨,CPU 没跑满但延迟曲线开始抖,日志里全是超时。这时候光加机器没用,得先判断瓶颈落在哪一层:是模型选错了,还是原语用错了,还是根本没压出真实数据。

这篇按「先定模型、再选原语、最后压出来验」的顺序,把常见做法和适用边界过一遍。每一步都给判断标准和排查动作,不讲抽象概念。你要是正卡在某个具体数字上,可以顺手到 站内测速与压测工具页(/tools/speed-test) 对着当时的实测项看。下面的所有经验区间都只是思路,真正的数字以你自己压出来的为准。

先定并发模型,再谈代码怎么写

模型选错,后面写再多优化都是白费。判断依据不是「哪个高级」,而是单请求里有没有阻塞调用、连接数在什么量级。纯转发型服务和计算型任务,选法完全不同。先把业务拆成「IO 等待为主」和「CPU 计算为主」两类,再对号入座。

一连接一线程
实现最直观,连接数不大、单请求耗时较长时最省心;连接数上万后线程切换和内存开销会先扛不住
事件循环 Reactor
单线程或多线程跑 epoll 循环,适合连接多但每个请求处理快的场景;一旦回调里有阻塞调用,整个循环被拖住
异步 Proactor
基于 io_uring / IOCP 那类完成通知机制,把等待交给内核;代码复杂度最高,适合 IO 密集且愿意投入改造成本的服务

什么情况下不适用事件循环:连接数其实不大,但单个请求要同步调外部接口、耗时很长。这时事件循环会把所有请求一起卡住,反而不如一线程一连接来得稳。判断标准很简单——把请求处理路径上的阻塞点列出来,只要有一条不可避免,就别硬上单线程 Reactor。

I/O 多路复用与线程池怎么配

多路复用这块,不同平台的机制用法有差异,写代码时最容易踩的坑是触发模式。边沿触发只在你把数据一次读干净时才安全,漏读一次就会挂住;水平触发省心但空转多一些。团队里如果没人专门盯这块,优先选不容易写错的模式。

  • 边沿触发必须配非阻塞套接字,读循环里要一直读到返回 EAGAIN 才算本轮结束
  • 水平触发下要控制好一次读多少,避免单个连接长期占着事件循环
  • 注册和注销事件的时机要跟连接生命周期严格对齐,否则容易出现已关闭的描述符还被轮询
  • 多线程同时操作同一个 epoll 实例时,加事件本身是线程安全的,但业务状态不是,得自己分片

线程池大小的经验估法是:先以 CPU 核数为基准,再看任务里「等待时间 / 计算时间」的比例,等待占比越高可以适当放大线程数。别拍脑袋定一个固定值,也别直接开成核数的几十倍,上下文切换的开销可能比省下的等待还贵。线程间通信优先走队列,别共享大对象互相加锁。

锁、原子量与无锁结构的取舍

顺序不能反:先用互斥锁把逻辑写对,压测确认锁确实是瓶颈之后,再考虑原子量或无锁结构。很多性能问题根本不是锁造成的,一上来就上无锁,最后是自己给自己埋并发 bug。

结构适合的场景不适合的场景
互斥锁临界区有多个共享写、逻辑复杂临界区极短且竞争激烈时开销明显
原子量单个计数、标志位、状态切换需要多字段一起保持一致性的操作
无锁队列单生产者单消费者、元素小、进出频繁需要批量一致性、元素含堆分配
读写锁 / RCU读远多于写、读路径要尽量短写比例不低时反而增加延迟抖动

误用读写锁和 RCU 也会让延迟更差:读多写少的前提下它们才划算,如果写操作其实很频繁,读写锁的写者饥饿和 RCU 的同步等待都会浮出来。判断方法还是压测,别靠直觉。

批量聚合、缓存与降级

把高频小请求攒成一批再处理,是提升吞吐最省力的办法,代价是增加一点延迟。批次窗口要按业务能忍的延迟来定,而不是按你最想要的吞吐来定。窗口越大吞吐越高,但用户感知的延迟也越明显,这个平衡点得业务方一起拍。

  1. 定窗口统计正常请求的到达间隔,窗口取比它略大的值,别一上来就设成几百毫秒
  2. 加计数对聚合前后各埋一个计数,确认批次实际大小和预期差多少
  3. 验语义确认业务能接受「稍晚一点拿到结果」,否则这条路直接放弃

降级顺序建议是:先拒绝非核心请求,再缩短超时,最后才考虑熔断。每一步都要有可观测的计数,不然降级了也不知道降了多少。什么情况不适用:强一致、要求毫秒级确定延迟的场景,批量聚合会破坏语义,这时候宁可牺牲吞吐也别攒批。

怎么压出来、看什么指标

压测工具和被测服务要分开部署,压测机自己先成瓶颈的话,测出来的数字没有任何意义。开始前先确认压测机的 CPU 和网卡没跑满,再去看服务端的表现。

  • 必看吞吐:单位时间完成多少请求,这是最粗的指标
  • 必看 P99 延迟:平均值会骗人,长尾才决定用户体感
  • 必看 CPU 利用率分布:是均匀吃满还是个别核飙高,后者往往说明分片不均
  • 必看上下文切换次数:异常高通常意味着锁竞争或线程数开太多
  • 调优顺序:定位瓶颈层 → 只改一处 → 重新压 → 对比,一次改一件事,否则不知道是谁起的作用

压测里最忌讳的是同时改三四个地方然后看总数字变好,你根本分不清哪个改动有效、哪个只是抵消了彼此的副作用。每次只动一个变量,把改动前后的两组数字并排放,结论才站得住。

先让逻辑正确,再让它快;先压出真实数据,再谈优化方向。
下一步把你自己的压测数据跟同类型服务对比一下,判断当前瓶颈到底在模型、原语还是部署,再决定改哪一层。

常见问题

C++ 写高并发一定要用无锁队列吗?

不一定。无锁结构在锁竞争确实成为瓶颈时才有收益,而且对内存序理解不到位很容易写出偶发 bug。建议先用锁把逻辑写正确、压测确认锁是瓶颈,再考虑换无锁或原子量,换完必须有回归压测。

线程是不是开得越多吞吐越高?

通常不是。线程数超过物理核数后,调度和缓存失效的开销会抵消收益,延迟反而变差。合理做法是以核数为基准,按业务里等待时间的占比适度放大,再用压测确认,不要凭感觉往上加。

单机压测正常,上线就抖,先查什么?

先排除压测环境和线上不一致的地方:网络链路、连接池上限、上游依赖的响应时间、日志级别。再确认是不是上游限流导致的排队。可以到 /tools/speed-test 先量一遍自己这条链路的延迟基线,有个参照再往下查。

本文依据各平台公开文档与官方公告整理,只做中文归纳;具体做法、参数与限制以官方文档为准。