十三、select、poll和epoll机制的介绍
相关链接:
一、概述
1、IO操作类型介绍
(1)同步IO
在操作系统中,程序运行的空间分为内核空间和用户空间,用户空间所有对io操作的代码(如文件的读写、socket的收发等)都会通过系统调用进入内核空间完成实际的操作。
CPU的速度远快于硬盘、网络等IO。在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作,这种情况称为同步IO.
在某个应用程序运行时,假设需要读写某个文件,此时就发生了 I/O 操作,在 I/O 操作的过程中,系统会将当前线程挂起,而其他需要 CPU 执行的代码就无法被当前线程执行了,这就是同步 I/O操作,因为一个 IO 操作就阻塞了当前线程,导致其他代码无法执行,所以我们可以使用 多线程或者多进程来并发执行代码,当某个线程/进程被挂起后,不会影响其他线程或进程。多线程和多进程虽然解决了这种并发的问题,但是系统不能无上限地增加线程/进程。由于系统切换线程/进程的开销也很大,所以,一旦线程/进程数量过多,CPU 的时间就花在线程/进程切换上了,真正运行代码的时间就少了,这样子的结果也导致系统性能严重下降。多线程和多进程只是解决这一问题的一种方法,另一种解决 I/O 问题的方法是异步 I/O,当然还有其他解决的方法
(2)异步IO
(3)阻塞IO

(4)非阻塞IO
- 当用户进程调用 read()/recvfrom() 等系统调用函数时,如果内核空间中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个 error。
- 对于应用进程来说,它发起一个 read() 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道内核中的数据还没有准备好,那么它可以再次调用 read()/recvfrom() 等函数。
- 当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程也就得到了数据。
(5)多路复用IO
- 当客户处理多个描述符时。
- 服务器在高并发处理网络连接的时候。
- 服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到 I/O 复用。
- 如果一个服务器即要处理 TCP,又要处理 UDP,一般要使用 I/O 复用。
- 如果一个服务器要处理多个服务或多个协议,一般要使用 I/O 复用。
二、多路复用IO操作介绍
1、select
(1)select整个处理过程
- 用户进程调用 select() 函数,如果当前没有可读写的 socket,则用户进程进入阻塞状态。
- 对于内核空间来说,它会从用户空间拷贝 fd_set 到内核空间,然后在内核中遍历一遍所有的 socket 描述符,如果没有满足条件的 socket 描述符,内核将进行休眠,当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的内核进程,即在 socket 可读写时唤醒,或者在超时后唤醒。
- 返回 select() 函数的调用结果给用户进程,返回就绪 socket 描述符的数目,超时返回0,出错返回-1。
- 注意,在 select() 函数返回后还是需要轮询去找到就绪的 socket 描述符的,此时用户进程才可以去操作 socket 。


(2)优缺点
优点:
- select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
缺点:
-
单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
-
需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
-
每次在有 socket 描述符活跃的时候,都需要遍历一遍所有的 fd 找到该描述符,这会带来大量的时间消耗(时间复杂度是 O(n),并且伴随着描述符越多,这开销呈线性增长)
(3)函数原型
1 2 3 4 5 6 7 8 | #include <sys/select.h> /* According to earlier standards */ #include <sys/time.h> #include <sys/types.h> #include <unistd.h> int select( int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset, const struct timeval *timeout) |
参数说明:
- maxfdp1 指定感兴趣的文件 描述符个数,它的值是套接字最大文件描述符加 1,socket描述符 0、1、2 …maxfdp1-1 均将被设置为感兴趣(即会查看他们是否可读、可写)。(加1的原因是文件描述符的值是从0开始的,所以文件描述符的个数是文件描述符的个数+1)
- readset:指定这个 文件描述符是可读的时候才返回。
- writeset:指定这个 文件描述符是可写的时候才返回。
- exceptset:指定这个 文件描述符是异常条件时候才返回。
- timeout:指定了超时的时间,当超时了也会返回。
如果对某一个的条件不感兴趣,就可以把它设为空指针。
返回值:
(4)基本用法
-
创建
1 2 3 4 5 6 7 | fd_set rset , allset; //用来清空fd_set集合,即让fd_set集合不再包含任何文件句柄。 FD_ZERO(&allset); //用来将一个给定的文件描述符加入集合之中 FD_SET(listenfd, &allset); |
-
监听
1 2 | /*只select出用于读的描述字,阻塞无timeout*/ nready = select(maxfd+1 , &rset , NULL , NULL , NULL); |
-
获取
1 | if (FD_ISSET(listenfd,&rset)) //检测fd在fdset集合中的状态是否变化,当检测到fd状态发生变化时返回真,否则,返回假(也可以认为集合中指定的文件描述符是否可以读写)。 |
-
删除文件描述符
1 2 3 | FD_CLR( int fd ,fd_set* set); 用来将一个给定的文件描述符从集合中删除 |
(5)实例1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | #include <stdio.h> #include <stdlib.h> #include <sys/time.h> #include <sys/types.h> #include <unistd.h> int main( void ) { fd_set rfds; struct timeval tv; int retval; /* Watch stdin (fd 0) to see when it has input. */ FD_ZERO(&rfds); FD_SET(0, &rfds); /* Wait up to five seconds. */ tv.tv_sec = 5; tv.tv_usec = 0; retval = select(1, &rfds, NULL, NULL, &tv); //监听标准输出是否有数据,最多阻塞5s ,第一个参数值为1,代表标准输出的文件描述符的值//在Linux进程中,默认情况下会有3个缺省打开的文件描述符,分别是标准输入(stdin)0,标准输出(stdout)1,标准错误(stderr)2。 /* Don't rely on the value of tv now! */ if (retval == -1) perror ( "select()" ); else if (retval) printf ( "Data is available now.\n" ); /* FD_ISSET(0, &rfds) will be true. */ else printf ( "No data within five seconds.\n" ); exit (EXIT_SUCCESS); } |
// 文件描述符0,1,2对应的物理设备一般是:键盘,显示器,显示器。
执行结果:
终端无任何输入时,等待5s后select返回
终端有输入时,select会监控读文件描述符返回非-1
(6)实例2
TCP的服务器端,可以监听多个客户端连接,只要有客户端发数据过来,select就返回。然后进行数据的接收
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | #include <stdio.h> #include <string.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/socket.h> #include <sys/select.h> const static int MAXLINE = 1024; const static int SERV_PORT = 10001; int main() { int i , maxi , maxfd, listenfd , connfd , sockfd ; /*nready 描述字的数量*/ int nready ,client[FD_SETSIZE]; int n ; /*创建描述字集合,由于select函数会把未有事件发生的描述字清零,所以我们设置两个集合*/ fd_set rset , allset; char buf[MAXLINE]; socklen_t clilen; struct sockaddr_in cliaddr , servaddr; /*创建socket*/ listenfd = socket(AF_INET , SOCK_STREAM , 0); /*定义sockaddr_in*/ memset (&servaddr , 0 , sizeof (servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_port = htons(SERV_PORT); servaddr.sin_addr.s_addr = htonl(INADDR_ANY); printf ( "s_addr=%s\n" ,servaddr.sin_addr.s_addr); bind(listenfd, ( struct sockaddr *) & servaddr , sizeof (servaddr)); listen(listenfd , 100); //监听socket连接,100是连接请求队列的最大个数,表示最大可以连接100个客户端 /*listenfd 是第一个描述字*/ /*最大的描述字,用于select函数的第一个参数*/ maxfd = listenfd; /*client的数量,用于轮询*/ maxi = -1; /*init*/ for (i=0 ;i<FD_SETSIZE ; i++) client[i] = -1; FD_ZERO(&allset); //清空文件描述符集合 FD_SET(listenfd, &allset); //将listenfd文件描述符加入集合 for (;;) { rset = allset; nready = select(maxfd+1 , &rset , NULL , NULL , NULL); //监听集合中的读socket,如果读文件描述符处于就绪状态就返回,否则一直阻塞 if (FD_ISSET(listenfd,&rset)) //判断集合中是否存在listenfd文件描述符 { clilen = sizeof (cliaddr); connfd = accept(listenfd , ( struct sockaddr *) & cliaddr , &clilen); /*寻找第一个能放置新的描述字的位置*/ for (i=0;i<FD_SETSIZE;i++) { if (client[i]<0) { client[i] = connfd; break ; } } /*找不到,说明client已经满了*/ if (i==FD_SETSIZE) { printf ( "Too many clients , over stack ." ); return -1; } FD_SET(connfd,&allset); //设置fd /*更新相关参数*/ if (connfd > maxfd) maxfd = connfd; if (i>maxi) maxi = i; if (nready<=1) continue ; else nready --; } for (i=0 ; i<=maxi ; i++) { if (client[i]<0) continue ; sockfd = client[i]; if (FD_ISSET(sockfd,&rset)) { n = read(sockfd , buf , MAXLINE); if (n==0) { /*当对方关闭的时候,server关闭描述字,并将set的sockfd清空*/ close(sockfd); FD_CLR(sockfd,&allset); client[i] = -1; } else { buf[n]= ' ' ; printf ( "Socket %d said : %s" ,sockfd,buf); write(sockfd,buf,n); //Write back to client } nready--; if (nready<=0) break ; } } } return 0; } |
2、poll
(1)概述
(2)函数原型
1 | int poll( struct pollfd *fds, unsigned int nfds, int timeout); |
参数:
- fds :一个struct pollfd类型的数组.
1 2 3 4 5 | struct pollfd { int fd; /* 文件描述符 */ short events; /* 请求的事件类型 */ short revents; /* 返回的事件类型 */ }; |
-
-
fd:要监视的文件描述符
-
-
- events:是要监视的事件revents是返回事件,内核设置具体的返回事件
-
POLLIN:系统内核通知应用层指定数据已经备好,读数据不会被阻塞
-
POLLPRI :有紧急的数据需要读取
-
POLLOUT :系统内核通知应用层IO缓冲区已准备好,写数据不会被阻塞
-
POLLERR :指定的文件描述符发生错误
-
POLLNVAL:无效的请求
-
- events:是要监视的事件revents是返回事件,内核设置具体的返回事件
-
nfds:pollfd数组的元素个数,要监控的文件描述符数量
-
timeout:超时时间(ms)
返回值:
- 成功:发生事件的文件数量,超时返回0
- 失败:-1
(3)实例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <string.h> #include <poll.h> int main( int argc, char *argv[]) { struct pollfd fds ={0}; fds.fd =0; fds.events=POLLIN; //系统内核通知应用层指定数据已经备好,读数据不会被阻塞 int ret = poll(&fds,1,5000); //监视读事件,超时事件为5s if (ret == -1) printf ( "poll error!\r\n" ); else if (ret) printf ( "data is ready!\r\n" ); else if (ret ==0) printf ( "time out!/r/n" ); } |
执行结果:
终端无输入时:
终端有输入时:
3、epoll
(1)概述
其实相对于select和poll来说,epoll更加灵活,但是核心的原理都是当socket描述符就绪(可读、可写、出现异常),就会通知应用进程,告诉他哪个socket描述符就绪,只是通知处理的方式不同而已。
epoll使用一个epfd(epoll文件描述符)管理多个socket描述符,epoll不限制socket描述符的个数,将用户空间的socket描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。当epoll记录的socket产生就绪的时候,epoll会通过callback的方式来激活这个fd,这样子在epoll_wait便可以收到通知,告知应用层哪个socket就绪了,这种通知的方式是可以直接得到那个socket就绪的,因此相比于select和poll,它不需要遍历socket列表,时间复杂度是O(1),不会因为记录的socket增多而导致开销变大。
- LT 模式:即水平触发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序可以不立即处理它。下次调用 epoll_wait 时,还会再次产生通知。
-
ET 模式:即边缘触发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序 必须立即处理它。如果不处理,下次调用 epoll_wait 时,不会再次产生通知。ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
(2)创建一个 epoll 的文件描述符
当创建好 epoll 句柄后,它就是会占用一个 fd 值,必须调用 close() 关闭,否则可能导致 fd 被耗尽,这也是为什么我们前面所讲的是:epoll 使用一个 epfd 管理多个 socket 描述符
1 2 3 | #include <sys/epoll.h> int epoll_create( int size); |
参数:
- size :用来告诉内核这个监听的数目一共有多大,它其实是在内核申请一空间,用来存放用户想监听的 socket fd 上是否可读可行或者其他异常,只要有足够的内存空间,size 可以随意设置大小,1G 的内存上能监听约 10 万个端口。
返回值:
-
成功:返回一个非负的文件描述符。
-
失败:返回-1
(3)epoll事件控制函数
1 | int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event); |
参数:
-
epdf:由 epoll_create() 函数返回的 epoll 文件描述符(句柄)。
-
op:op 是操作的选项,目前有以下三个选项:
-
EPOLL_CTL_ADD:注册要监听的目标 socket 描述符 fd 到 epoll 句柄中。
-
EPOLL_CTL_MOD:修改 epoll 句柄已经注册的 fd 的监听事件。
-
EPOLL_CTL_DEL:从 epoll 句柄删除已经注册的 socket 描述符。
-
- fd:指定监听的 socket 描述符。
- event:event 结构如下
1 2 3 4 5 6 7 8 9 10 | typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; |
-
EPOLLIN:表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)。
-
EPOLLOUT:表示对应的文件描述符可以写。
-
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
-
EPOLLERR:表示对应的文件描述符发生错误。
-
EPOLLHUP:表示对应的文件描述符被挂断。
-
EPOLLET:将 EPOLL 设为边缘触发 (Edge Triggered) 模式,这是相对于水平触发(Level Triggered) 来说的。
-
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里
(4)epoll等待事件函数
等待监听的事件的发生,类似于调用 select() 函数
1 | int epoll_wait( int epfd, struct epoll_event *events, int maxevents, int timeout); |
参数:
-
events:用来从内核得到事件的集合。
-
maxevents:告知内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的指定的 size。
-
timeout:超时时间。
-
函数的返回值表示需要处理的事件数目,如返回 0 表示已超时。
(5)epoll 为什么更高效
1. 当我们调用 epoll_wait() 函数返回的不是实际的描述符,而是一个代表就绪描述符数量的值,这个时候需要去 epoll 指定的一个数组中依次取得相应数量的 socket 描述符即可,而不需要遍历扫描所有的 socket 描述符,因此这里的时间复杂度是 O(1)。
2. 此外还使用了内存映射(mmap )技术,这样便彻底省掉了这些 socket 描述符在系统调用时拷贝的开销(因为从用户空间到内核空间需要拷贝操作)。mmap 将用户空间的一块地址和 内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换,不需要依赖拷贝,这样子内核可以直接看到 epoll 监听的 socket 描述符,效率极高。
3. 另一个本质的改进在于 epoll 采用基于事件的就绪通知方式。在 select/poll 中,进程只有在调用一定的方法后,内核才对所有监视的 socket 描述符进行扫描,而 epoll 事先通过 epoll_ctl() 来注册一个 socket 描述符,一旦检测到 epoll 管理的 socket 描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个 socket 描述符,当进程调用 epoll_wait() 时便可以得到通知,也就是说 epoll 最大的优点就在于它 只管就绪的socket 描述符,而跟 socket 描述符的总数无关(6)实例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | #define MAX_EVENTS 10 struct epoll_event ev, events[MAX_EVENTS]; int listen_sock, conn_sock, nfds, epollfd; /* Set up listening socket, 'listen_sock' (socket(), bind(), listen()) */ epollfd = epoll_create(10); / if (epollfd == -1) { perror ( "epoll_create" ); exit (EXIT_FAILURE); } ev.events = EPOLLIN; ev.data.fd = listen_sock; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) { perror ( "epoll_ctl: listen_sock" ); exit (EXIT_FAILURE); } for (;;) { nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1); if (nfds == -1) { perror ( "epoll_pwait" ); exit (EXIT_FAILURE); } for (n = 0; n < nfds; ++n) { if (events[n].data.fd == listen_sock) { conn_sock = accept(listen_sock, ( struct sockaddr *) &local, &addrlen); if (conn_sock == -1) { perror ( "accept" ); exit (EXIT_FAILURE); } setnonblocking(conn_sock); ev.events = EPOLLIN | EPOLLET; ev.data.fd = conn_sock; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) { perror ( "epoll_ctl: conn_sock" ); exit (EXIT_FAILURE); } } else { do_use_fd(events[n].data.fd); } } } |
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)