Unix

什么叫一切皆文件

这个是 Unix 的一个核心哲学,我给你拆开讲:

  1. “一切皆文件”是什么意思

在 Unix 系统里,几乎所有东西都用“文件”这种统一接口来访问

  • 普通文件:磁盘上的文本、二进制文件
  • 目录:其实是存储文件名到 inode 的映射表
  • 设备:键盘、显示器、硬盘、网卡都被抽象成“特殊文件”,放在 /dev
  • 管道、FIFO、Socket:进程间通信对象,也以文件描述符的方式访问
  • 甚至内核接口:比如 /proc/sys 里的伪文件,用来读取系统信息

所以用户态只需要 open() / read() / write() / close() 这一套 API,就能操作各种资源,而不用关心底层差异。

  1. 为什么要这样设计

核心是抽象统一性

  1. 简化接口:程序员不需要学习 N 套 API,读写磁盘和读写串口本质上没区别。
  2. 提高可移植性:应用只依赖标准文件 API,不管硬件怎么变,内核保证兼容。
  3. 增强组合性:因为统一成“文件”,就能用管道把命令组合起来(ls | grep txt | wc -l),这就是 Unix 强大的“工具拼装哲学”。
  4. 一个例子 🌰
  • 打开一个磁盘文件:fd = open("data.txt", O_RDONLY);
  • 打开一个终端设备:fd = open("/dev/tty", O_RDONLY);
  • 打开一个 TCP 连接(本质也是 socket 文件描述符):fd = socket(...);

对进程来说,拿到的都是 fd(文件描述符),之后都用 read(fd, buf, size),完全不用关心底层是磁盘、键盘还是网卡。

✅ 所以,“一切皆文件”不是说硬件真的变成了文件,而是 内核把所有资源抽象成统一的文件接口,让用户进程通过相同的方式访问

文件描述符

  • 文件描述符就是一个非负整数,是进程访问文件对象的“索引”。
  • 每个进程都有一个 文件描述符表(在 PCB 中),里面存放着“文件描述符 → 内核文件对象”的映射关系。

它相当于 进程与内核中文件对象之间的“句柄”,主要作用有:

  • 标识已打开的文件
    当进程 open() 一个文件时,内核会创建一个 file 结构体表示这个文件对象,并返回一个文件描述符(整数)。
    之后进程只要传递这个整数,就能继续读写该文件。
  • 屏蔽底层差异
    无论是磁盘文件、Socket、管道还是设备,用户态程序都只需要用 FD 操作,不必关心底层细节。
  • 资源管理
    内核通过 FD 表跟踪哪些文件被打开,何时关闭,避免资源泄露。

当进程 open("a.txt") 时,内核会在内存中创建一个 文件对象(struct file)

这个对象包含:

  • 文件的偏移量(读写指针)
  • 访问模式(读/写/追加)
  • 对应的 inode 指针(记录文件在磁盘上的元数据:权限、大小、数据块位置)

Linux 和 Unix

Unix 最早是 1969 年 AT&T 贝尔实验室研发的操作系统,它有几个特点:

  • 多用户、多任务
  • 使用 C 语言实现(可移植性强)
  • “一切皆文件”的设计哲学

Linux 出现于 1991 年,Linus Torvalds 编写的内核,它是 类 Unix (Unix-like) 系统:

  • 借鉴了 Unix 的设计思想和接口(比如 POSIX API)
  • 但代码完全重写,不是 Unix 的直接分支

所以,Linux 不是严格意义的 Unix,而是“模仿 Unix 的自由实现”。

内核空间

祛魅

所谓内核本质上就是权限更高的 C 语言程序,替代我们去负责底层的功能,系统调用就是 C 语言暴露出来的一个函数而已,所以不要把内核想的很抽象。进程之间通信的信号量不过是 C 语言的一个 int 变量,管道只不过是 C 语言的一个对象(环形缓冲区),消息队列不过是一个链表。这些听起来很高级的词汇底层不过还是我们学过的哪些数据结构实现的。

操作系统和内核区别

首先明确一个概念:操作系统 ≠ 内核

  • 内核 (Kernel)

  • 操作系统的核心部分

  • 负责最底层的功能:进程调度、内存管理、文件系统、设备驱动、中断处理、系统调用接口
  • 可以理解为“硬件和软件之间的桥梁”

  • 操作系统 (Operating System, OS)

  • 不仅包含内核,还包括:

  • 用户态的系统工具(shell、命令行、系统服务)

  • 系统库(glibc、Win32 API)
  • 配置文件、守护进程

  • 内核是 OS 的核心,但不是全部

img

其中的非直接缓冲区(JVM)就是在用户空间中,内核缓冲区(OS)就是在内核空间上。

内核空间是操作系统内核的专用内存区域,用于存储内核代码、数据结构和运行内核级别的系统调用。内核空间具有较高的权限级别,能够直接访问硬件资源和底层系统服务。一般来说,内核空间是受到严格保护的,用户级别的程序不能直接访问内核空间,以确保操作系统的稳定性和安全性。

用户空间是为用户级别的应用程序和服务分配的内存区域。它包含了应用程序的代码、数据和运行时堆栈。用户空间与内核空间相对隔离,具有较低的权限级别,不能直接访问内核空间或硬件资源。应用程序需要通过系统调用与内核空间进行交互,请求操作系统提供的服务。

内核空间和用户空间的划分有助于操作系统实现内存保护和权限控制,确保系统运行的稳定性和安全性。当用户程序需要访问系统资源或执行特权操作时,它需要通过系统调用切换到内核空间,由内核代理执行相应的操作。这种设计可以防止恶意或错误的用户程序直接访问内核空间,从而破坏系统的稳定性和安全性。同时,这种划分也提高了操作系统的可扩展性,因为内核空间和用户空间可以独立地进行扩展和优化。

中断

为了避免由于中断处理程序执行时间过长,而影响正常进程的调度,Linux 将中断处理程序分为上半部和下半部:

  • 上半部,对应硬中断,由硬件触发中断,用来快速处理中断;
  • 下半部,对应软中断,由内核触发中断,在内核线程执行,用来异步处理上半部未完成的工作;

Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型,可以通过查看 /proc/softirqs 来观察软中断的累计中断次数情况,如果要实时查看中断次数的变化率,可以使用 watch -d cat /proc/softirqs 命令。

每一个 CPU 都有各自的软中断内核线程,我们还可以用 ps 命令来查看内核线程,一般名字在中括号里面到,都认为是内核线程。

内存管理

虚拟内存

虚拟内存的作用

如果没有虚拟内存,所有应用程序都需要直接和物理内存打交道,但计算机会同时运行多个应用程序,每个应用程序不知道其他应用程序使用的内存地址,这就导致如果两个程序使用了同一个内存地址,就会造成写覆盖的问题,这也是为什么单片机只能运行一个程序的原因。

操作系统引入虚拟内存以后,应用程序就不需要知道其他程序使用什么内存,只管好自己就行了,虚拟地址到物理地址的转换完全交给操作系统来完成。进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存。

img

虚拟内存的作用:

  • 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
  • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
  • 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。

img

操作系统分配内存的过程

img

OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。

哪些内存可以被回收?

主要有两类内存可以被回收,而且它们的回收方式也不同。

  • 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存
  • 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:

  • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
  • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

内存分段

程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

通过下面两张图可以清晰了解段式内存管理方案。

img

img

分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

  • 第一个就是内存碎片的问题。
  • 第二个就是内存交换的效率低的问题。

下面这张图说明了段式内存管理如何导致外部碎片。

img

解决「外部内存碎片」的问题就是内存交换

可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。

这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。

但因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。

内存分页

简单分页

分段的好处就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的空间太大」的问题。

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫Page)。在 Linux 下,每一页的大小为 4KB。虚拟地址与物理地址之间通过页表来映射,如下图:

img

页表是存储在内存里的,内存管理单元MMU)就做将虚拟内存地址转换成物理地址的工作。

而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?

每个段在物理内存中是连续存放的,这也是造成外部碎片的原因,页式管理把内存空间分割成固定大小的页,页与页之间在物理内存中不要求连续,可以分散到任意空闲页框中,所以才提升了内存利用率。

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出Swap Out)。一旦需要的时候,再加载进来,称为换入Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

img

更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

img

简单的分页有什么缺陷吗?

页表占用的空间很大。

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

多级页表

套娃, 再加一层页表来映射二级页表,这样就不需要一次性把二级页表全部加载进来,只需要加载一级页表即可,使用到哪个二级页表再延迟加载。

对于 64 位的系统,通常是四级目录,分别是:

  • 全局页目录项 PGD(Page Global Directory);

  • 上层页目录项 PUD(Page Upper Directory);

  • 中间页目录项 PMD(Page Middle Directory);

  • 页表项 PTE(Page Table Entry);

img

TLB

通常称为页表缓存、转址旁路缓存、快表等。把最常访问的几个页表项存储到访问速度更快的硬件,记录虚拟地址与物理地址的映射关系,加快转换

段页式管理

内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理

img

Linux 内存布局

从硬件上讲,x86 是段页式管理,但 Linux 为了简化管理,把所有段都设置成整个地址空间,所以实际上是用的纯页式管理。

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:

img

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

img

用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:

img

  • 代码段,包括二进制可执行代码;
  • 数据段,包括已初始化的静态常量和全局变量;
  • BSS 段,包括未初始化的静态变量和全局变量;
  • 堆段,包括动态分配的内存,从低地址开始向上增长;
  • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关(opens new window));
  • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

上图中的内存布局可以看到,代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。

在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。

这里的段是逻辑上的段,物理上依旧是页式内存管理。

预读机制

什么是预读机制?

Linux 操作系统为基于 Page Cache 的读缓存机制提供预读机制,一个例子是:

  • 应用程序只想读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。
  • 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page;

预读失效会带来什么问题?

如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效

如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。

如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率

如何避免预读失效造成的影响?

我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的。

要避免预读失效带来影响,最好就是让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长

Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下:

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
  • MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

这两个改进方式,设计思想都是类似的,都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法。不再像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理。

接下来,具体聊聊 Linux 和 MySQL 是如何避免预读失效带来的影响?

Linux 是如何避免预读失效带来的影响?

Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)

  • active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
  • inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。

MySQL 是如何避免预读失效带来的影响?

young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图:

img

young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37(默认比例)的关系。

划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。

缓存污染

什么是缓存污染?

虽然 Linux (实现两个 LRU 链表)和 MySQL (划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响。

但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题

我以 MySQL 举例子,Linux 发生缓存污染的现象也是类似。

当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。

怎么避免缓存污染造成的影响?

只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉

Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断

  • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;

  • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;

malloc 是如何分配内存的?

malloc() 源码里默认定义了一个阈值:

  • 方式一:如果用户分配的内存小于 128 KB,则通过 brk() 系统调用从堆分配内存;
  • 方式二:如果用户分配的内存大于 128 KB,则通过 mmap() 系统调用在文件映射区域分配内存;

img

img

malloc(1) 会分配多大的虚拟内存?

malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池

4.2 malloc 是如何分配内存的?

free 释放内存,会归还给操作系统吗?

  • 如果 malloc 通过 brk() 方式申请的内存的情况:通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统。这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。
  • 如果 malloc 通过 mmap 方式申请的内存:free 释放内存后就会归归还给操作系统。

为什么不全部使用 mmap 来分配内存?

mmap 灵活干净但开销大,brk 管理堆速度快但对大块和碎片敏感。malloc 混着用,让小内存分配享受 brk 的速度红利,让大内存分配享受 mmap 的独立与干净,各取所长,才能在各种内存分配需求下都交出比较均衡、高效的答卷。

要是全用 mmap,小内存分配的频繁开销会让程序慢得怀疑人生;要是全用 brk,遇上大内存或者长期运行产生大量碎片,程序可能就“卡死”在明明有内存却分配不出来的尴尬境地。所以,这个组合拳,打得有理!

更详细的解释

咱们来聊聊 malloc 为啥不一股脑儿全用 mmap 来分内存,非得搞个 brk + mmap 的组合拳。这事儿吧,说白了就是性能和资源管理上的一种权衡,没有一招鲜吃遍天的好事儿。

你想啊,mmap 确实挺酷的,每次都能从操作系统那儿划拉一块全新的、独立的虚拟内存区域给你,用完了直接 munmap 还回去,干干净净,碎片问题也少。但问题就在于,这“酷”是有代价的!每次调用 mmap,都得劳烦操作系统内核跑一趟,做一大堆事情:找个没人用的地址空间、设置好页表项、可能还要清空内存页(确保安全)、更新内核数据结构…… 这一套流程下来,开销可比在用户态捣鼓点指针大多了。要是你程序里动不动就分配释放一堆小块儿内存(比如链表节点、小对象啥的),每次都来这么一趟 mmap/munmap,那性能可就真得慢得掉渣了,系统调用本身、TLB(快表)刷新的开销都能把你拖垮。

这时候 brk 的价值就体现出来了。它本质上是挪动一个叫“program break”的指针,把进程堆区的尾巴伸长或者缩短。分配小块内存时,malloc 在用户空间自己管理堆区这块地盘就行了。它预先通过 brk 扩大堆区(比如一次申请一大块),然后在这块连续的内存里,像切豆腐一样,根据你的请求切出合适的小块给你。释放的时候呢,也不是立刻还给操作系统,而是记录起来(放进空闲链表之类的结构),等下次有人再要小块内存时直接复用。只有当堆顶一大块连续内存都空闲了,malloc 才可能用 brk 把尾巴缩回去,把内存真正还给系统。这么搞,好处太明显了:对于大量、频繁的小内存申请释放,绝大部分操作都在用户态搞定,速度快得飞起,系统调用的开销被摊得非常薄。碎片问题虽然存在,但 malloc 自己会努力合并相邻的空闲块来缓解。

当然,brk 也不是万金油。堆区是连续的,要是中间被零零碎碎的小块占着,即使总空闲空间够,也可能找不到一块连续的大空间来满足大内存申请(这就是内部碎片)。而且,堆区理论上只能向一个方向长(通常向上),管理起来没那么灵活。

所以,malloc 的智慧就在于“看人下菜碟”:

  1. 小内存、频繁请求:主要靠 brk 管理的堆区。用户态搞定,速度快如闪电。
  2. 大内存(通常超过一个阈值,比如几百KB):直接用 mmap 单独映射一块。这样避免了在堆区造成难以忍受的大洞(外部碎片),释放时也能干净利落地立刻归还给系统,不拖累堆区。

怎么处理堆空间连续增长产生的内存碎片?

这就是 JVM GC 干的工作了,常见的方案有:紧凑/压缩堆、空闲链表管理、分代/区域分配等。

特性 brk(堆) mmap
内存连续性 必须连续 虚拟地址可分散,物理页不要求连续
小块分配 高效,但容易产生堆碎片 不适合小块频繁分配,开销大
大块分配 堆碎片可能导致失败 独立映射,释放干净,不影响堆

free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?

malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节,这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

img

这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。

进程、线程、协程

区别

图有错误:线程私有的只有寄存器和栈,没有堆

  • 首先,我们来谈谈进程。进程是操作系统中进行资源分配和调度的基本单位,它拥有自己的独立内存空间和系统资源。每个进程都有独立的堆和栈,不与其他进程共享。进程间通信需要通过特定的机制,如管道、消息队列、信号量等。由于进程拥有独立的内存空间,因此其稳定性和安全性相对较高,但同时上下文切换的开销也较大,因为需要保存和恢复整个进程的状态。
  • 接下来是线程。线程是进程内的一个执行单元,也是CPU调度和分派的基本单位。与进程不同,线程共享进程的内存空间,包括堆和全局变量。线程之间通信更加高效,因为它们可以直接读写共享内存。线程的上下文切换开销较小,因为只需要保存和恢复线程的上下文,而不是整个进程的状态。然而,由于多个线程共享内存空间,因此存在数据竞争和线程安全的问题,需要通过同步和互斥机制来解决。
  • 最后是协程。协程是一种用户态的轻量级线程,其调度完全由用户程序控制,而不需要内核的参与。协程拥有自己的寄存器上下文和栈,但与其他协程共享堆内存。协程的切换开销非常小,因为只需要保存和恢复协程的上下文,而无需进行内核级的上下文切换。这使得协程在处理大量并发任务时具有非常高的效率。然而,协程需要程序员显式地进行调度和管理,相对于线程和进程来说,其编程模型更为复杂。内核根本不知道有协程存在,所以 什么时候挂起、什么时候恢复,全靠程序员写代码来标记

进程

状态

img

挂起状态:进程没有占用实际的物理内存空间的情况**,**挂起状态可以分为两种:

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

PCB

进程描述信息:

  • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
  • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;

进程控制和管理信息:

  • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
  • 进程优先级:进程抢占 CPU 时的优先级;

资源分配清单:

  • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。

CPU 相关信息:

  • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。

每个 PCB 是如何组织的呢?

通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

  • 将所有处于就绪状态的进程链在一起,称为就绪队列
  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
  • 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。

那么,就绪队列和阻塞队列链表的组织形式如下图:

img

进程的控制

01 创建进程

操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。

创建进程的过程如下:

  • 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;
  • 为该进程分配运行时所必需的资源,比如内存资源;
  • 将 PCB 插入到就绪队列,等待被调度运行;

02 终止进程

进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。

当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。

终止进程的过程如下:

  • 查找需要终止的进程的 PCB;
  • 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
  • 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
  • 将该进程所拥有的全部资源都归还给操作系统;
  • 将其从 PCB 所在队列中删除;

03 阻塞进程

当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。

阻塞进程的过程如下:

  • 找到将要被阻塞进程标识号对应的 PCB;
  • 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
  • 将该 PCB 插入到阻塞队列中去;

04 唤醒进程

进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。

如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。

唤醒进程的过程如下:

  • 在该事件的阻塞队列中找到相应进程的 PCB;
  • 将其从阻塞队列中移出,并置其状态为就绪状态;
  • 把该 PCB 插入到就绪队列中,等待调度程序调度;

进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。

进程的上下文切换

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。

所以,进程的上下文切换不仅包含了**虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等**内核空间的资源。

通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:

img

进程切换时数据都保存在哪里?

  1. PCB 中保存的信息

PCB(Process Control Block)主要保存 进程的核心元数据和必要的上下文,包括:

  • CPU 状态:寄存器值(通用寄存器、程序计数器、栈指针、标志寄存器等)
  • 进程标识信息:PID、父进程 PID、用户/组 ID
  • 调度信息:进程状态(就绪、运行、阻塞)、优先级、时间片
  • 内存管理信息:页表基址、段表信息、堆/栈边界
  • 信号和 I/O 状态信息:挂起信号、等待队列、打开的文件描述符等

PCB 中保存的是 操作系统切换进程必须的信息,足够恢复进程继续执行。

  1. 不直接保存到 PCB 的内容

大块内存数据:堆和栈内容本身通常仍在内存中,不会拷贝到 PCB,只保存栈指针和边界信息

  • 缓存、TLB 条目等硬件状态:在上下文切换时可能被刷新,不存 PCB
  • 磁盘 I/O 缓冲:挂起的 I/O 数据通常在内核缓冲区,不存 PCB
  • PCB 更像是 索引和元信息,实际数据仍保留在内存或硬件资源中。

进程切换的开销

进程切换是指操作系统将 CPU 从一个进程切换到另一个进程运行,以实现多任务处理。这个过程会带来以下几方面的开销:

保存和恢复现场信息

  • 进程在运行过程中,CPU 的寄存器中会存储当前进程的各种信息,如程序计数器、通用寄存器的值等。当进行进程切换时,需要将这些寄存器中的值保存到进程的控制块(PCB)中,以便在该进程下次被调度运行时能够恢复到原来的状态。保存和恢复这些现场信息需要执行一系列的指令,这会消耗一定的 CPU 时间。

内存管理相关开销

  • 页表切换:每个进程都有自己独立的地址空间,通过页表来实现虚拟地址到物理地址的映射。当进程切换时,需要切换页表,使 CPU 能够正确地访问新进程的内存空间。页表的切换需要更新 CPU 中的页表寄存器,这会导致内存访问的局部性原理被破坏,使得后续的内存访问可能需要更多的时间来查找页表,增加了内存访问的延迟。
  • TLB 刷新:转换后备缓冲器(TLB)是 CPU 中用于加速虚拟地址到物理地址转换的缓存。当进程切换时,由于页表发生了变化,TLB 中的内容可能不再有效,需要进行刷新。TLB 刷新会导致后续的地址转换无法直接从 TLB 中获取结果,而需要再次访问页表,增加了地址转换的时间。

进程调度开销

  • 操作系统需要执行进程调度算法,从就绪队列中选择一个合适的进程来运行。这个过程需要对各个进程的状态、优先级等信息进行检查和比较,以确定下一个应该运行的进程。调度算法的执行需要消耗一定的 CPU 时间,尤其是在系统中进程数量较多时,调度的开销会更加明显。

缓存失效

  • 当进程切换时,原来进程使用的 CPU 缓存(如指令缓存、数据缓存等)中的内容可能对于新进程不再有效,因为不同进程的代码和数据通常是不同的。新进程开始运行后,可能需要重新从内存中加载指令和数据到缓存中,这会导致缓存未命中的情况增加,从而增加了内存访问的时间,降低了 CPU 的执行效率。

上下文切换的硬件开销

  • 在一些硬件架构中,进程切换可能会触发一些硬件相关的操作,如更新处理器的状态寄存器、清除流水线等。这些硬件操作也会消耗一定的时间和能量,增加了进程切换的总体开销。

进程的通讯方式

img

5.2 进程间有哪些通信方式?讲的更详细。

  1. 管道:
  • 所谓的管道,就是内核里面的一个环形缓存区
  • 管道传输数据是单向的
  • 管道这种通信方式效率低,不适合进程间频繁地交换数据。

img

  1. 消息队列:
  • 消息队列是保存在内核中的消息链表。在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
  • 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
  1. 共享内存:

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

共享内存解决了**效率低用户态与内核态数据拷贝**的问题,但是存在数据覆写的风险,信号量就是为了解决这个问题。

img

  1. 信号量:

信号量其实是一个整型的**计数器,主要用于实现进程间的互斥与同步**,而不是用于缓存进程间通信的数据。

信号量表示资源的数量,控制信号量的方式有两种原子操作:

  • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
  • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。

信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

  1. 信号

在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX

运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;
  • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;

如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

  • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程;

所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。

信号是异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。

2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSEGSTOP,它们用于在任何时候中断或结束某一进程。

  1. Socket

可以实现跨主机或者本地进程之间的通信,本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。

fork创建子进程时,子进程会复用父进程的资源吗

在使用fork创建子进程时,子进程会在一定程度上复用父进程的资源,但也有一些资源是独立分配和管理的。以下是具体说明:

内存资源

  • 一般情况下,子进程会复制父进程的地址空间,包括代码段、数据段、堆和栈等。这意味着子进程拥有一份与父进程相同的内存内容的拷贝,但它们在物理上是独立的,各自有自己的地址空间
  • 不过,现代操作系统通常采用写时复制(Copy - On - Write,COW)技术来优化内存使用。在子进程创建后的一段时间内,如果父进程和子进程都没有对共享的内存区域进行写操作,那么它们实际上共享相同的物理内存页面。只有当其中一个进程试图修改某个页面时,操作系统才会为修改的进程创建一个新的物理页面来存储修改后的数据,以确保两个进程的内存空间相互独立。

文件资源

  • 子进程会继承父进程打开的文件描述符。这意味着父进程中已经打开的文件,在子进程中仍然是打开的,并且文件指针的位置也与父进程相同。
  • 例如,如果父进程打开了一个文件用于读取,在fork之后,子进程也可以从该文件的当前位置继续读取数据(管道的原理)。不过,对文件描述符的操作,如关闭文件描述符,在父进程和子进程中是相互独立的,一个进程关闭文件描述符不会影响另一个进程对该文件的访问,除非两个进程都关闭了对应的文件描述符,否则文件不会真正被关闭。

其他资源

  • 子进程会继承父进程的一些其他属性,如进程的当前工作目录、用户ID、组ID等。
  • 但子进程有自己独立的进程ID,并且与父进程在进程调度、信号处理等方面是相互独立的。信号的处理在父进程和子进程中是独立设置的,一个进程接收到信号后执行的操作不会影响另一个进程。

fork创建的子进程会复用父进程的部分资源,这使得子进程在创建后能够快速地开始执行,同时又保证了子进程与父进程在一定程度上的独立性,以便各自进行独立的操作和资源管理。

线程

主要有三种线程的实现方式:

  • 用户线程(*User Thread*)**:在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;

  • 内核线程(*Kernel Thread*)**:在内核中实现的线程,是由内核管理的线程;

  • 轻量级进程(*LightWeight Process*)**:在内核中来支持用户线程;

多对一 一对一 多对多
img img img

用户态线程

用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程,如下图所示:

img

总结:速度快,风险高。

用户线程的优点

  • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快;

用户线程的缺点

  • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。
  • 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;

内核线程

内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程,如下图所示:

img

和用户级线程刚好反过来,速度慢,风险低。

内核线程的优点

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
  • 分配给线程,多线程的进程获得更多的 CPU 运行时间;

内核线程的缺点

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;
  • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;

轻量级进程

轻量级进程(*Light-weight process,LWP*)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度**。

img

1 : 1 模式

一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。

  • 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;
  • 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。

N : 1 模式

多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。

  • 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;
  • 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。

M : N 模式

根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。

  • 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。

组合模式

如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。

线程崩溃了进程也会崩溃吗?

答案:是的,线程共享资源,为了避免影响其他线程,干脆直接停掉,但也存在特殊情况,如果进程自定义了信号处理函数且信号并不是 kill -9 这种强制命令,那么可能忽略停止操作。

通过信号的方式停止进程,其背后的机制如下:

  1. CPU 执行正常的进程指令。
  2. 调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)。
  3. 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统。
  4. 操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出。

注意上面的第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行。

为什么线程崩溃不会导致 JVM 进程崩溃?

这个问题也有答案了,JVM 自定义了信号处理函数。

线程栈空间溢出怎么办?

每个操作系统创建线程时会为它分配栈空间,有一个默认大小,Linux 是 8MB,每个线程的栈空间大小是可以通过代码调整的,溢出会触发段错误

线程上下文的切换的详细过程?

详细过程:

线程切换的详细过程可以分为以下几个步骤:

  • 上下文保存:当操作系统决定切换到另一个线程时,它首先会保存当前线程的上下文信息。上下文信息包括一些 CPU 信息(寄存器状态(程序计数器、通用寄存器等)) 、栈指针、**栈中的数据**等,用于保存线程的执行状态。
  • 切换到调度器:操作系统将执行权切换到调度器(Scheduler)。调度器负责选择下一个要执行的线、程,并根据调度算法做出决策。
  • 上下文恢复:调度器选择了下一个要执行的线程后,它会从该线程保存的上下文信息中恢复线程的执行状态。
  • 切换到新线程:调度器将执行权切换到新线程,使其开始执行。

上下文信息的保存通常由操作系统负责管理,具体保存在哪里取决于操作系统的实现方式。一般情况下,上下文信息会保存在线程的控制块(Thread Control Block,TCB)中。

TCB是操作系统用于管理线程的数据结构,包含了线程的状态、寄存器的值、堆栈信息等。当发生线程切换时,操作系统会通过切换TCB来保存和恢复线程的上下文信息

线程相比进程能减少开销体现在哪里?

  • 内存管理共享:线程共享进程的虚拟地址空间、堆、全局变量等,因此切换线程时无需保存或切换页表、段表等虚拟地址映射信息。
  • 文件与I/O共享:线程共享进程的文件描述符表和其他内核资源,无需切换文件句柄或打开文件状态。
  • 上下文切换粒度小:线程切换只需保存和恢复 CPU 寄存器、栈指针和少量线程局部状态,而不涉及完整 PCB 信息。

img

协程

多线程编程是比较困难的,因为调度程序任何时候都能中断线程, 必须保留锁,去保护程序中重要部分,防止多线程在执行的过程中断。

而协程默认会做好全方位保护, 以防止中断。我们必须显示产出才能让程序的余下部分运行。对协程来说, 无需保留锁, 而在多个线程之间同步操作, 协程自身就会同步, 因为在任意时刻, 只有一个协程运行。总结下大概下面几点:

  • 无需系统内核的上下文切换,减小开销;
  • 无需原子操作锁定及同步的开销,不用担心资源共享的问题;
  • 单线程即可实现高并发,单核 CPU 即便支持上万的协程都不是问题,所以很适合用于高并发处理,尤其是在应用在网络爬虫中。

同时也存在一些缺点:

  • 无法使用 CPU 的多核

协程的本质是个单线程,它不能同时用 CPU 的多个核,协程需要和进程配合才能运行在多CPU上。当然我们日常所编写的绝大部分应用都没有这个必要,就比如网络爬虫来说,限制爬虫的速度还有其他的因素,比如网站并发量、网速等问题都会是爬虫速度限制的因素。除非做一些密集型应用,这个时候才可能会用到多进程和协程。

  • 处处都要使用非阻塞代码

写协程就意味着你要一值写一些非阻塞的代码,使用各种异步版本的库,比如后面的异步爬虫教程中用的aiohttp就是一个异步版本的request库等。 不过这些缺点并不能影响到使用协程的优势。

进程可以创建多少个线程?

创建一个线程,分配的资源只有栈空间,程序计数器是存储在 CPU 寄存器硬件资源里的,而每个操作系统分配的栈空间是有默认大小的,所以理论上根据操作系统位数(32bit 或者 64bit)就可以得到虚拟内存大小,根据虚拟内存大小和为线程分配栈空间大小就可以计算出最多可以分配多少个线程,这个值在 64bit 系统内是几千万。

但实际上线程最大数量还受系统参数的影响

  • /proc/sys/kernel/threads-max\,表示系统支持的最大线程数,默认值是 14553
  • /proc/sys/kernel/pid_max\,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败,默认值是 32768
  • /proc/sys/vm/max_map_count\,表示限制一个进程可以拥有的VMA(虚拟内存区域)的数量,具体什么意思我也没搞清楚,反正如果它的值很小,也会导致创建线程失败,默认值是 65530

线程间通讯有什么方式?

https://xiaolincoding.com/interview/os.html#%E7%BA%BF%E7%A8%8B%E9%97%B4%E9%80%9A%E8%AE%AF%E6%9C%89%E4%BB%80%E4%B9%88%E6%96%B9%E5%BC%8F

浏览器为什么使用多进程而不是多线程?

虽然多线程在某些场景下性能表现出色,但很多程序(如浏览器)依旧选择多进程模型,主要是基于稳定性、安全性、资源管理和兼容性等多方面的综合考量,以下为你详细分析:

稳定性(主要)

  • 进程隔离优势:进程间拥有独立的内存空间和系统资源,一个进程崩溃时,不会对其他进程造成影响。以浏览器为例,各个标签页通常运行在独立的进程中,如果某个标签页因恶意脚本或代码错误崩溃,不会导致整个浏览器或其他标签页关闭,用户可以继续使用其他功能。
  • 线程崩溃影响:多线程程序中,所有线程共享同一进程的内存空间,若一个线程出现错误(如访问非法内存),可能会导致整个进程崩溃,影响程序的正常运行。

资源管理(方便)

  • 独立资源分配:每个进程可以独立分配和管理资源,如CPU、内存等。浏览器可以根据不同标签页的需求,灵活分配资源,避免某个标签页占用过多资源而影响其他标签页的性能。
  • 线程资源竞争:多线程程序中,线程之间会竞争共享资源,需要使用同步机制(如锁)来保证数据的一致性,但这可能会导致性能下降,甚至出现死锁等问题。

安全性

  • 进程沙箱机制:多进程模型便于实现更严格的安全策略,例如为不同进程设置沙箱。浏览器可以将渲染进程放在沙箱中运行,限制其对系统资源的访问权限,防止恶意网页利用漏洞攻击系统。
  • 线程安全隐患:多线程环境下,由于线程共享内存,数据竞争和同步问题较为复杂,容易出现安全漏洞。攻击者可能利用这些漏洞进行数据篡改或执行恶意代码。

兼容性(给不同编程语言和技术实现的插件提供不同的环境)

  • 支持不同类型插件:一些程序(如浏览器)需要支持各种类型的插件,这些插件可能使用不同的编程语言和技术实现。多进程模型可以为每个插件提供独立的运行环境,避免插件之间的兼容性问题。
  • 线程兼容性挑战:多线程环境下,不同插件的代码可能会相互影响,导致兼容性问题,增加开发和维护的难度。

多核利用

  • 充分利用多核CPU:多进程模型可以更好地利用多核CPU的并行计算能力,每个进程可以在不同的CPU核心上独立运行,提高程序的整体性能。
  • 线程并行限制:虽然多线程也能利用多核CPU,但由于线程之间的同步和竞争问题,可能无法充分发挥多核CPU的优势。

文件系统

Linux 文件系统会为每个文件分配两个数据结构:索引节点(*index node*)和目录项(*directory entry*)**,它们主要用来记录文件的元信息和目录层次结构。

  • 索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间
  • 目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存

由于索引节点唯一标识一个文件,而目录项记录着文件的名字,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别名。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。

注意:目录也是文件,也是用索引节点唯一标识,与目录项是一对多的关系,目录项是内存的数据结构,缓存在内存。目录是磁盘上的文件,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。

img

索引节点是存储在硬盘上的数据,那么为了加速文件的访问,通常会把索引节点加载到内存中。

另外,磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。

  • 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
  • 索引节点区,用来存储索引节点;
  • 数据块区,用来存储文件或目录数据;

我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:

  • 超级块:当文件系统挂载时进入内存;
  • 索引节点区:当文件被访问时进入内存;

虚拟文件系统

文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(*Virtual File System,VFS*)。**

img

Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:

  • 磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。
  • 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的 /proc/sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据。
  • 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。

文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录。

虚拟内存和文件系统的关系

磁盘的数据不都是以文件的形式组织的,磁盘划分了一块 swap 区域专门给虚拟内存换入换出使用,既进程堆里面对象、栈的数据、BSS 段数据都是直接存储在磁盘中的,它们可以称为匿名页,如果要进入内存只能通过两种方式:swap 分区、swap 文件:

Swap 分区(Swap Partition)

  • 专门划出的磁盘区域,只用于虚拟内存换出。
  • 系统知道每个页在 swap 分区的偏移位置。

Swap 文件(Swap File)

  • 普通文件,但内核直接操作它的页块,绕过普通文件访问接口。
  • 文件系统本身不知道这些页存放在 swap 文件里对应的哪块,内核维护映射表(页 → swap offset)。
页类型 磁盘位置 谁管理 文件系统知道吗?
文件映射页 文件系统中的文件块 VFS + VM
匿名页 Swap 分区 / Swap 文件 VM 内核管理 否(文件系统不知道具体位置)

它们都是使用块设备驱动(Block Device Driver) 操作磁盘。

块设备驱动负责与磁盘硬件交互,包括:

  • 读/写扇区
  • DMA 传输
  • 调度 I/O 请求

不同的上层模块(VFS、VM)只是组织和调度 I/O 的方式不同,但物理磁盘访问都是通过同一套驱动

文件描述符表、打开文件表

在类 Unix 系统里,和文件相关的有三类重要的数据结构:

  1. 进程级:文件描述符表(per-process file descriptor table)
  • 每个进程独有。
  • 存放「文件描述符 → 打开文件表项的指针」。
  1. 系统级:打开文件表(system-wide open file table)
  • 操作系统内核维护,所有进程共享。
  • 每个表项包含:

  • 文件读/写偏移量(file offset)

  • 文件状态标志(读/写/追加等)
  • 指向 vnode/inode 的指针
  1. 文件系统级:inode 表(vnode/inode table)
  • 记录文件的元数据(权限、大小、数据块地址等)。

操作系统在打开文件表中维护着打开文件的状态和信息:

  • 文件指针:系统跟踪上次读写位置作为当前文件位置指针,这种指针对打开文件的某个进程来说是唯一的;
  • 文件打开计数器:文件关闭时,操作系统必须重用其打开文件表条目,否则表内空间不够用。因为多个进程可能打开同一个文件,所以系统在删除打开文件条目之前,必须等待最后一个进程关闭文件,该计数器跟踪打开和关闭的数量,当该计数为 0 时,系统关闭文件,删除该条目;
  • 文件磁盘位置:绝大多数文件操作都要求系统修改文件数据,该信息保存在内存中,以免每个操作都从磁盘中读取;
  • 访问权限:每个进程打开文件都需要有一个访问模式(创建、只读、读写、添加等),该信息保存在进程的打开文件表中,以便操作系统能允许或拒绝之后的 I/O 请求;

用户和操作系统对文件的读写操作是有差异的,用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件,那屏蔽掉这种差异的工作就是文件系统了。

磁盘文件组织方式

1. 连续分配(Contiguous Allocation)

定义:

  • 为文件分配一组连续的磁盘块(物理地址连续)。
  • 文件的目录项只需记录:起始块号 + 长度。

优点:

  • 顺序访问性能最佳(磁盘只需一次定位)。
  • 随机访问性能也很好(起始块号+偏移即可)。
  • 实现简单。

缺点:

  • 文件在创建时必须知道最大长度,不利于动态增长。
  • 会产生外部碎片,空闲块可能被切割零散。
  • 文件扩展时可能需要搬迁整个文件,代价大。

2. 隐式链表分配(Linked Allocation, 隐式链表)

定义:

  • 每个文件由一条链表连接的磁盘块组成。
  • 每个磁盘块里存放下一个磁盘块的地址(类似链表“next”指针)。
  • 目录项只需记录:起始块号。

优点:

  • 文件可动态增长(只需在末尾追加新块)。
  • 不会产生外部碎片(只要有空闲块就能用)。

缺点:

  • 随机访问性能差,要从头顺着链表走。
  • 每个块要存指针,浪费存储空间。
  • 指针丢失或损坏,整个链表可能断裂(文件损坏)。

3. 显式链表分配(File Allocation Table, FAT 方式)

定义:

  • 不把“下一个块的地址”存在数据块中,而是存在一张文件分配表(FAT)里。
  • FAT 表在内存中缓存,目录项只需记录:起始块号。
  • FAT[块号] = 下一块的地址。

优点:

  • 随机访问比隐式链表快,可以在内存中快速查找 FAT。
  • 仍然支持文件动态增长。
  • 数据块里不再存指针,空间利用率更高。

缺点:

  • FAT 表本身可能很大(磁盘块数目多时)。
  • FAT 表损坏会导致文件丢失。
  • 文件仍然不支持真正的直接随机定位(要查表走链)。

4. 索引分配(Indexed Allocation)

定义:

  • 给每个文件分配一个索引块,里面保存该文件所有数据块的地址。
  • 目录项只需记录:索引块号。

优点:

  • 随机访问性能好(可直接通过索引定位)。
  • 支持文件动态增长(只需增加索引项)。
  • 没有外部碎片问题。

缺点:

  • 索引块本身要占用额外存储空间。
  • 对于大文件,单个索引块可能不够,需要多级索引(比如 Unix inode 的直接/间接/多级索引)。

总结

方法 顺序访问 随机访问 空间利用率 动态增长 碎片问题 实现复杂度
连续分配 很好 很好 有外部碎片 不好 外部碎片 简单
隐式链表分配 一般 很差 有指针开销 简单
显式链表分配 一般 一般 较好 中等
索引分配 索引开销 较复杂

img

linux 选择的方式

img

它是根据文件的大小,存放的方式会有所变化:

  • 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式;
  • 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式;
  • 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
  • 如果二级间接索引也不够存放大文件,这采用三级间接索引方式;

那么,文件头(Inode)就需要包含 13 个指针:

  • 10 个指向数据块的指针;
  • 第 11 个指向索引块的指针;
  • 第 12 个指向二级索引块的指针;
  • 第 13 个指向三级索引块的指针;

所以,这种方式能很灵活地支持小文件和大文件的存放:

  • 对于小文件使用直接查找的方式可减少索引数据块的开销;
  • 对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询;

这个方案就用在了 Linux Ext 2/3 文件系统里,虽然解决大文件的存储,但是对于大文件的访问,需要大量的查询,效率比较低。

空闲空间管理

空闲表法

空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。如下图:

img

这种方法仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。

空闲链表法

我们也可以使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。如下图:
img

空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。

位图法

位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。

当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。它形式如下:

1
1111110011111110001110110111111100111 ...

在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。

Linux 选择方式

下图给出了 Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布:

img

最前面的第一个块是引导块,在系统启动时用于启用引导,接着后面就是一个一个连续的块组了,块组的内容如下:

  • 超级块,包含的是文件系统的重要信息,比如 inode 总个数、块总个数、每个块组的 inode 个数、每个块组的块个数等等。
  • 块组描述符,包含文件系统中各个块组的状态,比如块组中空闲块和 inode 的数目等,每个块组都包含了文件系统中「所有块组的组描述符信息」。
  • 数据位图和 inode 位图, 用于表示对应的数据块或 inode 是空闲的,还是被使用中。
  • inode 列表,包含了块组中所有的 inode,inode 用于保存文件系统中与各个文件和目录相关的所有元数据。
  • 数据块,包含文件的有用数据。

你可以会发现每个块组里有很多重复的信息,比如超级块和块组描述符表,这两个都是全局信息,而且非常的重要,这么做是有两个原因:

  • 如果系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。如果有冗余的副本,该信息是可能恢复的。
  • 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,这可以提高文件系统的性能。

不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组 0、块组 1 和其他 ID 可以表示为 3、 5、7 的幂的块组中。

目录的存储

img

如果一个目录有超级多的文件,我们要想在这个目录下找文件,按照列表一项一项的找,效率就不高了。

于是,保存目录的格式改成哈希表,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。

Linux 系统的 ext 文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。

目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。

软链接和硬链接

img

硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。

img

软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。

文件 I/O

文件的读写方式各有千秋,对于文件的 I/O 分类也非常多,常见的有

  • 缓冲与非缓冲 I/O
  • 直接与非直接 I/O
  • 阻塞与非阻塞 I/O VS 同步与异步 I/O

接下来,分别对这些分类讨论讨论。

缓冲与非缓冲 I/O(用户态标准库层面)

  • 层次:用户态(标准 C 库,如 stdio.hfread/fwrite
  • 定义:是否利用 标准库内部缓冲区(用户态缓冲区)

文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O

  • 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
  • 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。

这里所说的「缓冲」特指标准库内部实现的缓冲。

比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的。

直接与非直接 I/O(内核/磁盘访问层面)

根据是「否利用操作系统的缓存PageCache」,可以把文件 I/O 分为直接 I/O 与非直接 I/O

  • 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
  • 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。

如果你在使用文件操作类的系统调用函数时,指定了 O_DIRECT 标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O。

PageCache 的作用:PageCache 是一个内核缓冲区,进程从磁盘读的内容都先经过这里,然后再走向用户缓冲区,目的是起到缓存复用的作用,可能两个进程访问同一个数据,只要一个进程访问缓存以后,第二个进程就不需要再读磁盘了。同理进程向磁盘写数据也会经过这个缓冲区。

还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」

所以,PageCache 的优点主要是两个:

  • 缓存最近被访问的数据;
  • 预读功能;

    Page Cache 由多个 page 构成。page 在操作系统中通常为 4KB 大小(32bits/64bits),而 Page Cache 的大小则为 4KB 的整数倍。

针对大文件的读取,不应该使用 PageCache,因为大文件会把整个 PageCache 占满,而且大文件使用 read 调用程序会等待磁盘数据准备完成,导致阻塞很久,所以针对大文件的读写,应该采用异步 IO+直接 IO 的方式。

img

如果用了非直接 I/O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘?

以下几种场景会触发内核缓存的数据写入磁盘:

  • 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
  • 用户主动调用 sync,内核缓存会刷到磁盘上;
  • 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
  • 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;

进程写文件(使用缓冲 IO)过程中,写一半的时候,进程发生了崩溃,已写入的数据会丢失吗?

答案,是不会的。

因为进程在执行 write (使用缓冲 IO)系统调用的时候,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了,文件数据还是保留在内核的 page cache,我们读数据的时候,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。

内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了。

当然, 我们也可以在程序里调用 fsync 函数,在写文文件的时候,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。

阻塞与非阻塞 I/O VS 同步与异步 I/O

实际上,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。

而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。

https://xiaolincoding.com/os/6_file_system/file_system.html#%E9%98%BB%E5%A1%9E%E4%B8%8E%E9%9D%9E%E9%98%BB%E5%A1%9E-i-o-vs-%E5%90%8C%E6%AD%A5%E4%B8%8E%E5%BC%82%E6%AD%A5-i-o

DMA

在没有 DMA 技术前,I/O 的过程是这样的:

img

整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。

什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

img

可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

互斥锁和自旋锁

这两种锁是最基础的锁,其他的锁都是基于这两种基础的锁实现的。

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:

img

自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

一般加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;

CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。

读写锁

读写锁的实现原理

读写锁本身是高级锁,它可以基于更低级的独占锁实现

  1. 互斥锁 + 计数器
    • 一个互斥锁保护计数器和状态
    • 计数器记录当前读者数量
    • 写者必须等到计数器为 0 才能获得锁
    • 读者在获取锁时增加计数器,释放时减少
  1. 自旋锁 + 计数器
    • 类似逻辑,但等待者自旋,而不是睡眠

写锁是「独占锁」,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是「共享锁」,因为读锁可以被多个线程同时持有。

读写锁可以分为「读优先锁」、「写优先锁」、「公平读写锁」。

  • 读优先锁:读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。如下图:

img

  • 写优先锁:而「写优先锁」是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。如下图:

img

  • 公平读写锁:比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

乐观锁与悲观锁

前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。

可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程

这里举一个场景例子:在线文档。

我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交早,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号不一致则提交失败,如果版本号一致则修改成功,然后服务端版本号更新到最新的版本号。

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

死锁

死锁只有同时满足以下四个条件才会发生:

  • 互斥条件;
  • 持有并等待条件;
  • 不可剥夺条件;
  • 环路等待条件;

如何避免死锁

前面我们提到,产生死锁的四个必要条件是:互斥条件、持有并等待条件、不可剥夺条件、环路等待条件

那么避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件。(或者银行家算法)

线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。

调度算法

进程调度算法

先来先服务调度算法

最短作业优先调度算法

高响应比优先调度算法

时间片轮转调度算法

最高优先级调度算法

多级反馈队列调度算法

内存页面置换算法

最佳页面置换算法

先进先出置换算法

最近最久未使用的置换算法

时钟页面置换算法

最不常用算法

磁盘调度算法

先来先服务

最短寻道时间优先

扫描算法

循环扫描算法

LOOK 与 C-LOOK算法

网络系统

零拷贝技术

讲零拷贝之前,先看一下传统的文件传输方法:

传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。

代码通常如下,一般会需要两个系统调用:

1
2
read(file, tmp_buf, len);
write(socket, tmp_buf, len);

img

期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的。

要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

零拷贝技术实现的方式通常有 2 种:

  • mmap + write
  • sendfile

mmap + write

在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

1
2
buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

img

使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。

但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

sendfile

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:

1
2
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

首先,它可以替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

img

但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

所以,这个过程之中,只进行了 2 次数据拷贝,如下图:

img

这就是所谓的零拷贝(*Zero-copy*)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。**。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。

另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。

零拷贝与Direct I/O 的区别

两者的应用场景不一样。一个是用户直接写磁盘,一个是发送网络请求。

img img

IO 多路复用

多路复用是操作系统内部实现的,暴露了一些 API,mysql、redis、消息队列等中间件底层实现会调用这些 API 来实现多路复用。

img

优化的地方 1:

select 和 poll 维护了一个文件描述符集合,拷贝时间复杂度 O(n)。

epoll 在内核里使用红黑树**来跟踪进程所有待检测的文件描述字**,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

优化的地方 2:

select 和 poll 逐个检查文件是否准备就绪,select 通过 bitsmap 标记准备就绪的 Socket,然后把 bitsmap 和文件描述符集合都 拷贝到进程缓冲区,进程遍历 bitsmap 来调用 read 接口读取准备好的数据。select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。poll 不再用BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制。

epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

img

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

阻塞 IO 与非阻塞 IO

  1. 阻塞 I/O
  • 最简单的方式:直接 recv(),如果没有数据,线程就卡住。
  • 缺点:效率低,一直阻塞。
  • 非阻塞 + 轮询

  • 把 socket 设置成非阻塞:

1
fcntl(fd, F_SETFL, O_NONBLOCK);
  • 然后不停地 recv()send(),发现返回 EAGAIN 就说明暂时没数据。
  • 缺点:CPU 空转,占用资源大。

select、poll、epoll 图示

img

img

img

img

epoll 的工作模式

  • 水平触发:当文件描述符关联的内核缓冲区非空,或者有数据可以读取时,系统会一直发出可读信号进行通知。类似地,当写缓冲区不满,有空间可以写入时,系统会一直发出可写信号。这种模式支持阻塞和非阻塞两种方式,是许多 IO 多路复用机制(如 epoll)的默认模式。
  • 边缘触发:当文件描述符关联的读内核缓冲区由空转化为非空的时候,系统会发出可读信号进行通知。当写缓冲区由满转化为不满的时候,系统会发出可写信号。边缘触发仅在状态发生变化时通知一次,不会持续通知。

什么是一致性哈希

一致性哈希是为了解决负载均衡哈希算法无法应对扩容和缩容的问题。

hash(key) % 3 公式对数据进行了映射。但是有一个很致命的问题,如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。

要解决这个问题的办法,就需要我们进行迁移数据,比如节点的数量从 3 变化为 4 时,要基于新的计算公式 hash(key) % 4 ,重新对数据和节点做映射。

假设总数据条数为 M,哈希算法在面对节点数量变化时,最坏情况下所有数据都需要迁移,所以它的数据迁移规模是 O(M),这样数据的迁移成本太高了。

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。

一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值

我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为哈希环

一致性哈希要进行两步哈希:

  • 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
  • 第二步:当对数据进行存储或访问时,对数据进行哈希映射;

所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上

问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?

答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。

举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置:

img

假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置:

img

你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。

假设节点数量从 3 减少到了 2,比如将节点 A 移除:

img

你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。

因此,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响

上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节点。

但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。

比如,下图中 3 个节点的映射位置都在哈希环的右半边:

img

所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题

解决做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。

比如对每个节点分别设置 3 个虚拟节点:

  • 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
  • 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
  • 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03

引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。

img

节点数量多了后,节点在哈希环上的分布就相对均匀了

BIO 和 NIO

img

img

从这两图可以看出,NIO的单线程能处理连接的数量比BIO要高出很多,而为什么单线程能处理更多的连接呢?原因就是图二中出现的Selector
当一个连接建立之后,他有两个步骤要做,第一步是接收完客户端发过来的全部数据,第二步是服务端处理完请求业务之后返回response给客户端。NIO和BIO的区别主要是在第一步。
在BIO中,等待客户端发数据这个过程是阻塞的,这样就造成了一个线程只能处理一个请求的情况,而机器能支持的最大线程数是有限的,这就是为什么BIO不能支持高并发的原因。
而NIO中,当一个Socket建立好之后,Thread并不会阻塞去接受这个Socket,而是将这个请求交给Selector,Selector会不断的去遍历所有的Socket,一旦有一个Socket建立完成,他会通知Thread,然后Thread处理完数据再返回给客户端——这个过程是不阻塞的,这样就能让一个Thread处理更多的请求了。
下面两张图是基于BIO的处理流程和netty的处理流程,辅助你理解两种方式的差别:

img

常见系统调用

mmap

mmap 的全称是 memory map,意思就是把文件或设备的内容映射到进程的虚拟内存地址空间

底层机制:

  1. 进程调用 mmap() → 内核会在进程的 虚拟地址空间 中找到一块空闲区域(VMA,虚拟内存区域)。
  2. 这块区域不会立刻分配物理内存,而是建立虚拟地址和目标文件页(或者匿名页)的映射关系
  3. 当进程第一次访问这段内存时,会触发 缺页中断(page fault),内核这时才把对应的文件内容读到 PageCache,并建立物理页和虚拟页的映射。
  4. 进程读写这段内存,就相当于直接在操作系统的 PageCache 上操作,而不是通过 read()/write() 系统调用。

换句话说,mmap 的原理就是:用户空间地址直接映射到内核的 PageCache 或匿名页,通过缺页中断来延迟分配物理内存。

映射到 PageCache 或匿名页有什么区别?malloc 使用的是哪种方式?

  1. 文件映射 mmap
  • 内存页映射到文件对应的 PageCache**。**
  • PageCache 是全局的,多个进程 mmap 同一个文件时,确实会共享同一份物理页。
  • 所以这类 mmap 的内容不是进程“独有”的。
  1. 匿名 mmap (MAP_ANONYMOUS)
  • 内存页没有对应文件,只是内核分配的物理页,专属于调用进程。
  • 这类 mmap 就是“用户私有内存”,和 malloc 出来的效果一样。
  • 除非进程特意用 **fork()** 或者 **MAP_SHARED**,否则不会被其他进程看到或修改。

👉 malloc 分配大对象时用的就是 匿名 mmap,不是文件 mmap,所以它得到的是 用户独有的私有空间,不会被其他进程覆盖。

特性 **read()/write()** **mmap**
数据拷贝 用户缓冲区 ←→ 内核缓冲区 直接访问 PageCache,省一次拷贝
接口 系统调用 像访问数组一样
使用场景 适合小文件读写,简单可靠 大文件访问、随机读写、共享内存、设备映射
内存利用 多一份用户缓冲区 直接用 PageCache,更节省内存