本文由 简悦 SimpRead 转码, 原文地址 www.jianshu.com

计算机软硬件

硬件组成部分

https://www.cnblogs.com/yangjianyong-bky/articles/14608585.html
现代计算机系统的硬件组成部分:总线、I/O设备、主存、处理器
image.png

总线

是一组电子管道,这个管道贯穿了整个计算机系统,主要的工作就是携带信息字节在各个部件之间传递。有io总线,存储器总线,系统总线,总线接口

I/O设备

就是系统与外部世界的连接通道。例如鼠标/键盘/显示器,磁盘等等。

主存

则是一个临时的存储设备,用来存放程序以及程序要处理的数据。

处理器就是中央处理单元【CPU】

就是用来解释存储在主存中的指令的引擎。讲的明白点,就是把要计算的数据以及计算过程丢给处理器,然后让它计算得到我们想要的结果。

操作系统

就是我们熟知的 windows/linux/unix 操作系统。操作系统是位于硬件跟应用程序之间的一层软件,该软件(操作系统)提供了两个基本的功能:一个是防止硬件被失控的应用程序滥用;一个就是向应用程序提供了一种简单一致的机制来控制不同的硬件设备。
而怎么实现这两种功能的呢,是通过三个基本的抽象概念来是实现的,分别是:进程、虚拟内存以及文件。而这个进程,就是我们今天要讲的主角

内核空间跟用户空间

上面已经提到了,在硬件之上,有一层操作系统,更上层的是应用程序。操作系统可以直接控制硬件,可以访问到受保护的内存空间。而应用程序的进程则不可以直接操作硬件。
为了保护操作系统的内核运行,操作系统将虚拟空间划分为两部分:内核空间、用户空间。在这里只需要知道进程是运行在一个叫虚拟内存空间上的,这个虚拟内存空间有一部分是内核使用的,叫内核空间,有一部分是进程使用的,叫用户空间。
而进程需要操作硬件,只能通过调用操作系统提供的内核函数来实现,例如 linux 中的 read 操作和 write 操作。

进程

就是操作系统提供的一种抽象概念,这个概念给各种应用软件提供了一种假象,让不同的应用软件看上去好像是单独的占用硬件资源,让硬件单独的处理我这个正在运行的软件。也就是说一个进程代表着一个软件正在执行的过程。但是我们的电脑上,肯定不止开了一个软件,肯定一次性开了好几个,那么操作系统就会建立起很多个不同的进程来处理不同的软件。但是 CPU 一次只能处理一种程序,当有不同的程序需要同一个 CPU 处理怎么办?就是我们这篇文章的重点登场了:五种I/O模型来实现并发的处理不同的程序

1 概念说明

1.1 用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。操作系统的核心是内核,独立于普通的应用程序,内核空间可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。

1.2 进程切换

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

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

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

总而言之就是很耗资源

1.3 进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,使自己由运行状态变为阻塞状态,当进程进入阻塞状态,是不占用CPU资源的。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得 CPU),才可能将其转为阻塞状态。

1.4 文件描述符 fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念

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

1.5 缓存 IO

缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间

缓存 IO 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

2 Linux IO 模型

网络IO的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的操作。刚才说了,对于一次 IO 访问(以 read 举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间

当一个 read 操作发生时,它会经历两个阶段:

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

对于 socket 流而言

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

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

网络应用需要处理的无非就是两大类问题,网络IO,数据计算。相对于后者,网络 IO 的延迟,给应用带来的性能瓶颈大于后者。网络 IO 的模型大致有如下几种:

  • 同步模型(synchronous IO)
  • 阻塞 IO(bloking IO)
  • 非阻塞 IO(non-blocking IO)
  • 多路复用 IO(multiplexing IO)
  • 信号驱动式 IO(signal-driven IO)
  • 异步 IO(asynchronous IO)

**注:由于 **信号驱动式 IO(signal-driven IO)**在实际中并不常用,所以我这只提及剩下的四种IO Model。**

2.1 同步阻塞 IO(blocking IO)BIO

同步阻塞 IO 模型是最常用的一个模型,也是最简单的模型。在 linux 中,默认情况下所有的socket都是blocking。它符合人们最常见的思考逻辑。阻塞就是进程 "被" 休息, CPU可以处理其它进程

在这个 IO 模型中,用户空间的应用程序执行一个系统调用(recvform),这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞。不能处理别的网络 IO。调用应用程序处于一种不再消费 CPU 而只是简单等待响应的状态

在调用 recv()/recvfrom() 函数时,阻塞IO 发生在内核中等待数据和复制数据的过程,大致如下图:

Linux 五种IO模型 - 图2

流程描述

当用户进程调用了 recv()/recvfrom() 这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络 IO 来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的 UDP 包。这个时候 kernel 就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。
第二个阶段:当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。

所以,blocking IO 的特点就是在 IO 执行的两个阶段都被 block 了。

优点:

  1. 能够及时返回数据,无延迟;
  2. 对内核开发者来说这是省事了;

缺点:

  1. 对用户来说处于等待就要付出性能的代价了;

2.2 同步非阻塞 IO(nonblocking IO)

同步非阻塞就是 “每隔一会儿瞄一眼进度条” 的轮询(polling)方式

非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起 recvform 系统调用。重复上面的过程,循环往复的进行 recvform 系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。**需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态**

在 linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个non-blocking socket执行读操作时,流程如图所示:

Linux 五种IO模型 - 图3

nonblocking IO 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。

同步非阻塞方式相比同步阻塞方式:

优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行)。

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

2.3 IO 多路复用( IO multiplexing)

IO multiplexing 就是我们说的 select,poll,epoll,有些地方也称这种 IO 方式为 事件驱动 event driven IO。

由于同步非阻塞方式需要不断主动轮询这个任务的状态,轮询占据了很大一部分过程,轮询会消耗大量的 CPU 时间。

而 “后台” 可能有多个任务在同时进行,人们就想到了在一个进程/线程中 处理 多个任务的完成状态,只要有任何一个任务完成,就去处理它。那么这就是所谓的 “IO 多路复用”

UNIX/Linux 下的 select、poll ,epoll就是干这个的,且是内核级别

目前支持 I/O 多路复用的系统调用有 select,pselect,poll,epoll,I/O 多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作

但select,pselect,poll,epoll本质上都是同步阻塞I/O,因为他们都需要在读写事件就绪后自己负责进行读写(同步),且这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,且异步 I/O 的实现程序会负责把数据从内核拷贝到用户空间。这种方法是异步非阻塞IO

select 在select实现的过程中,在一个或多个进程管理着不同的描述符,每个描述符都有唯一的标识。当一个或多个进程向内核发起了select的系统调用后,内核就开始准备数据,当数据准备好后(或者说有描述符就绪(有数据 可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可)),要返回给对应进程中对应的描述符时,select 需要遍历所有进程中所有的描述符,找到对应的发起请求的那个描述符后通知进程来读取数据。由于每次都要把 select 管理的进程轮询一遍,时间复杂度就是我们所说的 O(N) 复杂度。在 select 方式中,系统规定了单个进程中能够打开的描述符最大上限是 1024个

select 和 poll 都需要描述符就绪后,通过遍历文件描述符来获取已经就绪的socket,poll没有最大连接数的限制,**原因是它是基于链表来存储的**

当一个或多个进程向内核发起了 epoll 调用后,内核这个时候就给该进程的描述符注册一个回调函数,在内核准备好数据后,内核会采用类似callback的回调机制 ,让对应的描述符的进程自己过来读取数据,每一次只需要通知一个进程就行了,这个时间复杂度就是 O(1) 复杂度,很明显,这比 O(N) 复杂度效率高的太多了

具体流程,如下图所示:

Linux 五种IO模型 - 图4

当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O 多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程 / 多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降底了系统的维护工作量,节省了系统资源,I/O 多路复用的主要应用场景如下:

服务器需要同时处理多个处于监听状态或者多个连接状态的套接字。

服务器需要同时处理多种网络协议的套接字。

2.4 信号驱动式 IO(signal-driven IO)

信号驱动IO在实际的开发中是很少用到的,因为无用的信号太多了
当进程调用了系统调用后,该模型会给对应的 socket 请求建立一个信号处理器,进程并不会被挂起。当内核准备好数据时,内核就会产生一个SIGIO信号给之前建立好的信号处理器,然后信号处理器就可以进行读写操作。

Linux 五种IO模型 - 图5

2.5 异步非阻塞 IO(asynchronous IO)AIO

相对于同步 IO,异步 IO 不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回error给用户进程,然后用户态进程可以去做别的事情。等到 socket 数据准备好了,内核直接复制数据给进程(无需用户进程来拷贝数据),然后从内核向进程发送通知给用户进程发送一个signal或执行一个基于线程的回调函数来完成这次 IO 处理过程
IO两个阶段,进程都是非阻塞的

Linux 提供了 AIO 库函数实现异步,但是用的很少。目前有很多开源的异步 IO 库,例如 libevent、libev、libuv。异步过程如下图所示:

Linux 五种IO模型 - 图6

在 Linux 中,通知的方式是 “信号”:

如果这个进程正在用户态忙着做别的事(例如在计算两个矩阵的乘积),那就强行打断之,调用事先注册的信号处理函数,这个函数可以决定何时以及如何处理这个异步任务。由于信号处理函数是突然闯进来的,因此跟中断处理程序一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进队列,然后返回该进程原来在做的事

如果这个进程正在内核态忙着做别的事,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到内核态的事情忙完了,快要回到用户态的时候,再触发信号通知

如果这个进程现在被挂起了,例如无事可做 sleep 了,那就把这个进程唤醒,下次有 CPU 空闲的时候,就会调度到这个进程,触发信号通知。

异步 API 说来轻巧,做来难,这主要是对 API 的实现者而言的。Linux 的异步 IO(AIO)支持是 2.6.22 才引入的,还有很多系统调用不支持异步 IO。Linux 的异步 IO 最初是为数据库设计的,因此通过异步 IO 的读写操作不会被缓存或缓冲,这就无法利用操作系统的缓存与缓冲机制

**很多人把 Linux 的 O_NONBLOCK 认为是异步方式,但事实上这是前面讲的同步非阻塞方式。**需要指出的是,虽然 Linux 上的 IO API 略显粗糙,但每种编程框架都有封装好的异步 IO 实现。操作系统少做事,把更多的自由留给用户,正是 UNIX 的设计哲学,也是 Linux 上编程框架百花齐放的一个原因。

从前面 IO 模型的分类中,我们可以看出 AIO 的动机:

同步阻塞模型需要在 IO 操作开始时阻塞应用程序。这意味着不可能同时重叠进行处理和 IO 操作。

同步非阻塞模型允许处理和 IO 操作重叠进行,但是这需要应用程序根据重现的规则来检查 IO 操作的状态。

这样就剩下异步非阻塞 IO 了,它允许处理和 IO 操作重叠进行,包括 IO 操作完成的通知。

3 五种 IO 模型总结

3.1 blocking 和 non-blocking 区别

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

3.2 synchronous IO 和 asynchronous IO 区别

两者的区别就在于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 的。

各个 IO Model 的比较如图所示:

Linux 五种IO模型 - 图7
《Unix网络编程》卷一

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