必须分层处理io密集型任务:用异步i/o驱动事件循环,线程池处理计算;否则线程数过多会导致上下文切换爆炸和fd耗尽崩溃。

直接结论:不要用 std::thread 一把梭处理 IO 密集型任务,必须分层——用异步 I/O 驱动事件循环,再用线程池处理计算部分;否则线程数一多,上下文切换和 fd 资源耗尽比业务逻辑还先崩。
为什么 std::thread + 阻塞 read/write 会拖垮性能
IO 密集型任务(比如 socket recv、send,或 fread/fwrite)本质是等磁盘/网卡就绪。一旦你对每个连接都起一个 std::thread 并调用阻塞式系统调用,线程就会卡在内核态等待,但线程本身仍占用栈内存(Linux 默认 8MB)、调度时间片、文件描述符等资源。
常见错误现象:
- 进程打开的 fd 数超过 ulimit -n,
accept或open返回-1,errno=24(EMFILE) - top 显示 CPU 利用率很低(strace -p PID 看到大量
recvfrom在wait_event上休眠 - 线程数 > 100 后,qps 不升反降,
perf record -e sched:sched_switch显示上下文切换次数爆炸
用 epoll + 单线程事件循环接管 IO 等待
核心思路:让一个线程管所有 fd 的就绪状态,只在数据真正可读/可写时才触发处理,避免“为等 IO 白养线程”。Linux 下首选 epoll,而不是 select 或 poll。
关键实操点:
- socket 必须设为非阻塞模式:
fcntl(fd, F_SETFL, O_NONBLOCK),否则epoll_wait虽然返回了,read还是可能阻塞 -
epoll_ctl注册时用EPOLLIN | EPOLLET(边缘触发),减少重复通知;但必须一次性读完所有可用数据,否则下次不提醒 - 事件循环里不做耗时计算(比如 JSON 解析、加解密),只做“收包→入队→唤醒工作线程”,否则会卡住整个事件循环
用双队列线程池分离 IO 就绪与计算任务
纯 epoll 单线程适合转发,但业务逻辑复杂时必须交出去算。这时不能直接把 std::function 丢进通用线程池——因为 IO 任务有状态(比如正在 recv 多个分片),需要能挂起/恢复。
推荐结构:
- 就绪队列:存已完整读取、可立即计算的任务(如已拼好的 HTTP 请求)
- 阻塞队列:存半截 IO 任务(如只收到 header,body 还没齐),由专门的 IO 监控线程(或 epoll 线程)在数据续到后移回就绪队列
- 工作线程只从就绪队列取任务,执行完后通过
std::condition_variable::notify_one()唤醒等待的主线程或回调线程
注意:阻塞队列的“阻塞”不是线程挂起,而是任务对象内部标记 state = WAITING_FOR_IO,靠事件驱动迁移,避免任何线程 sleep。
std::async 和 std::future 不适合高并发 IO 场景
很多人第一反应是用 std::async(std::launch::async, ...) 包一层 read,这看似“异步”,实则危险:
- 默认策略
std::launch::async每次都新建线程,无复用,等于回到“一 IO 一线程”的老路 - 若改用
std::launch::deferred,又变成同步调用,失去并发意义 -
std::future::get()是阻塞等待,无法配合epoll实现真正的事件驱动 - 没有超时控制、取消机制,一个卡死的
recv会让整个 future 永久 hang 住
真正要异步 IO,得用 Boost.Asio、libuv,或者自己封装 epoll + timerfd 做超时,而不是依赖 std::async 的语义糖衣。
最易被忽略的一点:IO 密集型系统的瓶颈从来不在 CPU,而在 fd 数量、内核 socket 缓冲区大小、以及事件循环是否被意外阻塞。上线前务必用 ss -s 看 socket 内存使用,用 cat /proc/PID/fd | wc -l 确认 fd 泄漏,比调优线程数重要十倍。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











