Back

操作系统

简单记录操作系统原理,如内核、CPU、内存、Linux等,太难啦,以后再慢慢啃细一点

[TOC]

内核

PC机启动流程

以Ubuntu Linux + GRUB引导程序为例:

PC机加电后,加载BIOS固件, 触发PC机BIOS固件发送指令 检测和初始化CPU、内存及主板平台,加载引导设备(比如硬盘)中的第一个扇区数据到0x7c00地址开始的内存空间,再接着跳转到0x7c00处执行指令,加载GRUB引导程序,加载硬盘分区中的OS文件,启动操作系统。

内核结构分类

内核:计算机资源的管理者。计算资源分为硬件资源和软件资源

  • 硬件资源:CPU、内存、硬盘、网卡、显卡、IO设备、总线,他们之间通过总线进行联系;

  • 软件资源:对上述各种硬件资源的管理程序、设备的驱动程序

宏内核结构 - 类似单体服务

将上述所有软件资源进行整合,链接在一起,形成一个大的可执行程序,控制所有硬件资源。这个大程序会在处理器的特权模式下运行,对外提供系统调用函数,供其他程序、进程调用。

当应用层有程序要调用进行内存分配时,就会调用宏内核进行内存分配

  1. 应用程序调用内核内存分配API函数
  2. 处理器切换到特权模式,开始运行内核代码
  3. 内核的内存管理代码按照特定的算法,分配内存
  4. 内存分配函数把分配的内存块的首地址返回给应用程序
  5. 应用程序接收到返回后,处理器开始运行用户模式下的应用程序,应用程序使用得到的内存首地址,开始使用这块内存

优点:由于所有硬件管理程序都整合在一起,相互调用时性能极高

缺点:所有硬件管理程序都整合在一起,没有模块化,扩展性差,移植性差,牵一发而动全身,每次修改都需要重新安装,其中一个模块有问题就会影响其他模块。

Linux就属于宏内核

Linux 的基本思想是一切都是文件:每个文件都有确定的用途,包括用户数据、命令、配置参数、硬件设备等对于操作系统内核而言,都被视为各种类型的文件。Linux 支持多用户,各个用户对于自己的文件有自己特殊的权利,保证了各用户之间互不影响。多任务则是现代操作系统最重要的一个特点,Linux 可以使多个程序同时并独立地运行。

第一次访问
第一次访问

Linux使用宏内核架构,主要分为上述五大模块,模块间的通信通过函数调用,函数间的调用没有层次关系,所以函数的调用路径纵横交错,如果有函数出问题,那就会影响到调用它的模块,存在安全隐患,但优点是存内核调用,性能极高

Linux高清全结构图:https://makelinux.github.io/kernel/map/

微内核结构 - 类似微服务

内核仅处理进程调度、中断、内存空间映射、进程间通信等,控制硬件资源的软件资源,转成一个个服务进程,比如进程管理、内存管理、设备管理、文件管理等,和用户进程一样,只是它们提供了宏内核的那些功能。

微内核内,进程间通过消息进行通信,应用程序每次要申请资源都需要发送消息到微内核,微内核再把这条消息转发给相关服务进程,直到完成这次调用。

当应用层有程序要调用进行内存分配时

  1. 应用程序发送内存分配的消息(发送消息这个函数由微内核提供)
  2. 处理器切换到特权模式,执行内核代码
  3. 微内核让当前进程停止运行,并根据消息包中的数据,推送给对应的消息接收者,比如这里是内存管理服务进程。
  4. 内存管理服务进程收到消息,分配一块内存
  5. 内存管理服务进程,处理完成后,通过消息的形式返回分配内存块的地址给内核,然后继续等待下一条消息。
  6. 微内核把包含内存地址的消息返回发送给内存分配消息的应用程序
  7. 处理器开始运行用户模式下的应用程序,应用程序收到这条消息,得到内存首地址,开始使用这块内存。

优点:系统结构清晰,移植性、伸缩性、扩展性强,微内核代码少,容易替换,各种系统服务进程可随时替换

缺点:进程间消息依赖微内核进行消息传递,会频繁进行服务进程切换,模式切换,导致性能较差。

分离硬件的相关性

分离硬件的相关性其实就是屏蔽底层硬件操作细节,形成一个独立的软件抽象层,对外提供接口,方便应用层接入。

是内核设计的一种指导思想,所以在设计操作系统内核的时候,就可以分为

  • 内核接口层:向应用层提供系统接口函数;
  • 内核功能层:系统函数的主要实现,实现进程管理、内存管理、中断管理、设备管理、驱动、文件系统等;
  • 内核硬件层:对硬件资源的操作,比如初始化CPU、内存、中断控制、其他IO设备

混合内核

混合内核在微内核的基础上进行改进,层次分明,动态加载模块到内核,兼具宏内核和微内核的优点。

Windows就属于混合内核。

CPU

中断:中断即中止执行当前程序,转而跳转到另一个特定的地址上,去运行特定的代码,响应外部事件。

硬件中断:中断控制器给CPU发送了一个电子信号,CPU对这个信号作出应答,随后中断控制器将中断号发给CPU。

软件中断:CPU执行INT指令,指令后带一个常数,该常数为软中断号。

工作模式

实模式

又称实地址模式,运行真正的指令,对指令的动作不作区分,直接执行指令的真实功能,另外,发往内存的地址是真实的,对任何地址不加限制地发往内存。

访问内存 - 分段内存管理模式:内存地址由段寄存器左移4位,再加上一个通用寄存器中的值或者常数形成地址,然后由这个地址去访问内存。仅支持16位地址空间,对指令不加限制地运行,对内存没有保护隔离作用。

中断实现,内存中存放一个中断向量表,该表的地址和长度由CPU的特定寄存器IDTR指向,一个条目由代码段地址和段内偏移组成,当出现中断号后,CPU就根据IDTR寄存器中的信息,计算出中断向量中的条目,进而装载CS(代码段基地址)、IP(代码段内偏移)寄存器,响应中断。

保护模式

保护模式是为了应对更高的计算量和内存量,CPU寄存器扩展为32位宽,使其能够寻址32位内存地址空间和32位的数据,还实现指令区分和访问内存地址限制。

为了区分哪些指令(如in、out、cli)和哪些资源(如寄存器、IO端口、内存地址)可以被访问,实现了特权级。特权级分为4级,R0~R3,R0最大,可以指向所有指令,之后依次递减,下一层是上一层的子集。内存访问则靠段描述符和特权级相互配合实现。

访问内存时使用平坦模型:为了应对分段模型的缺陷,在分段模型的基础上套多一层,简化分段模型,对内存段与段之间的访问严格检查,没有权限不会放行。

中断时由于要进行权限检测和特权级切换,需要扩展中断向量表的信息,每个中断用一个中断门描述符来表示,存于中断向量表中

长模式

又名AMD64模式,在保护模式的基础上,增加一些通用寄存器,扩展通用寄存器的位宽,所有的通用寄存器都是64位,还可单独使用低32位。低32位可以拆分成一个低16位寄存器,低16位又可以拆分为两个8位寄存器。

访问内存时需要开启分页模式,弱化段模式管理,仅保留权限级别的检查,忽略段基址和段长度,地址检查交给MMU。

中断时的中断向量表中的条目,中断描述符需要扩展,将其32位段内偏移扩展成支持64位

1、x86 CPU的位数越来越高,从16到32到64,每次进步都尽量的去兼容了之前的CPU架构,所以: A、16位时寻址能力不足,所以要借助额外的寄存器进行1M空间的寻址;32位时,每个程序都有自己独立的4G寻址空间,操作系统用低位的1G-2G,其余留给用户程序;64位后,暂时就遇不到寻址能力不足的事情了; B、前一代的寄存器尽量保留,不够用就扩展新的 C、寄存器的长度升级后,其低位可以兼容上一代的寄存器

2、CPU同时在安全性上也要提升,从只有实模式【可以随意执行全部CPU指令,内存可以直接通过物理地址访问,随意访问随意读写】,到了32的保护模式【将指令划分为ring0到ring3,CPU指令不是你想调用就能调用;内存不是你想访问就能访问,首先CPU要允许,而且操作系统允许】,而64的长模式在安全方面与32并没有本至区别;

3、从实模式到保护模式,访问内存时,需要访问的地址变大了,需要控制的内容变多了,于是引入了段描述符,所有的段描述符组成了描述符表,包括唯一的全局描述符GDT和多个局部描述符号LDT。GDT是操作系统特供,要重点关注。CPU寻址的时候,要通过段寄存器+GDTR寄存器定位到的内存中的描述符,判断是否允许访问。然后,再根据段描述符中地址进行访问。

4、同时内存中内存管理有段、页、段页三种常用模式。一般在应用层,程序员感受不太到,操作系统全给咱们做完了。

5、中断,其实是通过硬件或软件方式告诉CPU,来执行一段特殊的代码。比如咱们键盘输入,就是通过硬件中断的方式,告知操作系统的。在实模式下,中断是直接执行的。但到了保护模式和长模式下,就要特权级别校验通过才能执行,所以引入了中断门进行控制。在ring3调用中断一般是要通过操作系统切换到内核态ring0进行的,与内存类似,要通过中断向量表,确认中断门中权限是否允许,然后定位到指定代码执行。

6、BIOS引导后,系统直接进入最简单、特权最大的实模式;而后告知CPU,切换到保护模式,并运行在ring0。后续的用户进程,一般就在ring3,想执行特权指令要通过操作系统来执行

内核态和用户态

对于32位操作系统来说,寻址空间为4G(2的32次方),即进程最大地址空间是4G。

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

操作系统将虚拟地址空间划分为两部分,一部分为内核空间,另一部分为用户空间。针对 Linux 操作系统而言,最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,称为内核空间。而较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间;内核态在0~4G范围的虚拟空间地址都可以操作,只是3~4G范围的虚拟空间地址必须由内核态去操作,所有进程的内核态都共享3~4G这个范围的数据。

之所以要分内核空间和用户空间,是因为有些CPU指令的权限比较大,可能导致系统崩溃,如果允许所有进程访问,容易出事,导致故障或崩溃,所以操作系统将CPU指令集分为两部分:特权指令和非特权指令,特权指令只允许操作系统及相关模块使用,非特权指令给普通应用程序使用。

Intenel的CPU将特权等级分为4个,Ring0~Ring3,Ring0权限最高,可以使用所有CPU指令集,Ring3权限最低,只能使用常规的CPU指令集,不能操作硬件资源的CPU指令集,比如IO读写、网卡访问、申请内存等操作;

Linux系统只用了Ring0和Ring3,Ring3不能访问Ring0的地址空间,当进程运行在Ring3级别时被称为运行在用户态,运行在Ring0级别被称为运行在内核态。

在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。

在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。

所以,即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行,区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性。

内核会对这些特权指令集封装成一个个接口,供用户空间的应用程序使用,比如读写磁盘,分配内存、回收内存、从网络接口读写数据等。进程在用户态和内核态各有一个堆栈,用来保存在对应空间执行的上下文。应用程序要读磁盘数据,1. 首先通过特殊指令,从用户态进入内核态,执行特权指令,2. 从磁盘上读取数据到内核空间中,3. 再把数据拷贝到用户空间,并从内核态切换到用户态。

总共有三种方式可以从用户态切换到内核态:系统调用system call、软硬中断、异常。

从用户态到内核态的切换开销比较大,主要是因为:

  • 保留用户态现场(上下文、寄存器、用户栈等)
  • 复制用户态参数,用户栈切到内核栈,进入内核态
  • 额外的检查(因为内核代码对用户不信任)
  • 执行内核态代码
  • 复制内核态代码执行结果,回到用户态
  • 恢复用户态现场(上下文、寄存器、用户栈等)

CPU上下文

Linux是一个多任务操作系统,可以支持远大于CPU数量的任务同时进行,通过在短时间内分配CPU给任务运行实现并行的目的,因此CPU需要知道每个任务从哪里加载,哪里开始运行,需要事先帮它们设置好CPU寄存器和程序计数器,因此CPU寄存器和程序计数器就算CPU上下文。

  • CPU寄存器:CPU内置的容量小,但速度快的内存
  • 程序计数器:存储CPU正在执行指令的位置,或者即将执行的下一条指令的位置

CPU上下文切换时,先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

CPU上下文的类型:

  • 进程上下文切换:

    发生系统调用时,进程从用户态进入内核态,这个过程发生上下文切换:

    1、保存 CPU 寄存器里原来用户态的指令位 2、为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。 3、跳转到内核态运行内核任务。 4、当系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。

    所以,一次系统调用过程,实际发生了两次CPU上下文切换,用户态 -> 内核态 -> 用户态,但系统调用过程中,不会涉及虚拟内存等进程用户态的资源,也不会切换进程,系统调用过程中一直是同一个进程在运行。

    系统调用 通常也称为 特权模式切换,系统调用属于同进程内的CPU上下文切换,而进程上下文切换指的是两个不同的进程间的切换。

    进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。

    进程的上下文切换就比系统调用时多了一步:在保存内核态资源(当前进程的内核状态和 CPU 寄存器)之前,需要先把该进程的用户态资源(虚拟内存、栈等)保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈,这个过程就比较耗资源了。

    发生进程上下文切换的场景:

    • CPU将时间片轮流分配给各个进程,当某个进程的时间片被耗尽,就会被系统挂起,切换到其他正在等待CPU执行的进程;
    • 进程在系统资源不足时进行等待,此时会被CPU挂起,并由系统调度其他进程运行;
    • 当进程通过sleep函数主动挂起,此时会重新调度;
    • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
    • 发生硬件中断时,CPU上的进程被中断挂起,转而执行内核中的中断服务程序;
  • 线程上下文切换:

    内核中的任务调度,实际上调度的是线程,而进程只是给线程提供了共享的虚拟内存,全局变量等资源,线程本身也有自己的栈和寄存器,在上下文切换时也要保存。

    切换场景:

    • 前后两个属于不同进程的线程,因为资源不共享,在切换过程就跟进程上下文切换一样;
    • 前后两个属于同一进程的线程,因为虚拟内存是共享的,在切换时,虚拟内存这些资源保存不动,只需切换线程的私有数据、寄存器等不共享的数据;
  • 中断上下文切换:

    为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。

    跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。

    对同一个 CPU 来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。

同步原语

常见的有:原子操作、控制中断、自旋锁、信号量。底层原子操作都是依靠支持原子操作的机器指令的硬件实现。

  • 原子操作是对单一变量。

  • 控制中断:可以作用复杂变量,一般用在单核CPU,同一时刻只有一个代码执行流,响应中断会导致代码执行流切换,此时如果两个代码执行流操作到同一个全局变量就会导致数据不一致,所以需要在操作全局变量时关闭中断,处理完开启进行控制。

  • 自旋锁还有一个作用就是可以在多核CPU下处理全局变量,因为控制中断只能控制本地CPU的中断,无法控制其他CPU核心的中断,此时其他CPU是可以操作到该全局变量的。自旋需要保证读取锁变量和判断并加锁操作是原子的,且会导致CPU空转一段时间。

  • 信号量的一个作用就是解决自旋锁空转造成CPU资源浪费的缺点。

Linux初始化

引导 boot

也是机器加电,BIOS自检,BIOS加载引导扇区,加载GRUB引导程序,最后由GRUB加载Linux内核镜像vmlinuz。

CPU被设计成只能运行内存中的程序,无法直接运行存储在硬盘或U盘中的操作系统程序,因为硬盘U盘等外部存储并不和CPU直接相连,访问机制和寻址方式与内存不一样。如果要运行硬盘或U盘中的程序,就必须先加载到内存。

由于内存RAM断电会丢失数据,BIOS不是存储在普通的内存中的,而是存储在主板上特定的一块ROM内存中,在硬件设计上规定加电瞬间,强制将CS寄存器的值设置位0xF000,IP寄存器的值设置为0xFFF0,该地址就是BIOS的启动地址了。

BIOS在初始化CPU和内存后进行检查,完了之后将自己的一部分复制到内存,最后跳转到内存中运行。之后枚举本地设备进行检查和初始化,调用设备的固件程序。当设备完成检查和初始化后,BIOS就会在内存中划定一个地址范围,存放和建立中断表和中断服务程序。

一般我们会把Linux镜像放在硬盘的第一个扇区中(MBR主启动记录,包含基本的GRUB启动程序和分区表),然后让硬盘作为BIOS启动后搜索到的第一个设备,当MBR被BIOS装载到0x7c00地址开始的内存空间中,BIOS会把控制权交给MBR,即GRUB。

GRUB所在的第一个扇区,只有512个字节,不足以装下GRUB这种大型的通用引导器,因此GRUB的加载被分成了多个步骤和多个文件,在启动时动态加载,先加载diskboot.img,然后读取剩下的core.img,识别出文件系统,使其能够访问/boot/grub目录,最后加载vmlinuz内核文件。

启动 startup

1.GRUB 加载 vmlinuz 文件之后,会把控制权交给 vmlinuz 文件的 setup.bin 的部分中 _start,它会设置好栈,清空 bss,设置好 setup_header 结构,调用 16 位 main ,调用BIOS中断,进行初始化设置,包括console、堆、CPU模式、内存、键盘、APM、显卡模式等,然后切换到保护模式,最后跳转到 1MB 处的 vmlinux.bin 文件中。

2.从 vmlinux.bin 文件中 startup32、startup64 函数开始建立新的全局段描述符表和 MMU 页表,切换到长模式下解压 vmlinux.bin.gz。释放出 vmlinux 文件之后,由解析 elf 格式的函数进行解析,释放 vmlinux 中的代码段和数据段到指定的内存。然后调用其中的 startup_64 函数,在这个函数的最后调用 Linux 内核的第一个 C 函数。 3.Linux 内核第一个 C 函数重新设置 MMU 页表,随后便调用 start_kernel 函数, start_kernel 函数中调用了大多数 Linux 内核功能性初始化函数,在最后调用 rest_init 函数建立了两个内核线程,在其中的 kernel_init 线程建立了第一个用户态进程

Linux初始化要点
Linux初始化要点

内存管理

内存划分与组织

分段

  1. 段的长度大小不一,可动态改变,不便于用一个统一的数据结构表示,容易产生内存碎片;
  2. 地址是二维的,一维是段号,二维是段内地址,每个段的长度不一样,且每个段内部都是从0开始编址;
  3. 在分段管理中,每个段内部是连续的内存分配,但是段与段之间是离散分配的,存在一个逻辑地址到物理地址的映射,即段表机制;
  4. 内存不足时,内存数据会写回硬盘来释放内存,由于段的长度大小不一,每段的读写时间就会不一样,导致性能抖动;

分段主要是为了程序和数据可以被划分为逻辑上独立的地址空间,有助于数据的共享和保护,比如数据共享、数据保护、动态链接等,需要程序员显式划分每个段。

分页

  1. 页的大小固定,可以用位图表示页的分配和释放,作为主存的基本单位;
  2. 分页的地址空间是一维的,分配的最小单位是页,页也会产生内存碎片,但是可以通过修改页表的方式,让连续的虚拟页面映射到非连续的物理页面,比如有1,2,3是空闲,4已被分配,5是空闲,可以通过映射让5接上3;
  3. 内存不足时,内存数据会写回硬盘来释放内存,由于每页大小一致,每页的读写时间也会一致;
  4. 因为程序数据存储在不同的页中,而页又离散分布在内存中,因此需要一个页表来记录映射关系,以实现页号到物理块号的映射,访问分页系统中的内存数据需要两次访问,第一次是从内存中访问页表,找到指定的物理块号,加上页的偏移量得到实际的物理地址;第二次是根据第一次中得到的物理地址访问内存取出数据;

所以现代操作系统基本都用分页模式管理内存,比如x86 CPU长模式下MMU 4KB的分页。

操作系统在对内存管理时,使用一个数据结构描述一个内存页,每个数据结构包含相邻页的指针,多个内存页形成一个链表。

分页主要用于实现虚拟内存,从而获得更大的地址空间, 提高内存利用率。

分页再分区

此外,可以将多个页划分为多个区,形成内存区,内存区只是一个逻辑概念,将内存划分方便管理,比如地址范围为多少多少的内存区域就给硬件用,多少多少的给内核区,多少多少给应用区等。同理,使用一个数据结构描述一个内存区,表示内存区的开始地址和结束地址,物理页的数量、已分配的物理页的数量、剩余数量等。

组织内存页

为了能高效的分配内存,需要把内存区数据结构和内存页面数据结构关联起来,组织成一个内存分割合并的数据结构。妈的,真难。

以2的N次方为页面数组织页面的原因:

1、内存对齐,提升CPU寻址速度 2、内存分配时,根据需求大小快速定位至少从哪一部分开始 3、内存分配时,并发加锁,分组可以提升效率 4、内存分配回收时,很多计算也更简单

页置换算法

由于程序是分多次装入内存的,运行到一定时间,在地址映射过程中如果发现要访问的页不存在内存中,会发生缺页中断,此时操作系统必须在内存里选择一个页把它移除内存,为即将调入的页面让出空间,而选择淘汰哪一页就需要页面置换算法。

常见的页面置换算法:

  • 最佳置换算法:将当前页中未来最长时间不会被访问的页置换出去
  • FIFO、LRU、LFU
  • 时钟算法(NRU,最近未使用算法):将页面链接成一个环形列表,每个页有一个访问位0/1,1表示有一次获救的机会。页面被访问时访问位设为1,下次循环指针指向它时可以免除此次置换,但会把访问位置为0,代表它下次如果碰到循环指针就该被置换了。

内存碎片

内部内存碎片:由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就会产生内部内存碎片,这通常难以避免;

外部内存碎片:由于某些未分配的连续内存区域太小,以至于不能满足任意进程的内存分配请求,从而不能被进程利用的内存区域;

现在普遍使用段页式内存分配方案,将内存分为不同的段,再将每一段分成固定大小的页,通过页表机制,使得段内的页可以不必连续处于同一内存区域。

虚拟地址

由于各个应用程序由不同的公司开发,且每台计算机的内存容量都不一样,如果应用程序在运行时使用的是真实地址,将会导致各个应用程序在使用内存地址时产生冲突。为了解耦内存真实地址和应用使用的内存地址,对不同的应用程序使用的内存进行隔离和保护,引出虚拟地址概念。

虚拟地址:让每个应用程序都各自享有一个从0开始到最大地址的空间,在每个应用程序独立且私有,其他应用程序无法观察到,再由操作系统将虚拟地址映射成真实的物理地址,实现内存的使用。

虚拟地址由链接器产生,应用程序经过编译后通过链接器的处理变成可执行文件,链接器会处理程序代码间的地址引用,形成程序运行的静态内存空间视图。

实模式下,多个进程共享所有地址空间太过危险,因此有了保护模式,保护模式下为了让各个应用程序能正确使用内存,使用了虚拟地址映射,让各个应用程序都有自己的地址空间,因此访问内存时不用考虑其他问题,访问时再经过MMU翻译得到对应的页表,不同的应用程序由于不同的页表,产生了进程地址空间隔离,但多个应用程序也可以共享某个页表,此时就涉及进程间的通信了。

关于内存对齐:因为cpu总是以多个字节的块为单位访问内存的,不与它的访问块地址边界对齐cpu就要多次访问。

虚拟内存有三个特征:多次性、对换性、虚拟性。它将程序分多次调入内存,使得在较小的用户空间可以执行较大的用户程序,所以同时容纳更多进程并发执行,从而提高系统吞吐量;发生缺页时取决于内存的存储管理方式,可以调入一个段,也可以调入一个页;虚拟性表示虚拟内存和物理内存的映射。

虚拟内存的核心原理是:为每个程序设置一段"连续"的虚拟地址空间,把这个地址空间分割成多个具有连续地址范围的页 (page),并把这些页和物理内存做映射,在程序运行期间动态映射到物理内存(通过MMU管理)。当程序引用到一段在物理内存的地址空间时,由硬件立刻执行必要的映射;而当程序引用到一段不在物理内存中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失败的指令。

在进程运行期间只分配映射当前使用到的内存,暂时不使用的数据则写回磁盘作为副本保存,需要用的时候再读入内存,动态地在磁盘和内存之间交换数据。

MMU

虚拟地址转换为物理地址通过MMU(内存管理单元)实现,只能在保护模式或者长模式下开启。

MMU使用分页模型,即把虚拟地址空间和物理地址空间都分成同等大小的页,按照虚拟页和物理页进行转换,一个虚拟地址对应一个物理地址,一个虚拟页对应一个物理页,页大小一经配置就是固定,通过内存中存储的一个地址关系转换表(页表)实现转换。

为了增加灵活性和节约物理内存空间(因为页表是放在物理内存中的),所以页表中并不存放虚拟地址和物理地址的对应关系,只存放物理页面的地址,MMU 以虚拟地址为索引去查表返回物理页面地址,而且页表是分级的,总体分为三个部分:一个顶级页目录,多个中级页目录,最后才是页表。

分层的结构是根据页大小决定。

为了解决进程通过MMU访问内存比直接访问内存多了一次内存访问,导致性能下降的问题,引入TLB快表进行加速。TLB可以简单理解成页表的高速缓存,保存了最高频次被访问的页表项,一般由硬件实现,速度极快。

Cache

程序局部性原理:程序一旦编译完装载进内存中,它的地址就确定了,CPU大多数时间在执行相同的指令或者相邻的指令。

内存,从逻辑上看,是一个巨大的字节数组,内存地址就是这个数组的下标。相比与CPU,内存的数据量吞吐是很慢的,当CPU需要内存数据时,内存一时间给不了,CPU就只能等,让总线插入等待时钟周期,直到内存准备好。因此内存才是决定系统整体性能的关键。为了不让CPU每次都要等,因此利用程序局部性原理,引入了Cache。

Cache主要由高速的静态存储器、地址转换模块和Cache行替换模块组成。

Cache会把自己的高速静态存储器和内存分成大小相同的行,一行大小通常为32字节或64字节。Cache和内存交换数据的最小单位是一行,多个行又会形成一组。除了存储数据,还有标志位,如脏位、回写位、访问位等。

1.CPU 发出的地址由 Cache 的地址转换模块分成 3 段:组号,行号,行内偏移。

2.Cache 会根据组号、行号查找高速静态储存器中对应的行。如果找到即命中,用行内偏移读取并返回数据给 CPU,否则就分配一个新行并访问内存,把内存中对应的数据加载到 Cache 行并返回给 CPU。写入操作则比较直接,分为回写和直通写,回写是写入对应的 Cache 行就结束了,直通写则是在写入 Cache 行的同时写入内存。

3.如果没有新行了,就要进入行替换逻辑,即找出一个 Cache 行写回内存,腾出空间,替换行有相关的算法,替换算法是为了让替换的代价最小化。例如,找出一个没有修改的 Cache 行,这样就不用把它其中的数据回写到内存中了,还有找出存在时间最久远的那个 Cache 行,因为它大概率不会再访问了。

以上这些逻辑都由 Cache 硬件独立实现,软件不用做任何工作,对软件是透明的。

Cache虽提升了性能,但会产生数据一致性问题。Cache可以是一个独立硬件,也可以集成在CPU中,Cache通常有多级,每一级间都有可能产生数据一致性问题。解决一致性问题就需要数据同步协议,如MESI和MOESI。

MESI:M(Modified修改)、E(Exclusive独占)、S(Shared共享)、I(Invalida无效)

最开始只有一个核读取了A数据,此时状态为E独占,数据是干净的, 后来另一个核又读取了A数据,此时状态为S共享,数据还是干净的, 接着其中一个核修改了数据A,此时会向其他核广播数据已被修改,让其他核的数据状态变为I失效,而本核的数据还没回写内存,状态则变为M已修改,等待后续刷新缓存后,数据变回E独占,其他核由于数据已失效,读数据A时需要重新从内存读到高速缓存,此时数据又共享了

Linux内存管理

伙伴系统

Linux使用分页机制管理物理内存系统,把物理内存分成4KB大小的页面进行管理,通过一个Page结构体表示一个物理内存页面。

因为硬件的限制,Linux把属性相同的物理内存页面,归结到一个区,不同的硬件,划分不同的区。在32位的x86平台中,将0~16MB划分位DMA区,高内存区适用于要访问的物理地址空间大于虚拟地址空间,Linux内核不能建立直接映射的情况。物理内存中剩余的页面就划分位常规内存区,64位的x86平台则没有高内存区。

在很多服务器和大型计算机上,如果物理内存是分布式的,由多个计算节点组成,那么每个 CPU 核都会有自己的本地内存,CPU 在访问它的本地内存的时候就比较快,访问其他 CPU 核内存的时候就比较慢,这种体系结构被称为 Non-Uniform Memory Access(NUMA)

在内存区的上层,通过定义节点pglist_data,来包含多个区

连续且相同大小的Page组成伙伴,Linux在分配物理内存页面时,首先找到内存节点,接着找到内存区,然后是合适的空闲链表,最后在其中找到页的Page结构,完成物理内存页面的分配。

内存压缩不是指压缩内存中的数据,而是指移动内存页面,进行内存碎片整理,腾出更大的连续的内存空间。如果内存碎片整理了,还是不能成功分配内存,就要杀死进程以便释放更多内存页面

分配算法:关键字:快速分配路径、慢速分配路径,只有在快速分配路径失败之后,才会进入慢速分配路径,慢速分配路径中会进行内存回收相关的工作。最后,通过 expand 函数分割伙伴,完成页面分配。

SLAB

Linux使用SLAB来分配比页更小的内存对象。

SLAB分配器会把一个内存页面或者一组连续的内存页面,划分成大小相同的块,每一小块内存就是SLAB对象,在这一组连续的内存页面中除了SLAB对象,还有SLAB管理头和着色区。

着色区是一块动态的内存块,建立SLAB时才会设置它的大小,目的是为了错开不同的SLAB对象地址,降低硬件Cache行中地址的争用,避免抖动产生的性能下降。

Linux使用kmen_cache结构管理page对应内存页面上的小块内存对象,然后让该 page 指向 kmem_cache,由 kmem_cache_node 结构管理多个 page。

线程、进程、协程

  • 进程:可以简单理解为一个应用程序,进程是资源分配的基本单位。比如一个进程拥有自己的堆、栈、虚存空间、文件描述符等。

    涉及到用户态和内核态的切换。

    进程间的通信

    • 匿名管道:半双工,数据只能向一个方向流动,双方需要通信时,需要建立起两个管道;且只能用于有亲缘关系的进程;本质是一个内核缓冲区,可以看成是内存中的文件,但不属于某种文件系统,无需显示打开,创建时直接返回文件描述符,读写时需要确定对方的存在,否则将退出;以先进先出的方式存取数据,通信的双方需制定好数据的格式;
    • 有名管道:主要解决匿名管道只能作用与有亲缘关系的进程的问题,通过一个路径名关联,以文件形式存在于文件系统中,即使没有亲缘关系的进程也能通过访问路径实现通信;管道名字存在于文件系统中,内容存在内存中;打开时就得确定对方是否存在,否则将阻塞;
    • 信号:操作系统提供的一种机制,可以在任何时候发给某一进程,而无需指定该进程的状态,如果该进程当前处于未执行状态,该信号就由内核保存起来,直到进程回复执行并传递为止;信号接收可以被阻塞,直到阻塞解除;本质是对中断机制的模拟,异步通信,在用户态和内核态之间交互;能携带的信息较少。
    • 消息队列:存放在内核中的消息链表,只有在内核重启或显示地删除时,才会被真正的删除,与管道不同的是消息队列不需要确定接收进程是否存在;一般是FIFO,但也可以实现成随机查询;对消息格式,缓冲区大小等都能进行控制,比管道灵活;
    • 共享内存:没什么好说的,只是在访问共享内存时要依靠一些同步或互斥机制保证并发访问安全;
    • 信号量:计数器,一般用于多进程对共享内存访问的保护,内核中实现,保证原子操作
    • 套接字:通信机制,可用在本机或者跨网络,由域、端口号、协议类型三个属性确定;域分为AF_INET,即网络,另一个是AF_UNIX,即文件系统
  • 线程:线程是独立调度的基本单位,由CPU进行调度和执行的实体。一个进程中可以有多个线程,线程之间共享进程资源,是进程中的实际运作单位。

    涉及到用户态和内核态的切换。

  • 协程:GoLang中的协程

    • 在用户态层面,由线程控制,即用户应用层面自己控制,很难像抢占式调度那样强制CPU切换到其他线程/进程,只能是协作式调度,但同时也避免了上下文切换
    • 内存消耗比线程小,比如go开启协程是几kb,java开启一个线程至少1MB
    • 实现原理:在一个运行的线程上,起多个协程,每个协程会加入到调度队列中,线程会从调度队列里取出协程进行运行。队列个数默认取决于CPU的个数,协程间的切换会线程使用go提供的io函数进行控制。当有协程执行较慢时,会先将其挂起,然后唤醒其他线程,将未处理的协程队列转移到该线程,消费队列里的协程,当队列消费完成后,再切回原来的线程,继续执行刚刚挂起的协程。

参考:图解Go协程调度原理,小白都能理解

Golang 的 goroutine 是如何实现的?

进程与线程的区别

  1. 拥有资源

    进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。

  2. 调度

    线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。

  3. 系统开销

    由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。

  4. 通信方面

    线程间可以通过直接读写同一进程中的数据进行通信,在java中如使用共享变量、wait/notify机制、阻塞队列;但是进程通信需要借助管道、消息队列、共享内存、信号量、信号、套接字socket

上下文切换的开销

当CPU从执行一个线程切换到执行另外一个线程的时候,它需要先存储当前线程的本地的数据,程序指针等,然后载入另一个线程的本地数据,程序指针等,最后才开始执行。这种切换称为“上下文切换”(“context switch”)。CPU会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另外一个线程。

线程与协程的区别

  1. 一个线程可以有多个协程,一个进程也可以有多个协程
  2. 协程不被操作系统内核管理,完全由程序控制;线程是被分割的CPU资源,协程是组织好的程序内的代码,协程不会直接使用线程,协程直接利用的是执行器关联任意线程或线程池
  3. 协程能保留上一次调用时的状态

协程本质上可以看作是可挂起和恢复的用户态轻量级线程,可以在没有多线程环境下模拟并发,也可以在多线程环境下代替系统线程降低切换消息,支持更多并发。

操作系统最小的执行单位是线程,进程是线程的容器,操作系统是进程的容器。

调度方式

  • 非抢占式:系统一旦开始执行某一进程,就会让该线程就会一直执行下去,直至完成,或者发生了其他事件导致系统放弃对该进程的执行后,才会去执行另外一个进程。
  • 抢占式:系统执行某一进程,在其执行期间,系统可以立即停止当前进程,转而执行另外一个进程,待处理完后,重新回来继续执行之前停止的进程。

并发与并行

  • 并发:多个任务被处理,但同一时刻,只有一个任务在执行。单核处理器也可以做到并发,比如CPU时间分片轮转执行。
  • 并行:多个任务在同一时刻同时执行,需要多核处理器才能完成。

调度模型

用户空间线程和内核空间线程之间的映射关系

N:1模型:多个用户空间线程在1个内核空间线程上运行。优势是上下文切换非常快,因为这些线程都在内核态运行,但是无法利用多核系统的优点。

1:1模型:1个内核空间线程运行一个用户空间线程。这种充分利用了多核系统的优势但是上下文切换非常慢,因为每一次调度都会在用户态和内核态之间切换。POSIX线程模型(pthread)就是这么做的。

M:N模型:内核空间开启多个内核线程,一个内核空间线程对应多个用户空间线程。效率非常高,但是管理复杂。

进程

进程是一个应用程序运行时刻的实例,是应用程序运行时所需资源的容器,是一堆数据结构。操作系统记录这个应用程序使用了多少内存,打开什么文件,控制资源不可用时进行睡眠,进程运行到哪了等等这些统计信息,放到内存中,抽象成进程。

进程为了让操作系统管理,需要有一个地址空间,该地址空间至少包含两部分内容:内核和用户的应用程序。

当 CPU 在 R0 特权级运行时,就运行在内核的地址空间中,当 CPU 在 R3 特权级时,就运行在应用程序地址空间中。各进程的虚拟地址空间是相同的,它们之间物理地址不同,是由 MMU 页表进行隔离的,所以每个进程的应用程序的代码绝对不能随意访问内核的代码和数据。

每个进程都有一个内核栈,指向同一个块内核内存区域,共享一份内核代码和内核数据。内核进程使用一份页表,用户进程两份页表,用户进程多了一份用户空间页表,与其它用户进程互不干扰。

由于应用程序需要内核提供资源,而内核需要控制应用程序的运行,让其能随时中断或恢复执行,因此需要保存应用程序的机器上下文和它运行时刻的栈。使用内核的功能时,会先停止应用程序代码的运行,进入内核地址空间运行内核代码,然后返回结果。

进程的机器上下文分为几个部分,一部分是 CPU 寄存器,一部分是内核函数调用路径。CPU 的通用寄存器,是中断发生进入内核时,压入内核栈中的,从中断入口处开始调用的函数,都是属于内核的函数。函数的调用路径就在内核栈中,整个过程是这样的:进程调度器函数会调用进程切换函数,完成切换进程这个操作,而在进程切换函数中会保存栈寄存器的值。

建立进程,其实就是创建进程结构体,分配进程的内核栈于应用程序栈,并对进程的内核栈进行初始化,最后将进程加入调度系统,以便投入运行。

调度

CPU同一时刻只能运行一个进程,当一个进程不能获取某种资源导致它不能继续运行时,就应该让出CPU,因此面对多进程就需要一个合理的调度。

进程的等待和唤醒可以通过信号量实现。

每一个CPU都有一个空转进程,作为进程调度器最后的选择。空转进程会调用进程调度函数,在系统没有其他进程可以运行时又会调用空转进程,形成闭环。

Linux进程调度

Linux进程调度支持多种调度算法:基于优先级的调度算法、实时调度算法、完全公平调度算法CFQ;也支持多种不同的进程调度器:RT调度器、Deadline调度器、CFS调度器、Idle调度器。

进程优先级越高,占有CPU时间越长;调度优先级越高,调度系统选中它的概率就越大。

Linux的CFS调度器没有时间片概念,它是直接分配CPU使用时间的比例。CFS会按权重比例分配CPU时间,权重表示进程优先级,进程时间 = CPU总时间 * 进程权重 / 就绪队列所有进程权重之和。对外接口提供一个nice值,范围为-20~19,数值越小,权重越大,每降低一个nice值,就能多获得10%的CPU时间。

设备I/O

操作系统本身会对设备进行分类,定义一套对应设备的操作接口,由设备的驱动程序实现,而驱动程序实现对应设备的操作,从而达到操作系统与设备解耦的目的。

设备和设备驱动的信息会抽象成对应的数据结构,再通过设备表结构组织驱动和设备的数据结构。

总线是设备的基础,表示CPU与设备的连接,总线本身也是一个数据结构。

一个驱动程序开始是由内核加载运行,然后调用由内核提供的接口建立设备,最后向内核注册设备和驱动,完成驱动和内核的握手动作。

内核要求设备执行某项动作,就会调用设备的驱动程序函数,把相关参数传递给设备的驱动程序,由于参数很多,各种操作所需的参数又不相同,就会把这些需要的参数封装在一个数据结构中,称为I/O包。

在Linux中,各种设备时一个个文件,只是并不对应磁盘上的数据文件,而是对应着存在内存当中的设备文件,对这些文件的操作等同于对设备的操作。在目录 /sys/bus目录下能看到所有总线下的所有设备,总线目录下有设备目录,设备目录下是设备文件。

文件系统

文件系统是操作系统为了兼容不同的物理存储设备而存在。文件系统会把文件数据定义成一个动态的线性字节数组,将一段范围的字节数组整合成一个文件数据逻辑块,再映射成对应存储设备的物理存储块,从而解决不同物理存储设备有不同的物理存储块的问题。即 线性字节数组 -> 文件数据逻辑存储块 -> 物理存储块

可以通过位图来标识哪些逻辑存储块是空闲,哪些是已被分配占用。

通过一个包含文件系统标识、版本、状态、存储介质大小、文件系统逻辑存储块大小、位图所在存储块、根目录等信息的数据结构,作为文件系统的超级块或文件系统描述块。

文件系统的格式化,指的是在存储设备上重建文件系统的数据结构,一般是先设置文件系统的超级快、位图、根目录,三者是强顺序的。

Linux使用VFS虚拟文件系统作为中间层,抽象了文件系统共有的数据结构和操作函数集合,一个物理存储设备的驱动只要实现了这些函数就可以插入VFS中,从而可以同时支持各种不同的文件系统。

VFS包含四大数据结构:超级块结构super_block、目录结构dentry、文件索引节点结构inode、进程打开文件实例结构file。Liunx 挂载文件系统时,会读取文件系统超级块,从超级块出发读取并构造全部目录结构,目录结构指向存储设备文件时,是一个个文件索引节点结构。

网络

在Linux中,替代传输层以上协议实体的标准接口,称为套接字,负责实现传输层以上的所有功能,即套接字是一套通用的网络API,下层由多个协议栈实现。

网络IO模型

I / O操作通常包含以下两个步骤:

  1. 等待网络数据到达网卡(读就绪) / 等待网卡可写(写就绪) –> 读取/写入到内核缓冲区

  2. 从内核缓冲区复制数据 –> 用户空间(读) / 从用户空间复制数据 -> 内核缓冲区(写)

判断一个I/O模型是同步还是异步,主要看第二步在复制数据时是否会阻塞当前进程,即CPU能不能去执行其他线程。

Unix网络中的5种 I/O 模型:

  • 同步阻塞 I / O:一次IO,内数据没有准备好时,调用方法会阻塞等待,直至完成;

  • 同步非阻塞 I / O:一次IO内,数据没有准备好时不断间隔轮询,得到数据准备好的结果后拷贝到用户态,在轮询的间隔期可执行其他操作;

  • I / O多路复用:一个管道阻塞监听所有IO,内核数据是否准备好,事件驱动,监听到数据准备好后拷贝数据到用户态,如select、poll、epoll;

    I / O多路复用实际上就是在非阻塞I/O的基础上,增加一个线程同时监听多个文件描述符(I/O事件)阻塞等待,并在某个文件描述符可读写时收到通知,I / O复用并不是复用IO连接,而是复用线程,让一个线程可以同时处理多个连接(I/O事件)

  • 同步信号驱动 I / O:注册回调函数,内核数据准备好后进行回调,在此期间系统可以做其他事情,数据准备阶段是异步,数据拷贝阶段是同步;

  • 异步 I / O:真正的异步,数据的准备和拷贝都是自动完成;

I/O多路复用

select

特点:

  • 可监控的文件描述符个数取决于 sizeof(fd_set) 的值,默认是1024。假设服务器上 sizeof(fd_set)=512,每 bit 表示一个文件描述符,则服务器上支持的最大文件描述符是 512*8=4096。
  • 将 fd 加入 select 监控集的同时,还要再使用一个数据结构 array 保存放到 select 监控集中的 fd,一是用于在 select 返回后,array 作为源数据和 fd_set 进行 FD_ISSET 判断,二是 select 返回后会把以前加入的但并无事件发生的 fd 清空,则每次开始 select 前都要重新从 array 取得 fd 逐一加入(FD_ZERO 最先),扫描 array 的同时取得 fd 最大值 maxfd,用于 select 的第一个参数。
  • 所以select 模型必须在 select 前循环 array(加 fd,取 maxfd),select 返回后循环 array(FD_ISSET 判断是否有事件发生)

缺点:

  • 最大并发数限制:使用 32 个整数的 32 位,即 32*32=1024 来标识 fd;
  • 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
  • 性能衰减严重:每次 kernel 都需要线性扫描整个 fd_set,所以随着监控的描述符 fd 数量增长,其 I/O 性能会线性下降;

poll

  • poll 的实现和 select 非常相似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而不是 select 的 fd_set 结构;
  • poll 解决了最大文件描述符数量限制的问题,但是同样需要从用户态拷贝所有的 fd 到内核态,也需要线性遍历所有的 fd 集合,所以它和 select 只是实现细节上的区分,并没有本质上的区别。

epoll

虽然原理差不多,但select&poll 错误预估了一件事,当数十万并发连接存在时,可能每一毫秒只有数百个活跃的连接,同时其余数十万连接在这一毫秒是非活跃的。当进程认为需要找出有报文到达的活跃连接时,就会调用select&poll,所以select&poll在高并发时会被频繁调用,如果全部待监控的连接数很多,但活跃连接很少,就会有效率问题了。

epoll优化了select性能开销很大,文件描述符数量少的问题。

实现上,epoll分清了高频和低频调用,内部存储分为两种:一个是监听列表,使用红黑树存储所有监听的fd,每个插入或删除的fd性能稳定,插入时会为该fd注册一个回调函数。一个是就绪列表,使用链表存储,阻塞监听。当fd就绪时,就会触发回调函数由内核调用,把就绪的fd放到一个就绪链表里,epoll会去检查这个链表,保证每次拿到的都是可用的fd,所以epoll不需要把全部监听的fd集合都从用户态拷贝到内核态,只需要拷贝需要用的fd,保证了每个fd在其生命周期只拷贝一次。

select&poll 调用时会将全部监听的 fd 从用户态空间拷贝至内核态空间并线性扫描一遍找出就绪的 fd 再返回到用户态,epoll 内的实现只会直接返回已就绪 fd,因此 epoll 的 I/O 性能不会像 select&poll 那样随着监听的 fd 数量增加而出现线性衰减,实现高效。

零拷贝

传统的Linux的标准IO接口基于数据拷贝操作,IO操作会导致数据在操作系统内核地址空间的缓冲区和用户进程地址空间定义的缓冲区之间传输。设置缓冲区最大的好处是可以减少磁盘 I/O 的操作,而传统的 Linux I/O 在数据传输过程中深度依赖 CPU去执行数据拷贝的操作,系统开销极大,限制了操作系统有效进行数据传输操作的能力。

Linux中传统的IO读写通过read()/write()系统调用完成,read()把数据从存储器(磁盘、网卡)等读取到用户缓冲区,write()把数据从用户缓冲区写到存储器。一次读取磁盘文件,到写入网卡,中间一共触发了4次用户态和内核态的上下文切换,分别是read()/write()调用和返回时的切换,2次DMA拷贝(硬件与内核态之间)、2次CPU拷贝(用户态与内核态之间)。

零拷贝就是指在执行IO操作时,CPU不需要先将数据从某处内存复制到另一个特地区域,一般通过网络传输文件时节省CPU周期和内存带宽。

作用:

  • 减少甚至完全避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作,从而减少用户态 – 内核态上下文切换带来的系统开销。
  • 减少甚至完全避免操作系统内核缓冲区之间进行数据拷贝操作。
  • 帮助用户进程绕开操作系统内核空间直接访问硬件存储接口操作数据。
  • 利用 DMA 而非 CPU 来完成硬件接口和内核缓冲区之间的数据拷贝,从而解放 CPU,使之能去执行其他的任务,提升系统性能。

实现方式:

  • 减少甚至避免用户空间和内核空间之间的数据拷贝:在一些场景下,用户进程在数据传输过程中并不需要对数据进行访问和处理,那么数据在 Linux 的 Page Cache 和用户进程的缓冲区之间的传输就完全可以避免,让数据拷贝完全在内核里进行,甚至可以通过更巧妙的方式避免在内核里的数据拷贝。这一类实现一般是通过增加新的系统调用来完成的,比如 Linux 中的 mmap()sendfile() 以及 splice() 等。
  • 绕过内核的直接 I/O:允许在用户态进程绕过内核直接和硬件进行数据传输,内核在传输过程中只负责一些管理和辅助的工作。这种方式其实和第一种有点类似,也是试图避免用户空间和内核空间之间的数据传输,只是第一种方式是把数据传输过程放在内核态完成,而这种方式则是直接绕过内核和硬件通信,效果类似但原理完全不同。
  • 内核缓冲区和用户缓冲区之间的传输优化:这种方式侧重于在用户进程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种方法延续了以往那种传统的通信方式,但更灵活。

参考

极客时间 - 操作系统实战45讲

Linux系统中,为什么需要区分内核空间与用户空间?

极客时间 - Linux性能优化实战

Linux IO原理和零拷贝技术全面解析

Unix网络编程的5种I/O模型

Unix的五种IO模型介绍

Licensed under CC BY-NC-SA 4.0
Last updated on May 03, 2020 00:00 UTC
Built with Hugo
Theme Stack designed by Jimmy