网络编程之IO模型和IO多路复用


对于学习服务端编程的程序员来说,网络IO是经常会涉及到的知识;同行聊天或者面试等等,也经常会被问到。比如阻塞IO,非阻塞IO,同步IO,异步IO等概念是怎么回事?select, poll, epoll 有什么区别等等。要搞清楚这些,需要了解一点linux系统相关的知识。下面简单梳理一下。

1 基础知识

1.1 内核空间和用户空间

  • linux程序被执行后会成为一个进程,内核会为每个运行的进程提供了大小相同的虚拟地址空间,这使得多个进程可以同时运行而又不会互相干扰。对于32位机器而言,虚拟地址空间为4G(2的32次方)。

  • 操心系统将虚拟空间划分为两部分,一部分为内核空间(1G:0xC00000000xFFFFFFFF),一部分为用户空间(3G:0x000000000xBFFFFFFF)。进程运行时,属于内核部分的程序(如设备驱动)运行于内核空间,而用户程序运行于用户空间。

  • linux操作系统支持cpu提供的特权等级Ring0和Ring3,其中Ring0最高,Ring3最低。当进程在执行内核代码时,特权等级为Ring0,此时称进程处于内核态;而当进程执行用户代码时,特权等级为Ring3,此时称进程处于用户态。每个进程都拥有一个内核栈和一个用户栈,分别处于内核空间和用户空间。用户态到内核态的切换通常有三种方式:系统调用,异常,外围设备中断。

    用户态和内核态的理解和区别

    图示:
    用户空间和内核空间

1.2 进程切换

  • 进程在运行过程中一般有等待(阻塞/挂起)、就绪、执行三个状态;这是因为计算机在运行时,同一时间只能有一个进程处于执行状态。为了保证多进程的运行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换(process switch)、任务切换(task switch)或上下文切换(content switch)。而控制进程切换的策略则被称为进程调度

  • 从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

    1. 保存处理机上下文,包括程序计数器和其他寄存器。
    2. 更新PCB信息。
    3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
    4. 选择另一个进程执行,并更新其PCB。
    5. 更新内存管理的数据结构。
    6. 恢复处理机上下文。

1.3 进程阻塞

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

    进程的阻塞、挂起和睡眠

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

  • 缓存I/O又被称作标准I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。
  • 在Linux的缓存I/O机制中,对于write,数据会先被拷贝用户缓冲区,再拷贝到内核缓冲区,然后才会写到存储设备中。对于read,数据会先被拷贝到内核缓冲区,然后从内核缓冲区拷贝到用户缓冲区,最后交给用户程序处理。
  • 缓存IO的缺点:数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

2 IO模型

根据以上基础知识,我们知道IO读写是一个耗费资源和时间的过程。网络IO的模型大致有如下几种:

  • 同步阻塞IO(blocking IO)
  • 同步非阻塞IO(nonblocking IO)
  • IO多路复用( IO multiplexing)
  • 信号驱动IO( signal driven IO)
  • 异步IO(asynchronous IO)

注:信号驱动IO在实际中并不常用,所以常见的主要是四种IO模型。
4种IO模型

2.1 同步阻塞IO(blocking IO)

同步阻塞IO即在整个IO系统调用的过程中,进程都处于阻塞状态。在linux中,默认情况下所有的socket都是blocking。

以read为例:

  1. 进程发起read,进行recvfrom系统调用,同时进程进入阻塞(进程是自己选择阻塞与否),等待数据;
  2. 内核开始准备数据(从磁盘拷贝到内核缓冲区),进程请求的数据并不是一下就能准备好;准备数据是需要时间的;
  3. 内核将数据从内核缓冲区拷贝到了用户缓冲区,内核返回结果,进程解除阻塞。

也就是说,内核准备数据和数据从内核拷贝到用户空间这两个过程都是阻塞的。

阻塞IO过程如下图所示:
阻塞IO

优点:

  1. 能够及时返回数据,无延迟;
  2. 调用代码逻辑简单;

缺点:

  1. 等待浪费很多时间,影响程序性能;

2.2 同步非阻塞IO(nonblocking IO)

同步非阻塞IO即在IO系统调用的过程中,进程不必阻塞,而是采用定时轮询(polling)的方式数据是否准备就绪;在此期间,进程可以处理其他的任务。

以read为例:

  1. 进程发起read,进行recvfrom系统调用,如果kernel中的数据还没有准备好,就立刻返回一个error;
  2. 调用返回后进程可以进行其他操作,然后再次发起recvfrom系统调用,不断重复;(这个过程称为轮询polling)
  3. kernel中的数据准备好以后,再次收到recvfrom调用,就将数据拷贝到了用户内存,然后返回;

需要注意,在数据从内核拷贝到用户内存的过程中,进程仍然是属于阻塞的状态。

非阻塞IO过程如下图所示:
非阻塞IO

优点:

能够在IO操作过程中,处理其他的任务。

缺点:

任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

2.3 IO多路复用(IO multiplexing)

I/O多路复用就是通过一种机制,可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。常见的select, poll, epoll 都是IO多路复用。
需要注意的是,select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。

IO多路复用过程如下图所示:
IO多路复用

关于select, poll, epoll的细节:
select、poll、epoll之间的区别总结

2.4 异步IO(asynchronous IO)

异步IO是事件驱动IO。用户进程发起IO操作之后,会立即返回,然后可以处理其他任务。kernel会等待数据准备完成,然后将数据拷贝到用户内存。当这一切都完成之后,kernel会给用户进程发送一个signal,通知IO操作完成。在IO两个阶段,进程都是非阻塞的。
目前有很多开源的异步IO库,例如libevent、libev、libuv。

异步IO过程如下图所示:
异步IO

3 IO模型总结

先通过下图来看看5种IO模型的区别:
IO模型比较

3.1 blocking和non-blocking区别

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

3.2 synchronous IO和asynchronous IO区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。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;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的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。

3.3 non-blocking IO和asynchronous IO的区别

  • 在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的检查,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。

  • asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。