名词须知
用户空间和内核空间
现代操作系统采用虚拟存储器,对于32位操作系统而言,每个进程的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。操作系统将虚拟空间划分为两部分,一部分是内核空间,一部分是用户空间。每个进程都可以通过系统调用进入到内核。其中在Linux系统中,进程的用户空间是独立的,而内核空间是共有的,进程切换时,用户空间切换,内核空间不变。进程运行在用户空间时称为用户态(也称目态),运行在内核空间时称为内核态(也称管态)。cpu将指令分为特权指令和非特权指令,内核态可运行所有特权指令,比如:清内存、设置时钟、分配硬件资源设置用户权限等。用户态仅用于执行非特权指令。这样可以有效避免由于用户程序误用或滥用特权指令,比如清内存等影响其他应用应用程序,甚至造成系统崩溃。用户进程不能直接操作内核,保证了内核安全。
系统调用
前面我们提到进程在用户态时无法执行特权指令如分配硬件资源的操作,那么用户进程如何获取硬件资源如磁盘进行文件读写呢?操作系统为用户态进程与硬件设备进行交互提供了一组接口——系统调用。当用户进程需要某种服务时,通过系统调用,委托操作系统进行具体操作,操作系统得到结果后返回给用户空间。进行系统调用时,用户进程会从用户态进入内核态,从而可以进行硬件资源的访问和使用,操作结束,得到结果,系统调用返回,进程回到用户态。系统调用把用户从底层的硬件编程中解放了出来;极大地提高了系统的安全性使用户程序具有可移植性,因为系统调用是内核实现的,内核通过系统调用来控制开放什么功能及什么权限给用户程序。
系统调用主要分为六大类:进程控制、文件管理、设备管理、信息维护、通信和保护。本文主讲网络IO,所以主要涉及的系统调用为通信以及文件管理。
同步、异步以及阻塞、非阻塞
引用知乎经典例子:
老王买了一个新水壶要烧水。
首先,老王烧上水之后一直坐在火炉旁等着它烧开,这叫同步阻塞。
然后,老王觉得这样不太好,就去干自己的事情,过一会儿来看一眼水烧开了没有,过一会儿来看一眼水烧开了没有,这叫同步非阻塞。
之后,老王觉得自己那样太傻了,于是买了一个响水壶,老王还是坐在那等着水烧开,这叫异步阻塞。
最后,老王觉得自己那样还是太傻了,于是就把响水壶坐在那,去干他自己的事情,水壶响的时候再过来,这叫异步非阻塞。
阻不阻塞重点在于老王是否一直傻等水开,而同步异步重点在于老王获得结果的方式,是主动等待或轮询还是被动接受通知或回调。
IO调用过程
前面提到用户空间和内核空间的区别。用户程序想要进行IO操作时,需要通过系统调用的方式进入内核态,从而访问硬件资源如硬盘、网卡。以网络读操作为例,当进行默认的TCP通信时,服务端和客户端连接建立后,用户进程执行recvmsg()系统调用,操作系统采用DMA的方式从网卡读取数据到内存,实际上是虚拟内存中的内核空间,然后操作系统将数据由内核空间复制到用户空间,调用完成。
我们知道数据在网络中传输都是以二进制流的形式传递的,既然是流,那么就会有流的过程,从开始接受数据到数据流完整流入内核空间的过程我们称为数据准备阶段。数据从内核空间到用户空间的过程称为数据复制阶段。不同网络IO模型的区别就在于这两个阶段的进程表现不同。
五大IO模型
BIO(Blocking IO)
BIO是同步阻塞型io,进程(线程)接收网络数据流程如下所示:
用户进程在调用recvmsg()之后,在等待数据就绪和数据从内核空间拷贝到用户空间完成之前,进程都处于阻塞状态。直到获得结果前,当前进程或线程无法继续执行。
NIO(Non-Blocking IO)
NIO,我们称为非阻塞IO,与BIO相对应。NIO虽然是非阻塞的,但它还是异步的,这里的非阻塞也并不是完全的没有阻塞。之前在前面提到的数据准备阶段不是阻塞的,因为此过程不需要CPU全程参与,而由CPU借助DMA执行,因此CPU可以继续执行当前进程后续指令。在第二阶段的数据复制过程中,用户进程还是阻塞的。在 Linux 下,可以通过设置 socket 使其变为 non-blocking。在数据中准备阶段,进程会轮训调用recvmsg系nio统调用查询数据是否准备好。与bio的阻塞调用不同,此时recvmsg结果会立即返回,若数据未准备好会返回-1,此阶段不会阻塞当前进程类似于前文的老王隔段时间看一下水是否烧开。当数据准备完后,用户进程再一次进行系统调用recvmsg,cpu会将数据从内核拷贝至用户空间并返回,本阶段是阻塞的。过程如下所示:
NIO的有点在于用户进程在数据准备阶段轮训期间可以干点其他事情,比如提交新的IO任务。缺点在于相比于BIO,两次查询期间存在时间差,而数据随时会准备好,用户进程未实时处理。单线程情况下总的吞吐量降低。
IO多路复用
IO多路复用是NIO的升级版,在NIO中通过轮询的方式去查看当前连接的数据是否准备就绪,而IO多路复用则是轮询查看多个连接中是否有数据已经准备就绪。类比于老王烧水,老王嫌弃一个水壶烧太慢,大部分时间去了水都没开,于是买了好几个水壶一起烧,隔段时间去一次看看哪壶水开提哪壶。Linux中的IO多路复用借助select、poll或者epoll系统调用实现。这里以select为例,select函数定义如下:
int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);
maxfdp:被监听的文件描述符集合中的文件描述符的最大值加1,因为select函数进行描述符遍历时通过比较描述符大小的。
readset、writeset、exceptset:分别指向可读、可写和异常等事件对应的描述符集合。
timeout:用于设置select函数的超时时间,即告诉内核select等待多长时间之后就放弃等待。timeout == NULL 表示一直阻塞;imeout等于0表示函数立即返回;timeout大于0表示阻塞等待多久后返回结果。
返回值:返回值小于0表示出现异常,等于0表示到达阻塞超时时间,大于0则表示可读、可写的描述符个数。
调用过程如下所示:
select在网络编程中的优势在于,用户系统的一个线程可以同时处理多个IO请求,从而大大提高了并发能力。
select 、poll和epoll都是利用IO多路复用机制,主要区别在于select函数描述符个数有限制,最大为1024.poll和epoll则没有限制。相比于select和poll,epoll不需要将套接字描述符在用户和内核空间之间复制,因为epoll在进行注册时就已经复制,减少了每次调用时的系统开销,另一优势在于epoll存在就绪队列
的概念,当内核存在套接字数据就绪时,select和poll需要遍历套接字判断哪个套接字准备完成,而epoll可直接从就绪队列中获取就绪套接字信息。因此,在Linux中epoll可替代select和poll,处理高并发时的性能问题。
信号驱动IO
信号驱动io为异步非阻塞io,应用程序通过signaction系统调用注册接收sigio信号及信号处理函数。当用户进程接收到sigio信号后会调用相应的信号处理函数。当进行UDP通信时,内核只会在数据准备就绪和出现异常时发送sigio信号,而TCP通信存在连接建立和断开的过程,在建立断开连接、通道关闭、发送接收数据时都会发送sigio信号。由于TCP通信sigio触发条件过多,sigio信号无法区分场景,因此在TCP通信时基本不使用。UDP通信时流程如下:
与NIO不同,信号驱动IO在第一阶段完全是非阻塞的,用户进程可执行其他指令,不用关心数据什么时候准备好,在第二阶段数据拷贝依然是阻塞的。
异步IO(AIO)
异步IO即为异步非阻塞IO,异步非阻塞表示在IO请求的两个阶段用户进程都是非阻塞状态。提交请求后便可以做其他事情,直到接收到IO请求处理完成后才会对数据做相应处理。
Linux上的异步IO有两种方式,一种为glibc实现;另一种为Linux内核实现,Linux异步IO是 Linux 2.6内核版本中的一个新的增强,它是2.6版本内核的标准特性,也存在与2.4版本内核的补丁中。glibc基础实现方式为使用新线程进行IO操作,利用线程并发的特性,本质上还是阻塞的,也会占用额外的CPU资源,因此若要使用异步IO更推荐使用内核实现。
Linux原生内核异步IO借助libaio相关接口实现。libaio提供了五个API进行异步IO系统调用:
//设置io上下文
int io_setup(unsigned nr_events, aio_context_t *ctxp);
//销毁io上下文
int io_destroy(aio_context_t ctx);
//提交io请求,读写请求都通过submit提交
int io_submit(aio_context_t ctx, long nr, struct iocb *cbp[]);
//取消io请求
int io_cancel(aio_context_t ctx, struct iocb *, struct io_event *result);
//获取io完成状态
int io_getevents(aio_context_t ctx, long min_nr, long nr, struct io_event *events, struct timespec *timeout);
具体使用方式,类似文章有很多,本文不再赘述,(额,主要是Linux编程不太熟。。)
需要注意的是libaio仅适用于支持以O_DIRECT标志打开的文件,并不是所有文件都支持这种方式,当文件无法使用O_DIRECT方式打开时,使用的还是阻塞方式。
由于epoll所支持的并发量已经满足目前的并发需求,而网络编程中的aio也尚不成熟,因此aio在常见应用程序中使用较少。