乐趣区

关于select:彻底搞懂IO多路复用

上一篇文章以近乎啰嗦的形式详细描述了 BIO 与非阻塞 IO 的各种细节。如果各位还没有读过这篇文章,强烈建议先浏览一下,而后再来看本篇,因为逻辑关系是层层递进的。

1. 多路复用的诞生

非阻塞 IO 应用一个线程就能够解决所有 socket,然而付出的代价是必须频繁调用零碎调用来轮询每一个 socket 的数据,这种轮询太消耗性能,而且大部分轮询都是空轮询。

咱们心愿有个组件能同时监控多个 socket,并在 socket 把数据筹备好的时候通知过程哪些 socket 已“就绪”,而后过程只对就绪的 socket 进行数据读写。

Java 在 JDK1.4 的时候引入了 NIO,并提供了 Selector 这个组件来实现这个性能。

2. NIO

在引入 NIO 代码之前,有点事件须要解释一下。

就绪 ”这个词用得有点暧昧,因为不同的 socket 对就绪有不同的表白。比方对于监听 socket 而言,如果有客户端对其进行了连贯,就阐明处于就绪状态,它并不像连贯 socket 一样,须要对数据的收发进行解决;相同,连贯 socket 的就绪状态就至多蕴含了 数据筹备好读 is ready for reading)与 数据筹备好写is ready for writing)这两种。

因而,能够设想,咱们让 Selector 对多个 socket 进行监听时,必然须要通知Selector,咱们对哪些 socket 的哪些事件感兴趣。这个动作叫注册。

接下来看代码。

public class NIOServer {

    static Selector selector;

    public static void main(String[] args) {

        try {
            // 取得 selector 多路复用器
            selector = Selector.open();

            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            // 监听 socket 的 accept 将不会阻塞
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.socket().bind(new InetSocketAddress(8099));

            // 须要把监听 socket 注册到多路复用器上,并通知 selector,须要关注监听 socket 的 OP_ACCEPT 事件
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            while (true) {
                // 该办法会阻塞
                selector.select();

                // 失去所有就绪的事件,事件被封装成了 SelectionKey
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {SelectionKey key = iterator.next();
                    iterator.remove();
                    if (key.isAcceptable()) {handleAccept(key);
                    } else if (key.isReadable()) {handleRead(key);
                    } else if (key.isWritable()) {// 发送数据}
                }

            }

        } catch (IOException e) {e.printStackTrace();
        }
    }
        
    // 解决「读」事件的业务逻辑
    private static void handleRead(SelectionKey key) {SocketChannel socketChannel = (SocketChannel) key.channel();
        ByteBuffer allocate = ByteBuffer.allocate(1024);
        try {socketChannel.read(allocate);
            System.out.println("From Client:" + new String(allocate.array()));
        } catch (IOException e) {e.printStackTrace();
        }

    }

      // 解决「连贯」事件的业务逻辑
    private static void handleAccept(SelectionKey key) {ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();

        try {
            // socketChannel 肯定是非空,并且这里不会阻塞
            SocketChannel socketChannel = serverSocketChannel.accept();
            // 将连贯 socket 的读写设置为非阻塞
            socketChannel.configureBlocking(false);
            socketChannel.write(ByteBuffer.wrap("Hello Client,I am Server!".getBytes()));
            // 注册连贯 socket 的「读事件」socketChannel.register(selector, SelectionKey.OP_READ);
        } catch (IOException e) {e.printStackTrace();
        }

    }

}

咱们首先应用 Selector.open(); 失去了 selector 这个多路复用对象;而后在服务端创立了监听 socket,并将其设置为 非阻塞 ,最初将监听 socket 注册到selector 多路复用器上,并通知 selector,如果监听 socket 有OP_ACCEPT 事件产生的话就要通知咱们。

咱们在 while 循环中调用 selector.select(); 办法,过程将会阻塞在该办法上,直到注册在 selector 上的任意一个 socket 有事件产生为止,才会返回。如果不信的话能够在 selector.select(); 的下一行打个断点,debug 模式运行后,在没有客户端连贯的状况下断点不会被触发。

select() 返回,意味着有一个或多个 socket 曾经处于就绪状态,咱们应用 Set<SelectionKey> 来保留所有事件,SelectionKey封装了就绪的事件,咱们循环每个事件,依据不同的事件类型进行不同的业务逻辑解决。

OP_READ事件就绪的话,咱们就筹备一个缓冲空间,将数据从内核空间读到缓冲中;如果是 OP_ACCEPT 就绪,那就调用监听 socket 的 accept() 办法失去连贯 socket,并且 accept() 不会阻塞,因为在最开始的时候咱们曾经将监听 socket 设置为非阻塞了。失去的连贯 socket 同样须要设置为非阻塞,这样连贯 socket 的读写操作就是非阻塞的,最初将连贯 socket 注册到 selector 多路复用器上,并通知 selector,如果连贯 socket 有OP_READ 事件产生的话就要通知咱们。

上个动图对 Java 的多路复用代码做个解释。

接下来的重点天然是 NIO 中的 select() 的底层原理了,还是那句话,NIO 之所以能提供多路复用的性能,实质上还是操作系统底层提供了多路复用的零碎调用。

多路复用实质上就是同时监听多个 socket 的申请,当咱们订阅的 socket 上有咱们感兴趣的事件产生的时候,多路复用函数会返回,而后咱们的用户程序依据返回后果持续解决这些就绪状态的 socket。

然而,不同的多路复用模型在具体的实现上有所不同,次要体现在三个方面:

  1. 多路复用模型最多能够同时监听多少个 socket?
  2. 多路复用模型会监听 socket 上哪些事件?
  3. 当 socket 就绪时,多路复用模型如何找到就绪的 socket?

多路复用次要有 3 种,别离是 selectpollepoll,接下来将带着下面 3 个问题别离介绍这 3 种底层模型。

下文相干函数的申明以及参数的定义源于 64 位 CentOS 7.9,内核版本为 3.10.0

3. select

咱们能够通过 select 通知内核,咱们对哪些描述符(这些描述符能够示意规范输出、监听 socket 或者连贯 socket 等)的哪些事件(可读、可写、产生异样)感兴趣,或者某个超时工夫之后间接返回。

举个例子,咱们调用 select 通知内核仅在下列状况下产生时才返回:

  • 汇合 {1, 4, 7} 中有任何描述符 就绪;
  • 汇合 {2, 9} 中有任何描述符 就绪;
  • 汇合 {1, 3, 5} 中有任何描述符 有异样产生
  • 超过了 10S,啥事儿也没有产生。

3.1. select 应用办法

// 返回:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1
int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

nfds参数用来通知 select 须要查看的描述符的个数,取值为咱们感兴趣的最大描述符 + 1,依照方才的例子来讲 nfds 应该是 {{1, 4, 7}, {2, 9}, {1, 3, 5}} 中的最大描述符 +1,也就是 9 + 1,为 10。至于为什么这样,别急,咱们下文再说。

timeout参数容许咱们设置 select 的超时工夫,如果超过指定工夫还没有咱们感兴趣的事件产生,就进行阻塞,间接返回。

readfds里保留的是咱们对 读就绪事件 感兴趣的描述符,writefds保留的是咱们对 写就绪事件 感兴趣的描述符,exceptfds保留的是咱们对 产生异样 这种事件感兴趣的描述符。这三个参数会通知内核,别离须要在哪些描述符上检测数据可读、可写以及产生异样。

然而这些描述符并非像我的例子一样,间接把汇合 {1, 4, 7} 作为数组存起来,设计者从内存空间和应用效率的角度设计了 fd_set 这个数据结构,咱们看一下它的定义以及某些重要信息。

// file: /usr/include/sys/select.h
/* __fd_mask 是 long int 类型的别名  */
typedef long int __fd_mask;

#define __NFDBITS    (8 * (int) sizeof (__fd_mask))

typedef struct  {
   ...
   __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
   ...
} fd_set;

因而,fd_set的定义,其实就是 long int 类型的数组,元素个数为__FD_SETSIZE / __NFDBITS,我间接在我的 CentOS 上输入了一下两个常量值,如下:

#include <stdio.h>
#include "sys/select.h"

int main(){printf("__FD_SETSIZE:%d\n",__FD_SETSIZE);
   printf("__NFDBITS:%d\n",__NFDBITS);
   return 0;
}

// 输入后果
__FD_SETSIZE:1024
__NFDBITS:64

因而该数组中一共有 16 个元素(1024 / 64 = 16),每个元素为 long int 类型,占 64 位。

数组的第 1 个元素用于示意描述符 0~63,第2 个元素用于示意描述符 64~127,以此类推,每 1 个 bit 位用01 两种状态示意是否检测以后描述符的事件。

假如咱们对 {1, 4, 7} 号描述符的读就绪事件感兴趣,那么 readfds 参数的数组第 1 个元素的二进制示意就如下图所示,第 1、4、7 位别离被标记为 1,理论存储的 10 进制数字为 146。

理论应用 select 的时候如果让咱们本人推导下面这个过程进行参数设置那可费了劲了,于是操作系统提供了 4 个宏来帮咱们设置数组中每个元素的每一位。

// 将数组每个元素的二进制位重置为 0
void FD_ZERO(fd_set *fdset);

// 将第 fd 个描述符示意的二进制位设置为 1
void FD_SET(int fd, fd_set *fdset);

// 将第 fd 个描述符示意的二进制位设置为 0
void FD_CLR(int fd, fd_set *fdset);

// 查看第 fd 个描述符示意的二进制位是 0 还是 1
int  FD_ISSET(int fd, fd_set *fdset);

还是下面 {1, 4, 7} 这个例子,再顺带着介绍一下用法,晓得有这么回事儿就行了。

fd_set readSet;
FD_ZERO(&readSet);
FD_SET(1, &readSet);
FD_SET(4, &readSet);
FD_SET(7, &readSet);

既然 fd_set 底层用的是数组,那就肯定有长度限度,也就是说 select 同时监听的 socket 数量是无限的,你之前可能听过这个无限的数量是1024,然而1024 是怎么来的呢?

3.2. 下限为什么是 1024

其实 select 的监听下限就等于 fds_bits 数组中所有元素的二进制位总数。接下来咱们用初中数学的解题步骤推理一下这个二进制位到底有多少。

已知:

证实如下:

论断就是 __FD_SETSIZE 这个宏其实就是 select 同时监听 socket 的最大数量。该数值在源码中有定义,如下所示:

// file: /usr/include/bits/typesizes.h
/* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE        1024

所以,select 函数对每一个描述符汇合 fd_set,最多能够同时监听 1024 个描述符

3.3. nfds 的作用

为什么偏偏把最大值设置成 1024 呢?没人晓得,或者只是程序员喜爱这个数字罢了。

最后设计 select 的时候,设计者思考到大多数的应用程序基本不会用到很多的描述符,因而最大描述符的下限被设置成了31(4.2BSD 版本),起初在 4.4BSD 中被设置成了256,直到现在被设置成了1024

这个数量说多不多,说少也不算少,select()须要循环遍历数组中的位判断此描述符是否有对应的事件产生,如果每次都对 1024 个描述符进行判断,在咱们感兴趣的监听描述符比拟少的状况下(比方我上文的例子)那就是一种极大的节约。于是,select给咱们提供了 nfds 这个参数,让咱们通知 select() 只须要迭代数组中的前 nfds 个就行了,而不要总是在每次调用的时候遍历整个数组。

身为一个零碎函数,执行效率天然须要优化到极致

3.4. 再谈阻塞

上一篇文章讲过,当用户线程发动一个阻塞式的 read 零碎调用,数据未就绪时,线程就会阻塞。阻塞其实是调用线程被投入睡眠,直到内核在某个机会唤醒线程,阻塞也就完结。这里咱们借着 select 再聊一聊这个阻塞。

本大节中不做「过程」和「线程」的明确辨别,线程作为轻量级过程来对待

内核会为每一个过程创立一个名为 task_struct 的数据结构,这个数据结构自身是调配在内核空间的,其中保留了以后过程的过程号、socket 信息、CPU 的运行上下文以及其余很重要然而我不讲的信息(/ 狗头)。

Linux 内核保护了一个 执行队列 ,里边放的都是处于TASK_RUNNING 状态的过程的task_struct,这些过程以双向链表的形式排队期待 CPU 极短时间的临幸。

阻塞的实质就是将过程的 task_struct 移出执行队列,让出 CPU 的调度,将过程的状态的置为 TASK_UNINTERRUPTIBLE 或者 TASK_INTERRUPTIBLE,而后增加到 期待队列 中,直到被唤醒。

那这个期待队列在哪儿呢?比方咱们对一个 socket 发动一个阻塞式的 read 调用,用户过程必定是须要和这个 socket 进行绑定的,要不然 socket 就绪之后都不晓得该唤醒谁。这个期待队列其实就是保留在 socket 数据结构中,咱们瞄一眼 socket 源码:

struct socket {
    ...
  // 这个在 epoll 中会提到
    struct file        *file;
  ...
  // struct sock - network layer representation of sockets
    struct sock        *sk;
    ...
};

struct sock {
  ...
  // incoming packets
    struct sk_buff_head    sk_receive_queue;
    ...
  // Packet sending queue
    struct sk_buff_head    sk_write_queue;
  ...
  // socket 的期待队列,wq 的意思就是 wait_queue
  struct socket_wq __rcu    *sk_wq;
    
};

不必深刻了解哈,只有晓得 socket 本人保护了一个期待队列sk_wq,这个队列中每个元素保留的是:

  • 阻塞在以后 socket 上的过程描述符
  • 过程被唤醒之后应该调用的回调函数

这个回调函数是过程在退出期待队列的时候设置的一个函数指针(行话叫,向内核注册了一个回调函数),通知内核:我正等着这个 socket 上的数据呢,先睡一会儿,等有数据了你就执行这个回调函数吧,里边有把我唤醒的逻辑。

就这样,通过网卡接收数据、硬中断以及软中断再到内核调用回调函数唤醒过程,把过程的 task_struct 从期待队列挪动到执行队列,过程再次失去 CPU 的临幸,函数返回后果,阻塞完结。

当初回到select

用户过程会阻塞在 select 之上,因为 select 会同时监听多个 socket,因而以后过程会被增加到每个被监听的 socket 的期待队列中,每次唤醒还须要从每个 socket 期待队列中移除。

select的唤醒也有个问题,调用 select 的过程被唤醒之后是一脸懵啊,内核间接扔给他一个整数,过程不晓得哪些 socket 收到数据了,还必须遍历一下能力晓得。

3.5. select 如何多路复用

select在超时工夫内会被阻塞,直到咱们感兴趣的 socket 读就绪、写就绪或者有异样事件产生(这话如同啰嗦了好多遍了,是不是自然而然曾经记住了),而后 select 会返回已就绪的描述符数。

其实 读就绪 写就绪 或者 有异样事件产生 这 3 种事件里边的道道儿十分多,这里咱们就仅作字面上的了解就好了,更多细节,能够参考《Unix 网络编程 卷一》。

用户过程拿到这个整数阐明了两件事件:

  1. 咱们上文讲的所有 select 操作都是在内核态运行的,select返回之后,权限交还到了用户空间;
  2. 用户过程拿到这个整数,须要对 select 监听的描述符一一进行检测,判断二进制位是否被设置为 1,进而进行相干的逻辑解决。可是问题是,内核把“就绪”的这个状态保留在了哪里呢?换句话说,用户过程该遍历谁?

selectreadfdswritefdsexceptfds 3 个参数都是指针类型,用户过程传递这 3 个参数通知内核对哪些 socket 的哪些事件感兴趣,执行结束之后反过来内核会将就绪的描述符状态也放在这三个参数变量中,这种参数称为 值 - 后果 参数。

用户过程通过调用 FD_ISSET(int fd, fd_set *fdset) 对描述符集进行判断即可,看个整体流程的动图。

  • 用户过程设置 fd_set 参数,调用 select() 函数,并将描述符汇合拷贝到内核空间;
  • 为了提高效率,内核通过 nfds 参数防止检测那些总为 0 的位,遍历的过程产生在内核空间,不存在零碎调用切换上下文的开销;
  • select函数批改由指针 readsetwriteset 以及 exceptset 所指向的描述符集,函数返回时,描述符集中只有之前咱们标记过的并且处于就绪状态的描述符对应的二进制位才是 1,其余都会被重置为 0(因而每次从新调用 select 时,咱们必须把所有描述符集中感兴趣的位再次设置为 1);
  • 过程依据 select() 返回的后果判断操作是否失常,如果为 0 示意超时,-1示意出错,大于 0 示意有相应数量的描述符就绪了,进而利用 FD_ISSET 遍历查看所有相应类型的 fd_set 中的所有描述符,如果为1,则进行业务逻辑解决即可。

3.6. 总结

select(包含下文讲到的 poll)是阻塞的,过程会阻塞在 select 之上,而不是阻塞在真正的 I / O 零碎调用上,模型示意图见下图:

咱们从头到尾都是应用一个用户线程来解决所有 socket,同时又防止了非阻塞 IO 的那种有效轮询,为此付出的代价是一次 select 零碎调用的阻塞,外加 N 次就绪文件描述符的零碎调用。

4. poll

pollselect 的继任者,接下来聊它。

4.1. 函数原型

先看一下函数原型:

// 返回:若有就绪描述符则为其数目,若超时则为 0,若出错则为 -1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

函数有 3 个参数,第一个参数是一个 pollfd 类型的数组,其中 pollfd 构造如下:

struct pollfd {
    int    fd;       /* file descriptor */
    short  events;   /* events to look for */
    short  revents;  /* events returned */
 };

4.2. poll 订阅的事件

pollfd由 3 局部组成,首先是描述符 fd,其次events 示意描述符 fd 上待检测的事件类型,一个 short 类型的数字用来示意多种事件,天然能够想到用的是二进制掩码的形式来进行位操作。

源码中咱们能够找到所有事件的定义,我依据事件的分类对源码的程序做了肯定调整,如下:

// file: /usr/include/bits/poll.h
/* 第一类:可读事件  */
#define POLLIN        0x001        /* There is data to read.  */
#define POLLPRI        0x002        /* There is urgent data to read.  */
#define POLLRDNORM    0x040        /* Normal data may be read.  */
#define POLLRDBAND    0x080        /* Priority data may be read.  */


/* 第二类:可写事件  */
#define POLLOUT        0x004        /* Writing now will not block.  */
#define POLLWRNORM    0x100        /* Writing now will not block.  */
#define POLLWRBAND    0x200        /* Priority data may be written.  */


/* 第三类:谬误事件 */
#define POLLERR        0x008        /* Error condition.  */
#define POLLHUP        0x010        /* Hung up.  */
#define POLLNVAL    0x020        /* Invalid polling request.  */

pollfd构造中还有一个 revents 字段,全称是“returned events”,这是 pollselect的第 1 个不同点。

poll 会将每次遍历之后的后果保留到 revents 字段中,没有 select 那种值 - 后果参数,也就不须要每次调用 poll 的时候重置咱们感兴趣的描述符以及相干事件。

还有一点,谬误事件不能在 events 中进行设置,然而当相应事件产生时会通过 revents 字段返回 。这是pollselect的第 2 个不同点。

再来看 poll 的第 2 个参数 nfds,示意的是数组fds 的元素个数,也就是用户过程想让 poll 同时监听的描述符的个数。

如此一来,poll 函数将设置最大监听数量的权限给了程序设计者,自在管制 pollfd 构造数组的大小,冲破了 select 函数 1024 个最大描述符的限度 。这是pollselect的第 3 个不同点。

至于 timeout 参数就更好了解了,就是设置超时工夫罢了,更多细节敌人们能够查看一下 api。

pollselect 是齐全不同的 API 设计,因而要说不同点那真是海了去了,然而因为实质上和 select 没有太大的变动,因而咱们也只关注下面的这几个不同点也就罢了。须要留神的是 poll 函数返回之后,被唤醒的用户过程仍然是懵的,踉踉跄跄地去遍历文件描述符、查看相干事件、进行相应逻辑解决。

其余的细节就再参考一下 select 吧,poll咱们到此为止。

5. epoll

epoll是三者之中最弱小的多路复用模型,天然也更难讲,要喋喋不休只讲一下 epoll 的劣势倒也不难,不过会丢失很多细节,用源码解释又太干燥,思来想去,于是。。。

我拖更了。。。

5.1. epoll 入门

还是先从 epoll 的函数应用开始,不同 于 select/poll单个函数走天下,epoll用起来略微麻烦了一点点,它提供了函数三件套,epoll_createepoll_ctlepoll_wait,咱们一个个来看。

5.1.1. 创立 epoll 实例

// size 参数从 Linux2.6.8 之后失去意义,为放弃向前兼容,须要使 size 参数 > 0
int epoll_create(int size);

// 这个函数是最新款,如果 falgs 为 0,等同于 epoll_create()
int epoll_create1(int flags);

epoll_create() 办法创立了一个 epoll 实例,并返回了指向 epoll 实例的描述符,这个描述符用于下文行将介绍的另外两个函数。也能够应用 epoll_create1() 这个新函数,这个函数相比前者能够多增加 EPOLL_CLOEXEC 这个可选项,至于有啥含意,对本文并不重要。

这个 epoll 实例外部保护了两个重要构造,别离是 须要监听的文件描述符树 就绪的文件描述符(这两个构造下文会讲),对于就绪的文件描述符,他们会被返回给用户过程进行解决,从这个角度来说,epoll 防止了每次 select/poll 之后用户过程须要扫描所有文件描述符的问题

5.1.2. epoll 注册事件

创立完 epoll 实例之后,咱们能够应用 epoll_ctlctl 就是 control 的缩写)函数,向 epoll 实例中增加、批改或删除咱们感兴趣的某个文件描述符的某些事件。

//  返回值: 若胜利返回 0;若返回 - 1 示意出错
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  

第一个参数 epfd 就是方才调用 epoll_create 创立的 epoll 实例的描述符,也就是 epoll 的句柄。

第二个参数 op 示意要进行什么管制操作,有 3 个选项

  • EPOLL_CTL_ADD:向 epoll 实例 注册 文件描述符对应的事件;
  • EPOLL_CTL_DEL:向 epoll 实例 删除 文件描述符对应的事件;
  • EPOLL_CTL_MOD批改 文件描述符对应的事件。

第三个参数 fd 很简略,就是被操作的文件描述符。

第四个参数就是注册的事件类型,咱们先看一下 epoll_event 的定义:

struct epoll_event {
     uint32_t     events;      /* 向 epoll 订阅的事件 */
     epoll_data_t data;        /* 用户数据 */
};

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

events这个字段和 pollevents参数一样,都是通过二进制掩码设置事件类型,epoll 的事件类型在 /usr/include/sys/epoll.h 中有定义,更具体的能够应用 man epoll_ctl 看一下文档阐明,其中内容很多,晓得有这么回事儿就行了,然而留神一下 EPOLLET 这个事件,我特意加了一下正文,下文会讲到。

enum EPOLL_EVENTS {
      EPOLLIN = 0x001,
          #define EPOLLIN EPOLLIN
      EPOLLPRI = 0x002,
          #define EPOLLPRI EPOLLPRI
      EPOLLOUT = 0x004,
          #define EPOLLOUT EPOLLOUT
      EPOLLRDNORM = 0x040,
          #define EPOLLRDNORM EPOLLRDNORM
      EPOLLRDBAND = 0x080,
          #define EPOLLRDBAND EPOLLRDBAND
      EPOLLWRNORM = 0x100,
          #define EPOLLWRNORM EPOLLWRNORM
      EPOLLWRBAND = 0x200,
          #define EPOLLWRBAND EPOLLWRBAND
      EPOLLMSG = 0x400,
          #define EPOLLMSG EPOLLMSG
      EPOLLERR = 0x008,
          #define EPOLLERR EPOLLERR
      EPOLLHUP = 0x010,
          #define EPOLLHUP EPOLLHUP
      EPOLLRDHUP = 0x2000,
          #define EPOLLRDHUP EPOLLRDHUP
      EPOLLWAKEUP = 1u << 29,
          #define EPOLLWAKEUP EPOLLWAKEUP
      EPOLLONESHOT = 1u << 30,
          #define EPOLLONESHOT EPOLLONESHOT
          // 设置为 edge-triggered,默认为 level-triggered
      EPOLLET = 1u << 31
          #define EPOLLET EPOLLET
};

data字段比拟有意思,咱们能够在 data 中设置咱们须要的数据,具体是什么意思当初说起来还有点麻烦,稍安勿躁,咱们接着看最初一个函数。

5.1.3. epoll_wait

// 返回值: 胜利返回的是一个大于 0 的数,示意事件的个数;0 示意超时;出错返回 -1.
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

这个是不是就感觉很相熟了啊。epoll_wait的用法和 select/poll 很相似,用户过程被阻塞。不同的是,epoll会间接通知用户过程哪些描述符曾经就绪了。

第一个参数是 epoll 实例的描述符。

第二个参数是返回给用户空间的须要解决的 I / O 事件,是一个 epoll_event 类型的数组,数组的长度就是 epoll_wait 函数的返回值,再看一眼这个构造吧。

struct epoll_event {
     uint32_t     events;      /* 向 epoll 订阅的事件 */
     epoll_data_t data;        /* 用户数据 */
};

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

events 示意具体的事件类型,至于这个 data 就是在 epoll_ctl 中设置的 data,这样用户过程收到这个epoll_event,依据之前设置的data 就能获取到相干信息,而后进行逻辑解决了。

第三个参数是一个大于 0 的整数,示意 epoll_wait 能够返回的最大事件值。

第四个参数是 epoll_wait 阻塞调用的超时值,如果设置为 -1,示意不超时;如果设置为 0 则立刻返回,即便没有任何 I/O 事件产生。

5.2. edge-triggered 和 level-triggered

epoll还提供了一个利器——边缘触发(edge-triggered),也就是上文我没解释的EPOLLET 参数。

啥意思呢?我举个例子。如果有个 socket 有 100 个字节的数据可读,边缘触发(edge-triggered)和条件触发(level-triggered)都会产生 读就绪 事件。

然而如果用户过程只读取了 50 个字节,边缘触发就会陷入期待,数据不会失落,然而你爱读不读,反正老子曾经告诉过你了;而条件触发会因为你还没有读完,脚踏实地地不停产生 读就绪 事件催你去读。

边缘触发只会产生一次事件揭示,效率和性能要高于条件触发,这是 epoll 的一个大杀器。

5.3. epoll 进阶

5.3.1. file_operations 与 poll

进阶之前问个小问题,Linux 下所有文件都能够应用 select/poll/epoll 来监听文件变动吗?

答案是不行!

只有底层驱动实现了 file_operationspoll 函数的文件类型才能够被 epoll 监督!

留神,这里的 file_operations 中定义的 poll 和上文讲到的 poll() 是两码事儿,只是恰好名字一样罢了。

socket 类型的文件驱动实现了 poll 函数,具体实现是 sock_poll(),因而才能够被 epoll 监督

上面我摘录了 file_operations 中咱们常见的函数定义给大家看一下。

// file: include/linux/fs.h
struct file_operations {
    ...
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ...
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    ...
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    int (*release) (struct inode *, struct file *);
    ...
};

有点懵对吧,持续看。

Linux 对文件的操作做了高度的形象,每个开发者都能够开发本人的文件系统,Linux 并不知道其中的具体文件应该怎么 openread/write 或者 release,所以 Linux 定义了file_operations 这个“接口”,设施类型须要本人实现 struct file_operations 构造中定义的函数的细节。有点相似于 Java 中的接口和具体实现类的关系。

poll函数的作用咱们下文再说。

5.3.2. epoll 内核对象的创立

epoll_create()的次要作用是创立一个 struct eventpoll 内核对象,后续 epoll 的操作大部分都是对这个数据结构的操作。

  • wq:期待队列。双向链表,软中断就绪的时候会通过 wq 找到阻塞在 epoll 对象上的过程;
  • rdllist:就绪的描述符链表。双向链表,当描述符就绪时,内核会将就绪的描述符放到rdllist,这样用户过程就能够通过该链表间接找到就绪的描述符;
  • rbrRed Black Root。指向红黑树根节点,里边的每个节点示意的就是 epoll 监听的文件描述符。

而后,内核将 eventpoll 退出到以后过程已关上的文件列表中。啥?eventpoll也是一个文件?别急,咱们看看 epoll_create1 的源码。

//file: /fs/eventpoll.c
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
    int error, fd;
    struct eventpoll *ep = NULL;
    struct file *file;
    
  ...
  
  // 1. 为 struct eventpoll 分配内存并初始化
  //         初始化操作次要包含初始化期待队列 wq、rdllist、rbr 等
    error = ep_alloc(&ep);
    
  ...
  
  // 2. 获取一个可用的形容符号 fd,此时 fd 还未与具体的 file 绑定
    fd = get_unused_fd_flags(O_RDWR | (flags & O_CLOEXEC));
    
  ...
    
  // 3. 创立一个名为 "[eventpoll]" 的匿名文件 file
  //        并将 eventpoll 对象赋值到匿名文件 file 的 private_data 字段进行关联
    file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
                 O_RDWR | (flags & O_CLOEXEC));
    
  // 4. 将 eventpoll 对象的 file 指针指向刚创立的匿名文件 file
    ep->file = file;
  
  // 5. 将 fd 和匿名文件 file 进行绑定
    fd_install(fd, file);
    return fd;
}

好好看一下代码中的正文(肯定要看!),代码执行结束的后果就如下图这般。

调用 epoll_create1 后失去的文件描述符实质上是匿名文件 [eventpoll] 的描述符,该匿名文件中的 private_data 字段才指向了真正的 eventpoll 对象。

Linux 中的所有皆文件并非虚言。这样一来,eventpoll 文件 也能够被 epoll 自身监测,也就是说 epoll 实例能够监听其余的 epoll 实例,这一点很重要。

至此,epoll_create1调用完结。是不是很简略呐~

5.3.3. 增加 socket 到 epoll

当初咱们思考应用 EPOLL_CTL_ADD 向 epoll 实例中增加 fd 的状况。

接下来会波及到较多的源码,别恐怖,都很简略

这时候就要用到上文的 rbr 红黑树了,epoll_ctl对 fd 的增删改操查作实际上就是对这棵红黑树进行操作,树的节点构造 epitem 如下所示:

// file: /fs/eventpoll.c
struct epitem {
    /* 红黑树的节点 */
    struct rb_node rbn;

    /* 用于将以后 epitem 连贯到 eventpoll 中 rdllist 中的工具 */
    struct list_head rdllink;

    ...

    /* 该构造保留了咱们想让 epoll 监听的 fd 以及该 fd 对应的 file */
    struct epoll_filefd ffd;


    /* 以后 epitem 属于哪个 eventpoll */
    struct eventpoll *ep;

};

接着咱们看一下 epoll_ctl 的源码。

// file: /fs/eventpoll.c
SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
        struct epoll_event __user *, event)
{
    struct file *file, *tfile;
    struct eventpoll *ep;
    struct epitem *epi;

    ...

    /* 依据 epfd 找到 eventpoll 对应的匿名文件 */
    file = fget(epfd);

    /* fd 是咱们感兴趣的 socket 描述符,依据它找到对应的文件 */
    tfile = fget(fd);
    
  /* 依据 file 的 private_data 字段找到 eventpoll 实例 */
    ep = file->private_data;

    ...
    /* 在红黑树中查找一下,看看是不是曾经存在了
            如果存在了,那就报错;否则,执行 ep_insert */
  epi = ep_find(ep, tfile, fd);
  
    switch (op) {
    case EPOLL_CTL_ADD:
        if (!epi) {
            epds.events |= POLLERR | POLLHUP;
            error = ep_insert(ep, &epds, tfile, fd);
        } else
            error = -EEXIST;
        clear_tfile_check_list();
        break;
    ...
    }
  
    ...
}

epoll_ctl中,首先依据传入的 epfd 以及 fd 找到相干的内核对象,而后在红黑树中判断这个 epitem 是不是曾经存在,存在的话就报错,否则继续执行 ep_insert 函数。

ep_insert故名思义就是将 epitem 构造插入到红黑树当中,然而并非单纯插入那么简略,其中波及到一些细节。

5.3.3.1. ep_insert

很多要害操作都是在 ep_insert 函数中实现的,看一下源码。

// file: /fs/eventpoll.c
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
             struct file *tfile, int fd)
{
    int error, revents, pwake = 0;
    unsigned long flags;
    long user_watches;
    struct epitem *epi;
    struct ep_pqueue epq;

    // 1. 调配 epitem 内存空间
    if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL)))
        return -ENOMEM;
    
  ...
    
    // 2. 将 epitem 进行初始化
    INIT_LIST_HEAD(&epi->rdllink);
    INIT_LIST_HEAD(&epi->fllink);
    INIT_LIST_HEAD(&epi->pwqlist);
    epi->ep = ep;
    ep_set_ffd(&epi->ffd, tfile, fd);
    epi->event = *event;
    epi->nwait = 0;
    epi->next = EP_UNACTIVE_PTR;
    
  ...

    /* 3. 初始化 poll table,设置回调函数为 ep_ptable_queue_proc */
    epq.epi = epi;
    init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);

    /*
     * 4. 调用 ep_ptable_queue_proc 函数,*         设置 socket 期待队列的回调函数为 ep_poll_callback
     */
    revents = ep_item_poll(epi, &epq.pt);
    
  ...
    
    /* 5. epitem 插入 eventpoll 的红黑树 */
    ep_rbtree_insert(ep, epi);

    ...
}
5.3.3.2. 调配与初始化 epitem

尽管源码行数不少,然而这一步非常简单,就是将 epitem 中的数据筹备好,到插入的时候间接拿来用就行了。用一张图来阐明这一步的重点问题。

epitem曾经筹备好了,也就是监听的 socket 对象曾经有了,就差插入到红黑树了,然而在插入之前须要解决个问题,当监听的对象就绪了之后内核该怎么办?

那就是设置回调函数!

这个回调函数是通过函数 ep_ptable_queue_proc 来进行设置的。回调函数是干什么的呢?就是当对应的文件描述符上有事件产生,就会调用这个函数,比方 socket 缓冲区有数据了,内核就会回调这个函数。这个函数就是 ep_poll_callback

5.3.3.3. 设置回调函数

这一大节就是通过源码解说如何设置 ep_poll_callback 回调函数的。

没有急躁的话能够临时跳过这一大节,然而强烈建议整体看完之后回看这部分内容。

// file: /include/linux/poll.h
static inline void init_poll_funcptr(poll_table *pt, poll_queue_proc qproc)
{
    pt->_qproc = qproc;
    pt->_key   = ~0UL; /* all events enabled */
}

init_poll_funcptr函数将 poll_table 构造的 _qproc 函数指针设置为 qproc 参数,也就是在 ep_insert 中看到的 ep_ptable_queue_proc 函数。

接下来轮到 ep_item_poll 了,扒开它看看。

// file: /fs/eventpoll.c
static inline unsigned int ep_item_poll(struct epitem *epi, poll_table *pt)
{
  pt->_key = epi->event.events;
    
  // 这行是重点
    return epi->ffd.file->f_op->poll(epi->ffd.file, pt) & epi->event.events;
}

重点来了,通过上文咱们晓得了,ffd.file指的是 socket 代表的文件,也就是调用了 socket 文件本人实现的 poll 办法,也就是上文提到过的sock_poll()

而后通过上面层层函数调用,最终来到了 poll_wait 函数。

// file: /include/linux/poll.h
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{if (p && p->_qproc && wait_address)
        p->_qproc(filp, wait_address, p);
}

你看,poll_wait又调用了 poll_table_qproc函数,咱们刚刚在 init_poll_funcptr 中将其设置为了ep_ptable_queue_proc,于是,代码来到了ep_ptable_queue_proc

// file: /fs/eventpoll.c
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
                 poll_table *pt)
{
    ...
    
    if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL))) {
        
    // 设置最终的回调办法 ep_poll_callback
    init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
        
    ...
    
    // 将蕴含 ep_poll_callback 在内的信息放入 socket 的期待队列
        add_wait_queue(whead, &pwq->wait);
        ...
    } 
}

ep_ptable_queue_proc被我简化地只剩 2 个函数调用了,咱们在 3.4 节中提到了 socket 本人保护了一个期待队列sk_wq,并且这个期待队列中的每一项保留了阻塞在以后 socket 上的过程描述符(明确晓得该唤醒谁)以及回调函数(内核明确晓得数据来了该怎么做)。

这一系列的操作就是设置回调函数为ep_poll_callback,并封装队列项数据结构,而后把这个构造放到 socket 的期待队列中。

还有一个小问题,不晓得敌人们留神到了没有,我没提保留以后用户过程信息这回事儿。这也是 epoll 更加高效的一个起因,当初 socket 曾经齐全托管给 epoll 了,因而咱们不能在一个 socket 准备就绪的时候就立即去唤醒过程,唤醒的机会得交给 epoll,这就是为什么 eventpoll 对象还有一个队列的起因,里边寄存的就是阻塞在 epoll 上的过程。

再看一遍这个构造。

说完这些,你可能在想,交给 epoll 不也是让 epoll 唤醒嘛,有啥区别?还有 ep_poll_callback 这个回调具体怎么用也没解释。

别急,当初还不是解释的时候,持续往下。

5.3.3.4. 插入红黑树

最初一步就是通过 ep_rbtree_insert(ep, epi)epitem插入到红黑树中。

至此,epoll_ctl的整个调用过程全副完结。

此过程中我没有解释对于红黑树的任何操作,我也倡议大家把它当成一个黑盒,只须要晓得 epoll 底层采纳了红黑树对 epitem 进行增删改查即可,毕竟学习红黑树不是咱们的重点。

至于为什么内核开发者抉择了红黑树这个构造,天然就是为了高效地治理 epitem,使得在插入、查找、删除等各个方面不会因为epitem 数量的减少而产生性能的激烈稳定。

下面几个大节的所有工作,失去了如下这一张图。

5.3.4. epoll_wait

epoll 自身是阻塞的,阻塞也正是在这一步中体现的。

大部分人听到阻塞这个词就感觉很低效,这种想法并不对。

epoll_wait做的事件就是查看 eventpoll 对象中的就绪 fd 列表 rdllist 中是否有数据,如果有,就阐明有 socket 曾经筹备好了,那就间接返回,用户过程对该列表中的 fd 进行解决。

如果列表为空,那就将以后过程退出到 eventpoll 的过程期待队列 wq 中,让出 CPU,被动进入睡眠状态。

也就是说,只有有活儿(fd 就绪),epoll 会玩儿命始终干,相对不阻塞。然而一旦没活儿了,阻塞就是一种正确的抉择,要不然始终占用 CPU 也是一种极大的节约。因而,epoll 防止了很多不必要的过程上下文切换。

好了,当初来看 epoll_wait 的实现吧。

// file: /fs/eventpoll.c
SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
        int, maxevents, int, timeout)
{
    ...
    error = ep_poll(ep, events, maxevents, timeout);
    ...
}
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
           int maxevents, long timeout)
{
...

fetch_events:
    ...
    
  // 如果就绪队列上没有工夫产生,进入上面的逻辑
  // 否则,就返回
    if (!ep_events_available(ep)) {
        /*
         * We don't have any available event to return to the caller.
         * We need to sleep here, and we will be wake up by
         * ep_poll_callback() when events will become available.
         */
    // 定义期待队列项,并将以后线程和其进行绑定,并设置回调函数
        init_waitqueue_entry(&wait, current);
    // 将期待队列项退出到 wq 期待队列中
        __add_wait_queue_exclusive(&ep->wq, &wait);

        for (;;) {
            /*
             * We don't want to sleep if the ep_poll_callback() sends us
             * a wakeup in between. That's why we set the task state
             * to TASK_INTERRUPTIBLE before doing the checks.
             */
      // 让出 CPU,进入睡眠状态
            set_current_state(TASK_INTERRUPTIBLE);
            ...
            if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
                timed_out = 1;
            ...
        }
    ...
    }
...
}

源码中有局部英文正文我没有删除,读一下这些正文可能会对了解整个过程有帮忙。

ep_poll做了以下几件事:

  1. 判断 eventpollrdllist队列上有没有就绪 fd,如果有,那就间接返回;否则执行上面的步骤;
  2. 定义 eventpollwq期待队列项,将以后过程绑定至队列项,并且设置回调函数;
  3. 将期待队列项退出到 wq 队列;
  4. 以后过程让出 CPU,进入睡眠状态,过程阻塞。

每一步都比拟好了解,咱们重点来看一下第 2 步,也就是 init_waitqueue_entry 函数。

// file: /include/linux/wait.h
static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p)
{
    q->flags = 0;
    q->private = p;
    q->func = default_wake_function;
}

wait_queue_t就是 wq 期待队列项的构造体类型,将其中的 private 字段设置成了以后过程的 task_struct 构造体指针。而后将 default_wake_function 作为回调函数,赋值给了 func 字段,至于这个回调函数干嘛用的,还是别急,下文会说的。

于是,这个图又残缺了一些。

5.3.5. 来活儿了

收到数据之后,首先干苦力活的是网卡,网卡会将数据放到某块关联的内存当中,这个操作不须要 CPU 的参加。等到数据保留完了之后,网卡会向 CPU 发动一个 硬中断,告诉 CPU 数据来了。

这个时候 CPU 就要开始对中断进行解决了,然而 CPU 太忙了,它必须时时刻刻筹备好接管各种设施的中断,比方鼠标、键盘等,而且还不能卡在一个中断上太长时间,要不然能够想像咱们的计算机得“卡”成什么样子。

所以理论设计中硬中断只负责做一些简略的事件,而后接着触发 软中断,比拟耗时且简单的工作就交给软中断处理程序去做了。

软中断以内核线程的形式运行,每个 CPU 都会对应一个软中断内核线程,名字叫做ksoftirqd/CPU 编号,比方 0 号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0,为了不便,咱们间接叫做 ksoftirqd 好了。

从这个角度上来说,操作系统就是一个死循环,在循环中一直接管各种中断,解决不同逻辑。

内核线程通过各个函数调用,最终会调用到就绪的 socket 期待队列项中的回调函数ep_poll_callback,是时候看看这个函数了。

// file: /fs/eventpoll.c
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
    ...
  // 获取期待队列项对应的 epitem
    struct epitem *epi = ep_item_from_wait(wait);
  
  // 获取 epitem 对应的 eventpoll 实例
    struct eventpoll *ep = epi->ep;

    ...

    /* 如果以后 epitem 指向的 socket 曾经在就绪队列里了,那就间接退出
            否则,将 epitem 增加到 eventpoll 的就绪队列 rdllist 中
            If this file is already in the ready list we exit soon 
    */
    if (!ep_is_linked(&epi->rdllink)) {list_add_tail(&epi->rdllink, &ep->rdllist);
    }

    // 查看 eventpoll 期待队列上是否有期待的过程
    if (waitqueue_active(&ep->wq))
        wake_up_locked(&ep->wq);
    
  ...
}

ep_poll_callback的逻辑十分简洁清晰。

先找到就绪 socket 对应的期待队列项中的 epitem,继而找到对应的eventpoll 实例,再接口着判断以后的 epitem 是不是曾经在 rdllist 就绪队列里了,如果在,那就没啥好做的了,函数退出就行了;如果不在,那就把 epitem 退出到 rdllist 中。

最初看看 eventpoll 的期待队列上是不是有阻塞的过程,有的话就调用 5.3.4 节中设置的 default_wake_function 回调函数来唤醒这个过程。

epoll 中重点介绍的两个回调函数,ep_poll_callbackdefault_wake_function 就串起来了。前者调用了后者,后者唤醒了过程。epoll_wait的最终使命就是将 rdllist 中的就绪 fd 返回给用户过程。

5.4. epoll 总结

咱们来梳理一下 epoll 的整个过程。

  1. epoll_create创立了 eventpoll 实例,并对其中的就绪队列 rdllist、期待队列wq 以及红黑树 rbr 进行了初始化;
  2. epoll_ctl将咱们感兴趣的 socket 封装成 epitem 对象退出红黑树,除此之外,还封装了 socket 的 sk_wq 期待队列项,里边保留了 socket 就绪之后的函数回调,也就是ep_poll_callback
  3. epoll_wait查看 eventpoll 的就绪队列是不是有就绪的 socket,有的话间接返回;否则就封装一个 eventpoll 的期待队列项,里边保留了以后的用户过程信息以及另一个回调函数default_wake_function,而后把以后过程投入睡眠;
  4. 直到数据达到,内核线程找到就绪的 socket,先调用 ep_poll_callback,而后ep_poll_callback 又调用 default_wake_function,最终唤醒eventpoll 期待队列中保留的过程,解决 rdllist 中的就绪 fd;
  5. epoll 完结!

等下,还没完结!在 5.3.2 节中还留了一个小坑,我说:eventpoll 文件 也能够被 epoll 自身监测,也就是说 epoll 实例能够监听其余的 epoll 实例,这一点很重要。

怎么个重要法,这就波及到 eventpoll 实例中的另一个队列了,叫做poll_wait

struct eventpoll {
    
    ...

    wait_queue_head_t wq;

    /* 就是它!!!!!!!*/
    wait_queue_head_t poll_wait;

    struct list_head rdllist;

    struct rb_root rbr;

    struct file *file;
};

如上图所示

  • epollfd1监听了 2 个一般描述符 fd1fd2
  • epollfd2监听了 epollfd1 和 2 个一般描述符fd3fd4

如果 fd1fd2 可读事件 触发,那么就绪的 fd 的回调函数 ep_poll_callback 对将该 fd 放到 epollfd1rdllist就绪队列中。因为 epollfd1 自身也是个文件,它的可读事件此时也被触发,然而 ep_poll_callback 怎么晓得该把 epollfd1 放到谁的 rdllist 中呢?

poll_wait来喽~~

当 epoll 监听 epoll 类型的文件的时候,会把监听者放入被监听者的 poll_wait 队列中,下面的例子就是 epollfd1poll_wait队列保留了 epollfd2,这样一来, 当 epollfd1有可读事件触发,就能够在 poll_wait 中找到 epollfd2,调用epollfd1ep_poll_callbackepollfd1 放入 epollfd2rdllist中。

所以 poll_wait 队列就是用来解决这种递归监听的状况的。


到此为止,多路复用彻底完结~~~

退出移动版