💖 点赞 + 收藏 + 关注,Socket 工业级网络编程全套实战持续更新!
✨ 本专栏 全程手写源码、无阉割、纯工程
0. 前言
上一章我们掌握了阻塞 IO 与非阻塞 IO,同时发现纯轮询非阻塞套接字会造成 CPU 满载,无法直接用于正式项目。想要单线程管理多个文件描述符(TCP 连接、UDP、管道),就需要 IO 多路复用技术。
Linux 下 IO 多路复用存在三套经典 API:select、poll、epoll。学习顺序建议先吃透 select、poll,理解多路复用核心思想,再学习 epoll,否则很容易对就绪通知、水平触发、边缘触发理解不透彻。
本章将完成:
- select 函数原型、fd_set 文件描述符集合原理
- select 完整 TCP 多客户端服务端代码实现
- poll 接口讲解 + 同功能完整 Demo
- select /poll 底层原理、性能瓶颈对比
- 工业项目选型建议,常见 bug 与调试方案
核心思想:IO 多路复用,是把多个 fd 交给内核监控,内核阻塞等待,直到任意 fd 发生可读、可写、异常事件之后,再唤醒应用程序处理,避免用户态空轮询,大幅降低 CPU 占用。
1. select 函数详解
1.1 函数原型
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds:所有 fd 中最大值 + 1,内核用来循环遍历 fd 集合;
- readfds:监听可读事件 fd 集合(有数据、连接断开、新客户端 accept 都属于可读);
- writefds:监听可写事件 fd 集合(内核发送缓冲区有空,可以发送数据);
- exceptfds:监听异常事件 fd 集合(带外数据等,常规网络开发几乎不用,传 NULL);
- timeout:阻塞超时时间。
- NULL:永久阻塞,直到事件触发;
- tv_sec=0, tv_usec=0:非阻塞轮询;
- 设置时间:最长等待指定时长,超时后 select 返回 0。
1.2 fd_set 配套操作宏
fd_set 本质是一个位图,每一位代表一个 fd 是否被监听,提供 4 个标准宏操作:
FD_ZERO(fd_set *fdset); // 清空整个集合,初始化必须调用
FD_SET(int fd, fd_set *fdset); // 将fd加入监听集合
FD_CLR(int fd, fd_set *fdset); // 将fd从监听集合移除
FD_ISSET(int fd, fd_set *fdset); // 判断fd是否就绪(事件触发)
⚠️ 重大坑点:select 会修改传入的 fd_set!每次循环调用 select 前,都必须重新 FD_ZERO、FD_SET 组装 fd 集合,不能复用上次的集合。
经典限制:默认 fd_set 最大支持 1024 个 fd(FD_SETSIZE=1024),无法直接支持上万并发连接,这也是 select 最大短板。
2. 实战:select 实现单线程多客户端 TCP 服务端
业务逻辑说明:
- 创建监听 listen_fd,设置非阻塞,加入 read 监听集合;
- select 阻塞等待事件;
- listen_fd 可读 → accept 新连接,新 connfd 设置非阻塞,加入监听集合;
- 普通 connfd 可读 → read 读取数据,使用环形缓冲区 + 上一章协议解析处理;
- read 返回 0 / 读写异常 → close 关闭 fd,从 fd 集合移除;
- 简易回声服务,收到数据原路回复客户端。
select_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/select.h>
#define LISTEN_PORT 8888
#define BUF_MAX 256
#define MAX_CLIENTS 64
int SetSocketNonBlock(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0)
return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void)
{
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0)
{
perror("socket fail");
exit(EXIT_FAILURE);
}
// 端口复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(LISTEN_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
{
perror("bind fail");
close(listen_fd);
exit(EXIT_FAILURE);
}
if (listen(listen_fd, 5) < 0)
{
perror("listen fail");
close(listen_fd);
exit(EXIT_FAILURE);
}
SetSocketNonBlock(listen_fd);
printf("select tcp server run on port %d\n", LISTEN_PORT);
// 保存所有客户端fd,初始化为-1代表空闲
int client_fds[MAX_CLIENTS] = {0};
memset(client_fds, -1, sizeof(client_fds));
fd_set read_fds;
int max_fd = listen_fd;
while (1)
{
// 每次循环必须重置fd集合
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds);
max_fd = listen_fd;
// 将有效客户端fd加入监听集合
for (int i = 0; i < MAX_CLIENTS; i++)
{
if (client_fds[i] > 0)
{
FD_SET(client_fds[i], &read_fds);
if (client_fds[i] > max_fd)
max_fd = client_fds[i];
}
}
// 设置select超时 1s
struct timeval timeout = {1, 0};
int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
if (ret < 0)
{
perror("select fail");
continue;
}
else if (ret == 0)
{
// 超时,无事件,继续循环
continue;
}
// 事件就绪,判断是否是监听fd(新连接)
if (FD_ISSET(listen_fd, &read_fds))
{
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
if (connfd < 0)
{
perror("accept fail");
continue;
}
SetSocketNonBlock(connfd);
printf("new client connect: %s:%d, fd=%d\n",
inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);
// 存入客户端数组
int idx = -1;
for (int i = 0; i < MAX_CLIENTS; i++)
{
if (client_fds[i] == -1)
{
idx = i;
break;
}
}
if (idx == -1)
{
printf("max client limit!\n");
close(connfd);
}
else
{
client_fds[idx] = connfd;
}
}
// 遍历处理各个客户端fd可读事件
for (int i = 0; i < MAX_CLIENTS; i++)
{
int fd = client_fds[i];
if (fd == -1)
continue;
if (FD_ISSET(fd, &read_fds))
{
uint8_t buf[BUF_MAX] = {0};
int rlen = read(fd, buf, BUF_MAX - 1);
if (rlen > 0)
{
printf("fd:%d recv:%s\n", fd, buf);
// 回声:原路发回
write(fd, buf, rlen);
}
else if (rlen == 0)
{
printf("client fd:%d disconnect\n", fd);
close(fd);
client_fds[i] = -1;
}
else
{
if (errno != EAGAIN && errno != EWOULDBLOCK)
{
perror("read error");
close(fd);
client_fds[i] = -1;
}
}
}
}
}
close(listen_fd);
return 0;
}
编译运行:
gcc select_server.c -o select_server
./select_server
可以使用多个 telnet 或者 nc 工具同时连接 8888 端口,验证单线程多客户端并发能力。
3. poll 接口详解,解决 select 部分缺陷
3.1 poll 函数原型
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
- fds:pollfd 结构体数组,存放需要监听的 fd、监听事件、返回就绪事件;
- nfds:数组元素个数;
- timeout:超时时间,单位 ms。-1 永久阻塞,0 非阻塞轮询。
pollfd 结构体定义:
struct pollfd
{
int fd; // 需要监听的文件描述符
short events; // 我们想要监听的事件(POLLIN 可读 / POLLOUT 可写)
short revents; // 内核返回:实际触发的就绪事件
};
常用事件宏:
- POLLIN:可读事件(有数据、连接关闭、新连接)
- POLLOUT:可写事件(发送缓冲区空闲)
- POLLERR:fd 发生错误,自动监听,无需手动设置 events
- POLLHUP:对端挂断连接
poll 对比 select 的改进点:
- 不再有 1024 fd 上限限制(受系统进程最大 fd 限制);
- 不会修改原始监听数组,不用每次循环重建 fd 集合;
- 不需要计算 max_fd,接口使用更加友好;
共同短板:select/poll 都需要遍历全部 fd 集合查找就绪 fd,时间复杂度 O (n),上万连接场景性能急剧下降。
4. poll 简易 TCP 回声服务端 Demo
poll_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#include <poll.h>
#define LISTEN_PORT 8888
#define BUF_MAX 256
#define MAX_FD 64
int SetSocketNonBlock(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0)
return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void)
{
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0)
{
perror("socket fail");
exit(EXIT_FAILURE);
}
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(LISTEN_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(listen_fd, 5);
SetSocketNonBlock(listen_fd);
printf("poll tcp server run on port %d\n", LISTEN_PORT);
struct pollfd fds[MAX_FD];
memset(fds, 0, sizeof(fds));
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
while (1)
{
int ret = poll(fds, MAX_FD, 1000);
if (ret < 0)
{
perror("poll error");
continue;
}
else if (ret == 0)
{
continue;
}
// 监听fd就绪,新连接
if (fds[0].revents & POLLIN)
{
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cli_len);
if (connfd < 0)
{
perror("accept");
continue;
}
SetSocketNonBlock(connfd);
printf("new client %s:%d fd=%d\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), connfd);
// 寻找空闲位置存入新fd
for (int i = 1; i < MAX_FD; i++)
{
if (fds[i].fd == 0)
{
fds[i].fd = connfd;
fds[i].events = POLLIN;
break;
}
}
}
// 遍历所有客户端fd
for (int i = 1; i < MAX_FD; i++)
{
int fd = fds[i].fd;
if (fd <= 0)
continue;
if (fds[i].revents & POLLIN)
{
uint8_t buf[BUF_MAX] = {0};
int rlen = read(fd, buf, BUF_MAX - 1);
if (rlen > 0)
{
printf("fd:%d recv: %s\n", fd, buf);
write(fd, buf, rlen);
}
else if (rlen == 0)
{
printf("client fd:%d close\n", fd);
close(fd);
fds[i].fd = 0;
fds[i].events = 0;
}
else
{
if (errno != EAGAIN && errno != EWOULDBLOCK)
{
close(fd);
fds[i].fd = 0;
fds[i].events = 0;
}
}
}
// 异常挂断
if (fds[i].revents & (POLLERR | POLLHUP))
{
close(fd);
fds[i].fd = 0;
fds[i].events = 0;
}
}
}
close(listen_fd);
return 0;
}
5. select VS poll 横向对比
| 特性 | select | poll |
|---|---|---|
| fd 上限 | 默认 1024,修改内核编译才能扩大 | 无硬性上限,受系统最大进程 fd 限制 |
| 参数处理 | 每次调用都会改写 fd_set,每次循环需要重建集合 | 不会修改原始 events,仅修改 revents,复用数组 |
| 时间精度 | timeval 微秒级 | timeout 毫秒级 |
| 性能 | O (n),全部 fd 轮询 | O (n),全部 fd 轮询 |
| 跨平台 | Windows/Linux 都支持,兼容性最好 | Linux 原生,Windows 支持差 |
✅ 适用场景:
- 嵌入式网关、小型服务,并发连接几十~几百:select /poll 完全够用,代码简单易维护;
- 高并发上万长连接:两者都不推荐,使用 epoll。
6. 工程踩坑汇总
- select 每次循环必须 FD_ZERO 重建 fd 集合,忘记重建会导致 fd 监听丢失;
- 监听 fd 和业务 connfd 建议全部设置为非阻塞,防止 accept/read 阻塞卡死主线程;
- 就绪事件判断,必须使用
revents & POLLIN,不要直接判断相等; - 连接关闭(POLLHUP)一定要 close fd,并且将 pollfd 数组 fd 置 0,避免野 fd 持续监听;
- select/poll 返回就绪后,循环 read 直到返回 EAGAIN,防止缓冲区残留半包数据;
- 大量连接场景,每次就绪都要遍历整个 fd 数组,CPU 开销随连接数量线性上涨;
- 注意系统限制:
ulimit -n修改进程最大打开文件描述符数量,防止 fd 耗尽。
7. 本章小结
本章讲解 IO 多路复用基础模型 select 和 poll,掌握 fd_set、pollfd 用法,实现单线程多客户端 TCP 服务端,横向对比两套 API 优缺点。 核心要点:
- IO 多路复用由内核阻塞监听多个 fd,事件就绪后唤醒应用处理,降低 CPU 占用;
- select 存在 1024 默认 fd 上限,poll 解除该限制;
- select/poll 就绪后都需要遍历全部 fd 查找就绪连接,复杂度 O (n),不适合超高并发;
- 所有 connfd 建议统一非阻塞,配合环形缓冲区处理半包、粘包。
遗留思考: 如何解决 select/poll O (n) 遍历的性能瓶颈?epoll 是 Linux 专为高并发设计的 IO 多路复用方案,支持就绪回调通知,时间复杂度 O (1)。 下一章:epoll 完整实战,水平触发 LT、边缘触发 ET,Reactor 模型,高并发服务架构。
下一章:epoll 高并发 IO 多路复用完整实战|LT 水平触发 & ET 边缘触发、Reactor 单线程模型