IO多路复用详解

基本概念

在IO多路复用模型中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统中,对应的系统调用为select/poll/epoll系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的IO系统调用。在IO多路复用模型中通过select/poll/epoll系统调用,单个应用程序的线程,可以不断地轮询成百上千的socket连接,当某个或者某些socket网络连接有IO就绪的状态,就返回对应的可以执行的读写操作。

与同步阻塞IO模型不同的是,select/poll/epoll系统调用可以等待多个文件描述符(套接字)上的发生IO事件,可以设置等待超时。select/poll/epoll系统调用只返回描述符就绪的个数(一般可认为是IO事件的个数),用户需要遍扫描整个描述符集处理IO时间。

img

select系统调用详解

select函数定义

int select (
    int nfds,                   // 监控的文件描述符集里最大文件描述符加1,这是因为文件描述符从0开始
    fd_set *readfds,            // 监控读数据到达文件描述符集合
    fd_set *writefds,           // 监控写数据到达文件描述符集合
    fd_set *exceptfds,          // 监控异常发生达文件描述符集合
    struct timeval *timeout		// 指定阻塞时间,超时就会立即返回
    );     

参数说明:

  1. nfds

    Linux系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。nfds参数指定所有被监控的文件描述符的个数,其最大值由FD_SETSIZE定义,想要改变其值,不仅需要修改相应的头文件,还需要重新编译内核。在centos7中按照如下路劲就能找到nfds的最大值。

    在/usr/include/sys/select.h文件中,有如下定义

    #define FD_SETSIZE		__FD_SETSIZE
    

    在/usr/include/bits/typesizes.h文件中,有如下定义

    #define __FD_SETSIZE		1024
    

​ 所以,nfds的最大值可以在C语言的头文件定义中找到,Linux系统默认为1024。

  1. readfds、writefds、errorfds

    fd_set的定义如下:

    typedef 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:

    #include <sys/select.h>
    int FD_ZERO(int fd, fd_set *fdset);  // 将 fd_set 所有位 置0
    int FD_CLR(int fd, fd_set *fdset);   // 将 fd_set 某一位 置0
    int FD_SET(int fd, fd_set *fd_set);  // 将 fd_set 某一位 置1
    int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1
    
  2. 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函数来对套接字进行读写操作,就像对文件进行读写一样。此外,套接字具有一些独特的属性。常见的套接字属性有:

  1. 协议类型:套接字的协议类型决定了该套接字所使用的协议,例如TCP、UDP等。
  2. 地址族:套接字的地址族决定了该套接字所使用的地址类型,例如IPv4、IPv6等。
  3. 地址:套接字的地址是一个IP地址和端口号的组合,用于标识该套接字所连接的远程主机或本地主机。
  4. 状态:套接字的状态反映了该套接字当前所处的状态,例如已连接、未连接、监听等。
  5. 缓冲区:套接字有多个缓冲区,用于存储接收到的数据和待发送的数据。缓冲区的大小、数量和使用方式都可以通过系统调用等接口进行配置。
  6. 选项:套接字有多个选项,用于控制套接字的行为,例如超时时间、重传次数、广播等。
  7. 文件描述符:套接字是一种文件,因此它也有一个文件描述符,用于在应用程序中标识该套接字。
  8. 发送队列和接收队列:发送队列是用于存储已发送但未被对方确认接收的数据。接收队列是用于存储接收到的数据。

除此之外,操作系统内核空间还维护了一些与套接字相关的队列。如下面的等待队列和就绪队列。应用程序无法直接访问这些队列,只能通过系统调用的方式访问。

  1. 等待队列:由等待该套接字的进程组成的队列,这些进程正在等待某些事件的发生,例如数据到达。当事件发生时,内核会从等待队列中唤醒一个或多个进程,使它们可以继续执行。
  2. 就绪队列:套接字相关联的文件系统对象有一个就绪队列,这个队列里面放的是等待该文件系统对象上某个事件发生的进程。在 Linux 内核中,套接字的就绪状态是通过文件描述符和文件系统对象之间的关系来实现的。当套接字上发生某个事件(例如数据可读、连接建立等)时,内核会将该事件标记为“就绪”,并将文件描述符加入到对应的文件系统对象的就绪队列中。

有了上面的背景知识,现在就可以理解select系统调用底层工作原理。下面是详细过程:

  1. 当用户进程调用select系统调用,内核创建socket对象。
  2. select系统调用会把要监听的文件描述符从用户态空间拷贝到内核态空间。
  3. 在内核态中,操作系统内核会遍历所有要监听的文件描述符。再说详细点,就是内核会去挨个检查每个文件描述符对应的套接字里面的接收队列,看有没有数据到达。
  4. 遍历完成后,如果有一个或多个文件描述符就绪,就会立即从内核态返回到用户态,并返回已经就绪的文件描述符的数量。但是用户进程并不知道具体是那个文件描述符已经就绪,所以用户进程最后还需要去遍历readfds,才知道具体那个文件描述符已经就绪。
  5. 现在回到第2步。如果在内核遍历完成后,所有文件描述符都没有就绪,那么调用select的用户进程就会进入阻塞状态(前提是timeout没有设置为0)。
  6. 现在我们需要把注意力放在网络上,当客户端发送数据给服务器时,数据包会经过客户端的传输层、网络层、链路层、物理层,并经过电磁波传输到达服务器的网卡。
  7. 服务器的网卡接收到数据后,会通过DMA的方式将数据包写入到指定的内存中。
  8. 数据写入完成后,DMA控制器会发送一个硬中断信号给CPU,来告诉CPU有新的数据包到达(硬中断的优先级较高,CPU会优先处理该中断)。
  9. CPU根据硬中断信号查找并调用相应的中断处理程序,中断处理程序的任务就是根据数据包的IP和端口号找到相应的套接字,并将数据包拷贝到套接字的接收队列中。然后再检查这个套接字的等待队列里面是否有等待该套接字的进程。如果有被阻塞的进程正在等待该套接字,那么中断处理程序就会发送一个信号唤醒哪些被阻塞的进程。中断处理程序的最后一步操作就是将该套接字的文件描述符加入到对应的文件系统对象的就绪队列中,也就是说等待该文件系统对象上某个事件发生的进程放入到就绪队列中。至此,中断处理程序成功返回。
  10. 内核会将该套接字对应的文件描述符设置为已就绪,并从内核态返回到用户态。至此,select系统调用的整个过程就结束了。

为了加深理解,画了个select系统调用底层原理示意图。

image-20230529112218341

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相关的函数定义

  1. epoll_create函数和epoll_create1函数

    int 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 函数。

  2. epoll_ctl函数

    int 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 结构体,用于描述要监听的事件类型和数据。该结构体的定义如下:

    struct 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 表示失败。

  3. epoll_wait函数

    int 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 设置成非阻塞。只有说了主语,这些概念才有意义。

当有数据到达时,通过下面几个步骤唤醒对应的进程处理数据:

  1. (前面的过程参考select系统调用)。
  2. 中断处理程序根据数据包里面的IP和端口号就能找到对应的socket对象,将内存中的数据包拷贝到socket的接收队列中(事件被触发) ,再调用socket的等待队列中等待项设置的回调函数ep_poll_callback。
  3. ep_poll_callback 函数根据等待队列项找到epitem。
  4. 由于epitem保存了已就绪的socket的文件描述符,并且epitem对象是epoll对象的一个红黑树节点,所以ep_poll_callback函数可以将就绪的socket的文件描述符添加到epoll对象的就绪队列中。
  5. ep_poll_callback 函数检查epoll对象的等待队列上是否有等待项。
  6. 如果没有等待项,说明用户进程并未阻塞,此时软中断结束。
  7. 如果有等待项,则通过调用回调函数 default_wake_func 唤醒这个进程。
  8. 当进程醒来后,继续从epoll_wait时暂停处的代码继续执行,把epoll对象就绪队列的事件返回给用户进程,让用户进程调用recv把已经到达socket的数据拷贝到用户空间使用。

感想:

研究了整整一天终于看懂了epoll这个系统调用的底层原理。真的感觉这个系统调用的底层原理非常复杂。过不了几天,我感觉就能把这些东西忘的差不多了。为了更方便的回顾,画了epoll对象、epitem对象和socket对象的关系图。

image-20230530190725409

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 | EPOLLETEPOLLOUT | EPOLLET,则表示该文件描述符采用边缘触发方式。
  • 如果将 events 字段设置为 EPOLLINEPOLLOUT,则表示该文件描述符采用水平触发方式。

其中,EPOLLIN 表示文件描述符可读,EPOLLOUT 表示文件描述符可写,EPOLLET 表示边缘触发方式。

需要注意的是,边缘触发方式下,epoll_wait 函数只会在文件描述符上发生状态变化时才返回,而水平触发方式下,epoll_wait 函数会在文件描述符上有数据可读或可写时就返回。因此,边缘触发方式下需要更加谨慎地处理事件,否则可能会出现遗漏事件的情况。

应用场景

基于IO多路复用(epoll实现)的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 8080

int 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);
}

运行结果如下:

image-20230530101607070

以上代码的大致流程如下:

仙士可博客

Redis的IO多路复用模型简单分析

Redis采用IO多路复用技术,其底层实现原理如下:

  1. 事件驱动:Redis采用事件驱动的方式处理客户端请求。当有客户端请求到达时,Redis会将请求放入一个事件队列中,然后通过IO多路复用技术来监听事件。当有事件到达时,Redis会从事件队列中取出事件,并根据事件类型进行处理。

  2. IO多路复用:Redis采用IO多路复用技术来处理客户端请求。通过IO多路复用技术,Redis可以同时监听多个客户端请求,从而实现高并发的读写操作。

  3. 非阻塞IO:Redis采用非阻塞IO来处理客户端请求。通过非阻塞IO,Redis可以在等待客户端请求的同时,继续处理其他请求,从而提高系统的吞吐量。

  4. 事件循环: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。

需要注意的是,由于Windows和Linux的底层实现机制不同,导致在Windows上使用IOCP和在Linux上使用epoll并不完全相同,因此,在跨平台开发时需要注意这种差异。


参考链接:

  1. 深入学习IO多路复用 select/poll/epoll 实现原理
  2. 图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的
  3. 大话 Select、Poll、Epoll-腾讯云开发者社区-腾讯云
  4. Epoll原理解析 - 博客园
  5. Linux内核API default_wake_function - 极客笔记
  6. epoll工作原理 - 哔哩哔哩
  7. 小白也看得懂的 I/O 多路复用解析
  8. C语言网络编程-tcp服务器实现
  • 3
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值