深入理解Epoll工作原理.

1,122 阅读9分钟

内核是如何和用户进程协作的.

本文参考张彦飞老师的 《深入理解Linux网络:修炼底层内功,掌握高性能原理》。

读完本文将会收获一下内容:(安安静静的读完、思考完)

  1. 一些基本概念.
  2. TCP网络包到达之后处理流程.
  3. Select工作原理.
  4. Epoll工作原理.
  5. 同步阻塞IO模型.

一、在理解之前,有几个概念需要理解一下.

1.1 同步、异步的概念.

同步异步体现在在进程/线程程实现某个动作时未得到结果前是否可以执行其他动作.

  • 同步:执行某个动作结果未返回时,当前进程/线程等待结果返回.
  • 异步:执行某个动作结果未返回时,当前进程/线程不必等待结果返回,可以去做别的事情. 当第一个动作完成后,会通过回调来通知该动作执行者动作执行完毕.

1.2 阻塞、非阻塞的概念.

阻塞非阻塞体现在进程/协程在实现某个动作时未得到结果前是否挂起.

  • 阻塞:执行某个动作未返回时,当前进程/线程挂起.
  • 非阻塞:执行某个动作未返回时,不会阻塞当前进程/线程.

1.3 内核与用户进程协作的几种方式.

  • select:将要查看的socket文件描述符透传够内核,这个时候会发生一次描述符拷贝,内核拿到具体的文件描述符后遍历当前集合是否有事件发生,如果某个文件描述符有事件发生,则会将该文件描述符的二进制位设置为1,用户进程通过遍历所有文件描述符所表示的二进制位是否为1,如果为1则表示有数据到达,这个时候进行系统调用去取数据即可.
  • poll:本质上和select一样,只是解决了select能监控的文件描述符数量问题。本质上也需要发生内核拷贝.
  • epoll:本质上省略去了文件描述符内核拷贝,以及没有文件描述符数量的限制.

二、select基本工作原理.

  1. 调用socket函数创建socket文件描述符.bind绑定当前机器信息(比如随机分配端口),listen初始化半连接队列和全连接队列.
  2. 调用accept函数等待TCP链接,每一个链接进来都将当前连接的文件描述符放入到bitmap中(fd_set ).
  3. 调用FD_ZERO:将给定的文件描述符集合清空,每次进行读取数据前状态必须清空.
  4. 调用FD_SET:将要监听的文件描述符设置为1,表示要监听这几个文件描述符指向的链接.
  5. 调用select函数,将要监听的文件描述符集合传入给select函数.
  6. select会将给定的文件描述符中就绪的描述符设置为1,未就绪的设置为0.
  7. 用户进程根据返回状态进行读取数据即可.

总结:

image-20230528173542730

三、Epoll工作原理.

3.1 EPOLL_CREATE

EventPolls 数据结构

image-20230528183436576

epoll_create初始化一个epoll内核结构,返回该结构的文件描述符. 下面介绍一下每个字段的含义:

  • wq: 等待队列链表。软中断就绪的时候会通过该链表找到阻塞在Epoll上等待该socket数据的用户进程.
  • rdlist:就绪文件描述列表。当有socket上有数据到来时,软中断时内核会把就绪的socket的文件描述符放入到epoll就绪文件描述列表中.
  • rbr:EpollItem存储结构红黑树。支持海量连接的高效查找、插入、删除、遍历等。通过这棵树来管理用户进程下添加进来的所有socket连接.
3.2 EPOLL_CTL_ADD

每次要添加一个新的连接时,都会初始化一个EpollItem结构体,用来结构化struct. 从下面我们可以看到每一个eventItem都有一个指向eventPoll结构的指针。

struct epitem{
    struct rb_node rbn; // 红黑树节点. 
    struct epoll_filefd ffd; // 当前节点表示的socket文件描述符.
    struct eventpoll *ep; // 所归属的eventpoll对象. 
    struct list_head pwqlist; // 数据接受队列.包含回调指针. 
}

在初始化时,同时会对该item注册一个回调函数(当有数据到来时要执行的回调函数):ep_pool_callback这里并没有将当前进程添加到当前socket的回调函数中,而只是设置了一个回调函数,并且进程描描述符设置的为NULL,这是因为等待进程被放在eventpoll实例上了

整个添加连接的过程可以说是构造一个EpollItem的过程,大概如下:

  1. EpItem初始化内存.
  2. 设置当前Item所关联的socket文件描述符.
  3. 定义并初始化等待队列.
  4. 注册数据到来时的回调函数:ep_poll_callback,当前socket三次握手之后,处于等待数据接受状态,当前进程处于阻塞状态,所以需要设置回调函数.
  5. 将EpItem插入到eventPoll对象的红黑树中.

插入后具体的结构如下:

image-20230528210756569

我们知道,当我们通过EPOLL_CTL_ADD将某一个Socket添加到指定的Epoll中时,会形成上面的结构,其中epitemEventPoll互相关联,同时Socket的等待队列中的回调函数为ep_poll_callback,当某一个TCP包到来时,具体发生什么,我们下面接着来看. 而ep_poll_callback函数主要做了两件事情:

  1. 首先会将当前Socket文件描述符存入到当前epoll的就绪文件描述符队列中.
  2. 其次会唤醒当前epoll上的等待进程,等待进程被唤醒后,将会接着执行read.
  3. 用户进程读取到数据后,进程将会从内核态转换用户态,此时我们就可以对TCP包进行编解码操作.
3.3 EPOLL_WAIT工作原理.

下面我们从EPOLL_WAIT开始分析,EPOLL收到数据包后是怎么唤醒用户进程来内核读取指定Socket数据就绪队列中的数据的.

  1. 第一步:数据包到网卡之后,首先会将当前TCP包通过DMA技术存入到RingBuffer中,之后会通过硬中断通知CPU当前网卡有数据到来,其次硬中断中触发网络包到达软中断,此时内核线程ksoftirqd收到软中断后,将会将数据包从RingBuffer中取出放到当前Socket的数据就绪队列中.

  2. 第二步:唤醒等待在Socket上的用户进程. 我们知道在将某个Socket封装成epitem添加到EventPoll中时,已经将Socket的等待队列中的等待项中的回调函数设置成了epoll的回调函数ep_poll_callback,那么当当前包添加到数据就绪队列中之后,则会执行进程等待队列中的回调函数,而epoll则会执行ep_poll_callback回调函数.

  3. 第三步:执行epoll回调函数ep_poll_callback.而ep_poll_callback主要做了以下内容:

    1. 将当前Socket文件描述符存入到当前epoll的就绪文件描述符队列中.
    2. 如果EventPoll等待队列上有进程在等待,那么将会通过在回调函数中调用default_wake_function来唤醒用户进程,而用户进程的描述符是封装在epoll的等待项中的,这里只需要通过进程描述符修改进程的状态为可运行状态,同时将进程放入到可执行队列中,然后就等待CPU调度执行.
    image-20230531222050051
  4. 第四步:用户进程感知到EVENT_WAIT结束后,继续执行read. 这里我有一个猜想是:epoll_wait返回就绪的文件描述符个数,而用户态也是可以获取到就绪队列的文件句柄,或者,用户态根据返回的个数直接去就绪的文件描述符队列中取出N个数据项,那么这N个数据项就是本次已经就绪的Socket事件,此时,用户态也能正常读取到数据.

总的流程类似下面这种图.

image-20230531221439840

四、同步阻塞IO.

其实,epoll在没有数据到来时,也是会对进程阻塞的,那么为什么epoll会比select等性能好呢?其实这个是因为select系统调用时涉及到用户态到内核态的内存拷贝,其会将用户态的文件描述符集合拷贝到内核态,同理,系统调用返回时,也会发生拷贝,其会将就绪的文件描述符状态拷贝到用户态.而epoll则不是,epoll在有事件发生时,则会返回就绪的文件描述符,其中不在涉及内核态到用户态的拷贝过程,所以在海量链接下epoll性能是非常突出的.

我们既然已经了解了多路复用机制,那么我们也来简单了解下基本的同步阻塞IO模型,也就是目前客户端基本上使用的模型. 整体流程图如下:

image-20230531223344544

进本流程描述:

  1. 进程用户态创建Socket.
  2. 调用read后,如果当前Socket数据接受队列上没有数据时则会将当前进程阻塞掉,修改当前进程状态,并让出CPU使用权,一直等待到有数据包的到来,这里当前进程已经让出CPU使用权,CPU已经不在对当前进程进行调度.
  3. 数据包到达网卡后,通过DMA技术将本次网络包复制到RingBuffer中.
  4. 网卡通过给CPU特定针脚发送电压变化来通知CPU有网络包到来,也就是网络包到来硬中断.
  5. CPU简单处理后发送软中断,此时内核线程ksoftirqd来进行处理.
  6. 内核线程根据特定软中断信号执行网络包接受处理函数,其会将网络包从RingBuffer中取出来放入到指定Socket的数据就绪队列中.
  7. 唤醒用户进程,CPU重新调度,调度到之后,进入内核态读取Socket数据就绪队列中的数据包到用户态.

五、总结:

  1. 多路复用:多个Socket复用一个进程,一个进程内监听多个Socket.
  2. Select特点:
    1. IO多路复用.
    2. 文件描述符限制、用户态-内核态内存拷贝 性能不是很好.
  3. Epoll特点:
    1. 使用红黑树存储文件描述符,发生用户态到内核态的拷贝只有在调用EPOLL_CTL_ADD指令时才会发生,不会向selectpoll一样,每次都会发生拷贝.
    2. 通过异步IO事件确定就绪的文件描述符而不是轮询.
    3. 使用队列存储就绪文件描述符,且返回就绪文件描述符的个数N,用户进程可直接从就绪队列中获取N个就绪的文件描述符进行数据读取. 避免轮询查找就绪的文件描述符.

文章同步到公众号:社恐的小马同学。