网络编程之IO多路复用

本文详细介绍了Linux下的I/O复用技术,包括阻塞与非阻塞、IO多路复用的概念。重点讨论了select和epoll的工作原理及其区别,epoll以其高效性成为Linux平台的优选。文中还分析了各种服务器设计模型的优缺点,特别是单线程多路I/O复用结合多线程读写业务的模型在企业服务器中的应用。

首先了解一下什么是流,什么是I/O

  1. 可以进行I/O操作的内核对象。
  2. 比如文件,管道,套接字等都会有流的概念。
  3. 流的入口一般是文件描述符,在Linux中一切皆文件。
  4. 文件的读写需要通过流来进行,而对流的读写操作即为I/O

阻塞

在处理问题时我们一般选择阻塞的方式,节省CPU资源。但不是绝对的。

  1. 阻塞等待:此期间不占用CPU的时间。
  2. 非阻塞轮询:所谓轮询是指不停的询问,占用CPU资源。

当然阻塞的缺点也很明显,比如说我等待的资源刚好这一时刻只来了一个需要我处理的资源,我结束等待处理完成皆大欢喜。但是资源并不是一个接一个的,比如取快递来说,快递没来我在阻塞等待,突然100个快递一起来了,那我就有的忙了。这就是阻塞式等待的缺点。

IO多路复用

因为上述方法不能满足用户需求,所以就有了IO多路复用。
它既有阻塞等待CPU资源节约的优点,还能兼顾同一时刻响应多路请求。

select函数

select函数在并发请求时的作用:继续上述的取快递为例,快递没来之前我CPU处于阻塞休息的状态,突然有100个快递来了,这时候select监听到了有快递来了,但是它监听的数量有限一般就是1024个。select监听到了有快递来告诉我(CPU)要去取快递,但是select不会告诉我具体是哪个快递到了,所以我只能知道有快递到了,这时我再去便利这所有的快递挨个询问是哪个到了,把到了的快递取了(处理请求)。

具体工作如下:

while(1){
	select([]);阻塞
	for(int i = 0; i < maxSize; ++i){
		if(i == 数据){
			处理
		}
	}
}

epoll
虽然说我可以休息避免了不停的问,但是如果只来了3个快递,我还要挨个打电话去问,其余的1021次就是在浪费时间。

所以就有了epoll();

epoll就强大了,它的监听工作和select一样,但是他会同时告诉我有几个快递到了并且具体到是哪个快递。这样我CPU的工作量就大大减轻了。而且epoll所监听的个数往往比1024还要大很多。

epoll的伪代码如下:

while(1){
	需要处理的流[] = epoll_wait(epoll_fd);  // 阻塞
	for(int i = 0; 需要处理流.size; ++i){
		CPU处理 = 需要处理的流[i];
	}
}

epoll虽强但只是Linux独有的。而select是平台无关的。
像其他有用到epoll的都是对原生Linux C中epoll的封装。

什么是epoll

  1. 与select和poll一样,对 I /O多路复用的技术。
  2. 只关心“活跃的连接”,无需遍历不需要CPU处理的描述符。
  3. 能够处理大量的连接请求。(系统能够打开最大的文件个数)(linux下可以用 cat /proc/sys/fs/file-max 查看 一般远远大于1024)

epoll API

  1. 创建epoll:int epoll_create(int size);

  2. 控制epoll:int epoll_ctl(int epfd, int op, struct epoll_event *event);在这里插入图片描述在这里插入图片描述

  3. 等待epoll:int epoll_wait(int epfd, struct epoll_event *event, int maxevents, int timeout),在这里插入图片描述

  4. C语言epoll编程思路在这里插入图片描述

  5. epoll的两种触发模式:水平触发(LT)和边缘触发(ET)
    水平触发:内核会不断的抛出已经被激活的事件,直到该事件被用户处理完才能返回结果。类似于TCP是比较安全的,不会丢包的处理方式。
    边缘触发:内核只会抛出用户需要处理的事件,只会通知用户一次,后面不管用户处理还是不处理内核都不管。类似于UDP只管发不管你收到没收到,可能丢包的处理方式。

常见服务器的设计模型

单线程Accept

只适合学习的demo练习,实际企业不会使用这种设计模型。

单线程Accept + 多线程读写业务

类比与上一种优化了多个客户端的响应,但是线程之间的切换成本很高,也不实用。

单线程多路 I / O 复用

优点:解决了同时监听多个客户端的读写模型,该模型是阻塞式、非忙轮询的,不浪费CPU资源,对CPU的利用率较高。
缺点:虽然是监听了多个用户,但是同一时刻下正在处理的业务只能是一个,并发为1。当多个客户端同时访问时,由于该模型是串行处理的方式,会造成排队延迟的问题。

在客户端比较少的情况下可以使用。

单线程多路 I / O 复用 + 多线程读写业务(工作池)

较上一种模型降低了排队延迟,因为业务的处理交给了线程处理,主线程只负责分配任务然后响应客户端的读写请求。但是读写的处理并发为1,所以这种模型的并发量不是特别高,依然会出现排队延迟的问题。比单纯使用 IO多路复用的效率要高一些。

单线程多路 I / O 复用 + 多线程多路 I / O 复用(连接线程池)

优点:这种模型将读写的监听以及读写业务的处理都交给一个线程去做,主线程只负责分配资源,这样同一时刻的并发量就是N (线程池中线程的数量)。并发量相比于上述几种要高很多很多。CPU的利用率大大提高。如果线程池数量和CPU核数适配那么可以尝试将CPU核心与线程进行绑定,从而降低线程切换频率,极大的利用了CPU的资源。
缺点:虽然并发量显著提高,但是最高也不过是N,当有多个客户端请求同一个线程时依然会有排队等待的现象。实际上该模型就是 N × (单线程多路 I / O复用)。

目前大部分企业服务器的设计都用的该模型。

单线程多路 I / O 复用 + 多线程多路 I / O 复用(线程池)+ 多线程

模型分析:
①Server在启动监听之前,开辟固定数量(N)的线程,用Thead Pool线程池管理
②主线程main thread创l建listenFd之后,采用多路/O复用机制(如:select、epal)进行IO状态阻塞监控。有一个客户端Connect请求,I/O复用机制检测到ListenFd触发读事件,则进行Accept建立连接,并将新生成的connFd分发给Thread Pool中的某个线程进行监听。
③Thread Pool中的每个thread都启动多路 I/O复用机制(select、epoll,用来监听main thread建立成功并且分发下来的socket套接字。一旦其中某个被监听的客户端套接字触发 I/O读写事件,那么,会立刻开辟一个新线程来处理 I/O读写业务
④当某个读写线程完成当前读写业务,如果当前套接字没有被关闭,那么将当前客户端套接字如:ConnFd重新加回线程池的监控线程中,同时,自身线程自我销毁。

优点:在上一模型基础上,除了能够保证同时响应最高的并发数,又能够解决读写并行通道的局限问题。
同一时刻的读写并行通道,达到了最大化极限,一个客户端可以对应一个单独的执行流程处理读写业务,读写并行通道与客户端的数量1∶1关系。

缺点:过于理想化。因为要求CPU核心数数量足够大。
如果硬件CPU数量可数,那么该模型就造成大量的CPU切换的成本浪费。因为为了保证读写并行通道和客户端是1:1的关系,就要保证server开辟的thread的数量与客户端一致。

综上,
最适合企业服务器的设计模型就是单线程多路 I / O 复用 + 多线程多路 I / O 复用(连接线程池)

IO 多路复用 (I/O Multiplexing) 是一种允许多个 I/O 流共享同一个线程的技术,在网络编程中有重要作用。 ### 原理 IO 多路复用的原理是通过一个机制,让单线程可以同时监听多个文件描述符(如网络连接、文件、管道等)。程序监视多个文件描述符(通常是套接字),等待其中一个或多个文件描述符变为就绪状态。一旦某个文件描述符就绪,即该文件描述符上可以进行无阻塞读写操作,操作系统就会通知应用程序,然后应用程序对该文件描述符进行相应的读写操作,避免了线程阻塞在单个 I/O 操作上[^2][^3]。 ### 实现方式 常见的IO多路复用实现方式有 select、poll 和 epoll。虽然引用中未详细提及这些实现方式,但以下是它们的简单介绍: - **select**:它是最早的 IO 多路复用实现方式,通过一个 **fd_set** 数据结构来管理多个文件描述符,调用 select 函数时会将该数据结构从用户空间复制到内核空间,内核检查这些文件描述符的状态并返回就绪的文件描述符数量。不过它有文件描述符数量限制(一般为1024),每次调用都需要重新设置 **fd_set**。 ```python import select import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('localhost', 8888)) server.listen(5) inputs = [server] while True: readable, _, _ = select.select(inputs, [], []) for sock in readable: if sock is server: conn, addr = server.accept() inputs.append(conn) else: data = sock.recv(1024) if data: # 处理数据 pass else: inputs.remove(sock) sock.close() ``` - **poll**:它和 select 类似,但使用 **pollfd** 结构体数组来管理文件描述符,没有文件描述符数量的限制,不过仍然需要将数据从用户空间复制到内核空间。 ```c #include <stdio.h> #include <stdlib.h> #include <sys/poll.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> #define MAX_EVENTS 10 #define BUFFER_SIZE 1024 int main() { int server_fd, new_socket; struct sockaddr_in address; int opt = 1; int addrlen = sizeof(address); char buffer[BUFFER_SIZE] = {0}; // 创建套接字 if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { perror("socket failed"); exit(EXIT_FAILURE); } // 设置套接字选项 if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) { perror("setsockopt"); exit(EXIT_FAILURE); } address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; address.sin_port = htons(8888); // 绑定套接字 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); } struct pollfd fds[MAX_EVENTS]; fds[0].fd = server_fd; fds[0].events = POLLIN; for (int i = 1; i < MAX_EVENTS; i++) { fds[i].fd = -1; } while (1) { int ready = poll(fds, MAX_EVENTS, -1); if (ready == -1) { perror("poll"); exit(EXIT_FAILURE); } for (int i = 0; i < MAX_EVENTS; i++) { if (fds[i].fd == -1) continue; if (fds[i].revents & POLLIN) { if (fds[i].fd == server_fd) { if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) { perror("accept"); exit(EXIT_FAILURE); } for (int j = 1; j < MAX_EVENTS; j++) { if (fds[j].fd == -1) { fds[j].fd = new_socket; fds[j].events = POLLIN; break; } } } else { int valread = read(fds[i].fd, buffer, BUFFER_SIZE); if (valread <= 0) { close(fds[i].fd); fds[i].fd = -1; } else { // 处理数据 printf("Received: %s\n", buffer); } } } } } return 0; } ``` - **epoll**:它是 Linux 特有的高效 IO 多路复用机制,通过 **epoll_ctl** 函数来管理文件描述符,使用事件驱动的方式,内核会在文件描述符就绪时通过回调机制通知应用程序,避免了大量的文件描述符复制和遍历操作,性能较高,适合处理大量并发连接。 ```python import socket import select server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(('localhost', 8888)) server.listen(5) epoll = select.epoll() epoll.register(server.fileno(), select.EPOLLIN) try: while True: events = epoll.poll() for fileno, event in events: if fileno == server.fileno(): conn, addr = server.accept() epoll.register(conn.fileno(), select.EPOLLIN) else: sock = socket.fromfd(fileno, socket.AF_INET, socket.SOCK_STREAM) data = sock.recv(1024) if data: # 处理数据 pass else: epoll.unregister(fileno) sock.close() finally: epoll.unregister(server.fileno()) epoll.close() server.close() ``` ### 应用场景 - **高并发网络服务器**:如 Web 服务器、聊天服务器等,需要同时处理大量客户端连接,IO 多路复用可以让单线程处理多个连接,减少线程创建和切换的开销,提高服务器的并发处理能力。 - **实时监控系统**:监控多个设备或数据源的状态变化,当某个数据源有数据更新时及时处理。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值