OS随记

OS随记

感觉学的东西还是太乱了

没有体系化

仅用以记录一下自己学习的一些操作系统内容

因为我每次学完就忘了

所以打下来

也许能加深记忆?

同时

输出倒逼输入

不错

仅供参考…

部分由ai生成

部分为自己的理解

其实ai用来教学

还是很不错的嘛

开始吧

进程与线程

你知道程序到底是怎么跑起来的吗?

为什么多线程会出bug?

为什么浏览器有时候会卡死?

Unix进程控制接口

优雅(Windows😄)

fork() -> 创建子进程

复制当前进程,生成一个几乎完全相同的子进程,父进程返回子进程PID,子进程返回0

1
2
3
4
5
6
pid_t pid = fork();
if (pid == 0) {
printf("I am the child\n");
} else {
printf("I am the parent, child pid=%d\n", pid);
}

exec() -> 执行新程序

用另一个程序替换当前进程的内存空间,PID不变,但代码,数据,堆栈全部被新程序替换

1
2
execl("/bin/ls", "ls", "-l", NULL);  // 子进程执行 ls
perror("exec failed"); // exec 失败才会执行

wait() -> 等待子进程

父进程等待子进程结束,获取其退出状态

1
2
3
int status;
pid_t pid = wait(&status);
printf("child %d exited with code %d\n", pid, WEXITSTATUS(status));

用以防止僵尸进程(Zombie Process)

exit() -> 终止进程

进程主动退出,返回一个状态码给父进程

1
2
printf("Exiting...\n");
exit(0); // 0 表示正常退出

kill() -> 给进程发送信号

可以用来终止通知其它进程

1
kill(pid, SIGTERM);  // 给 pid 发送终止信号

tips:kill并不一定真的杀掉进程,取决于进程是否捕获/忽略该信号!

如图

进程状态机

进程在生命周期中有三种核心状态

  • 就绪(Ready) -> 等待CPU

  • 运行(Running) -> 正在执行

  • 阻塞(Blocked) -> 等待I/O或事件

如图

关键:进程不能直接从阻塞变运行,必须先变就绪

因此,sleep()不占CPU,while(true)却能让你的CPU直接飙到100%!

实操:

使用top命令查看系统当前的进程列表和资源占用

如图

调试程序死锁时如何判断进程卡在哪里?

待补充

进程 vs 线程

如图

进程是资源分配的基本单位

线程是CPU调度的基本单位

依照我的理解,比如你启动了浏览器,这就作为一个进程开始运行,而浏览器中的各种功能模块,比如界面刷新,页面渲染,网络请求,JavaScript执行,视频解码等等,又是其中的线程

进程拥有独立的地址空间,文件描述符与信号处理

同一进程内的线程共享堆内存和全局变量,但各自有独立的寄存器状态,在CPU上是作为独立的执行单元被调度的

因此,一个浏览器进程里可以同时跑多个线程

比如负责渲染网页的线程在解析HTML,负责加载资源的线程在下载图片,负责响应用户操作的线程在更新界面

它们在使用的体验上像是并行发生,就是因为线程级别的调度让CPU在它们之间快速切换

如图

区别一下并行并发

二者有如下核心区别:

1.每个进程拥有独立的内存空间,文件描述符等资源,创建和切换的成本很高,而同一进程内的多个线程共享这些资源,创建和切换的开销要小得多

2.进程之间是相互隔离的,一个进程崩溃通常不会影响其他进程,但线程之间没有这种隔离,一个线程的崩溃往往会导致整个进程退出

3.进程间通信(IPC)需要借助管道,消息队列等复杂机制,而线程间可以直接访问共享内存,通信效率更高,但这也带来了需要处理数据竞争,死锁等同步问题的复杂性

进程间切换的开销来自哪里?

1.每个进程拥有独立的页表,切换进程时,操作系统必须加载新进程的页表,这会导致CPU内部的TLB(地址转换缓存)失效,在切换初期,CPU访问内存会因无法命中缓存而变慢,这种性能损耗是进程切换的主要成本,而同一进程内的线程共享页表,切换时无需刷新TLB

2.进程切换通常涉及系统调用,CPU需要从用户态切换到内核态(权限最高的核心态),完成现场保护(保存寄存器,栈指针,程序计数器等)后再切回,虽然线程切换通常也要经过内核,但它不涉及内存地址空间的更换,内核只需调度执行流即可~

3.CPU缓存污染,新进程会占用大量CPU的L1,L2,L3缓存,导致原进程的热数据被逐出,当原进程再次被调度时,需要重新从RAM加载数据,产生额外的延迟,线程切换由于共享内存地址空间,缓存数据的复用率更高!

协程

上下文切换(Context Switch) -> CPU从一个进程/线程切换到另一个的过程

保存当前进程的所有寄存器状态到PCB,加载目标进程的PCB恢复寄存器,切换内存映射(修改CR3寄存器刷TLB)

上下文切换有开销

这就是为什么线程比进程切换快(不需要切换地址空间)

协程比线程快(用户态完成,不需要陷入内核)

协程可以理解为用户态手动控制的轻量级线程

它和线程一样可以实现并发,但区别在于,线程由操作系统内核调度,协程则由程序自己调度

其本质上是一段可以挂起(暂停)并恢复执行的函数

当你调用一个协程时,它可以执行到一半,遇到yield(或await)主动让出CPU,稍后再从让出的地方继续执行

协程的很小(可动态增长,初始只需几KB)

协程切换只在用户态,保存/恢复少量寄存器即可,开销极小

当协程等待网络IO或磁盘IO时,主动让出,调度器立刻执行其它就绪协程,充分利用CPU

协程让同步编程的风格实现了异步非阻塞的性能

协程通常依赖于一个用户态调度器(比如Go的goroutine调度器,Pythonasyncio的事件循环)

调度器管理一组协程

当协程执行到await或yield时,调度器将其状态保存,切换到另一个就绪协程

当等待的事件完成(如socket可读),调度器将其重新标记为就绪

关键:协程无法利用多核并行执行(除非配合多线程)

它主要用于高并发IO密集型任务,而不是CPU密集型计算

对于CPU密集型,需要结合多进程或多线程…

多进程 vs 多线程

因此当要构建稳定可靠且具有隔离性的系统时采用多进程

  • 强隔离需求,比如Chrome浏览器的每个标签页,分布式系统中的微服务,一个子进程崩溃,主进程和其他子进程不受影响,不会导致整个程序挂掉

  • 高安全性,进程间拥有独立的地址空间,一个进程很难非法访问另一个进程的数据,更适合处理敏感数据

  • 计算密集型任务,需要充分利用多核CPU时(如视频解码,科学计算),Python由于GIL(全局解释器锁)的限制,多线程无法利用多核,此时通常使用多进程!

GIL是CPython为了线程安全加的全局锁,它使得Python多线程无法在多个CPU核上真正并行执行CPU密集任务,但对IO密集任务影响不大!

而当首要目标是高并发低延迟通信时,多线程则更为合适

  • 大量I/O操作,文件读写,网络请求,数据库交互等,线程在等待I/O时可以快速切换,且共享内存无需IPC(进程间通信)机制,开发效率高

  • 需要频繁共享数据,比如生产者-消费者模式,线程间直接读写共享内存比进程间通信(管道,消息队列等)快得多

  • 资源敏感场景,如果需要创建成千上万个执行单元,线程占用的内存和创建开销远小于进程…

进程间通信(IPC)

进程各自拥有独立的地址空间,数据不共享,因此操作系统需要提供专门的机制以实现进程间通信,开销相对较大

1.管道(pipe)

如图

匿名管道(父子进程间,单向)和命名管道(无亲缘关系进程间,支持双向)

匿名管道

示例:

1
grep "error" log.txt | less

grep进程的输出没有直接打印到屏幕,而是流入less进程作为输入

管道其实是内核在内存中开辟的一块固定大小的缓冲区(通常4KB or 64KB)

写端:一个进程将数据写入这块缓冲区

读端:另一个进程从缓冲区读取数据

单向流动:数据严格从写端流向读端

管道没有文件名,它不像普通文件那样有inode和路径,匿名管道通过fork()机制继承给父子进程,所以只能在有亲缘关系的进程间使用

同时,如果读端读取时,管道为空,读操作会阻塞,直到有数据写入

如果写端写入时,管道满了,写操作也会阻塞,直到有人读走数据

这种机制天然实现了进程间的同步,不需要额外加

命名管道

为了给无亲缘关系的进程通信,还有命名管道(FIFO)

它在磁盘上有一个特殊的文件(使用ls可以看到),但这个文件只作为入口,数据依然在内存中流转,不会落盘~

2.消息队列

如图

通过内核维护的消息链表传递数据块,有格式边界,适合小数据量异步通信

每条消息是一个独立的单元,有类型,有长度

发送方放入一条消息,接收方按类型或按顺序整条取出,天然有边界

发送方把消息放进队列后就可以离开,接收方可以稍后再取

消息由内核管理,即使接收方暂时没运行,消息也暂存在队列中,实现时间上的解耦

灵活,消息队列有标识符,多个进程可以打开同一个队列,发送时可以指定消息类型,接收方可以只取特定类型的消息

同时消息队列由内核持久化,除非显式删除或系统重启,否则一直存在

这允许进程间跨越生命周期的通信

3.共享内存

效率最高的方式,将同一块物理内存映射到不同进程的虚拟地址空间,数据无需复制,但本身不带同步机制,通常需配合信号量使用

零拷贝

4.信号量

一个计数器,用于控制多进程对共享资源的访问,常与共享内存搭配实现同步

5.信号

异步通知机制(如SIGKILL强制终止),用于告知进程发生了某事件,处理逻辑复杂

6.Socket(套接字)

最灵活的方式,支持跨机通信(如网络编程),也可用于本机进程通信(Unix Domain Socket)

Socket是一个抽象的编程接口,由操作系统提供

其设计哲学是网络也是文件

Unix:一切皆文件😎

你创建一个Socket,系统返回一个文件描述符

收发数据时,用read/write或专用的send/recv,操作方式和读写文件非常相似

区别在于,普通文件读写的是磁盘数据,Socket读写的是网络数据包

通过IP地址端口号定位对方的进程

支持TCP和UDP

模型:

服务端:

socket() 创建套接字

bind() 绑定IP和端口

listen() 进入监听状态,等待客户端连接

accept() 接受一个客户端连接,返回一个新的套接字用于通信

read() / write() 收发数据

close() 关闭连接

客户端:

socket() 创建套接字

connect() 连接到服务器的IP和端口

read() / write() 收发数据

close() 关闭连接

ai的胡言乱语?

微服务架构本质上是把进程间通信变成了网络通信?

Redis用Unix Domain Socket比TCP快?

gRPC是进程间通信的现代解决方案?

线程间通信

1.共享变量

最简单直接的方式,但必须配合同步机制防止数据竞争

2.互斥锁

保护临界区,保证同一时刻只有一个线程访问资源

3.读写锁

允许多个读线程并发,但写线程独占

4.条件变量

常与互斥锁配合,用于让线程在某个条件满足时(如队列非空)再继续执行,实现等待/通知机制

5.信号量

与进程间类似,但通常用于线程计数同步

6.ThreadLocal

并非用于通信,而是用于隔离,为每个线程提供变量的独立副本,避免共享冲突

PCB(进程控制块)

PCB是操作系统用来描述和管理进程的数据结构

每个进程对应一个PCB

其中存储着存储PID,进程状态,程序计数器,寄存器值,内存映射,打开的文件列表,优先级等等

进程切换的本质就是保存当前PCB,加载下一个PCB

Linux里PCB是task_struct结构体

源码:

略😝

实践:

/proc目录

/proc是Linux的虚拟文件系统(procfs),不占实际磁盘空间

每个进程在/proc下有一个数字目录,目录名就是PID

目录里的文件对应task_struct的各个字段

如图

ps:我在wsl上运行的qwq

使用ps aux命令列出进程信息

本质:

读取/proc目录下每个PID目录 -> 解析信息 -> 排版输出

如图

僵尸进程&孤儿进程

僵尸进程:子进程退出后父进程未回收

当一个进程结束(调用exit或被信号终止),它会释放大部分资源(内存,文件描述符等),但内核会保留一个最小的数据结构,即进程控制块(PCB)中的进程描述符

其中包含进程ID(PID),退出状态,运行时间等信息,直到父进程通过waitwaitpid读取它的退出状态

因此在父进程读取之前,这个进程就处于僵尸状态

ps aux | grep Z 找僵尸进程

此时子进程已退出但父进程没调用wait(),PCB还挂着

僵尸进程不占用内存,CPU,只占用PCB中极小的内存(通常几十字节)

但僵尸进程的PID会被保留,如果大量僵尸进程积累,可能会耗尽PID资源,导致新进程无法创建

且无法通过kill -9杀死僵尸进程,因为它已经死了😭,唯一的方法是终止其父进程,让init(PID 1)接管并wait…

孤儿进程:父进程先于子进程退出(orphan)

当一个父进程比子进程先终止时,该子进程就变成了孤儿进程

它没有父进程,会被系统的init进程(PID 1)或subreaper进程自动收养

init会周期性调用wait,因此孤儿进程终止时不会变成僵尸,会被自动回收

本身没有危害,是正常的设计~

为什么Docker容器里要用init进程?

调度与并发

并发bug如何调试?

如何理解锁,死锁,调度算法?

怎么写出正确的多线程程序?

数据库事务隔离是什么?

???

CPU调度算法

FCFS(先来先服务):简单但长任务饿死短任务

SJF(短作业优先):平均等待时间最短但可能饿死长任务

Round Robin(时间片轮转):最公平,Linux默认

多级反馈队列:新进程进高优先级队列,运行太久降级,兼顾响应速度和吞吐量

Linux的CFS调度器,Nginx的工作进程调度,Go的goroutine调度器都是这些思想的变体?

个人感觉此处无需多言

竞态条件(Race Condition)

多线程对共享资源的访问顺序影响结果

例子:

两个线程同时执行x = x + 1

读到同一个旧值,各自加1后写回,结果只加了1次…

竞态条件的可怕之处是它大多数时候不出现,偶尔才触发,极难复现

思考…😟

互斥锁(Mutex)/自旋锁(Spinlock)

保护共享资源的基本同步原语

Mutex:拿不到锁就睡眠,让出CPU,等待唤醒

自旋锁:拿不到锁就一直循环检查(忙等待),不让出CPU

短临界区就用自旋锁(避免睡眠唤醒开销)

长临界区则用Mutex(避免浪费CPU)

Linux内核大量使用自旋锁

Java synchronized用的是对象内置的Monitor锁?

为什么持有自旋锁时不能调用可能睡眠的函数?

数据库的行锁是Mutex?

读写锁(RWLock)

区分读和写,允许多个读者并发,写者独占

读多写少时可以提升并发度

但是写者可能被读者饿死(取决于实现)

且读写锁本身开销略大于互斥锁

待补充

乐观锁?

无锁编程(Lock-Free / CAS)

Compare-And-Swap原子操作?

ABA问题?

死锁(Deadlock)

多个进程互相等待对方持有的资源,永远无法推进

死锁成立必须同时满足四个条件:

1.互斥(资源不能共享)

2.持有并等待(拿着A等B)

3.不可抢占(不能强夺资源)

4.循环等待(A等B,B等A)

破坏任意一个条件即可预防死锁!

银行家算法可以动态避免死锁

实际系统多用死锁检测+超时重试…

数据库死锁排查?

分布式系统的分布式锁设计?

信号量(Semaphore)

比Mutex更通用的同步机制,可控制资源数量

信号量是一个整数值,加上两个原子操作:P(wait)和V(signal)

它的值代表可用的资源数量

P操作:尝试获取一个资源(wait)

逻辑:

1
2
3
4
如果信号量值 > 0:
值减1,继续执行
否则:
阻塞当前线程/进程

V操作:释放一个资源(signal)

逻辑:

1
2
值加1
如果有其它线程/进程在等待这个信号量,唤醒其中一个

这两个操作是原子的,不会被中断,保证了并发正确性

易知,二元信号量其实就等价于Mutex

当信号量的初始值设为N(N > 1)时,它表示有N个相同的资源可以同时使用,这就是计数信号量

而计数信号量可以允许N个并发访问(如连接池最多10个连接)

P操作:申请一个资源,只要信号量值 > 0,就减1并继续,当值减到 0 时,再来的线程就会阻塞,直到有人释放资源

V操作:释放一个资源,值加1,如果之前有线程在等待,就唤醒一个

经典问题:生产者-消费者,读者-写者,哲学家就餐???

现实:

数据库连接池的实现原理?

限流算法(令牌桶)的信号量变体?

条件变量(Condition Variable)

让线程等待某个条件成立的同步机制

配合Mutex使用

wait():原子地释放锁并睡眠

signal()/broadcast():唤醒等待的线程

正确使用必须在while循环里检查条件(防止虚假唤醒)

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <pthread.h>
#include <stdio.h>

int buffer = 0; // 共享缓冲区
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *producer(void *arg) {
for (int i = 0; i < 10; i++) {
pthread_mutex_lock(&mutex);
buffer = i;
printf("Produced: %d\n", i);
pthread_cond_signal(&cond); // 唤醒消费者
pthread_mutex_unlock(&mutex);
}
return NULL;
}

void *consumer(void *arg) {
for (int i = 0; i < 10; i++) {
pthread_mutex_lock(&mutex);
while (buffer == -1) { // 假设 -1 表示无数据
pthread_cond_wait(&cond, &mutex);
}
printf("Consumed: %d\n", buffer);
buffer = -1;
pthread_mutex_unlock(&mutex);
}
return NULL;
}

Java的wait()/notify(),Python的threading.Condition都是这个机制😁

待补充

如何实现线程安全的队列?

理解Java BlockingQueue的底层原理…

内存管理

为什么程序会崩溃?

为什么会有内存泄漏?

为什么虚拟内存能让程序以为自己有无限内存?

C/C++/Rust深入理解…

pwn梦开始的地方…

虚拟地址空间

在没有虚拟内存的早期系统中

程序直接操作物理内存地址(实模式)

显然,这样会带来严重问题

比如:

地址冲突:多个程序同时运行必须被分配到互不重叠的物理内存区域,程序员或系统需要静态划分

碎片与扩展难:程序需要连续物理内存,当内存不足时难以动态增长

安全性差:任何程序都能访问任意物理地址,容易相互破坏!

因此引入了虚拟地址空间

虚拟地址空间通过地址翻译机制,为每个进程提供独立的,从0开始的地址空间

进程使用的地址称为虚拟地址(VA),CPU在访问内存时,由硬件(MMU)配合操作系统将虚拟地址转换为实际的物理地址(PA)

这样

每个进程都认为自己独占了整个内存,且地址空间从0开始,连续,规整

这种抽象是内存管理的基础,让进程隔离,简化编译,灵活使用物理内存成为可能!

典型64位进程地址空间:

如图

两个进程的地址0x400000指向的物理地址完全不同!

这是进程隔离的基础,一个进程崩溃不会影响其它进程!

为什么C数组越界不一定立刻崩溃?

理解Segfault(段错误)是什么!

分页(Paging)&页表

把虚拟地址空间和物理内存都切成固定大小的页(通常4KB)

页表记录虚拟页到物理页帧的映射

CPU访问内存时,MMU自动查页表完成地址翻译

多级页表(x86-64用4级)节省页表内存(理解)

将页表本身的存储也分页,并按照虚拟地址的使用情况,在缺页时动态分配所需的页表页

未使用的虚拟地址范围不会产生任何页表内存开销

待补充…

TLB(快表)是页表的硬件缓存

命中率99%+

miss了才查内存页表!

mmap()的工作原理?

待补充

为什么进程切换慢(要刷新TLB!)

huge page为何能提升性能?

缺页异常(Page Fault)

访问一个虚拟地址,但其对应的物理页不在内存中

三种情况:

1.页在磁盘(swap),从磁盘换入

物理内存不足时,内核会把一些不常用的页面换出到磁盘的swap分区(或swap文件)

当进程再次访问这些被换出的页面时,页面内容不在物理内存中,PTE被标记为不存在,但PTE中会保存一个swap偏移量,指示该页在磁盘上的位置

关键:这一步会产生磁盘I/O,所以性能开销较大

2.合法的新访问(懒分配),分配新物理页

进程通过malloc()mmap(MAP_ANONYMOUS)申请了内存

但内核只是在虚拟地址空间里记录了VMA(表示这块地址将来可用),并没有立即分配物理页,也没有建立页表映射

这是典型的惰性分配(lazy allocation)优化

避免为可能永远不用的内存提前分配物理资源

这解释了为什么malloc大块内存后不立即消耗物理内存,只有真正写入时才分配

而对于文件映射(mmap文件),缺页时才会从文件的页缓存中读取相应页面

流程:

进程首次访问该虚拟地址(如*p = 1)

缺页异常发生,因为PTE是空的(不存在)

内核检查该地址是否落在进程的某个VMA范围内,且访问权限(读/写)合法

如果不合法转为情况3(非法访问!)

如果合法

那么,内核分配一个物理页帧,通常初始化为全零(对于匿名内存)

更新页表,建立虚拟地址到物理页帧的映射,设置访问权限

最后,进程恢复执行,完成内存访问!

智慧!

3.真正的非法访问,触发SIGSEGV(what the hell!)

进程访问了一个根本没有对应的VMA的地址(例如空指针解引用,数组越界到未映射区域)

或者虽然地址在VMA内但访问权限不匹配(比如尝试写入只读的代码段)

内核向进程发送SIGSEGV信号(Segmentation Fault!)

core dumped!

写时复制技术(Copy-on-Write,CoW)

CoW是一种优化技术,用于延迟复制物理内存,直到真正需要修改时

它依赖缺页异常来捕获首次写入

实例:

fork()为什么快?

当父进程调用fork()创建子进程时,传统做法是复制整个父进程的地址空间

但这样效率极低,而且大部分内存可能根本不会被写入!

因此Linux采用CoW机制

具体步骤如下:

1.共享物理页:fork()后,父子进程的页表都指向相同的物理页帧

但这些页表项被标记为只读(即使原来父进程是可写的)

2.读操作:如果父子进程都只是读取数据,不会发生任何复制,效率极高!

3.写操作:当其中一个进程(例如子进程)尝试写入某个共享页时:

  • CPU触发缺页异常(因为页表项是只读的,而写操作需要写权限)

  • 内核检查异常地址:发现这是CoW页面(通过PTE中的特殊标志或VMA标志)

  • 内核复制该物理页到一个新的物理页帧

  • 更新触发写入的进程的页表,将新页帧映射到该虚拟地址,并标记为可写

  • 另一个进程的页表仍然指向原物理页(仍是只读)

  • 异常返回后,重新执行写入指令,此时写入的便是新复制的页面!

此后,父子进程各自拥有独立的物理页,修改互不影响!

极大节省了内存时间

Docker的overlay文件系统?

Redis fork子进程做RDB快照时的内存消耗?

待补充…

栈vs堆

两种内存分配区域,管理方式完全不同!

:自动管理,函数返回时自动释放,分配极快(只移动栈指针),大小有限(默认8MB)

:手动(C/C++)或GC管理,大小几乎无限,分配慢(要找合适的空闲块)

栈溢出(StackOverflow)!

C/C++堆内存泄漏

C里malloc/free管理堆

Java/Python对象都在堆上

Rust的所有权系统本质上就是让编译器自动管理堆~

待补充…

内存分配器(malloc原理)

malloc如何在堆上找到合适的空闲内存块?

空闲链表:维护一个空闲块链表

first-fit/best-fit策略找合适大小…

glibc的ptmalloc😁

slab分配器(Linux内核):预先分配固定大小的对象池,避免碎片

在Linux内核中,内存分配的需求与用户空间截然不同

内核需要频繁地创建和销毁大量固定大小的数据结构(如task_structinodedentry等)

如果直接使用通用的伙伴系统(buddy system)以(通常4KB)为单位分配,会造成两个严重问题:

  • 内部碎片:一个几十字节的结构体占用一个完整的页框,浪费大量内存

  • 外部碎片:频繁分配和释放不同大小的页框会导致页级别碎片,难以分配连续的大页

为了解决这些问题,Linux内核引入了slab分配器

其核心思想是:预先分配一组固定大小的对象池,将对象缓存起来,按需分配和回收,避免直接与伙伴系统交互,从而消除碎片,提高性能

待补充…

tcmalloc(Google)和jemalloc(Firefox/Redis)高性能分配器,用线程本地缓存减少竞争

Redis为什么换用jemalloc而不是系统malloc?

内存碎片问题?

to be continued…

垃圾回收(GC,Garbage Collection)算法

自动回收不再使用内存的机制

引用计数:Python用,无法处理循环引用

标记-清除:从根节点遍历,未标记的就是垃圾

能正确处理循环引用

但是需要暂停程序(Stop-the-worldSTW)才能准确标记

且清除后会产生内存碎片,后续分配大对象可能失败

Stop-the-worldGC的大问题,Java曾经因此引发生产事故😭

分代回收:新生代(大多数对象很快死亡)用复制算法,老年代则用标记-整理

JVM的G1/ZGC,Go的三色标记都是这些思想的组合

三色标记(Tri-color Marking)

三色标记是一种并发标记算法,可以在程序运行时(不暂停)进行大部分标记工作

将对象分为三种颜色:

白色:未访问过的对象(可能是垃圾)

灰色:已访问但其引用的对象尚未全部检查

黑色:已访问且其所有引用都已检查

标记过程:从根开始,将对象标记为灰色,然后逐步处理灰色对象,将其引用的白色对象变为灰色,自身变为黑色,最后白色对象就是垃圾!

如图

Python内存泄漏排查?

Java GC调优?

Go GC pause过长的原因?

待补充…

文件系统与I/O

文件系统是程序磁盘之间的翻译层

如何写出高性能的I/O代码?

数据库为什么比文件快?

Docker镜像是怎么工作的?

???

inode&文件描述符

inode是文件的元数据,文件描述符是进程访问文件的句柄

inode存储文件大小,权限,时间戳,数据块位置等等,就是不存文件名(文件名存在目录里)

文件描述符(fd)是个整数,指向内核的文件表项,再指向inode

  • 0 = stdin

  • 1 = stdout

  • 2 = stderr

使用lsof命令可以列出所有进程打开的fd

fd泄漏(没关闭)会耗尽系统资源

inode不是文件名,而是文件在磁盘上的元数据索引节点

文件名和inode是一一对应的关系(硬链接除外😠)

操作系统通过文件名找到inode号,再通过inode找到散落在磁盘各处的数据块

ls -i看inode号

硬链接和软链接的区别?

ulimit -n设置最大fd数量

链接

硬链接

1
ln 源文件 硬链接名    # 创建硬链接

硬链接的本质是多个文件名指向同一个inode

在文件系统中,inode才是文件的本体

当你创建一个硬链接时,并没有创建新文件,只是给这个inode多贴了一个标签

系统维护一个链接计数,记录有多少个文件名指向这个inode

因此修改任意一个文件名下的内容,其它文件名下看到的内容都会变(因为其实是同一份数据)

删除原文件时,文件并不会被真正删除,只是链接计数减1

只有当计数归零时,磁盘空间才会被释放~

软链接

1
ln -s 源文件 软链接名  # 创建软链接

软链接(符号链接)是一个独立的文件,它有自己的inode,但这个文件的内容是另一个文件的路径

自己的理解:

其实可以把它看作一个指针?

操作系统读取软链接时,会重定向到它所指向的路径去读取真正的文件~

因此修改软链接的内容,实际上就是在修改原文件的内容

而如果删除了原文件,软链接依然存在,但它会失效,变成一个悬空链接(dangling pointer😄)

I/O

I/O(Input/Output)是内存外设(磁盘,网卡,键盘等)之间的数据拷贝过程

从CPU的视角来看,IO很慢😭

于是为了效率,操作系统引入了分层机制:

阻塞非阻塞:

阻塞:进程调用read()从文件描述符读取数据

如果内核缓冲区中还没有数据(例如网络数据尚未到达),则系统调用阻塞,进程进入睡眠状态

直到数据准备就绪,内核将数据从内核缓冲区拷贝到用户缓冲区,read()返回,进程继续执行

一个线程只能处理一个fd,当有大量连接时,要么需要多线程(每个线程一个连接),要么线程被阻塞浪费CPU资源😅

非阻塞:设置文件描述符为O_NONBLOCK标志

调用read()时,如果内核缓冲区没有数据,系统调用立即返回,并设置错误码为EAGAINEWOULDBLOCK

进程需要循环(轮询)不断调用read()直到数据准备好

线程不会被阻塞,可以不断检查多个fd,但轮询会占用大量CPU

因此单独使用非阻塞I/O并不高效,很少直接使用😅

同步异步:

同步:进程在数据拷贝时被阻塞(阻塞,非阻塞,多路复用都属于同步I/O)

异步:进程在发起操作后即可做其它事,拷贝过程由内核完成

性能的关键:

IO的瓶颈通常在于等待(磁盘寻道网络延迟)和拷贝(数据在用户空间和内核空间之间来回复制)

高性能系统设计(如epoll零拷贝)都是为了减少这两者的开销~

待补充…

I/O多路复用(select -> poll -> epoll):一个线程同时监控多个文件描述符,当其中任何一个有I/O事件(可读,可写等)时,内核通知应用程序去处理

select:最古老,监控fd数量有限(通常1024),每次需要遍历全部fd,效率随fd数量增加线性下降

poll:与select类似,但使用链表管理fd,无最大数量限制,但仍需遍历

epoll(Linux特有):基于事件驱动,只返回有事件发生的fd,无需遍历全部,效率与活跃连接数相关

支持边缘触发(ET)和水平触发(LT)

???

Nginx/Redis单线程处理万级并发的核心

Node.js的事件循环本质便是I/O多路复用

???

异步I/O(aio/io_uring):应用程序发起I/O操作后立即返回,不等待数据

当内核完成数据拷贝(从内核到用户空间)后,通过信号,回调函数或io_uring的完成队列通知应用程序

传统异步I/O:AIO

现代异步I/O:io_uring(Linux5.1+)

待补充…

缓冲区I/O vs 直接I/O

读写磁盘是否经过操作系统的页缓存

缓冲I/O(默认):读写都经过内核页缓存(page cache),读命中缓存直接返回,写先写缓存再异步刷盘

读操作:进程发起read(),内核首先在页缓存(page cache)中查找,如果数据已在缓存中,直接返回,否则,从磁盘读取到页缓存,再复制到用户空间

写操作:进程发起write(),内核将数据写入页缓存,立即返回(认为已写完),实际写入磁盘由内核后续异步完成(通过pdflush或后台线程)

利用内存作为磁盘的缓存,加速重复读操作,写操作延迟低(不需要等待磁盘完成)

双缓存问题:如果应用程序(如数据库)自己也有一个缓存(buffer pool),数据会同时存在于页缓存和应用程序缓存中,浪费内存,且增加了两次数据复制(内核 <-> 用户空间)

不可控的刷盘时机:应用程序调用write()后,数据何时真正落盘不可控,对要求持久性的数据库来说,可能导致事务日志未及时落盘,违反ACIDDurability(持久性)

内核通用策略不一定最优:内核的预读,回写策略是为通用负载设计的,无法针对特定应用(如B+Tree随机写,顺序日志写)做精细优化

待补充…

直接I/O(O_DIRECT标志):绕过页缓存,直接读写磁盘

每次读写都需要磁盘I/O(没有内核自动缓存),对随机读密集型负载可能变慢

那为什么数据库(MySQL InnoDBPostgreSQL)用直接I/O,自己管缓存呢?

因为他们比OS更了解数据的访问模式🤡

fsync(int fd)系统调用的作用?

强制将指定文件在页缓存中所有已修改的数据(包括文件元数据)立即写入磁盘,并且会等待磁盘完成写入后才返回

fdatasync()类似,但只同步数据内容,不同步元数据(如修改时间),通常更快一些

数据库崩溃恢复为什么可靠?

预写日志技术,Write-Ahead Logging(WAL)

在修改实际数据页(如表中的行)之前,先将修改操作以日志形式写入一个只追加的日志文件(通常称为redo log)

日志写入必须先于数据页的刷盘,也就是说,日志落盘后,数据库才认为事务提交成功,数据页可以在之后异步写入磁盘

系统崩溃后重启时,数据库会重放日志中尚未应用到数据页的操作,将数据恢复到崩溃前的状态!

Redis AOFappendfsync配置选项?

待补充…

零拷贝(Zero-Copy)技术

把文件数据发送给网络,不经过用户空间的技术

传统路径:磁盘 -> 内核缓冲区 -> 用户缓冲区 -> 内核socket缓冲区 -> 网卡,4次拷贝

sendfile()系统调用:磁盘 -> 内核缓冲区 -> 网卡,2次拷贝,且不进入用户空间

NginxKafka的高性能文件传输都依赖sendfile

Java的FileChannel.transferTo()就是对sendfile封装(抽象)

Kafka为什么能处理百万级消息?

零拷贝+顺序写磁盘

Nginx静态文件服务为什么快?

待补充…

epoll原理

Linux最高效的I/O事件通知机制

复习一下

select:每次调用把全部fd从用户态拷贝到内核,O(n)扫描,最多1024个fd

poll:去掉fd数量限制,但还是O(n)扫描

epoll:内核维护就绪列表,只返回有事件的fd,O(1)!

edge-triggered(ET) vs level-triggered(LT)是epoll的两种工作模式,ET性能更高但编程更难

Redis,Nginx底层都用epoll😱

为什么C10K问题被epoll解决了?

待补充…


怎么这么复杂😭

好多内容呀

毕竟是几十年发展的历史(maybe)

慢慢来吧

累了

先休息一会儿…


可以忽略

自言自语几句

最近,我时常发问,学习这些内容的意义是什么?

我尝试询问ai,翻阅论坛,其给出的回答不外乎:

  • 这些底层知识构成了真正理解计算机系统,解决复杂生产问题的基石

  • 在实际工作中,它们无处不在

  • 这些知识让你超越调包侠层次

  • 面试官非常看重这种对底层原理的理解

这些千篇一律的回答真的是我想要的吗

我不知道

我开始思考

不知怎的

斯多葛主义

这个词汇

突兀地出现于我的脑海中

依稀记得

高中时

我将其作为记诵的作文素材

来来回回,反反复复地写

却并没有真正理解

直到现在

有些事在我们控制之内

有些事则在我们控制之外

意义并不来自那些不可控的外在结果

而来自我们如何运用理性,选择德性,对待命运

无论外界如何变化

我始终以正确的态度做正确的事

这就是意义本身

意义不是附加在结果上的标签

而是蕴含在行动过程中的内在价值

这些话语

曾经只是无脑地背下来

现在却越来越明晰

是啊

我永远无法控制

这些知识在面试中是否会被问到?

是否会为我带来升职加薪?

外界会认可我的努力吗?

但我所能控制的是

我是否尽力地去梳理清楚了每一个知识点

是否始终保持着好奇心自信心专注力

在遇到困难时是否坚持了思考

控制二分法教会我:

把注意力放在后者

因为那是真正属于我的部分

而对前者保持平静

因为它们不由我决定

一如塞涅卡所言

通往自由之路

是不再等待外部的许诺

最后

如果你看到了这里

祝愿你

不要被未来的恐惧所困扰

因为你将带着与现在相同的理性面对它

生活愉快

天天开心☺️


OS随记
https://roxy5201314.github.io/2026/03/25/OS随记/
作者
roxy
发布于
2026年3月25日
更新于
2026年3月30日
许可协议