I/O多路复用之select:从原理到实战
说到网络编程,很多新手第一个接触的I/O模型就是select。我记得刚入行那会儿,看到select的代码总觉得有点玄乎——一堆宏、一个位图、还有那个让人摸不着头脑的fd_set。后来在项目中踩过几次坑,才真正理解了它的设计哲学。
今天我们就来彻底搞懂select。我会从函数原型讲起,再到实际编码,最后带你写一个完整的TCP回显服务器。
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 | 不是文件描述符数量,是最大值+1 |
| readfds | 监听可读事件的集合 | 可传入NULL,表示不关心可读 |
| writefds | 监听可写事件的集合 | 可传入NULL |
| exceptfds | 监听异常事件的集合 | 一般传NULL,用得少 |
| timeout | 超时时间 | NULL表示阻塞等待,0表示立即返回 |
这里有个坑,我当年就犯过。nfds参数传的是最大文件描述符 + 1,不是文件描述符的总数。比如你监听了fd 3、5、7,那nfds就得传8。为什么?因为select内部要遍历从0到nfds-1的所有fd,传大了浪费性能,传小了漏掉监听。
fd_set操作:位图的秘密
fd_set本质上是一个位图。在Linux内核里,它就是一个long类型的数组。每个bit对应一个文件描述符。比如bit 0对应fd 0,bit 1对应fd 1,以此类推。
操作fd_set有四个宏,记住就行:
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是否在集合中
嗯,这里要注意:select返回后,fd_set会被内核修改。只有那些就绪的fd才会保留在集合中,没就绪的会被清除。所以每次调用select前,都得重新把关心的fd加进去。这个特性让很多人写代码时容易忘,我见过不少线上bug就是这么来的。
select的优缺点
说实话,select是个老古董了。它诞生于BSD系统,距今快40年了。但老不代表没用,很多嵌入式系统、老旧代码还在用。我们来客观分析一下:
优点
- 跨平台性好:几乎所有操作系统都支持select
- 编程模型简单:就几个宏,容易上手
- 超时控制精准:可以精确到微秒级
- 同时监听多种事件:读、写、异常一次搞定
缺点
- 文件描述符数量有限:默认1024,改内核参数可以调大,但本质还是受限
- 每次都要重新设置fd_set:麻烦,容易出错
- O(n)的轮询复杂度:不管有多少fd就绪,都要遍历全部
- fd_set有拷贝开销:每次调用select,都要把fd_set从用户态拷贝到内核态
说白了,select适合连接数不多(几百个以内)的场景。如果你要处理上万连接,还是用epoll吧。但作为学习I/O多路复用的入门,select是绕不开的。
实战:基于select的TCP回显服务器
光说不练假把式。我们来写一个完整的TCP回显服务器。所谓回显,就是客户端发什么,服务器原样返回什么。
先画个流程图,看看整体逻辑:
好,现在上代码。我会把关键部分拆开讲:
#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 listen_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
// 1. 创建监听socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket");
exit(1);
}
// 2. 设置端口复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(listen_fd);
exit(1);
}
// 4. 开始监听
if (listen(listen_fd, MAX_CLIENTS) < 0) {
perror("listen");
close(listen_fd);
exit(1);
}
printf("服务器启动,监听端口 %d\n", PORT);
// 5. 初始化fd_set
fd_set read_fds, all_fds;
int max_fd = listen_fd;
int client_fds[MAX_CLIENTS];
FD_ZERO(&all_fds);
FD_SET(listen_fd, &all_fds);
for (int i = 0; i < MAX_CLIENTS; i++) {
client_fds[i] = -1;
}
// 6. 主循环
while (1) {
read_fds = all_fds; // 每次都要重新拷贝
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
if (ret < 0) {
perror("select");
break;
} else if (ret == 0) {
printf("select超时,继续等待...\n");
continue;
}
// 7. 检查监听fd是否有新连接
if (FD_ISSET(listen_fd, &read_fds)) {
client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue;
}
printf("新客户端连接: %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 把新客户端加入数组
int i;
for (i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == -1) {
client_fds[i] = client_fd;
break;
}
}
if (i == MAX_CLIENTS) {
printf("客户端数量已达上限,拒绝连接\n");
close(client_fd);
} else {
FD_SET(client_fd, &all_fds);
if (client_fd > max_fd) {
max_fd = client_fd;
}
}
}
// 8. 检查所有客户端fd
for (int i = 0; i < MAX_CLIENTS; i++) {
int fd = client_fds[i];
if (fd == -1) continue;
if (FD_ISSET(fd, &read_fds)) {
memset(buffer, 0, BUFFER_SIZE);
int n = recv(fd, buffer, BUFFER_SIZE - 1, 0);
if (n <= 0) {
// 客户端断开连接
printf("客户端 %d 断开连接\n", fd);
close(fd);
FD_CLR(fd, &all_fds);
client_fds[i] = -1;
} else {
// 回显数据
printf("收到数据: %s", buffer);
send(fd, buffer, n, 0);
}
}
}
}
// 清理
close(listen_fd);
return 0;
}
代码写完了,我们来跑一下。启动服务器后,用telnet或者nc连接:
# 终端1:启动服务器
$ ./select_echo_server
服务器启动,监听端口 8888
# 终端2:连接客户端
$ nc localhost 8888
hello
hello
world
world
看到没?客户端发什么,服务器就回什么。这就是回显服务器。
代码要点总结
- 端口复用:SO_REUSEADDR一定要设,否则服务器重启时会报"Address already in use"
- fd_set拷贝:每次select前都要重新拷贝,因为select会修改原集合
- 超时处理:select返回0表示超时,可以根据业务需求做心跳检测
- 客户端管理:用数组保存所有客户端fd,方便遍历和管理
- 断开检测:recv返回0表示对方关闭连接,记得清理资源
好了,select的内容就讲到这里。代码虽然简单,但麻雀虽小五脏俱全。你把这个例子吃透了,后面学epoll会轻松很多。
公众号:蓝海资料掘金营,微信deep3321