先分清两件事:并发与并行
并发是「同时处理多件事」的能力(结构层面),并行是「同一时刻真的在同时执行」(需要多核)。单核机器上仍可以高并发,但只有多核才有真并行。选模型前先问:任务是 CPU 密集还是 IO 密集。
四种模型对比
| 模型 | 隔离性 | 开销 | 适合 | 不适合 |
|---|---|---|---|---|
| 多进程 | 最强(内存独立) | 高(内存与启动成本) | CPU 密集、需要故障隔离 | 需要频繁共享状态 |
| 多线程 | 弱(共享内存) | 中 | 需要共享数据、阻塞型 IO | 锁竞争严重的场景 |
| 事件循环 | 单线程内协作 | 低 | 大量 IO 等待、连接数高 | 任何 CPU 密集任务 |
| 协程 | 单线程或多线程承载 | 很低 | 高并发 IO、写法接近同步 | 同样不适合 CPU 密集 |
选择顺序
- CPU 密集:多进程(或多线程 + 真并行语言),把任务分到多个核;
- IO 密集且连接多:事件循环或协程,用少量线程支撑大量等待中的连接;
- 需要强隔离(不可信代码、易崩溃模块):多进程;
- 需要共享大量状态:多线程,但要控制锁的粒度。
四个常见坑
- 阻塞调用混进事件循环:一次同步 IO 就会卡住整个循环,所有请求一起变慢;
- 线程池被打满:下游变慢时线程全在等待,新请求排队,故障被放大;
- 锁竞争:锁粒度过大等于把并发串行化,应缩小临界区或改用无锁结构;
- 连接数预估不足:每个连接都有内存与文件描述符成本,需要做上限与超时。
先算一算需要多少并发
用 Little's law:并发数 ≈ 吞吐 × 响应时间 粗略估算。若目标是 1000 QPS、平均响应 200ms,则同时约有 200 个请求在处理中。用这个数字反推线程数、连接池与队列长度,比拍脑袋定数量可靠得多。
反压不能少
任何并发模型都要有反压(backpressure):队列有界、超时可控、超出能力时快速失败。没有反压的系统在压力升高时会把延迟转嫁成内存增长,最终以崩溃收场,而不是优雅降级。
衡量要看分布,不要只看平均
评价并发能力要看 P50 / P95 / P99 与超时率,而不是平均耗时。平均耗时正常但 P99 很差,通常意味着有排队、锁竞争或个别慢请求拖累尾部延迟。
实战场景:三个选型判断
- “文件上传偶尔损坏”:UDP 不保证顺序与重传,传文件应走 TCP;要低延迟又要可靠,可在应用层自建确认与重传(如 QUIC)。
- “实时语音卡顿、延迟高”:丢一帧好过等重传,媒体流通常选 UDP,并在应用层做丢包隐藏与抖动缓冲。
- “连接一多就内存飙高”:每个连接都有成本,需设连接上限、超时与反压,避免“请求堆积”演变成 OOM。
常见问题
协程比线程快吗?协程胜在创建与切换成本低、占用内存小,单核吞吐不一定更高;真正的 CPU 工作仍需靠多核并行。事件循环能不能用多核?可以:一个核跑一个事件循环进程(常见的多 worker 模式),由主进程分发连接。为什么加了线程反而更慢?多半是锁竞争、上下文切换或共享队列成为瓶颈。
死锁与活锁
- 死锁四条件:互斥、持有并等待、不可抢占、循环等待;打破任意一条即可避免,最常用的是统一加锁顺序以消除循环等待;
- 活锁:线程都在运行却无法推进,例如双方不断退让重试;解法是引入随机退避而不是固定顺序;
- 饥饿:低优先级任务长期拿不到资源;需检查调度策略与锁的公平性;
- 排查手段:定期导出线程栈(如 JVM 的 jstack)观察锁等待链,比事后猜测高效得多。