IO多路复用(上):select系统调用
聊到高并发服务,就绕不开IO多路复用。很多新手问我:「老师,为什么不用多线程?每个连接一个线程不就行了?」
我刚开始做网络编程时也这么想。直到有一次,我负责一个在线棋牌游戏的后端,高峰期同时在线3000多人。如果用「一个连接一个线程」的模式,光线程切换就能把CPU吃满。那会儿我才真正意识到——IO多路复用不是可选项,是必选项。
select系统调用:最朴素的IO多路复用
select是IO多路复用的老前辈。它诞生于BSD系统,后来被POSIX标准化。说白了,它的作用就是:让一个进程同时监控多个文件描述符,等其中某个fd就绪了再通知你。
先看它的原型:
#include <sys/select.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
参数看着多,其实不难理解:
| 参数 | 含义 | 我踩过的坑 |
|---|---|---|
| nfds | 最大fd编号 + 1 | 很多人直接传FD_SETSIZE,其实传实际最大值效率更高 |
| readfds | 监控可读事件的fd集合 | 每次调用前必须重新设置,select会修改这个集合 |
| writefds | 监控可写事件的fd集合 | 别把写事件和「数据已发送」搞混了 |
| exceptfds | 监控异常事件的fd集合 | 实际用得少,但OOB数据必须靠它 |
| timeout | 超时时间,NULL表示阻塞等待 | 传{0, 0}就是轮询,小心CPU跑满 |
操作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是否在集合中
select的优缺点:没有银弹
任何技术都有两面性。select用了几十年,它的优缺点我总结一下:
优点
- 跨平台性好:几乎所有Unix/Linux系统都支持,Windows也有类似实现
- 接口简单:就几个宏加一个函数,上手快
- 超时控制精准:timeval结构体可以精确到微秒
缺点
- fd数量受限:FD_SETSIZE默认1024,改内核参数才能扩大
- 每次都要拷贝fd_set:从用户态到内核态,来回拷贝1024位,连接多了开销很大
- 线性扫描效率低:内核不知道哪个fd就绪了,只能遍历所有fd
- 就绪fd需要遍历查找:select返回后,你得遍历所有fd调用FD_ISSET
我的经验: select适合连接数在几百以内的场景。超过500个连接,我建议直接上epoll。曾经有个项目,用select扛800个连接,CPU占用率一直在30%以上,换成epoll后直接降到5%。
select实战:多客户端聊天室
光说不练假把式。咱们写一个简单的聊天室服务器,用select管理所有客户端连接。
核心逻辑就三步:
- 创建监听socket,加入readfds
- select等待事件
- 处理新连接或客户端消息
先看服务器主循环:
#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 MAX_CLIENTS 30
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_fds[MAX_CLIENTS];
fd_set readfds;
int max_fd, activity, i, valread;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 创建监听socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8888);
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
listen(server_fd, 3);
// 初始化客户端数组
for (i = 0; i < MAX_CLIENTS; i++) {
client_fds[i] = 0;
}
printf("聊天室服务器启动,端口: 8888\n");
while (1) {
// 清空并设置fd集合
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
max_fd = server_fd;
// 把活跃的客户端加入集合
for (i = 0; i < MAX_CLIENTS; i++) {
int sd = client_fds[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");
exit(EXIT_FAILURE);
}
// 处理新连接
if (FD_ISSET(server_fd, &readfds)) {
int new_socket = accept(server_fd,
(struct sockaddr *)&address,
(socklen_t*)&addrlen);
printf("新客户端加入: %s:%d\n",
inet_ntoa(address.sin_addr),
ntohs(address.sin_port));
// 把新客户端加入数组
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == 0) {
client_fds[i] = new_socket;
break;
}
}
}
// 处理客户端消息
for (i = 0; i < MAX_CLIENTS; i++) {
int sd = client_fds[i];
if (FD_ISSET(sd, &readfds)) {
valread = read(sd, buffer, BUFFER_SIZE);
if (valread == 0) {
// 客户端断开
getpeername(sd, (struct sockaddr*)&address,
(socklen_t*)&addrlen);
printf("客户端断开: %s:%d\n",
inet_ntoa(address.sin_addr),
ntohs(address.sin_port));
close(sd);
client_fds[i] = 0;
} else {
// 广播消息给所有其他客户端
buffer[valread] = '\0';
for (int j = 0; j < MAX_CLIENTS; j++) {
if (client_fds[j] != 0 && client_fds[j] != sd) {
send(client_fds[j], buffer, strlen(buffer), 0);
}
}
}
}
}
}
return 0;
}
💡 小技巧: 实际项目中,我会把客户端数组改成链表或动态数组,避免MAX_CLIENTS写死。另外,广播消息时最好加个换行符,不然客户端收到的消息会粘在一起。
这个聊天室虽然简单,但已经体现了select的核心用法:
- 一个主循环,管理所有连接
- 每次循环重新设置fd_set
- select返回后,遍历所有fd检查就绪状态
- 新连接和消息处理都在同一个线程里完成
嗯,这里要注意——select返回后,一定要遍历所有fd。你不能假设只有第一个fd就绪了。我曾经犯过这个错,结果客户端发消息时断时续,排查了半天才发现是遍历逻辑写错了。
这个流程图把select聊天室的核心逻辑串起来了。你仔细看——每次循环都要重新设置fd_set,这是select最烦人的地方,但也是必须遵守的规则。
核心要点回顾:
- select用fd_set管理fd,每次调用前必须重新设置
- 返回后fd_set被修改,只保留就绪的fd
- 适合连接数几百以内的场景
- 聊天室实战中,一个主循环搞定所有事情
select虽然老,但理解它对于学习epoll、kqueue这些高级IO多路复用机制非常有帮助。说白了,select就是IO多路复用的「入门课」,把这章吃透了,后面学epoll会轻松很多。