网络I/O管理---五种I/O模型

佐手、 提交于 2019-12-17 06:12:36

                                                                      网络I/O管理---五种I/O模型

    

    网络I/O会涉及到到三个系统对象:一个是用户空间调用 I/O的进程或者线程;一个是内核空间的内核系统;最后一个是IO device.

       

  

    整个请求过程为:

  1. 用户进程/线程发起请求;
  2. 内核接受到请求后,从I/O设备中获取数据到内核buffer中;
  3. 再将内核buffer中的数据copy_to_user到用户进程的地址空间;

 

在整个请求过程中,数据输入到内核buffer需要时间从内核buffer复制到进程也需要时间。因此根据这两段时间内等待方式的不同,I/O分为以下 5 种模式:

    (1)阻塞IO(blocking IO)

    (2)非阻塞IO(Non-blocking IO)

    (3)IO复用(IO mutiplexing)

    (4)信号驱动的IO(signal driven IO)

    (5)异步IO(asynchrnous IO)

 

一、阻塞IO(blocking IO)

    在linux中,默认情况下所有的socket都是 blocking,一个典型的读操作流程如下:    

    

    例如当用户进程调用了 read 系统调用, kernel 就开始了 IO操作的 准备数据阶段。对于网络 IO来说,很多时候数据在一开始还没到达(比如,还没收到一个完整的数据包),

这时候kernel就需要等待足够的数据到来。用户空间这边,整个用户进程处于阻塞状态。

    当kernel一直等待数据准备好了,kernel就会将数据从kernel缓存缓冲区拷贝到用户空间用户进程/线程缓冲区,然后kernel返回结果,用户进程才解除block状态,重新运行起来。

    

    网络编程接口中listen(), send(), recv()等接口都是阻塞的,例如下边简单的“一问一答”的服务器模型。   

 

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

才返回。实际上,除非特别指定,几乎所有的IO接口,包括socket接口,都是阻塞型的。阻塞IO给网络编程带来了一个很大的问题,例如调用send的同时,线程被阻塞,

在此期间,线程将无法执行任何运算或响应任何网络请求。

 

 一个简单的改进方案是在服务端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或线程),这样任何一个连接的阻塞都不会影响

其他的连接。具体使用多线程还是多进程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户端服务,则不推荐多进程;

如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。

    

    假设对上述C/S模型,提出为多客户服务的要求,于是有了如下改进:    

 

                                                                        多线程的服务器模型

 

 

上述多线程模型,主线程持续等待客户端的连接请求,如果有连接请求,则创建新线程,并在新线程中提供为第一个例子同样的服务。

    多线程的服务器模型似乎完美的解决了为多客户端提供服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会

严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态

        此时可能很多朋友会说考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。

“连接池”维持连接的缓存池,尽量重复使用已有的连接,减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛 应用很多大型系统。

但是线程池和连接池技术也只是一定程度上缓解了频繁调用IO接口带来的资源占用。

        而且,所谓“池”是始终有其上限,当请求大大超过上限时,“池“构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用”池“必须考虑其面临的响应规模,

并根据响应规模调整“池”的大小。

        上例中,如果出现上千甚至上万次的客户端请求,线程池或连接池可以解决部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模服务请求,

但是面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口尝试解决这个问题。

 

 

二、非阻塞IO(non-blocking IO)    

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

    

 

 从上图可以看出,当用户进程调用read操作时,如果kernel中的数据还没准备好,那么它并不会block用户进程,而是立刻返回一个 -1,errno为EAGAIN。

而不需要马上等待,马上就得到一个结果。用户进程判断结果是一个error时,就知道数据还没准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,

并且又再次收到了用户进程的系统调用read,则马上将kernel 缓冲区的数据拷贝到用户进程缓存空间,然后返回,所以在阻塞式IO中,用户进程其实需要不断的主动询问

kernel数据是否准备好了。    

 

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

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

 

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

int flag = 0;
flag = fcntl(fd, F_GETFL); // 获取当期fd的flag
flag |= O_NONBLOCK; // 设置新的flag
fcntl(fd, F_SETFL, flag); // 更新flag

 

继续改进上述C/S模型,采用单个线程,但是能同时从多个连接中检测数据是否送达,并接受数据的模型:

 

可以看到服务器可以通过循环调用recv接口,在单线程中实现对所有连接的数据接收工作。但是该模型绝对不被推荐!!!

因为,循环调用recv将大幅度推高CPU占用率;此外这个方案中,recv更多的是起到检测 “操作是否完成”的作用,实际操作系统中提供了更高效的

检测 “操作是否完成”作用的接口,例如 select多路复用模式,可以一次检测多个连接是否活跃,也就是IO的多路复用,下面就介绍这个利器。

 

 

三、多路复用IO(IO multiplexing)

        IO 多路复用,上述提到的 select就是其中一种方式,还有例如poll/epoll等方式。IO 多路复用方式也称为事件驱动方式。select/epoll的好处就在于,单个进程/线程就可以

同时处理多个网络连接的 IO。它的基本原理就是 select/epoll这个函数会不断的轮询所有负责的sockfd,当某个sockfd有了数据到达,就通知用户进程,它的流程如下:  

      

 

当用户进程调用了 select,那么这个进程都会被block,而同时,kernel 会监听 所有 select 负责的 socket,select遍历管理的socket,用 FD_ISSET来判断

究竟是哪个socket有数据,然后进行IO操作,此时就可以再次调用read操作,将数据拷贝到用户进程。

        这个图和 blocking IO 的图其实没有太大不同,事实上还更差一点,因为这里需要调用两个系统调用 (select和read),而blocking IO只调用了一个系统调用read,

但是使用select后的最大优势是用户可以在一个线程内同时处理多个sockfd的IO 请求。用户可以注册多个socket,然后不断的调用select读取被激活的socket,即可达到

在同一个线程处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。【如果处理的连接不是很高的话,使用select/epoll的服务端不一定比

多线程 + 阻塞IO的服务端性能更好,可能延迟还更大。selec/epoll的优势并不是对于单个连接能处理得更快,而是在于处理更多的连接】

        多路复用模型中,每个socket一般设置为非阻塞的,整个线程被select阻塞,而不是被socket IO给阻塞。

 

        大部分unix/linux都支持select函数,该函数用于探测多个文件描述符状态变化。

 

FD_ZERO(int fd, fd_set *fds);
FS_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, 则该 fd_set的第16个bit就被标记为 1。

具体的置位、验证可使用 FD_SET, FD_ISSET等宏实现。在select函数中,readfds,writefds和exceptfds同时作为输入参数和输出参数。如果输入的

readfds标记了16号fd,则select()将检测 16 号fd是否可读可写。在select返回后,可以通过检测readfds是否有标记 16 号fd,来判断该“可读/可写” 事件

是否发生,用户可以设置timeout时间,当超时时候select就会返回而不是一直阻塞。

        改造上述模型为多路复用方式:        

   

 

上述模型只是描述了使用select接口同时从多个客户端接收数据的过程,由于select接口可以同时对多个fd进行读状态、写状态和错误状态的检测,

所以可以很容易的构建为多个客户端提供独立的服务系统。

 

 

这里需要指出的是,客户端的一个 connect() 操作,将在服务器端激发一个“可读事件”,所以 select() 也能探测来自客户端的 connect() 行为。

该模型关键地方是如何动态维护select的三个参数:readfds、writefds和expectfds。

        这种模型的特征在于每次执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应,该模型也可以归类为 “事件驱动模型”

相比其他模型,使用 select() 多的事件驱动模型只用 单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多个客户端提供服务。

    

        如果是简单一个事件驱动服务器程序,该模型可被参考。但是该模型仍旧有很多问题。

  1. 首先 select() 接口并不是实现 “事件驱动” 的最好选择,因为当需要探测的文件描述符较大时,select() 接口本身需要消耗大量时间去轮询每个fd;
  2. 很多操作系统提供了更高效的接口,例如linux提供了 epoll, BSD 提供了 kqueue,Solaris提供了 /dev/poll 等等

        如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐,只不过不同的操作系统提供的epoll接口有很大差异,所以使用类似epoll的接口

实现具有较好的跨平台能力的服务器就会比较困难。

    3. 另外该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。例如,庞大的执行体1将导致响应事件2的执行体

        迟迟得不到执行,并在很大程度上降低了事件探测的及时性幸运的是,有很多高效的事件驱动库可以屏蔽上述困难,常见的事件驱动库有libevent库

        还有作为libevent替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号等技术以支持异步响应,这使得这些库

        称为构建事件驱动模型的不二选择。后续文章将分享 使用 libev 替换 select() 和 epoll接口,实现高效稳定的服务器模型。

 

 

四、异步IO(Asynchronous IO)

        在linux下 异步IO 常用于磁盘 IO 读写操作,不用于网络 IO,从内核 2.6 版本才开始引入。        

 

 

用户进程发起read操作后,立刻就可以开始去做其他的事情。而另一方面,从kernel的角度,当它接受到一个 异步 read之后,首先它会立刻返回,所以

不会对用户进程产生任何block。然后,kernel会等待数据准备完成后,将数据拷贝到用户空间内存,当这一切都完成后,kernel就会给用户进程发送一个signal

告诉它read操作完成了。异步IO是真正的非阻塞,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。

        

    总结上述 4 种IO的区别:

    1、blocking 和 non-blocking的区别在哪里?

    解:调用blocking IO 会一直block住对应的进程/线程直到操作完成,而 non-block IO在kernel还在准备数据的情况下就会立刻返回。

 

    2、synchronous IO 和 asynchronous IO的区别在哪里?

    解:两者区别就在于 同步IO 做 “IO operation” 的时候回 将 process阻塞,按照这个定义,上述的 blocking IO,non-blocking IO,IO mutiplexing都属于同步IO

            有人可能会说,non-blocking IO 并没有被block啊,这里有个非常 “狡猾”的地方,定义中所指的 “IO operation” 是指 真实的 IO 操作,就是例子中read

        这个系统调用。non-blocking IO 执行过 read 这个系统调用的时候,如果kernel 的数据没有准备好,这时候不会 block进程,但是 当 kernel 中数据准备好的

        时候,read会从 kernel 拷贝到 用户内存中, 这个时候进程 是被 block了,在这段时间进程是被block的。而 异步IO 则不一样,当进程发起IO 操作之后,就直接

        返回再也不理睬了,直到kernel 发送一个信号,告诉进程说 IO 完成。整个过程中,用户进程完全没有被 block。

 

 

五、信号驱动的IO(signal driven IO)

        上述IO多路复用是事件驱动模型的一种,信号驱动IO也属于基于事件驱动IO模型,如下图:        

 

 

可以看到该模型中,只有IO执行的第二阶段阻塞了用户进程,而在第一阶段是没有阻塞的。

        首先允许套接字进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO 信号,可以在信号处理函数中调用IO

操作函数处理数据。当数据准备好读取时,内核就为该进程产生一个 SIGNO 信号。我们随后既可以在信号处理函数中调用 read 读取数据报,并通知主循环数据已经准备好

等待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理 SIGNO 信号,这种模型的优势在于 等待数据报 到达(第一阶段)期间,进程可以继续执行,不被阻塞。

免去了 select 的阻塞与轮询,当 有活跃套接字时,由注册的 handler 处理。

 

生活中通信模型

以上五种 I/0 模型的介绍,比如枯燥,其实在生活中也存在类似的 “通信模型”,为了帮助理解,我们用生活中约妹纸吃饭这个不是很恰当的例子来说明这几个 I/O Model(假设我现在要用微信叫几个妹纸吃饭):

  • 发个微信问第一个妹纸好了没,妹子没回复就一直等,直到回复在发第二个 (blocking I/O)。
  • 发个微信问第一个妹纸好了没,妹子没回复先不管,发给第二个,但是过会要继续问之前 没有回复的妹纸有没有好(nonblocking I/O)。
  • 将所有妹纸拉一个微信群,过会在群里问一次,谁好了回复一下(I/O multiplexing)。
  • 直接告诉妹纸吃饭的时间地址,好了自己去就行(Asynchronous I/O)。
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!