IO多路复用

Select
结构体fd_set 标记fd, 就是一个fd的集合;一个1024位的位集合;
image

常用宏和函数: FD_ZERO FD_SET FD_CLR FD_ISSET select

int selection = select(nfds, &fdread, NULL, NULL, &tv);
最大fd值+1(内核扫描范围0-nfds-1),,可读集合,,可写集合,,异常集合,,超时参数(阻塞时长)

  1. 把 fdread 位图拷贝到内核空间
  2. 遍历 [0, nfds) 范围内的所有fd
  3. 对每个被置位的fd,检查其Socket接收缓冲区:
    • 有数据 → 该位保持 1(就绪)
    • 无数据 → 该位变 0(未就绪) ← ⚠️ fdread 被内核修改!
  4. 把当前线程挂在该socket的等待队列上 sleep
  5. 数据到达(网卡中断 → 协议栈 → socket缓冲区)→ 唤醒线程
    或 超时 → 唤醒线程
  6. 把修改后的 fdread 拷贝回用户空间
  7. 返回就绪的fd总数

Select的优点:实现多路复用
Select的局限:1024上限fd数;; 每次调用需要重置fd_set;; 内核需要遍历从0-ndfs的socket(全量扫描)

	fd_set rfds, rset;

	FD_ZERO(&rfds);
	FD_SET(sockfd, &rfds);

	int maxfd = sockfd;

	while (1) {
		rset = rfds;

		int nready = select(maxfd+1, &rset, NULL, NULL, NULL);

		if (FD_ISSET(sockfd, &rset)) { // accept 处理监听的fd

			int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
			printf("accept finshed: %d\n", clientfd);

			FD_SET(clientfd, &rfds); //新的fd需要加入到select监听中,查看后续是否有数据 
			
			if (clientfd > maxfd) maxfd = clientfd;//由于clientfd加入,maxfd可能变化
		}

		// recv  处理数据fd/ clientfd
		int i = 0;
		for (i = sockfd+1; i <= maxfd;i ++) { // i fd
            //遍历所有可能的clientfd 是否被set了
			if (FD_ISSET(i, &rset)) {
				char buffer[1024] = {0};
				
				int count = recv(i, buffer, 1024, 0);
				if (count == 0) { // disconnect
					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);
			}
		}
	}

=======================================================

poll
使用一个struct pollfd 数组代替了 三个fd_set(读写错)
struct pollfd {
int fd; // 要监视的文件描述符
short events; // 感兴趣的事件(POLLIN、POLLOUT 等)
short revents; // 实际发生的事件(由内核填充)
};

工作流程:
1用户程序填充一个 pollfd 数组,每个元素指定要监视的 fd 和感兴趣的事件。
2调用 poll() 时,内核遍历整个数组,检查每个 fd 是否有事件发生(线性扫描,时间复杂度 O(n))。
3若有事件,内核在对应元素的 revents 字段标记事件类型。
4返回后,用户程序再次遍历数组,检查每个 revents 是否非零,从而处理就绪的 fd。

poll的优点:没有最大 fd 数量的硬限制(仅受内存限制);相比select参数简约了。
poll的缺点:每次调用都需要将整个 pollfd 数组从用户态复制到内核态,返回时又将 revents 复制回用户态,数据拷贝开销大。
随着监视的 fd 数量增长,性能线性下降(O(n) 扫描 + O(n) 拷贝)。

	struct pollfd fds[1024] = {0};  监听最多1024个fd的情况
	fds[sockfd].fd = sockfd;
	fds[sockfd].events = POLLIN;

	int maxfd = sockfd;

	while (1) {

		int nready = poll(fds, maxfd+1, -1);

		if (fds[sockfd].revents & POLLIN) {  //实际事件是否和预期相等

			int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
			printf("accept finshed: %d\n", clientfd);

			//FD_SET(clientfd, &rfds); // 
			fds[clientfd].fd = clientfd;
			fds[clientfd].events = POLLIN;
			
			if (clientfd > maxfd) maxfd = clientfd;

		}
	
		int i = 0;
		for (i = sockfd+1; i <= maxfd;i ++) { // i fd

			if (fds[i].revents & POLLIN) {

				char buffer[1024] = {0};
				
				int count = recv(i, buffer, 1024, 0);
				if (count == 0) { // disconnect
					printf("client disconnect: %d\n", i);
					close(i);

					fds[i].fd = -1;
					fds[i].events = 0;
					
					continue;
				}

				printf("RECV: %s\n", buffer);

				count = send(i, buffer, count, 0);
				printf("SEND: %d\n", count);

			}
		
		}
	}

===================================================

epoll
是 Linux 特有的高效 I/O 复用机制:事件驱动、回调机制、红黑树 + 就绪链表。
红黑树(在内核中):用于存储所有注册的 fd 及其关注的事件。支持高效的增、删、改(O(log n))。
就绪链表(rdlist):存储已就绪的 fd 及其事件。当某个 fd 上有事件发生时,内核通过回调函数将其加入就绪链表。
int epoll_create(int size); // 创建一个 epoll 实例(返回一个文件描述符)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除 fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件

struct epoll_event {
uint32_t events; /* Epoll events (bitmask) /
epoll_data_t data; /
User data variable */
};
typedef union epoll_data {
void ptr; // 指向任意用户数据结构
int fd; // 文件描述符
uint32_t u32; // 32位无符号整数
uint64_t u64; // 64位无符号整数
} epoll_data_t; 这是一个联合体
fd最常用;;void
ptr 指向自定义结构体(如连接对象、缓冲区、回调函数等),适合面向对象或复杂状态管理;

以EPOLLET的边缘触发模式为例,介绍其流程:

1、创建 epoll 实例:epoll_create 在内核中创建一个 eventpoll 结构,包含红黑树和就绪链表。
2、注册 fd:epoll_ctl 将 fd 及其关注的事件添加到红黑树中。同时,内核为该 fd 注册一个回调函数(ep_poll_callback)。
3、等待事件:epoll_wait 调用时,只需检查就绪链表是否为空。若不为空,则将就绪事件拷贝到用户提供的 events 数组中,并返回就绪数量;若为空,则进程休眠直到有事件发生。
4、事件回调:当某个 fd 上发生 I/O 事件时,内核调用其回调函数,将该 fd 对应的 epitem 添加到就绪链表中,并唤醒等待在 epoll_wait 上的进程。

优势:1、只返回就绪的fd:无需像 poll 那样遍历整个数组,只需拷贝就绪链表的内容(O(1) 就绪事件获取)。
2、避免重复拷贝:通过 mmap 或共享内存(早期实现)或内核与用户空间共享数据结构,减少数据拷贝。
3、支持两种触发模式:
水平触发(LT):默认模式,只要 fd 上有数据可读,每次 epoll_wait 都会返回(类似 poll)。此处,有数据可能是刚传递数据,也有可能是上次数据没读完。
边缘触发(ET):仅在状态发生变化时通知一次,要求用户一次性读完所有数据(否则可能丢失事件),效率更高,但编程复杂。
如果buffer相较于一次连接完整数据过小,一次epoll wait调用情况下;如客户端发送32字节,服务端只接收10字节,在水平触发中,该调用会多次触发(4次)该fd就绪,把该数据完整接收;; 而边缘ET中,只触发一次,该buffer只会存一部分,另外部分收不到,要求客户端发多次,才可以完整接收;; 因此对于边沿出发, 通常while循环,将数据读完;如果这个过程阻塞,recv卡柱,因此它更适合非阻塞io,适合数据大小不确定;;水平适合数据大小一致。

思考:1、为什么内核用红黑树存储所有监测的fd?
epoll 需要支持增删查改fd操作,这些操作要求时间复杂度尽可能低。红黑树保证在最坏情况下插入、删除、查找都是 O(log n),足以应对成千上万个 fd 的管理。 而哈希表虽然查找快,需要解决冲突(链表法/开放寻址),内存开销大; 无法有序遍历(虽然 epoll 不需要有序,但哈希表扩容时性能抖动明显);AVL树插入/删除需要更多旋转操作(比红黑树更频繁);B树/B+树内存中节点分裂/合并开销高,且每个节点存储多个键值,不适合内存中频繁的增删;
2、epoll中的就绪链表为什么使用是链表数据结构?
在 struct eventpoll 中,就绪事件容器是这样定义的:

struct eventpoll {
    // ...
    /* List of ready file descriptors */
    struct list_head rdllist;        // 就绪链表
    /* RB tree root used to store monitored fd structs */
    struct rb_root_cached rbr;       // 红黑树,管理所有被监听的 fd
    /* Single linked list for overflow items */
    struct epitem *ovflist;          // 溢出单链表
    // ...
};

rdllist​ 的类型是 struct list_head,这是 Linux 内核标准的双向链表结构,但从行为上看,它确实按照先进先出(FIFO)​ 的方式工作。Linux 内核里确实有一个专门的队列实现叫 kfifo(环形缓冲区),但 kfifo 是基于数组的环形缓冲区,需要预先确定大小。而 epoll 的就绪 fd 数量是动态的、不可预测的——可能瞬间几千个 fd 同时就绪,用固定大小的环形缓冲区要么浪费内存,要么面临溢出。链表则可以动态扩展。

3、面对多个连接的io,为什么采用reactor思想去处理它们?
每个io中包含了很多事件,每个事件接受后都需要处理,Reactor 是一种事件驱动的网络编程模型,广泛应用于高性能服务器。它的核心思想是将 I/O 事件的检测与事件的处理分离,由一个中央分发器(Reactor)统一监听所有连接上的事件,然后将事件分发给对应的处理器(Handler)去处理。
它的组成:
1)Reactor事件分发器 | 循环调用 epoll_wait / select 等多路复用 API,监听所有注册的 fd 上的事件,当事件发生时,将事件分发给对应的 Handler。
2)Handler事件处理器 处理具体的 I/O 事件(读、写、异常等)。通常每个连接对应一个 Handler,负责该连接上的数据收发和业务逻辑。
3)Acceptor​ 连接接受器(特殊hander) 专门处理 accept 事件,当有新连接到来时,创建新的 Handler 并将其注册到 Reactor。

优点:解耦 I/O 事件检测与业务处理,每部分职责单一,易于维护;提高并发能力,连接数不再受限于线程数,而是受限于系统 fd 上限和内存;支持多种 I/O 模型:单 Reactor 单线程:适用于 CPU 密集型或短连接场景(如 Redis)、单 Reactor 多线程:Reactor 只负责事件分发,Handler 将耗时任务提交给线程池处理(如 Netty 的 NioEventLoopGroup)、多 Reactor 多线程(主从 Reactor):主 Reactor 负责 accept,从 Reactor 负责读写,进一步提升并发(如 Nginx、Memcached)。

	int epfd = epoll_create(1);

	struct epoll_event ev;
	ev.events = EPOLLIN;
	ev.data.fd = sockfd;
	epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

	while (1) {

		struct epoll_event events[1024] = {0};
		int nready = epoll_wait(epfd, events, 1024, -1);

		int i = 0;
		for (i = 0;i < nready;i ++) {

			int connfd = events[i].data.fd;

			if (connfd == sockfd) {

				
				int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
				printf("accept finshed: %d\n", clientfd);

				ev.events = EPOLLIN;
				ev.data.fd = clientfd;
				epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
				
			} else if (events[i].events & EPOLLIN) {

				char buffer[1024] = {0};
				
				int count = recv(connfd, buffer, 1024, 0);
				if (count == 0) { // disconnect
					printf("client disconnect: %d\n", connfd);
					close(connfd);
					epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
					
					continue;
				}

				printf("RECV: %s\n", buffer);

				count = send(connfd, buffer, count, 0);
				printf("SEND: %d\n", count);
			}
		}
	}
posted @ 2026-07-24 15:25  超级麋鹿  阅读(1)  评论(0)    收藏  举报