Linux的IO多路复用和Epoll

文摘   2022-10-29 14:19   广东  

前言

  我们从事服务端开发,少不了对网络编程的接触,Epoll也是不可缺少的知识。总之,让我们来通过这篇文章来了解一下吧。

文章内容

  • 一些基础概念
  • 理解I/O多路复用技术
  • Epoll原理
  • 常见问题

  那就开始罢!(迫不及待)

基础概念

  在开始之前我们需要了解Linux的一些基础概念,里面有很多内容会和以前的《你好,Linux内核架构和原理》有重复,但我觉得没必要让一篇文章的内容建立在另一篇的基础上,太麻烦了,直接在这里介绍一遍需要了解的就好了。(但是你最好是阅读过我的前两篇文章)

用户空间 / 内核空间

  内核空间(Kernel space)是为操作系统内核存在的,操作系统与驱动程序都运行在内核空间中,它常驻于内存中并且不允许用户的应用程序直接对它进行调用或者读写。
  一般来说,每一个普通的用户进程都各自有一个用户空间(User space),也就是说它一个进程处于用户态时它不能访问内核空间,也不能直接进行调用内核的函数,当需要调用时将进程切换到内核态。用户态可以通过系统调用(System Calls)、库函数、Shell脚本来访问内核态资源。   在Linux中,内核空间拥有最高的1GB(0xC0000000-0xFFFFFFF),而给各个进程使用的3GB(0x00000000-0xBFFFFFFF)则是用户空间。

进程阻塞

  一个进程正在等待某些事情的发送,例如读取一个文件,请求一个系统资源。若进程会等待这件事完成再进行下一件事,则这个等待行为被称为阻塞(Block)。若这个进程不理会这件未完成的工作,直接进行下一个,则为非阻塞(non-blocking)。
  阻塞这个行为是进程自发的,主动的。只有正在运行状态的进程,才能将它自己转换为阻塞状态。在这个状态下进程不会占用CPU资源。

进程切换

  内核控制了进程的执行,它能挂起正在运行的某个进程,也能恢复以前挂起的某个进程的执行。这是进程切换,而操作系统内核负责控制这个。将一个进程转到另一个进程上运行。

文件描述符

  文件描述符(File descriptor)是一个计算机科学的术语,这个概念适用于Unix和Linux。Linux的一个很重要的原则是【一切皆为文件】 当进程创建文件或者打开一个文件,内核会返回一个文件描述符,它是一个文件的索引,往后所有的I/O操作的系统调用都会使用这个索引。不同的文件描述符指向同一个文件也是可能的。(句柄)

I/O多路复用技术

  高性能一直以来都是开发者们的追求,尽可能的将服务器的性能发挥到极致。而这很大程度上取决于服务器的网络编程模型,例如I/O模型的同步异步,阻塞非阻塞;进程模型的单进程,多进程。

常见的I/O模型

  • 同步阻塞I/O(Blocking IO)
  • 同步非阻塞I/O(Non-blocking IO)
  • 异步IO(Asychronous IO)
  • IO多路复用(IO Multiplexing)

同步与异步

  同步/异步关注的是消息通信机制(synchronous communication/asynchronous communication)。用一个实际的例子来解释,快餐厅出餐,一个订单完成再接下一个订单,这个逻辑就是同步(synchronous)。而异步(asynchronous)就是这个餐厅把所有订单都接下来,然后哪个先做好就先出哪个。同步在发出一个调用后,得到结果返回后才会继续,而异步可以同时做很多,哪个快就哪个先完成,并没有固定的先后顺序。

阻塞,同步

  阻塞(blocking)和同步(synchronous)看似概念上没什么区别,有人会将它们混为一谈。
  首先,【阻塞】在没有得到返回时会等待,即阻塞这个线程,而【同步】的重点是【调用的结果】,它在调用后一直等到有结果才返回,没结果就等着,但是线程仍然是激活的状态。

非阻塞I/O

  I/O(Input/Output)指对磁盘文件系统或者数据库的写入和读取。阻塞I/O会完成整个获取数据的过程,而非阻塞I/O则是不带数据直接返回,需要通过文件描述符再次读取才能获取数据。

基本的Socket模型

  我们使用Socket这个抽象层来进行网络编程,只需要服务端跑起来,调用Socket()函数,创建网络协议为IPv4,传输协议什么的,再调用bind()函数绑定IP地址和端口。进入监听状态,阻塞以等待客户端连接。就那么简单。当客户端与服务端成功建立连接后,则可以开始read()和write()操作。
  基于Linux一切皆为文件的原则,在内核中的Socket不也是如此,以文件的形式存在。

应对更多的客户端

  在最基本的Socket模型中,服务端一次只能应对一个客户端的连接请求,这实在是太浪费资源了!当它还没处理完一个服务端的读写操作,或者是连接请求,其他客户端就无法与他建立连接。于是针对这个问题,我们要改进这个I/O模型。

多线程

  多线程是支持多个客户端最传统的方式之一,也就是在接受客户端连接后返回的Socket,创建一个线程以应对那个客户端,这个线程只需要关心这个【已连接的Socket】,而主线程就关注【监听Socket】,每多一个连接的客户端就创建一个这样的线程,如果断链就及时销毁。

认识IO多路复用

BIO、NIO的局限性

  同步阻塞(BIO,Blocking IO)。服务端采用单线程时,在接受一个请求并recv或send调用阻塞时,无法接受其他连接请求,也就是无法处理并发。而服务端采用多线程时,接收一个请求后开启一个线程,进行一些操作,可以完成并发处理,但是请求增加的同时大量的线程占用很大的内存,并且线程切换上带来很大的开销,每次接受请求都开一个线程是一种资源浪费。
  同步非阻塞(NIO,Non-blocking IO)接收一个请求后会将它放入一个集合,每次轮询一边集合的数据,没有的话就返回错误。这个过程会相当浪费资源。

什么是IO多路复用?

  I/O多路复用可以使一个或者一组现场(线程池)处理多个连接,用两个系统调用。它属于一种同步IO模型,来实现一个线程监视多个文件句柄。【多路】在这里指网络连接,而【复用】在这里指一个线程。
  I/O多路复用模型下,服务端会采用select/poll/epoll等系统调用获取连接的列表,遍历有事件的进行操作,例如accept/recv/send,这样它就能支持更多并发连接的请求。

Select/Poll

Select

  Select实现多路的方式就是将已连接的Socket都放到一个集合,再调用函数将这个集合Copy到内核,让内核很简单粗暴的遍历这个集合,当有事件产生,就将这个Socket标记为可读写,再Copy到用户空间,用户空间再遍历寻找到这些可读写的Socket。select使用BitsMap,也就是说长度是固定的,在Linux下由FD_SETSIZE限制,默认最大是1024,也就是只能监听0-1023的文件描述符。首先我们来介绍一下,Select函数监视的文件描述符,函数和参数

int select(int maxfdp,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *errorfds,
    struct timeval *timeout
);
  • maxfdp:集合中文件描述符的范围
  • readfds:可读文件描述符集合
  • writefds:可写文件描述符集合
  • errorfds:异常事件的文件描述符集合
  • timeout:select的阻塞时间,可以为Null

fd_set是一个数据结构,你可以将它理解为一种存放文件描述符的集合

Poll

  其实Poll和Select没有什么本质上的差别,或者说差别不大,它只是用动态数组,以链表来组织,而相对于Select的BitsMap打破了文件描述符个数的限制(系统文件描述符限制不算)。它与Select都是以线性结构来储存Socket的,所以也是遍历的方法来找到可读写Socket,时间复杂度为O(n)。这种反复在用户态和内核态之间Copy集合,遍历,随着并发数上升会带来指数级增长的性能损耗。

那么在接下来,我们将介绍一个新的概念。Epoll。

Epoll原理

Epoll简介

  Epoll相对于Select和Poll而言更加灵活,原理上也不同,它使用一个文件描述符管理多个描述符,将客户端关系的文件描述符存放在内核里面一个事件表中,这样在用户空间和内核空间之间只需要Copy一次就好了。打破了描述符的限制。

Epoll设计简介

算法效率

  从算法效率开始讲起,Epoll在Linux内核用红黑树构建了一个【文件系统】,红黑树用于放入用户态传入的文件句柄,这种做法使得Epoll的效率更高。

触发模式

  Epoll提供了两种触发模式,LT水平触发和ET边沿触发,涉及到I/O操作的则是Epoll+ET+非阻塞I/O模型是效率比较高的,但是这会因实际情况而异。这些让我们在后面详细介绍

突破描述符上限

  Epoll支持的文件描述符集合数目上限是可以打开的文件数目上限,也就是远大于1024,这大小和内存有关。相比select和poll,Epoll才算是真正意义上的,大大提高文件描述符数量上限。

INotify机制

  它在linux内核2.6.13以后被支持,功能就能监视文件系统的变化。它出现的目的是替代dnotify。INotify可以为应用程序来监控文件系统的变化,例如文件更改、新建删除。INotify有最基本的两个对象inotify和watch,使用文件描述符表示。

inotify对象

  inotify对象对应的就是一个【队列】,我们可以向这个对象添加一些监听。当事件发生时可以通过对应的函数从这个队列中读取需要的事件信息。

watch对象

  watch对象是一个包括监听模板,事件掩码两个元素的二元组,这是描述文件系统变化事件监听的对象。监听目标是一个路径,可能是文件,也可以是文件夹。事件掩码则是监听事件的类型,例如创建文件。

这个机制在很大程度上避免了轮询文件的麻烦,但是它是需要被动将事件读取而不是主动通知,对于应用而言,它不知道什么时候是读取的最佳时期,这很麻烦。让我们来与Epoll进行对比,就知道Epoll的妙处了!(好像费太多口水提这个了,算了,没关系w)

边缘触发(edge-triggered)

  边缘触发(ET),又称边沿触发,它只有在读取缓冲区数据有增加时才会触发。由于每次有更改只会触发一次,它才不管你缓存区是否还有数据,所以必须得一次性的把数据给读取出来,不然会影响下次的处理。
  我们考虑一个应用从外来的连接接收数据并处理,如果用ET处理就需要循环读取直到没有数据可读,如果是那种不断发数据的连接源就会导致这个循环不结束,其他连接就不能去处理。所以在Epoll中,epoll_wait函数不会直接处理返回的套接字,而是将它放进一个列表,直到把所有的套接字都放进去后再进行处理,当这个列表里面的一个套接字不可读时,就会被移除。所以在ET下必须使用非阻塞I/O模型,避免一个文件句柄阻塞操作时把处理多个文件描述符的那个任务给【饿死】(这种行为叫做饥饿)。

水平触发(level-trggered)

  水平触发(LT)是只要读取缓冲区有数据就会读取,一直发出可读信号。只要写缓冲区不满,还有写入的空间,它就会一直发出可写信号通知。LT模型阻塞和非阻塞都支持,这也是Epoll的默认模式。

Epoll工作流程

  建立一个epoll对象,并且会执行epoll在内核专属的高速缓存区,并且在里面建立红黑树和双向列表。用户态传入的文件句柄将会被放到红黑树内。接下来,内核针对读/写的缓冲区来分别判断这些文件句柄是否可读/写。epoll在将文件句柄放入红黑树的同时还向内核注册了这个文件句柄的回调函数,当这个文件句柄可读/写时则获调用这个回调函数,然后这个文件句柄就被放到了就绪链表。
  epoll_wait不需要在意上面这堆东西,它只需要在这时监控就绪链表就行了,因为可读可写的文件句柄会被放进去,并返回到用户态。不被内核修改的文件句柄只需要在第一次传入就能重复的监控,直到被删除,无需多次Copy。
  也就是说,Epoll特有的高效在于

  • 减少用户态和内核态之间的文件句柄Copy
  • I/O性能不会因为监听文件描述符的数量增长而下降
  • 红黑树储存以及对应回调函数的做法相比与hash更高效,不必预先分配很多空间
  • 减少对可读写文件句柄的遍历

*红黑树

  红黑树这个数据结构很复杂,我简单介绍一下好了。红黑树(Red-Black Tree)是一种特殊的二叉查找树,而在它的每个节点上都有储存位表示节点的【颜色】 也就是红/黑。根节点,叶子节点是黑色,如果一个节点是红色的,它的子节点必须是黑色。它的时间复杂度是O(lgn),效率极高。所以它的应用很广泛,经常被用于储存有序数据,例如Linux虚拟内存管理也有用到红黑树来实现。

*双向链表

  双向链表也叫双链表,它每个数据节点都分别有一个指向后方的,一个前驱节点的指针。也就是说,我们可以很方便的从双向链表的任意一个节点开始访问。

常见问题

Epoll一定比Select更高效吗?

  答案很显然是不一定,Epoll的高效体现在连接多,活动少的情况下,当连接很少的时候反而发挥不出它的优势。Epoll为了管理文件描述符还得维护一个红黑树和一些队列,如果监听的文件描述符很少底层开销就会得不偿失。而在这种情况下,Select和Poll的方式就会显得简洁很多。

为什么Nginx使用的I/O多路复用是多进程单线程?

  Nginx通信采用了I/O多路复用,不过它采用了多进程(单线程)的形式。它就是一个主进程和多个工作进程,每个工作进程只有一个I/O线程。Nginx这么做是为了保障它的可靠性,在多线程情况下线程之间是共享一个地址空间的,而Nginx开放了插件机制,当一个第三方模块引发了一个地址越界时,可能会导致整个Nginx都无了。

BIO和NIO在应用场景上的区别是什么?

  在连接较少且追求稳定的情况下BIO会更好,它虽然对服务器资源要求高,但是既简单又稳定,没有线程切换。而NIO适用于连接多又高并发的情况,但是它的连接数量多的同时单个链接的网络响应速度会下降(这和总带宽有关)

这段时间挺赶的,这篇文章尽管才4k字,也用了中间相距半个月的两个星期来写,草率的结束罢,当然,你有什么其他有趣的问题可以提出w (完)


Makiror Ouyang
Per Aspera Ad Astra.