IO 多路复用

一、什么是内核空间和用户空间

1.1 内核空间和用户空间

操作系统的核心是内核(kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。

由于我们用户所有的应用都是运行在操作系统之上的,所以一旦操作系统不能稳定运行,那就完了。因此为了保证操作系统的稳定性,Linux 区分了内核空间和用户空间。

可以这样理解,内核空间运行操作系统程序和驱动程序,而用户空间则运行应用程序。Linux 以这种方式隔离了操作系统程序和应用程序,避免了应用程序影响到操作系统自身的稳定性。

1.2 内核态和用户态

当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。

对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。

1.3 如何从用户空间进入内核空间

其实所有的系统资源管理都是在内核空间中完成的,比如读写磁盘文件、分配回收内存、从网络接口读写数据等等。我们的应用程序是无法直接进行这样的操作的。但是我们可以通过内核提供的接口来完成这样的任务。

比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个「系统调用」,以此来告诉内核「我要读取磁盘上的某某文件」。

系统调用就是操作系统向用户提供服务的接口。

二、什么是 IO

2.1 IO 基本概念

IO 是输入(Input)和输出(Output)的首字母缩写,直观意思是计算机输入和输出,它描述的是计算机的数据流动的过程,因此 IO 的第一大特征是有数据的流动。另外,对于一次 IO 操作,它究竟是输入还是输出,是针对不同的主体而言的,不同的主体有不同的描述。

例如,甲乙两人交谈,甲将大脑中的想法通过声带震动,继而通过声波传入乙的耳朵,乙通过耳膜的震动再由神经将信号解析到大脑,就这个数据流动的过程,对甲而言是输出,对乙而言则是输入。

因此,理解 IO 一定要弄清楚所要研究的
本体
。下面,我们从三个层面来理解IO。

2.2 从直观层面去理解 IO

此时,IO 是计算机和外设之间的数据流动过程,本体是一个有使用意义的可运行的电脑,它是计算机运行的完全必要部分。姑且认为这个完全必要部分是台式电脑的主机,里面有 CPU、内存、主板、电源等设备,因为有了这些,一台有使用意义的电脑即可运行。有了主机,并不能方便的为人所服务,因此得有外设。

外设是电脑的外围设备,如显示器、键盘、鼠标等,它们是完成人机交互的辅助工具。

外设包含两种重要设备(但不限于此):输入设备和输出设备。

  • 像鼠标、键盘属于输入设备,将人的指令转成「鼠键行为」这种数据传给主机
  • 显示器是输出设备,主机通过运算,把「运算结果」这种数据传给显示器

2.3 从计算机架构的角度去理解 IO

从计算机架构上来讲,任何涉及到
计算机核心(CPU 和内存)与其他设备间的数据流动的过程
就是 IO。本体就是计算机核心(CPU 和 内存)。

例如从硬盘上读取数据到内存,是一次输入;将内存中的数据写入到硬盘就产生了输出。在计算机的世界里,这就是 IO 的本质。

2.4 从编程的角度去理解 IO

此时,IO 的主体是其应用程序的运行态,即进程。特别强调的是我们的应用程序其实并不存在实质的 IO 过程,真正的 IO 过程是操作系统的事情,这里把应用程序的 IO 操作分为两种动作:
IO 调用和 IO 执行

  • IO 调用是由进程发起
  • IO 执行是操作系统的工作

因此,更准确些来说,此时所说的 IO 是应用程序对操作系统 IO 功能的一次触发,即 IO 调用。IO 调用的目的是:

  1. 将进程的内部数据迁移到外部,即输出
  2. 或将外部数据迁移到进程内部,即输入

这里,外部数据指非进程空间数据,如从文件中读取的数据。

以一个进程的输入类型的 IO 调用为例,它将完成或引起如下工作内容:

  1. 进程向操作系统请求外部数据
  2. 操作系统将外部数据加载到内核缓冲区
  3. 操作系统将数据从内核缓冲区拷贝到进程缓冲区
  4. 进程读取数据继续后面的工作

2.5 缓存 IO

缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。

在 Linux 的缓存 IO 机制中:

  • 读操作:数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到用户空间
  • 写操作:数据先从用户空间复制到内核空间缓冲区,然后从内核空间缓冲区复制到磁盘

IO多路复用.drawio

2.6 IO 设备

从一个设备中读数据到内存或者从内存写数据到这个设备,而这个设备就叫 IO 设备:

IO多路复用.drawio

根据 IO 设备不同,IO 分为「磁盘 IO」和「网络 IO」:

  • 磁盘 IO:对存储介质的读写,如硬盘
  • 网络 IO:在网络通信过程中数据的传输,即对网卡的读写

IO多路复用.drawio

2.7 阻塞和非阻塞 IO

阻塞和非阻塞强调的是进程对于操作系统 IO 是否处于就绪状态的处理方式。上面已经说过,应用程序的 IO 实际是分为两个步骤:IO 调用和 IO 执行。

  • IO 调用是由进程发起的
  • IO 执行则是操作系统的工作

操作系统的 IO 情况决定了进程 IO 调用是否能够得到立即响应。如进程发起了读取数据的 IO 调用,操作系统需要将外部数据拷贝到进程缓冲区,在有数据拷贝到进程缓冲区前,进程缓冲区处于不可读状态,我们称之为操作系统 IO 未就绪。

进程的 IO 调用是否能得到立即执行是需要操作系统 IO 处于就绪状态的,对于读取数据的操作:

  • 如果操作系统 IO 处于未就绪状态,当前进程或线程如果一直等待直到其就绪,该种IO方式为阻塞IO
  • 如果进程或线程并不一直等待其就绪,而是可以做其他事情,这种方式为非阻塞 IO

2.7.1 阻塞 IO

我们以 Socket 为例,在 Linux 中,默认情况下所有 Socket 都是阻塞模式的。当用户进程或线程调用系统函数
read()
,内核开始准备数据(从网络接收数据),内核准备数据完成后,数据从内核拷贝到用户空间的应用程序缓冲区,数据拷贝完成后,请求才返回。从发起 Read 请求到最终完成内核到应用程序的拷贝,整个过程都是阻塞的:

image-20230318231811322

如果当前进程或线程一直等待直到其就绪,该种 IO 方式就称为阻塞 IO

2.7.2 非阻塞 IO

如果用户进程或线程线程在发起 Read 请求后立即返回,不用等待内核准备数据的过程,而是可以做其他事情,这种方式就称为非阻塞 IO:

image-20230318231840614

对于非阻塞 IO,我们编程时需要经常去轮询就绪状态。即如果 Read 请求没读取到数据,用户进程或线程会不断轮询发起 Read 请求,直到数据到达(内核准备好数据)后才停止轮询

2.8 同步和异步 IO

同步和异步描述的是针对当前执行进程或线程而言,发起 IO 调用后,当前进程或线程是否挂起等待操作系统的 IO 执行完成。

我们说一个 IO 执行是同步执行的,意思是程序发起 IO 调用后,当前线程或进程需要等待操作系统完成 IO 执行工作并告知进程或线程已经完成,进程或线程才能继续往下执行其他既定指令。

如果说一个 IO 执行是异步的,意思是该动作是由当前进程或线程请求发起,且当前进程或线程不必等待操作系统 IO 的执行完毕,可直接继续往下执行其他既定指令。操作系统完成 IO 后,当前进程或线程会得到操作系统的
通知

以一个读取数据的 IO 操作而言,在操作系统将外部数据写入进程缓冲区这个期间,进程或线程挂起等待操作系统 IO 执行完成的话,这种 IO 执行策略就为同步,如果进程或线程并不挂起而是继续工作,这种 IO 执行策略便为异步。

同步和异步这个概念是针对于程序进程,而阻塞与非阻塞是针对系统处理 IO 操作的过程。

三、多路复用

3.1 最基本的 Socket 模型

服务端:

  1. 首先调用
    socket()
    函数,创建网络协议为 IPv4、以及传输协议为 TCP 的 Socket
  2. 接着调用
    bind()
    函数,给这个 Socket 绑定一个 IP 地址和端口
  3. 绑定完 IP 地址和端口后,就可以调用
    listen()
    函数进行监听
  4. 服务端进入了监听状态后,通过调用
    accept()
    函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来

客户端:

  1. 同样,首先调用
    socket()
    函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket
  2. 接着调用
    connect()
    函数发起连接,然后万众期待的 TCP 三次握手就开始了

连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过
read()

write()
函数来读写数据。至此, TCP 协议的 Socket 程序的调用过程就结束了,整个过程如下图:

IO多路复用.drawio

TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的。

可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。

3.2 多进程模型

基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。

服务器的主进程负责监听客户的连接,一旦与客户端连接完成,
accept()
函数就会返回一个「已连接 Socket」,这时就通过
fork()
函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。这两个进程刚复制完的时候,几乎一摸一样,不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。

正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,可以发现:

  • 子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」
  • 父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」

下面这张图描述了从连接请求到连接建立,父进程创建子进程为客户服务的过程:

img

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的「包袱」是很重的,性能会大打折扣。

3.3 多线程模型

既然进程间上下文切换的「包袱」很重,那我们就搞个比较轻量级的模型来应对多用户的请求——多线程模型。

线程是运行在进程中的一个「逻辑流」,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源的,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享资源在上下文切换时是不需要切换的,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

当服务器与客户端 TCP Socket 完成连接后,通过
pthread_create()
函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。

如果每来一个连接就创建一个线程,线程运行完后,操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个「已连接的 Socket」放入到一个工作队列中,然后线程池里的线程负责从工作队列中取出「已连接 Socket」进程处理。

v2-d67eb5cc4b947eed8b19846d4ed85cb5_720w

3.4 IO 多路复用

上面基于多进程或者多线程的模型,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程 / 线程,操作系统就算死扛也是扛不住的。

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

img

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

我们所熟知的 select、poll、epoll,就是内核提供给用户的多路复用系统调用的接口,当用户调用这些接口时,进程就可以通过
一个系统调用函数
从内核中
获取多个事件
,从而实现了 IO 多路复用。

下面对这三个多路复用接口做了简单介绍。

3.4.1 select

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核中,让内核来检查是否有网络事件产生。检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行两次「遍历」文件描述符集合的操作,一次是在内核态里,一个次是在用户态里 ,而且还会发生两次「拷贝」文件描述符集合,先从用户空间传入到内核空间,由内核修改后,再传出到用户空间中。

且 select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

3.4.2 poll

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

3.4.3 epoll

epoll 通过两个方面,很好解决了 select/poll 的问题。

  1. epoll 在内核里使用
    红黑树
    来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过
    epoll_ctl()
    函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  2. epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用
    epoll_wait()
    函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

从下图你可以看到 epoll 相关的接口作用:

img

epoll 的方式使得即使监听的 Socket 数量变得很多的时候,效率也不会大幅度降低,而监听的文件描述符的上限就为系统定义的进程打开的最大文件描述符个数。

声明

参考资料

标签: none

添加新评论