epoll 能监听十万连接,为何你的服务器仍会卡死?Reactor 实战避坑

不少高并发服务器教程会给出这样的结论:

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() 那几行代码,而是事件到达后的每一个边界条件。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值