阅读分享 -- 《Linux 系统编程 第二版》V

书接上回IV 这里我们要分享的时I/O多路监听部分。阅读分享 -- 《Linux 系统编程 第二版》IV|ZklMao-Space (zklkk.online)

概述

这部分我们要分享的多路I/O 监视。可能会有点长。epoll 详细分析请看这篇 epoll detail|ZklMao-Space (zklkk.online)

问题回答为什么要使用I/O 多路复用?

省流:

硬件性能羸弱(实时操作系统,作业简单 [ 单进程 单路I/O 实时处理 ])

---需求提升 - 支撑工艺提升 --->

硬件性能提升 (分时系统) 多进程 [作业任务逐渐复杂] ->

-> 发现多进程一对一模型进程占用资源在当时硬件资源下占用过多 ->

-> 发明名一对多服务模型,监视者思想被提出,因此有了多路复用模型。

多路复用模型介绍

select(...)

参考: select(2) - Linux manual page (man7.org)

       #include <sys/select.h>

       typedef /* ... */ fd_set;

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

       void FD_CLR(int fd, fd_set *set);
       int  FD_ISSET(int fd, fd_set *set);
       void FD_SET(int fd, fd_set *set);
       void FD_ZERO(fd_set *set);

       int pselect(int nfds, fd_set *_Nullable restrict readfds,
                  fd_set *_Nullable restrict writefds,
                  fd_set *_Nullable restrict exceptfds,
                  const struct timespec *_Nullable restrict timeout,
                  const sigset_t *_Nullable restrict sigmask);

----------------------------------------------------------------------------------------------------
   Tip: Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

       pselect():
           _POSIX_C_SOURCE >= 200112L

在开始select(...) 前先做前置部分的分享,分别时上面上四个FD_...(...) 集合函数 和 timeval 结构体,timeval 结构是select(...) 的超时返回参数,即等待指定的设备集可用的最长等待时间。如果timeout和值设置为0,select 会立即返回;如果设置为NULL,则会一直阻塞等待传入描述集中有描述就绪时才返回。

           struct timeval {
               time_t      tv_sec;         /* seconds */
               suseconds_t tv_usec;        /* microseconds */
           };

人话版本:

[超时参数所有参数成员为0] : 有事件则返回事件数,没有事件也立即返回

[超时参数成员不为0] 最多等待tv_sec+tv_usec 时间,这段时间内有时间则返回,没有时间到也返回

[超时参数为nullptr] 一直阻塞等待,知道有事件才返回

select(...) 参数解析

nfds

最大描述符,这个有点小意思了,这是select(...)/pselect(...) 函数socket bitmap表上的最大描述符.select(...) 工作bitmap大致如下(对应实现 fs/select.c):

image-pnop.png

  • select(...) 是系统库提供给用户开发的上层接口,select(...) 工作时会向下调用sys_select(...);sys_select(...) 以nfds 右边界从fds_bits 数组中左向右开始遍历检查一遍所有的已置位描述符.并统计有事件的描述符个数.nfds 是一个优化参数,在监听描述符很少的时候可以减少访问操作次数.
readfds

读描述符集,需要监视描述符有可读事件可将描述符加入此集合;可读事件[描述符指向的通道有数据可读(socket|pip|stdin), 对端socket 关闭也会触发可读事件,但读到的数据长度是0]

writefds

可写合集.

exceptfds

等待带外数据存在性或意味错误条件检查的套接口。请注意如果设置了SO_OOBINLINE选项为假FALSE,则只能用这种方法来检查带外数据的存在与否。对于SO_STREAM类型套接口,远端造成的连接中止和KEEPALIVE错误都将被作为意味出错。如果套接口正在进行连接connect()(非阻塞方式),则连接试图的失败将会表现在exceptfds参数中。

timeout

超时参数.见上

描述符就绪条件

image-ehbg.png

大致工作原理

程序调用select(...) 后,会进入内核态调用内核函数sys_select(...) 将用户传入的read/write/exp 合集拷贝到内核中描述符数组中,开始逐一检查该描述符在内核作业列表上是否有读写或意外事件要处理(TCP 协议栈中,若每个socket 都有对应的TCP 控制块,当收的数据到达最低可读水位线后会将该事件回调通知到内核),若有则将该描述符加入到对应读/写/意外 合集中直到遍历到最后一个描述符并处理完该流程,内核会将这些修改后的读写/意外合集拷贝覆盖到用户传参的集合中,并返回用户态,将调用权返还给调用进程,让进程继续进行后续作业.

select 在windows 下不同点
  • 在 Linux 平台上,select 函数的第一个参数必须设置成需要检测事件的所有 fd 中的最大值加1
  • nfds 在windows 下是一个无效参数

  • select 最大监控描述符个数是1024(windows 下64 / Linux 需要修改配置文件) Linux 下不可修改,但在windows 下可重新定义 宏 FD_SETSIZE : #define FD_SETSIZE xxx

    ps:这不是posix 接口设计的锅,是glibc 实现的锅,所以要项支持更多连接数最好还是使用 poll/epoll man: select(2) - Linux manual page (man7.org) --- os:至于为什么是1024 可能是数组在栈上,访问比较快所以综合考量用了1024

pselect(...)

在分享pselect(...)前先讲信号集,信号掩码。pselect(...) 和 select(...)最大的区别就是加入了信号集(信号掩码)处理。加入信号掩码的目的是希望select(...) 不被某些信号中断(比如某些作业需要保证原子性不希望在作业期间有竞态事件发生(被信号中断挂起或返回)).

阻塞信号集(人话:信号屏蔽字)

也叫信号屏蔽字,将某些信号加入集合,对他们设置屏蔽,当屏蔽某个信号后,再收到该信号,该信号的处理将推后(解除屏蔽后)。

未决信号集

主要有二:

  • 信号产生,未决信号集中描述该信号的位立刻翻转为1,表信号处于未决状态;当信号被处理对应位翻转回为0,这一时刻往往非常短暂。(中间状态)
  • 信号产生后由于某些原因主要是阻塞不能抵达,这类信号的集合称之为未决信号集。在屏蔽解除前,信号一直处于未决状态。(被标记信号已被屏蔽,所有不会被处理,一直被标记,知道接触屏蔽,下次再次来信号时会出现上面的情况)
信号掩码

就是下面这个玩意,类似于 0666 或者 O_RDONLY | O_WRONLY | O_CREATE 后的int 数

image-hpru.png

Linux 信号表

在 Linux 下,每个信号的名字都以字符 SIG 开头,每个信号和一个数字编码相对应,在头文件 signum.h 中,这些信号都被定义为正整数。信号名定义路径:/usr/include/i386-linux-gnu/bits/signum.h

要想查看这些信号和编码的对应关系,可使用命令:kill -l

image-urvp.png

列表中,编号为 1 ~ 31 的信号为传统 UNIX 支持的信号,是不可靠信号(非实时的),编号为 32 ~ 63 的信号是后来扩充的,称做可靠信号(实时信号)。不可靠信号和可靠信号的区别在于前者不支持排队,可能会造成信号丢失,而后者不会。

非可靠信号一般都有确定的用途及含义, 可靠信号则可以让用户自定义使用。

 void FD_CLR(int fd, fd_set *set);
   int  FD_ISSET(int fd, fd_set *set);
   void FD_SET(int fd, fd_set *set);
   void FD_ZERO(fd_set *set);
Select(...) I/O 多路监控编写流程
  1. fd_set 创建感兴趣的合集 readset/writeset/expset
  2. FD_ZERO(...) 清空/初始化 上面创建的合集 readset/writeset/expset
  3. FD_SET(...) 将需要监听的描述符按照需求加入 读/写/意外 readset/writeset/expset 合集中 (此处可以优化,同时创建一个各个合集相同备份,因为select 每次调用都会修改用户传进去的描述符合集)
  4. 更新select nfds 参数值 并且 +1(Linux 下),设置select(...) 超时参数;pselect(...) 还可设置屏蔽信号集参数...
  5. 循环调用 select(...)/pselect(...) 并接收 select(...) 返回值
  6. 根据select(...)/pselect(...) 返回值作业务处理 (返回值为-1 则需要作异常处理) 大于0 则表示有描述符就绪可操作
  7. 使用FD_ISSET(...) 逐个检查记录的fd 在那个合集中 readset/writeset/expset 返回值大于0 则表示在此集合中 并作 读/写/异常 业务处理
  8. 回到 3 还原备份的描述符合集到传参合集中继续...

image-uild.png

注意事项
  • timeval 参数每次调用select 都会被修改,所有每次传参前都需要初始化为用户设定值的超时值,不再次初始化会使传入超时值为0【此时程序占用cpu会飙高,在少事件或者没有事件的情况下跑了个空while(1);】
  • select(...)/pselect(...) 每次调用都会修改用户 传入集合注意备份恢复。
  • 在 Linux 平台上,select 函数的第一个参数必须设置成需要检测事件的所有 fd 中的最大值加1
  • 下线或已关闭的描述符要及时用FD_CLR(...) 删除,否则影响下次FD_ISSET(...) 判断。
  • select(...) 适合总数量较少的连接合集频繁上下线的情况。为什么在linux 为1024 在windows下64 是因为用资源换效率,数据在栈中,速度快,且因为默认情况下最大也就1024 数组短全满也不会花费多少事件去遍历处理这个数组。
  • 在 select 系统调用中,如果 select 上监听的描述符,在其他线程上被关闭了,则这种行为是未定义的。某些 UNIX 系统上,select 会停止阻塞直接返回,并且将该描述符视为准备好的(接下来的 IO 操作会发生错误)。在 Linux(以及其他系统)上,在其他线程上关闭该描述符对 select 无影响,epoll 上的处理方式也一样。在 2.6.37 之前,如果 timeout 参数大于 (LONG_MAX HZ) 毫秒的话,则会被视为 - 1,也就意味着永远等待。所以,如果某系统上 sizeof(long)等于 4,HZ 等于 1000,则如果 timeouts 大于 35.79 的话,就会给视为无限等待。((2^31 – 1) /1000/1000/60 == 35.79)

Select 缺陷机制

    1.select 支持的文件描述符数量有限,默认是 1024
    2. 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,fd 越多开销越大
    3. 被监控的 fds 集合中,只要有一个数据可读,整个socket 集合都会被遍历一次调用 sk 的 poll 函数收集可读事件

poll(...)

poll 一般不用,虽然对比select 入参减少,但仍然会作和select(...) 函数一样的两次拷贝(用户穿入参数还是会被修改)。且poll(...) 不可移植。

参数解析

// file: /usr/include/sys/poll.h

/* Data structure describing a polling request.  */
struct pollfd
  {
    int fd;                     /* File descriptor to poll.  */
    short int events;           /* Types of events poller cares about. 用户感兴趣的事件集(事件掩码) */
    short int revents;          /* Types of events that actually occurred. 此项内核操作,描述符发生的事件合集(事件掩码)  */
  };

/* Poll the file descriptors described by the NFDS structures starting at
   FDS.  If TIMEOUT is nonzero and not -1, allow TIMEOUT milliseconds for
   an event to occur; if TIMEOUT is -1, block until an event occurs.
   Returns the number of file descriptors with events, zero if timed out,
   or -1 for errors.

   This function is a cancellation point and therefore not marked with
   __THROW.  */
extern int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);
  • fds一个指向 struct pollfd 结构体数组的指针,用于指定待监视的文件描述符及其感兴趣的事件。每个 struct pollfd 结构包含一个文件描述符 fd 和一个短整型 events,用于指定关注的事件类型。revents 字段在 poll 返回时被内核修改,用于指示发生的事件类型。
  • nfds:表示 fds 数组的大小,即待监视的文件描述符数量。这里填参也要 nfds + 1,用意和上面selec(...) 一样。
  • timeout:指定阻塞等待的时间(以毫秒为单位)

poll(...) 基本工作原理

image-odgq.png

  • 用户将想要监听的socket文件绑定struct pollfd对象,并注册监听事件至struct pollfd对象events成员,监听多个socket文件使用struct pollfd数组。
  • 用户通过struct pollfd数组注册poll事件至poll_list链表,poll_list链表单个元素可以存储固定数量的struct pollfd对象。
  • poll系统调用采用轮询方式获取socket事件信息,一次poll调用需完成整个poll_list链表轮询工作,轮询socket的过程中会创建socket等待队列项,并加入socket等待队列(用于socket唤醒进程)。如果检测到socket处于就绪状态,将socket事件保存在struct pollfd对象的revents成员。
  • poll系统调用完成一次轮询后,如果检测到有socket处于就绪状态,则将poll_list链表所有的struct pollfd通过copy_to_user拷贝至用户struct pollfd数组。如果未检测到有socket处于就绪状态,根据超时时间确定是否返回或者阻塞进程。
  • socket检测到读,写,异常事件后,会通过注册到socket等待队列的回调函数poll_wake将进程唤醒,唤醒的进程将再次轮询poll_list链表。

抄自: 图解Linux poll机制,终于集齐IO复用三剑客(精华篇) (qq.com)

人话版本:

创建poll_list链表,拷贝用户传入的数组按段均分到链表中的每一节点 -> (第一次轮询poll_list 创建 socket等待列表,注册唤醒函数poll_wake()) 轮询链表 检查有无事件发生 ->

a.有事件 -> 将对应revent 置为对那个事件标志 继续遍历做对应工作,直到遍历完成,经链表中的数据覆盖拷贝回用户传入的数组

b.无事件 -> 继续遍历,遍历完若设置了超时则返回,若没有设置超时则阻塞,等待加入socket等待链表的socket 有事件就绪,并调用poll_weak() 唤醒进程,轮询poll_list 链表,重复上面的工作。

poll 事件定义

常量 说明
POLLIN 普通或优先级带数据可读
POLLRDNORM 普通数据可读
POLLRDBAND 优先级带数据可读
POLLPRI 高优先级数据可读
POLLOUT 普通数据可写
POLLWRNORM 普通数据可写
POLLWRBAND 优先级带数据可写
POLLERR 发生错误
POLLHUP 发生挂起
POLLNVAL 描述字不是一个打开的文件
events和revents对poll事件支持情况 (异常事件用户不可设置到感兴趣的掩码中,需要系统产生并修改)

image-ukqy.png

总结:异常事件用户不可设置到感兴趣的掩码中,需要系统产生并修改。

编程模型

image-wccx.png

  • 初始化pollfd 数组,将监听fd放入数组中,一般时第一个,设置感兴趣的事件。
  • 更新nfds,将最大fnfs +1 和其他参数填入poll(...); while(1) poll(...);
  • 获取poll(...) 返回值。若返回 -1 做异常判断,= 0 continue; > 0 遍历传入nfds 数组作业务处理:
    a. 若有事件产生的是监听fd [if (fds[0].fd && fds[0].revents==POLLIN) ...] 说明有链接进来。加新fd到fds数组。
    b. 不是监听fd 产生事件,判断对应fd 的 revents 事件位看看是什么事件。作对应处理。若为关闭事件则需在fds 数中移除该fd

poll优缺点

1. 每次调用 poll,都需要把 fd 集合从用户态拷贝到内核态,fd 越多开销越大
2. 每次调用 poll,都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大

优点:

  • poll没有1024最大文件描述符限制。
  • poll监视事件(events)和返回事件(revents)分离,简化编程。

poll和select的区别?

poll和select底层实现非常相似,分析poll和select内核源码会发现二者之间很多地方都复用了相同的代码。

poll可以说是select的加强版,poll优化了select一些设计缺陷:

  • poll不受1024最大文件描述符限制,poll采用poll_list链表方式存储输入和输出事件,理论上可以不受最大文件描述符限制。
  • poll传入的是struct pollfd数组,并指定了数组长度,可以减少无效的轮询,提高轮询效率。
  • poll监视事件(events)和返回事件(revents)分离,每次调用poll不需要重新设置struct pollfd对象。
  • poll返回时不会返回剩余超时时间,用户不需要当心超时出现异常。(select 会修改timeval 结构体,返回剩余时间)

poll对select做了很多优化但依然没有改变轮询方式,也就改变selec执行效率低的本质问题。

epoll(...)

epoll 是基于事件驱动的 I/O 方式。相对于 select 来说,epoll 没有描述符个数限制;使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件放到内核的一个事件表中,通过直接物理内存映射,使其在用户空间也可直接访问,省去了拷贝带来的资源消耗

参数解析

#file: /usr/include/sys/epoll.h
er is a buffer that will contain triggered
   events. The "maxevents" is the maximum number of events to be
   returned ( usually size of "events" ). The "timeout" parameter
   specifies the maximum wait time in milliseconds (-1 == infinite).

   This function is a cancellation point and therefore not marked with
   __THROW.  */
extern int epoll_wait (int __epfd, struct epoll_event *__events,
                       int __maxevents, int __timeout);

epoll_event 结构体
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 */
} __EPOLL_PACKED;

其中,events是事件类型,包括以下几种:

  • EPOLLIN: 可读事件。

  • EPOLLOUT: 可写事件。

  • EPOLLPRI: 紧急事件,有紧急数据可读。

  • EPOLLRDHUP: 连接关闭事件。

  • EPOLLERR:错误事件,发生错误。

  • EPOLLHUP:挂起事件,被挂起。

  • EPOLLET: 置文件描述符为边沿触发模式,epoll 上默认的行为模式是水平触发模式;

  • EPOLLONESHOT (since Linux 2.6.2) :避免竞争文件描述符(这里是网络编程,那就是避免竞争套接字); 配置文件描述符为一次性的 (one-shot)。当该文件上通过 epoll_wait 触发了一个事件之后,该文件描述符就被内部 disable 了(但并未移除出监听列表),在 epoll 实例上不再报告该文件描述符上的事件了。用户必须用 EPOLL_CTL_MOD 调用 epoll_ctl,以新的事件掩码再次注册该描述符。具体用例见下:

    EPOLLONESHOT事件使用场合:一个线程在读取完某个socket上的数据后开始处理这些数据,而数据的处理过程中该socket又有新数据可读,此时另外一个线程被唤醒来读取这些新的数据。于是,就出现了两个线程同时操作一个socket的局面。可以使用epoll的EPOLLONESHOT事件实现一个socket连接在任一时刻都被一个线程处理。 作用:对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多出发其上注册的一个可读,可写或异常事件,且只能触发一次。 使用:注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个sockt。 效果:尽管一个socket在不同事件可能被不同的线程处理,但同一时刻肯定只有一个线程在为它服务,这就保证了连接的完整性,从而避免了很多可能的竞态条件。

    void addfd( int epollfd, int fd, bool oneshot )
    {
        struct epoll_event event;
        event.data.fd = fd;
        event.events = EPOLLIN | EPOLLET;
        if( oneshot )
        {
            event.events |= EPOLLONESHOT;
        }
        epoll_ctl( epollfd, EPOLL_CTL_ADD, fd, &event );
        setnonblocking( fd );
    }
    
    void reset_oneshot( int epollfd, int fd )
    {
        struct epoll_event event;
        event.data.fd = fd;
        event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
        epoll_ctl( epollfd, EPOLL_CTL_MOD, fd, &event );
    }
    
    ....
    ret = recv(...)
        if(...)
        {}
        else if( ret < 0 )
        {
            // EWOULDBLOCK 【Vsxwork | windows】:用于非阻塞模式,不需要重新读或者写
            if( errno == EAGAIN || errno == EWOULDBLOCK) 
            {
                reset_oneshot( epollfd, sockfd );
                printf( "read later\n" );
                break;
            }
        }
    

结构体中的 epoll_data是一个联合体,用于在 epoll_event结构体中传递事件数据。它有四个成员变量,可以根据具体的需求选择使用其中的一个。通常可以选择 int 类型的 fd,用于存储发生对应事件的文件描述符

epoll_create (...)

创建一个 epoll fd,返回一个新的 epoll 文件描述符。失败然会-1,并设置errno

  • __size :参数 size用于指定监听的文件描述符个数,Linux 2.6.8 之后该参数已经没有实际意义。传入一个大于 0 的值即可。

epoll_ctl (...)

向 epoll 中注册事件,该函数如果调用成功返回 0,否则返回 - 1

  • int __epfd: 指向epoll 示例的描述符 (用epoll_create(...) 创建的epoll fd)
  • int __op : 操作类型/操作码 [传入对应宏] 可以是下面三个
    • EPOLL_CTL_ADD:将文件描述符添加到 epoll 实例中。
    • EPOLL_CTL_MOD:修改已添加到 epoll 实例中的文件描述符的关注事件。
    • EPOLL_CTL_DEL:从 epoll 实例中删除文件描述符。
  • int __fd : 要控制的文件描述符
  • struct epoll_event *__event : 指向 epoll_event结构体的指针,用于指定要添加、修改或删除的事件

epoll_wait (...)

成功时返回接收到的事件的数量。如果超时时间为 0 并且没有事件发生,则返回 0。失败返回-1,设置errno

  • int __epfd: 指向epoll 示例的描述符 (用epoll_create(...) 创建的epoll fd)

  • struct epoll_event *__events :用于接收事件的 epoll_event结构体数组。

  • int __maxevents:events数组的大小,最多可以接收多少个事件。

  • int __timeou:超时时间单位为毫秒,表示 epoll_wait 函数阻塞的最长时间。常用的取值有以下三种:

    * - `-1`:表示一直阻塞,直到有事件发生。
    * - `0`:表示立即返回,不管有没有事件发生。
    * `> 0`:表示等待指定的时间,如果在指定时间内没有事件发生则返回。
    

基本工作原理

看图说话. 详细分析见 epoll detail|ZklMao-Space (zklkk.online)

image-dolc.png

image-ubqy.png

epoll 是同做在应用程序和内核之间的一个多路监控模型。epoll 模型在poll 解决select 描述符限制的基础上进一步优化了 poll 遗留的两大问题,即用户空间和内核空间的往返两次拷贝,每次检查事件都需要从头遍历描述符数组/描述符链表的效率问题。基本工作原理如下:

应用程序调用epoll_create() 这个函数向下调用对应系统调用,陷入内核态,内核创建一个anon file(这个anon file 占用的文件描述符就是epoll_create() 返回的epoll 示例描述符) 这个文件有一个private_data 的数据指针指向创建好的(内核匿名文件,这个就是epoll 高效的原因,不需要拷贝用户态的数据到内核态)一个eventpoll对象,这个对象包含两个比较重要的数据结构,一个是红黑树的根节点,一个是双向链表,应用程序使用epoll_ctl() 添加进来的事件添加的同时 epitem 也会被创建,同时将唤醒回调函数注册仅内核协议栈中,当有数据从网卡驱动中通过DMA 拷贝到内核缓冲区中时,协议栈会调用对应的回调唤醒函数唤醒因epoll_wait() 而陷入睡眠的进程并将对应的红黑树节点指向双端链表,epoll_wait() 会在内核态中遍历eventpoll对象中的双向链表,将就绪的事件拷贝到用户态,然后返回给应用程序。

编程模型

image-bpuj.png

LT/ET 使用说明

边缘触发(ET 模式)在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞 socket,以避免由于一个文件描述符的阻塞读 / 阻塞写操作把处理多个文件描述符的任务饿死。

注意:

  1. 默认情况下,epoll 采用 LT 模式;若要采用 ET 模式,调用 epoll_ctl 的时候在 events 中添加 EPOLLET。
  2. 对于读写的 connfd,边缘触发模式下,必须使用非阻塞 fd,并要一次性全部读写完数据(否则会干扰其他事件)。

水平触发是缺省的工作方式,并且同时支持 block 和 no-block (可以使用 fcntl函数的 O_NONBLOCK标志来将文件描述符设置为非阻塞模式) socket. 在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的 select/poll 都是这种模型的代表。

使用技巧

  • 在使用边沿触发时,是否需要持续调用 read/write,直到它们返回 EAGAIN,这取决于描述符类型,如果描述符是 packet/token-oriented 类型的,比如 UDP 数据报,或者 canonical 模式下的终端,则探测 IO 读写空间耗尽唯一方法就是持续调用 read/write 直到返回 EAGAIN。
  • epoll 实例的描述符本身,也可以使用 poll/epoll/select 进行监听,如果该 epoll 实例正在等待事件,则该 epoll 实例就是可读的。
  • 对于流式文件,比如管道、FIFO、流套接字等,则探测 IO 读写空间耗尽,还可以通过检测 read/write 返回值进行判断。比如,若调用 read 请求一定数量的数据,但是 read 返回值小于该请求数,则可以断定该描述符的 IO 读空间已经耗尽了。这种情形同样类似于 write。

注意事项

  • Epoll 的连接上限取决于 file-max.conf 配置文件中的软限制值、硬限制值和物理内存的大小.
  • 如果在同一个 epoll 实例上,注册同一个描述符两次的话,则会返回 EEXIST 错误。
    [ 但可以通过 (dup, dup2) 可以向同一个 epoll 实例添加拷贝的描述符 ,当重复的描述符注册不同的事件时,使用这种技巧可以用来过滤事件。]
  • 如果向两个 epoll 实例注册了指向相同的描述符,那么事件触发时,会通知到所有的 epoll 实例,但是这么做时,一定要小心。
  • EPOLLONESHOT 仅监听一次事件。当监听完这次事件之后,就会把这个fd从epoll的内核事件检测的队列中删除;如果想要再次使用请使用EPOLL_CTL_MOD重新添加事件;【注意这里的移除的队列并非从内核事件中删除该事件,仅仅是从内核事件检测队列中移除,而内核事件表中任然存在该事件,】对于套接字而言如果不close(该套接字),那么该套接字仍然会存在,所以记住不用了close(该套接字);close(该套接字),就从内核事件表中移除了
  • 边沿触发需要一个不同的方式来写程序,通常利用非阻塞 IO。并需要仔细检查 EAGAIN。
  • 注意,如果某个线程阻塞于 epoll_wait 时,另一个线程向相同的 epoll 实例上添加了新的文件描述符,而且该描述符上的事件触发,则会导致原来阻塞于 epoll_wait 上的线程停止阻塞。
  • 最新版本epoll 已经修复 epoll 惊群现象. epoll(7) - Linux manual page (man7.org)
  • 如果试图将 epoll 实例描述符注册到自己的 epoll 实例上,那么 epoll_ctl 会返回 EINVAL 错误。
  • 可以通过 UNIX 域套接字来传递 epoll 文件描述符,但是这样做是没有意义的,因为接收进程没有该 epoll 实例的描述符集合的副本.
  • 关闭一个描述符,会导致该描述符从所有 epoll 集合中自动被移除。但前提是该描述符没有通过 dup 等函数进行过复制。一个描述符,在通过 dup、dup2、fcntl 的 F_DUPFD,或者 fork 之后,会产生一个新的重复描述符,指向相同的文件表。因此,只有当引用同一文件表的所有重复描述符都关闭之后 (引用计数为 0),该描述符才会从 epoll 集合中删除。(可以使用 EPOLL_CTL_DEL 调用 epoll_ctl 来明确删除 epoll 集合中的该描述符)这意味着,即使 epoll 集合中的某个描述符被关闭了,但是只要该描述符有打开的重复描述符,那该描述符上的事件,依然会被报告。
  • 如果在 epoll_wait 调用时,发生了多个事件,则这些事件会合在一起进行报告。

/proc 接口限制

    下面的接口可用来限制内核中 epoll 使用的内存总量:

/proc/sys/fs/epoll/max_user_watches (since Linux 2.6.28)

    该接口限定了,每个实际用户 ID,可以在所有 epoll 实例上注册的描述符总数。每个注册的描述符,在 32 位系统上大约耗费 90 字节,在 64 位系统上大约耗费 160 字节。


    select 轮询的 fd 的数量由 FD_SETSIZE 设置,默认值是 1024。对于那些需要支持的上万连接数目的 IM 服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译服务器代码,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache 方案),不过虽然 linux 上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。不过 epoll 则没有这个限制,它所支持的 FD 上限是最大可以打开文件的数目,这个数字一般远大于 2048, 举个例子,在 1GB 内存的机器上大约是 10 万左右,具体数目可以 **cat /proc/sys/fs/file-max** 查看,一般来说这个数目和系统内存关系很大。

epoll 优点

  • 文件描述符数量不再受限;
  • 高效:在大规模并发连接的场景下,epoll 模型可以显著提高效率。使用一个文件描述符来管理多个连接,避免了遍历所有连接的开销。并且 epoll 使用了 “事件通知” 的方式,只有在有事件发生时才会通知应用程序,避免了无效轮询。(通过每个 fd 定义的回调函数来实现的,只有就绪的 fd 才会执行回调函数。I/O 的效率不会随着监视 fd 的数量的增长而下降)
  • 更快的响应速度:由于 epoll 是基于事件驱动的模型,在有事件发生时立即通知应用程序,可以更快地响应客户端的请求。
  • 可扩展性好:epoll 模型采用了无锁设计,将连接集合的管理交给内核处理,并利用回调函数机制处理连接的读写事件,减少了锁竞争,提高了系统的可扩展性。

iocp(...)

优缺点分析

这里仅针对Linux下的三大I/O 多路监控模型....

image-cjrz.png

dup(...)/dup2(...)

#include <unistd.h>
int dup(int oldfd);
  • dup 用来复制参数 oldfd 所指的文件描述符。当复制成功是,返回最小的尚未被使用过的文件描述符,若有错误则返回 - 1. 错误代码存入 errno 中返回的新文件描述符和参数 oldfd 指向同一个文件,这两个描述符共享同一个数据结构,共享所有的锁定,读写指针和各项全现或标志位。
  • 调用 dup(oldfd) 等效于 fcntl(oldfd, F_DUPFD, 0)

#include <unistd.h>
 int dup2(int oldfd, int newfd);

dup2(oldfd, newfd) 等效于 close(oldfd); fcntl(oldfd, F_DUPFD, newfd); 在 shell 的重定向功能中,(输入重定向”<” 和输出重定向”>”) 就是通过调用 dup 或 dup2 函数对标准输入和标准输出的操作来实现的。