LV06-03-网络编程-06-网络IO模型

本文主要是网络编程——网络IO模型的相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。

点击查看使用工具及版本
Windows windows11
Ubuntu Ubuntu16.04的64位版本
VMware® Workstation 16 Pro 16.2.3 build-19376536
SecureCRT Version 8.7.2 (x64 build 2214) - 正式版-2020年5月14日
开发板 正点原子 i.MX6ULL Linux阿尔法开发板
uboot NXP官方提供的uboot,NXP提供的版本为uboot-imx-rel_imx_4.1.15_2.1.0_ga(使用的uboot版本为U-Boot 2016.03)
linux内核 linux-4.15(NXP官方提供)
STM32开发板 正点原子战舰V3(STM32F103ZET6)
点击查看本文参考资料
参考方向 参考原文
------
点击查看相关文件下载
--- ---

一、网络I/O概述

1. 五种I/O模型

我们日常说的的网络通信本质上其实就是网络I/O,通过网络I/O,我们可以和远程设备进行通信(数据交换)。由于网络I/O和正常的磁盘I/O在性能和访问方式上有较大的差异,所以针对磁盘I/O的读写方法也就无法适用于网络I/O上,大部分操作系统针对网络I/O抽象除了一套特殊的接口—— 网络Socket接口 ,用于对网络I/O进行操作。

Linux当中一切皆文件,为了统一概念,socketLinux当中也是通过文件描述符来进行描述的,只不过这个文件描述符描述的不是本地文件,而是远程设备对应的文件。由于网络通信存在不可预知的问题,所以诞生了很多网络I/O模型,这些I/O模型本质上是一种客户端(或者说是服务消费者)对网络I/O请求的处理方式。简单来说,网络I/O本质是 socket 的读写,socketLinux 系统被抽象为流,IO 可以理解为对流的操作。

对于一次 IO 访问 (以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个 read 操作发生时,它会经历两个阶段:

  • 第一阶段:等待数据准备 (Waiting for the data to be ready)。
  • 第二阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。

对于 socket 流而言,

  • 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。
  • 第二步:把数据从内核缓冲区复制到应用进程缓冲区。

Linux/UNIX中主要有以下五种I/O模型:

阻塞I/Obloking IO,我们最常用的一种I/O模型
非阻塞I/Onon-blocking IO,可防止进程阻塞在I/O操作上,需要轮询
多路复用I/Omultiplexing IO,允许同时对多个I/O进行控制
信号驱动I/Osignal-driven IO,一种异步通信模型
异步I/Oasynchronous IO,一种异步通信模型

其实在这五种I/O中,信号驱动I/O用的不是很多,我们见的多数只有四种I/O

2. 同步和异步

  • 同步

所谓同步,就是发出一个功能调用时,在没有得到结果之前,该调用就不返回或继续执行后续操作。 简单来说,同步就是必须一件一件事做,等前一件做完了才能做下一件事。

  • 异步

当一个异步过程调用发出后,调用者在没有得到结果之前,就可以继续执行后续操作。当这个调用完成后,一般通过状态、通知和回调来通知调用者。对于异步调用,调用的返回并不受调用者控制。

二、阻塞I/O

1. 场景描述

就像是这样,比如说我们现在要到医院做一个检查,需要拍一个片子,我们拍完之后,拍片子的医生一般就会告诉我们多久后可以来拿结果,但是实际上有很多人都拍了这个片子,医生给的依然是一个大概的时间,其实我们相当于还是不知道什么时候出结果,于是,我们只好坐在拍片子的地方等,直到结果出来,然后才能去找医生询问片子的结果,但是等待的这段时间我们一直都没有离开,这就是一种阻塞。

2. 基本概念

阻塞式I/O顾名思义就是对文件的I/O操作(读写操作)是阻塞式的,这是最普遍使用的一种I/O模式,大部分程序使用的都是阻塞模式的I/O

在默认的情况下,socket套接字建立后处于的模式其实就是阻塞I/O模式,我们之前网络编程中学习的很多函数在调用的过程中都会发生阻塞:

  • 读操作:read()recv()recvfrom()

  • 写操作:write()send()

  • 其他操作:accept()connect()

3. 网络模型

我们以read()为例,在这个种I/O 模型中,用户空间的进程执行一个系统调用 read(),从套接字上读取数据,当套接字的接收缓冲区中还没有数据可读,函数read()将发生阻塞。然后它会一直阻塞下去,等待套接字的接收缓冲区中有数据可读。经过一段时间后,缓冲区内接收到数据,于是内核便去唤醒该进程,通过read()访问这些数据。如果在进程阻塞过程中,对方发生故障,那么这个进程将永远阻塞下去。

在这种模型中,从等待数据到处理数据的两个阶段,整个进程都被阻塞。不能处理别的网络 IO。调用进程处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。

image-20220630160305540

写操作时发生阻塞的情况要比读操作少,主要发生在要写入的缓冲区的大小小于要写入的数据量的情况下,这时,写操作不进行任何拷贝工作,将发生阻塞。一旦发送缓冲区内有足够的空间,内核将唤醒进程,将数据从用户缓冲区中拷贝到相应的发送数据缓冲区。

【注意】UDP不用等待确认,没有实际的发送缓冲区,所以UDP协议中不存在发送缓冲区满的情况,在UDP套接字上执行的写操作永远都不会阻塞。

4. 优缺点

  • 优点

开发简单,在阻塞等待期间,用户进程挂起,在挂起期间不会占用 CPU 资源,能够提升CPU的处理效率。

  • 缺点

一个进程维护一个 I/O ,不适合高并发,因为一个请求I/O会阻塞进程(线程),所以,需要为每个请求分配一个处理进程(线程)以及时响应,系统开销大。

三、非阻塞I/O

1. 场景描述

还是上边的例子,等结果的这段时间我们就必须一直在这里等着什么也不干嘛?显然可以不用,我们在这个时候可以去旁边的超时转一转或者医院的其他地方转一转,但是我们又急着找医生,想要快点拿到片子,于是我们转一会就去拍片子的地方问一下结果出来没有,来来回回好多次,还不一定等得到结果。这就是非阻塞啦,就是要轮训,不断的问有没有准备好。

2. 基本概念

非阻塞式I/O就是对文件的I/O操作(读写操作)是非阻塞式的。当我们将一个套接字设置为非阻塞模式,就相当于告诉了系统内核:当我请求的I/O 操作不能够马上完成时,你想让我的进程进行休眠等待的时候,不可以这么做,需要马上返回一个错误(EAGAINEWOULDBLOCK)给我。

在一个用户进程中使用了非阻塞模式的套接字,一般来说它需要使用一个循环来不停地测试是否一个文件描述符有数据可读(称做polling)。不停的polling 内核来检查是否I/O操作已经就绪,这将会是一个极浪费CPU 资源的操作。

3. 网络模型

我们以recv()函数为例,因为这个函数可以设置为非阻塞模式。在这个种I/O 模型中,用户空间的进程执行一个系统调用 recv(),从套接字上读取数据,当套接字的接收缓冲区中还没有数据可读,函数recv()将返回一个error。进程在返回之后,可以干点别的事情,然后再发起 recv() 系统调用。然后重复上面的过程,循环往复的进行 recv()系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。

image-20220630165320699

【注意】拷贝数据整个过程,进程仍然是属于阻塞的状态。

4. 优缺点

  • 优点

每次发起 I/O 调用,在内核等待数据的过程中可以立即返回,用户线程不会阻塞,实时性好。

  • 缺点

不断轮询内核是否有数据,占用大量 CPU 资源,效率不高。

5. 设置非阻塞模式

当我们一开始建立一个套接字描述符的时候,系统内核将其设置为阻塞I/O模式,那么如何将其修改为非阻塞呢?我们可以通过fcntl()函数或者ioctl()函数:

1
2
3
4
5
6
7
8
9
/* fcntl的使用 */
int fcntl(int fd, int cmd, long arg);
int flag;
flag = fcntl(sockfd, F_GETFL, 0);
flag |= O_NONBLOCK;
fcntl(sockfd, F_SETFL, flag);
/* ioctl的使用 */
int b_on =1;
ioctl(sock_fd, FIONBIO, &b_on)

到目前为止,其实我还没有用过这两个函数,所以这里先简单提一下,以后用到了再详细说明。

四、多路复用I/O

这部分其实也是本篇笔记的重点,也是学习的重点。

1. 场景描述

还是上边的例子,现在的医院其实大多数进行检查的地方都会有一个自助打印机,如果说检查结果出来了,上边就会显示,我们直接自己打印就可以了,就不需要去问拍片子的人结果有没有出来了,这其实就算是一个I/O多路复用的实例。

2. 什么是I/O多路复用

I/O多路复用(IO multiplexing),它会通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行I/O操作时,能够通知应用程序进行相应的读写操作。I/O多路复用技术是为了解决:在并发式I/O场景中进程或线程阻塞到某个I/O系统调用而出现的技术,使进程不阻塞于某个特定的I/O系统调用。

I/O多路复用存在一个非常明显的特征:外部阻塞式,内部监视多路I/O

3. 为什么使用I/O多路复用

前边我们学习了TCP的多线程服务器和多进程服务器,不管是哪一种,若是新到来一个TCP连接,就需要分配一个进程或者线程,那么随着连接的客户端不断增加,服务器需要维护的进程或者线程数量也是不断增加,这样持续下去,操作系统无论如何是扛不住的。

既然为每个请求分配一个进程或者线程的方式不合适,那有没有可能只使用一个进程来维护多个 socket 呢?答案是有的,那就是使用I/O多路复用技术。

一个进程虽然任一时刻只能 一个请求,但是如果处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用

I/O多路复用的基本思想是:

  • 先构造一张有关描述符的表,然后调用一个函数。当这些文件描述符中的一个或多个已准备好进行I/O时函数才返回。
  • 函数返回时告诉进程那个描述符已就绪,可以进行I/O操作。

这样其实就很大程度上减轻了操作系统维护大量进程和线程的压力。

4. 网络模型

I/O 多路复用模型会用到 select()poll()epoll() 函数,这几个函数也会使进程阻塞,其中select 调用是内核级别的。

  • select 轮询相对非阻塞的轮询的区别在于:select 可以对多个 socket 端口进行监听,当其中任何一个 socket 的数据准好了,就能返回进行可读,然后进程再进行 recv() 系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。
  • select 相对阻塞I/O不同在于:此时的 select 不是等到 socket 数据全部到达再处理,而是有了一部分数据就会调用用户进程来处理。如何知道有一部分数据到达了呢?监视的事情就交给了内核,内核负责数据到达的处理,也可以理解为”非阻塞”吧。

具体流程,如下图所示:

image-20220701063024372
点击查看流程描述

I/O多路复用就是我们说的 selectpollepoll,有些地方也称这种 I/O 方式为 event driven IOselect/epoll 的好处就在于单个进程就可以同时处理多个网络连接的I/O。它的基本原理就是 selectpollepoll 这些函数会不断的轮询所负责的所有 socket套接字,当某个 socket套接字有数据到达了,就通知用户进程。

当用户进程调用了 select(),那么整个进程会被阻塞 ,而同时,内核会监视所有 select 负责的 socket套接字,当任何一个 socket套接字 中的数据准备好了,select() 就会返回。这个时候用户进程再调用 read()或者一些其他读取数据的操作,将数据从内核拷贝到用户进程。

多路复用的特点是通过一种机制一个进程能同时等待 I/O 文件描述符,内核监视这些文件描述符 (其实就是套接字描述符),其中的任意一个进入读就绪状态,selectpollepoll 函数就可以返回。这三个函数又对应着三种监视的方式。

与阻塞I/O相比。这里需要使用两个 system call (系统调用,也就是select()recv()函数),而阻塞I/O只调用了一个 system call。但是,用 select 的优势在于它可以同时处理多个 客户端的连接。

所以,如果处理的连接数不是很高的话,使用 select/epoll 的服务器不一定比使用多进程(线程)的服务器性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接

I/O多路复用模型中,实际中,对于每一个 socket,一般都设置成为 non-blocking(非阻塞),但是,如上图所示,整个用户的进程其实是一直被阻塞的。只不过进程是被 select()这个函数阻塞,而不是被socket IO 给阻塞了。所以I/O多路复用是阻塞在 selectepoll 这样的系统调用之上,而没有阻塞在真正的 I/O 系统调用如 recv() 之上。

了解了前面三种 I/O 模式,在用户进程进行系统调用的时候,它们在等待数据到来的时候,处理的方式不一样,直接等待,轮询,select/poll 轮询,两个阶段过程:

  • 第一个阶段有的阻塞,有的不阻塞,有的可以阻塞又可以不阻塞。
  • 第二个阶段都是阻塞的。

从整个 I/O 过程来看,他们都是顺序执行的,因此可以归为同步模型(synchronous)。都是进程主动等待且向内核检查状态。

高并发的程序一般使用同步非阻塞方式,而非多线程加同步阻塞方式。并发数是指同时进行的任务数 (如同时服务的 HTTP 请求),而并行数是可以同时工作的物理资源数量 (如 CPU 核数)。通过合理调度任务的不同阶段,并发数可以远远大于并行数,这就是区区几个 CPU 可以支持上万个用户并发请求的奥秘。在这种高并发的情况下,为每个任务 (用户请求)创建一个进程或线程的开销非常大。而同步非阻塞方式可以把多个 I/O 请求丢到后台去,这就可以在一个进程里服务大量的并发 I/O 请求。

IO 多路复用是同步阻塞模型还是异步阻塞模型?

同步是需要主动等待消息通知,而异步则是被动接收消息通知,通过回调、通知、状态等方式来被动获取消息。I/O 多路复用在阻塞到 select() 阶段时,用户进程是主动等待并调用 select() 函数获取数据就绪状态消息,并且其进程状态为阻塞。所以,把 I/O 多路复用归为同步阻塞模式。

五、信号驱动I/o

1. 基本概念

我们允许 socket 进行信号驱动 I/O,并注册一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。

2. 网络模型

具体过程如下图:

image-20220701074211434

当进程发起一个I/O操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用I/O读取数据。

六、异步I/o

1. 基本概念

相对于同步 I/O,异步 I/O 不是顺序执行。用户进程进行 aio_read() 系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到 socket 数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。异步I/O 两个阶段,进程都是非阻塞的。

2. 网络模型

Linux 提供了 AIO 库函数实现异步,但是用的很少。目前有很多开源的异步 I/O 库,例如 libeventlibevlibuv。异步过程如下图所示:

image-20220701083251722