简介:这份资源是UIUC CS241系统编程课程的中文翻译讲义,面向正在学习操作系统、并发编程与底层系统开发的高校学生及自学者,帮助读者跨越英文原版讲义的阅读门槛,系统掌握进程、线程、内存管理与文件系统等核心知识。资源包共收录137个文件,以93个Markdown讲义为主体,辅以22张JPG示意图、9个JS脚本、3个CSS样式及HTML页面等站点构建文件,压缩包约3.5MB,整体结构清晰,便于按章节顺序阅读或本地搭建静态站点查阅。目前已有195人学习下载。讲义内容覆盖系统编程关键概念与实验思路,配合图示能更直观地理解抽象机制,适合作为课程同步学习、期末复习或面试前查漏补缺的参考材料,也可供参与开源翻译校对的读者对照使用。
1. 系统编程的"黑匣子":为什么 fork 之后 printf 会打印两次
写过 C 语言的人大概都遇到过这个让人头皮发麻的现象:一段再普通不过的printf("hello\n"),放在fork()之前调用,程序跑完屏幕上却出现了两行 hello。你盯着代码看了半天,逻辑上明明只打印了一次,可它就是变成了两次。更玄学的是,把输出重定向到文件时这个现象又变了——有时候是两行,有时候是一行,有时候顺序还乱了。
这不是编译器的 bug,也不是操作系统的玄学,而是系统编程里最核心的一层机制在起作用:进程、文件描述符、缓冲区,这三者之间的耦合关系。UIUC CS241 这门课之所以被很多人称为"系统编程的分水岭",就是因为它不讲 API 怎么调,而是把 fork 到底复制了什么、缓冲区在什么时候被刷新、文件描述符表在内核里长什么样,一层层拆开给你看。这份中文讲义把整门课的核心内容翻译整理了出来,覆盖了从进程管理、内存分配到并发编程、网络通信的完整链路。搞懂它,你再看那些"莫名其妙"的行为,就不再是玄学,而是可以推导的必然结果。
2. fork 的底层机制:复制的是什么,共享的又是什么
2.1 写时复制:fork 为什么不是"复制整个进程"
很多人第一次学 fork 时会形成一个直觉:fork 就是把父进程的内存全部拷贝一份给子进程。这个直觉在结果上大致对,但在实现上完全错了。如果每次 fork 都真的做一次全量内存拷贝,那一个占了几百 MB 的进程 fork 一次就要多占几百 MB,系统早就崩了。
Linux 实际采用的机制是写时复制(Copy-On-Write,COW)。fork 调用发生时,内核并不复制物理内存页,而是把父进程的页表复制一份给子进程,然后把这父子两边所有可写的页都标记为只读。父子进程的虚拟地址指向的是同一批物理页。只有当某一方试图写入某个页时,CPU 触发页错误(page fault),内核才在那个时刻真正分配一个新的物理页、拷贝内容、恢复可写权限。这就是"写时才复制"。
这个机制解释了一个关键问题:fork 的开销和进程实际占用的内存大小关系不大,主要取决于页表的大小。一个进程即使占了 1GB 内存,只要 fork 之后父子都不写,实际物理内存几乎不增加。常见的实现思路是,内核用反向映射(rmap)来快速找到某个物理页被哪些进程映射,这样在写保护触发时能高效处理。
2.2 缓冲区:printf 打印两次的真正原因
回到开头那个问题。printf不是直接写屏幕的,它先把内容写进 C 标准库维护的一个用户态缓冲区。这个缓冲区什么时候真正调用write系统调用刷到内核,取决于缓冲模式:
- 连接到终端(tty)时,是行缓冲,遇到
\n就刷新。 - 重定向到文件或管道时,是全缓冲,缓冲区满了才刷新。
现在看 fork 的时机。如果printf("hello\n")在 fork 之前执行,且输出到终端,那么\n已经触发了刷新,缓冲区是空的,fork 之后子进程继承的是一个空缓冲区,不会重复输出。但如果输出被重定向到文件,\n不触发刷新,缓冲区里还留着 "hello\n",fork 时子进程复制了父进程的整个用户态内存,包括这个缓冲区。父子进程各自退出时都会刷新自己的缓冲区,于是 "hello\n" 被写了两次。
#include <stdio.h> #include <unistd.h> int main() { // 重定向到文件时,stdout 是全缓冲 printf("hello\n"); // 内容进入用户态缓冲区,未刷新 fork(); // 子进程复制了包含 "hello\n" 的缓冲区 // 父子进程各自退出,各自刷新缓冲区 return 0; }这段代码在终端上跑输出一行,重定向到文件跑输出两行。差异的根源不在 fork,而在缓冲区在 fork 那一刻的状态。要避免这个问题,可以在 fork 之前调用fflush(stdout),或者干脆用write(STDOUT_FILENO, ...)绕过标准库缓冲。
2.3 文件描述符表:父子进程共享了什么
fork 之后,父子进程的文件描述符表是各自独立的副本,但表项指向的"打开文件描述"(open file description)是共享的。这个区分非常重要。
每个进程有自己的 fd 表,fd 是一个小整数索引,指向内核里的一个 file 结构。fork 时,内核复制 fd 表,所以父子进程的 fd 编号相同、指向同一个 file 结构。这个 file 结构里保存了文件偏移量(offset)和文件状态标志。这意味着:父进程读了一个字节,子进程再读时偏移量已经前进了,两个进程在共享同一个读写位置。
int fd = open("data.txt", O_RDONLY); fork(); // 父子进程的 fd 指向同一个 open file description // 一方 read 之后,另一方的 read 会从新位置继续如果想让父子进程各自独立读写,需要在 fork 之后各自重新open一次文件,这样会创建两个独立的 open file description,偏移量互不影响。这个机制在实现管道、socket 通信时是基础——正因为共享偏移量,父子进程才能协作读写同一个管道。
3. 内存管理与信号:虚拟地址空间是怎么被切分的
3.1 虚拟地址空间的布局
一个进程看到的地址空间是虚拟的,从低地址到高地址大致分为:代码段(text)、已初始化数据段(data)、未初始化数据段(bss)、堆(heap,向上增长)、内存映射区(mmap 区域,向下增长)、栈(stack,向下增长)。堆和 mmap 区之间是空闲的,谁先碰到谁就占用。
malloc小内存时从堆里分配,通过brk/sbrk系统调用移动堆顶指针;大内存(通常超过 MMAP_THRESHOLD,默认 128KB)时直接用mmap映射一块匿名内存。这个阈值可以通过mallopt调整。理解这一点,就能明白为什么释放大块内存时free会直接munmap还给内核,而释放小块内存时只是还给 malloc 自己的空闲链表,进程的 RSS 不一定下降。
3.2 信号:异步事件的处理时机
信号是内核向进程投递的异步通知。进程收到信号后,不会立刻跳转去执行处理函数,而是在从内核态返回用户态的那一刻检查待处理信号,如果有就暂停当前执行流,跳去执行信号处理函数,返回后再继续。
这个"返回用户态时检查"的时机决定了信号处理的几个特性:信号处理函数可能在任何两条指令之间被插入执行,所以它里面能安全调用的函数非常有限——只有异步信号安全的函数(如write、_exit)才是安全的,printf、malloc都不是。常见的翻车场景是在信号处理函数里调printf,结果因为标准库内部锁的状态不确定而卡死或崩溃。
volatile sig_atomic_t flag = 0; void handler(int sig) { flag = 1; // 只做最简单的赋值,安全 } int main() { signal(SIGINT, handler); while (!flag) { // 主循环做实际工作 } // 退出前在主流程里做清理 return 0; }正确的模式是:信号处理函数只设置一个volatile sig_atomic_t标志,主循环轮询这个标志,真正的清理和输出放在主流程里做。这样既响应了信号,又避开了异步安全问题。
3.3 信号与系统调用的交互
当一个阻塞中的系统调用(如read)被信号打断时,会发生什么?默认情况下,如果信号处理函数返回,系统调用会返回 -1 并把errno设为EINTR。这意味着你的read可能没读到任何数据就返回了,如果不处理这个错误码,程序逻辑就会出错。
处理方式有两种:一是检查errno == EINTR后重新调用;二是安装信号时用sigaction并设置SA_RESTART标志,让内核自动重启被打断的系统调用。signal()函数在不同平台上的行为不一致,有的默认重启有的不重启,所以生产代码里一般用sigaction显式控制。
4. 并发与同步:锁、条件变量和死锁的根源
4.1 竞态条件的本质
多线程程序出问题,根源几乎都是竞态条件:多个线程访问共享数据,最终结果依赖于线程调度的时序。看一个最简单的例子,两个线程各自对同一个全局变量做 100 万次自增,最后结果几乎不可能是 200 万。
原因在于counter++不是原子操作,它至少包含三步:从内存读到寄存器、寄存器加一、写回内存。两个线程的这三步可能交错执行,导致某次自增被覆盖。要修复,必须让这三步整体原子化,这就是互斥锁的作用。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; int counter = 0; void* worker(void* arg) { for (int i = 0; i < 1000000; i++) { pthread_mutex_lock(&lock); counter++; // 临界区,同一时刻只有一个线程能进 pthread_mutex_unlock(&lock); } return NULL; }锁的本质是给临界区加一个"同一时刻只允许一个线程进入"的约束。但锁本身也有开销:加锁解锁涉及原子指令和可能的内核态切换,所以临界区要尽量小,只包住真正需要保护的共享数据操作。
4.2 条件变量:为什么不能只用锁
有些场景下,线程需要等待某个条件成立才能继续,比如消费者等待队列非空。如果只用锁,消费者只能反复加锁、检查、解锁、再检查,这叫忙等待,白白消耗 CPU。条件变量解决的就是这个问题:它让线程在条件不满足时睡眠,条件满足时被唤醒。
条件变量必须和一把锁配合使用,标准用法是:
pthread_mutex_lock(&mutex); while (queue_is_empty(&q)) { // 必须用 while,不能用 if pthread_cond_wait(&cond, &mutex); // 等待时自动释放锁,被唤醒时重新获取 } item = dequeue(&q); pthread_mutex_unlock(&mutex);这里有两个容易踩的坑。第一,pthread_cond_wait返回后必须重新检查条件,因为可能存在虚假唤醒(spurious wakeup),线程可能在条件并未成立时被唤醒。所以判断条件要用while而不是if。第二,pthread_cond_wait在等待期间会释放锁,被唤醒返回前会重新获取锁,这个"释放-等待-重新获取"是原子的,不会给其他线程留下可乘之机。
4.3 死锁的四个必要条件
死锁发生时,一组线程各自持有资源又都在等待对方释放资源,谁也动不了。死锁的产生需要同时满足四个条件:互斥(资源同一时刻只能被一个线程持有)、持有并等待(持有资源的同时等待其他资源)、不可抢占(资源不能被强制夺走)、循环等待(存在一个等待环)。
打破其中任何一个条件就能避免死锁。工程上最常用的是打破循环等待:给所有锁定义一个全局顺序,所有线程都按这个顺序加锁。比如规定必须先锁 A 再锁 B,那么就不会出现线程 1 锁 A 等 B、线程 2 锁 B 等 A 的局面。
// 危险:两个线程加锁顺序相反 // 线程1: lock(A); lock(B); // 线程2: lock(B); lock(A); // 可能死锁 // 安全:统一按地址顺序加锁 if (&A < &B) { lock(A); lock(B); } else { lock(B); lock(A); }用锁的地址大小来决定加锁顺序,是一个简单有效的通用做法,保证所有线程的加锁顺序一致。
5. 避坑与排查:系统编程里那些反复出现的坑
5.1 现象:程序偶发卡死,gdb 看栈停在 malloc
原因:多线程环境下,一个线程在信号处理函数里调用了malloc,而主线程此时正持有 malloc 内部的锁。信号处理函数打断了主线程,试图获取同一把锁,形成自锁。解决:信号处理函数里只调用异步信号安全的函数,绝不用malloc、printf、free。需要传递数据时用volatile sig_atomic_t标志或自管道(self-pipe)技巧。
5.2 现象:read 返回 -1,errno 是 EINTR,程序逻辑错乱
原因:阻塞的系统调用被信号打断,内核返回 EINTR,但代码没有处理这个错误码,把它当成了真正的读失败。解决:对所有可能被信号打断的慢速系统调用(read、write、accept、select 等)做 EINTR 检查并重试,或者用sigaction设置SA_RESTART让内核自动重启。
ssize_t n; do { n = read(fd, buf, sizeof(buf)); } while (n == -1 && errno == EINTR);5.3 现象:fork 之后子进程输出重复,或者文件偏移量错乱
原因:一是标准库缓冲区在 fork 时被复制,父子各自刷新导致重复输出;二是父子共享 open file description,读写位置互相影响。解决:fork 前fflush所有输出流;需要独立读写位置时,fork 后各自重新open文件。
5.4 现象:多线程自增结果偏小,且每次运行结果不同
原因:counter++非原子,多线程交错执行导致更新丢失。解决:用互斥锁保护临界区,或者用原子操作(如 C11 的atomic_int配合atomic_fetch_add)。注意原子操作只保证单个操作的原子性,多个操作组合时仍需锁。
5.5 现象:条件变量等待后逻辑出错,偶尔读到空队列
原因:用if而不是while判断条件,遇到虚假唤醒时条件并未成立就继续执行。解决:条件判断一律用while循环,被唤醒后重新检查条件,不成立就继续等待。
6. 从机制到取舍:为什么系统编程值得反复读源码
学系统编程最容易走的一条弯路,是把 API 当成黑盒来用:fork就是创建进程,malloc就是分配内存,pthread_mutex_lock就是加锁。这样用能跑通大部分场景,但一旦遇到偶发 bug,就完全无从下手,因为你不知道这些调用背后到底发生了什么。
这份讲义的价值,在于它把每个 API 背后的机制都摊开了。fork 的写时复制、缓冲区的刷新时机、文件描述符表的共享结构、信号处理的插入点、锁和条件变量的配合方式——这些机制不是孤立的知识点,而是相互咬合的一整套模型。你理解了这套模型,就能自己推导出行为,而不是靠记忆和猜测。
从设计取舍的角度看,系统编程里几乎每个机制都是权衡的产物。写时复制用页错误换内存拷贝,是在 fork 频繁的场景下省内存;缓冲区用延迟刷新换吞吐,是在批量输出时减少系统调用;条件变量用睡眠换 CPU,是在等待时间不确定时避免忙等。这些取舍没有绝对的对错,只有适用场景的不同。理解取舍,比记住结论更重要。
验证自己是否真的理解了,有一个简单的方法:拿一段看起来"行为诡异"的代码,不看答案,自己推演它在不同条件下(终端 vs 文件、单线程 vs 多线程、有无信号)的输出,然后实际跑一遍对比。如果推演和实际一致,说明模型建对了;如果不一致,差异点就是你理解的盲区。
我自己的习惯是,每次写涉及 fork、信号、多线程的代码,都会先在纸上画出进程树、fd 表和缓冲区的状态,确认每个时刻谁持有什么、谁在等什么,再动手写。这个习惯帮我避开了太多偶发的诡异 bug。希望这份讲义也能帮你建立起同样的推导能力,把系统编程从玄学变成可以掌控的工程。
本文还有配套的精品资源,点击获取