首页 > 代码库 > Unix 网络编程 I/O 模型 第六章

Unix 网络编程 I/O 模型 第六章

前提,也是重点是,

当接收收据、或者读取数据时,分两步

1 等待数据准备好。

2 从内核拷贝数据到进程。


对于一个network IO 即 socket(这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
 1 等待数据准备 (Waiting for the data to be ready)
 2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。

共有5种IO模型。

? blocking I/O         阻塞IO
? nonblocking I/O   非阻塞IO
? I/O multiplexing (select and poll)  IO复用
? signal driven I/O (SIGIO) 信号驱动IO
? asynchronous I/O (the POSIX aio_functions) 异步IO

 

1 blocking IO 
在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block

另外,recvfrom知道数据准备好,且从kernel拷贝到了进程,或者是出错才返回。常见错误时系统调用被信号中断,即recvfrom是慢系统调用。

几乎所有的程序员第一次接触到的网络编程都是从 listen()、send()、recv() 等接口开始的。使用这些接口可以很方便的构建服务器 / 客户机的模型。

我们假设希望建立一个简单的服务器程序,实现向单个客户机提供类似于“一问一答”的内容服务。

图 1. 简单的一问一答的服务器 / 客户机模型

图 1. 简单的一问一答的服务器 / 客户机模型

我们注意到,大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。

实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send() 的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。这给多客户机、多业务逻辑的网络编程带来了挑战。这时,很多程序员可能会选择多线程的方式来解决这个问题。

一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的CPU资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用pthread_create ()创建新线程,fork()创建新进程。
    我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。

图 2. 多线程的服务器模型

图3 多线程的服务器模型

    在上述的线程 / 时间图例中,主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。
    很多初学者可能不明白为何一个socket可以accept多次。实际上socket的设计者可能特意为多客户机的情况留下了伏笔,让accept()能够返回一个新的socket。下面是 accept 接口的原型:
     int accept(int s, struct sockaddr *addr, socklen_t *addrlen); 
    输入参数s是从socket(),bind()和listen()中沿用下来的socket句柄值。执行完bind()和listen()后,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请求,则将该连接请求加入请求队列。调用accept()接口正是从 socket s 的请求队列抽取第一个连接信息,创建一个与s同类的新的socket返回句柄。新的socket句柄即是后续read()和recv()的输入参数。如果请求队列当前没有请求,则accept() 将进入阻塞状态直到有请求进入队列。
    上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。
    很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
    对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

 

2 non-blocking IO

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,用户进程其实是需要不断的主动询问kernel数据好了没有。就是轮训(Polling),这对cpu是很大的浪费。

所以,non-blocking IO的特点就是在等待数据部阻塞,拷贝阻塞

 

以上面临的很多问题,一定程度是 IO 接口的阻塞特性导致的。多线程是一个解决方案,还一个方案就是使用非阻塞的接口。

非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下的函数可以将某句柄 fd 设为非阻塞状态。

fcntl( fd, F_SETFL, O_NONBLOCK );

下面将给出只用一个线程,但能够同时从多个连接中检测数据是否送达,并且接受数据。

图 3. 使用非阻塞的接收数据模型

图 3. 使用非阻塞的接收数据模型

在非阻塞状态下,recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在本例中,

  • recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;
  • recv() 返回 0,表示连接已经正常断开;
  • recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成;
  • recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno。

可以看到服务器线程可以通过循环调用 recv() 接口,可以在单个线程内实现对所有连接的数据接收工作。

但是上述模型绝不被推荐。因为,循环调用 recv() 将大幅度推高 CPU 占用率;此外,在这个方案中,recv() 更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如 select()。

3 IO multiplexing

它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

 所以,IO multiplexing Model的特点就是两个阶段都阻塞,但是等待数据阻塞在select上,拷贝数据阻塞在recfrom上

 

 在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。因此select()与非阻塞IO类似。

    大部分Unix/Linux都支持select函数,该函数用于探测多个文件句柄的状态变化。下面给出select接口的原型:
    FD_ZERO(int fd, fd_set* fds) 
    FD_SET(int fd, fd_set* fds) 
    FD_ISSET(int fd, fd_set* fds) 
    FD_CLR(int fd, fd_set* fds) 
    int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, 
    struct timeval *timeout) 
    这里,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为16的句柄,则该fd_set的第16个bit位被标记为1。具体的置位、验证可使用 FD_SET、FD_ISSET等宏实现。在select()函数中,readfds、writefds和exceptfds同时作为输入参数和输出参数。如果输入的readfds标记了16号句柄,则select()将检测16号句柄是否可读。在select()返回后,可以通过检查readfds有否标记16号句柄,来判断该“可读”事件是否发生。另外,用户可以设置timeout时间。
    下面将重新模拟上例中从多个客户端接收数据的模型。

图 4. 使用 select() 的接收数据模型

图7 使用select()的接收数据模型

    述模型只是描述了使用select()接口同时从多个客户端接收数据的过程;由于select()接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。如下图。

图 5. 使用 select() 接口的基于事件驱动的服务器模型

图8 使用select()接口的基于事件驱动的服务器模型

    这里需要指出的是,客户端的一个 connect() 操作,将在服务器端激发一个“可读事件”,所以 select() 也能探测来自客户端的 connect() 行为
    上述模型中,最关键的地方是如何动态维护select()的三个参数readfds、writefds和exceptfds。作为输入参数,readfds应该标记所有的需要探测的“可读事件”的句柄,其中永远包括那个探测 connect() 的那个“母”句柄;同时,writefds 和 exceptfds 应该标记所有需要探测的“可写事件”和“错误事件”的句柄 ( 使用 FD_SET() 标记 )。
    作为输出参数,readfds、writefds和exceptfds中的保存了 select() 捕捉到的所有事件的句柄值。程序员需要检查的所有的标记位 ( 使用FD_ISSET()检查 ),以确定到底哪些句柄发生了事件。
    上述模型主要模拟的是“一问一答”的服务流程,所以如果select()发现某句柄捕捉到了“可读事件”,服务器程序应及时做recv()操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入writefds,准备下一次的“可写事件”的select()探测。同样,如果select()发现某句柄捕捉到“可写事件”,则程序应及时做send()操作,并准备好下一次的“可读事件”探测准备。下图描述的是上述模型中的一个执行周期。

图 6. 一个执行周期

图9 多路复用模型的一个执行周期

    这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。
    相比其他模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。
    但这个模型依旧有着很多问题。首先select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐。遗憾的是不同的操作系统特供的epoll接口有很大差异,所以使用类似于epoll的接口实现具有较好跨平台能力的服务器会比较困难。
    其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。如下例,庞大的执行体1的将直接导致响应事件2的执行体迟迟得不到执行,并在很大程度上降低了事件探测的及时性。

图 7. 庞大的执行体对使用 select() 的事件驱动模型的影响

图10 庞大的执行体对使用select()的事件驱动模型的影响

    幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent库,还有作为libevent替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。下章将介绍如何使用libev库替换select或epoll接口,实现高效稳定的服务器模型。

    实际上,Linux内核从2.6开始,也引入了支持异步响应的IO操作,如aio_read, aio_write,这就是异步IO。

 

4 signal driven I/O (SIGIO) 

We can also use signals, telling the kernel to notify us with the SIGIO signal when the
descriptor is ready. We call this signal-driven I/O and show a summary of it in Figure 6.4.

We first enable the socket for signal-driven I/O (as we will describe in Section 25.2) and
install a signal handler using the sigaction system call. The return from this system call is
immediate and our process continues; it is not blocked. When the datagram is ready to be
read, the SIGIO signal is generated for our process. We can either read the datagram from
the signal handler by calling recvfrom and then notify the main loop that the data is ready
to be processed (this is what we will do in Section 25.3), or we can notify the main loop
and let it read the datagram.
Regardless of how we handle the signal, the advantage to this model is that we are not

blocked while waiting for the datagram to arrive. The main loop can continue executing and
just wait to be notified by the signal handler that either the data is ready to process or the
datagram is ready to be read.

所以,signal driven I/O 的特点就是第一个过程没有阻塞,数据准备好的时候会通过SIGIO通知进程,拷贝数据阻塞在recfrom上,优点是进程可以继续执行。

注: signal driven I/O 和 IO multiplexing Model 很像,只是一个阻塞,被动等待,一个会得到通知。

5 Asynchronous I/O

linux下的asynchronous IO其实用得很少。先看一下它的流程:

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

 

blocking和non-blocking的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。但两者在从kernel拷贝数据到应用程序的时候都是阻塞的。、

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。Stevens给出的定义(其实是POSIX的定义)是这样子的:
    A synchronous I/O operation causes the requesting process to be blocked until that I/O operationcompletes;
    An asynchronous I/O operation does not cause the requesting process to be blocked;
 
两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞,IO operation 包括两个过程:等待数据+数据拷贝。blocking和noblocking的数据拷贝都要阻塞,按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:

经过上面的介绍,会发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

参考文献:
IO - 同步,异步,阻塞,非阻塞:http://blog.csdn.net/historyasamirror/article/details/5778378

使用事件驱动模型实现高效稳定的网络服务器程序:http://www.ibm.com/developerworks/cn/linux/l-cn-edntwk/

 

 

select函数

1)函数作用:

允许进程指示内核等待多个事件中的一个发生, 并只在有一个或多个事件发生,或通过定时唤醒它。(前面说的同时处理socket描述符和等待用户输入就符合这个情况)(Berkeley的实现允许任何描述符的I/O复用)

2)函数定义

#include <sys/time.h>#include <sys/select.h>int select (int maxfdp1, fd_set *readset, fd_set *writeset, fd_set exceptset, const struct timeval *timeout);struct timeval {    long tv_sec;    long tv_usec;}

 

参数介绍:

timeout:告知内核等待所指定的描述符中任何一个就绪的时间。其有三种可能:

    • 永远等下去,此时值设置为NULL;
    • 等一段固定的时间。即等待时间不超过所指定的timeout时间值;
    • 根本不等待。此时称为轮询,timeout结构总的秒、微妙都设置为0;

exceptset:目前支持的异常条件只有两个

    • 某个套接字的带外数据到达(24章讨论);
    • 某个已置为分组模式的伪终端,存在可以从其主终端读取的控制状态信息(本书不涉及)

readset,writeset:我们要让内核读和写的描述符;

maxfdp1: 指定待测描述符的个数,具体值从0开始到maxfdp1-1(FD_SETSIZE常值为fd_set描述符的总数)。

返回值:若有就绪描述符则为其个数,超时返回0,出错为-1.

select可以作为定时器,此时中间三个描述符集设置为NULL,这个定时器比sleep还有精确,sleep以秒为单位,其以微妙为单位。

3)fd_set类型的相关操作

fd_set rset; //注意新定义变量一定要用FD_ZERO初始化,其自动分配的值不可意料,会导致不可意料的后果。void FD_ZERO(fd_set *fdset); //initialize the set: all bits offvoid FD_SET(int fd, fd_set *fdset); //turn on the bit for fd in fdsetvoid FD_CLR(int fd, fd_set *fdset); //turn off the bits for fd in fdsetint FD_ISSET(int fd, fd_set *fdset); //is the bit for fd on in fdset

 4)套接字准备好的条件

套接字准备好读的条件

a)该套接字接受缓冲区中的数据字节数大于等于套接字接受缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也

就是返回准备好读入的数据)。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低水位标记。对于tcp和udp套接字而言,其默认值为1 

b)该套接字的读半部关闭(也就是接受了FIN的tcp连接)。对这样的套接字的读操作将不阻塞并返回0.(也就是返回EOF)

c)该套接字是一个监听套接字(就是该套接字掉用过listen,在调用listen函数之后,一个套接字会从主动连接的套接字变身为一个监听套接字,默认是主动套接字)且已完成的连接数不为0。对这样的套接字的accept通常不阻塞。就是上面描述的“select() 也能探测来自客户端的 connect() 行为”(后边可会发文介绍阻塞accept的一种时序条件)

d)其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这样待处理错误(pending error)也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。

套接字准备好写的条件

a)该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接(如udp套接

字)。这意味着如果我们把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(例如由传输层接受的字节数)。我们可以使用SO_SNDLOWAT套接字选项来设

置该套接字的低水位标记。对于tcp和udp而言,其默认值通常为2048。

b)该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号。(连接建立,若某一端关闭连接,而另一端仍然向它写数据,第一次写数据后会收到对端的RST响应,此后再写数据,内核将向进程发出SIGPIPE信号,通知进程此连接已经断开。而SIGPIPE信号的默认处理是终止程序,)

c) 使用非阻塞connect的套接字已建立连接,或者connect已经以失败告终。(对于阻塞式套接字,调用connect函数将激发TCP的三次握手过程,而且仅在连接建立成功或者出错时才返回;对于非阻塞式套接字,如果调用connect函数会之间返回-1(表示出错),且错误为EINPROGRESS,表示连接建立,建立启动但是尚未完成;如果返回0,则表示连接已经建立,这通常是在服务器和客户在同一台主机上时发生)

d) 其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置成确切的错误条件。这些待处理的错

误也可以通过指定SO_ERROR套接字选项调用getsockopt获取并清除。

注意:当某个套接字上发生错误时,它将select标记为即可读又可写。

 

Unix 网络编程 I/O 模型 第六章