第7章:I/O多路复用(select)——从原理到实战
说实话,我在刚接触网络编程那会儿,最头疼的就是怎么同时处理多个客户端连接。早期的做法是每个连接开一个线程,但连接一多,线程切换的开销就让人崩溃。后来我遇到了select,才算是找到了一个轻量级的解决方案。
今天我们就来聊聊select。它虽然不算新潮,但绝对是理解I/O多路复用的基石。你想想看,一个进程能同时盯着几百个socket,哪个有数据就处理哪个——这不就是「多路复用」的精髓吗?
7.1 select函数原理
select的核心思想很简单:把一堆文件描述符交给内核,让内核帮我们监控它们的状态变化。一旦某个描述符可读、可写或发生异常,内核就通知我们。
来看它的函数原型:
#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。我刚开始总搞错这个值,后来习惯用
maxfd + 1来算。 - readfds:监控可读事件的描述符集合
- writefds:监控可写事件的描述符集合
- exceptfds:监控异常事件的描述符集合
- timeout:超时时间,NULL表示阻塞等待
返回值:成功时返回就绪的描述符数量,超时返回0,出错返回-1。
核心机制:select本质上是一个阻塞调用。内核会遍历你传入的所有描述符,检查它们的状态。一旦有描述符就绪,或者超时时间到了,select就会返回。
7.2 fd_set操作——位图的艺术
fd_set 其实就是一个位图(bitmap)。每个bit对应一个文件描述符。比如fd=5,就对应第5个bit。
系统提供了一组宏来操作这个位图:
void FD_ZERO(fd_set *set); // 清空集合
void FD_SET(int fd, fd_set *set); // 将fd加入集合
void FD_CLR(int fd, fd_set *set); // 将fd从集合移除
int FD_ISSET(int fd, fd_set *set); // 检查fd是否在集合中
我个人的习惯用法是这样的:
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds); // 把监听socket加入
FD_SET(clientfd, &readfds); // 把客户端socket也加入
// 调用select后,用FD_ISSET检查
if (FD_ISSET(sockfd, &readfds)) {
// 有新连接
}
避坑指南:select返回后,fd_set会被内核修改,只保留就绪的描述符。所以每次调用select前,都必须重新设置fd_set。我曾经因为这个bug排查了一整个下午……
7.3 select的优缺点——没有银弹
任何技术都有它的适用场景。select也不例外。
| 优点 | 缺点 |
|---|---|
| 跨平台支持好(几乎所有Unix/Linux都支持) | 监控的描述符数量有限制(默认1024) |
| API简单,容易上手 | 每次调用都要把fd_set从用户态拷贝到内核态 |
| 超时控制灵活 | 内核需要遍历所有描述符,O(n)复杂度 |
| 适合连接数不多的场景 | 返回后需要再次遍历才能知道哪些描述符就绪 |
说白了,select适合连接数在几百以内的场景。如果你要处理上万连接,那还是考虑epoll或kqueue吧。
7.4 select服务器实现——手写一个简易版
下面我写一个完整的select服务器示例。它支持多个客户端同时连接,并能回显客户端发送的消息。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <sys/time.h>
#define PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_fd, max_fd, activity;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 创建socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置端口复用
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
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");
exit(EXIT_FAILURE);
}
printf("服务器启动,监听端口 %d\n", PORT);
// 客户端socket数组
int client_sockets[MAX_CLIENTS];
for (int i = 0; i < MAX_CLIENTS; i++) {
client_sockets[i] = 0;
}
fd_set readfds;
while (1) {
// 清空集合
FD_ZERO(&readfds);
// 加入服务器socket
FD_SET(server_fd, &readfds);
max_fd = server_fd;
// 加入客户端socket
for (int 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,超时5秒
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
activity = select(max_fd + 1, &readfds, NULL, NULL, &timeout);
if (activity < 0) {
perror("select error");
continue;
}
if (activity == 0) {
printf("select超时,没有活动\n");
continue;
}
// 检查是否有新连接
if (FD_ISSET(server_fd, &readfds)) {
client_fd = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen);
if (client_fd < 0) {
perror("accept");
continue;
}
printf("新客户端连接,fd=%d\n", client_fd);
// 存入数组
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_sockets[i] == 0) {
client_sockets[i] = client_fd;
break;
}
}
}
// 检查客户端是否有数据
for (int i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if (FD_ISSET(sd, &readfds)) {
int valread = read(sd, buffer, BUFFER_SIZE);
if (valread == 0) {
// 客户端断开
printf("客户端断开,fd=%d\n", sd);
close(sd);
client_sockets[i] = 0;
} else {
// 回显数据
buffer[valread] = '\0';
printf("收到: %s", buffer);
send(sd, buffer, valread, 0);
}
}
}
}
close(server_fd);
return 0;
}
注意事项:
- 每次循环都要重新设置fd_set,因为select会修改它
- 客户端断开后要及时关闭socket并从数组中移除
- select的nfds参数一定要传最大的fd+1,否则内核可能漏掉某些描述符
7.5 select的核心流程图
下面我用一张SVG图来展示select的工作流程,方便你理解整个调用链路:
7.6 实战中的避坑指南
我在实际项目中用select踩过不少坑,这里分享几个最典型的:
- 忘记重新设置fd_set:select返回后,fd_set会被修改。如果你不重新设置,下次调用select时可能漏掉很多描述符。
- nfds参数传错:一定要传最大描述符+1,而不是描述符的总数。我见过有人传了100,但实际最大fd是500,结果内核只检查了前100个。
- 超时时间被修改:Linux下select会修改timeout结构体,记录剩余时间。如果你要重复使用同一个timeout,记得每次重新设置。
- 信号中断:select可能会被信号中断,返回-1并设置errno为EINTR。这时候应该重新调用select,而不是直接退出。
我的建议:如果你刚开始学网络编程,先用select把基础打牢。它虽然性能不如epoll,但逻辑清晰,容易调试。等你把select玩透了,再去看epoll、kqueue这些高级货,会发现很多概念都是相通的。
好了,select的内容就讲到这里。记住一句话:select是老前辈,但它的设计思想影响了后续所有的I/O多路复用技术。搞懂了select,你就掌握了网络编程的半壁江山。
公众号:蓝海资料掘金营,微信deep3321