现代计算机系统叫做程序存储式计算机,抽象点说计算机系统就只有两种部件:CPU + IO设备,IO设备又分为内设和外设,内设指的就是内存、硬盘、网卡等,外设的种类就更多了,诸如鼠标键盘、移动硬盘、显示器等等都是。IO设备和CPU之间的数据交互都要通过操作系统来调度的, epoll就是Linux系统内核高效处理IO事件的一种实现,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。nginx、redis等大部分游戏服务器都使用到这一多路复用技术。

计算机体系概览

CPU和IO设备

下图是一个典型的计算机结构图,计算机由CPU、不同IO设备组成。不管是IO南桥还是北桥芯片,又或是其它设计。最终都是CPU和一堆IO设备通过CPU的系统总线交换数据。这里内存是一种格外重要的IO设备,因为绝大多数时候CPU直接和内存交换数据。(CPU还可以通过总线直接和其它内设或外设的内存交换数据,比如显卡闪存等。内存不需要经过CPU寄存器也可以通过系统总线和其它设备交换数据,这就是DMA方式)。

image-20210827144541068

互联网的时代,网络作为连接一切的桥梁,网卡成了计算机中重要的IO设备。下图展示了网卡接收数据的过程。在①阶段,网卡收到网线传来的数据;经过②阶段的硬件电路的传输;最终将数据写入到内存中的某个地址上(③阶段)。这个过程涉及到DMA传输、IO通路选择等硬件有关的知识,但我们只需知道:网卡会把接收到的数据写入内存。后面操作系统就可以去读取它们了。

image-20210827144608435

中断

计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时(电容可以保存少许电量,供CPU运行很短的一小段时间),它应立即去保存数据,保存数据的程序具有较高的优先级。

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

image-20210827150636082

当网卡把数据写入到内存后,网卡向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。

阻塞

阻塞是进程调度的关键一环,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,recv、select和epoll都是阻塞方法。

为简单起见,先看看下面recv的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);   
// 绑定
bind(s, ...)
// 监听
listen(s, ...)
// 接受客户端连接
int c = accept(s, ...)
// 接收客户端数据
recv(c, ...);
// 将数据打印出来
printf(...)

这是一段最基础的网络编程代码,先新建socket对象,依次调用bind、listen、accept,最后调用recv接收数据。recv是个阻塞方法,当程序运行到accept的时候就会阻塞,等待socket连接;连接建立后转变成recv阻塞,它会一直等待,直到接收到数据才往下执行。

阻塞的原理是系统进程存在就绪、等待、唤醒等不同状态。就绪的进程队列才会获得CPU的执行时间片。

TCP有限状态机

TCP协议下,客户端和服务器端在建立连接的过程中有下面的状态转换关系:

image-20201228002015753

系统IO模型

进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力称为I/O复用(I/O multiplexing),是由select和poll这两个函数支持的。有些系统提供了更为先进的让进程在一串事件上等待的机制。轮询设备(poll device)就是这样的机制之一,不过不同厂家提供的方式不尽相同。I/O复用并非只限于网络编程,许多重要的应用程序也需要使用这项技术。

不管是Linux还是Windows系统,IO处理的模型大致分下面5类,最高效的“异步IO”目前Windows有相应的实现,而Linux内核还没有正式实现。

image-20201228002142810

1. 阻塞式I/O

我们说进程在从系统调用recvfrom开始到它返回的整段时间内是被阻塞的。进程被挂起。

2. 非阻塞式I/O

当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间,这就是“忙等待”。

不过这种模型偶尔也会遇到,通常是在专门提供某一种功能的系统中才有。

3. I/O复用

有了I/O复用(I/O multiplexing),我们就可以调用select或poll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。

阻塞于select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区。I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,I/O复用还稍有劣势。不过使用select的优势在于我们可以等待多个描述符就绪。

与I/O复用密切相关的另一种I/O模型是在多线程中使用阻塞式I/O。这种模型与上述模型极为相似,但它没有使用select阻塞在多个文件描述符上,而是使用多个线程(每个文件描述符一个线程),这样每个线程都可以自由地调用诸如recvfrom之类的阻塞式I/O系统调用了。

4. 信号驱动式I/O

我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号;我们随后既可以在信号处理函数中调用recvfrom读取数据报。

无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知。

5. 异步I/O

工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

支持POSIX异步I/O模型的系统仍较罕见。

select/pselect/poll/epoll

select 和 poll

该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。也就是说,我们调用select告知内核对哪些描述符(就读、写或异常条件)感兴趣以及等待多长时间。我们感兴趣的描述符不局限于套接字,任何描述符都可以使用select来测试。

poll提供的功能与select类似,不过在处理流设备时,它能够提供额外的信息。

对于普通的本地应用,selectpoll可能就很好用了,看看他们的API

1
2
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

它们有一个共同点,用户需要将监控的文件描述符集合打包当做参数传入,每次调用时,这个集合都会从用户空间拷贝到内核空间,这么做的原因是内核对这个集合是无记忆的。对于绝大部分应用,这是一种十足的浪费,因为应用需要监控的描述符在大部分时间内基本都是不变的。变化不是高频现象。

epoll 对此的改进

epoll对此的改进也正是它的实现方式,它需要完成以下两件事:

  1. 描述符添加—内核可以记下用户关心哪些文件的哪些事件。
  2. 事件发生—内核可以记下哪些文件的哪些事件真正发生了,当用户前来获取时,能把结果提供给用户。

本文是学习下面的博文总结而来,讲的很好,多看看。

(完)