
不少高并发服务器教程会给出这样的结论:
select 性能低,epoll 性能高。
这句话没有错,却很容易让人产生误解:仿佛把 select() 换成 epoll_wait(),服务器就能自动支撑十万连接。
事实上,epoll 只负责告诉程序“哪些文件描述符可能已经就绪”。怎样读取数据、处理半包、缓存响应以及控制慢客户端,仍然是应用程序自己的责任。
一、Reactor 模式解决什么问题
传统的一连接一线程模型很直观:
接收连接
↓
创建线程
↓
阻塞读取
↓
处理请求
↓
发送响应
连接数量较少时,这种方式完全可用。
但当连接增长到数万时,大量线程会带来:
- 线程栈内存;
- 上下文切换;
- 调度开销;
- 锁竞争;
- 难以控制的资源占用。
Reactor 的核心思路是:
一个事件循环监听大量连接
↓
哪个连接就绪,就处理哪个
↓
暂时不能继续时,立即返回事件循环
线程不再阻塞等待某一个客户端。
二、epoll 的三个基本操作
创建 epoll 实例:
int epollFd = epoll_create1(EPOLL_CLOEXEC);
注册或修改监听对象:
epoll_event event{};
event.events = EPOLLIN;
event.data.fd = socketFd;
epoll_ctl(epollFd, EPOLL_CTL_ADD, socketFd, &event);
等待事件:
epoll_event events[1024];
int count = epoll_wait(
epollFd,
events,
1024,
-1
);
epoll_wait() 返回的不是完整业务请求,只是就绪通知。
三、所有 Socket 都应该设为非阻塞
事件循环中只要有一个连接发生阻塞,其他连接都会受到影响。
#include <fcntl.h>
bool SetNonBlocking(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
return false;
}
return fcntl(
fd,
F_SETFL,
flags | O_NONBLOCK
) != -1;
}
监听 Socket 和客户端 Socket 都应该设置成非阻塞模式。
四、LT 与 ET 的区别
epoll 支持两种常见通知模式。
水平触发 LT
只要文件描述符仍然可读,epoll_wait() 就会继续提醒。
优点是逻辑简单,即使一次没有读完,下一轮还会收到通知。
边缘触发 ET
只有状态从“不可读”变成“可读”时才通知。
注册方式:
event.events = EPOLLIN | EPOLLET;
ET 可以减少重复通知,但使用要求更严格:
收到事件后,必须一直处理到返回 EAGAIN。
如果只读取一次,缓冲区里剩余的数据可能长期得不到下一次通知。
五、正确处理新连接
在 ET 模式下,一次连接事件可能对应多个已完成握手的客户端,因此 accept() 也要循环调用:
void AcceptConnections(int listenFd, int epollFd)
{
while (true) {
int clientFd = accept4(
listenFd,
nullptr,
nullptr,
SOCK_NONBLOCK | SOCK_CLOEXEC
);
if (clientFd >= 0) {
epoll_event event{};
event.events =
EPOLLIN |
EPOLLET |
EPOLLRDHUP;
event.data.fd = clientFd;
if (epoll_ctl(
epollFd,
EPOLL_CTL_ADD,
clientFd,
&event) == -1) {
close(clientFd);
}
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
if (errno == EINTR) {
continue;
}
break;
}
}
只调用一次 accept(),会让已经排队的其他连接继续等待。
六、读取数据必须读到 EAGAIN
每个连接需要维护自己的输入缓冲区:
struct Connection {
int fd;
std::string input;
std::string output;
};
读取函数:
bool ReadAvailable(Connection& connection)
{
char buffer[4096];
while (true) {
ssize_t size = recv(
connection.fd,
buffer,
sizeof(buffer),
0
);
if (size > 0) {
connection.input.append(buffer, size);
if (connection.input.size() > 1024 * 1024) {
return false;
}
continue;
}
if (size == 0) {
return false;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
return true;
}
return false;
}
}
这里增加了输入大小限制,否则恶意客户端可以不断发送数据耗尽服务器内存。
七、一次 recv 不等于一条消息
TCP 是字节流协议,以下情况都可能发生:
一次 recv 收到半条消息
一次 recv 收到一条消息
一次 recv 收到多条消息
假设协议使用四字节长度头:
| body_size | body |
解析时应保留未完成数据:
std::vector<std::string> ParseMessages(std::string& input)
{
std::vector<std::string> messages;
while (input.size() >= 4) {
uint32_t networkLength;
std::memcpy(&networkLength, input.data(), 4);
uint32_t bodyLength = ntohl(networkLength);
if (bodyLength > 1024 * 1024) {
throw std::runtime_error("message too large");
}
if (input.size() < 4 + bodyLength) {
break;
}
messages.emplace_back(
input.substr(4, bodyLength)
);
input.erase(0, 4 + bodyLength);
}
return messages;
}
生产环境可使用读取下标或环形缓冲区,避免频繁执行 erase() 搬移数据。
八、send 也可能只发送一部分
即使 Socket 已经可写,send() 仍不保证一次写完。
bool FlushOutput(Connection& connection)
{
while (!connection.output.empty()) {
ssize_t size = send(
connection.fd,
connection.output.data(),
connection.output.size(),
MSG_NOSIGNAL
);
if (size > 0) {
connection.output.erase(0, size);
continue;
}
if (size == -1 && errno == EINTR) {
continue;
}
if (size == -1 &&
(errno == EAGAIN || errno == EWOULDBLOCK)) {
return true;
}
return false;
}
return true;
}
如果还有未发送数据,需要监听 EPOLLOUT:
void UpdateInterest(
int epollFd,
const Connection& connection)
{
epoll_event event{};
event.events =
EPOLLIN |
EPOLLET |
EPOLLRDHUP;
if (!connection.output.empty()) {
event.events |= EPOLLOUT;
}
event.data.fd = connection.fd;
epoll_ctl(
epollFd,
EPOLL_CTL_MOD,
connection.fd,
&event
);
}
响应全部发送后应移除 EPOLLOUT,否则可写事件可能造成无意义唤醒。
九、事件循环骨架
while (true) {
int count = epoll_wait(
epollFd,
events,
MAX_EVENTS,
-1
);
if (count == -1) {
if (errno == EINTR) {
continue;
}
break;
}
for (int i = 0; i < count; ++i) {
int fd = events[i].data.fd;
uint32_t flags = events[i].events;
if (fd == listenFd) {
AcceptConnections(listenFd, epollFd);
continue;
}
auto it = connections.find(fd);
if (it == connections.end()) {
continue;
}
Connection& connection = it->second;
bool alive = true;
if (flags & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
alive = false;
}
if (alive && (flags & EPOLLIN)) {
alive = ReadAvailable(connection);
if (alive) {
HandleMessages(connection);
}
}
if (alive && (flags & EPOLLOUT)) {
alive = FlushOutput(connection);
}
if (!alive) {
epoll_ctl(
epollFd,
EPOLL_CTL_DEL,
fd,
nullptr
);
close(fd);
connections.erase(fd);
continue;
}
UpdateInterest(epollFd, connection);
}
}
真实项目还应处理连接超时、任务队列、日志与资源上限。
十、不要在 Reactor 线程执行耗时任务
下面的代码会让所有连接一起等待:
void HandleRequest(const Request& request)
{
QueryLargeDatabase();
RunImageRecognition();
CompressHugeFile();
}
事件循环应该只负责:
- 接收数据;
- 解析轻量协议;
- 分发任务;
- 收集结果;
- 发送响应。
耗时任务可以放进工作线程池:
Reactor 线程
↓
任务队列
↓
工作线程池
↓
完成队列
↓
唤醒 Reactor
工作线程不应直接并发修改连接对象,否则容易产生竞态条件。
十一、必须处理慢客户端
某个客户端如果长期不读取响应,服务器的输出缓冲区会不断增长。
可以设置高水位:
constexpr size_t MAX_OUTPUT_SIZE = 2 * 1024 * 1024;
if (connection.output.size() > MAX_OUTPUT_SIZE) {
CloseConnection(connection.fd);
}
更完善的背压策略包括:
- 暂停继续读取该连接;
- 限制单连接待处理请求数;
- 限制输出缓冲区大小;
- 为响应设置超时;
- 对连接进行流量配额控制。
高并发服务器最怕的往往不是连接数量,而是每个连接都能无限占用资源。
十二、ET 模式还要考虑公平性
“读取到 EAGAIN”可能让一个持续高速发送数据的客户端长期占用事件循环。
可以增加单轮处理预算:
constexpr size_t MAX_BYTES_PER_ROUND = 256 * 1024;
达到预算后暂停当前连接,让事件循环处理其他连接。必要时将该连接加入待继续处理队列。
性能优化不能以饿死其他连接为代价。
十三、上线前检查清单
- 所有 Socket 是否为非阻塞模式;
- ET 模式是否处理到
EAGAIN; accept()是否循环执行;- 是否正确保存半包;
- 是否处理部分发送;
- 无数据待写时是否移除
EPOLLOUT; - 是否屏蔽或规避
SIGPIPE; - 输入和输出缓冲区是否有上限;
- 耗时任务是否移出 Reactor;
- 是否存在连接超时;
- 是否限制单轮处理量;
- 文件描述符上限是否合理。
总结
epoll 提供的是高效的就绪通知机制,不是完整的高并发服务器。
稳定的 Reactor 还需要正确处理:
非阻塞 IO
半包与粘包
部分写入
状态管理
慢客户端
任务调度
超时回收
资源上限
真正决定服务器是否可靠的,往往不是 epoll_wait() 那几行代码,而是事件到达后的每一个边界条件。

330

被折叠的 条评论
为什么被折叠?



