服务器并发模型

并发模型决定服务器如何同时管理多个客户端连接和任务。它不是单纯的“使用线程”或“使用 epoll”,而是 I/O 等待方式、事件分发、业务执行、连接状态和资源控制的组合。

先区分两类工作

一个网络服务器通常同时面对两类工作:

工作 示例 特点
I/O 工作 accept()、recv()、send()、协议缓冲和连接关闭 通常很短,但会等待网络事件
业务工作 数据库查询、文件压缩、图片处理、调用外部服务 可能耗时,甚至阻塞很久

高并发设计的核心原则是:不要让耗时业务阻塞负责大量连接 I/O 的事件循环。事件循环应尽快完成收发和状态更新;耗时任务通常交给线程池、进程池或异步后端处理。

单线程阻塞模型

最简单的服务器在一个线程中顺序处理一个连接:

1
2
3
4
5
6
7
accept 一个客户端
↓
循环 recv / 处理 / send
↓
close
↓
再 accept 下一个客户端

优点:

  • 代码最直观,几乎没有共享状态和并发同步问题。
  • 非常适合学习 Socket 基础、验证协议或编写临时工具。

缺点:

  • 一个慢客户端或长连接会阻塞所有后续客户端。
  • 吞吐量和连接并发能力都很弱。

它不适合真实的多客户端服务,但常作为理解其他模型的起点。

多进程模型

主进程负责监听和接受连接,子进程负责处理客户端。早期常见方式是 accept() 后 fork(),或预先创建多个工作进程。

1
2
3
主进程:listen / accept
↓
子进程:读取、处理、响应、关闭

优点:

  • 地址空间隔离较好,一个子进程崩溃通常不会直接破坏其他进程的内存。
  • 能利用多核 CPU。

缺点:

  • 创建进程、地址空间和进程间通信的成本通常高于线程。
  • 共享连接状态、缓存和限流信息更复杂。
  • 每连接 fork() 在高并发下代价很大。

预派生(prefork)进程池可减少频繁创建进程的开销。多进程仍适用于强隔离需求、传统 Web 服务架构或通过多进程提升故障隔离的系统。

每连接一个线程

每当 accept() 返回新的 client_fd,就创建一个线程,以阻塞方式处理该连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <cerrno>
#include <cstddef>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>

void handleClient(int client_fd) {
char buffer[4096];
for (;;) {
const ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
if (n > 0) {
// 示例只表示流程;生产代码还要处理 send() 的部分发送
send(client_fd, buffer, static_cast<std::size_t>(n), 0);
} else if (n == 0) {
break;
} else if (errno == EINTR) {
continue;
} else {
break;
}
}
close(client_fd);
}

// accept() 成功后调用;生产环境应由受控的线程池替代 detach。
void startClientThread(int client_fd) {
std::thread(handleClient, client_fd).detach();
}

优点:

  • 编程模型接近顺序代码,每个连接的状态自然保存在自己的调用栈中。
  • 慢客户端只会阻塞负责它的线程,不会阻塞其他连接。

缺点:

  • 线程数量与连接数线性增长;大量空闲长连接也会占用线程栈和调度资源。
  • 线程创建/销毁、上下文切换和内存占用会限制规模。
  • detach() 使线程生命周期难以统一管理,示例中不推荐直接用于生产。

它可以用于连接数有限、每个连接逻辑简单的服务,但应设置最大连接数和线程上限。

线程池模型

线程池预先创建固定数量或有上限的一组工作线程,通过任务队列分配工作,避免“一连接一线程”的无限增长。

1
2
3
4
5
监听线程 / 事件循环
↓ 提交任务
有界任务队列
↓ 取任务
固定数量工作线程

线程池常见的两种使用方式:

方式 做法 适用性
阻塞式连接任务 一个工作线程独占一个连接并阻塞读写 简单,但长连接仍可能耗尽线程
业务任务池 I/O 由事件循环处理,完整请求或耗时工作交给线程池 更适合大量连接与耗时业务

线程池不是“线程越多越好”。CPU 密集型工作通常以 CPU 核数附近为起点;大量阻塞 I/O 的工作可以更多,但仍要通过压测和资源限制确定。任务队列必须有上限:当队列满时需要定义背压策略,例如拒绝请求、返回繁忙错误、暂停读取或丢弃可丢弃任务。

基于多路复用的事件驱动模型

一个事件循环使用 epoll、kqueue、poll 或 select 等接口等待多个连接的 I/O 事件。连接通常设置为非阻塞。

1
2
3
4
5
6
EventLoop
├── 监听 fd 可读:accept 新连接
├── 客户端 fd 可读:recv、追加输入缓冲区、解析协议
├── 客户端 fd 可写:继续发送输出缓冲区
├── 定时器:关闭空闲/超时连接
└── 唤醒事件:接收其他线程提交的任务或写回结果

优点:

  • 少量线程可管理大量大部分时间空闲的连接。
  • 不会为每个连接保留一个阻塞线程。
  • 便于统一处理超时、限流、连接统计和背压。

缺点:

  • 状态机、缓冲区、错误路径和对象生命周期更复杂。
  • 如果把 CPU 密集或阻塞业务放进事件循环,整个循环仍会卡住。
  • 非阻塞 send()、部分发送、可写事件注册都需要正确管理。

I/O 多路复用接口与 epoll 的 LT/ET 细节见《IO多路复用》。

Reactor 模型

Reactor 是事件驱动服务器的常见组织方式:Reactor 等待并分发就绪事件,处理器在收到事件后自行执行实际 accept()、recv() 或 send()。

1
2
3
4
5
Reactor / EventLoop
↓ 分发就绪事件
Acceptor、连接读处理器、连接写处理器、定时器处理器
↓
协议解析与业务分发

常见 Reactor 组合:

结构 I/O 与业务分工 适用情况
单 Reactor 单线程 一个线程处理 I/O、协议和业务 逻辑简单、负载较轻;业务必须很短
单 Reactor + 线程池 Reactor 处理 I/O,线程池执行耗时业务 常见折中方案
主从 Reactor + 线程池 主 Reactor 接受连接;多个从 Reactor 分别管理连接 I/O;业务再进线程池 多核、高连接数服务

主从 Reactor 的常见流程:

1
2
3
4
5
6
7
8
9
主 Reactor 收到监听事件
↓
accept 新连接
↓
按负载将连接分配给某个从 Reactor
↓
从 Reactor 负责此连接的后续读写和定时器
↓
耗时业务提交线程池,结果再投递回所属 Reactor 发送

一个连接应尽量由固定的事件循环拥有和修改其 I/O 状态,这称为连接所有权。工作线程不能随意直接修改该连接的事件注册或输出缓冲区;更稳妥的做法是将结果投递回所属事件循环,由它串行地更新连接状态。

Proactor 模型

Proactor 关注的是完成事件:应用先提交异步读写请求,操作系统完成真正的 I/O 后通知应用结果。

1
2
3
4
5
6
7
应用提交异步读/写请求
↓
内核完成 I/O
↓
完成队列通知 Proactor
↓
处理完成结果和后续业务
模型 通知的内容 谁调用实际读写
Reactor “fd 已就绪,可以读/写” 应用在事件回调中调用 recv() / send()
Proactor “此前提交的读/写已经完成” 内核完成已提交的操作,应用消费结果

Windows IOCP 是典型 Proactor 风格接口;Linux io_uring 可构建完成队列驱动的架构。不要仅因使用了 epoll 就称为 Proactor:epoll 通常通知的是就绪,而不是读写完成。

常见混合架构

实际服务器常采用混合方案:

1
2
3
4
5
6
7
非阻塞 I/O + 多路复用(管理连接)
↓
协议解析
↓
有界线程池 / 异步客户端(执行耗时业务)
↓
投递结果回原 EventLoop(发送响应)

这种设计既避免了海量连接对应海量线程,也避免了数据库、磁盘或 CPU 密集任务卡住事件循环。

选择与容量规划

情况 推荐起点
教学、小工具、连接很少 单线程阻塞模型
连接数有限且每连接逻辑简单 有上限的每连接线程 / 线程池
大量空闲长连接、短 I/O 操作 非阻塞 I/O + Reactor
多核且高连接数、业务可并行 主从 Reactor + 有界业务线程池
需要平台原生完成队列能力 IOCP、io_uring 等 Proactor 风格方案

无论使用哪种模型,都应明确设置和监控:最大连接数、fd 上限、线程数、任务队列长度、单连接输入/输出缓冲区上限、请求超时、空闲超时、日志与指标。高并发问题往往不是“事件循环不够快”,而是没有背压和资源边界。

常见误区

  • epoll 不是服务器架构本身;它只是一个 I/O 事件通知机制。
  • 线程池不能自动让服务器高并发;如果任务会永久阻塞,固定线程池仍会被耗尽。
  • 一个事件循环里不能执行耗时业务;否则所有归属该循环的连接都会受影响。
  • 可写事件不代表可以一次 send() 完全部响应;必须维护输出缓冲区和已发送位置。
  • 多线程处理同一连接必须同步;更推荐将连接 I/O 状态限制在所属事件循环线程中。
  • 优化前先压测和观测瓶颈;数据库、锁竞争、日志、序列化或下游服务常常比 I/O 模型更先成为问题。