8. I/O多路复用(上):select()函数原理、优缺点、基于select的并发服务器
好,咱们今天聊一个真正让网络编程上台阶的东西——I/O多路复用。
说实话,我刚开始学网络编程那会儿,最头疼的就是并发。用多进程吧,资源开销大;用多线程吧,锁和同步又让人头大。后来我师父跟我说:“你去看看select。”嗯,这一看,就打开了新世界的大门。
8.1 为什么需要I/O多路复用?
先想一个问题:一个服务器要同时处理多个客户端连接,怎么办?
最简单的办法——每个连接开一个进程或线程。但问题是,连接一多,系统就扛不住了。你想想看,1000个客户端就要1000个进程,光是上下文切换就能把CPU拖死。
那有没有一种办法,用一个进程/线程同时监控多个socket?
有。这就是I/O多路复用。
说白了,就是让内核帮我们盯着多个文件描述符(socket),一旦哪个有数据来了,就通知我们。我们只需要在通知到来时去处理就行了。
核心思想: 一个线程 + 一个select调用 = 同时管理成百上千个连接。
8.2 select()函数原理
select是I/O多路复用里最经典的一个。虽然现在很多人说它老了,但它的设计思想至今仍在影响epoll、kqueue这些后来者。
8.2.1 函数原型
#include <sys/select.h>
#include <sys/time.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
参数看着多,其实不难理解:
- nfds:要监控的最大文件描述符 + 1。注意是+1,不是直接传最大值。
- readfds:监控哪些fd可读(有数据到达)。
- writefds:监控哪些fd可写(发送缓冲区有空闲)。
- exceptfds:监控哪些fd有异常。
- timeout:超时时间。传NULL表示一直等,传0表示立即返回(轮询模式)。
返回值:成功时返回就绪的文件描述符数量,超时返回0,出错返回-1。
我的小经验: 实际项目中,readfds用得最多,writefds偶尔用,exceptfds我几乎没用过。别把精力浪费在不常用的参数上。
8.2.2 fd_set操作宏
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是否在集合中(通常用于检查哪个fd就绪了) |
嗯,这里要注意:每次调用select之前,都要重新设置fd_set。因为select返回后,内核会修改这个集合,只保留就绪的fd。
我曾经踩过的坑: 刚开始写select服务器时,我忘了每次循环重新FD_SET所有fd,结果第二次调用select时,集合里只剩上次就绪的那几个fd了。排查了半天才发现问题。记住:select会修改fd_set,每次都要重新初始化!
8.2.3 select的工作流程
我画了一张图,帮你理清select的完整流程:
流程其实很清晰:
- 初始化fd_set,把所有要监控的fd加进去。
- 调用select,把控制权交给内核。
- 内核遍历所有fd,检查是否有数据到达。
- 如果有就绪的fd,select返回;否则阻塞等待。
- 返回后,用FD_ISSET遍历检查哪些fd就绪了,然后处理。
- 回到第1步,重新开始。
8.3 select的优缺点
任何技术都有两面性。select用了这么多年,大家还在用,说明它有不可替代的地方;但大家又都在往epoll迁移,说明它确实有硬伤。
8.3.1 优点
- 跨平台性好:几乎所有操作系统都支持select,包括Windows、Linux、macOS。
- 使用简单:API设计直观,容易上手。
- 超时控制方便:timeout参数可以精确控制等待时间。
- 适合少量连接:如果并发连接数不超过几百个,select完全够用。
8.3.2 缺点
- 最大fd数量限制:
FD_SETSIZE通常为1024,意味着最多监控1024个fd。虽然可以改,但改了也不推荐。 - 每次都要重新设置fd_set:内核会修改集合,导致每次调用前都要重新FD_SET,效率低。
- 线性遍历效率低:内核需要遍历所有fd来检查就绪状态,连接越多越慢。
- fd_set复制开销大:每次调用select,都要把整个fd_set从用户态复制到内核态。
- 只返回就绪数量,不返回具体fd:你得自己遍历所有fd去检查,O(n)复杂度。
一句话总结: select适合连接数不多(几百以内)的场景。连接数上千?还是用epoll吧。
8.4 基于select的并发服务器实现
光说不练假把式。咱们写一个完整的echo服务器,用select同时处理多个客户端。
8.4.1 服务器代码
#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 <sys/select.h>
#define PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_fd, max_fd, activity, i, valread;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 客户端socket数组,-1表示空闲
int client_sockets[MAX_CLIENTS];
for (i = 0; i < MAX_CLIENTS; i++) {
client_sockets[i] = -1;
}
// 创建服务器socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置端口复用,避免"Address already in use"
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt failed");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
if (listen(server_fd, 3) < 0) {
perror("listen failed");
exit(EXIT_FAILURE);
}
printf("服务器启动,监听端口 %d\n", PORT);
while (1) {
fd_set readfds;
FD_ZERO(&readfds);
// 添加服务器socket到集合
FD_SET(server_fd, &readfds);
max_fd = server_fd;
// 添加所有客户端socket到集合
for (i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (sd > 0) {
FD_SET(sd, &readfds);
if (sd > max_fd) {
max_fd = sd;
}
}
}
// 调用select,等待活动
activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (activity < 0) {
perror("select error");
continue;
}
// 检查是否有新连接
if (FD_ISSET(server_fd, &readfds)) {
client_fd = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen);
if (client_fd < 0) {
perror("accept failed");
continue;
}
printf("新客户端连接: %s:%d\n",
inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 把新客户端加入数组
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == -1) {
client_sockets[i] = client_fd;
break;
}
}
// 如果客户端满了,拒绝连接
if (i == MAX_CLIENTS) {
printf("客户端已满,拒绝连接\n");
close(client_fd);
}
}
// 检查所有客户端是否有数据
for (i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (sd > 0 && FD_ISSET(sd, &readfds)) {
valread = read(sd, buffer, BUFFER_SIZE);
if (valread == 0) {
// 客户端断开连接
printf("客户端断开: %d\n", sd);
close(sd);
client_sockets[i] = -1;
} else {
// 收到数据,原样返回(echo)
buffer[valread] = '\0';
printf("收到: %s", buffer);
send(sd, buffer, valread, 0);
}
}
}
}
return 0;
}
8.4.2 代码要点解析
这段代码有几个关键点,我跟你聊聊:
- 客户端数组管理:用一个固定大小的数组保存所有客户端socket,-1表示空闲位置。简单但有效。
- 每次循环重新设置fd_set:因为select会修改集合,所以每次都要重新FD_ZERO和FD_SET。
- max_fd的更新:每次都要重新计算最大fd值,因为新连接加入后max_fd可能变大。
- 双重检查:先检查server_fd是否有新连接,再遍历客户端检查数据。
我个人的习惯: 生产环境中,我会把客户端数组改成动态扩容的链表或动态数组,而不是固定大小。但教学代码里,固定数组更容易理解。
8.5 避坑指南
最后,分享几个我踩过的坑,你遇到了能少走弯路:
- 忘记重新设置fd_set:这是最常见的错误。每次循环都要重新FD_ZERO和FD_SET。
- max_fd忘记+1:select的第一个参数是最大fd+1,不是最大fd本身。
- 没有处理EINTR错误:select被信号中断时会返回-1,errno为EINTR。需要重新调用select。
- 客户端数组满了不处理:如果客户端数量超过MAX_CLIENTS,要优雅地拒绝新连接,而不是直接崩溃。
我曾经踩过的坑: 有一次线上服务器突然不响应新连接了,排查了半天才发现是客户端数组满了,但代码里没有处理这种情况,新连接被accept了但没地方存,导致文件描述符泄漏。从那以后,我每次写select服务器都会加上客户端数量限制和拒绝逻辑。
好了,select的内容就聊到这儿。它的原理不复杂,但细节很多。建议你亲手把上面的代码跑一遍,改一改,看看效果。只有亲手写过,才能真正理解select的精髓。
公众号:蓝海资料掘金营,微信deep3321