基本概念
在 IO 多路复用模型中,引入了一种新的系统调用,查询 IO 的就绪状态。在 Linux 系统中,对应的系统调用为 select/poll/epoll 系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的 IO 系统调用。在 IO 多路复用模型中通过 select/poll/epoll 系统调用,单个应用程序的线程,可以不断地轮询成百上千的 socket 连接,当某个或者某些 socket 网络连接有 IO 就绪的状态,就返回对应的可以执行的读写操作。
与同步阻塞 IO 模型不同的是,select/poll/epoll 系统调用可以等待多个文件描述符(套接字)上的发生 IO 事件,可以设置等待超时。select/poll/epoll 系统调用只返回描述符就绪的个数(一般可认为是 IO 事件的个数),用户需要遍扫描整个描述符集处理 IO 时间。
select 系统调用详解
select 函数定义
int select ( int nfds, // 监控的文件描述符集里最大文件描述符加1,这是因为文件描述符从0开始 fd_set *readfds, // 监控读数据到达文件描述符集合 fd_set *writefds, // 监控写数据到达文件描述符集合 fd_set *exceptfds, // 监控异常发生达文件描述符集合 struct timeval *timeout // 指定阻塞时间,超时就会立即返回);参数说明:
-
nfds
Linux 系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。nfds 参数指定所有被监控的文件描述符的个数,其最大值由 FD_SETSIZE 定义,想要改变其值,不仅需要修改相应的头文件,还需要重新编译内核。在 centos7 中按照如下路劲就能找到 nfds 的最大值。
在/usr/include/sys/select.h 文件中,有如下定义
c#define FD_SETSIZE __FD_SETSIZE在/usr/include/bits/typesizes.h 文件中,有如下定义
c#define __FD_SETSIZE 1024
所以,nfds 的最大值可以在C语言的头文件定义中找到,Linux 系统默认为 1024。
-
readfds、writefds、errorfds
fd_set 的定义如下:
ctypedef struct fd_set { unsigned long fds_bits[FD_SETSIZE / (sizeof(unsigned long) * 8)];} fd_set;fds*bits 是一个数组,用于存储各个文件描述符的状态。数组中的每个元素都是一个 unsigned long 类型的整数,sizeof(unsigned long)的值是 8 个字节。因此,fd_set 类型最多可以表示(FD_SETSIZE /( 8 * 8)) _ (8 * 8)个描述符的状态,也就是可以表示 FD_SETSIZE 个描述符的状态。如果设 fd_set 长度为 1 字节,则一个 fd_set 变量最大可以表示 8 个文件描述符。所以 readfds、writefds、errorfds 是三个文件描述符集合。select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为“就绪”的文件描述符。然后用找到的子集替换这三个引用参数中的对应集合,返回所有就绪描述符的数量。
除此之外,fd_set 的使用还涉及以下几个 API:
c#include <sys/select.h>int FD_ZERO(int fd, fd_set *fdset); // 将 fd_set 所有位 置0int FD_CLR(int fd, fd_set *fdset); // 将 fd_set 某一位 置0int FD_SET(int fd, fd_set *fd_set); // 将 fd_set 某一位 置1int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1 -
timeout
timeout 参数表示调用 select 时的阻塞时长。如果所有 fd 文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会将 select 监听的所有文件描述符遍历一遍后立即返回,不阻塞。
注意:readfds、writefds、errorfds 这三个参数作为入参和出参的意义是不一样的。以 readfds 为例,readfds 参数既是入参,也是出参。作为入参,它指定了待监视的文件描述符集合;作为出参,它指示了哪些文件描述符已经就绪。
再说具体一点,假设 fd_set 长度为 1 字节,readfds 作为入参时其二进制表示为 00001001,这表示 select 系统调用只监视文件描述符 0 和 3(从右往左看)。当 select 函数成功返回时,readfds 参数会被内核修改,以指示哪些文件描述符已经就绪。假设 readfds 返回时的二进制表示为 00001000,这表示文件描述符 3 已就绪。
select 的底层原理
在描述 select 系统调用底层工作原理之前,需要有一些前置知识。首先需要深入理解套接字(socket),
套接字也是一种I/O资源,它可以通过文件描述符来进行操作。例如,我们可以使用 read 和 write 函数来对套接字进行读写操作,就像对文件进行读写一样。此外,套接字具有一些独特的属性。常见的套接字属性有:
协议类型:套接字的协议类型决定了该套接字所使用的协议,例如 TCP、UDP 等。地址族:套接字的地址族决定了该套接字所使用的地址类型,例如 IPv4、IPv6 等。地址:套接字的地址是一个 IP 地址和端口号的组合,用于标识该套接字所连接的远程主机或本地主机。状态:套接字的状态反映了该套接字当前所处的状态,例如已连接、未连接、监听等。缓冲区:套接字有多个缓冲区,用于存储接收到的数据和待发送的数据。缓冲区的大小、数量和使用方式都可以通过系统调用等接口进行配置。选项:套接字有多个选项,用于控制套接字的行为,例如超时时间、重传次数、广播等。文件描述符:套接字是一种文件,因此它也有一个文件描述符,用于在应用程序中标识该套接字。发送队列和接收队列:发送队列是用于存储已发送但未被对方确认接收的数据。接收队列是用于存储接收到的数据。除此之外,操作系统内核空间还维护了一些与套接字相关的队列。如下面的等待队列和就绪队列。应用程序无法直接访问这些队列,只能通过系统调用的方式访问。
等待队列:由等待该套接字的进程组成的队列,这些进程正在等待某些事件的发生,例如数据到达。当事件发生时,内核会从等待队列中唤醒一个或多个进程,使它们可以继续执行。就绪队列:套接字相关联的文件系统对象有一个就绪队列,这个队列里面放的是等待该文件系统对象上某个事件发生的进程。在 Linux 内核中,套接字的就绪状态是通过文件描述符和文件系统对象之间的关系来实现的。当套接字上发生某个事件(例如数据可读、连接建立等)时,内核会将该事件标记为“就绪”,并将文件描述符加入到对应的文件系统对象的就绪队列中。
有了上面的背景知识,现在就可以理解 select 系统调用底层工作原理。下面是详细过程:
- 当用户进程调用 select 系统调用,内核创建 socket 对象。
- select 系统调用会把要监听的文件描述符从用户态空间拷贝到内核态空间。
- 在内核态中,操作系统内核会遍历所有要监听的文件描述符。再说详细点,就是内核会去挨个检查每个文件描述符对应的套接字里面的接收队列,看有没有数据到达。
- 遍历完成后,如果有一个或多个文件描述符就绪,就会立即从内核态返回到用户态,并返回已经就绪的文件描述符的数量。但是用户进程并不知道具体是那个文件描述符已经就绪,所以用户进程最后还需要去遍历 readfds,才知道具体那个文件描述符已经就绪。
- 现在回到第 2 步。如果在内核遍历完成后,所有文件描述符都没有就绪,那么调用 select 的用户进程就会进入阻塞状态(前提是 timeout 没有设置为 0)。
- 现在我们需要把注意力放在网络上,当客户端发送数据给服务器时,数据包会经过客户端的传输层、网络层、链路层、物理层,并经过电磁波传输到达服务器的网卡。
- 服务器的网卡接收到数据后,会通过 DMA 的方式将数据包写入到指定的内存中。
- 数据写入完成后,DMA 控制器会发送一个硬中断信号给 CPU,来告诉 CPU 有新的数据包到达(硬中断的优先级较高,CPU 会优先处理该中断)。
- CPU 根据硬中断信号查找并调用相应的中断处理程序,中断处理程序的任务就是根据数据包的 IP 和端口号找到相应的套接字,并将数据包拷贝到套接字的接收队列中。然后再检查这个套接字的等待队列里面是否有等待该套接字的进程。如果有被阻塞的进程正在等待该套接字,那么中断处理程序就会发送一个信号唤醒哪些被阻塞的进程。中断处理程序的最后一步操作就是将该套接字的文件描述符加入到对应的文件系统对象的就绪队列中,也就是说等待该文件系统对象上某个事件发生的进程放入到就绪队列中。至此,中断处理程序成功返回。
- 内核会将该套接字对应的文件描述符设置为已就绪,并从内核态返回到用户态。至此,select 系统调用的整个过程就结束了。
为了加深理解,画了个 select 系统调用底层原理示意图。

select 的优缺点
优点:
- 相较于同步阻塞 IO 与同步非阻塞 IO 模型,一次 select 系统调用可以同时监听多个文件描述符,不需要每个文件描述符都进行系统调用,减少了用户态和内核态之间切换的次数。
缺点:
- 单进程监听的文件描述符的数量存在限制,默认为 1024 个。
- 每次 select 系统调用需要将文件描述符集合从用户态拷贝到内核态,系统调用返回时还需要将文件描述符集合从内核态拷贝到用户态。高并发场景下这样的拷贝会消耗极大资源。
- 当阻塞的进程被唤醒后,不知道哪些文件描述符已就绪,需要遍历传递进来的文件描述符集合的每一位,不管它们是否就绪。
- select 系统调用返回时不知道具体那个文件描述符已就绪,还需要重新把文件描述符集合遍历一遍。
- 入参的三个文件描述符集合每次调用都需要重置。
poll 系统调用详解
poll 函数定义
和 select 类似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而非 select 的 fd_set 结构。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);struct pollfd { int fd; // 要监听的文件描述符 short events; // 监听的事件 short revents; // 就绪的事件};poll 函数支持的事件类型:
POLLIN:对应的文件描述符上有数据可读,常量值为 1。POLLOUT:对应的文件描述符上可以写入数据,常量值为 2。POLLERR:对应的文件描述符发生错误,常量值为 4。POLLHUP:对应的文件描述符被挂起,常量值为 16。POLLNVAL:对应的文件描述符非法,常量值为 32。
在使用 poll() 函数时,我们可以使用位运算符 | 来组合多个事件类型。例如,如果我们想等待一个文件描述符上既有数据可读又可以写入数据,可以这样设置 events 字段:
fds[0].events = POLLIN | POLLOUT;调用 poll 的底层原理
在使用 poll() 函数时,我们需要创建一个 pollfd 结构体数组。
struct pollfd fds[MAX_CLIENTS + 1];每个结构体表示一个文件描述符和它所等待的事件。poll() 函数会将这些结构体放入一个链表中,等待事件发生。当有事件发生时,poll() 函数会遍历整个链表,找到对应的结构体,并将其标记为就绪状态,然后返回就绪的文件描述符。使用链表的好处是,可以方便地添加和删除等待事件,而不需要重新分配数组空间。另外,链表的节点可以动态分配内存,避免了数组空间浪费的问题。也正是基于链表存储,所以 poll 系统调用无最大文件描述符数量的限制。
由于 select 和 poll 在内部机制方面并没有太大的差异。相比于 select 机制,poll 只是取消了最大监控文件描述符数限制,这里就不在过多的赘述了。
poll 的优缺点
优点:
- 相比于 select,支持等待更多类型的事件,包括读、写、异常和挂起等事件,可以更加灵活地处理 I/O。
- 相比于 select,取消了最大监控文件描述符数限制。
缺点:
- 每次 poll 系统调用需要将文件描述符集合从用户态拷贝到内核态,系统调用返回时还需要将文件描述符集合从内核态拷贝到用户态。高并发场景下这样的拷贝会消耗极大资源。
- 当阻塞的进程被唤醒后,不知道哪些文件描述符已就绪,需要遍历传递进来的文件描述符集合的每一位,不管它们是否就绪。
- poll 系统调用返回时不知道具体那个文件描述符已就绪,还需要重新把文件描述符集合遍历一遍。
epoll 系统调用详解
epoll 相关的函数定义
-
epoll_create函数和epoll_create1函数
cint epoll_create(int size);int epoll_create1(int flags);epoll_create 是 epoll 的初始化函数,用于创建一个 epoll 对象。其中,size 表示最多可以监听的文件描述符数量。该函数返回一个整数值,表示创建的 epoll 对象的文件描述符。如果出错,返回值为 -1。
epoll_create1 也用于创建一个 epoll 对象,并返回一个文件描述符,但它可以通过 flags 参数来设置一些标志位,以改变 epoll 对象的行为。常用的标志位包括:
EPOLL_CLOEXEC:设置文件描述符的 close-on-exec 标志,即在执行 exec 系统调用时自动关闭文件描述符。EPOLL_NONBLOCK:设置文件描述符的非阻塞标志,即对该文件描述符的操作都是非阻塞的。
总的来说,epoll_create1 函数比 epoll_create 函数更加灵活,可以通过标志位来设置一些 epoll 对象的属性,而 epoll_create 函数则比较简单,只能创建默认属性的 epoll 对象。在实际编程中,建议优先使用 epoll_create1 函数。
-
epoll_ctl函数
cint epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);epoll_ctl 函数负责把服务端和客户端建立的 socket 连接注册到 eventpoll 对象里。其中,第一个参数 epfd 是 epoll 对象的文件描述符。第二个参数 op 表示操作类型,可以是以下三个值之一:
操作类型 描述 EPOLL_CTL_ADD 在 epoll 的监视列表中添加一个文件描述符(即参数 fd),指定监视的事件类型(参数 event),常量值为 1。 EPOLL_CTL_DEL 将某监视列表中已经存在的描述符(即参数 fd)删除,参数 event 传 NULL,常量值为 2。 EPOLL_CTL_MOD 修改监视列表中已经存在的描述符(即参数 fd)对应的监视事件类型(参数 event),常量值为 3。 在 centos 这个发行版中,EPOLL_CTL_ADD、EPOLL_CTL_MOD 和 EPOLL_CTL_DEL 的定义在/usr/include/sys/epoll.h 文件下找到。
第三个参数 fd 是要添加、修改或删除的文件描述符。第四个参数 event 是一个 struct epoll_event 结构体,用于描述要监听的事件类型和数据。该结构体的定义如下:
cstruct epoll_event { uint32_t events; /* 监听的事件类型 */ epoll_data_t data; /* 用户数据 */};typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64;} epoll_data_t;其中,events 表示要监听的事件类型,可以是以下几个值之一:
EPOLLIN:表示文件描述符可读;EPOLLOUT:表示文件描述符可写;EPOLLERR:表示文件描述符发生错误;EPOLLHUP:表示文件描述符被挂起。
data 是一个 epoll_data_t 类型的联合体,用于存储用户数据。它可以是一个指针、一个文件描述符、一个 32 位整数或一个 64 位整数。epoll_ctl 函数返回 0 表示成功,-1 表示失败。
-
epoll_wait函数
cint epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll_wait 用于等待文件描述符上的事件发生。其中,epfd 是 epoll 对象的文件描述符,events 是一个指向 struct epoll_event 数组的指针,用于存储发生事件的文件描述符和事件类型,maxevents 表示最多等待的事件数量,timeout 表示等待的超时时间,单位是毫秒。epoll_wait 函数返回发生事件的文件描述符数量,如果出错,返回值为 -1。
epoll 的底层原理
在理解 epoll 底层原理之前,需要知道
struct eventpoll(epoll 对象)的作用。 epoll 对象是用于管理多个文件描述符的机制,它可以同时监听多个文件描述符上的事件,包括套接字的可读、可写、错误等事件。尽管 epoll 对象和套接字的功能不同,但它们之间也有联系。在实际应用中,通常会将套接字的文件描述符加入到 epoll 对象中,以便能够监听套接字上的事件。这样,当套接字上有数据可读或可写时,epoll 对象就会通知应用程序进行相应的操作。因此,epoll 对象和套接字是可以结合使用的。
epoll 对象的定义如下:
struct eventpoll{ wait_queue_head_t wq; // sys_epoll_wait用到的等待队列 struct list_head rdllist; // 接收就绪的描述符都会放到这里 struct rb_root rbr; // 每个epoll对象中都有一颗红黑树 ......}- wq: 等待队列。如果当前进程没有数据需要处理,会把当前进程描述符和
回调函数default_wake_functon (这个回调函数等会要考)构造一个等待队列项,放入当前 wq 对待队列中,等到数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。 - rdllist: 就绪的描述符的队列。当有的连接就绪的时候(socket 监听的事件完成的时候),内核会把就绪的 socket 的文件描述符放到 rdllist 队列里。这样应用进程只需要判断队列就能找出就绪进程,而不用去遍历整棵树。
- rbr: 一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
struct eventpoll 的结构如下图所示:
在创建好 epoll 对象后,接下来就是将 socket 的文件描述符和 epoll 对象关联起来并指定需要监听的事件了。这就是 epoll_ctl 函数的作用,也就是说 epoll_ctl 函数可以把 socket 的文件描述符以及监听的事件添加(也可以是删除或更新)到 epoll 对象中。那具体是如何关联的呢?实际上,epoll_ctl 函数首先会创建一个 epitem 对象。epitem 结构体如下所示:
struct epitem{ struct rb_node rbn; // 红黑树节点 struct epoll_filefd ffd; // socket文件描述符信息 struct eventpoll *ep; // 所归属的epoll对象 struct list_head pwqlist; // 等待队列}这个 epitem 对象其实就是 epoll 对象里面的红黑树中的一个节点。epitem 对象也是对 socket 的一种抽象概括,也就是说从 epitem 对象中能够得到 socket 的部分关键信息。我们也可以把 epitem 对象理解为 socket 对象和 epoll 对象关联的一个桥梁。当 socket 上监听的事件没有发生时,socket 就会变为阻塞状态。总的来说,epoll_ctl 函数主要做了下面这三件事情:
-
创建红黑树节点对象 epitem
-
将等待事件添加到 socket 的等待队列中,通过
pwqlist(等待队列)设置数据就绪的回调函数为 ep_poll_callback。(当事件发生后,软中断处理程序就会调用 ep_poll_callback) -
将 epitem 节点插入到 epoll 对象的红黑树中
接下来就开始静静的等待事件的发生。显然,这就是 epoll_wait 函数的作用。epoll_wait 函数的动作比较简单,检查 epoll 对象的就绪队列(里面放的是就绪的文件描述符)是否有数据到达,如果没有就把当前的进程描述符添加到一个等待队列项里,加入到 epoll 对象的进程等待队列里,设置等待项回调函数 default_wake_function,然后阻塞自己,等待数据到达时通过回调函数被唤醒。
是的,当没有 IO 事件的时候, epoll 也是会阻塞掉当前进程。这个是合理的,因为没有事情可做了占着 CPU 也没啥意义。网上的很多文章有个很不好的习惯,讨论阻塞、非阻塞等概念的时候都不说主语。这会导致你看的云里雾里。拿 epoll 来说,epoll 本身是阻塞的,但一般会把 socket 设置成非阻塞。只有说了主语,这些概念才有意义。
当有数据到达时,通过下面几个步骤唤醒对应的进程处理数据:
- (前面的过程参考 select 系统调用)。
- 中断处理程序根据数据包里面的 IP 和端口号就能找到对应的 socket 对象,将内存中的数据包拷贝到 socket 的接收队列中(事件被触发) ,再调用 socket 的等待队列中等待项设置的回调函数 ep_poll_callback。
- ep_poll_callback 函数根据等待队列项找到 epitem。
- 由于 epitem 保存了已就绪的 socket 的文件描述符,并且 epitem 对象是 epoll 对象的一个红黑树节点,所以 ep_poll_callback 函数可以将就绪的 socket 的文件描述符添加到 epoll 对象的就绪队列中。
- ep_poll_callback 函数检查 epoll 对象的等待队列上是否有等待项。
- 如果没有等待项,说明用户进程并未阻塞,此时软中断结束。
- 如果有等待项,则通过调用回调函数 default_wake_func 唤醒这个进程。
- 当进程醒来后,继续从 epoll_wait 时暂停处的代码继续执行,把 epoll 对象就绪队列的事件返回给用户进程,让用户进程调用 recv 把已经到达 socket 的数据拷贝到用户空间使用。
感想:研究了整整一天终于看懂了 epoll 这个系统调用的底层原理。真的感觉这个系统调用的底层原理非常复杂。过不了几天,我感觉就能把这些东西忘的差不多了。为了更方便的回顾,画了 epoll 对象、epitem 对象和 socket 对象的关系图。

epoll 的优缺点
优点:
- 高效处理高并发下的大量连接,有非常有益的性能。(红黑树将存储 epoll 所监听的 FD,高效的数据结构,本身插入和删除性能比较好;通过 epoll 对象中的就绪队列可以直接知道哪些文件描述符已就绪,减少了遍历文件描述符集的时间开销; mmap 的引入,将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,减少用户态和内核态之间的数据交换。)
缺点:
- 跨平台性不够好,目前只支持 Linux 操作系统。MacOS 和 Windows 操作系统不支持该函数。
- 在监听的文件描述符或事件较少的时候,可能 select 和 poll 的性能更优。
ET vs LT
基本概念
Edge Triggered (ET) 边沿触发
-
socket 的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
-
socket 的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件
Level Triggered (LT) 水平触发
-
socket 接收缓冲区不为空,有数据可读,则读事件一直触发
-
socket 发送缓冲区不满可以继续写入数据,则写事件一直触发
epoll_ctl 模式设置
epoll_wait 函数的触发方式可以通过 epoll_ctl 函数的 EPOLL_CTL_ADD 操作来设置。在添加文件描述符到 epoll 对象时,可以通过设置 epoll_event 结构体中的 events 字段来指定触发方式。具体来说:
- 如果将 events 字段设置为
EPOLLIN | EPOLLET或EPOLLOUT | EPOLLET,则表示该文件描述符采用边缘触发方式。 - 如果将 events 字段设置为
EPOLLIN或EPOLLOUT,则表示该文件描述符采用水平触发方式。
其中,EPOLLIN 表示文件描述符可读,EPOLLOUT 表示文件描述符可写,EPOLLET 表示边缘触发方式。
需要注意的是,边缘触发方式下,epoll_wait 函数只会在文件描述符上发生状态变化时才返回,而水平触发方式下,epoll_wait 函数会在文件描述符上有数据可读或可写时就返回。因此,边缘触发方式下需要更加谨慎地处理事件,否则可能会出现遗漏事件的情况。
应用场景
基于 IO 多路复用的 Web 服务器
基于 epoll 多路复用的方式写一个并发的 Web 服务器对于理解 epoll 多路复用很有帮助。
客户端代码:
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <string.h>#include <arpa/inet.h>#include <sys/socket.h>#define PORT 8080int main(int argc, char const *argv[]){ int client_fd, valread; struct sockaddr_in server_addr; char buffer[1024] = {0}; const char *http_request = "GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nConnection: close\r\n\r\nHello World!"; client_fd = socket(AF_INET, SOCK_STREAM, 0); // 创建socket server_addr.sin_family = AF_INET; // sin_family用来定义是哪种地址族,AF_INET表示使用IPv4进行通信 server_addr.sin_port = htons(PORT); // 指定端口号,htons()将短整型数据转换成网络字节顺序 inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); // 将IPv4地址从点分十进制转换为二进制格式 // 连接服务器 if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("connect failed"); exit(EXIT_FAILURE); } send(client_fd, http_request, strlen(http_request), 0); // 发送HTTP请求 return 0;}服务器代码:
#include <stdio.h>#include <fcntl.h>#include <errno.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <arpa/inet.h>#include <sys/epoll.h>#include <sys/socket.h>#define MAX_EVENTS 10 // epoll_wait函数每次最多返回的就绪事件数量#define BUF_SIZE 1024 // 缓冲区大小int main(int argc, char *argv[]){ int nfds, i, n; char buffer[BUF_SIZE]; int server_fd, client_fd, epoll_fd; struct epoll_event ev, events[MAX_EVENTS]; // ev是添加到epoll对象中的事件,events[]用于存储epoll_wait函数返回的就绪事件 short port = 8080; // 服务器的监听端口 char listen_addr_str[] = "127.0.0.1"; // 服务器的IP地址 int server_socket, client_socket; // 定义服务端的socket和客户端的socket struct sockaddr_in server_addr, client_addr; // 定义服务端和客户端的IPv4的套接字地址结构(定长,16字节) size_t listen_addr = inet_addr(listen_addr_str); // 将点分十进制的IPv4地址转换成网络字节序列的长整型 // 对IPv4的套接字地址结构做初始化 bzero(&server_addr, sizeof(server_addr)); // 将server_addr结构体的前sizeof(serveraddr)个字节清零,与memset()差不多 server_addr.sin_family = AF_INET; // sin_family用来定义是哪种地址族,AF_INET表示使用IPv4进行通信 server_addr.sin_port = htons(port); // 指定端口号,htons()将短整型数据转换成网络字节顺序 server_addr.sin_addr.s_addr = listen_addr; // 指定服务器的IP地址 socklen_t client_len = sizeof(client_addr); server_fd = socket(PF_INET, SOCK_STREAM, 0); // 创建套接字,SOCK_STREAM表示使用TCP协议 fcntl(server_fd, F_SETFL, fcntl(server_fd, F_GETFL, 0) | O_NONBLOCK); // 设置非阻塞 bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)); // 绑定端口 // 监听端口,SOMAXCONN默认值为128,表示TCP服务可以同时接受的连接请求的最大数量 listen(server_fd, SOMAXCONN); // 创建 epoll 对象 if ((epoll_fd = epoll_create1(0)) < 0) { perror("epoll_create1 error"); exit(1); } ev.events = EPOLLIN; // 添加事件 ev.data.fd = server_fd; // 添加服务器socket到epoll对象中 if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) < 0) { perror("epoll_ctl error"); exit(1); } // 开始循环监听 while (1) { // 等待事件发生,返回发生事件的文件描述符数量 nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds < 0) { perror("epoll_wait error"); exit(1); } // 处理所有就绪事件 for (i = 0; i < nfds; i++) { if (events[i].data.fd == server_fd) // 如果是服务器socket有新连接请求 { while ((client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len)) > 0) { // 设置客户端socket非阻塞 fcntl(client_fd, F_SETFL, fcntl(client_fd, F_GETFL, 0) | O_NONBLOCK); // 添加客户端socket到epoll对象中 ev.events = EPOLLIN | EPOLLET; // ET模式,缓冲区状态变化时触发事件 ev.data.fd = client_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) < 0) { perror("epoll_ctl error"); exit(1); } } } else { while ((n = read(events[i].data.fd, buffer, BUF_SIZE)) > 0) // 如果是客户端socket有数据到达 { printf("Received: %s\n", buffer); // 输出从客户端接收到的数据 char *response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!"; write(events[i].data.fd, response, strlen(response)); // 回复客户端 close(events[i].data.fd); // 关闭客户端socket } } } } close(server_fd); // 关闭服务器socket exit(0);}运行结果如下:

Redis 的 IO 多路复用模型分析
Redis 采用 IO 多路复用技术,其底层实现原理如下:
-
事件驱动:Redis 采用事件驱动的方式处理客户端请求。当有客户端请求到达时,Redis 会将请求放入一个事件队列中,然后通过 IO 多路复用技术来监听事件。当有事件到达时,Redis 会从事件队列中取出事件,并根据事件类型进行处理。
-
IO 多路复用:Redis 采用 IO 多路复用技术来处理客户端请求。通过 IO 多路复用技术,Redis 可以同时监听多个客户端请求,从而实现高并发的读写操作。
-
非阻塞 IO:Redis 采用非阻塞 IO 来处理客户端请求。通过非阻塞 IO,Redis 可以在等待客户端请求的同时,继续处理其他请求,从而提高系统的吞吐量。
-
事件循环:Redis 采用事件循环的方式处理客户端请求。事件循环是指 Redis 在等待客户端请求的同时,不断地进行事件处理。通过事件循环,Redis 可以在保证高并发的同时,保持低延迟和高吞吐量。
Redis 的 IO 多路复用模型是基于 epoll 实现的。在 Linux 系统中,有多种 IO 多路复用模型,包括 select、poll 和 epoll 等。Redis 最初是基于 select 模型实现的,但由于 select 模型在大量连接的情况下性能不佳,因此 Redis 从 2.6 版本开始采用 epoll 模型。
综上所述,Redis 的 IO 多路复用模型是 Redis 能够实现高性能、高并发、低延迟的关键。通过 IO 多路复用技术的应用,Redis 能够同时监听多个客户端请求,从而实现高并发的读写操作。同时采用非阻塞 IO 和事件循环的方式处理客户端请求,在保证高并发的同时,保持低延迟和高吞吐量。
问:redis的IO多路复用模型是基于epoll实现,由于epoll系统调用只持支Linux操作系统,为什么windows也能使用redis的IO多路复用模型?Redis 是使用 epoll 系统调用作为 IO 多路复用模型的底层实现,而 Windows 操作系统不支持 epoll 系统调用。因此,在 Windows 上使用 Redis 时,Redis 不能直接使用 epoll 作为底层 I/O 模型。Redis 在 Windows 上会使用类似 epoll 的技术实现 I/O 多路复用。
也就是说,Redis 在 Windows 上使用了 IOCP(Input/Output Completion Ports)技术来实现 I/O 多路复用。IOCP 是 Windows 专有的技术,它是一种高效的 I/O 调度机制,它可以支持一组 I/O 操作的异步完成通知。通过 IOCP,Redis 可以在 Windows 平台上实现高效的异步 I/O,并且可以避免使用 epoll 等 Linux 专有的 API。
参考链接: