写点什么

linux 开发各种 I/O 操作简析,以及 select、poll、epoll 机制的对比

用户头像
良知犹存
关注
发布于: 2020 年 11 月 24 日

作者:良知犹存

转载授权以及围观:欢迎添加微信公众号:羽林君


IO 概念区分

四个相关概念:

  • 同步(Synchronous)

  • 异步( Asynchronous)

  • 阻塞( Blocking )

  • 非阻塞( Nonblocking)


阻塞 I/O

 阻塞,就是调用我(函数),我(函数)没有接收完数据或者没有得到结果之前,我不会返回。

在 linux 中,默认情况下所有的 socket 都是阻塞的,一个典型的读操作流程大概是这样:



当用户进程调用了 read()/recvfrom() 等系统调用函数,它会进入内核空间中,当这个网络 I/O 没有数据的时候,内核就要等待数据的到来,而在用户进程这边,整个进程会被阻塞,直到内核空间返回数据。当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程才解除阻塞的的状态,重新运行起来。

所以,阻塞 I/O 的特点就是在 IO 执行的两个阶段(用户空间与内核空间)都被阻塞了。


非阻塞 I/O

    非阻塞,就是调用我(函数),我(函数)立即返回。阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu 不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。


有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回,它还会抢占 CPU 去执行其他逻辑,也会主动检测 I/O 是否准备好。

执行的模型如下:



能看到,非阻塞 I/O 的特点是用户进程需要不断的 主动询问 内核空间的数据准备好了没有。


同步 I/O

    在操作系统中,程序运行的空间分为内核空间和用户空间,用户空间所有对 io 操作的代码(如文件的读写、socket 的收发等)都会通过系统调用进入内核空间完成实际的操作。

    而且我们都知道 CPU 的速度远远快于硬盘、网络等 I/O。在一个线程中,CPU 执行代码的速度极快,然而,一旦遇到 I/O 操作,如读写文件、发送网络数据时,就需要等待 I/O 操作完成,才能继续进行下一步操作,这种情况称为同步 I/O

其实所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。也就是必须一件一件事做,等前一件做完了才能做下一件事。

    实际工作中我们却很少使用同步 I/O,因为当你读写某个文件,进行 I/O 操作时候,如果数据没有及时回应到,那么系统就会将当前执行读写的线程挂起来等待数据的读取完成,而其他需要 CPU 执行的代码就无法被当前线程执行,这就是同步 I/O 的弊端。仅仅因为一个 I/O 操作就会阻塞当前线程,导致其他代码无法执行,当然我们遇到这样时候会选择用多线程或者多进程来并发执行代码。

       但是多线程和多进程也无法根除这种阻塞问题,因为系统内存大小的限制,所以系统不能无限的增加线程和进程。此外过多的线程和进程,就会导致系统切换线程和进程的开销变大,真正运行代码时间就会变少,这样子系统性能也会严重下降。


异步 I/O

    简单来说就是,用户不需要等待内核完成实际对 io 的读写操作就直接返回了。

    当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者

I/O 过程主要分两个阶段:

1.数据准备阶段

2.内核空间复制回用户进程缓冲区空间

无论阻塞式 IO 还是非阻塞式 IO,都是同步 IO 模型,区别就在与第一步是否完成后才返回,但第二步都需要当前进程去完成,异步 IO 呢,就是从第一步开始就返回直到第二步完成后才会返回一个消息,也就是说,异步能够让你在第一步时去做其它的事情。



同步 IO 和异步 IO 的区别就在于:数据拷贝的时候进程是否阻塞


阻塞 IO 和非阻塞 IO 的区别就在于:应用程序的调用是否立即返回

因为异步 IO 把 IO 的操作给了内核,让内核去操作,同步 IO 的话,需要等待 IO 操作从内核态的数据缓冲区拷贝到用户态的数据缓冲区,所以此时的同步 IO 是阻塞的。


多路复用 I/O

    多路复用 I/O 就是我们说的 select,poll,epoll 等操作,复用的好处就在于 单个进程 就可以同时处理 多个 网络连接的 I/O,能实现这种功能的原理就是 select、poll、epoll 等函数会不断的 轮询 它们所负责的所有 socket ,当某个 socket 有数据到达了,就通知用户进程。

一般在 Linux 下我们会有以下几种的字符设备读写方式,下面是一个使用的对比:

1、查询方法:一直在查询,不断去查询是否有事件发生,整个过程都是占用 CPU 资源,非常消耗 CPU 资源。

2、中断方式:当有事件发生时,就去跳转到相应事件去处理,CPU 占用时间少。

3、poll 方式: 中断方式虽然占用 CPU 资源少,但是在应用程序上需要不断在死循环里面执行读取函数,应用程序不能去做其它事情。poll 机制解决了这个问题,当有事件发生时,才去执行读 read 函数,按键事件没有按下时<如果规定了时间,超过时间后返回无按键信息>,去执行其它的处理函数。

    这里我们能够看到 poll 使用的优势,select,poll,epoll 都是 IO 多路复用的机制。I/O 多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。

    我们再说一下 select,poll 和 epoll 这几个 IO 复用方式,这时你就会了解它们为什么是同步 IO 了,以 epoll 为例,在 epoll 开发的服务器模型中,epoll_wait()这个函数会阻塞等待就绪的 fd,将就绪的 fd 拷贝到 epoll_events 集合这个过程中也不能做其它事(虽然这段时间很短,所以 epoll 配合非阻塞 IO 是很高效也是很普遍的服务器开发模式--同步非阻塞 IO 模型)。有人把 epoll 这种方式叫做同步非阻塞(NIO),因为用户线程需要不停地轮询,自己读取数据,看上去好像只有一个线程在做事情,也有人把这种方式叫做异步非阻塞(AIO),因为毕竟是内核线程负责扫描 fd 列表,并填充事件链表的,个人认为真正理想的异步非阻塞,应该是内核线程填充事件链表后,主动通知用户线程,或者调用应用程序事先注册的回调函数来处理数据,如果还需要用户线程不停的轮询来获取事件信息,就不是太完美了,所以也有不少人认为 epoll 是伪 AIO,还是有道理的。

select 函数

  该函数准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒。select 的调用过程如下所示:



select 的几大缺点:

(1)每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大

(2)同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大

(3)select 支持的文件描述符数量太小了,默认是 1024

    poll 的机制与 select 类似,与 select 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll 没有最大文件描述符数量的限制。poll 和 select 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

    epoll 是在 2.6 内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll 更加灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。

    epoll 既然是对 select poll 的改进,就应该能避免上述的三个缺点。那 epoll 都是怎么解决的呢?在此之前,我们先看一下 epoll 和 select 和 poll 的调用接口上的不同, select 和 poll 都只提供了一个函数 select 或者 poll 函数。而 epoll 提供了三个函数, epoll create,epoll cti 和 epoll wait , epoll create 是创建一个 epol 句柄 ; epoll ctl 是注册要监听的事件类型; epoll wait 则是等待事件的产生。

    对于第一-个缺点, epoll 的解决方案在 epoll ctl 函数中。每次注册新的事件到 epoll 句柄中时(在 epoll ctI 中指定 EPOLL CTL ADD) ,会把所有的 fd 拷贝进内核,而不是在 epoll wait 的时候重复拷贝。epoll 保证 了每个 fd 在整个过程中只会拷贝一次。

    对于第二个缺点, epoll 的解决方案不像 select 或 poll- -样每次都把 current 轮流加入 fd 对应的设备等待队列中,而只在 epoll ctl 时把 current 挂一遍(这一遍必不可少)并为每个 fd 指定一-个回调函数 ,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的 fd 加入-一个就绪链表)。epoll wait 的工作实际上就是在这个就绪链表中查看有没有就绪的 fd (利用 schedule_ timeout0 实现睡一会,判断一会的效果 ,和 select 实现中的第 7 步是类似的)。

    对于第三个缺点, epoll 没有这个限制,它所支持的 FD 上限是最大可以打开文件的数目, 这个数字-般远大于 2048,举个例子,在 1GB 内存的机器上大约是 10 万左右,具体数目可以 cat /proc/sys/fs/file-max 查看,一般来说这个数目和系统内存关系很大。

总结:

1 、 select ,poll 实现需要自 己不断轮询所有 fd 集合,直到设备就绪 ,期间可能要睡眠和唤醒多次交替。而 epoll 其实也需要调用 epoll wait 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪 fd 放入就绪链表中,并唤醒在 epoll wait 中进入睡眠的进程。虽然都要睡眠和交替,但是 select 和 poll 在“醒着 ”的时候要遍历整个 fd 集合,而 epoll 在“醒着”的时候只要判断一下就绪 链表是否为空就行了,这节省 了的 CPU 时间。这就是回调机制带来的性能提升。

2 、 select , poll 每次调用都要把 fd 集合从用户态往内核态拷贝一-次,并且要把 current 往设备等待队列中挂一次,而 epoll 只要一次拷贝,而且把 current 往等待队列上挂也只挂一次(在 epoll wait 的开始,注意这里的等待队列并不是设备等待队列,只是一个 epoll 内部定义的等待队列)。这也能节省不少的开销。

在选择 select,poll,epoll 时要根据具体的使用场合以及这三种方式的自身特点。

1、表面上看 epoll 的性能最好,但是在连接数少并且连接都十分活跃的情况下,select 和 poll 的性能可能比 epoll 好,毕竟 epoll 的通知机制需要很多函数回调。

2、select 低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

这就是我分享的 select,poll,epoll,其中参考了很多人的文章,如果大家有什么更好的思路,也欢迎分享交流哈。


END

推荐阅读

【1】C++的智能指针你了解吗?

【2】嵌入式底层开发的软件框架简述 

【3】CPU中的程序是怎么运行起来的 必读

【4】C++的匿名函数(lambda表达式)

【5】阶段性文章总结分析

本公众号全部原创干货已整理成一个目录,回复[ 资源 ]即可获得



更多分享,扫码关注我

参考链接:

https://blog.csdn.net/Crazy_Tengt/article/details/79225913

https://tutorial.linux.doc.embedfire.com/zh_CN/latest/system_programing/socket_io.html

https://www.zhihu.com/question/19732473


发布于: 2020 年 11 月 24 日阅读数: 1204
用户头像

良知犹存

关注

还未添加个人签名 2020.05.29 加入

还未添加个人简介

评论

发布
暂无评论
linux开发各种I/O操作简析,以及select、poll、epoll机制的对比