一、IO多路复用简介
I/O多路复用(I/O Multiplexing)是一种高效的并发编程技术,旨在通过单个线程或进程同时管理多个I/O操作,从而提高系统的并发处理能力和资源利用率。
核心概念
I/O多路复用通过一种机制(如select、poll、epoll等)同时监听多个I/O事件(如可读、可写、异常等),并在事件就绪时通知应用程序进行处理。这种方式避免了为每个I/O操作创建独立的线程或进程,从而减少了系统开销和资源消耗。
简单来说就是一线程,多路IO,且收发互不影响。
多路:指多个I/O操作(如多个TCP连接或文件描述符)。
复用:指复用一个或少量线程来处理这些I/O操作。
二、IO多路复用的优势
减少线程/进程开销:通过复用单个线程或进程处理多个I/O操作,避免了频繁的线程创建、销毁和上下文切换。
提高并发性能:特别适合处理大量并发连接或I/O操作的场景,如网络服务器。
资源利用率高:减少了系统资源的浪费,提升了单机的性能极限。
三、IO多路复用的应用场景
网络服务器:需要同时处理大量客户端连接,如Web服务器、聊天服务器等。
高性能I/O操作:如文件读写、数据库连接等需要高效处理的场景。
多协议支持:同时处理多种网络协议的套接字。
四、IO多路复用的实现机制
在Linux系统中,常见的I/O多路复用机制包括:
select:最早的多路复用机制,使用位图表示文件描述符集合,但有文件描述符数量限制和性能问题。
poll:改进版select,使用数组表示文件描述符集合,无数量限制,但性能仍不如epoll。
epoll:Linux特有的高性能多路复用机制,使用事件驱动模型,支持边缘触发和水平触发模式,适合高并发场景。
在Linux发展过程中,select、poll 和 epoll 是三种用于处理并发网络连接的 I/O 复用机制。它们的出现和演变与 Linux 系统对于高并发、低延迟处理的需求密切相关。
select(最早的I/O复用机制)
1、历史
select()首次出现在1970年代末和1980年代初期,最早出现在POSIX标准中,并且在Unix系统中广泛使用。随着POSIX标准的普及(POSIX.1标准发布于1988年),它成为了跨平台的标准I/O复用方法。
select()最初是为了提供一种方法来监控多个文件描述符,尤其是网络套接字(socket)。它的目标是让应用程序可以同时等待多个连接事件(例如,某个socket是否准备好进行读取或写入)。
2、性能瓶颈
① 轮询机制:select()在内部使用轮询的方式检查每个文件描述符的状态。这意味着在大量文件描述符时,性能会显著下降。每次调用select()时,都会遍历整个文件描述符集合,效率较低。
② 文件描述符数量限制:select()的一个显著缺点是它限制了可监视的文件描述符数量。通常这个上限是1024(虽然可以通过调整系统参数来增加,但并非无限制)。
3、意义
select()的出现是Unix系统中多路复用I/O的第一次尝试,它提供了基于事件的非阻塞I/O操作,使得程序可以在等待多个事件发生时避免阻塞。
4、select的组成
如图,select的组成很简单!
1个函数:select()
1个类型:fd_set
4个宏定义:FD_ZERO, FD_SET, FD_CLR, FD_ISSET
(1)select()
select() 可以同时监控多个文件描述符的状态。具体来说,它可以监听一个文件描述符集合,判断每个文件描述符是否准备好进行读操作、写操作,或者是否发生异常。
select()函数中,有五个参数,分别对应:
最大fd+1(fd的范围)、可读集合、可写集合、出错集合、阻塞时长。
以下是声明文件中的定义。
看着很复杂?但实际上在代码中并没有这么夸张。
int maxfd;
fd_set rset, wset; //fd_set是一个结构体,因此可以这样声明
int nready = select(maxfd+1, &rset, &wset, NULL, NULL);
这样就成功调用了select函数了!
代码中的rset可以理解为一个用于存储信息的结构体,而timeout = NULL意味着无限阻塞,阻塞点为select有无信息返回。select() 在没有事件发生时会阻塞,直到文件描述符集合中的某些文件描述符变得“就绪”。
至于为何是maxfd+1,涉及到fd的分配机制:fd是一个个int型非负整数,从0开始分配(且0,1,2被系统自动分配给stdin, stdout, stderr)。因此从0开始的情况下,如果不+1,就会少遍历一个。
接着,让我们假设:
rset和wset集合中包含的fd(文件描述符)为:3,4,5,6,7,8。
其中,4和5可读,6和7可写。
那么,select会返回什么给nready呢?
select() 会返回就绪的文件描述符的数量,即在调用时满足条件的文件描述符数量。对于每种文件描述符集合,它会分别检查:
rset:select() 会检查哪些文件描述符是可读的。4 和 5 是可读的,所以有 2 个文件描述符满足条件。
wset:select() 会检查哪些文件描述符是可写的。6 和 7 是可写的,所以有 2 个文件描述符满足条件。
因此,nready 的值是满足条件的文件描述符的总数,即:
nready = 2 (可读的) + 2 (可写的) = 4
(2)fd_set
fd_set 是用于表示文件描述符集合的数据类型,通常定义在头文件 <sys/select.h> 中。
由头文件定义可知,fd_set 是一个用于存储文件描述符集合的结构体。它通过 fds_bits 或 __fds_bits 数组来表示文件描述符的状态,每个文件描述符对应一个二进制位;__fd_mask 是用于存储和操作这些位的类型(通常是 unsigned long);fds_bits[__FD_SETSIZE / __NFDBITS]是数组的大小,确保可以容纳足够的位来表示所有的文件描述符;__FDS_BITS(set) 宏提供了访问位数组的方式。
总的来说,fd_set是一个比特位集合。大小默认为1024。
我们通常使用四个宏来操作fd_set,实现添加、删除或检查文件描述符在集合中的状态。
(3)FD_ZERO, FD_SET, FD_CLR, FD_ISSET
select() 为了操作和管理文件描述符集合,提供了四个宏:FD_ZERO、FD_SET、FD_CLR 和 FD_ISSET。
头文件声明如下:
FD_ZERO(fd_set *set):初始化文件描述符集合,将集合中的所有文件描述符状态清零。
FD_ZERO(&set);
FD_SET(int fd, fd_set *set):将指定的文件描述符 fd 添加到文件描述符集合 set 中。
FD_SET(fd, &set);
FD_CLR(int fd, fd_set *set):从文件描述符集合 set 中移除指定的文件描述符 fd。
FD_CLR(fd, &set);
FD_ISSET(int fd, fd_set *set):检查文件描述符集合 set 中是否包含指定的文件描述符 fd。
if (FD_ISSET(fd, &set)) {
// 文件描述符 fd 在集合 set 中
}
5、案例
通过select()实现一个多客户端支持的 TCP 服务器,能够同时处理多个客户端的连接和数据传输。
完整代码
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main(){
// 创建 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 设置服务器地址
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //0.0.0.0
servaddr.sin_port = htons(2000); //0-1023
// 绑定 socket
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr)))
{
printf("bind failed:%s\n", strerror(errno));
close(sockfd);
return 1;
}
// 监听连接
listen(sockfd, 10);
printf("listen finished\n");
fd_set rfds, rset; //fd集合+可读集合
FD_ZERO(&rfds); //清空
FD_SET(sockfd,&rfds); //设置集合内fd
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int maxfd = sockfd;
while(1){
rset = rfds;
int nready = select(maxfd + 1, &rset, NULL, NULL, NULL); //返回集合总数
if (FD_ISSET(sockfd, &rset)){
int clientfd = accept(sockfd, (struct sockaddr*) & clientaddr, &len);
FD_SET(clientfd, &rfds);
if(clientfd > maxfd){ //考虑fd复用的情况,client连接断开后重连可能复用小fd
maxfd = clientfd;
}
printf("New client connected: %d\n", clientfd);
}
int i = 0;
for(i = sockfd+1; i <= maxfd+1; i++){
if(FD_ISSET(i, &rset)){
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if(count == 0){
printf("client disconnect: %d\n", i);
close(i);
FD_CLR(i, &rfds);
continue;
}
printf("RECV:%s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND:%d\n", count);
}
}
}
printf("listen finished\n");
getchar();
printf("exit\n");
return 0;
}
五、总结
Select是早期 Unix/Linux 系统中实现 I/O 多路复用的基础机制,它通过一个位图管理多个文件描述符,允许程序同时监听多个 I/O 事件(如可读、可写、异常等)。它的历史意义在于首次引入了 I/O 多路复用的概念,为后续更高效的机制(如 Poll 和 Epoll)奠定了基础。
然而,Select 存在明显的缺陷:一是文件描述符数量有限(通常为1024),无法满足高并发需求;二是每次调用都需要将文件描述符集合从用户空间拷贝到内核空间,且返回时需要遍历所有描述符,性能较低。
这些缺陷促使了 Poll 的诞生,后者在文件描述符数量和灵活性上进行了改进,为 I/O 多路复用技术的进一步发展铺平了道路。