前言
网络 I/O操作过程中会涉及到两个系统对象,一个是用户空间I/O操作的进程或者线程,另一个是内核
空间的内核系统,比如发生 I/O read操作时,它会经历两个阶段:
1.等待数据准备就绪
2.将数据从内核拷贝到进程或者线程中。
在以上两个阶段上有不同的处理方式,因此出现了多种网络 IO 模型。
说明:本文的知识基本来源于其他博客,只是重新整理了一番,相当于COPY
I/O网络模型
阻塞I/O(blocking I/O)
默认情况下,socket网络编程都是阻塞的,典型的读操作流程如下
当用户进程调用了 read 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于网络 I/O 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的数据包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除block的状态,重新运行起来。所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被block 了。几乎所有的程序员第一次接触到的网络编程都是从 listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器/客户机的模型。下面是一个简单地“一问一答”的服务器模型。
大部分的socket接口都是阻塞性的,比如accept没有收到客户端的连接时,会一直被阻塞。recv()没有收到对等方数据时也会阻塞到对方发送数据为止。
为了解决服务器支持多个客户端的问题,首先想到的是采用多线程或者线程池的解决方式,即是说,开启一个线程为客户端服务,一段时间服务器去心跳检测客户端是否还处于活动中,如果处于非活动状态,则主动断开和客户端的连接。关于阻塞性模式的测试代码见:socket 编程-单线程和多线程版本.
即使是使用多线程或者线程池的模式,也很难支持大量客户端同时连接服务器的情况,主要是由于服务器能创建线程以及资源的限制。
非阻塞I/O(non-blocking I/O)
可以通过修改socket的属性来将其修改为非阻塞模式,非阻塞模式的流程如下所示
当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回,所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel数据准备好了没有。
假如目前已经有2个客户端连接到服务器,服务器线程可以通过循环对2个客户端调用recv()接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 recv()将大幅度推高 CPU 占用率;此外,在这个方案中 recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如 select()多路复用模式,可以一次检测多个连接是否活跃。
多路复用I/O
多路I/O复用也称为事件驱动I/O,linux中主要包括select/epoll/poll模型,这些模型的共同特点就是一个线程可以同时处理多个网络连接的I/O,基本原理是这些模型会不断的轮训所有的socket,当某个socket有数据到达时,就告知用户进程,可以进行相应的读取操作了。
I/O多路复用在调用select/poll接口的时候会被阻塞的,但是使用 select /poll以后最大的优势是用户可以在一个线程内同时处理多个 socket 的 IO请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。当然,如果处理的连接数不是很多的情况下,可能使用多线程的方式更加优越。
在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。因此 select()与非阻塞 IO 类似。
但这个模型依旧有着很多问题。首先 select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。如果需要实现更高效的服务器程序,类似 epoll 这样的接口更被推荐。遗憾的是不同的操作系统特供的 epoll 接口有很大差异,所以使用类似于 epoll 的接口实现具有较好跨平台能力的服务器会比较困难。
幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent 库,还有作为 libevent 替代者的 libev 库。
例子代码:
select服务器模型
poll模型
epoll模型
异步I/O
linux下的异步I/O只能用于磁盘I/O操作,不用于网络I/O,window下有IOCP提供支持。
用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。
信号驱动I/O
首先我们允许套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可以在信号处理函数中调用 read 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理 SIGIO 信号,这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了 select 的阻塞与轮询,当有活跃套接字时,由注册的 handler 处理。
阻塞/非阻塞,异步/同步
通过网络模型的介绍,我们在来说说阻塞/非阻塞和异步/同步的区别。
阻塞和非阻塞
blocking 与 non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用 blocking IO 会一直 block 住对应的进程直到操作完成,而non-blocking IO 在 kernel 还在准备数据的情况下会立刻返回。
同步非阻塞和异步
在non-blocking IO 中,虽然进程大部分时间都不会被 block,但是它仍然要求进程去主动的 检查,并且当数据准备完成以后,也需要进程主动的再次调用 recvfrom 来将数据拷贝到用户内存。
而 asynchronous IO 则完全不同。它就像是用户进程将整个 IO 操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查 IO 操作的状态,也不需要主动的去拷贝数据。也就是说网络I/O的2个过程都不需要调用者去主动巡查,用户只要发出一个系统调用,然后等待内核返回结果即可。
以上五种模型中,只有异步I/O才是真正的实现异步操作,其他的几种网络I/O模型都是同步的。