引言

在 Linux 网络编程中,IO 多路转接是一个绕不开的话题。很多高性能服务器,比如 Web 服务器、网关服务器、长连接服务器,底层都离不开它。

所谓 IO 多路转接,本质上解决的是一个问题:

一个线程如何同时管理多个 socket 连接?

如果没有 IO 多路转接,我们很容易写出一个连接一个线程的服务器。刚开始看起来很自然,但连接数一多,线程数量、内存消耗和上下文切换都会变得很夸张。

所以 Linux 提供了 select、poll、epoll 这一类接口,让我们可以用一个线程同时监听多个文件描述符,哪个 fd 有事件,就处理哪个 fd。


一、为什么需要 IO 多路转接?

先看一个最普通的 TCP 服务器逻辑:

while (true)
{
    int sock = accept(listenSock, nullptr, nullptr);

    char buffer[1024];
    read(sock, buffer, sizeof(buffer));

    // 处理客户端数据
}

这段代码的问题在于,acceptread 默认都是阻塞的。

如果服务器卡在某个客户端的 read 上,而这个客户端又迟迟不发数据,那么服务器就无法继续处理其他客户端连接。

于是有人会想到:那我每来一个连接,就创建一个线程处理它。

while (true)
{
    int sock = accept(listenSock, nullptr, nullptr);

    std::thread t([sock]() {
        char buffer[1024];

        while (true)
        {
            ssize_t n = read(sock, buffer, sizeof(buffer));
            if (n <= 0) break;

            // 处理数据
        }

        close(sock);
    });

    t.detach();
}

这种方式在连接数少的时候没问题,但如果连接数达到几千、几万,就会出现明显问题:

问题 说明
线程数量太多 一个连接一个线程,连接数越多线程越多
内存占用大 每个线程都有自己的栈空间
上下文切换频繁 CPU 在大量线程之间切换,真正处理业务的时间变少
扩展性差 不适合高并发长连接场景

所以我们需要一种更好的方式:

不让每个连接都占用一个线程,而是让一个线程同时观察多个连接。

这就是 IO 多路转接。

可以简单理解成下面这样:

用户程序

select / poll / epoll

内核帮忙监听多个 fd

fd 1 可读

fd 2 无事件

fd 3 可写

处理 fd 1

处理 fd 3

也就是说,程序不再傻傻地阻塞在某一个 fd 上,而是把一批 fd 交给内核:

这些 fd 你帮我看着,谁有事件了你告诉我。

二、select 和 poll 简单理解

Linux 下比较常见的 IO 多路转接接口有三个:

select  ->  poll  ->  epoll

它们的目标是一样的,都是监听多个 fd 的事件。区别在于实现方式和效率不同。


1. select

select 是比较早期的 IO 多路转接接口。

函数原型如下:

#include <sys/select.h>

int select(
    int nfds,
    fd_set* readfds,
    fd_set* writefds,
    fd_set* exceptfds,
    struct timeval* timeout
);

select 使用 fd_set 来保存要监听的文件描述符。

常用操作如下:

FD_ZERO(fd_set* set);        // 清空集合
FD_SET(int fd, fd_set* set); // 添加 fd
FD_CLR(int fd, fd_set* set); // 删除 fd
FD_ISSET(int fd, fd_set* set); // 判断 fd 是否就绪

比如监听标准输入:

#include <iostream>
#include <unistd.h>
#include <sys/select.h>

int main()
{
    while (true)
    {
        fd_set readfds;
        FD_ZERO(&readfds);
        FD_SET(0, &readfds); // 0 表示标准输入

        int ret = select(1, &readfds, nullptr, nullptr, nullptr);

        if (ret < 0)
        {
            perror("select");
            break;
        }

        if (FD_ISSET(0, &readfds))
        {
            char buffer[1024] = {0};
            ssize_t n = read(0, buffer, sizeof(buffer) - 1);

            if (n > 0)
            {
                std::cout << "输入内容: " << buffer << std::endl;
            }
        }
    }

    return 0;
}

select 的大致流程是:

没有

准备 fd_set

调用 select

内核检查 fd 集合

是否有 fd 就绪

阻塞等待

select 返回

用户遍历 fd_set

处理就绪 fd

select 的缺点也很明显:

第一,默认最多监听 1024 个 fd。

第二,每次调用 select 之前,都要重新设置 fd_set,因为 select 返回时会修改传入的集合。

第三,select 返回后,只告诉你“有 fd 就绪了”,但是不会直接告诉你具体是哪几个 fd,所以还要自己遍历一遍。

for (int fd = 0; fd <= maxfd; fd++)
{
    if (FD_ISSET(fd, &readfds))
    {
        // 处理就绪 fd
    }
}

fd 数量少的时候还好,fd 数量多的时候,这种线性遍历就比较浪费。


2. poll

poll 可以看成是 select 的改进版。

函数原型如下:

#include <poll.h>

int poll(struct pollfd* fds, nfds_t nfds, int timeout);

核心结构体是 pollfd

struct pollfd
{
    int fd;         // 要监听的 fd
    short events;   // 关心的事件
    short revents;  // 实际发生的事件
};

常见事件如下:

事件 含义
POLLIN 可读
POLLOUT 可写
POLLERR 错误
POLLHUP 对端关闭或挂起
POLLNVAL 非法 fd

监听标准输入的例子:

#include <iostream>
#include <unistd.h>
#include <poll.h>

int main()
{
    struct pollfd fds[1];
    fds[0].fd = 0;
    fds[0].events = POLLIN;

    while (true)
    {
        int ret = poll(fds, 1, -1);

        if (ret < 0)
        {
            perror("poll");
            break;
        }

        if (fds[0].revents & POLLIN)
        {
            char buffer[1024] = {0};
            ssize_t n = read(0, buffer, sizeof(buffer) - 1);

            if (n > 0)
            {
                std::cout << "输入内容: " << buffer << std::endl;
            }
        }
    }

    return 0;
}

相比 selectpoll 的好处是:

  • 不再受 FD_SETSIZE 的限制;
  • 使用数组保存 fd,结构更清晰;
  • eventsrevents 分开,用户关心的事件和实际发生的事件不会混在一起。

但是 poll 仍然有一个核心问题:

poll 返回后,用户依然需要遍历整个数组,找出哪些 fd 就绪。

所以 poll 虽然比 select 好一些,但在高并发场景下仍然不够理想。


三、重点理解 epoll

epoll 是 Linux 下最常用的高性能 IO 多路转接接口。

它和 selectpoll 最大的区别是:

select 和 poll 每次都要把一批 fd 传给内核;
epoll 是先把 fd 注册到内核中,之后事件来了再通知用户。

这就像两种不同的工作方式。

select/poll 更像这样:

用户:这些 fd 你帮我检查一下。
内核:好,我检查完了,有几个能用了。
用户:是哪几个?我自己再遍历看看。

epoll 更像这样:

用户:这些 fd 以后都交给你管理。
内核:好。
用户:有事件了吗?
内核:有,fd 3 和 fd 7 就绪了,直接拿去处理。

1. epoll 的三个核心接口

使用 epoll 主要掌握三个函数:

epoll_create / epoll_create1
epoll_ctl
epoll_wait

第一个是创建 epoll 实例:

#include <sys/epoll.h>

int epfd = epoll_create1(0);

这里返回的 epfd 也是一个文件描述符,可以把它理解成一个“事件管理器”。

第二个是把 fd 添加、修改或者删除到 epoll 中:

int epoll_ctl(
    int epfd,
    int op,
    int fd,
    struct epoll_event* event
);

常见操作有:

操作 说明
EPOLL_CTL_ADD 添加 fd
EPOLL_CTL_MOD 修改 fd 关心的事件
EPOLL_CTL_DEL 删除 fd

添加一个 socket 到 epoll 中:

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;

epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);

第三个是等待事件发生:

int epoll_wait(
    int epfd,
    struct epoll_event* events,
    int maxevents,
    int timeout
);

使用方式一般是:

struct epoll_event events[1024];

int n = epoll_wait(epfd, events, 1024, -1);

for (int i = 0; i < n; i++)
{
    int fd = events[i].data.fd;

    if (events[i].events & EPOLLIN)
    {
        // fd 可读
    }
}

注意这里非常关键:

epoll_wait 返回后,events 数组里放的就是已经就绪的事件。

这和 selectpoll 很不一样。

selectpoll 返回后,还要自己从一堆 fd 里面找谁就绪了。
epoll_wait 返回后,直接处理返回的这几个事件即可。


2. epoll 的工作流程

epoll 编程的基本流程如下:

创建 listenSock

创建 epoll 实例

把 listenSock 加入 epoll

epoll_wait 等待事件

事件来自谁

listenSock 就绪

clientSock 就绪

accept 新连接

把 clientSock 加入 epoll

read 读取数据

代码层面可以理解成:

int epfd = epoll_create1(0);

// 监听 socket 加入 epoll
epoll_ctl(epfd, EPOLL_CTL_ADD, listenSock, &ev);

while (true)
{
    int n = epoll_wait(epfd, events, 1024, -1);

    for (int i = 0; i < n; i++)
    {
        if (events[i].data.fd == listenSock)
        {
            // 有新连接,accept
        }
        else
        {
            // 普通客户端发数据,read
        }
    }
}

这就是 epoll 服务器的核心骨架。


3. epoll 为什么适合高并发?

epoll 的优势主要体现在三个方面。

第一,epoll 不需要每次都把所有 fd 重新传给内核。

selectpoll 每调用一次,都要把 fd 集合传进去;而 epoll 是通过 epoll_ctl 先把 fd 注册进去,后续只需要调用 epoll_wait 等事件。

第二,epoll 返回的就是就绪事件。

如果有 10000 个连接,但此时只有 50 个连接发数据,那么 epoll 返回的就是这 50 个就绪事件。程序只需要处理这 50 个,而不是把 10000 个连接全部扫一遍。

第三,epoll 更适合“连接很多,但活跃连接相对较少”的场景。

比如:

  • 聊天服务器;
  • 网关服务器;
  • 长连接服务;
  • 游戏服务器;
  • WebSocket 服务。

这些场景下,连接数量可能很多,但不是每个连接每一刻都在发数据。epoll 的优势就会比较明显。


4. epoll 的 LT 和 ET 模式

epoll 有两种常见工作模式:

LT:Level Trigger,水平触发
ET:Edge Trigger,边缘触发
LT 模式

LT 是 epoll 的默认模式。

它的特点是:

只要 fd 上还有数据没读完,epoll_wait 就会一直提醒你。

比如 socket 缓冲区里有 100 字节数据,你这次只读了 50 字节,那么下次调用 epoll_wait 时,它还会继续通知你这个 fd 可读。

LT 模式比较容易使用,不容易漏事件,适合初学阶段。

ET 模式

ET 模式需要加上 EPOLLET

ev.events = EPOLLIN | EPOLLET;

ET 的特点是:

只有 fd 状态发生变化时才通知一次。

也就是说,当 socket 从“没有数据”变成“有数据”时,epoll 通知你一次。
如果这次你没有把数据读完,后面它不一定继续通知你。

所以 ET 模式下,通常必须配合非阻塞 IO,并且一次性读到 EAGAIN

典型写法如下:

while (true)
{
    ssize_t n = read(fd, buffer, sizeof(buffer));

    if (n > 0)
    {
        // 处理读取到的数据
    }
    else if (n == 0)
    {
        // 对端关闭连接
        close(fd);
        break;
    }
    else
    {
        if (errno == EAGAIN || errno == EWOULDBLOCK)
        {
            // 数据已经读完
            break;
        }
        else
        {
            // 读取出错
            close(fd);
            break;
        }
    }
}

LT 和 ET 可以这样对比:

模式 特点 优点 缺点
LT 只要有数据就一直通知 容易使用,不容易出错 通知次数可能更多
ET 状态变化时只通知一次 通知次数少,效率更高 写法要求高,必须读到 EAGAIN

刚开始学习 epoll 时,建议先理解 LT,再去写 ET。


5. 为什么 ET 一定要配合非阻塞?

这是 epoll 里非常容易踩坑的地方。

ET 模式要求我们一次性把数据读完,但是如果 fd 是阻塞的,就可能出现下面的问题:

第一次 read:读到一部分数据
第二次 read:继续读到数据
第三次 read:缓冲区没数据了
程序阻塞住

程序一旦阻塞在某个 fd 上,就失去了 IO 多路转接的意义。

所以 ET 模式一般要满足三个条件:

ET 模式
+
非阻塞 fd
+
循环读取直到 EAGAIN

设置非阻塞的代码如下:

#include <fcntl.h>

int SetNonBlock(int fd)
{
    int flags = fcntl(fd, F_GETFL);
    if (flags < 0)
    {
        return -1;
    }

    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

当非阻塞 fd 没有数据可读时,read 不会卡住,而是返回错误,并设置 errnoEAGAINEWOULDBLOCK

这时我们就知道:

当前数据已经读完了,可以退出读取循环了。


四、epoll 服务器代码示例

下面写一个简单的 epoll echo 服务器。
它的功能很简单:

  • 监听指定端口;
  • 接收客户端连接;
  • 读取客户端发来的数据;
  • 把数据原样返回给客户端。

这份代码不是工业级服务器,但非常适合理解 epoll 的基本结构。

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>

#include <unistd.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

static const int MAX_EVENTS = 1024;
static const int BUFFER_SIZE = 4096;

int SetNonBlock(int fd)
{
    int flags = fcntl(fd, F_GETFL);
    if (flags < 0)
    {
        return -1;
    }

    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

bool AddFdToEpoll(int epfd, int fd, uint32_t events)
{
    epoll_event ev;
    ev.events = events;
    ev.data.fd = fd;

    return epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) == 0;
}

void CloseFd(int epfd, int fd)
{
    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
    close(fd);
}

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " <port>" << std::endl;
        return 1;
    }

    int port = std::stoi(argv[1]);

    int listenSock = socket(AF_INET, SOCK_STREAM, 0);
    if (listenSock < 0)
    {
        perror("socket");
        return 1;
    }

    int opt = 1;
    setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    sockaddr_in local;
    std::memset(&local, 0, sizeof(local));

    local.sin_family = AF_INET;
    local.sin_addr.s_addr = INADDR_ANY;
    local.sin_port = htons(port);

    if (bind(listenSock, reinterpret_cast<sockaddr*>(&local), sizeof(local)) < 0)
    {
        perror("bind");
        close(listenSock);
        return 1;
    }

    if (listen(listenSock, 128) < 0)
    {
        perror("listen");
        close(listenSock);
        return 1;
    }

    SetNonBlock(listenSock);

    int epfd = epoll_create1(0);
    if (epfd < 0)
    {
        perror("epoll_create1");
        close(listenSock);
        return 1;
    }

    if (!AddFdToEpoll(epfd, listenSock, EPOLLIN | EPOLLET))
    {
        perror("epoll_ctl listenSock");
        close(listenSock);
        close(epfd);
        return 1;
    }

    std::cout << "epoll server started, port: " << port << std::endl;

    epoll_event events[MAX_EVENTS];

    while (true)
    {
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);

        if (n < 0)
        {
            if (errno == EINTR)
            {
                continue;
            }

            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < n; i++)
        {
            int fd = events[i].data.fd;
            uint32_t ev = events[i].events;

            if (ev & (EPOLLERR | EPOLLHUP))
            {
                CloseFd(epfd, fd);
                continue;
            }

            if (fd == listenSock)
            {
                while (true)
                {
                    sockaddr_in peer;
                    socklen_t len = sizeof(peer);

                    int clientSock = accept(
                        listenSock,
                        reinterpret_cast<sockaddr*>(&peer),
                        &len
                    );

                    if (clientSock < 0)
                    {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                        {
                            break;
                        }

                        perror("accept");
                        break;
                    }

                    SetNonBlock(clientSock);

                    std::cout << "new client: "
                              << inet_ntoa(peer.sin_addr)
                              << ":"
                              << ntohs(peer.sin_port)
                              << ", fd = "
                              << clientSock
                              << std::endl;

                    AddFdToEpoll(epfd, clientSock, EPOLLIN | EPOLLET);
                }
            }
            else if (ev & EPOLLIN)
            {
                char buffer[BUFFER_SIZE];

                while (true)
                {
                    std::memset(buffer, 0, sizeof(buffer));

                    ssize_t size = read(fd, buffer, sizeof(buffer));

                    if (size > 0)
                    {
                        std::cout << "client fd " << fd
                                  << " says: " << buffer;

                        send(fd, buffer, size, 0);
                    }
                    else if (size == 0)
                    {
                        std::cout << "client fd " << fd
                                  << " closed" << std::endl;

                        CloseFd(epfd, fd);
                        break;
                    }
                    else
                    {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                        {
                            break;
                        }

                        perror("read");
                        CloseFd(epfd, fd);
                        break;
                    }
                }
            }
        }
    }

    close(listenSock);
    close(epfd);

    return 0;
}

编译:

g++ epoll_server.cc -o epoll_server

运行:

./epoll_server 8080

另开一个终端连接:

nc 127.0.0.1 8080

输入:

hello epoll

服务器收到数据后,会把内容原样返回。

这段代码的核心结构其实就是:

创建监听 socket
创建 epoll
把 listenSock 加入 epoll
循环调用 epoll_wait
如果是 listenSock 就绪,就 accept 新连接
如果是 clientSock 就绪,就 read 数据

五、select、poll、epoll 对比总结

最后用一张表总结一下:

对比项 select poll epoll
数据结构 fd_set 位图 pollfd 数组 内核事件表
fd 数量限制 默认 1024 无固定小上限 无固定小上限
是否每次传入全部 fd
返回后是否遍历所有 fd 否,只遍历就绪事件
高并发能力 一般 一般
跨平台性 较好 较好 Linux 专属
使用难度 简单 中等 中等偏高

可以这样理解:

select:比较老,简单但限制多。
poll:改进了 select 的 fd 数量限制,但仍然需要线性遍历。
epoll:Linux 下高并发服务器常用方案,更适合大量连接场景。

IO 多路转接的核心不是某个函数有多复杂,而是理解它背后的思想:

不要让程序阻塞在某一个 fd 上,而是让内核帮我们同时观察多个 fd。

而 epoll 的优势在于:

fd 先注册到内核,事件发生后,内核直接把就绪事件返回给用户。

所以在 Linux 高并发网络编程中,epoll 是非常重要的一块内容。
学会 epoll 之后,再去理解 Reactor 模式、线程池服务器、异步网络库,都会轻松很多。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐