一、VFS 和“一切皆文件”
习惯 Windows 的都知道,文件是文件,注册表是注册表,网络连接是网络连接,硬件设备是硬件设备。
但 Linux 内核用一层抽象,直接抹平这些资源的差异。一切皆文件!
本质是:统一的接口。
不管底层的资源是什么,Linux 都向用户空间提供标准的系统调用:
Linux 为实现一切皆文件 ,引入 **VFS (Virtual File System)**。VFS 是一个内核软件层,介于用户程序和具体文件系统之间。
系统分层架构:
VFS 比较像面向对象编程的多态。
- Ext4、NTFS、FAT32 必须实现这些接口(子类)。
Linux 内核用 C 语言的结构体 (struct) 模拟面向对象的类。
(1)超级块:struct super_block。
- 代表一个已挂载的文件系统。把一个 U 盘挂载到
/mnt,内核就会创建一个超级块对象。 - 包含信息: 文件系统的类型(是 FAT 还是 Ext4?)、块大小、总大小、根目录在哪里。
- 方法:
alloc_inode (创建一个新 inode), write_inode (把 inode 写回磁盘)。
(2)索引节点:struct inode。
- 包含信息: 文件的元数据。包括权限、所有者、文件大小、时间戳、数据块在磁盘的位置。
- 方法:
inode_operations。比如 create (创建), mkdir (建目录), lookup (查找)。
(3)目录项:struct dentry。
- 包含信息: 文件名、指向对应 Inode 的指针、指向父目录的指针。
- 目的: 加速查找。Linux 维护一个 Dentry Cache (dcache) 。
dentry 对象就存在内存,快速把路径字符串映射到 inode。
(4)文件对象:struct file。
- 包含信息:当前读写位置 (f_pos), 这是最重要的!两个进程打开同一个文件,会有两个
file 对象,各自维护自己的读写进度。还有指向 dentry 的指针、打开模式 (只读/读写)。 - 方法:
file_operations。包含 read, write, fsync, mmap。
对象之间的关系:
进程 (Process)
|
文件描述符表 (fd table)
[0: stdin ]
[1: stdout]
[2: stderr]
[3: fd] ----> struct file (文件对象)
|-- f_pos: 1024 (当前读到哪了)
|-- f_op: ext4_file_operations (操作函数表)
|-- f_path.dentry ------------------------+
|
v
struct dentry (目录项)
|-- d_name: "a.txt"
|-- d_inode -----------+
|
v
struct inode (索引节点)
|-- i_ino: 34211 (ID)
|-- i_size: 2048
|-- i_op: ext4_inode_ops
|-- 磁盘块映射: [Block 100, Block 101]
用图表示:
read(fd, buf, 100) ,内核发生了什么?
- CPU 触发软中断(就是系统调用),从用户态进入内核。
- 内核根据传入的
fd,在当前进程的“文件描述符表”找到对应的 struct file 对象。 - VFS 检查文件是不是以“读”模式打开。然后,VFS 调用
struct file的 f_op->read 函数指针。如果是 Ext4,调用 ext4_file_read_iter;如果是 Socket,调用 sock_read_iter。 - 如果是Ext4,驱动通过
struct file 找到 dentry,再找到 inode。 通过 inode 的映射表,计算出文件偏移量对应的物理磁盘块号。 - 内核先看这些数据是不是已经在内存(Cache)里了?命中, 直接从内存拷贝到用户提供的
buf,非常快,不碰磁盘。未命中, 发起 BIO (Block I/O) 请求。 - BIO 请求会放入电梯调度队列(排序、合并),最终由磁盘驱动程序控制磁头读取数据,通过 DMA 传送到内存。
二、内存管理
先知道两个概念:
- 外部碎片: 内存还有剩余,但都是零散的小块,不能满足大块内存申请。
- 内部碎片: 只想要 50 Bytes,非要分给 4KB(一页),浪费 4046 Bytes。
Linux 的伙伴系统解决外部碎片,SLAB/SLUB 解决内部碎片。
伙伴系统直接管理物理内存页(4KB)。任务是分配连续的页框。
伙伴系统不按字节管理,而是按“阶”来管理。维护 11 个链表(Order 0 ~ Order 10)。
- Order 0 链表挂的都是大小为 页 (4KB) 的空闲块。
- Order 1 链表挂的都是大小为 页 (8KB) 的空闲块。
- Order 10 链表挂的都是大小为 页 (4MB) 的空闲块。
要申请 3 页 内存。
- 系统会向上取整到 2 的幂,即申请 4 页(Order 2)。
- 系统检查 Order 2 链表,如果有空闲块,直接给。
- 如果没有, 会去 Order 3 (8页) 链表找。
- 如果 Order 3 有块,把这 8 页一刀两断,切成两个 4 页。一个 4 页给出去用。另一个 4 页(也就是“伙伴”)挂回到 Order 2 的链表。
用完释放那 4 页:
- 如果是,合并成一个 8 页的大块,放入 Order 3 链表。
伙伴系统的最小单位是 4KB(一页)。内核有非常多的微小对象,比如 task_struct、inode,只有几百字节。
如果直接用伙伴系统,每存一个对象就浪费大半页内存。
SLAB/SLUB 就是专门针对小对象的 对象池 技术。
SLAB 分配器有三个层级:
- Cache (高速缓存): 每种特定类型的对象都有一个 Cache。比如有一个专门存放
task_struct 的 Cache,还有一个专门存放 inode 的 Cache。 - Slab (内存块): Cache 里面包含多个 Slab。一个 Slab 由连续的几个物理页组成(从伙伴系统申请来的)。
- Object (对象): Slab 被切分成无数个固定大小的小块,这就是 Object。
每个 Cache 维护三个链表,对应 Slab 的三种状态:
- Slabs Partial: 住了部分对象,还有空位。(优先从这里分配!)
- Slabs Empty: 完全空闲。(如果内存紧张,这些会被退还给伙伴系统)。
SLAB 结构:
SLAB、SLUB、SLOB 这三个名字,是这段逻辑的不同实现版本:
- SLAB: 最早的经典设计。设计复杂,元数据(管理数据)放在 Slab 内部,内存开销稍大,对 CPU 缓存队列管理较复杂。
- SLUB (Unqueued SLAB):现在的绝大多数 Linux 发行版都在默认用。是对SLAB的简化,去掉了复杂的队列,把元数据直接塞进
struct page 结构体里(复用页描述符的字段),很大的节省内存,对多核 CPU 更友好。 - SLOB: 简化最多的版本,专门为嵌入式系统(内存极小)设计。
三、完全公平调度器(CFS)
Linux 2.4 时代的调度器是 O(n) ,随着进程增多,调度器要遍历所有进程来找到谁运行,效率很低。
Ingo Molnar 设计的 O(1) 调度器解决了这个问题。不管系统有多少个进程,都能在恒定的时间找到下一个该运行的进程。
O(1) 调度器为每个 CPU 维护一个运行队列,队列里有两个数据结构:
- Active 数组: 存放还有时间片剩余、等待运行的进程。
- Expired 数组: 存放时间片用完、等待下一轮调度的进程。
每个数组本质上是 140 个链表(对应 0-139 优先级)。
还有一个 Bitmap,每一位对应一个优先级链表。如果某个优先级的链表里有进程,对应的位就置 1。
调度流程:
- 找进程: 调度器不用遍历链表。用 CPU 的硬件指令直接扫描 Bitmap,直接就能找到第一个非空的优先级链表。
- 时间片耗尽: 一个进程把时间片用光后,内核会重新计算它的优先级,然后把它扔到 Expired 数组 。
- 数组交换:Active 数组 的进程全部执行完(变空),调度器只要交换 Active 和 Expired 的指针。刚才过期的进程又变成活跃进程。
O(1) 调度器在服务器(吞吐量优先)非常好,但到桌面(交互优先)就很差。
为保证看视频不卡顿(交互进程),调度器要“猜”哪些进程是交互式的,动态提高优先级。
但是,编译器的某些行为也很像交互进程。这样一来,代码就非常复杂,很难维护,经常猜错。
Ingo Molnar(没错,还是他,自己革了自己的命)在 2.6.23 引入 CFS。
理想的多任务处理器:可以无限细分的 CPU。N 个进程,每个进程应该在任何时刻都是获得 1/N 的 CPU 处理能力。
当然,现实的 CPU 一次只能运行一个进程。所以 CFS 就模拟这种“完美公平”。
CFS 引入一个全新的概念:vruntime。
定义: 进程已经在 CPU 上运行的“虚拟”时间。
公式:vruntime += 实际运行时间 * (NICE_0_WEIGHT / 进程权重)
- 普通进程: 权重为 1,
vruntime = 实际运行时间。 - 高优先级进程: 权重很大。运行 100ms,
vruntime 只增加 50ms。(时间过得慢,在红黑树中向右移动得慢,能获得更多 CPU)。 - 低优先级进程: 权重很小。运行 100ms,
vruntime 增加 200ms。(时间过得快,把 CPU 让给别人)。
调度准则只有一条:谁的 vruntime 最小,就让谁运行。
CFS 不再用数组,而是用一棵按 vruntime 排序的时间序红黑树来组织所有等待运行的进程。
- 树的左侧:
vruntime 小的进程(饥饿的、缺 CPU 的)。 - 树的右侧:
vruntime 大的进程(刚刚跑过的、不缺 CPU 的)。
调度流程:
- 调度器只会选择红黑树最左边 的节点运行。因为
vruntime 最小。 - 进程运行期间,时钟中断会不断增加它的
vruntime。 - 随着
vruntime 变大,在树中的位置逻辑上会慢慢向右移动。一旦发现树里有比当前进程 vruntime 更小的节点,当前进程就会被抢占。 - 被抢占的进程重新插入红黑树,根据新的
vruntime 找到正确的位置。
CFS 红黑树:
| | |
|---|
| 核心结构 | | |
| 复杂度 | O(1) | O(log N) |
| 调度依据 | | 虚拟运行时间 (vruntime) |
| 交互性处理 | 靠猜 | 交互进程常睡眠,vruntime 极小,唤醒后自动抢占最左侧 |
| 公平性 | | |
| 代码逻辑 | | |
四、RCU锁机制
RCU (Read-Copy-Update) 主要是解决 读写锁 在多核 CPU 下的性能瓶颈。
读写锁的逻辑: 可以多个读者同时进入,但写者必须独占。
虽然读者之间不阻塞,但它们必须去修改同一个锁的计数器 。
- Core 0 修改了锁,Core 1 的缓存就失效了,必须重新从内存拉取。
- 结果就是:没有写者,光是一堆读者在那儿抢锁,性能也会随着 CPU 核心数增加而断崖式下跌。
RCU 让读者完全没有任何同步开销。不拿锁,没有原子操作,甚至没有内存屏障。
RCU 的全称概括三个步骤:
- Read (读): 读者直接读,不做任何加锁动作。
- Copy (拷贝): 写者要修改数据时,不直接改原数据,而是拷贝一份副本,在副本上修改。
- Update (更新): 写者用一个原子指针操作,把全局指针指向新的副本。
看起来很简单,但最大的难题是:旧的数据什么时候能删?
因为可能还有读者正在读旧数据!如果写者把指针一换,马上就把旧内存 free 掉,正在读旧数据的读者就会崩溃。
RCU 加入了 延迟释放 和 宽限期。
读者代码非常的简单:
rcu_read_lock(); // 1. 标记进入读临界区
p = rcu_dereference(global_ptr); // 2. 获取指针
if (p != NULL) {
do_something(p->data); // 3. 读数据
}
rcu_read_unlock(); // 4. 标记退出读临界区
rcu_read_lock 的约束是:在临界区内,不能发生上下文切换(不能睡眠)。
写者的技术实现逻辑:
// 1. 分配新内存
new_p = kmalloc(...);
// 2. Copy:拷贝旧数据到新内存
memcpy(new_p, old_p, ...);
// 3. Modify:修改新内存里的数据
new_p->field = new_value;
// 4. Update:原子替换指针,让新读者看到新数据
rcu_assign_pointer(global_ptr, new_p);
写者不能马上 kfree(old_p),必须等待。
// 5. 等待宽限期
synchronize_rcu();
// 6. 没人读 old_p 了,安全释放
kfree(old_p);
宽限期是指:从 指针被替换 那一刻开始,到 所有在替换前就已经开始的读操作 全部结束为止的时间段。
RCU 的读者在 rcu_read_lock() 和 unlock() 之间,一定会发生进程切换。
所以,内核只要检测到所有 CPU 都发生了一次上下文切换,或者都经历了一次 Idle 状态,就可以断定:所有 CPU 上原来的读临界区肯定都结束了。
RCU 时序:
synchronize_rcu() 是阻塞的,写者要傻等。那可不行。
Linux 有异步接口 call_rcu(),让写者不等待。把 释放旧内存 封装成一个回调函数,挂到一个链表上,然后直接返回。
内核检测到宽限期结束后,由专门的软中断或者内核线程批量执行这些回调函数,释放内存!
五、eBPF
eBPF (Extended Berkeley Packet Filter) 是 Linux 内核过去十年最有革命性的技术,没有之一。
eBPF 之前,想修改内核的行为,只有两条路:
eBPF 在不修改内核源码、不重启机器、安全的前提下,让用户在内核运行自定义的代码。
eBPF 本质是一个运行在 Linux 内核的寄存器虚拟机。
一个 eBPF 程序从写出来到运行,要经历:
- 编译: 用 LLVM/Clang 把 C 代码编译成 eBPF 字节码。
- 加载: 通过
bpf() 系统调用,把字节码传给内核。 - 内核的 Verifier 会对字节码进行全面检查:有没有死循环?有没有非法内存访问?有没有未初始化的变量?
- JIT 编译 (Just-In-Time): 内核的 JIT 编译器会把字节码实时翻译成本地机器码。
- 挂载: 把程序挂载到指定的钩子 (Hook) 上。
架构:
eBPF 程序运行在内核里,用户程序运行在用户态,怎么通信?
用 Maps。Maps 是内核通用的键值对存储结构。
eBPF 程序不能随意调用内核函数(那样不安全)。内核有白名单函数,叫 Helper Functions。
bpf_ktime_get_ns(): 获取当前时间。bpf_trace_printk(): 打印调试信息。bpf_map_lookup_elem(): 查找 Map。
六、namespace 和 cgroups
Namespace 的本质是欺骗。通过修改进程的视觉,让进程以为自己独占整个操作系统。
如果所有进程共享全局资源:
会容易出现冲突,Namespace 就是解决这个问题。
Linux 内核六个核心 Namespace:
| | |
|---|
| PID | | 容器里的进程以为自己是 PID 1 (上帝进程),但在宿主机看到的不是。 |
| NET | | 容器拥有独立的网卡 (eth0)、IP 地址、路由表、iptables 规则。 |
| MNT | | |
| UTS | | 容器可以有自己的 Hostname,不影响宿主机。 |
| IPC | | 容器内的进程不能通过共享内存或信号量跟宿主机进程通信。 |
| USER | | 容器里的 root 用户 (UID 0),在宿主机上可能只是一个普通用户 (UID 1000)。 |
PID Namespace 是最神奇的一个。
内核为进程维护多层 PID 映射关系。进入容器后,getpid() 系统调用返回的是映射后的虚拟 PID。
只有有隔离还不够。如果一个容器里的进程吃光宿主机的内存或 CPU,整个系统都会崩溃。Cgroups 就是防止这种情况。
Cgroups 是 Linux 内核提供的一种机制,可以把一组进程组织成层级结构,并对这个层级树上的节点应用资源限制。
以文件系统的形式暴露给用户,挂载在 /sys/fs/cgroup。
子系统:
- cpu: 限制 CPU 使用率。
cpu.cfs_quota_us设定周期内能运行多少微秒。 - memory: 限制内存使用量。
memory.limit_in_bytes设定最大内存。如果容器超出这个限制,内核会杀掉容器里的进程。 - blkio: 限制磁盘 I/O。限制读写速度 (IOPS 或 BPS),防止某个容器把硬盘写废,导致其他容器卡顿。
- pids: 限制进程数量。防止Fork Bomb(进程无限自我复制,耗尽系统 PID 资源)。
Cgroups 的结构是一个树状目录。
把 Namespace 和 Cgroups 结合在一起。
所谓的“容器”,在 Linux 内核眼里,根本就不存在!
内核没有一个叫 struct container 的数据结构。
Docker 容器 只是一个普通的进程。只是这个进程被 Namespace 隔离,看不到其他进程,看不到真实网络。又被 Cgroups 限制住,只能用指定的 CPU 和内存。再配合 OverlayFS(分层文件系统)换一个独立的根目录。
这就是为什么 Linux 容器比虚拟机(VM)轻量得多的原因:没有虚拟硬件,没有运行独立的 OS 内核,只是宿主机上的一个被隔离、被限制的进程而已。
七、统一设备模型 和 sysfs
Linux 设备模型是把硬件管理抽象成三个实体:**Bus (总线)、Device (设备)、Driver (驱动)**。
总线是 CPU 和设备之间通信的通道。内核的总线也是一个容器。维护两个链表:
有一个新设备插入,或者有一个新驱动加载,总线就会遍历链表,调用 match() 函数,看是否匹配。
设备代表物理上的硬件。
驱动代表软件逻辑。
为实现这套模型,内核引入一个基类:Kobject。可以理解为面向对象语言的 Object 类。
- 层次结构:
parent 指针指向父对象,构成树状结构。 - Sysfs 映射: 每个 Kobject 都在 sysfs 对应一个目录。
sysfs 是一个基于内存的虚拟文件系统,挂载在 /sys 目录下。
sysfs 不存文件,是内核数据结构的可视化窗口。
sysfs 目录下的文件,其实是内核变量的接口。
- 读文件: 触发内核的
show() 函数,把内核变量转成字符串。 - 写文件: 触发内核的
store() 函数,把字符串转成参数,修改内核行为。