【网络编程】IO多路转接——select
文章目录
- 1. 前言
- 2. select 函数
- 2.1 select 是如何实现IO转接的?
- 2.2 select 函数细节
- 2.3 文件描述符集合操作函数
- 3. select 使用
- 3.1 伪代码
- 3.2 完整代码
- 4. select 总结
- 4.1 优点
- 4.2 缺点
- 4.3 跨平台差异
- 4.4 检测行为与结果处理
- 4.5 适用场景
1. 前言
IO多路转接:
不再由应用程序自己监视客户端连接和数据通信,取而代之由内核代替应用程序监视文件
-
多进程/多线程的并发服务器
- 在进行套接字通信的时候有一些阻塞函数:accept,read/recv,write/send
- 我们需要不停地检测新的客户端连接:需要不停地调用accept,需要占用一个线程/进程进行检测
- 和客户端的连接建立成功了,通信
- 发送数据:write/send,如果写缓冲区写满,阻塞 -> 需要一个单独的线程/进程处理
- 接收数据:read/recv,对方不给当前终端发送数据,当前终端阻塞 -> 需要单独线程
- 处理数据的接收
-
总结:套接字通信过程中有大量的阻塞操作,需要多次线程/进程处理阻塞任务
-
细节分析:
- accept 为什么会阻塞:
- 使用 accept 读了用于监听的文件描述符对应的读缓冲区,检测过程是阻塞的
- read/recv 为什么会阻塞:
- 使用了这两个函数检测了通信的文件描述符的读缓冲区,检测过程是阻塞的
- write/send 为什么会阻塞:
- 使用了这两个函数检测了通信的文件描述符的写缓冲区,如果写满了就一直阻塞
- accept 为什么会阻塞:
-
结论:使用多线程/多进程处理并发,其实本质就是使用不同的线程/进程检测文件描述符的缓冲区
- 文件描述符:
- 通信的
- 监听的
- 缓冲区
- 读缓冲区
- 写缓冲区
- 文件描述符:
-
IO 多路转接就是调用一个系统函数委托内核帮助我们去检测程序中的一系列文件描述符的状态,内核检测完毕之后会给用户一个反馈,用户通过内核反馈就指定那些文件描述符有状态变化,有针对性的对这些文件描述符进行状态处理,在处理状态变化的文件描述符的时候:
- 内核检测到有新连接,建立新连接,调用
accept()
函数- 这个时候调用这个函数是不阻塞的
- 内核检测到通信的文件描述符读缓冲区有数据 ==> 对端给当前终端发送数据
- 需要使用
read()/recv()
接收数据 ==> 不阻塞
- 需要使用
- 内核检测到通信的文件描述符的写缓冲区可写
- 可以使用
write()/send()
发送数据 ==> 不阻塞
- 可以使用
- 内核检测到有新连接,建立新连接,调用
-
IO 多路转接接触到的有 3 个,需要掌握的有 2 个
- select(跨平台)
- poll(对select的改进)
- epoll(Linux)
2. select 函数
2.1 select 是如何实现IO转接的?
select
是一个跨平台的函数,Linux 和 Windows 平台都可以使用- 我们调用这个函数,该函数会调用相对应的平台的系统 API,委托操作系统执行某些操作
- 在调用
select
的时候需要通过参数的形式就将要检测的文件描述符的集合传递给内核,内核根据这个集合进行文件描述符的状态检测- 读集合:要检测这一系列文件描述符的读缓冲区(若干文件描述符 = 1个监听 + n个通信)
- 监听文件描述符,看是否有新客户端连接
- 通信的文件描述符,看是否有客户端数据到达
- 写集合:委托内核检测集合中的文件描述符对应的写缓冲区是否可写
- 通信的文件描述符
- 异常集合:检测集合中文件描述符进行读写操作的时候是否异常
- 读集合:要检测这一系列文件描述符的读缓冲区(若干文件描述符 = 1个监听 + n个通信)
- 内核根据传递的集合中的数据,对文件描述符表进行线性检测,如果有满足条件的文件描述符,内核会通知调用者
- 满足条件:
- 对于读集合:文件描述符对应的读缓冲区中有数据
- 对于写集合:文件描述符的写缓冲区可写
- 对于异常集合:读写操作出现了错误
- 内核如何通知调用者:
- 内核会将用户传递给内核的读/写/异常集合进行修改,得到最新的数据
- 满足条件:
- 最终用户得到的信息
- 知道委托内核检测是集合中一共有多少个文件描述符状态发生了变化
- 通过检测内核传出的读/写/异常集合可以判断出是哪个文件描述符发生了状态变化
2.2 select 函数细节
头文件:#include <sys/select.h
函数原型:int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
fd_set
是一种数据类型
struct timeval {long tv_sec; /* seconds */ 秒long tv_usec; /* microseconds */微秒};
- 参数
nfds
:这个值是检测的读/写/异常集合中最大的文件描述符值 +1- 读集合检测文件描述符:5,6,7,8
- 写集合检测文件描述符:9,10,11,12
- 异常集合检测文件描述符:5,6,7,8,9,10,11,12
- 根据上表描述,nfds == 12 + 1
- 内核遍历文件描述符表是线性遍历,nfds是遍历结束的标志
readfds
:读集合,存储若干文件描述符,并且都是检测他们的读缓冲区- 常用
- 两种情况:
- 判断有没有新连接
- 判断有没有通信数据
- 传入传出参数
- 传入的是委托内核检测的文件描述符集合
- 传出的是内核检测到的满足条件的文件描述符的集合
- 传出的文件描述符个数 <= 传入的文件描述符个数
writefds
:写集合,存储若干文件描述符,并且都是检测他们的写缓冲区是否可写- 一般情况下,文件描述符的写缓冲区都是可写的(有存储空间),因此这集合很少用
- 不检测指定为NULL
- 传入传出参数
- 传入的是委托内核检测的文件描述符集合
- 传出的是内核检测到的满足条件的文件描述符的集合
- 传出的文件描述符个数 <= 传入的文件描述符个数
exceptfds
:异常集合,检测集合中文件描述符有没有读写错误- 一般情况下,这个集合也很少用
- 不检测指定为NULL
- 传入传出参数
- 传入的是委托内核检测的文件描述符集合
- 传出的是内核检测到的满足条件的文件描述符的集合
- 传出的文件描述符个数 <= 传入的文件描述符个数
- timeout:表示一个时间段
- 因为
select
在检测文件描述符集合的时候需要时间,默认如果没有满足条件的文件描述符时会阻塞 - 如果值为0,函数调用之后马上返回
- 参数如果指定了一个时间段,并且在这个时间段中没有检测到满足条件的文件描述符,函数解除阻塞
- NULL,没有发现集合中满足条件的文件描述符函数就一直阻塞
- 因为
- 返回值:
-
0:检测完成之后,满足条件的文件描述符的总个数
- =0:没有检测到满足条件的文件描述符,超时时间到了,强制函数返回
- -1:函数调用失败
-
2.3 文件描述符集合操作函数
fd_set 类型数据操作函数:
void FD_CLR(int fd, fd_set *set);int FD_ISSET(int fd, fd_set *set);void FD_SET(int fd, fd_set *set);void FD_ZERO(fd_set *set);
- 将文件描述符 fd 从 set 集合中删除
void FD_CLR(int fd, fd_set *set);
- 判断文件描述符 fd 是不是在 set 集合中,如果在返回1,如果不在返回0
int FD_ISSET(int fd, fd_set *set);
- 将文件描述符 fd 添加到 set 集合中
void FD_SET(int fd, fd_set *set);
- 清空 set 集合中设置的所有数据,一般用于初始化
void FD_ZERO(fd_set *set);
fd_set 数据类型和文件描述符表有什么关系?
- 所有文件描述符都存储在文件描述符表中 —> 内核中
- 默认大小:1024个整数
- 数组下标:0-1023
- fd_set 记录者要委托内核检测哪一个文件描述符
- 如何记录的?
sizeof(fd_set)
= 128 字节 * 8 = 1024 bit- 从低地址位 -> 高地址位:0-1023
- 如何记录的?
- 结论:
fd_set
中每一个标志位和文件描述符表中的元素下标值一一对应
3. select 使用
3.1 伪代码
int main() {// 1. 创建监听的套接字int lfd = socket();// 2. 绑定bind();// 3. 设置监听listen();// 4. 初始化要检测的文件描述符集合fd_set reads, tmp;FD_ZERO(&reads); // 清零FD_SET(lfd, &reads); // 将lfd添加到要检测的读集合// 5. 使用select函数检测集合中文件描述符的状态// 如果要知道文件描述符持续的状态变化,就需要不停地检测int nfds = lfd;while(1) {// 不同的委托内核检测文件描述符的集合//tmp传入的时候代表委托内核检测的读集合,调用完毕,记录的是读缓冲区中有数据的文件描述符的集合tmp = reads;int num = select(nfds+1, &tmp, NULL, NULL, NULL);// 遍历文件描述符表(相当于),i 就是文件描述符中各个文件描述符的值for (int i = lfd; i <= nfds; i++) {// 有没有新连接if (i == lfd &FD_ISSET(lfd, &tmp)) {// 建立新连接,得到通信的cfdint cfd = accept(lfd, NULL, NULL);// 将 cfd 添加到检测的集合中,在下次调用select的时候就可以检测到了FD_SET(cfd, &reads);// 更新最大的文件描述符nfds = nfds < cfd ? cfd : nfds;}// 有没有通信的数据else {// 处理lfd,其余的文件描述符都是通信的,说明有客户端连接if (FD_ISSET(i, &tmp)) {// 接收数据int len = read(i, buf, sizeof(buf));if (len == 0) {// 客户端已经断开连接了// 通信的文件描述符从检测的集合中删除,下一轮就不检测了FD_CLR(i, &reads);close(i);}}}}}return 0;
}
select()
中使用 tmp = reads
进行备份:
在使用
select()
时,必须使用临时变量tmp = reads
来备份reads
,不能直接将reads
传入select()
,否则会导致监听列表被破坏。
-
select()
会修改传进去的fd_set
变量-
它会把没有“就绪”的文件描述符从集合中移除。
-
所以传进去的变量在函数调用后会被破坏。
-
-
reads
是我们维护的完整监听列表-
包括监听套接字
lfd
、所有连接的客户端套接字cfd
。 -
它代表“当前关心的所有文件描述符”。
-
-
如果直接传
reads
,调用完select()
后就残缺了-
下一轮就无法继续监听那些暂时“没反应”的客户端。
-
会导致部分客户端永远不再被检测,出现通信问题。
-
-
解决办法就是:每次调用前备份一份
tmp = reads; select(nfds+1, &tmp, NULL, NULL, NULL);
-
这样被
select()
修改的是tmp
,原始的reads
不变。 -
后续你仍然可以在
reads
中增删客户端连接。
-
3.2 完整代码
select-server.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/select.h>int main(int argc, char *argv[]) {// 1. 创建监听的套接字int lfd = socket(AF_INET, SOCK_STREAM, 0);if (lfd == -1) {perror("socket");exit(0);}// 2. 绑定struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_port = htons(8989);addr.sin_addr.s_addr = INADDR_ANY;int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));if (ret == -1) {perror("bind");exit(0);}// 3. 设置监听ret = listen(lfd, 128);if (ret == -1) {perror("listen");exit(0);}// 4. 初始化检测的集合fd_set reads, tmp;FD_ZERO(&reads);FD_SET(lfd, &reads);int nfds = lfd;// 5. 不停地委托内核检测集合中的文件描述符的状态while (1) {tmp = reads;int num = select(nfds + 1, &tmp, NULL, NULL, NULL);//printf("num = %d\n", num);for (int i = 0; i <= nfds; i++) {if (i == lfd && FD_ISSET(lfd, &tmp)) {// 建立新连接,这里的调用是不阻塞的int cfd = accept(lfd, NULL, NULL);// cfd 添加到检测的原始集合中FD_SET(cfd, &reads);nfds = nfds < cfd ? cfd : nfds;} else {// 通信if (FD_ISSET(i, &tmp)) {char buf[1024];memset(buf, 0, sizeof(buf));int len = recv(i, buf, sizeof(buf), 0);if (len == 0) {printf("客户端已经断开连接...\n");// 将i从原始检测集合中删除,下次不再检测FD_CLR(i, &reads);close(i);} else if (len > 0) {printf("recv data: %s\n", buf);send(i, buf, len, 0);} else {perror("recv");break;}}}}}// 6. 断开连接close(lfd);return 0;
}
client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>int main() {// 1. 创建通信的套接字int cfd = socket(AF_INET, SOCK_STREAM, 0);if (cfd == -1) {perror("socket");exit(0);}// 2. 连接服务器struct sockaddr_in addr;addr.sin_family = AF_INET; // IPv4addr.sin_port = htons(8989); // 网络字节序// 将 192.168.69.141 转换为大端的整型数inet_pton(AF_INET, "192.168.100.141", &addr.sin_addr.s_addr);int ret = connect(cfd, (struct sockaddr*)&addr, sizeof(addr));if (ret == -1) {perror("connect");exit(0);}// 3. 通信int num = 0;while (1) {// 发送数据char buf[1024];sprintf(buf, "hello world, %d, ......", num++);send(cfd, buf, strlen(buf) + 1, 0);// 接收数据memset(buf, 0, sizeof(buf));int len = recv(cfd, buf, sizeof(buf), 0);if (len == 0) {printf("服务器已经断开连接...\n");break;} else if (len > 0) {printf("recv buf: %s\n", buf);} else {perror("recv");break;}sleep(1);}// 6. 断开连接close(cfd);return 0;
}
4. select 总结
4.1 优点
-
实现了 I/O 多路复用
- 能同时检测多个文件描述符是否准备好 I/O 操作。
-
跨平台支持好
- 几乎所有主流操作系统(如 Linux、Windows)都支持。
4.2 缺点
-
参数复杂、使用繁琐
- 需要设置多个
fd_set
(readfds、writefds、exceptfds)和timeval
超时时间。
- 需要设置多个
-
性能差,效率低
- 检测方式是线性遍历,fd 数量一多就会导致效率严重下降。
-
fd 数量有限制
- Linux 下最多只能监听 1024 个文件描述符(
FD_SETSIZE
限制)。
- Linux 下最多只能监听 1024 个文件描述符(
-
频繁的数据拷贝开销大
- 每次调用:
- 传入时:用户态的 fd_set 要复制到内核态。
- 传出时:内核态处理完还要将结果返回到用户态。
- 每次调用:
4.3 跨平台差异
平台 | 第一个参数意义 |
---|---|
Linux | 设置为监控集合中 最大 fd + 1 |
Windows | 无意义,固定写 0 即可 |
4.4 检测行为与结果处理
- 检测方式:
- 内核会扫描用户传入的 fd_set 中的所有 fd,逐个检查状态。
- 返回值:
- 返回有状态变化的文件描述符数量。
- 进一步处理:
- 用户需要再从 fd_set 中判断 具体哪个 fd 发生了状态变化(例如使用
FD_ISSET
)。
- 用户需要再从 fd_set 中判断 具体哪个 fd 发生了状态变化(例如使用
4.5 适用场景
-
select
适合 fd 数量较少、跨平台需求强 的场景。 -
在高并发或 fd 数量大的场合推荐使用更高效的机制如:
poll
(无上限,但仍线性检测)epoll
(Linux 特有,高效、适合大并发)kqueue
(FreeBSD、macOS)