IO分类
阻塞和非阻塞
阻塞IO:用户空间引发内核空间的系统调用,需要内核IO操作彻底完成之后,返回值才会返回到用户空间,执行用户的操作。阻塞指的用户空间程序的执行状态,用户空间程序需要等到IO操作彻底执行完毕。java中,默认创建的socket是阻塞的。
非阻塞IO:用户空间引发内核空间的系统调用,不需要等待内核IO操作彻底完成,内核立即给用户返回一个返回值。用户空间程序继续执行用户的操作,处于非阻塞的状态。java中,设置非阻塞的IO,以socket为例,见如下所示的代码:
serverChannel = ServerSocketChannel.open()
serverChannel.configureBlocking(false)
有兴趣,也可以参考java中关于Socket这个类的文档
同步和异步
同步IO:用户空间和内核空间的调用发起方式。同步IO是指用户空间的线程是主动发起IO请求的一方,内核空间是被动接受方。
异步IO:用户空间和内核空间的调用发起方式。异步IO是指用户空间的线程是被动接受方,但是内核空间里面的kernel是主动发起IO请求的一方。
四种常见的IO模型
服务器端的编程需要构造高性能的IO模型,常见的IO模型有四类:
同步阻塞IO(Blocking IO):
结合上面的描述,同步阻塞IO指的是用户空间主动发起的调用,然后需要等待内核空间将IO操作彻底完成之后才会返回用户空间,这期间,用户空间线程将会处于阻塞状态。
BIO优点: 程序简单,在阻塞等待数据的期间,用户挂起线程,用户线程基本不会占用CPU资源。
BIO缺点:每个请求可能会配置一套独立的线程,当并发量很高的场景下,内存和线程切换的成本很高。
应用举例:在Java中使用线程池的方式去连接数据库,就是使用的同步阻塞IO模型。
同步非阻塞IO(Non-blocking IO):
如果是socket被设置为non-blocking,NIO模型如果出现了系统调用,会出现以下两种情况:
(1)当内核缓存区里面没有数据,那么当用户空间发起的系统调用时,会立即返回一个失败的信息
(2)当内核缓存区里面有数据,那么当用户空间发起的系统调用时,会进入到阻塞状态,将内核缓存区里面的数据复制到用户缓冲区。直到数据返回成功,才会解除阻塞的状态。
NIO的优点:每次发起IO系统调用,线程在内核等待缓冲区数据的时候,会立即返回值,不会阻塞。实时性比较好。
NIO的缺点:需要不断地轮询发起系统调用,这样会占用大量的CPU时间,资源利用率很低。
IO多路复用(IO Multiplexing)
首先从字面意思来理解多路复用:
-
多路: 多个socket网络连接
-
复用:复用一个线程,使用一个线程来检查多个文件套接字(又称文件句柄)的就绪状态
IO多路复用是一种同步IO模型,实现用一个线程监视多个文件句柄,一旦有文件句柄准备就绪,就可以通知应用程序进行相应的读写操作。没有文件句柄就绪,就会阻塞应用程序,然后交出CPU的时间片。
通过对之前两种IO模型的总结,我们可以发现:
针对高并发的场景,同步阻塞模型的缺点是需要做频繁的内存和线程的切换,效率很低。同步非阻塞的缺点是要在用户程序空间轮询的发起系统调用,这导致内核态和用户态的频繁切换,也会消耗大量的资源。
IO多路复用则可以避免内核态和用户态的频繁切换,因为IO多路复用模型将轮询套接字(又称为文件句柄)的动作,直接放在了内核态进行,这样避免了内核态和用户态的频繁切换
举例说明
我们以基础的socket模型为例,展现IO多路复用的机制:
下面是基础的socket模型伪码:
listenSocket = socket(); //系统调用socket()函数,调用创建一个主动socket
bind(listenSocket); //给主动socket绑定地址和端口
listen(listenSocket); //将默认的主动socket转换成服务器使用的被动socket(也叫监听socket)
while(true) { //循环监听客户端的连接请求connectSocket = accept(listenSocket); //接受客户端连接,获取已连接socketrecv(connSocket); //从客户端读取数据,只能同时处理一个客户端send(connSocket); //给客户端返回数据,只能同时处理一个客户端
}
网络通信的流程如下图所示:
上图所示的socket网络通信,是典型的同步阻塞模型,当有大量客户端连接时,这种模型的处理性能比较差。使用IO多路复用可以解决这种困境。
linux中,操作系统提供了select、poll和epoll三种多路复用机制。
select机制
四个问题
1、IO多路复用可以最多监听多少个socket?
2、IO多路复用可以监听socket里面的哪些事件?
3、IO多路复用如何感知已经就绪的文件描述符fd?
4、IO多路复用如何实现网络通信?
首先在linux平台上查看一下select函数定义,可以参考一下文章1Linux内核select源码剖析 、文章2Linux select源码分析。
/**
* 参数说明
* 监听的文件描述符数量 __nfds
* 被监听描述符的三个集合*__readfds, *__writefds 和 *__exceptfds
* 监听时阻塞等待的超时时长*__timeout
* 返回值:返回一个socket对应的文件描述符
*/
int select(int __nfds, fd_set * __readfds, fd_set * __writefds, fd_set * __exceptfds, struct timeval * __timeout)
select函数监听的文件描述符被分成三类,分别是__readfds, __writefds 和 __exceptfds,当用户调用select时,假设当前监控的是___readfds集合,select操作会将需要监控___readfds集合从用户空间拷贝到内核空间,随后在内核空间一直遍历自身的skb(SocketBuffer),检查每个skb的poll逻辑,已确定socket是否存在可读事件。若没有socket可读,则会进入到睡眠状态。当发现有sokcet可读,则会唤醒用户空间的程序,然后在用户态去遍历监控的集合,并读取数据。
select 多路复用方法存在的缺陷:
1、调用select需要将套接字列表从用户态复制到内核态,对于多并发场景,资源消耗量比较大。
2、能监听的端口号的数量有限制,FD_SETSIZE,32位机器限制1024个套接字,64位机器限制2048个套接字。
3、被监控的fdlist列表,如果有一个套接字数据可读,业务就需要遍历一遍用户态的fdlist列表,时间复杂度O(n)。
poll
相较于select,poll优化了select的缺陷二,使用的是pollfd结构,而不是fd_set结构,突破了1024的限制,但是poll没有解决缺陷1和缺陷3,仍然需要导致用户态到内核态的资源消耗过大的问题。