Epoll的基本原理

Epoll的基本原理

IO多路复用的背景

计算机之间的通信一般是通过socket来进行,socket保存的是通信过程中必要的控制信息。两台计算机想要通信首先要通过socket建立连接,然后相互读写数据。我们日常使用的浏览器其实可以看作一个客户端,服务端一般是由互联网公司来运维。在浏览器输入地址之后,浏览器会帮助我们和服务端建立连接,不断读写数据。但客户端可能不止一个,有可能有多个,多个客户端同时连接服务器,而服务器如何同时处理这些客户端的连接,又如何维护这些连接呢?可行的方法有很多,比如同时启动多个进程来处理,或者启动多个线程进行处理。但这对于服务器来说过于耗费资源,无法应对大量请求的场景。没建立一个进程都需要在计算机内部创建一个进程的结构,分配一定的内存,占用一定的资源。虽然线程共享这些资源,但仍然无法支撑很大规模的请求。这个时候就需要一种新的技术。当然生产级的大规模请求还有更多的技术结合起来使用,这里只介绍 IO多路复用。使用io多路复用的软件比较多如redis, nginx等。

计算机的硬件结构图

网卡处理数据包的流程

  • 计算机执行程序时,会有优先级的需求。比如,当计算机收到中断电信号时,它应立即去保存数据,保存数据的程序具有较高的优先级。

  • 一般而言,由硬件产生的信号需要cpu立马做出回应(不然数据可能就丢失),所以它的优先级很高。cpu理应中断掉正在执行的程序,去做出响应;当cpu完成对硬件的响应后,再重新执行用户程序。和函数调用差不多。只不过函数调用是事先定好位置,而中断的位置由“信号”决定。

  • 现在可以回答本节提出的问题了:当网卡把数据写入到内存后,网卡向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据

进程阻塞为什么不占用cpu资源

阻塞是进程调度的关键一环,指的是进程在等某事件(如接受网络数据)发生之前的
等待状态,rec、select和epoll都是阻塞方法。下面是一段基础的网络编程代码。

# -*- coding: UTF-8 -*-
# 文件名:server.py

import socket               # 导入 socket 模块

s = socket.socket()         # 创建 socket 对象
host = socket.gethostname() # 获取本地主机名
port = 8081                # 设置端口
s.bind((host, port))        # 绑定端口

s.listen(5)                 # 等待客户端连接
while True:
    c,addr = s.accept()     # 建立客户端连接
    print '连接地址:', addr
    c.send('hello world')
    c.close()                # 关闭连接

内核状态分为运行中、等待中、停止、僵尸。运行中的进程正在CPU中执行或者等待CPU分配时间片。等待中的进程在等某个事件的发生,比如网卡接收到新的数据发送中断到CPU。等待中的进程在所等待的事件没有到来之前不会被执行,因此也不会占用CPU的资源。

如上图所示,进程A包含了网络编程创建server并执行监听的过程。执行recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中。操作系统中只剩下进程B和进程C继续执行。A被阻塞住等待事件的发生。如果有新的数据包到达网卡,会发生中断。socket接收到数据之后,操作系统把A进程放回工作队列继续执行,处理数据。

内核接受网络数据的过程

同时监听多个socket的简单方法

服务端需要管理多个客户端连接,而recv只能监视单个socket,这种矛盾下,人们开始寻找监视多个socket的方法。epoll的要义是高效的监视多个socket。从历史发展角度看,必然先出现一种不太高效的方法,人们再加以改进。只有先理解了不太高效的方法,才能够理解epoll的本质。

假如能够预先传入一个socket列表,如果列表中的socket都没有数据,挂起进程,直到有一个socket收到数据,唤醒进程。这种方法很直接,也是select的设计思想。
为方便理解,我们先复习select的用法。在如下的代码中,先准备一个数组(下面代码中的fds),让fds存放着所有需要监视的socket。然后调用select,如果fds中的所有socket都没有数据,select会阻塞,直到有一个socket接收到数据,select返回,唤醒进程。用户可以遍历fds,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。

select的流程

  • select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。
  • 当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。
  • 所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。遍历所有的socket判断是不是有事件。

select遇到的问题

其一,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。

其二,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
那么,有没有减少遍历的方法?有没有保存就绪socket的方法?这两个问题便是epoll技术要解决的。

Epoll的设计思路

功能分离

select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。

就绪列表

select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。

epoll的原理和流程

创建epoll对象

如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。

维护监视队列

创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。

接受数据

当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。

eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。

当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。

阻塞和唤醒

1 假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
2 当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。

posted @ 2021-03-30 20:41  周围静地出奇  阅读(745)  评论(0编辑  收藏  举报