一.阻塞非阻塞IO
阻塞IO:阻塞 I/O 是指当一个 I/O 操作被请求时,调用该操作的线程或进程会一直等待,直到 I/O 操作完成后才继续执行。例如,调用 recvfrom()等函数时,如果没有数据到达,进程会一直阻塞等待,直到数据到达并读取完成。
阻塞IO缺点:阻塞IO存在两次上下文切换,且一般单线程只能监控单连接。第一次阻塞发生在用户太读取数据数据并未准备好,用户态进程会被挂起并切换至内核态。第二次阻塞发生在从内核RingBuffer拷贝数据至用户空间的时候,数据拷贝完毕会发生内核态到用户空间的上下文切换。
非阻塞IO:非阻塞 I/O 是指 I/O 操作在被调用时,如果无法立即完成,则不会让线程或进程阻塞,而是立刻返回。调用方可以重复请求或轮询该操作的状态,直到 I/O 操作完成。例如, recv() 进行非阻塞读取时,如果没有数据到达,则立即返回一个错误,调用方可以在稍后再次调用检查数据是否已到达。非阻塞IO的CPU开销较大,且存在一次进程上下切换,因为Linux内核本质上不存在异步IO(内核缓存区拷贝数据时)。
IO多路复用:I/O 多路复用(I/O Multiplexing)是一种在单个线程或进程中同时监控多个 I/O 描述符(如 socket)的技术。通过 I/O 多路复用,程序可以在一个线程中等待多个 I/O 操作完成,无需为每个 I/O 操作创建独立的线程或进程,从而高效地处理大量并发 I/O 请求。
二.Select
select()
函数用于监控多个文件描述符(如 sockets)是否发生了 I/O 事件(如可读、可写、异常),从而实现 I/O 多路复用。它会阻塞等待,直到至少一个文件描述符状态发生变化或超时后返回。
cpp
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int select(
int nfds, // 监控的文件描述符集里最大文件描述符加1
fd_set *readfds, // 监控有读数据到达文件描述符集合,引用类型的参数
fd_set *writefds, // 监控写数据到达文件描述符集合,引用类型的参数
fd_set *exceptfds, // 监控异常发生达文件描述符集合,引用类型的参数
struct timeval *timeout); // 定时阻塞监控时间
fd_set 文件描述符集合
select 函数参数中的 fd_set 类型表示文件描述符的集合。
由于文件描述符 fd 是一个从 0 开始的无符号整数,所以可以使用 fd_set 的二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。比如比如设 fd_set 长度为 1 字节,则一个 fd_set 变量最大可以表示 8 个文件描述符。当 select 返回 fd_set = 00010011 时,表示文件描述符 1、2、5 已经就绪。
cpp
#include <sys/select.h>
int FD_ZERO(int fd, fd_set *fdset); // 将 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset); // 将 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set); // 将 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1
使用select监控多个文件描述符的Demo如下:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAX_SOCKETS 5
#define PORT 8080
int main() {
int sockets[MAX_SOCKETS]; // 保存5个socket文件描述符
fd_set readfds; // 用于存储需要监控的文件描述符集合
struct sockaddr_in address;
int max_sd, activity, new_socket;
char buffer[1024];
// 创建并绑定 5 个 socket
for (int i = 0; i < MAX_SOCKETS; i++) {
if ((sockets[i] = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("Socket creation failed");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT + i); // 每个socket不同端口
if (bind(sockets[i], (struct sockaddr*)&address, sizeof(address)) < 0) {
perror("Bind failed");
close(sockets[i]);
exit(EXIT_FAILURE);
}
if (listen(sockets[i], 3) < 0) {
perror("Listen failed");
close(sockets[i]);
exit(EXIT_FAILURE);
}
}
printf("Waiting for connections...\n");
while (1) {
// 初始化文件描述符集合
FD_ZERO(&readfds);
max_sd = 0;
// 将每个 socket 文件描述符加入到集合中
for (int i = 0; i < MAX_SOCKETS; i++) {
FD_SET(sockets[i], &readfds);
if (sockets[i] > max_sd) {
max_sd = sockets[i];
}
}
// 使用 select 监控文件描述符集合中的变化
activity = select(max_sd + 1, &readfds, NULL, NULL, NULL);
if ((activity < 0) && (errno != EINTR)) {
perror("Select error");
}
// 检查每个 socket 是否有活动(数据可读或新连接)
for (int i = 0; i < MAX_SOCKETS; i++) {
if (FD_ISSET(sockets[i], &readfds)) {
// 接受新的连接
if ((new_socket = accept(sockets[i], NULL, NULL)) < 0) {
perror("Accept error");
exit(EXIT_FAILURE);
}
printf("New connection on socket %d\n", sockets[i]);
// 读取数据
int valread = read(new_socket, buffer, 1024);
if (valread > 0) {
buffer[valread] = '\0';
printf("Received: %s\n", buffer);
}
close(new_socket); // 关闭新连接
}
}
}
// 关闭 sockets
for (int i = 0; i < MAX_SOCKETS; i++) {
close(sockets[i]);
}
return 0;
}
三.Select性能分析
1)调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间,select 执行完后,还需要将 fd_set 从内核空间拷贝回用户空间,高并发场景下这样的拷贝会消耗极大资源;(epoll 优化为不拷贝)
2)进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪;(epoll 优化为异步事件通知)
3)select 只返回就绪文件的个数,具体哪个文件可读还需要遍历;(epoll 优化为只返回就绪的文件描述符,无需做无效的遍历)
2.同时能够监听的文件描述符数量太少。受限于 sizeof(fd_set) 的大小,在编译内核时就确定了且无法更改。一般是 32 位操作系统是 1024,64 位是 2048。(poll、epoll 优化为适应链表方式)