Linux网络(三)—— 几种套接字I/O模型

背景知识

一、阻塞与非阻塞

对于一个套接字的 I/O 通信,它会涉及到两个系统对象,一个是调用这个 I/O 的进程或者线程,另一个就是系统内核。比如一个读操作发生时,它会经历两个阶段:

  1. 等待数据准备
  2. 将数据从内核拷贝到进程中

阻塞:在 Linux 中,默认情况下所有的 socket 都是 blocking,当用户进程调用了 recvfrom/recv 这个系统调用,内核就开始了I/O 的第一个阶段:准备数据。但是很多时候数据在一开始还没有到达(比如,还没有收到一个完整的 UDP/TCP 包),这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后直到返回结果,用户进程才解除阻塞状态,重新运行起来。
所以,阻塞 I/O 的特点就是 I/O 执行的两个阶段都被阻塞了。
调用阻塞 I/O 会一直阻塞对应的进程直到操作完成,而非阻塞 I/O 在内核还在准备数据的情况下会立刻返回。

1. 阻塞

阻塞阻塞调用是指调用结果返回之前,当前进程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。
【举个栗子】
快递的例子:比如到你某个时候到A楼一层(假如是内核缓冲区)取快递,但是你不知道快递什么时候过来,你又不能干别的事,只能死等着。但你可以睡觉(进程处于休眠状态),因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。

有人也许会对阻塞调用和同步调用傻傻分不清,实际上它们是不同的。下面请看图
在这里插入图片描述
可以很容易看出它们的不同吧。对于同步调用来讲,线程还是激活状态,因为它会多次进行询问;而对于阻塞调用,线程处于挂起状态,直到内核返回数据才被重新激活。

2. 非阻塞

非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前进程,而会立刻返回。
【举个栗子】
还是等快递的例子:如果用忙轮询的方法,每隔5分钟到A楼一层(内核缓冲区)去看快递来了没有。如果没来,立即返回。而快递来了,就放在A楼一层,等你去取。

对象的阻塞模式和阻塞函数调用
对象是否处于阻塞模式和函数与是不是阻塞调用有很强的相关性,但是并不是一一对应的。阻塞对象上可以有非阻塞的调用方式,我们可以通过一定的 API 去轮询状态,在适当的时候调用阻塞函数,就可以避免阻塞。而对于非阻塞对象,调用特殊的函数也可以进入阻塞调用。我们经常使用的函数 select 就是这样一个例子。


二、同步与异步

首先给出 POSIX 的定义

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
An asynchronous I/O operation does not cause the requesting process to be blocked;

1. 同步

所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。也就是必须一件一件事做,等前一件做完了才能做下一件事,而只有当所有的工作按照顺序执行完之后,才会返回调用的位置,继续向下执行。
这个很好理解,我们一般做的函数调用,都是同步的,我们的程序按照我们既定的顺序一步一步执行,在前一个操作返回后,我们根据操作的结果,进行下一个阶段的处理,这就是一个同步的过程。

2. 异步

异步,异步的概念和同步相对。当一个异步调用发出后,如果调用者不能立刻得到结果,那么它不会等待,会去处理其他请求,当实际处理这个调用的部件完成后,通过状态、通知和回调来通知调用者

简单来说

  • 一个同步接收方式,在这个端口下如果是来了两个客户请求,第一个连接得到了响应,与服务器建立通信,而第二个请求就会一直阻塞直到第一个请求完成操作,各个请求之间就像排个队,顺序执行,这就是同步。
  • 而一个异步接收方式,就是同时来两个或多个请求,服务端就同时响应多个客户端,同时给它们连接。各个客户端与服务器的通信是并行的,一个客户端不必等待另一个客户端完成操作。

三. 四种 I/O 的比较

同步和异步的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起 I/O 请求后需要等待或者轮询内核 IO 操作完成后才能继续执行;而异步是指用户线程发起 IO 请求后仍继续执行(直接返回),当内核 IO 操作完成后会通知用户进程,或者调用用户线程注册的回调函数。
阻塞和非阻塞的概念描述的是用户线程调用内核 IO 操作的方式:阻塞是指 IO 操作需要彻底完成后才会返回到用户空间;而非阻塞是指 IO 操作被调用后立即返回给用户一个状态值,无需等到 IO 操作彻底完成

一个 IO 操作可以分为两步:发起 IO 请求和实际的 IO 操作
阻塞 IO 和非阻塞 IO 的区别在于第一步,发起 IO 请求是否会被阻塞,如果阻塞直到完成那么就是阻塞 IO。如果不阻塞,那么就是非阻塞 IO。
同步 IO 和异步 IO 的区别就在于第二步是否阻塞,如果实际的 IO 读写阻塞请求进程(IO 操作完成后才能返回),那么就是同步 IO,因此阻塞 IO、非阻塞 IO、IO 复用、信号驱动 IO都是同步 IO。如果不阻塞,而是操作系统做完 IO 两个阶段的操作再将结果返回给用户线程,那么就是异步 IO。


四、并发与迭代

套接字编程经常使用 客户/服务器 编程模型(简称 C/S 模型)中,C/S 模型根据复杂度分为简单的客户/服务器模型复杂的客户/服务器 模型。

C/S简单客户/服务器模型是一对一关系,一个服务器某一时段内只处理一个客户端请求,迭代服务器模型属于此模型
C/S复杂服务器模型是一对多关系,一个服务器某一时段内可以处理多个客户端的请求,并发服务器模型属于此模型

迭代服务器模型并发服务器模型都是 socket 编程中最常见使用的两种编程模型。

通常来说,大多数TCP服务器都是并发的,大多数UDP服务器是迭代的

比如我们在做聊天室的时候,使用 UDP 协议来发送消息,但是在用户需要上传下载数据的时候,我们通常在服务器和客户端之间建立一个 TCP 连接来传送文件
但是如果服务一个客户请求的时间不长,使用迭代服务器没有太大问题,一旦客户请求的时间需要花费很长,不希望整个服务器被单个客户长期占用,而希望同时服务多个客户,就需要选择并发服务器

在这里插入图片描述

1. 迭代服务器逻辑

1. 创建监听套接字 listenfd = socket(……);
2. 命名套接字 bind(listenfd, ……);
3. 开始监听客户端的连接 listen(listenfd, LISTEN_QUEUE);
4. 循环处理每个客户端的请求
	while(1)
	{
		connectfd = accept(listenfd, ……); //扶额u其接受客户端的连接
		DealLogic(connfd); //逻辑处理:在这个 connectfd 上与客户端进行通信 send/recv
		close(connectfd); //关闭连接
	}

这个进程是一个一个处理客户端发来的连接的,比如一个客户端发来一个连接,那么只要它还没完成自己的任务,那么它就一直占用服务器的进程直到处理完毕后服务器关闭掉这个 socket。
单进程模式下,如果没有客户端到来,进程一直阻塞在 accept 调用上,阻塞在 accept() 不可怕,如果阻塞在 read() 系统调用,将导致整个服务器不能对其他的客户端提供服务。
【举个栗子】
假如服务器 accept() 得到一个客户端的连接,此时的 fd 唯一表示该连接。现在服务器进入 read() 系统调用,但是此时客户端并没有发送数据,那么服务端就会一直被阻塞在 read 系统调用中。此时来了一个新的连接,但是服务器不能响应,即 accept() 函数不能被服务器调用。那么这个连接是失败的。可想而知,迭代服务器模型是多么的低效
这个问题在 UDP 程序中也很明显,但是 UDP 恰恰常用这种方式,因为它是非连接的,整个过程就调用 sendto/recvfrom 两个函数。如果在数据传输过程中数据丢失,那么客户端会阻塞在 recvfrom 上。
因此最好设置客户端的 recvfrom 超时

2. 并发服务器逻辑

1. listenfd = socket(……);  //创建监听套接字
2. bind(listenfd, ……);		//命名套接字
3. listen(listenfd, LISTEN_QUEUE); //监听端口
4. while(1)  //为每个客户端请求创建线程或者进程处理
5. {
		connfd = accept(listenfd, ……); //获取客户端的连接请求
		if((pid = fork()) == 0)  //在子进程中处理客户端请求
		{
			close(listenfd); //子进程不需要监听,只需要负责处理逻辑
			Deallogic(connfd); //处理客户端请求
			close(connfd); //关闭客户端的连接套接字
		}
		close(connfd); //父进程先关闭客户端的连接套接字,继续监听
	}

这样没来一个客户端,服务器就 fork 一个进程去处理请求,这样主进程就一直处于监听状态而不会被阻塞

[注意点]
千万不要以为 fork 一个子进程就产生了2个新的 socket 描述符,实际上子进程和父进程是共享 listenfd 和 connfd 的,每个文件和套接字都有一个引用计数,引用计数在文件表项中维护,它是当前打开着的引用该文件或套接字的描述符的个数。socket 返回后与 listenfd 关联的文件表项的引用计数为1.accept返回后与 connfd 关联的文件表项的引用计数也为1。然后 fork 返回后,这两个描述符就在父子进程间共享(也就是被复制),因此这两个套接字相关联的文件表项各自的访问计数器均为2.这么一来,当父进程关闭 connfd 时,它只是把相应的引用计数值从2减到1.该套接字真正的清理和资源释放要等到其引用计数值达0时才发生。也就是在子进程也关闭 connfd 时发生。
因此当父进程关闭 connfd 时,它只是把这个 connfd 的访问计数值减1而已。由于访问计数值 > 0,所以它并没有断开与客户端的连接。

那么如果父进程不关闭 connfd 有什么后果?

  1. 因为可分配的 socket 描述符是有限的,如果分配了后不释放,自然内核也不会对它们回收再利用,那么有限个描述符总会有耗尽的一天
  2. 服务器再获取客户端的连接和,将与客户端通信的任务交给子进程,而父进程希望你能继续监听并 accept 下一个连接。但是如果每获取一个连接,父进程不关闭自己跟客户端的连接,那么这个连接就会永远存在!即服务器获取到的所有客户端连接都不会断开,始终存在于服务器的生命周期内,那后果可想而知。。。

五、常见的 I/O 模型

常见的网络 I/O 模型如下几类:

  • 阻塞式 IO
  • 非阻塞式 IO
  • IO 复用
  • 信号驱动 IO
  • 异步 IO

除了这几个比较经典之外,还有其他的比如:多进程或者多线程并发 I/O,基于事件驱动的服务器模型和多线程的服务器模型(Multi-Thread),以及 windows 下的 IOCP 模型和 Linux 下的 epoll 模型


1. 阻塞 I/O(blocking I/O)

进程会一直阻塞,直到数据拷贝完成,阻塞 IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据)都被 block 了。

应用进程调用一个 IO 函数,导致应用阻塞,等待数据准备好。如果数据没有准备好,一直等待……数据准备好了,从内核拷贝到用户空间,IO 函数返回成功指示。

几乎所有的程序员第一次接触网络编程都是从 listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。使用这些接口可以很方便的构建 服务器/客户机 模型。下面是一个简单地 “一问一答” 的服务器。
在这里插入图片描述

上图描述的是套接字在调用 recv()/recvfrom() 函数时,发生在内核中等待数据和拷贝数据的过程。调用 recv() 函数时,系统首先检查数据是否准备好,如果数据没有准备好,那么系统均处于等待状态。当数据准备好后,将数据从内核拷贝到用户空间,然后该函数返回。在调用 recv() 函数时,内核未必会存在数据,那么此时就会阻塞在 recv() 调用上。

当使用 socket() 函数创建套接字时,默认的套接字都是阻塞的。这意味着当调用 Socket API 不能立即完成时,进程/线程处于等待状态,直到操作完成。
但是并不是所有 Socket API 以阻塞套接字为参数调用都会发生阻塞。例如,以阻塞模式的套接字为参数调用 bind()、listen() 函数时,函数会立即返回。

将可能阻塞套接字的 Socket API 调用分为以下四种:

  1. 输入操作:recv()、recvfrom() 函数。以阻塞套接字为参数调用该函数接收数据。如果此时套接字缓冲区内没有数据可读,则调用线程在数据到来前一直睡眠。
  2. 输出操作:send()、sendto() 函数。以阻塞套接字为参数调用该函数发送数据。如果套接字缓冲区没有可用空间,线程会一直睡眠,直到有空间。
  3. 接受连接:accept()。以阻塞套接字为参数调用该函数,等待接受对方的连接请求。如果此时没有连接请求,线程就会进入睡眠状态。
  4. 发出连接:connect() 函数。对于 TCP 连接,客户端以阻塞套接字为参数,调用该函数向服务器发起连接。该函数在收到服务器的应答前,不会返回。这意味着 TCP 连接总会等待至少到服务器的一次往返时间。

使用阻塞模式的套接字,开发网络程序比较简单,容易实现。当希望能够立即发送和接收数据,且处理的套接字数量比较少的情况下,使用阻塞模式来开发网络程序比较合适。

阻塞模式的不足表现为,在大量建立好的套接字线程之间进行通信比较困难。当使用 “生产者-消费者” 模型开发网络程序时,为每个套接字都分配一个读线程、一个处理数据线程和一个用于同步的事件,那么这样无疑加大了系统的开销。其最大的缺点是当希望同时处理大量套接字时,将无从下手,其扩展性很差。

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

实际上,除非特别指定,几乎所有的 IO 接口(包括 socket 接口)都是阻塞型的。这给网络编程带来一个很大问题,如在调用 send() 的同时,线程被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求,即我们当前描述的阻塞 IO 服务器模型其实是一个(同步+阻塞+迭代 类型的服务器模型)。这种服务器是最低效的模型,给多客户机、多业务逻辑的网络编程带来了挑战。

这时,我们可能会选择多线程的方式(即同步+阻塞+并发)来解决这个问题


多线程/进程服务器(同步 + 阻塞 + 并发)

一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接拥有独立的线程(或进程),这样任何一个连接的阻塞都不影响到其它的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或很长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create() 创建新线程,fork() 创建新进程。
这种方式本质上仍是阻塞 I/O,但是使用了多进程或多线程来实现并发操作

[服务器工作流程]
多线程/进程服务器同时为多个客户机提供应答服务,主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。
服务器套接字为每次 accept() 能够返回一个新的 socket。当服务器执行完 bind() 和 listen() 后,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请求,则将该连接请求加入请求队列。调用 accept() 接口正是从 socket s 的请求队列抽取第一个连接信息,创建一个与 s 同类的新的 socket 返回句柄。新的 socket 句柄即是后面 send/recv 的参数。如果请求队列当前没有请求,则 accept() 将进入阻塞状态直到有请求进入队列。

[缺点]
上述多线程服务器模型似乎完美地解决了为多个客户机提供问答的要求,但其实不然。如果同时要响应成百上千路的连接请求,则无论是多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。

[解决方案]
很多程序员可能会考虑使用“线程池”和“连接池”。“线程池” 旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池” 维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和 各种数据库等。但是,“线程池” 和 “连接池” 技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓 “池” 始终有其上限,当请求大大超过上限时,“池” 构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用 “池” 必须考虑其面临的相应规模,并根据相应规模调整 “池” 的大小。
但是,“线程池” 和 “连接池” 技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。

对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。


2. 非阻塞 I/O(nonblocking I/O)

在这种模型下,当用户进程发出 read 操作时,如果内核中的数据还没有准备好,那么它不会阻塞用户进程,而是立刻返回一个 error。从用户进程角度将,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作,一旦内核中的数据准备好了,并且又再次收到了用户进程的系统调用,那么它马上就将数据从内核拷贝到用户内存中,然后返回。

所以,在非阻塞 IO 中,用户进程其实是需要不断主动询问内核数据准备好了没有。

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

fcntl(fd, F_SETFL, O_NONBLOCK);

从应用程序的角度来说,blocking read 调用会延续很长时间。在内核执行读操作和其他工作时,应用程序会被阻塞。
非阻塞的 IO 可能并不会立即满足,需要应用程序调用许多次来等待操作完成。这可能效率不高,因为在很多情况下,当内核执行这个命令时,应用程序必须进行忙碌等待,直到数据可用为止。另一个问题是,在循环调用非阻塞 IO 的时候,将大幅度占用 CPU,所以一般使用 select 等来检测是否可以操作。

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

flag = fcntl(sockfd, F_GETFL, 0); //获取文件状态
fcntl(sockfd, F_SETFL, flag|O_NONBLOCK); //设置文件状态为非阻塞

非阻塞式 I/O 模型对4种 I/O 操作返回的错误

  1. 读操作:接收缓冲区无数据时返回 EWOULDBLOCK
  2. 写操作:发送缓冲区无空间时返回 EWOULDBLOCK;空间不够时部份拷贝,返回实际拷贝字节数
  3. 建立连接:启动3次握手,连接失败立刻返回错误 EINPROGRESS;若服务器客户端在同一主机上 connnet 立即返回成功
  4. 接受连接:没有新连接返回 EWOULDBLOCK

在这里插入图片描述

可以看到服务器线程可以通过循环调用 recv() 接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 recv() 将大幅度占用 CPU;此外,在这个方案中 recv() 更多的是起检测 “操作是否完成” 的作用,实际操作系统提供了更为高效的检测 “操作是否完成” 作用的接口,例如 select() 多路复用模式,可以一次检测多个连接是否活跃。


3. I/O 复用(select 和 poll)(I/O multiplexing)

IO multiplexing 这个词可能有点陌生,但是如果我说 select/epoll ,大概就都能明白了。有些地方也称这种 IO 方式为事件驱动 IO(event driven IO)。我们都知道,select/epoll 的好处就在于单个进程可以同时处理多个网络连接的 IO。它的基本原理就是 select/epoll 这个 function 会不断的轮询所负责的所有 socket,当某个socket有数据到达了,就通知用户进程。它的流程如下图:

在这里插入图片描述

当用户进程调用了 select,那么整个进程会被 block,而同时,内核会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核拷贝到用户空间。
这个图和阻塞 I/O 的图其实并没多大区别,事实上更差一些。因为这里需要使用两个系统调用(select / recvfrom),而阻塞 I/O 只用到了一个系统调用(recvfrom)。但是,用 select 的优势在于它可以同时处理多个连接。所以,当连接数不是很多的话,使用 select/epoll 不一定比 多线程/多进程服务器性能更好,可能延迟还更大。所以,select/epoll 的优势并不是对于单个连接处理得更快,而是在于能处理更多的连接

在多路复用模型中,对于每一个 socket,一般都设置为 non-blocking,但是,如上图所示,整个用户的进程其实是一直被阻塞的。只不过进程是被 select 函数阻塞,而不是被 socket IO 阻塞。

大部分的 Unix/Linux 都支持 select 函数,该函数用于探测多个文件句柄的状态变化。
下面给出 select 接口的原型:

void FD_ZERO(int fd, fd_set* fds); //清空集合
void FD_SET(int fd, fd_set* fds);  //将一个给定的文件描述符加入集合之中
int FD_ISSET(int fd, fd_set* fds); //检查集合中指定的文件描述符是否可读写
void FD_CLR(int fd, fd_set* fds); //将一个给定的文件描述符从集合中删除

int select(int maxfdp1, fd_set *readst, fd_set *writeset, fd_set *exceptset, struct timeval *timeout);

select 函数参数介绍

  1. maxfdp1:指定待测试的描述字个数,它的值是待测试的最大描述字加1(因此把该参数命名为 maxfdp1),描述字0,1,2,……maxfdp1均将被测试,因为文件描述符是从0开始的,所以要 -1。
  2. 中间三个参数 readset、writeset 和 expectset 指定我们要让内核测试读、写和异常条件的描述字。如果对某一个条件不感兴趣,可以把它设为空指针。struct fd_set 可以理解为一个集合,这个集合中存放的是文件描述符,可通过上述代码中以 FD 开头的四个宏进行设置。
  3. timeout 告知内核等待所指定描述字的任何一个就绪可花多长时间。其 timeval 结构用于指定这段时间的秒数和微秒数。
    该参数有三种可能:
    (1) timeout = NULL 等待无限长的时间。等待可以被一个信号中断。当有一个描述符做好准备或者是捕获到一个信号时函数会返回。如果捕获到一个信号,select 函数将返回 -1,并将变量 errno 设为 EINTR。
    (2) timeout->tv_sec != 0 || timeout->tv_usec != 0 等待一段固定时间:当有描述符符合条件或者超过超时时间的话,函数返回。在超时时间即将用完但又没有描述符合条件的话,返回 0。对于第一种情况,等待也会被信号所中断。
    (3) timeout->tv_sec = 0 && timeout->tv_usec = 0 不等待,直接返回:加入描述符集的描述符都会被测试,并且返回满足要求的描述符个数。这种方法通过轮询,无阻塞地获得了多个文件描述符状态。

使用 select 函数的流程图
在这里插入图片描述

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

  • 这里需要指出的时,客户端的一个 connect() 操作,将在服务器端激发一个 “可读事件”,所以 select() 也能探测来自客户端的 connect() 行为。
  • 上述模型中,最关键的地方是如何动态维护 select() 的三个参数 readsets、writesets 和 expectsets。
  • 作为输入参数,readsets 应该标记所有需要探测的 “可读事件” 的句柄,其中永远包括哪个探测 connect() 的那个 “母”句柄;同时,writesets 和 expectsets 应该标记所有需要探测的 “可写事件” 和 “错误事件” 的句柄(使用 FD_SET() 标记)
    作为输出参数,readsets、writesets 和 expectsets 中保存了 select() 捕捉道德所有事件的句柄值。我们需要检查的所有标记位(使用 FD_ISSET() 检查),以确定到底哪些句柄发生了事件。

上述模型主要模拟的是 “一问一答” 的服务流程,所以如果 select() 发现某句柄捕捉到了 “可读事件” ,服务器程序应及时做 recv() 操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入 writesets,准备下一次的 “可写事件” 的 select() 探测。同样,如果 select() 发现某句柄捕捉到 “可写事件”,则程序应及时做 send() 操作,并准备好下一次的 “可读事件” 探测准备。
这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类于 “事件驱动模型”。

[模型的优点和缺点]

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

幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动程序有 libevent 库,还有作为 libenvent 替代的 libev 库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal)等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。


4. 事件驱动(event driven I/O)

libevent 是一种高性能事件循环/事件驱动库
为了实际处理每个请求,libevent 库提供了一种事件机制,它作为底层网络后端的包装器。事件系统让为连接添加处理函数变得非常简单,同时降低了底层 IO 复杂性。这是 libevent 系统的核心。

创建 libevnet 服务器的基本方法
注册当发生某一操作(比如接受来自客户端的连接)时应该执行的函数,然后调用主事件循环 event_dispatch()。执行过程的控制现在由 libevent 系统处理。注册事件和将调用的函数之后,事件系统开始自治;在应用程序运行时,可以在事件队列中添加(注册)或删除(取消注册)事件。事件注册非常方便,可以通过它添加新事件以处理新打开的连接,从而构建灵活的网络处理系统。

具体的事件驱动 I/O,可以参考使用事件驱动模型实现高效稳定的网络服务器程序


5. 信号驱动 I/O(signal driven I/O(SIGIO))

使用信号驱动 I/O时,当网络套接字可读后,内核通过发送 SIGIO 信号通知应用程序,于是应用程序可以开始读取数据。优势也称此种方式为异步 IO。但是严格讲,这种方式不能算是真正的异步 I/O,因为实际读取数据到应用进程缓存的工作仍然是由应用自己负责的。

首先我们允许套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续允许不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。
在这里插入图片描述

首先允许套接字使用信号驱动 I/O 模式,并且通过 sigaction 系统调用注册一个 SIGIO 信号处理程序。当有数据到达后,系统向应用进程交付一个 SIGIO 信号,然后既可以如图所示那样在信号处理程序中读取套接字数据,然后通知主循环处理逻辑。也可以直接通知主循环逻辑,让主程序进行数据读取操作。
无论采用以上哪种方式读取数据,应用进程都不会因为尚无数据到达而被阻塞,应用主循环逻辑可以继续执行其他功能,直到收到通知后去读取数据或者处理已经在信号处理函数中读取完毕的数据。

为了让套接字描述符可以工作于信号驱动 I/O 模式,应用进程必须完成如下三步设置

  1. 注册 SIGIO 信号处理程序。(安装信号处理器)
  2. 使用 fcntl 的 F_SETOWN 命令,设置套接字所有者(设置套接字所有者)
  3. 使用 fcntl 的 F_SETFL 命令,置 O_ASYNC 标志,允许套接字信号驱动 I/O。(允许套接字进行信号输入输出)

[注意]
必须保证在设置套接字所有者之前,详细谈注册信号处理程序,否则就有可能在 fcntl 调用后,信号处理程序内核向应用程序发送 SIGIO 信号,导致应用丢失此信号。

下面的程序段描述了怎么为套接字注册信号函数

sigaction 函数:
	int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

具体的怎样使用sigaction,请参考Linux通信之信号(二)

该函数会按照参数 signum 指定的信号编号来设置该信号的处理函数,signum 可指定除 SIGKILL 和 SIGSTIOP 以外的所有信号,如果参数 act 不是 NULL(NULL 表示使用信号默认处理方式),则用来设置新的信号处理方式。

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


6. 异步 I/O (asynchronous I/O (the POSIX aio_functions))

Linux 下的异步 IO 其实用得不多,从内核 2.6 版本开始引入。先看一下它的流程:
【图片】

用户发起 read 操作后,立刻就可以开始去做其他的事。而另一方面,从内核的角度,当它收到一个异步的 read 之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。然后,内核会等数据准备好,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个信号,告诉它 read 操作已经完成。

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

下面介绍一下 IOCP(I/O Completion Port)

IOCP(I/O Complexing Port),常称为 I/O 完成端口。IOCP 模型属于一种通讯模型,适用于(能控制并发执行的)高负载服务器的一个技术。通俗一点说,就是用于高效处理大量的客户端进行数据交换的一个模型。或者可以说,就是能异步 I/O 操作的模型。

Windows 下高并发的高性能服务器一般会采用完成端口 IOCP 技术,Linux 下则会采用 Epoll 实现一个高性能的 I/O。


7. 总结

到目前为止,四种 I/O 模型都介绍完了。现在回过头来回答最初那几个问题:blocking IO和 non-blocking IO 的区别,synchronous IO 和 asychronous IO的区别。

  • 【blocking IO和 non-blocking IO 的区别】
    前面的介绍很明确的说了,调用 blocking IO 会一直 block 住对应进程直到进程操作完成。而 non-blocking IO 在内核还在准备数据的情况下立即返回。但是这里有一点需要注意,non-blocking 在将数据从内核拷贝到用户进程的过程(recvfrom)也是被阻塞住的。
  • 【synchronous IO 和 asychronous IO 的区别】
    之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个系统调用。non-blocking IO在执行recvfrom这个系统调用的时候,如果kernel的数据没有准备好,这时候不会block进程。但是当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内进程是被block的。而asynchronous IO则不一样,当进程发起IO操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

还有一种不常用的 signal driven IO,即信号驱动 IO。总的来说,UNP 中总结的 IO 模型有5种之多:阻塞 IO,非阻塞 IO,IO 复用,信号驱动 IO,异步 IO。前四种都属于同步 IO。阻塞 IO不用说了。非阻塞 IO,IO 请求时加上 O_NONBLOCK 一类的标志位,立刻返回,IO 没有就绪就会返回错误,需要请求进程不断发 IO 请求直到返回正确。IO复用同非阻塞 IO 本质一样,不过利用了新的 select 系统调用,由内核来负责本来是请求进程该做的轮询操作。看似比非阻塞 IO 还多了一个系统调用开销,不过因为可以支持多路 IO,才算提高了效率。信号驱动 IO,调用 sigaction 系统调用,当内核种 IO 数据就绪时以 SIGIO 信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。异步 IO,不会因 IO 操作阻塞,IO 操作全部完成才通知请求进程。


六、小结

常见的网络 IO 模型有如下几类

  1. blocking IO(简单迭代型 / 多进程(线程)并发型)
  2. non-blocking IO
  3. IO 复用(select / poll)
  4. 信号驱动 IO(signal)
  5. 异步 IO(asyc)

前四种都是同步 IO,只有最后一种是异步 IO。
在这里插入图片描述

同步 IO 引起进程阻塞,直到 IO 操作完成;异步 IO 不会引起进程阻塞;IO 复用 是先通过 select 调用阻塞。

除了这几个经典模型之外,还有其他

  1. 多进程/线程并发 IO
  2. 基于事件驱动的服务器模型和多线程的服务器模型(Multi-Thread)
  3. windows 下的IOCP模型和 Linux 下 的 epoll 模型
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值