网络通信-深入理解IO

网络通信之深入理解IO整理总结

Posted by Kang on September 15, 2019

1. 基础知识回顾

1.1 用户空间和内核空间

  现在操作系统都采用虚拟寻址,处理器先产生一个虚拟地址,通过地址翻译成物理地址(内存的地址),再通过总线的传递,最后处理器拿到某个物理地址返回的字节。
  对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,用户进程不能直接操作内核(kernel),操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。
  每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。空间分配如下图所示:
系统内存空间分配
  补充:地址空间就是一个非负整数地址的有序集合。如{0,1,2...}。
  有了用户空间和内核空间,整个linux内部结构可以分为三部分,从最底层到最上层依次是:硬件–>内核空间–>用户空间。如下图所示:
linux系统内部结构

  • 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。
  • Linux使用两级保护机制:0级供内核使用,3级供用户程序使用。

1.2 内核态与用户态

  (1)当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。
  (2)当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。

1.2.1 进程上下文与中断上下文

  程序在执行过程中通常有用户态和内核态两种状态,CPU对处于内核态根据上下文环境进一步细分,因此有了下面三种状态:
(1)内核态,运行于进程上下文,内核代表进程运行于内核空间。
(2)内核态,运行于中断上下文,内核代表硬件运行于内核空间。
(3)用户态,运行于用户空间。

1.2.2 进程上下文

  用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递 很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存 器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。
  相对于进程而言,就是进程执行时的环境。具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存信息等。一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。
(1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
(2)寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
(3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
  当发生进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的是模式切换(mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。

1.2.3 中断上下文

  中断:简单地说就是CPU在忙着作自己的事情,这时候硬件(比如说键盘按了一下)触发了一个电信号,这个信号通过中断线到达中断控制器i8259A,i8259A接受到这个信号后,向CPU发送INT信号申请CPU来执行刚才的硬件操作,并且将中断类型号也发给CPU,此时CPU保存当前正在做的事情(REST指令把程序计数器PC中的下一条待执行的指令的内存地址保存到栈)的情景现场,然后去处理这个申请,根据中断类型号找到它的中断向量(即中断程序在内存中的地址),然后去执行这段程序(这段程序已经写好,在内存中),执行完后再向i8259A发送一个INTA信号表示其已经处理完刚才的申请。此时CPU就可以继续做它刚才被打断做的事情了,将刚才保存的情景现场恢复出来,CPU继续执行接下来下面的程序。
  硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的 一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“ 中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。中断时,内核不代表任何进程运行,它一般只访问系统空间,而不会访问进程空间,内核在中断上下文中执行时一般不会阻塞。

1.3 进程上下文切换(进程切换)

  为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换(也叫调度)。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

  • 保存当前进程A的上下文。
      上下文就是内核再次唤醒当前进程时所需要的状态,由一些对象(程序计数器、状态寄存器、用户栈等各种内核数据结构)的值组成。这些值包括描绘地址空间的页表、包含进程相关信息的进程表、文件表等。
  • 切换页全局目录以安装一个新的地址空间。
      恢复进程B的上下文。可以理解成一个比较耗资源的过程。

1.4 进程的阻塞

  正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为。

1.5 文件描述符

  文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
  文件描述符在形式上是一个非负整数。文件描述符是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

1.6 直接I/O和缓存I/O

  缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,以write为例,数据会先被拷贝进程缓冲区,在拷贝到操作系统内核的缓冲区中,然后才会写到存储设备中。

缓存I/O的write:

缓存I/O的write图

直接I/O的write:(少了拷贝到进程缓冲区这一步)

直接I/O的write图
  write过程中会有很多次拷贝,直到数据全部写到磁盘。好了,准备知识概略复习了一下,开始探讨IO模式。

NIO 的缓冲将数据缓存起来后,可以前后移动的去控制读取,而传统的IO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节。需要注意的是,缓冲区大小要考虑Buffer大小,若文件过大,需要分段传输。Pooling Buffer可以做到缓冲区的复用,降低创建和销毁的资源消耗。

2. I/O模式

  对于一次IO访问(这回以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区,最后交给进程。所以说,当一个read操作发生时,它会经历两个阶段:
  1. 等待数据准备 (Waiting for the data to be ready),将外部数据被加载到内核缓存中。
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process). 正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:

  • 阻塞 I/O(blocking IO)
  • 非阻塞 I/O(nonblocking IO)
  • I/O 多路复用( IO multiplexing)
  • 信号驱动 I/O( signal driven IO)
  • 异步 I/O(asynchronous IO)
    注:由于信号驱动I/O在实际中并不常用,所以只提及剩下的四种IO 模型。

    可以看出IO模型是从全局开看IO交互的,具体的实现看句柄的管理方式(select/poll、epoll)+拷贝方式

2.1 block I/O模型(阻塞I/O)

阻塞I/O模型示意图:
阻塞I/O模型示意图
read为例:
(1)进程发起read,进行recvfrom系统调用;
(2)内核开始第一阶段,准备数据(从磁盘拷贝或者外部接收数据到缓冲区),进程请求的数据并不是一下就能准备好;准备数据是要消耗时间的;
(3)与此同时,进程阻塞(进程是自己选择阻塞与否),等待数据ing;
(4)直到数据从内核拷贝到了用户空间,内核返回结果,进程解除阻塞。

  也就是说,内核准备数据和数据从内核拷贝到进程内存地址这两个过程都是阻塞的。

2.2 non-block(非阻塞I/O模型)

  可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
非阻塞socket读
(1)当用户进程发出read操作时,如果kernel中的数据还没有准备好;
(2)那么它并不会block用户进程,而是立刻返回一个error,从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果(可以去做其他事情了);
(3)用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call;
(4)那么它马上就将数据拷贝到了用户内存,然后返回。

  所以,nonblocking IO的特点是用户进程在内核准备数据的阶段需要不断的主动询问数据好了没有。

2.3 信号驱动 IO 模

  在信号驱动 IO 模型中,当用户线程发起一个 IO 请求操作,会给对应的 socket 注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用 IO 读写操作来进行实际的 IO 请求操作。

2.4 asynchronous I/O(异步 I/O)

  真正的异步I/O很牛逼,流程大概如下:
异步 I/O图
(1)用户进程发起read操作之后,立刻就可以开始去做其它的事。
(2)而另一方面,从kernel的角度,当它收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。
(3)然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用 IO 函数进行实际的读写操作;而在异步 IO 模型中,收到信号表示 IO 操作已经完成,不需要再在用户线程中调用 IO 函数进行实际的读写操作。

2.5 I/O多路复用

  多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
  这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗)

  I/O多路复用实际上就是用select, poll, epoll监听多个io对象,当io对象有变化(有数据)的时候就通知用户进程。好处就是单个进程可以处理多个socket。当然具体区别我们后面再讨论,现在先来看下I/O多路复用的流程:
I/O多路复用图
(1)当用户进程调用了select,那么整个进程会被block;
(2)而同时,kernel会“监视”所有select负责的socket;
(3)当任何一个socket中的数据准备好了,select就会返回;
(4)这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
  所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
  上图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。
所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + 阻塞 IO的web server性能更好,可能延迟还更大。
  select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
  在IO multiplexing Model中,可以理解在内核中存在一个注册表,当用户进程调用select时,会向该表注册一个socket,内核中有单独的线程去不停的读取该表注册的socket的状态。对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,**整个用户的process处理流程其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block**。

多路复用 IO 模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用 IO 模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。

2.6 IO小结

(1)blocking和non-blocking的区别
  是从用户线程在没有可处理数据的情况下能不能立刻返回来看的。调用blocking IO会一直block住对应的进程,直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。
(2)同步IO和异步IO
  是从整个IO操作需不需要应用再去操作的角度来看的,比如non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中(也是属于IO操作的一部分),这个时候进程是被block了,在这段时间内,进程是被block的,所以BIO也是同步IO。
  而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,是io操作,进程完全没有被block。

(3)NIO和AIO的区别

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

(4)NIO为啥比IO强?
  因为在多路复用 IO 模型中,只需要使用一个线程(这个线程还是内核中的)就可以管理多个socket,系统不需要建立新的进程或者线程(去参与系统内核通讯),也不必维护这些线程和进程,并且只有在真正有socket 读写事件进行时,才会使用 IO 资源,所以它大大减少了资源占用。

(5)Stream和Channel有什么不同
  Stream是单向的,而Channel是双向的,可以读写


3. poll、epoll

3.1 select/poll与epoll

相同点

  三者都只是对句柄的管理方式,并且三者都是代理模式中的代理者,监控句柄上是否有socket事件发生。

不同点

  select/poll只是能检测到有没有事件发生,被唤醒的应用端还是要自己再去轮询一次才能检测到具体的事件是什么。epoll则可以看做本地自己维护了一个活跃事件列表,当系统告诉epoll时,epoll将该事件记录下来,并唤醒应用程序。
  select:各个客户端自己无差别的轮询访问fd句柄集合,集合只能存储1024个。
  poll:和select基本相同,本质上的区别就是存放 fd 集合的数据结构不一样,通过链表进行了句柄管理,所以管理的更多。
  epoll:通过红黑树来管理句柄,维护了一个活跃socket列表。

3.2 代理管理器的产生

3.2.1 非阻塞忙轮询的I/O

  为了能同时处理多个流,可以fork多进程、创建多线程或者非阻塞忙轮询的I/O方式。
  我们来讨论非阻塞忙轮询的I/O方式。在这种方式下, 我们需要不停的把所有流从头到尾轮询以遍,然后再从头开始。但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。这里要补充一点,阻塞模式下,内核对于I/O事件的处理是通过阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(select以及epoll)处理甚至直接忽略,程序想这样:

1
2
3
4
5
6
while true {
  for i in stream[]; {
    if i has data
    read until unavailable
  }
}

  为了避免CPU空转,可以引进了一个代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流(于是我们可以把“忙”字去掉了)。

1
2
3
4
5
6
7
while true {
  select(streams[])
  for i in streams[] {
      if i has data
      read until unavailable
  }
}

  于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是哪几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。
  但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,每一次无差别轮询时间就越长。此时就有了epoll了。
  epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

4. 事件驱动编程模型

4.1 论事件驱动

  通常,我们写服务器处理模型的程序时,有以下几种模型:
(1)每收到一个请求,创建一个新的进程,来处理该请求;
(2)每收到一个请求,创建一个新的线程,来处理该请求;
(3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
  上面的几种方式,各有千秋:
  第(1)种方法,由于创建新的进程:实现比较简单,但开销比较大,导致服务器性能比较差。
  第(2)种方式,由于要涉及到线程的同步,有可能会面临死锁等问题。
  第(3)种方式,在写应用程序代码时,逻辑比前面两种都复杂。
  综合考虑各方面因素,一般普遍认为第(3)种方式是大多数网络服务器采用的方式。

4.2 看图说话讲事件驱动模型

  在UI编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢?
  方式一:创建一个线程,该线程一直循环检测是否有鼠标点击,那么这个方式有以下几个缺点:

  1. CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
  2. 如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
  3. 如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题;
      所以,该方式是非常不好的。

方式二:就是事件驱动模型
  目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:

  1. 有一个事件(消息)队列;
  2. 鼠标按下时,往这个队列中增加一个点击事件(消息);
  3. 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
  4. 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数; 事件驱动模型图

  事件驱动编程是一种网络编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。
  让我们用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。
  在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。
  在多线程版本中,这3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人痛不欲生的bug。
  在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。

  1. 当我们面对如下的环境时,事件驱动模型通常是一个好的选择:
  2. 程序中有许多任务,而且…
  3. 任务之间高度独立(因此它们不需要互相通信,或者等待彼此)而且… 在等待事件到来时,某些任务会阻塞。
    当应用程序需要在任务间共享可变的数据时,这也是一个不错的选择,因为这里不需要采用同步处理。
    网络应用程序通常都有上述这些特点,这使得它们能够很好的契合事件驱动编程模型。