关于select:一文说透IO多路复用selectpollepoll

72次阅读

共计 14024 个字符,预计需要花费 36 分钟才能阅读完成。

概述

如果咱们要开发一个高并发的 TCP 程序。惯例的做法是:多过程或者多线程。即:应用其中一个线程或者过程去监听有没有客户端连贯上来,一旦有新客户端连贯,就新开一个线程,将其扔到线程(或过程)中去解决具体的读写操作等业务逻辑,主线程(过程)持续期待,监听其余的客户端。

这样操作往往存在很大的弊病。首先是浪费资源,要晓得,单个过程的最大虚拟内存是 4G,单个线程的虚拟内存也有将近 8M,那么,如果上万个客户端连贯上来,服务器将会承受不住。

其次是浪费时间,因为你必须始终等在 accept 那个中央,非常被动。

上述的网络模型,其实说白了,就是一个线程一路 IO,在单个线程里只能解决一个 IO。因而,也可称之为单路 IO。而一路 IO,就是一个并发。有多少个并发,就必须要开启多少个线程,因而,对资源的节约是显而易见的。

那么,有没有一种形式,能够在一个线程里,解决多路 IO 呢?

咱们回顾一下多线程模型,它最大的技术难点是 acceptrecv函数都是阻塞的。只有没有新连贯上来,accept就阻塞住了,无奈解决后续的业务逻辑;没有数据过去,recv又阻塞住了,无奈解决新的 accept 申请。因而,只有可能搞定在同一个线程里同时 accept 和 recv 的问题,仿佛所有问题就迎刃而解了。

有人说,这怎么可能嘛?必定要两个线程的。

还真有可能,而这所谓的可能,就是 IO 多路复用技术。

IO 多路复用

所谓的 IO 多路复用,它的核心思想就是,把监听新客户端连贯的操作转包进来,让零碎内核来做这件事件。即由内核来负责监听有没有连贯建设、有没有读写申请,作为服务端,只须要注册相应的事件,当事件触发时,由内核告诉服务端程序去解决就行了。

这样做的益处不言而喻:只须要在一个主线程里,就能够实现所有的工作,既不会阻塞,也不会节约太多资源。

说得通俗易懂一些,就是原来须要由主线程干的活,当初都交给内核去干了。咱们不必阻塞在 acceptrecv那里,而是由内核通知程序,有新客户端连贯上来了,或者有数据发送过去了,咱们再去调用 acceptrecv就行了,其余工夫,咱们能够解决其余的业务逻辑。

那么有人问了,你不还是要调用 acceptrecv吗?为什么当初就不会阻塞了呢?

这就要深刻说一下 listenaccept的关系了。

如果服务器是海底捞火锅店的话,listen就是门口迎宾的小姐,当来了一个客人(客户端),就将其迎进店内。而 accept 则是店内的大堂经理,当没人来的时候,就始终闲在那里没事做,listen将客人 迎进来之后,accept就会调配一个服务员 (fd) 专门 服务于这个客人。

所以说,只有 listen 失常工作,就能源源不断地将客人迎进饭店(客户端能 失常连贯上服务器),即便此时并没有 accept。那么,有人必定有疑难,总不能始终 往里迎吧,酒店也是有大小的,全副挤在大堂也装不下 那么多人啊。还记得 listen 函数的第二个参数 backlog 吗?它就示意在没有 accept 之前,最多能够迎多少个客人进来。

因而,对于多线程模型来说,accept 作为大堂经理,在 没客人来的时候,就眼巴巴地盯着门口,啥也不干,当 listen 把人迎进来了,才开始干活。只能说,摸鱼,还是 accpet 会啊。

而 IO 多路复用,则相当于请了一个秘书。accept 作为大堂经理,必定有很多其余事件能够忙,他就不必 始终盯着门口,当 listen 把人迎进来之后,秘书就会把客人(们)带到经理身边,让经理安顿服务员(fd)。

只是这个秘书是内核提供的,因而不仅收费,而且勤快。收费的劳动力,何乐而不为呢?

它的流程图大略是上面这样子的:

咱们通常所说的 IO 多路复用技术,在 Linux 环境下,次要有三种实现,别离为 select、poll 和 epoll,以及 io_uring。在 darwin 平台,则有 kqueue,Windows 下则是 iocp。从性能上来说,iocp 要优于 epoll,与 io_uring 并驾齐驱。但 select、poll、epoll 的演变是一个继续迭代的过程,虽说从效率以及应用普及率上来说,epoll 堪称经典,但并不是另外两种实现就毫无用处,也是有其存在的意义的,尤其是 select。

本文不会花太多笔墨来介绍 kqueue,笔者始终认为,拿 MacOS 作为服务器开发,要么脑子瓦特了,要么就是钱烧的。基本上除了本人写写 demo 外,极少能在生产环境真正用起来。而 iocp 自成一派,将来有暇,将专门开拓专题细说。io_uring 作为较新的内核才引入的个性,本文也不宜大肆开展。

唯有 select、poll 以及 epoll,久经工夫考验,已被宽泛使用于各大出名网络应用,并由此诞生出许多经典的网络模型,切实是值得好好细说。

select

原型

select 函数原型:

/* According to POSIX.1-2001, POSIX.1-2008 */
       #include <sys/select.h>


       /* According to earlier standards */
       #include <sys/time.h>
       #include <sys/types.h>
       #include <unistd.h>


       int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数阐明:

  • nfds:最大的文件描述符 +1,代表监听这一组描述符(为什么要 +1?因为除了以后最大描述符之外,还有可能有新的 fd 连贯上来)
  • fd_set: 是一个位图汇合,对于同一个文件描述符,能够监听不同的事件
  • readfds:文件描述符“可读”事件
  • writefds:文件描述符“可写”事件
  • exceptfds:文件描述符“异样”事件,个别内核用的,理论编程很少应用
  • timeout:超时工夫:0 是立刻返回,- 1 是始终阻塞,如果大于 0,则达到设置值的微秒数即返回
  • 返回值:所监听的所有监听汇合中满足条件的总数(满足条件的读、写、异样事件的总数),出错时返回 -1,并设置 errno。如果超时工夫触发,则返回 0。

从 select 的函数原型可知,它次要依赖于三个 bitmap 的汇合,别离为可读事件汇合,可写事件汇合,以及异样事件汇合。咱们只须要将待监听的 fd 退出到对应的汇合中,当有对应事件触发,咱们再从汇合中将其 拿进去 进行解决就行了。

那么,怎么将文件描述符加到监听事件汇合中呢?

内核为咱们提供了四个操作宏:

void FD_CLR(int fd, fd_set *set);    // 将 fd 从 set 中革除进来,位图置为 0
int  FD_ISSET(int fd, fd_set *set);   // 判断 fd 是否在汇合中,返回值为 1,阐明满足了条件
void FD_SET(int fd, fd_set *set);    // 将 fd 设置到 set 中去,位图置为 1
void FD_ZERO(fd_set *set);    // 将 set 汇合清空为 0 值 

有了以上根底,咱们 就能大抵梳理一下 select 解决的流程:

  1. 创立 fd_set 位图汇合(3 个汇合,一个 readfds,一个 writefds,一个 exceptfds)
  2. FD_ZERO 将 set 清空
  3. 应用 FD_SET 将须要监听的 fd 设置对应的事件
  4. select 函数注册事件,只有 select 函数返回了大于 1 的值,阐明有事件触发,这时候把 set 拿进去做判断
  5. FD_ISSET 判断 fd 到底触发了什么事件

实现

其代码 实现如下所示:

#include <stdio.h>
#include <stdlib.h>
#include  <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>


int main(int argc, char *argv[]){
    int i, n, maxi;

    int nready,  client[FD_SETSIZE];    // FD_SETSIZE 为内核定义的,大小为 1024, client 保留曾经被监听的文件描述符,防止每次都遍历 1024 个 fd
    int maxfd, listenfd, connfd,  sockfd;
    char  buf[BUFSIZ], str[INET_ADDRSTRLEN];  // INET_ADDRSTRLEN = 16

    struct sockaddr_in clie_addr, serv_addr;
    socklen_t clie_addr_len;
    fd_set rset, allset;   //allset 为所有曾经被监听的 fd 汇合,rset 为 select 返回的有监听事件的 fd

    listenfd = socket(AF_INET, SOCK_STREAM, 0);   // 创立服务端 fd

    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(8888);

    if (bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {perror("bind");
    }
    listen(listenfd, 20);

    maxfd = listenfd;
    maxi = -1;
    for(i = 0; i < FD_SETSIZE; i++) {client[i] = -1;
    }

    FD_ZERO(&allset);
    FD_SET(listenfd, &allset);

    //----------------------------------------------------------
    // 至此,初始化全副实现,开始监听

    while(1) {
        rset = allset;  //allset 不能被 select 改掉了,所以要复制一份进去放到 rset
        nready = select(maxfd+1, &rset, NULL, NULL,  NULL);
        if (nready < 0) {perror("select");
        }

        //listenfd 有返回,阐明有新连贯建设了 
        if (FD_ISSET(listenfd, &rset)) {clie_addr_len = sizeof(clie_addr);
            connfd = accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addr_len);
            printf("received form %s at port %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)), ntohs(clie_addr.sin_port));

            // 把新连贯的 client fd 放到 client 数组中
            for (i = 0; i < FD_SETSIZE; i++) {if (client[i] == -1) {client[i] = connfd;
                    break;
                }
            }

            // 连接数超过了 1024,select 函数解决不了了,间接报错返回
            if (i == FD_SETSIZE) {fputs("too many clients\n", stderr);
                exit(1);
            }

            FD_SET(connfd, &allset);    // 把新的客户端 fd 退出到下次要监听的列表中
            if (connfd > maxfd) {maxfd = connfd;      // 次要给 select 第一个参数用的}

            if (i > maxi) {maxi = i;    // 保障 maxi 存的总是 client 数组的最初一个下标元素}

            // 如果 nready = 0,阐明新连贯都曾经解决完了,且没有已建设好的连贯触发读事件
            if (--nready == 0) {continue;}
        }

        for (i = 0; i <= maxi; i++) {if ((sockfd = client[i]) < 0) {continue;}

            //sockfd 是存在 client 里的 fd,该函数触发,阐明有数据过去了
            if (FD_ISSET(sockfd, &rset)) {if ((n = read(sockfd, buf, sizeof(buf))) == 0) {printf("socket[%d] closed\n", sockfd);
                    close(sockfd);
                    FD_CLR(sockfd, &allset);
                    client[i] = -1;
                } else if (n > 0) {
                    // 失常接管到了数据
                    printf("accept data: %s\n", buf);
                }

                if (--nready == 0) {break;}        
            }
        }
    }
    close(listenfd);
    return 0;
}

毛病

select 作为 IO 多路复用的初始版本,只能说是能用而已,性能并不能高到哪儿去,应用的局限性也比拟大。次要体现在以下几个方面:

  • 文件描述符下限:1024,同时监听的最大文件描述符也为 1024 个
  • select 须要遍历所有的文件描述符(1024 个),所以通常须要自定义数据结构(数组),独自存文件描述符,缩小遍历
  • 监听汇合和满足条件的汇合是同一个汇合,导致判断和下次监听时须要对汇合读写,也就是说,下次监听时须要清零,那么以后的汇合后果就须要独自保留。

长处

但 select 也并不是一无是处,我集体是非常喜爱 select 这个函数的,次要得益于以下几个方面:

  • 它至多提供了单线程同时解决多个 IO 的一种解决方案,在一些简略的场景(比方并发小于 1024)的时候,还是很有用途的
  • select 的实现比起 poll 和 epoll,要简单明了许多,这也是我为什么举荐在一些简略场景优先应用 select 的起因
  • select 是跨平台的,相比于 poll 和 epoll 是 Unix 独有,select 显著有更加广大的施展空间
  • 利用 select 的跨平台个性,能够实现很多乏味的性能。比方实现一个跨平台的 sleep 函数。

    • 咱们晓得,Linux 下的原生 sleep 函数是依赖于 sys/time.h 的,这也就意味着它无奈被 Windows 平台调用。
    • 因为 select 函数自身跨平台,而第五个参数恰好是一个超时工夫,即:咱们能够传入一个超时工夫,此时程序就会阻塞在 select 这里,直到超时工夫触发,这也就间接地实现了 sleep 性能。
    • 代码实现如下

      // 传入一个微秒工夫
      void general_sleep(int t){
          struct timeval tv; 
          tv.tv_usec = t % 10e6;
          tv.tv_sec = t / 10e6;
          select(0, NULL, NULL, NULL, &tv);
      }
    • select 实现的 sleep 函数至多有两个益处:

      • 能够跨平台调用
      • 精度能够准确到微秒级,比起 Linux 原生的 sleep 函数,精度要高得多。

poll

原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
      int   fd;         /* file descriptor */
      short events;     /* requested events */
      short revents;    /* returned events */
};

参数阐明:

fds:数组的首地址

nfds: 最大监听的文件描述符个数

timeout:超时工夫

鉴于 select 函数的一些 毛病和局限性,poll的实现就做了一些降级。首先,它冲破了 1024 文件描述符的限度,其次,它将事件封装了一下,形成了 pollfd 的构造体,并将这个 构造体中注册的事件间接与 fd 进行了绑定,这样 就无需每次有事件触发,就遍历所有的 fd 了,咱们只须要遍历这个 构造体数组中的 fd 即可。

那么,poll函数能够注册哪些事件类型呢?

POLLIN 读事件
POLLPRI 触发异样条件
POLLOUT 写事件
POLLRDHUP  敞开连贯
POLLERR  产生了谬误
POLLHUP  挂断
POLLNVAL 有效申请,fd 未关上
POLLRDNORM 等同于 POLLIN
POLLRDBAND 能够读取优先带数据(在 Linux 上通常不应用)。POLLWRNORM 等同于 POLLOUT
POLLWRBAND 能够写入优先级数据。

事件尽管比拟多,但咱们次要关怀 POLLINPOLLOUT就行了。

实现

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
#include <errno.h>
#include <ctype.h>


#define OPEN_MAX 1024


int main(int argc, char *argv[]){
    int i, maxi, listenfd, connfd,  sockfd;
    int nready;  // 承受 poll 返回值,记录满足监听事件的 fd 个数
    ssize_t n;  
    char  buf[BUFSIZ], str[INET_ADDRSTRLEN];  // INET_ADDRSTRLEN = 16
    struct pollfd client[OPEN_MAX];     // 用来寄存监听文件描述符和事件的汇合
    struct sockaddr_in cliaddr, servaddr;
    socklen_t clilen;
   
    listenfd = socket(AF_INET, SOCK_STREAM, 0);   // 创立服务端 fd


    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));  // 设置端口复用


    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(8888);


    if (bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {perror("bind");
    }
    listen(listenfd, 128);


    // 设置第一个要监听的文件描述符,即服务端的文件描述符
    client[0].fd = listenfd;
    client[0].events = POLLIN;   // 监听读事件



    for(i = 1; i < OPEN_MAX; i++) {   // 留神从 1 开始,因为 0 曾经被 listenfd 用了
        client[i].fd = -1;
    }


    maxi = 0;   // 因为曾经加进去一个了,所以从 0 开始就行


    //----------------------------------------------------------
    // 至此,初始化全副实现,开始监听


    for(;;) {nready = poll(client,  maxi+1, -1);   // 阻塞监听是否有客户端读事件申请


        if (client[0].revents & POLLIN) {   // listenfd 触发了读事件
            clilen = sizeof(cliaddr);
            connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen);    // 承受新客户端的连贯申请
            printf("recieved from %s at port %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port));


            for (i = 0; i < OPEN_MAX; i++) {if (client[i].fd < 0) {client[i].fd = connfd;  // 将新连贯的文件描述符加到 client 数组中
                    break;
                }
            }


            if (i == OPEN_MAX) {perror("too many open connections");
            }


            client[i].events = POLLIN;


            if(i > maxi) {maxi = i;}


            if (--nready == 0) {continue;}
        }
        
        // 后面的 if 没满足,阐明有数据发送过去,开始遍历 client 数组
        for (i = 1; i <= maxi; i++) {if ((sockfd = client[i].fd) < 0) {continue;}

            // 读事件满足,用 read 去承受数据
            if (client[i].revents & POLLIN) {if ((n = read(sockfd, buf, sizeof(buf))) < 0) {if (errno = ECONNRESET) {   // 收到 RST 标记
                        printf("client[%d] aborted conection\n", client[i].fd);
                        close(sockfd);
                        client[i].fd = -1;  //poll 中不监控该文件描述符,间接置 - 1 即可,无需像 select 中那样移除
                    } else {perror("read error");
                    }
                } else if (n == 0) {    // 客户端敞开连贯
                    printf("client[%d] closed connection\n", client[i].fd);
                    close(sockfd);
                    client[i].fd = -1; 
                } else {printf("recieved data: %s\n", buf);
                }
            }
            if (--nready <= 0) {break;}
            
        }
    }
    close(listenfd);
    return 0;
}

长处

poll 函数相比于 select 函数来说,最大的长处就是冲破了 1024 个文件描述符的限度,这使得百万并发变得可能。

而且不同于 select,poll 函数的监听和返回是离开的,因而不必在每次操作之前都独自备份一份了,简化了代码实现。因而,能够了解为 select 的降级增强版。

毛病

尽管 poll 不须要遍历所有的文件描述符了,只须要遍历退出数组中的描述符,范畴放大了很多,但毛病依然是须要遍历。假如真有百万并发的场景,当仅有两三个事件触发的时候,依然要遍历上百万个文件描述符,只为了找到那触发事件的两三个 fd,这样看来,就有些得失相当了。而这个毛病,将在 epoll 中得以彻底解决。

poll 作为 一个适度版本的实现,说实话位置有些难堪:它既不具备 select 函数跨平台的劣势,又不具备 epoll 的高性能。因而应用面以及遍及水平相对来说,反而是三者之中最差劲的一个。

若说它的惟一应用场景,大略也就是开发者既想冲破 1024 文件描述符的限度,又不想把代码写得像 epoll 那样简单了。

epoll

原型

epoll 堪称是以后 IO 多路复用的最终状态,它是 poll 的 加强版本。咱们说 poll 函数,尽管冲破了 select 函数 1024 文件描述符的限度,且把监听事件和返回事件离开了,然而说到底还是要遍历所有文件描述符,能力晓得到底是哪个文件描述符触发了事件,或者须要独自定义一个数组。

而 epoll 则能够返回一个触发了事件的所有描述符的数组汇合,在这个数组汇合里,所有的文件描述符都是须要解决的,就不须要咱们再独自定义数组了。

尽管 epoll 功能强大了,然而应用起来却麻烦得多。不同于 select 和 poll 应用一个函数监听即可,epoll 提供了三个函数。

epoll_create

首先,须要应用 epoll_create 创立一个句柄:

#include <sys/epoll.h>

int epoll_create(int size);

该函数返回一个文件描述符,这个文件描述符并不是 一个惯例意义 的文件描述符,而是一个均衡二叉树(精确来说是红黑树)的根节点。size 则是树的大小,它代表你将监听多少个文件描述符。epoll_create 将依照传入的大小,结构出一棵大小为 size 的红黑树。

留神:这个 size 只是倡议值,理论内核并不一定局限于 size 的大小,能够监听比 size 更多的文件描述符。然而因为均衡二叉树减少节点时可能须要自旋,如果 size 与理论监听的文件描述符差异过大,则会减少内核开销。

epoll_ctl

第二个函数是 epoll_ctl,这个函数次要用来操作 epoll 句柄,能够应用该函数往红黑树里减少文件描述符,批改文件描述符,和删除文件描述符。

能够看到,select 和 poll 应用的都是 bitmap 位图,而 epoll 应用的是红黑树。

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl 有四个参数,参数 1 就是 epoll_create 创立进去的句柄。

第二个参数 op 是操作标记位,有三个值,别离如下:

  • EPOLL_CTL_ADD 向树减少文件描述符
  • EPOLL_CTL_MOD 批改树中的文件描述符
  • EPOLL_CTL_DEL 删除树中的文件描述符

第三个参数就是须要操作的文件描述符,这个没啥说的。

重点看第四个参数,它是一个构造体。这个构造体原型如下:

           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 */
           };

第一个元素为 uint32_t 类型的 events,这个和 poll 相似,是一个 bit mask,次要应用到的标记位有:

  • EPOLLIN 读事件
  • EPOLLOUT 写事件
  • EPOLLERR 异样事件

这个构造体还有第二个元素,是一个 epoll_data_t 类型的联合体。咱们先重点关注外面的 fd,它代表一个文件描述符,初始化的时候传入须要监听的文件描述符,当监听返回时,此处会传出一个有事件产生的文件描述符,因而,无需咱们遍历失去后果了。

epoll_wait

epoll_wait 才是真正的监听函数,它的原型如下:

int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout);

第一个参数不用说了,留神第二个参数,它尽管也是 struct epoll_event * 类型,然而和 epoll_ctl 中的含意不同,epoll_ctl 代表传入进去的是一个地址,epoll_wait 则代表传出的是一个数组。这个数组就是返回的所有触发了事件的文件描述符汇合。

第三个参数 maxevents 代表这个数组的大小。

timeout 不用说了,它代表的是超时工夫。不过要留神的是,0 代表立刻返回,- 1 代表永恒阻塞,如果大于 0,则代表毫秒数(留神 select 的 timeout 是微秒)。

这个函数的返回值也是有意义的,它代表有多少个事件触发,也就能够简略了解为传出参数 events 的大小。

监听流程

大抵梳理一下 epoll 的监听流程:

  • 首先,要有一个服务端的 listenfd
  • 而后,应用 epoll_create 创立一个句柄
  • 应用 epoll_ctl 将 listenfd 退出到树中,监听 EPOLLIN 事件
  • 应用 epoll_wait 监听
  • 如果 EPOLLIN 事件触发,阐明有客户端连贯上来,将新客户端退出到 events 中,从新监听
  • 如果再有 EPOLLIN 事件触发:
  • 遍历 events,如果 fd 是 listenfd,则阐明又有新客户端连贯上来,反复下面的步骤,将新客户端退出到 events 中
  • 如果 fd 不为 listenfd,这阐明客户端有数据发过来,间接调用 read 函数读取内容即可。

触发

epoll 有两种触发形式,别离为 程度触发 边际触发

  • 程度触发

    所谓的程度触发,就是只有仍有数据处于就绪状态,那么可读事件就会始终触发。

    举个例子,假如客户端一次性发来了 4K 数据,然而服务器 recv 函数定义的 buffer 大小仅为 1024 字节,那么一次必定是不能将所有数据都读取完的,这时候就会持续触发可读事件,直到所有数据都解决实现。

    epoll 默认的触发形式就是程度触发。

  • 边际触发

    边际触发恰好相反,边际触发是只有数据发送过去的 时候会触发一次,即便数据没有 读取完,也不会持续触发。必须 client 再次调用 send 函数触发了可读事件,才会持续读取。

    假如客户端 一次性发来 4K 数据,服务器 recv 的 buffer 大小为 1024 字节,那么服务器在第一次收到 1024 字节之后就不会持续,也不会有新的可读事件触发。只有 当客户端 再次发送数据的时候,服务器可读事件触发,才会持续读取第二个 1024 字节数据。

    留神:第二次可读事件触发时,它读取的依然是上次未读完的数据,而不是客户端第二次发过来的新数据。也就是说:数据没读完尽管不会持续触发 EPOLLIN,但不会失落数据。

  • 触发形式的设置:

    程度触发和边际触发在内核里 应用两个 bit mask 辨别,别离为:

    EPOLLLT 程度 触发

    EPOLLET 边际触发

    咱们只须要在注册事件的时候将其与须要注册的工夫 做一个位或运算即可:

    ev.events = EPOLLIN;    //LT
    ev.events = EPOLLIN | EPOLLET;   //ET

实现

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<sys/epoll.h>
#include<ctype.h>


#define OPEN_MAX 1024


int main(int argc, char **argv){
    int i, listenfd, connfd, sockfd,epfd, res, n;
    ssize_t nready = 0;
    char buf[BUFSIZ] = {0};
    char str[INET_ADDRSTRLEN];
    socklen_t clilen;
    struct sockaddr_in cliaddr, servaddr;
    struct epoll_event event, events[OPEN_MAX];


    // 开始创立服务端套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);


    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));


    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(8888);


    bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
    listen(listenfd, 128);


    // 开始初始化 epoll
    epfd = epoll_create(OPEN_MAX);


    event.events = EPOLLIN;
    event.data.fd = listenfd;
    res = epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &event);
    if (res == -1) {perror("server epoll_ctl error");
        exit(res);
    }
    
    for(;;) {
        // 开始监听
        nready = epoll_wait(epfd, events, OPEN_MAX, -1);
        if (nready == -1) {perror("epoll_wait error");
            exit(nready);
        }


        for (i = 0; i < nready; i++) {if (!(events[i].events & EPOLLIN)) {continue;}
            if (events[i].data.fd == listenfd) {
                // 有新客户端连贯
                clilen = sizeof(cliaddr);
                connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
                printf("received from %s at port %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
                    ntohs(cliaddr.sin_port));


                event.events = EPOLLIN;
                event.data.fd = connfd;
                if (epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &event) == -1) {perror("client epoll_ctl error");
                    exit(-1);
                }
            } else {
                // 有数据能够读取
                sockfd = events[i].data.fd;
                n = read(sockfd, buf, sizeof(buf));
                if (n ==0) {
                    // 读到 0,阐明客户端敞开
                    epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
                    close(sockfd);
                    printf("client[%d] closed connection\n", sockfd);
                } else if (n < 0){
                    // 出错
                    epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
                    close(sockfd);
                    printf("client[%d] read error\n", sockfd);
                } else {
                    // 读到了数据
                    printf("received data: %s\n", buf);
                }


            }
        }
    }
    close(listenfd);
    close(epfd);
     
    return 0;
}

长处

epoll 的长处不言而喻,它解决了 poll 须要遍历所有注册的 fd 的 问题,只须要关怀触发了工夫的极少量 fd 即可,大大晋升了效率。

而更有意思 的是 epoll_data_t 这个联合体,它外面有四个元素:

typedef union epoll_data {
      void        *ptr;
      int          fd;
      uint32_t     u32;
      uint64_t     u64;
 } epoll_data_t;

简略开发时,咱们能够将 fd 记录在其中,然而 咱们留神到 这外面还有一个 void * 类型的元素,那就提供了有限可能。它能够是一个 struct,也能够是一个 callback,也能够是 struct 嵌套 callback,从而实现无线的扩大可能。赫赫有名的反应堆 reactor 模型就是通过这种形式实现的。

在下篇专题里,笔者将带大家走进 reactor 模型,领略 epoll 的神奇魅力。

毛病

什么?epoll 也有毛病?当然有,我认为 epoll 的最大毛病就是代码实现起来变得复杂了,写起来简单,了解起来更简单。

而且还有一个不能算毛病的毛病,对于笔者这样一个长期开发跨平台应用程序的开发者来说,epoll 虽好,但无奈实现一套跨平台的接口封装,却过于鸡肋了。

正文完
 0