Linux IO 多路转接详解:从 select、poll 到 epoll
本文介绍了Linux网络编程中的IO多路转接技术,重点比较了select、poll和epoll三种机制。文章首先说明传统单线程阻塞式服务器的局限性,指出多线程处理连接会面临线程爆炸、内存开销和上下文切换等问题。然后详细分析了三种IO复用技术:select采用fd_set结构但存在1024个fd限制和效率问题;poll改进为pollfd数组但仍需遍历所有fd;epoll通过注册机制实现高效通知,避免
引言
在 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));
// 处理客户端数据
}
这段代码的问题在于,accept 和 read 默认都是阻塞的。
如果服务器卡在某个客户端的 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 多路转接。
可以简单理解成下面这样:
也就是说,程序不再傻傻地阻塞在某一个 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 的大致流程是:
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;
}
相比 select,poll 的好处是:
- 不再受
FD_SETSIZE的限制; - 使用数组保存 fd,结构更清晰;
events和revents分开,用户关心的事件和实际发生的事件不会混在一起。
但是 poll 仍然有一个核心问题:
poll 返回后,用户依然需要遍历整个数组,找出哪些 fd 就绪。
所以 poll 虽然比 select 好一些,但在高并发场景下仍然不够理想。
三、重点理解 epoll
epoll 是 Linux 下最常用的高性能 IO 多路转接接口。
它和 select、poll 最大的区别是:
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 数组里放的就是已经就绪的事件。
这和 select、poll 很不一样。
select 和 poll 返回后,还要自己从一堆 fd 里面找谁就绪了。
而 epoll_wait 返回后,直接处理返回的这几个事件即可。
2. epoll 的工作流程
epoll 编程的基本流程如下:
代码层面可以理解成:
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 重新传给内核。
select 和 poll 每调用一次,都要把 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 不会卡住,而是返回错误,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。
这时我们就知道:
当前数据已经读完了,可以退出读取循环了。
四、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 模式、线程池服务器、异步网络库,都会轻松很多。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)