在《Unix网络编程》一书中提到了五种IO模型,分别是:阻塞IO、非阻塞IO、多路复用IO、信号驱动IO以及异步IO,其中前4种IO都属于同步IO。
这5种IO同时也被称为Linux的五种网络IO模型。由于服务器端一般都是使用的Linux操作系统,所以了解这几种io模型也是学习java网络io模型BIO,NIO,AIO的基础。
在了解IO模型之前首先需要了解操作系统内核态和用户态的概念:用户态字面理解就是用户使用的空间,内核态则是操作系统使用的空间。在IO中(文件IO或者是网络IO),都是由用户去调用Read读取内核态中的数据,读取数据到用户态;而write则是将数据从用户态写到内核态中,由内核去写入文件或者是通过网络IO(网卡)发送数据。
内核(kernel)利用文件描述符(file descriptor)来访问文件。文件描述符是非负整数。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。
实际上,文件描述符是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
对于一次IO操作(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
IO可分为文件IO和网络IO,对于网络IO来说,它的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。对于socket流而言:
接下来需要理解阻塞和非阻塞:
阻塞:
阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。
非阻塞:
非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。当前线程可以去干别的事情。
阻塞IO和非阻塞IO的区别就在于: 应用程序的调用是否立即返回
同步:
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。也就是必须一件一件事做,等前一件做完了才能做下一件事。
有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回,它还会抢占cpu去执行其他逻辑,也会主动检测io是否准备好。
异步:
异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
对于IO来说,同步IO和异步IO的区别就在于: IO操作的第二阶段,数据拷贝的时候进程是否阻塞。以read读操作为例:
同步IO:应用程序主动向内核查询是否有可用数据,如果有,当前进程自己负责把数据从内核copy到用户空间,拷贝的过程中进程阻塞。
异步IO:应用程序向内核发起读数据请求时需要:(1)告诉内核数据存放位置(2)注册回调函数,当内核完成数据copy后调用回调通知应用程序取数据。因为数据copy由内核完成的,所以拷贝的时候进程不阻塞。
换句话说,同步IO/异步IO最大区别:同步IO数据从内核空间到用户空间的copy动作是由应用程序自己完成。而异步IO则是注册回调函数并告知内核用户空间缓冲区存放地址,数据copy由内核完成。
参考:简述同步IO和异步IO的区别
阻塞IO是最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。
当用户进程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户进程就会处于阻塞状态,用户进程交出CPU。当内核等到数据就绪之后,进程就会将内核中的数据拷贝到用户内存,然后内核返回结果给用户线程,用户线程才解除block状态。
当用户进程发起一个IO操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,进程在返回之后,可以干点别的事情,然后它可以再次发送IO操作。一旦内核中的数据准备好了,并且又再次收到了用户进程的请求,那么进程就会将内核中的数据拷贝到用户内存,然后返回。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
所谓I/O多路复用机制,就是说通过一种机制,实现一个线程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。没有文件描述符就绪时会阻塞应用程序,交出cpu 。多路是指网络连接,复用指的是同一个线程。
这种机制的使用需要额外的功能来配合: select、poll、epoll。
select、poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。
Linux 用 select/poll/epoll 函数实现 IO 复用模型,这些函数也会使进程阻塞,但是和阻塞IO所不同的是这些函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。直到有数据可读或可写时,才真正调用IO操作函数。
select的三大缺点:
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历从用户态传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024
poll解决了第三个缺点,它没有最大连接数的限制,原因是它是基于链表来存储的,但它同样会有前两个缺点。
epoll的改进
epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。
对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd从用户态拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。在内核⾥使⽤「红⿊树」来关注进程所有待检测的 Socket,红⿊树是个⾼效的数据结构,增删查⼀般时间复杂度是 O(logn)
对于第二个缺点,epoll在epoll_ctl时为每个fd指定一个回调函数,当设备就绪,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表,,然后唤醒等待队列上的等待者。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd。
对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
epoll的两种触发模式
epoll ⽀持边缘触发和水平触发的⽅式,而 select/poll 只⽀持⽔平触发,⼀般而言,边缘触发的方式会比水平触发的效率高。 因为边缘触发可以减少 epoll_wait 的系统调⽤次数,系统调⽤也是有⼀定的开销的的,毕竟也存在上下⽂的切换。
总结:
(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,而epoll只要一次拷贝。
参考:IO多路复用 && 五种IO模型
elect、poll、epoll优缺点
linux多路IO–epoll(一)–水平触发和边沿触发
在多路复用IO模型中,会有一个内核线程不断去轮询多个socket的状态,只有当真正读写事件发生时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
Linux 用socket进行信号驱动 IO,用户线程发起一个IO请求操作会给对应的socket安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO 信号,然后处理 IO 事件。
进程在第一个阶段是非阻塞,在第二个阶段是阻塞;和非阻塞IO有点相似,但它与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统API的调用次数,提高了效率。
这个一般用于UDP中,对TCP套接口几乎是没用的,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么事情。在UDP上,SIGIO信号会在下面两个事件的时候产生:
1 数据报到达套接字
2 套接字上发生错误
因此我们很容易判断SIGIO出现的时候,如果不是发生错误,那么就是有数据报到达了。
而在TCP上,由于TCP是双工的,它的信号产生过于频繁,并且信号的出现几乎没有告诉我们发生了什么事情。因此对于TCP套接字,SIGIO信号是没有什么使用的。
前面四种IO模型实际上都属于同步IO,只有最后这一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝到进程中 的过程都会让用户线程阻塞。
Linux中,可以调用 aio_read 函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方
式,然后无论内核数据是否准备好,都会直接返回,用户态进程可以去做别的事情,当内核将数据拷贝到缓冲区之后,再通知应用程序。IO两个阶段,进程都是非阻塞的。
Linux提供了AIO库函数实现异步,但是用的很少。目前有很多开源的异步IO库,例如libevent、libev、libuv。
参考:聊聊Linux 五种IO模型
Linux的5种网络IO模型详解 这篇博客描述上有些问题