Linux的N种IO模型
Linux下主要的IO模型可以分为以下五种:
- 阻塞式IO(Blocking IO)
- 非阻塞式IO(Non-Blocking IO)
- IO复用(Multiplexing IO)
- 信号驱动式IO(Signal-Driven IO)
- 异步IO(Asynchronous IO)
同步:客户端一直等待服务器的响应,直到返回结果为止。
异步:客户端发起调用之后,直接返回,不会等待服务端的响应。服务端通过通知机制或者回调机制来通知客户端
阻塞:客户端发起调用之后,服务端返回结果之前,客户端线程会被挂起,此时客户端线程无法被CPU调度
非阻塞:客户端发起调用之后,立即返回,不管服务端此时有没有返回。
概念释义
在介绍具体的LinuxIO前,先看下几个相关的概念:
用户态和内核态
这两个概念往往也被称为用户空间和内核空间。操作系统的核心是内核,独立于普通的应用程序,它可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保护内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
用户态和核心态的切换
用户态切换到核心态有三种方式:
- 系统调用:用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。
- 异常:当CPU在执行运行在用户态下的程序时,发生了某些不可知的异常,会触发当前有运行进程切换到处理此异常的内核相关程序中,也就是切换到了内核态。
- 外围设备中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中盾信号,这时CPU会暂停执行吓一跳即将要执行的指令转而去执行与中断信号对应的处理程序。
进程切换
内核挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行,称为进程切换。从一个进程转到另外一个进程执行,涉及到以下步骤:
- 保存处理机上下文,包括程序计数器和其他寄存器
- 更新PCB信息
- 把进程的PCB信息移入相应的队列,如就绪、在某时间阻塞等队列
- 选择另一个进程执行,并更新其PCB
- 更新内存管理的数据结构
- 恢复处理机上下文
进程阻塞
正在执行的进程由于一些事情发生,如请求资源失败、等待某种操作完成、新数据尚未达到或者没有新工作做等,由系统自动执行阻塞原语,使进程状态变为阻塞状态。因此,进程阻塞是进程自身的一种主动行为,只有处于运行中的进程才可以将自身转化为阻塞状态。当进程被阻塞,它是不占用CPU资源的。
文件描述符
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念
。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表
。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
缓存IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间`。
缓存IO的缺点
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
水平触发和边沿触发
水平触发:如果文件描述符上可以非阻塞地执行I/O系统调用,此时认为它已经就绪,触发通知。举个例子,比如说我们采用epoll水平触发模式监听一个文件描述符的可读,当这个文件可读就绪时,epoll会触发一个通知,然后我们执行一次读取操作,但这次操作我们并没有把该文件描述符的数据全部读取完。当下一次调用epoll监听该文件描述符时,epoll还会再次触发通知,直到该事件被处理完。这就意味着,当epoll触发通知后,我们可以不立即处理该事件,当下次调用epoll监听时,然后会再次向应用程序通告此事件,此时我们再处理也不晚。
边沿触发(边缘触发):如果文件描述符自上次状态检查以来有了新的I/O活动(比如新的输入),此时需要触发通知。还是上个例子,如果这次我们采用epoll的边沿触发模式监听一个文件描述符的可读,当可读就绪时,epoll会触发一个通知,如果我们此时不立即处理该事件,当下次再调用epoll监听时,虽然该文件描述符的状态是可读的,但是此时epoll并不会再给应用程序发送通知。因为在边沿触发工作模式下,只有下一个新的I/O事件到来时,才会再次发送通知。
Linux IO模型
网络IO的本质是对Socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。比如对一个READ操作来说,数据会先被拷贝到操作系统内核的缓冲区,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个READ操作发生时,它会经历以下两个阶段:
- 等待数据准备
- 将数据从内核中拷贝到进程中
对于socket流而言:
- 通常涉及等待网络上的数据分组到达,然后被复制到某个内核的缓冲区
- 把数据从内核缓冲区复制到应用进程缓冲区
网络应用需要处理的无非就是两大类问题,网络IO,数据计算
。相对于后者,网络IO的延迟,给应用带来的性能瓶颈大于后者。网络IO的模型大致有如下几种:
- 阻塞式IO(Blocking IO)
- 非阻塞式IO(Non-Blocking IO)
- IO复用(Multiplexing IO)
- 信号驱动式IO(Signal-Driven IO)
- 异步IO(Asynchronous IO)
阻塞式IO
客户端进程调用的服务端的时候,客户端进程会一直阻塞,直到数据拷贝完成,数据从内核空间拷贝到用户空间之后,服务端进程返回成功,客户端进程才会从阻塞中恢复。
非阻塞式IO
客户端进程不再执着于死等服务端进程完成,而是采取轮询的方式,每隔一段时间,调用相关的IO函数,确实数据是否准备完成,数据准备完成之后,再将数据拷贝到用户空间。需要注意的是,在拷贝数据的过程中,客户端进程仍然是阻塞的。
IO复用
非阻塞式IO每个轮询一个线程,期间会消耗大量的CPU时间。而后台执行的任务又不可能只有一个。这个时候,如果有一个机制,能同时轮询多个socket状态,在任意socket返回之后,及时发起调用,通知对应的用户端线程该多好。
Linux下的三个系统调用:select、poll、epoll函数,就是专门干这个的。
select调用是内核级别的,select轮询相对非阻塞的轮询的区别在于—前者可以等待多个socket,能实现同时对多个IO端口进行监听,当其中任何一个socket的数据准好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。
select或poll调用之后,会阻塞进程,与blocking IO阻塞不同在于,此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。
I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时(注意不是全部数据可读或可写),才真正调用I/O操作函数。
对于多路复用,也就是轮询多个socket。多路复用既然可以处理多个IO,也就带来了新的问题,多个IO之间的顺序变得不确定了,当然也可以针对不同的编号。具体流程,如下图所示:
信号驱动式IO
首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。过程如下图所示:
异步IO
相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。异步过程如下图所示:
IO复用之select、poll、epoll简介
epoll是linux所特有,而select是POSIX所规定,一般操作系统均有实现。
select
select本质是通过设置或检查存放fd标志位的数据结构来进行下一步处理。缺点是:
- 单个进程可监视的fd数量被限制,即能监听端口的大小有限。一般来说和系统内存有关,具体数目可以cat /proc/sys/fs/file-max查看。32位默认是1024个,64位默认为2048个
- 对socket进行扫描时是线性扫描,即采用轮询方法,效率低。当套接字比较多的时候,每次select()都要遍历FD_SETSIZE个socket来完成调度,不管socket是否活跃都遍历一遍。会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,就避免了轮询,这正是epoll与kqueue做的
- 需要维护一个用来存放大量fd的数据结构,会使得用户空间和内核空间在传递该结构时复制开销大
poll
poll本质和select相同,将用户传入的数据拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或主动超时,被唤醒后又要再次遍历fd。它没有最大连接数的限制,原因是它是基于链表来存储的,但缺点是:
- 大量的fd的数组被整体复制到用户态和内核空间之间,不管有无意义。
- poll还有一个特点“水平触发”,如果报告了fd后,没有被处理,那么下次poll时再次报告该fd。
epoll
epoll支持水平触发和边缘触发,最大特点在于边缘触发,只告诉哪些fd刚刚变为就绪态,并且只通知一次。还有一特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一量该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。epoll的优点:
- 没有最大并发连接的限制。
- 效率提升,只有活跃可用的FD才会调用callback函数。
- 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递。
总结
支持一个进程打开连接数 | IO效率 | 消息传递方式 | |
---|---|---|---|
select | 32位机器1024个,64位2048个 | IO效率低 | 内核需要将消息传递到用户空间,都需要内核拷贝动作 |
poll | 无限制,原因基于链表存储 | IO效率低 | 内核需要将消息传递到用户空间,都需要内核拷贝动作 |
epoll | 有上限,但很大,2G内存20W左右 | 只有活跃的socket才调用callback,IO效率高 | 通过内核与用户空间共享一块内存来实现 |