IO多路复用整理

介绍

大学时候写在csdn上的一篇博客,最近在看异步IO,就准备先介绍下IO方面内容,给后续的Node异步IO介绍奠定一个基础,同时也把当时的文章改进下搬过来。

基础介绍

首先最基础的,我们先来理解什么是IO。

什么是IO?

我们都知道unix世界里,一切皆文件(不知道的现在也知道了),而文件是什么呢?文件就是一串二进制流,不管socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。

在信息交换的过程中,我们都是对这些流进行数据的收发操作,简称为I/O操作(input and output),例如读出数据使用系统调用read,写入数据使用系统调用write。

文件描述符

计算机中这么多流,我们是如何知道要操作哪个流呢?这时候我们需要有一个能够对文件进行定位的标识符,文件描述符应运而生。

概念

文件描述符是内核为了高效管理已经被打开的文件所创建的索引,它是一个从0开始的整数(对每个进程而言),程序所有执行的I/O操作都是通过文件描述符进行的。

分配规则

在程序刚刚启动时,0,1,2三个文件描述符已经被占用了:

  • 0代表标准输入设备stdin
  • 1代表标准输出设备stdout
  • 2代表标准错误stderr

POSIX标准要求每次打开文件时(含socket)必须使用当前进程中最小可用的文件描述符号码,因此再打开一个文件,它的文件描述符会是3。

文件描述符作为文件的索引,也有它的最大限制。作为系统的一个重要的资源,实际中最大打开的文件数是系统内存的10%,这个是系统级限制,有系统就会有用户,用户级限制是单个进程最大打开的文件数,一般是1024,可以使用ulimit -n命令查看(我这里是256🌚)。

系统是通过文件描述符定位到文件的流程如下:

系统为每一个进程维护了一个文件描述符表,这是一个进程级的文件描述符表,它通过文件描述符所对应的文件指针指向系统级的打开文件描述符表中的一个打开文件句柄,句柄中存储了打开文件相应的全部信息,包括文件偏移量、状态标示、访问模式文件类型以及文件属性等等,其中有一个inode指针,它指向了inode表中该文件的表项,具体想要了解inode可以看我之前的文章

阻塞与非阻塞

阻塞:进程调用了不能立即完成的任务而进行等待

非阻塞:进程调用了立即返回的任务,无需长时间等待

说的很简单,但从进程角度看可以这么理解:

首先看下进程状态转换图:

进程转换图

(偷了个懒,从网上找的,原文地址

进程阻塞可以看做执行了系统调用后,因为有长时间的任务,从而转换成waittinig状态,(因为cpu资源是宝贵的,同一时间一个CPU核只能处理一个running的任务)。

非阻塞就是相反的,一直是running状态(进程是并发的,所以并不会一直是running,不过因为切片很小,我们从宏观认为他是没变的)。

这里聊一下非阻塞与异步IO的区别,有文章说是维度上的不同(不是一类概念),也有人说是一回事。我参考一些文章后得出如下结论:

  • 非阻塞是直接得到结果,这个结果可以是空值、完整的结果或者不完整的
  • 异步IO是必定会返回一个完整的结果,只是可能会需要等待一段时间后返回

IO多路复用

好了,我们讲了这么多,那么,到底什么是I/O多路复用呢?

概念

IO复用就是一种机制,可以同时监控多个阻塞的IO,一旦发现进程指定的一个或者多个IO准备读取,它就通知该进程。

多进程多线程当然也可以处理,但是开销增加的太大了,不只是空间的浪费,同时进程线程间的切换开销也很大(时间成本)。IO复用则是执行系统调用对IO端口进行监控,返回执行情况,显然要比我们在用户空间的操作要快得多。

实现

select, poll, epoll 都是I/O多路复用的具体的实现,下面我们按顺序介绍。

select

函数介绍

I/O多路复用这个概念被提出来以后, select是第一个实现 (1983 左右在BSD里面实现的)。

select的做法是将监听端口加到集合fs_set中,轮训监听端口状态,一旦发现了有IO事件,则返回通知进程,过程如下图:

select

问题

select 被实现以后,很快就暴露出了很多问题。

  1. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。

  2. select 如果任何一个sock(I/O stream)出现了数据,select 仅仅会返回,但是并不会告诉你是那个sock上有数据,于是你只能自己一个一个的找。

  3. select 只能监视1024个链接, linux 定义在头文件中的,参见FD_SETSIZE。

  4. select执行时会将fs_set整个从用户空间copy到内核空间,如果很大时复制操作消耗资源会比较多。

  5. select 不是线程安全的。如果你把一个sock加入到select,然后突然另外一个线程发现,这个sock不用,要收回。这个在这个select 不支持的,如果你强行关掉这个sock,select的描述是不可预测的,以下为官网介绍:

    “If a file descriptor being monitored by select() is closed in another thread, the result is unspecified”

poll

针对select的问题,14年以后(1997年)poll被实现出来。

poll与select最大的不同为存储监听端口的不再是数组,而是链表(具体实现看平台),于是也就没有了1024的限制,但是一些问题依然没有解决,比如线程安全、拷贝到内核空间以及轮训检查等等。

epoll

针对以上问题,在5年后的2002,大神 Davide Libenzi 实现了epoll。

实现

epoll的内部实现分为两部分:

  • 用于存储监听端口的红黑树
  • 用于存储有IO事件端口的双端队列

参考图如下(图片来源:《深入理解 Nginx:模块开发与架构解析(第二版)》,陶辉):

epoll

我们添加的监听端口存放在红黑树中,当端口有IO事件时,epoll会调用回调函数将事件放到队列中,进程只需要监听队列即可。

至于监听的端口存放在红黑树中,显然是为了支持快速的插入、删除以及取出操作(红黑树建议了解一下)。

优点

epoll 可以说是I/O 多路复用最新的一个实现,epoll 修复了poll 和select绝大部分问题,比如:

  1. 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
  2. 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  3. 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。(共享内存的方式实现)

epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

扩展

epoll支持水平触发边缘触发,边缘触发即它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次,这种效率要比水平触发一直占着队列的坑位每次都要处理要好一些,不过边缘触发既然发生后不会在提示,记得一次读完。

总结

本文是一个偏科普的,比较基础,也是自己大学时候写的(貌似很多参考别人的=-=),做了一点改进,不过很多内容都没有细说,epoll后面会专门出一篇文章介绍,毕竟实在是太重要了,红黑树不了解的同学也希望去深入了解下,基本逢考必问。

参考文章

原文链接

同步/异步,阻塞/非阻塞概念深度解析

IO复用原理剖析

Epoll原理解析

注:此文章改自以前的文章,加了些改动,之前没写明引用文章出处,现在也很难找,如果有引用您的原创内容,请及时联系,在此处添加您的文章信息。

-------------本文结束,感谢您的阅读-------------