1. 文件描述符 fd 与 socket#

1.1 什么是文件描述符

文件描述符(file descriptor)是一个非负整数,从 0 开始。进程使用文件描述符来标识一个打开的文件。

系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。当进程打开(open)或者新建(create)文件时,内核会在该进程的文件列表中新增一个表项,同时返回一个文件描述符 —— 也就是新增表项的下标。

一般来说,每个进程最多可以打开 64 个文件,fd ∈ 0~63。在不同系统上,最多允许打开的文件个数不同,Linux 2.4.22 强制规定最多不能超过 1,048,576。

每个进程默认都有 3 个文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。

1.2 fd 与 socket 关系

socket 是 Unix 中的术语。socket 可以用于同一台主机的不同进程间的通信,也可以用于不同主机间的通信。一个 socket 包含地址、类型和通信协议等信息,通过 socket() 函数创建:

int socket(int domain, int type, int protocol)

返回的就是这个 socket 对应的文件描述符 fd。操作系统将 socket 映射到进程的一个文件描述符上,进程就可以通过读写这个文件描述符来和远程主机通信。

可以这样理解:socket 是进程间通信规则的高层抽象,而 fd 提供的是底层的具体实现。socket 与 fd 是一一对应的。通过 socket 通信,实际上就是通过文件描述符 fd 读写文件。这也符合 Unix“一切皆文件”的哲学。

本文可以将 socket 和 fd 视为同义词。

2. 内核接收网络数据全过程

如下图所示,进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知cpu有数据到达,cpu执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④),再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。
在这里插入图片描述
唤醒进程的过程如下图所示。
在这里插入图片描述
以上是内核接收数据全过程
这里有两个问题:
其一,操作系统如何知道网络数据对应于哪个socket?
其二,如何同时监视多个socket的数据?
第一个问题:因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。
第二个问题是多路复用的重中之重

3. 从阻塞 I/O 到 I/O 多路复用

阻塞 I/O,是指进程发起调用后,会被挂起(阻塞),直到收到数据再返回。如果调用一直不返回,进程就会一直被挂起。因此,当使用阻塞 I/O 时,需要使用多线程来处理多个文件描述符。

多线程切换有一定的开销,因此引入非阻塞 I/O。非阻塞 I/O 不会将进程挂起,调用时会立即返回成功或错误,因此可以在一个线程里轮询多个文件描述符是否就绪。

但是非阻塞 I/O 的缺点是:每次发起系统调用,只能检查一个文件描述符是否就绪。当文件描述符很多时,系统调用的成本很高。

因此引入了 I/O 多路复用,可以通过一次系统调用,检查多个文件描述符的状态。这是 I/O 多路复用的主要优点,相比于非阻塞 I/O,在文件描述符较多的场景下,避免了频繁的用户态和内核态的切换,减少了系统调用的开销。

I/O 多路复用相当于将「遍历所有文件描述符、通过非阻塞 I/O 查看其是否就绪」的过程从用户线程移到了内核中,由内核来负责轮询。

进程可以通过 select、poll、epoll 发起 I/O 多路复用的系统调用,这些系统调用都是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞时长超过设置的 timeout 后,再返回。I/O 多路复用内部使用非阻塞 I/O 检查每个描述符的就绪状态。

如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。

I/O 多路复用引入了一些额外的操作和开销,性能更差。但是好处是用户可以在一个线程内同时处理多个 I/O 请求。如果不采用 I/O 多路复用,则必须通过多线程的方式,每个线程处理一个 I/O 请求。后者线程切换也是有一定的开销的。

为什么 I/O 多路复用内部需要使用非阻塞 I/O

I/O 多路复用内部会遍历集合中的每个文件描述符,判断其是否就绪:

for fd in read_set
    ifreadable(fd) ) // 判断 fd 是否就绪
        count++
        FDSET(fd, &res_rset) // 将 fd 添加到就绪集合中
        break
...
return count

这里的 readable(fd) 就是一个非阻塞 I/O 调用。试想,如果这里使用阻塞 I/O,那么 fd 未就绪时,select 会阻塞在这个文件描述符上,无法检查下个文件描述符。

注意:这里说的是 I/O 多路复用的内部实现,而不是说,使用 I/O 多路复用就必须使用非阻塞 I/O,

4. select

函数签名与参数

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

readfds、writefds、errorfds 是三个文件描述符集合。select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为“就绪”的描述符。然后用找到的子集替换参数中的对应集合,返回所有就绪描述符的总数。

timeout 参数表示调用 select 时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。

4.1 fd_set 文件描述符集合

fd_set 结构体:

typedef long int __fd_mask;

/* fd_set for select and pselect.  */
typedef struct {
    #ifdef __USE_XOPEN
        __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
    # define __FDS_BITS(set) ((set)->fds_bits)
    #else
        __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
    # define __FDS_BITS(set) ((set)->__fds_bits)
    #endif
} fd_set;

参数中的 fd_set 类型表示文件描述符的集合。

由于文件描述符 fd 是一个从 0 开始的无符号整数,所以可以使用 fd_set 的二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。比如比如设 fd_set 长度为 1 字节,则一个 fd_set 变量最大可以表示 8 个文件描述符。当 select 返回 fd_set = 00010011 时,表示文件描述符 125 已经就绪。

fd_set 的使用涉及以下几个 API:
```c
#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

4.2 执行流程

在这里插入图片描述

  • 用户线程调用select,将fd_set从用户空间拷贝到内核空间
  • 内核在内核空间对fd_set遍历一遍,检查是否有就绪的socket描述符,如果没有的话,就会进入休眠,直到有就绪的socket描述符
  • 内核返回select的结果给用户线程,即就绪的文件描述符数量
  • 用户拿到就绪文件描述符数量后,再次对fd_set进行遍历,找出就绪的文件描述符
  • 用户线程对就绪的文件描述符进行读写操作

select 需要将所有文件描述符传到内核空间里面主要是出于安全考虑:

  1. 用户空间的指针所指向的虚拟地址,在内核态访问的时候,不能保证还存在在物理页面上
  2. 如果在内核态直接访问用户空间的地址,当发生“Page Fault“缺页错误时,内核会死机。而将数据从用户空间拷贝到内核空间的函数 copy_from_user,在发生缺页错误时会优雅地返回一个错误码,保证程序不崩掉。
  3. 为了安全,在读取/写入用户空间的指针之前,必须先验证这个指针是否确实是用户空间的、是否有相应的权限。这是为了防止用户空间访问内核空间。copy_from_user 的入口封装了这个逻辑。
  4. 执行系统调用的线程有可能在验证用户内存之后、但在实际完成系统调用之前被调度出来,这可能导致用户空间的指针,指向了一段包含恶意程序的代码。所以从安全性和(最终)性能等方面来说,先制作一份拷贝会更好。

4.3 select 使用示例

下图的代码说明:

  • 先声明一个 fd_set 类型的变量 readFDs
  • 调用 FD_ZERO,将 readFDs 所有位置 0
  • 调用 FD_SET,将 readFDs 感兴趣的位置 1,表示要监听这几个文件描述符
  • 将 readFDs 传给 select,调用 select
  • select 会将 readFDs 中就绪的位置 1,未就绪的位置 0,返回就绪的文件描述符的数量
  • 当 select 返回后,调用 FD_ISSET 检测给定位是否为 1,表示对应文件描述符是否就绪

比如进程想监听 1、2、5 这三个文件描述符,就将 readFDs 设置为 00010011,然后调用 select。

如果 fd=1、fd=2 就绪,而 fd=5 未就绪,select 会将 readFDs 设置为 00000011 并返回 2。

如果每个文件描述符都未就绪,select 会阻塞 timeout 时长,再返回。这期间,如果 readFDs 监听的某个文件描述符上发生可读事件,则 select 会将对应位置 1,并立即返回。
在这里插入图片描述

4.4 select 优点

  1. 跨平台,几乎所有平台都支持;
  2. 时间精度高,ns 级别。

4.5 select 缺点

  1. 最大限制:单个进程能够监视的文件描述符的数量存在最大限制。(基于数组存储的赶脚)一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。它由FD_SETSIZE设置,32位机默认是1024个。64位机默认是2048.
  2. 时间复杂度: 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低,时间复杂度O(n)。当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。它仅仅知道有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
  3. 内存拷贝:需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

5. poll

poll 和 select 几乎没有区别。poll 在用户态通过数组方式传递文件描述符,在内核会转为链表方式存储,没有最大数量的限制。

poll 的函数签名

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

pollfd 的结构体

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

5.1 与select的异同点#

相同点:

  • 内核线程都需要遍历文件描述符,并且当内核返回就绪的文件描述符数量后,还需要遍历一次找出就绪的文件描述符
  • 需要将文件描述符数组或链表从用户空间拷贝到内核空间
    性能开销会随文件描述符的数量而线性增大

不同点:

  • select存储的数据结构是文件描述符数组,poll采用链表
  • select有最大连接数限制,poll没有最大限制,因为poll采用链表存储

5.2 执行过程(基本与 select 类型)

  1. 用户线程调用 poll 系统调用,并将文件描述符链表拷贝到内核空间
  2. 内核对文件描述符遍历一遍,如果没有就绪的描述符,则内核开始休眠,直到有就绪的文件描述符
  3. 返回给用户线程就绪的文件描述符数量
  4. 用户线程再遍历一次文件描述符链表,找出就绪的文件描述符
  5. 用户线程对就绪的文件描述符进行读写操作

5.3 poll 优点

没有最大连接数的限制。(基于链表来存储的)

5.4 poll 缺点

  1. 时间复杂度:对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低,时间复杂度O(n)。它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
  2. 内存拷贝:大量的fd数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。大量的fd数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
  3. 水平触发:如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

注意:select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

6. epoll

epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。

核心代码:

#include <sys/epoll.h>

// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 红黑树用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
    ...
    /*红黑树的根节点,这颗树存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表存储所有就绪的文件描述符*/
    struct list_head rdlist;
    ...
};

// API
int epoll_create(int size); // 内核中间加一个 eventpoll 对象,把所有需要监听的 socket 都放到 eventpoll 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 检测双链表中是否有就绪的文件描述符,如果有,则返回

简而言之,epoll 有以下几个特点:

  • 使用红黑树存储文件描述符集合
  • 使用双链表存储就绪的文件描述符
  • 每个文件描述符只需在添加时传入一次,通过事件 callback 更改文件描述符状态。

select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_create、epoll_ctl 和 epoll_wait。

  • epoll_create 创建 eventpoll对象(红黑树,双链表)
  • 一棵红黑树,存储监听的所有文件描述符,并且通过 epoll_ctl 将文件描述符添加、删除到红黑树
  • 一个双链表,存储就绪的文件描述符列表,epoll_wait调用时,检测此链表中是否有数据,有的话直接返回
  • 所有添加到 eventpoll 中的事件都与设备驱动程序建立回调关系

6.1 epoll_create#

int epoll_create(int size);

内核在 epoll 文件系统中建了个file结点

  • epoll_create 会创建一个 epoll 实例,同时返回一个引用该实例的文件描述符
  • 返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl、epoll_wait 会使用这个文件描述符来操作相应的 epoll 实例
  • 使用完,必须调用close()关闭,否则导致fd被耗尽

epoll 实例内部存储:

  • 监听列表:所有要监听的文件描述符,使用红黑树,由 epoll_ctl 传来
  • 就绪列表:所有就绪的文件描述符,使用双向链表

6.2 epoll_ctl

epoll_ctl 会监听文件描述符 fd 上发生的 event 事件

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数说明

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
  • fd 表示要监听的目标文件描述符
  • event 表示要监听的事件(可读、可写、发送错误…)
  • op 表示要对 fd 执行的操作,有以下几种:
    • EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
    • EPOLL_CTL_MOD:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)
    • EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用
  • 返回值 0 或 -1,表示上述操作成功与否。

epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event,如果红黑树中已经存在立刻返回。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。

6.3 epoll_wait

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

epoll 模型的主要函数,功能相当于 select

参数说明:

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
  • events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
  • maxevents 指定 events 的大小
  • timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回
  • 返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。

6.4 mmap 内存映射

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

6.5 epoll 优点

epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。对于“文件描述符数量少”,select 使用整型数组存储文件描述符集合,而 epoll 使用红黑树存储,数量较大。对于“性能开销大”,epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此 epoll 不需要像 select 那样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。相当于时间复杂度从 O(n) 降为 O(1)。此外,每次调用 select 时都需要向内核拷贝所有要监听的描述符集合,而 epoll 对于每个描述符,只需要在 epoll_ctl 传递一次,之后 epoll_wait 不需要再次传递。这也大大提高了效率。

  1. 没有最大连接数的限制。(基于 红黑树+双链表 来存储的:1G的内存上能监听约10万个端口)
  2. 时间复杂度低: 边缘触发和事件驱动,监听回调,时间复杂度O(1)。只有活跃可用的fd才会调用callback函数;即epoll最大的优点就在于它只管“活跃”的连接,而跟连接总数无关,因此实际网络环境中,Epoll的效率就会远远高于select和poll。
    3.内存拷贝:利用mmap()文件映射内存加速与内核空间的消息传递,减少拷贝开销。

6.6 epoll 缺点

依赖于操作系统:Linux

6.7 应用场景

适合用epoll的应用场景:

  • 对于连接特别多,活跃的连接特别少
  • 典型的应用场景为一个需要处理上万的连接服务器,例如各种app的入口服务器,例如qq

不适合epoll的场景:

  • 连接比较少,数据量比较大,例如ssh
  • epoll 的惊群问题:因为epoll 多用于多个连接,只有少数活跃的场景,但是万一某一时刻,epoll 等的上千个文件描述符都就绪了,这时候epoll 要进行大量的I/O,此时压力太大。

6.8 epoll 两种模式

epoll对文件描述符的操作有两种模式:LT(level trigger) 和 ET(edge trigger)。LT是默认的模式,ET是“高速”模式。

  • LT(水平触发)模式下,只要有数据就触发,缓冲区剩余未读尽的数据会导致 epoll_wait都会返回它的事件;
  • ET(边缘触发)模式下,只有新数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返回。

水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。

LT 模式

LT(level triggered)是缺省的工作方式,并且同时支持 block 和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作。
在这里插入图片描述

ET 模式

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN (提示你的应用程序现在没有数据可读请稍后再试)为止,否则下次的 epoll_wait 不会返回余下的数据,会丢掉事件。
在这里插入图片描述
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

7. select、poll、epoll的区别在这里插入图片描述

selectpollepoll
底层数据结构数组存储文件描述符链表存储文件描述符红黑树存储监控的文件描述符,双链表存储就绪的文件描述符
如何从fd数据中获取就绪的fd遍历fd_set遍历链表回调
时间复杂度获得就绪的文件描述符需要遍历fd数组,O(n)获得就绪的文件描述符需要遍历fd链表,O(n)当有就绪事件时,系统注册的回调函数就会被调用,将就绪的fd放入到就绪链表中。O(1)
FD数据拷贝每次调用select,需要将fd数据从用户空间拷贝到内核空间每次调用poll,需要将fd数据从用户空间拷贝到内核空间使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间
最大连接数有限制,一般为1024无限制无限制

注意

  1. 在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。
  2. 如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
网络编程 / 阻塞io和非阻塞io / io多路复用和边缘触发和水平触发 / reactor 单线程、多线程、多进程应用
网络原理tcp/udp,网络编程epoll/reactor,“八股文”的必要性

Linux后台服务器开发学习资料、视频 有需要的可以自行添加学习交流群960994558

Logo

更多推荐