跳转至

《操作系统导论》

阅读信息

  • 评分:⭐️⭐️⭐️⭐️⭐️
  • 时间:12/14/2023 → 04/28/2024
  • 读后感:没有读 18~24 章。对内存的堆、栈有了更深刻的理解。

课后习题代码:https://github.com/remzi-arpacidusseau/ostep-homework

在线中文版:https://pages.cs.wisc.edu/~remzi/OSTEP/Chinese/

虚拟化

操作系统将物理资源虚拟化,以便更加灵活、高效调度这些资源。同时操作系统也需要管理这些资源,以达到高效公平的目的。

现代操作系统提供的 API:

  • 创建:shell 中键入命令或通过应用程序图标启动
  • 销毁:正常进程在运行完成后自行退出,但异常未退出的进程应该被强制停止
  • 等待:进程挂起
  • 其他控制:进程挂起并恢复
  • 状态:获取进程的运行状态

应用程序通过陷阱和陷阱处理程序来通过操作系统来调用内核。

进程

进程状态转换 进程的创建,需要以下步骤:

  1. 将程序和静态数据加载到内存中。早期操作系统在程序运行前全部加载完成,现代操作系统采用惰性加载(即仅在需要时加载)
  2. 操作系统为程序分配内存。malloc()free() 分别用于申请和释放内存空间
  3. 打开文件描述符。通常每个进程都打开 3 个:标准输入、输出和错误(0、1、2)

在 Unix 系统中,进程通过fork()exec()来创建新的进程,通过wait()等待子进程执行完成。

以下是调用fork()创建进程的实例:

1  #include <stdio.h>
2  #include <stdlib.h>
3  #include <unistd.h>
4
5  int main(int argc, char *argv[]) {
6      printf("hello world (pid:%d)\n", (int)getpid());
7
8      int rc = fork(); // 父进程 rc 为子进程 pid,子进程中的 pid 则为 0
9      if (rc < 0) {
10         // fork failed; exit
11         fprintf(stderr, "fork failed\n");
12         exit(1);
13     } else if (rc == 0) {
14         // child (new process)
15         printf("hello, I am child (pid:%d)\n", (int)getpid());
16     } else {
17         // parent goes down this path (main)
18         printf("hello, I am parent of %d (pid:%d)\n", rc, (int)getpid());
19     }
20
21     return 0;
22 }

// 输出如下:
hello world (pid:29146)
hello, I am parent of 29147 (pid:29146)
hello, I am child (pid:29147)

// 由于父进程和子进程都可能运行,因此也可能输出
hello world (pid:29146)
hello, I am child (pid:29147)
hello, I am parent of 29147 (pid:29146)

如果父进程在子进程结束前退出,父进程会向子进程发送 SIGKILL 信号时,子进程会被立即终止。

操作系统控制权

如果某个进程不释放控制权,操作系统如何介入终止呢?答案是时钟中断。时钟设备可以编程为每隔几毫秒产生一次中断,产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序就会运行。

受限直接执行(limited direct execution)的基本思路很简单:就让你想运行的程序在 CPU 上运行,但首先确保设置好硬件,以便在没有操作系统帮助的情况下限制进程可以执行的操作。

重新启动很有用,因为它让软件回到已知的状态,很可能是经过更多测试的状态。重新启动还可以回收旧的或泄露的资源(例如内存),否则这些资源可能很难处理。最后,重启很容易自动化。由于所有这些原因,在大规模集群互联网服务中,系统管理软件定期重启一些机器,重置它们并因此获得以上好处,这并不少见。

进程调度

调度策略:

优点 缺点 应用场景
先到先服务(FCFS, First-Come, First-Served) 简单,易于实现 短时任务易被阻塞,平均周转比较高 适用于处理时间较长的作业或任务,以确保公平性和简单性
最短优先(SJF, Short Job First) 短时任务较快被执行,平均周转比较低 可能导致长时任务长时间等待 适用于处理时间短的作业或任务。它可以最小化平均等待时间,适用于需要提高系统吞吐量和响应速度的场景。比如在密集型计算
最短完成时间优先(STCF,Shortest Time-to-Completion First) 在 SJF 的基础上,允许已经在执行的进程被新来的、执行时间更短的进程抢占 增加了抢占开销 适用于需要更快响应时间的实时系统或需要及时处理突发事件的场景。例如,对于需要快速响应用户请求的交互式系统
轮转调度 (Round Robin) CPU 轮流执行每个被分配时间片的进程,对与交互式系统有较快响应 时间片长度越短,响应时间越短,但也会导致过多的上下文切换成本,因此时间片的选择需要平衡响应时间和上下文切换 适用于时间共享系统
多级反馈队列调度 (Multilevel Feedback Queue) 可以平衡长、短作业的执行 饥饿问题:如果系统有大量交互型工作,则会不断占用 CPU,导致长时间任务永远无法得到 CPU。(通过提升优先级和更好的计时解决)
一个程序可能在不同时间表现不同,一个计算密集的进程可能在某段时间表现为一个交互型进程。(通过提升优先级解决)
适用于动态负载和多种类型任务的系统,它可以根据任务的特性和优先级,灵活地调整任务的执行顺序。例如,在通用操作系统中,多级反馈队列调度可以用于同时支持长作业和短作业,平衡系统的响应速度和公平性

多级反馈队列调度中有多个队列,不同队列有不同的优先级。其调度规则如下:

  • 如果优先级 A>B,则运行 A
  • 如果 A=B,则交替运行
  • 工作进入系统时,放在最高优先级
  • 一旦工作用完了其在某一层中的时间配额(无论中间主动放弃了多少次 CPU),就降低其优先级(用于避免恶意程序主动释放 CPU 而持续占用高优先级)
  • 经过一段时间 S,就将系统中所有工作重新加入最高优先级队列(用于解决长时间任务饥饿问题)

如果不知道工作是短工作还是长工作,那么就在开始的时候假设其是短工作,并赋予最高优先级。如果确实是短工作,则很快会执行完毕,否则将被慢慢移入低优先级队列,而这时该工作也被认为是长工作了。通过这种方式,MLFQ 近似于 SJF。

大多数的 MLFQ 变体都支持不同队列可变的时间片长度。高优先级队列通常只有较短的时间片(比如 10ms 或者更少),因而这一层的交互工作可以更快地切换。相反,低优先级队列中更多的是 CPU 密集型工作,配置更长的时间片会取得更好的效果。

有些调度程序将最高优先级队列留给操作系统使用。

操作系统很少知道什么策略对系统中的单个进程和每个进程算是好的,因此提供接口并允许用户或管理员给操作系统一些提示(hint)常常很有用。我们通常称之为建议(advice),因为操作系统不一定要关注它,但是可能会将建议考虑在内,以便做出更好的决定。这种用户建议的方式在操作系统中的各个领域经常十分有用,包括调度程序(通过 nice)、内存管理(madvise),以及文件系统(通知预取和缓存)。

上下文切换的成本不仅仅来自保存和恢复少量寄存器的操作系统操作。程序运行时,它们在 CPU 高速缓存、TLB、分支预测器和其他片上硬件中建立了大量的状态。切换到另一个工作会导致此状态被刷新,且与当前运行的作业相关的新状态被引入,这可能导致显著的性能成本。

在多核 CPU 时,跨 CPU 访问(尤其是写入)共享数据或数据结构时,需要使用互斥原语(比如锁),才能保证正确性。

多核 CPU 为了避免某些核的饥饿,队列少的核会“窃取”队列多的核的任务,以实现负载均衡。

虚拟内存

进程的虚拟地址空间

参见进程的虚拟地址空间

代码中打印的内存地址都是虚拟地址,只有操作系统才知道内存的物理地址。

进程的地址空间从低到高分为:

  • 代码段(Text Segment):代码段是存储程序执行代码的区域,也称为只读区域。在代码段中存储了可执行文件的指令集,这些指令在程序执行时被加载到内存中,并由 CPU 执行。
  • 数据段(Data Segment):数据段通常包含了初始化的全局变量和静态变量,以及在程序执行期间分配的一些全局变量。共享代码也存在此区域(如库文件)。数据段可以分为可读写的数据段和只读的数据段。
  • 堆(Heap):堆是动态内存分配的区域,用于存储程序运行时动态分配的内存。堆的大小可以在程序运行时动态增长或缩小,通过堆管理器(如 malloc()、Java 的new()free() 函数)来分配和释放内存。
  • 栈(Stack):栈是用于存储函数调用相关的数据的区域,如局部变量、函数参数、返回地址等。栈采用先进后出(LIFO)的方式管理数据,每次函数调用都会在栈上创建一个栈帧,函数返回时将其弹出栈。如果递归调用过深,会导致栈溢出。

如果堆、栈间的内存空间耗尽,会导致程序崩溃或系统不稳定,此时会触发垃圾回收或 swap 交换。

当程序调用malloc()时,它会在堆上请求整数的空间,函数返回这样一个整数的地址(成功时,失败时则返回 NULL),然后将其存储在栈中以供程序使用。

许多新语言的都支持自动内存管理,如 Java、Golang、Python、PHP 等,在这些语言中,当你调用类似malloc()的机制来分配内存时(通常用new或类似的东西来分配一个新对象),你永远不需要调用某些东西来释放空间。实际上,垃圾收集器(garbage collector)会运行,找出你不再引用的内存,替你释放它。(所有语言的垃圾自动回收机制回收的是堆内存

垃圾回收时机:

  • 周期性
  • 空闲时间
  • 空间已满

关于堆内存的常见错误:

  • 忘记分配内存:可能导致 Segmentation fault
  • 没有分配足够的内存(或称为缓冲区溢出(buffer overflow)):导致程序故障和崩溃。realloc () 函数用于更改已分配内存的大小或重新分配内存(创建更大的内存空间,并将旧区域复制到新区域,如 go 中的数组扩容)
  • 忘记初始化分配的内存:会读取到随机值(这会在堆、栈都发生。这是因为内存的释放是通过删除变量指向的内存地址实现的,而内存中的内容并不会被清除,因此当新的变量申请内存但未初始化时,会读取到旧的内存值)。calloc() 可以申请内存并初始化为零。
  • 忘记释放内存:导致内存泄露(memory leak)
  • 在用完之前释放内存:导致悬挂指针错误(dangling pointer),即程序崩溃,或读取了新变量的内容
  • 重复释放内存:导致程序崩溃
  • 错误地调用free()free() 只接受之前 malloc() 得到的指针

由于系统中存在两级内存管理,才使得我们在编写短时任务时,在用malloc()申请内存后,程序退出前不调用free()也没有内存泄露发生:

  • 第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收
  • 第二级管理在每个进程中,例如在调用malloc()free()时,在堆内管理。即使你没有调用free(),操作系统也会在程序结束运行时,收回进程的所有内存(包括用于代码、栈,以及相关堆的内存页)。无论地址空间中堆的状态如何,操作系统都会在进程终止时收回所有这些页面,从而确保即使没有释放内存,也不会丢失内存。

因此,对于短时间运行的程序,泄露内存通常不会导致任何操作问题(尽管它可能被认为是不好的形式)。如果你编写一个长期运行的服务器(例如 Web 服务器或数据库管理系统,它永远不会退出),泄露内存就是很大的问题,最终会导致应用程序在内存不足时崩溃。

段错误(也称段异常):如果试图非法访问超出堆边界的地址,硬件就会发现地址越界,因此陷入操作系统,从而终止出错程序。

并发

在并发时,CPU 指令不是以原子方式执行,就可能导致执行结果不符合预期。

线程相比进程,它们拥有相同的地址空间,因此能够访问相同的数据。

类型 优点 缺点 适用场景
互斥锁/互斥量(Mutex) 互斥性强,可保证数据一致性 性能开销大,线程阻塞严重 临界区资源访问时间较长
自旋锁(SpinLock) 因不涉及线程的挂起和唤醒,因此开销低 竞争激烈时 CPU 消耗高,不适合长时间持有锁。在单核 CPU 开销也非常大 多核 CPU锁竞争不激烈,且占用锁时间较短的场景
读写锁(ReadWriteLock) 允许多个线程同时读数据,提高读性能 写操作需要排队,性能略低于互斥锁 读操作远多于写操作的场景,如数据库读写
条件锁(ConditionLock) 可实现线程之间的等待和唤醒 使用复杂,需要额外的条件变量 需要线程之间协作的场景,如生产者 - 消费者问题
分布式锁 可用于分布式系统中,解决锁竞争问题 实现复杂,依赖外部资源,如 Redis 分布式系统中需要保证数据一致性的场景

信号量的讲解:https://www.bilibili.com/video/BV18P41187NV/

互斥量是一种特殊的二值性信号量。

死锁

定义 示例(狭路相逢的两个人) 解决
死锁 在并发系统中,两个或多个进程或线程因互相持有对方所需资源而无法继续执行的情况。 两人互不相让,都在等对方先让开。 见下表
活锁 系统中的一组进程或线程在不断地改变自己的状态,但是无法取得进展,导致系统陷入了一种持续的、无法解决的状态。与死锁不同的是,活锁中的进程或线程是在不断地相互响应,试图解决问题,但最终仍然无法取得进展 两人互相礼让,却恰巧站到同一侧,再次让开,又站到同一侧,同样的情况不断重复下去导致双方都无法通过。 在循环结束时,随机等待一个时间,然后重复执行,以降低线程间的相互干扰。

死锁产生需满足的 4 个条件,只要破坏其中任意一个即可避免死锁:

条件 解决
禁止抢占(no preemption):系统资源不能被强制从一个进程中退出。 trylock
持有和等待(hold and wait):一个进程可以在等待时持有系统资源。 原子的抢锁
互斥(mutual exclusion):资源只能同时分配给一个行程,无法多个行程共享。 用无锁指令,如 CAS
循环等待(circular waiting):一系列进程互相持有其他进程所需要的资源。 有序加锁,避免同时加锁

如果死锁偶尔发生,可以在检测到死锁时通过终止进程或者资源抢占等方式来解除死锁。

哲学家就餐问题:假设餐桌上有 5 位哲学家,每两位间有一把叉子。假设他们必须用两个叉子才能用餐,如何才能避免就餐时死锁的发生?

哲学家就餐问题图示

在 5 人用餐的情况下,最多同时 2 人用餐:

用餐轮数 用餐的哲学家
1 P1 & P3
2 P2 & P4
3 P5 & P2
4 P1 & P3
5 ……

钱迪/米斯拉解法:

  • 初始所有的叉子都是脏的
  • 当哲学家用餐时,向所有邻居发出借叉子的请求
  • 当拥有餐叉的哲学家收到请求时
    • 如果餐叉是干净的(正在用餐),那么他继续留着
    • 如果是脏的(用餐完毕),就擦干净并交出餐叉

钱迪/米斯拉解法适用于任意大的问题,但没有解决饥饿问题:如果某个哲学家一直用餐,就会导致其邻居饥饿,此时可以通过限制就餐时间等限制条件来解决。

CAS

CAS(Compare and Swap)是一种通过 CPU 硬件实现的原子操作,在 CAS 执行期间,CPU 不会被中断,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。

bool compare_and_swap (int *addr, int oldval, int newval)
{
  if ( *addr != oldval ) { // 内存中的值被其他线程修改
      return false;
  }
  *addr = newval; // 内存中的值未被其他线程修改
  return true;
}

CAS 操作通常包含三个数:

  • 内存位置(通常是一个指针)
  • 旧值(也称期望值)
  • 新值(也称当前值)

由于 CAS 操作是通过 CPU 硬件加锁保障数据不会被其他线程修改,无需在代码层面进行加锁,因此被称为乐观锁,而互斥锁则是悲观锁。

CPU 执行 CAS 的步骤:

  1. 当内存中没有值时,CPU 写入值(比如 1),并将该值存储于类似于寄存器的位置(通常称为 CAS 缓存行)。
  2. CAS 操作中,线程提供旧值(1)和新值(比如 2)。处理器会将内存中的旧值与 CAS 缓存行中的值进行比较。
    1. 如果相等,则说明内存中的值未被其他线程修改,CPU 向内存写入新值(2)并更新 CAS 缓存行;(会存在ABA 问题,可通过添加版本号来解决)
    2. 否则,不执行更新操作。

锁必然带来各种困难,业务我们应该尽可能地避免使用锁,除非确信必须使用。

基于事件的并发可查看I/O 多路复用

持久性

磁盘

I/O 操作相比 CPU 操作非常缓慢,为了避免 I/O 期间占用 CPU,于是引入了 DMA(Direct Memory Access),当需要 I/O 操作时,操作系统告诉 DMA 数据存放的位置,要拷贝的大小以及要拷贝到哪个设备(就像卡车司机运货一样)。在 DMA 完成任务后,就会向 CPU 发送一个中断。

磁盘的 I/O 时间:\(T_{I/O}=T_{寻道}+T_{旋转}+T_{传输}\)。从公式中可以看出,寻道和旋转是最昂贵的磁盘操作,因此,应该尽可能以顺序或大块数据的方式读写,以便分摊寻道和旋转时间。现代磁盘性能提升也主要在于传输速率方面,而不是寻道。

在所有可能的寻道中,磁盘上的平均寻道距离是全部距离的 \(1/3\),因此平均寻道时间是完整寻道时间的 \(1/3\)

操作系统将多个磁道相邻的 I/O 任务合并,这样不仅减少了发送到磁盘的请求数量,也降低了磁盘开销。

磁盘结构 如图所示,磁盘结构:

  • A:磁道(最外圈是 0 号);
  • B:几何扇区;
  • C:磁道扇区(第一个扇区是引导扇区);
  • D:簇(扇区组)

4K 对齐 & 碎片整理

4K 对齐就是让每个块填满,从而减少额外的 I/O 操作和提高性能。

对于 HDD,长时间的读写导致磁盘空间不连续,磁头需要多次寻道,因而碎片整理就是让存储空间连续,以提高磁头的顺序读写性能。而 SSD 通过电子原理读写可以快速找到随机数据,另外 SSD 有读写寿命限制,碎片整理的频繁擦写反而会降低 SSD 的使用寿命。

RAID

RAID 把多个硬盘组合成为一个逻辑硬盘,因此,操作系统只会把它当作一个实体硬盘。

RAID 等级 备份方式 最少硬盘 可用容量 容错能力 顺序读 顺序写 随机读 随机写 典型应用场景 核心优缺点与底层机制总结
0 条带化拆分 2 \(n\) 0 (无) \(n\) \(n\) \(n\) \(n\) 视频剪辑缓存、临时渲染、追求极致速度 优: 所有性能满血满配
缺: 零安全性,坏一盘全毁
1 纯镜像备份 2 1 \(n-1\) \(n\) \(1\) \(n\) \(1\) 系统盘启动、核心数据库日志、个人重要备份 优: 安全性极高,读取速度翻倍
缺: 空间利用率极低(成本高)
4 单一专用校验盘 3 \(n-1\) 1 盘 \(n-1\) \(n-1\) \(n-1\) 1 (极慢) (目前基本淘汰,或被专有文件系统替代) 优: 容量利用率尚可
缺: 致命瓶颈,所有校验码要挤进同一块盘,随机写卡死
5 奇偶校验打散 3 \(n-1\) 1 盘 \(n-1\) \(n-1\) \(n\) \(n/4\) (慢) 个人NAS、小型企业备份、读多写少的温数据 优: 兼顾容量与基础安全
缺: 写惩罚大(4);大容量HDD掉盘重建时间极长,极易发生二次掉盘导致数据全毁
6 双重打散校验 4 \(n-2\) 2 盘 \(n-2\) \(n-2\) \(n\) \(n/6\) (极慢) 大容量企业级存储、冷数据归档、大型监控库 优: 允许同时坏两块盘,大盘重建时更安全
缺: 写惩罚极大(6),随机写入性能垫底
10 先镜像后条带 4 \(n / 2\) 至少 1盘
至多 \(n/2\)
\(n\) \(n/2\) \(n\) \(n/2\) 高并发数据库、企业核心业务、虚拟化平台 优: 完美平衡安全与随机读写性能,写惩罚仅为2
缺: 硬盘成本高,最少需4块盘

文件

文件描述符(File Descriptor)只是一个整数,是每个进程私有的,在 Unix 系统中用于访问文件。@I/O 多路复用 @系统级 I/O

strace可以跟踪程序在运行时所做的每个系统调用。

# foo 文件内容为 hello
prompt> strace cat foo
...
open("foo", O_RDONLY|O_LARGEFILE) = 3 // O_RDONLY 为仅读取;
                                      // O_LARGEFILE 允许打开大于 2G 或 4G 的文件;
                                      // 3 即为 fd 号。每个进程打开时就已经预先分配了 0、1、2(即标准输入、输出、错误),因此打开的文件 fd 从 3 开始
read(3, "hello\n", 4096)          = 6 // 3 为 fd,告诉系统读取了哪个文件
                                      // 第 2 个参数为放置 read()结果的缓冲区
                                      // 缓冲区大小(4KB)
                                      // 6 为读取的字节数(5 个字符+结束符)
write(1, "hello\n", 6)            = 6 // write 此时将 read 读取的内容标准输(1)出到屏幕
hello
read(3, "", 4096)                 = 0 // read 试图从 3 中读取更多内容
close(3)                          = 0 // 调用 close 关闭 fd 为 3 的文件
...
prompt>

lseek 用于从文件指定偏移量读取:off_t lseek(int fildes, off_t offset, int whence); 。第一个参数 fildes 为 fd。

当程序调用write()写入文件时,文件系统会将这些文件写入内存的 buffer 中,之后再写入存储设备。这有助于提高 I/O 性能,但对 DBMS 来说,可能导致数据的丢失或更新不及时,因此 Unix 中提供了fsync(int fd)接口,强制将所有脏数据(即尚未被写入磁盘的数据)写入磁盘。

当使用mv命令重命名文件名时,底层调用了rename(char *old, char *new),这是一个原子调用

文件信息的元数据存储在 inode 的 stat 结构体中,可通过stat()fstat()来查看。

struct stat {
    dev_t     st_dev;       /* ID of device containing file */
    ino_t     st_ino;       /* inode number */
    mode_t    st_mode;      /* protection */
    nlink_t   st_nlink;     /* number of hard links */
    uid_t     st_uid;       /* user ID of owner */
    gid_t     st_gid;       /* group ID of owner */
    dev_t     st_rdev;      /* device ID (if special file) */
    off_t     st_size;      /* total size, in bytes */
    blksize_t st_blksize;   /* blocksize for filesystem I/O */
    blkcnt_t  st_blocks;    /* number of blocks allocated */
    time_t    st_atime;     /* time of last access */
    time_t    st_mtime;     /* time of last modification */
    time_t    st_ctime;     /* time of last status change */
};
prompt> echo hello > file
prompt> stat file
  File: 'file'
  Size: 6 Blocks: 8    IO Block: 4096    regular file
Device: 811h/2065d Inode: 67158084    Links: 1
Access: (0640/-rw-r-----) Uid: (30686/ remzi) Gid: (30686/ remzi)
Access: 2011-05-03 15:50:20.157594748 -0500
Modify: 2011-05-03 15:50:20.157594748 -0500
Change: 2011-05-03 15:50:20.157594748 -0500

空目录并不是空的,它包含两个条目:自身的引用(.)和父目录的引用(..)。

创建文件时,实际上做了两件事:

  1. 构建一个结构(inode),它将跟踪几乎所有关于文件的信息,包括其大小、文件块在磁盘上的位置等等
  2. 将人类可读的名称链接到该文件,并将该链接放入目录中(调用link()实现)

当使用rm命令删除文件时,底层调用的是unlink(filename),然后将引用计数减 1,当引用计数为 0 时,文件系统释放 inode 和相关数据块,从而真正删除该文件。

prompt> echo hello > file
prompt> stat file
... Inode: 67158084   Links: 1 ...
prompt> ln file file2
prompt> stat file
... Inode: 67158084   Links: 2 ...
prompt> stat file2
... Inode: 67158084   Links: 2 ...
prompt> ln file2 file3
prompt> stat file
... Inode: 67158084   Links: 3 ...
prompt> rm file
prompt> stat file2
... Inode: 67158084   Links: 2 ...
prompt> rm file2
prompt> stat file3
... Inode: 67158084   Links: 1 ...
prompt> rm file3
特点 OS 创建动作
硬链接 1. 多个硬链接有相同的 inode 号,并且各自可以独自修改
2. 不能链接到目录(避免循环链接)
3. 不能链接到其他磁盘分区中的文件(因为 inode 号在特定文件系统中唯一,而不是跨文件系统(注:ext4、NTFS、FAT32 是不同的文件系统))
1. 在目标目录中创建新的文件名
2. inode 的引用计数+1
软链接/符号链接 1. 有独自的 inode 号
2. 软连接本身是一种文件类型
3. 源文件被删则软连接文件也失效
4. 软链接目标文件的大小主要取决于源文件名的长短
1. 在目标目录中创建新的文件名
2. 创建新的 inode,新的 inode 中包含指向原始文件的路径
pi@pi:~ $ ls -il
total 0
1376 -rw-r--r-- 2 pi pi 0 Apr 11 21:30 a                               // touch a
1644 -rw-r--r-- 1 pi pi 0 Apr 11 21:30 b                               // cp a b
1376 -rw-r--r-- 2 pi pi 0 Apr 11 21:30 c                               // ln a c
1717 lrwxrwxrwx 1 pi pi 1 Apr 11 21:31 d -> a                          // ln -s a d
1950 -rw-r--r-- 1 pi pi  0 Apr 11 23:40 hjkasdhfjkdhgjkhdfjkghk        // 创建软链接的源文件
1963 lrwxrwxrwx 1 pi pi 23 Apr 11 23:40 u -> hjkasdhfjkdhgjkhdfjkghk   // 软链接目标文件大小为源文件名的字符长度

mount命令可将所有文件系统统一到一棵树中,而不是拥有多个独立的文件系统,这让命名统一而且方便。

# 将 ext3 格式的/dev/sda1 挂载到/home/users
prompt> mount -t ext3 /dev/sda1 /home/users

文件系统 & inode

文件系统必须记录每个文件的信息。该信息是元数据(metadata)的关键部分,并且记录诸如文件包含哪些数据块(在数据区域中)、文件的大小,其所有者和访问权限、访问和修改时间以及其他类似信息的事情。为了存储这些信息,文件系统通常有一个名为 inode 的结构。

简单的文件系统使用块或对象数组,而复杂的文件系统使用基于树的结构。

文件系统结构图

文件系统结构图

图中每个块 4KB,64 个块共计 256KB。前 8 个块用于存储 metadata,后 56 个块用于存储数据。

通常 inode(index node)占用 128/256 字节。假设 inode 占用 256B,一个 4KB 块可容纳 16 个 inode,图中 5 个 inodes 块可容纳 80 个 inode,

id是位图(bitmap)结构,用于指示相应的对象/块是空闲还是被占用(注意每个块是 4KB,因此每个块可指示 \(4 \times 1024 \times 8 = 32,768\) 个块的状态)。

S 是超级块,其中包含该文件系统诸如多少个 inode 和数据块(此例子中为 80 和 56)、inode 表的开始位置(块 3),以及标识文件系统类型。因此在挂载文件系统时,操作系统将首先读取超级块,初始化各种参数,然后将该卷添加到文件系统树中。当卷中的文件被访问时,系统就会知道在哪里查找所需的磁盘上的结构。

综上,挂载文件系统后,首先读取文件系统的超级块,然后获知 inode 表的起始位置、inode 和数据块数量,再根据 inode 表的指示对文件进行数据读写操作。由于不同的文件系统有不同的 inode 表,因此硬链接不能跨文件系统。

过多的文件耗尽 inode,导致系统异常的示例:

  • 耗尽 inode 导致系统异常的示例 1
    耗尽 inode 导致系统异常的示例 1
  • 耗尽 inode 导致系统异常的示例 2
    耗尽 inode 导致系统异常的示例 2

从图中可以看出,/dev/vda1 磁盘空间使用率只有 47%,但 inode 使用率却达到 100%,经排查后发现,Laravel 的 cache 目录产生了大量小文件,耗尽了 inode。

ext2 的 inode 字段:

名称 大小(字节) 用途
mode 2 读、写、执行权限
uid 2 所有者
size 4 占用空间大小
time 4 最近的访问时间
ctime 4 创建时间
mtime 4 最近的修改时间
dtime 4 inode 的删除时间
gid 2 所属组
links_count 2 硬链接数
blocks 4 为该文件分配的块数量
flags 4 ext2 如何使用该 inode
osd1 4 OS 相关的字段
block 60 一组磁盘指针(共 15 个)
generation 4 文件版本(用于 NFS)
file acl 4 除了 mode 位外的新的许可模式
dir acl 4 目录的访问控制

在大多数 Unix 系统中,根的 inode 为 2。

文件读取时间线 如表40.3所示,多次访问 /foo/bar 文件,路径中的每一级都要读取其 inode 信息,并更新文件的最后访问时间。

每次写入文件在逻辑上会导致 5 个 I/O:

  1. 一个读取数据位图(然后更新以标记新分配的块被使用)
  2. 一个写入位图(将它的新状态存入磁盘)
  3. 一次是读取 inode
  4. 另一次是写 inode(为了更新块的位置)
  5. 最后一次写入真正的数据块本身

文件创建时间线 创建文件的 I/O:

  1. 读取 inode 位图(查找空闲 inode)
  2. 写入 inode 位图(将其标记为已分配)
  3. 写入新的 inode
  4. 写入目录的数据
  5. 读写

现代 DBMS 数据写入顺序 写缓冲的好处:

  1. 对同一个块的多次写入打包,减少 I/O 次数
  2. 对不同块的写入打包,通过系统调度减少 I/O 时间
  3. 避免写入有很快删除的操作带来的 I/O

现代文件系统将写入在内存中缓冲 5~30s。数据库这类软件为了避免在写缓冲更新到磁盘前丢失数据,可通过调用fsync()将数据强制写入磁盘。

崩溃一致性问题

解决方案一:FSCK

FSCK(file system checker,文件系统检查程序)在文件系统挂载并可用之前运行。FSCK 扫描顺序如下:

  1. 扫描超级块,确保文件系统容量大于分配的块数
  2. 扫描 inode、间接块、双重间接块等
  3. 检查 inode 是否被损坏或其他异常
  4. 验证 inode 的引用链接数。如果发现已分配的 inode,但没有目录引用它,则会将其移动到lost found目录
  5. 检查重复指针,即两个不同的 inode 引用同一个块
  6. 检查坏块指针,即指针明显指向超出其有效范围的某个指针
  7. 目录检查,确保整个层次结构中没有目录的引用超过一次(硬链接不能指向目录)

FSCK 随着磁盘空间的爆发式增长显得力不从心,同时为了修复仅有的几个坏块而扫描整个磁盘也十分低效。因此,现代操作系统通过以下手段对 FSCK 进行了优化:

  • 快速检查:只检查文件系统的元数据和关键部分
  • 增量检查:只检查自上次检查以来发生更改的部分
  • 异步检查:允许系统启动后运行 FSCK
  • 日志文件系统(如 ext3、ext4、btrfs、NTFS):使用事务日志来记录文件系统的更改,发生崩溃或断电时,只需检查事务日志并回滚未完成的更改

解决方案二:WAL

  • ext2 结构
    ext2 结构
  • ext3 的 Journal 结构
    ext3 的 Journal 结构
  • ext3 结构
    ext3 结构
  • Journal 的超级块中记录最新和最旧事务
    Journal 的超级块中记录最新和最旧事务

ext3 相比 ext2 来说,只是增加了日志部分,以用于崩溃防止数据丢失。

在 ext3 的 Journal 中,如果系统崩溃导致 db 部分损坏,在系统恢复后,则会将受损的数据写入磁盘,如果受损数据发生在超级块上,则可能导致文件系统无法被挂载。因此,在 ext4 中,Journal 的开始和结束部分新增了内容校验和,这样在系统崩溃恢复后,可以快速判定数据是否受损。

在 Linux 中,可通过 df -T 命令查看文件系统类型

当我们向存储系统以先后顺序写入数据 A 和 B 时,由于 CPU 的乱序执行,可能导致 B 先被写入,而写屏障(write barrier)则可以确保写入时以特定的顺序写入。

问:WAL 也是写入磁盘,直接把数据写入磁盘不就好了吗?为什么要写两次?

答:WAL 有以下几个作用:

  1. 原子性保证:在执行事务等原子操作时,如果中途断电,会导致 DB 无法保证原子性,而有了 WAL,则可以在崩溃后回滚以保证原子性
  2. 并发控制:数据变更操作在 WAL 日志中是有序的,这有助于协调多个并发操作,确保数据一致性
  3. 性能提升:
    1. WAL 是顺序写入,相比磁盘的随机写入性能更高
    2. 批量将数据写入 DB,也能降低对磁盘的频繁访问
区别 场景 实际应用
完整数据日志 在日志中完整记录变更的数据,确保数据崩溃可恢复。
缺点:日志体积大、写入较慢 对数据的增、删、改、查等操作,如数据库管理软件或其他数据密集型应用程序 MySQL、PostgreSQL
元数据日志 在日志中不记录变更的数据,适用于动作变更日志,而非数据变更。
优点:体积小、写入块 主要用于记录磁盘数据的结构性或位置性变更,比如文件或目录的创建、删除、移动等操作 ext3/ext4、NTFS、JFS、XFS、ZFS

日志结构文件系统

写入磁盘时,LFS 首先将所有更新(包括元数据)缓冲在内存段中。当段已满时,它会在一次长时间的顺序传输中写入磁盘,并传输到磁盘的未使用部分。LFS 永远不会覆写现有数据,而是始终将段写入空闲位置。由于段很大,因此可以有效地使用磁盘,并且文件系统的性能接近其峰值。

NFS

在 NFS 中,文件句柄有 3 个重要组件:卷标识符、inode 号和世代号。这 3 项一起构成客户希望访问的文件或目录的唯一标识符。卷标识符通知服务器,请求指向哪个文件系统(NFS服务器可以导出多个文件系统)。inode 号告诉服务器,请求访问该分区中的哪个文件。最后,复用 inode 号时需要世代号。通过在复用 inode 号时递增它,服务器确保具有旧文件句柄的客户端不会意外地访问新分配的文件。

NFS 中崩溃恢复的核心在于,大多数常见操作具有幂等性。

评论