本页定位 · Operating Systems

tech-linux 教你"怎么用"操作系统,本页教你"它到底在干什么"。进程/线程/协程、虚拟内存、文件系统、调度、并发同步、I/O 模型——这些是所有上层技术(语言运行时、数据库、容器、分布式)的共同地基。本页按六章把每条知识拆成"是什么 → 为什么 → 怎么用 → 踩什么坑",学完你看并发 bug、OOM、IO 慢,都能往底层归因,而不是只会重启。

1

进程 / 线程 / 协程:执行的基本单位

Process · Thread · Coroutine · 上下文切换

程序是"躺"在磁盘上的静态文件,进程是它"跑"起来的实例;进程里可以有很多条执行流,叫线程;而协程是更轻的用户态执行流。三者一个比一个轻,理解它们的边界与成本,是高并发的地基。

进程:被隔离的"资源容器"

进程拥有独立的虚拟地址空间——A 进程根本"看不进"B 进程的内存,所以一个进程崩了不会污染别人。它也是资源记账单位(内存、打开的文件、权限)。代价是创建/切换重、进程间通信(IPC)要走内核。

进程地址空间段存什么谁可读写
代码段 (.text)编译后的机器指令只读、可执行
数据段 (.data/.bss)全局/静态变量读写
堆 (heap)malloc 动态分配向上增长
栈 (stack)局部变量、函数调用帧向下增长
内核区内核代码/数据(映射)用户态不可直接访问

线程:共享地址空间里的"执行流"

一个进程里的多个线程共享堆和全局数据,但各有独立的栈和寄存器。共享带来高效率,也带来竞态——后续第 4 章专门讲怎么保护。线程切换比进程轻,因为不用换页表。

// POSIX 线程:两个线程共享同一个 counter 变量 #include <pthread.h> int counter = 0; void* worker(void* arg) { for (int i = 0; i < 100000; i++) counter++; // 无保护→会丢更新 return NULL; } // pthread_create(&t, NULL, worker, NULL);
上下文切换不是免费的

线程太多、频繁切换,CPU 大量时间花在"保存/恢复寄存器、切换页表/TLB"而非干活——这就是高并发下"线程爆炸反而更慢"的根因。协程(Go/Rust async)正是为减少切换成本而生。

协程:用户态的"轻量线程"

协程不对应操作系统线程,由程序自身(或运行时)在用户态调度。切换只改几个寄存器和栈指针,不进内核、无系统调用,成本比线程低一到两个数量级。Go 的 goroutine、Python 的 asyncio、C++ 的 coroutine 都是它。

// 用户态调度(概念示意):成千上万个协程跑在少量线程上 while (tasks.not_empty()) { task = scheduler.pick(); // 用户态决定下一个跑谁 switch_to(task); // 不进内核,零 syscall 开销 } // 遇 IO 就 yield,让出执行权

论为什么协程能扛百万并发

一个 OS 线程默认栈 8MB,百万线程就是 8TB,不可能。协程栈初始几 KB、按需增长,百万协程只需几 GB。而且它们的切换在用户态完成,不用内核介入——这正是现代高并发服务的根本解法。

进程间通信 IPC:隔离之后怎么"说话"

进程隔离了,但彼此常要协作。OS 提供几种 IPC 机制,各有取舍:管道简单但单向、共享内存最快但需自己同步、消息队列解耦、Socket 可跨机。

IPC 方式速度特点适用
管道 pipe中单向、父子进程shell 流水线
共享内存最快零拷贝,但需自己加锁超高频交换
消息队列中解耦、可缓冲模块解耦
Socket慢可跨机、通用网络/跨进程

进程的诞生:fork / exec 两步走

Unix 创建进程是两步:fork() 复制出一个几乎一模一样的子进程(写时复制,Copy-On-Write,不真拷贝内存),exec() 把子进程的内存换成新程序。Shell 跑命令、Docker 起容器底层都是这套。

pid = fork(); // 返回两次:父进程拿到子 pid,子进程拿到 0 if (pid == 0) { execl("/bin/ls", "ls", NULL); // 子进程:把自身替换为 ls 程序 } else { wait(&status); // 父进程:等子进程结束 }

论为什么是 fork+exec 而不是一步"创建进程"

fork 出来的子进程天然继承了父的文件描述符、环境变量、信号处理——shell 重定向、管道正是靠这个。再 exec 换程序,既保留了"上下文",又装了"新脑子"。这种组合比"一步创建"灵活得多,是 Unix 哲学的经典设计。

线程与协程:一图看清代价差

常有人问"协程和线程到底差在哪"。一句话:协程是"用户态线程",切换不进内核、栈更省,所以能开巨多;线程是"内核态",隔离与调度由 OS 管,稳但重。选型看并发规模与语言生态——写高并发服务优先协程,写计算型多核并行仍靠线程/进程。

维度线程(OS)协程(用户态)
调度者内核运行时/程序自身
切换成本高(进内核 + 刷 TLB)低(仅寄存器/栈指针)
默认栈MB 级KB 级、按需增长
可开规模上千到顶百万级
调试可见性系统工具直接看依赖运行时追踪

进程状态与僵尸进程:为什么必须 wait

进程在生命周期里会在运行、就绪、阻塞等状态间切换;退出后若父进程没调用 wait 回收,它会变成僵尸进程——已经死了却仍占着 PID 和内核结构不释放,积累多了会耗尽 PID 表,导致新进程创建失败。若父进程先挂,子进程变孤儿,被 init(PID 1)领养并最终回收,问题不大。

// 父不 wait → 子退出后变僵尸(STAT=Z),必须回收 if ((pid = fork()) == 0) exit(0); // 父忘记 wait(pid) → 子进程残留为僵尸,占着 PID 不释放 // 正确:signal(SIGCHLD, reap) 或阻塞 wait,及时回收
僵尸堆积才是真麻烦

孤儿被 PID 1 接管后会正常回收,不必慌;真正要防的是僵尸堆积:父进程是长生命周期服务(如常驻 daemon)却忘了 wait,僵尸越积越多,最终 PID 耗尽,连 fork 都失败。监控用 ps 看 STAT 列是否有 Z,代码里用 waitpid 循环回收。

论别神话协程

协程省的是"调度与切换成本",不是计算本身。CPU 密集任务开再多协程也不会更快,反而可能因抢占式调度引入额外开销。协程的价值在"等待密集型"(IO/网络/锁等待)场景——这点和第 5 章的 IO 模型、第 1 章的"等 IO 就 yield"一脉相承。

2

内存管理:虚拟内存、分页、TLB、缺页、页面置换

Virtual Memory · Paging · TLB · Page Fault

你写的每个指针都是"虚拟地址",OS 默默把它翻译成"物理地址"。这一层抽象带来了隔离、超卖与共享,也带来了 swap 卡顿与 OOM。这一章把翻译过程讲透。

虚拟内存:让你以为独占一整片内存

每个进程都以为自己独占从 0 到 Max 的连续地址空间,其实是 OS 用页表把虚拟页映射到物理页框。好处三连:隔离(A 看不到 B)、超卖(实际不够时用磁盘 swap 顶)、共享(多进程映射同一份只读库,如 libc)。

论为什么会有 OOM 和 swap 卡顿

分配超过物理内存时,OS 把冷页换到磁盘(swap),访问它要"换页"——一次磁盘 IO,程序瞬间卡住,这就是"内存快满时系统变黏"的原因。容器设了内存上限还会触发 OOM Killer 直接杀进程。

分页与页表:地址翻译的"字典"

虚拟地址被切成"页号 + 页内偏移"。页表就是"页号→物理页框号"的映射表。地址翻译硬件(MMU)查表得到物理地址。多级页表让"稀疏的大地址空间"不必真占满内存。

// 地址翻译(简化):页号查表,偏移直接拼 page_no = (virtual_addr >> PAGE_SHIFT) & PAGE_MASK; offset = virtual_addr & (PAGE_SIZE - 1); if (!pte[page_no].valid) raise(PAGE_FAULT); // 不在内存→缺页中断 phys_addr = pte[page_no].frame << PAGE_SHIFT | offset;
概念作用
虚拟页 (4KB 常见)地址空间的基本单位
页表项 PTE记录"映射谁、是否有效、是否脏"
多级页表节省稀疏地址空间的存储
MMU硬件加速地址翻译

TLB:页表的"缓存"

每次访存都查页表太慢,CPU 内置转址旁路缓冲 TLB缓存最近用过的"虚拟页→物理框"映射。TLB 命中是 1 个周期,未命中要走内存查页表(几十周期)。切换进程要刷 TLB(因为页表换了),这也是上下文切换的隐性成本。

TLB 抖动(Thrashing)

如果工作集(同时要用到的页)远大于物理内存,页不断被换进换出,TLB 和页表缓存反复失效,CPU 大部分时间花在"等内存"而非算业务——系统看起来 100% 忙却几乎没产出。这时加 CPU 没用,得加内存或减负载。

缺页中断:访问"还没在内存的页"时发生

程序访问的页如果不在物理内存(首次访问、或被换出),MMU 触发缺页中断,内核介入:分配物理页、从磁盘/swap 调入、更新页表、刷新 TLB,再重新执行那条指令。分"合法缺页"(正常按需调页)和"非法访问"(段错误 SIGSEGV)。

// 缺页处理(内核视角,简化) if (addr 越界 || 无权) -> 发 SIGSEGV 杀进程; if (页在 swap) { 选 victim 页; 若脏则写回磁盘; // 页面置换 调入目标页; } else (首次访问) -> 分配物理页 (demand paging); 更新页表; 刷新 TLB; 重新执行触发指令;

页面置换:内存满了先扔谁

物理页不够时,内核要挑一页换出。目标是"尽量少换"(换出去的待会又要换回来最亏)。经典算法:FIFO(先进先出,简单但可能扔掉常用页)、LRU(最近最少用,理想但实现贵)、Clock(LRU 的近似,环形扫描)。

算法思路优缺点
FIFO最早进来的先换实现简单,但可能扔热页
LRU最久没用的先换效果好,但要记录访问时间
Clock环形扫描,借/清访问位LRU 的廉价近似,实用首选
最优 OPT换"最远才用"的理论最优,但未来不可知

mmap:把文件/共享内存直接映射进地址空间

mmap 把文件或匿名内存映射到进程的虚拟地址,访问它就像访问数组,省掉了 read/write 的用户态缓冲拷贝。多个进程 MAP_SHARED 同一文件即实现共享内存。

// 把文件直接映射进内存,按指针访问,零次 read 拷贝 void* p = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); // 多进程 MAP_SHARED 同一文件 → 共享内存(IPC 最快方式) read(p, ...); // 像读内存一样读文件 munmap(p, len);

论mmap 是把双刃剑

它快(省拷贝、利用页缓存),但映射大文件时"按需调页"会让首次访问零星缺页;而且 mmap 出错不像 read 返回 -1 那么直观(可能 SIGBUS)。数据库(如 RocksDB/mmap 索引)爱用它,普通文件读写还是 read/write 更可控。

写时复制 COW:fork 为什么那么快

fork() 看似"复制了整个进程内存",实则用了写时复制:父子先共享同一份物理页、标记为只读;谁要写某一页,才真的拷贝那一页。所以 fork 本身几乎零成本,只有"真写"才付拷贝代价——这也是它敢被高频调用的原因。

// fork 后父子共享页,写时才拷贝(概念示意) if ((pid = fork()) == 0) { data[0] = 1; // 子进程写 → 触发该页 COW 拷贝,不影响父 } else { // 父进程看到的 data 仍是旧值,互不干扰 }

论COW 是"超卖"思路的延伸

和虚拟内存"超卖"一样,COW 是"能共享就先共享、真改再分"的懒惰哲学。它让 fork+exec 极快(exec 前几乎不拷贝),也是写时复制快照、容器镜像分层的思想雏形——镜像层叠,改动的才真复制。

3

文件系统:inode、目录、缓存页、IO 栈

inode · Directory · Page Cache · IO Stack

文件是"字节流 + 元数据"的抽象。它落在磁盘上要经 inode、目录项、页缓存、块设备多层。这一章讲清"一次 read 到底经过了什么",以及为什么"写完了"不等于"存稳了"。

inode:文件的"身份证 + 地图"

磁盘上真正描述一个文件的是 inode,它存元数据(权限、大小、时间戳、数据块指针),但不包括文件名。文件名存在于目录里——目录本身也是文件,内容是"文件名→inode 号"的映射。

inode 里的字段说明
文件类型/权限普通文件/目录/设备,rwx
大小 / 时间戳字节数、atime/mtime/ctime
数据块指针直接/间接块,指向真实数据
链接数几个名字指向它(硬链接)

论为什么"删了文件磁盘没变小"

删文件只是把目录项删掉、inode 链接数减一;只有当链接数为 0 且没进程打开它,数据块才被回收。所以"文件被进程占着、你却 rm 了"——磁盘空间要等进程关闭才释放。这也是 lsof 能查到"deleted but open"的原因。

目录与路径解析:从 "/" 一路查下去

解析 /a/b/c 是从根 inode 出发,逐段在目录的数据块里查子项,拿到下一级 inode,直到末级。这是个"链式查找",所以路径越深、目录项越多,查找越慢(dentry 缓存就是为加速它)。

// 路径解析(概念):从根目录 inode 出发逐段找 dir = root_inode; for name in ["a", "b", "c"]: entry = dir.lookup(name); // 在目录数据块里找子项 dir = entry.inode; return dir; // 拿到 c 的 inode

页缓存 Page Cache:让"读"快到不像 IO

内核把最近读过的文件块缓存在内存(Page Cache)。第二次读同一块直接命中内存(纳秒级),不用碰磁盘。写通常也是先写缓存(write-back),由内核异步刷盘——所以"write 返回"只代表"进了缓存",不代表"落盘"。

论Page Cache 是性能红利也是风险

红利:重复读极快、写合并降 IO。风险:进程写了数据、还没刷盘就断电/宕机 → 数据丢了(这就是为什么数据库要 WAL + fsync)。另外 Page Cache 会挤占应用内存,需要时用 fadvise/direct IO 规避。

IO 栈:一次 read 从 syscall 到磁盘

一次 read 的完整旅程:用户态 → 系统调用陷入内核 → VFS 统一接口 → 具体文件系统 → Page Cache(命中则直接返,未命中则发起块 IO)→ 块层/io 调度 → 设备驱动 → 磁盘。越往下越慢,所以缓存和异步才关键。

层级干什么
VFS统一文件/文件系统接口
具体文件系统 (ext4/xfs)把文件偏移映射到磁盘块
Page Cache内存缓存,命中即返
块层 / IO 调度合并、排序、排队
设备驱动 / 磁盘真正读写硬件

文件描述符:一切皆文件的操作柄

进程打开文件/套接字/管道,内核返回一个小整数 fd,后续读写都靠它。fd 是"进程级"的,fork 会复制(所以子进程能继承父的 fd,管道/重定向由此而来)。ulimit -n 限制单进程能开的 fd 数——高并发服务常因"fd 耗尽"出错。

fd = open("data.txt", O_RDONLY); // 返回文件描述符 n = read(fd, buf, sizeof buf); // 从内核缓冲拷到用户 buf write(STDOUT_FILENO, buf, n); close(fd); // 用完了释放,否则 fd 泄漏
fd 泄漏和"Too many open files"

忘了 close、或长连接没管好,fd 会一直涨,直到撞上 ulimit -n 上限,新连接/新文件全失败报 EMFILE。排查用 lsof -p PID | wc -l。生产服务要把 ulimit -n 调大,但更要确保"打开必关闭"。

持久化保证:fsync 与日志式文件系统

write 进 Page Cache 后,宕机就丢。要真"存稳"得 fsync 强制刷盘。现代文件系统(ext4/xfs)用日志(journal)保证"要么全写要么没写",避免崩溃后文件系统结构不一致——数据库 WAL 正是同一思想的用户态实现。

// 写后不 fsync,宕机即丢;WAL 必须 fsync 才能保证持久化 f = open("wal.log", O_WRONLY | O_APPEND, 0644); write(f, record, len); fsync(f); // 强制把缓存刷到磁盘(昂贵,但要 durability 就得付)

论性能 vs 持久化的权衡

每写都 fsync 最安全也最慢;完全不 fsync 最快但会丢数据。实务折中:批量/分组提交(group commit)+ 适当 fsync 频率,或用带电池/BBU 的 RAID 卡把"写缓存"变安全。这正对应数据库"每秒 fsync 几次"的调优旋钮。

硬链接与软链接:一个文件几个名字

硬链接是"同一个 inode 的多个目录项"——删除一个名字只是链接数减一,数据还在;软链接(符号链接)是存了"另一个路径"的特殊文件,像快捷方式,原文件删了软链接就断。两者都是"一个数据、多个入口"的不同实现。

对比硬链接软链接
跨文件系统不能能
指向同一 inode另一个路径名
原文件删除数据仍在链接失效
目录链接通常不允许允许
占用空间仅一个目录项一个特殊文件
软链接循环与"删不干净"

软链接可指向目录,配置不当会形成循环(find 会绕晕、rm -r 可能钻进去)。删目录时若没注意,可能顺着软链接删到外面。生产做清理脚本务必先 realpath 确认目标边界,别误删。

4

并发与同步:互斥、信号量、死锁、原子操作

Mutex · Semaphore · Deadlock · Atomic

多线程共享数据时,得保证"同一时刻只有一个在改",否则出现竞态。锁、信号量、原子操作是工具;死锁是它们用不好时的噩梦。这一章既给工具,也教你躲坑。

临界区与互斥锁 Mutex

临界区是"同时只允许一个线程进入"的代码段。互斥锁(Mutex)保证这一点:谁拿到锁谁进,出来才放。它简单直接,是并发保护的第一选择。

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_lock(&m); counter++; // 临界区:同时只有一个线程能进 pthread_mutex_unlock(&m); // 别忘了解锁!
忘记解锁 / 在临界区里 return

一旦临界区提前 return 或抛异常而没解锁,其他线程就永远拿不到锁——死锁。C 里要用 goto/统一出口;C++/Java/Go 用 RAII/defer/try-finally 保证"进必出"。这是并发 bug 的头号来源。

信号量与条件变量:锁之外的协作

信号量维护一个计数器,控制"最多 N 个线程同时进"(N=1 就是互斥锁,N>1 是限流/连接池);条件变量用于"某个条件满足再唤醒"的等待(如队列非空再消费)。

机制语义典型用途
Mutex互斥,非你即我保护临界区
Semaphore计数,限 N 个并发连接池、限流
Cond Var等条件满足再唤醒生产者/消费者

论条件变量为什么必须配互斥锁

判断"条件是否满足"和"睡下去等"之间若没有锁保护,可能条件刚变好你就睡了,永远醒不来(丢失唤醒)。条件变量规定:wait 时要持有锁,wait 内部会原子地"放锁+睡",被唤醒后再重新拿锁——这套约定才保证不丢唤醒。

死锁:四个必要条件与怎么预防

死锁是"大家互相等对方手里的资源,谁都动不了"。四个必要条件:互斥、持有并等待、不可剥夺、循环等待。破坏任一即可预防。

// 死锁经典:两线程以相反顺序加锁 ThreadA: lock(L1); lock(L2); ThreadB: lock(L2); lock(L1); // 交错执行 → 互等 → 死锁 // 预防:所有线程按固定顺序加锁(一律 L1→L2) // 或用 try_lock + 超时回退,避免硬等
必要条件破坏方法
互斥一般保留(资源本就独占)
持有并等待一次性申请所有资源
不可剥夺申请不到就释放已持有的
循环等待全局固定加锁顺序

活锁与饥饿:死锁之外的"假死"

活锁:线程都在动(互相谦让重试),却始终没进展,像两个人在走廊互相让路。饥饿:某线程一直抢不到资源(被高优先级/贪心者长期挤掉)。死锁是完全停,活锁/饥饿是"在忙但没用"。

论怎么避免活锁

活锁常源于"检测到冲突就立即重试"。解法:加随机退避(重试前随机等一小会儿),让双方错开;或用"谁先退让"的确定性规则,而不是大家都退。这和分布式里"冲突重试"的教训如出一辙。

原子操作:无锁的"读-改-写"

像"自增"这种复合操作,用原子指令(CPU 单条指令完成,中间不被打断)就能线程安全,无需锁、无上下文切换。适合简单计数器等。但原子只保证"这条操作"不可分割,多个原子操作之间仍可能竞态。

// 原子自增:一条 CPU 指令完成"读-改-写" atomic_int counter = 0; atomic_fetch_add(&counter, 1); // 无锁、无切换,比 Mutex 轻得多 // 对应 Go:atomic.AddInt64(&c, 1)
原子救不了"复合逻辑"

原子能保证单个变量读改写安全,但"先判断再操作"(如 if x>0 then x--)不是原子整体,仍要锁。别以为"用了 atomic 就线程安全了"——要看操作本身是不是单一不可拆。

读者-写者问题:读多写少怎么办

很多数据是"读远多于写"。若用普通互斥锁,读者之间也互斥,白白串行。读者-写者锁(RWLock)允许多个读者并发,但写者独占——大幅提升读多场景吞吐。

pthread_rwlock_t rw; pthread_rwlock_rdlock(&rw); // 读者:可多人同时进 // ... 读共享数据 ... pthread_rwlock_wrlock(&rw); // 写者:独占,等所有读者退 // ... 改共享数据 ...

论写者饥饿是 RWLock 的常见病

读者源源不断,写者可能一直等不到"零读者"的瞬间而饿死。解法:写者优先(来了写者就不再放新读者进),或限时升级。这也解释了为什么 Go 的 sync.RWMutex 在写多时表现不佳——它是为读多设计的。

自旋锁:极短临界区的取舍

普通互斥锁抢不到就"睡"(让出 CPU,附带上下文切换成本)。自旋锁抢不到就"空转忙等",不睡——适合临界区极短(几条指令)的场景,避免"睡眠/唤醒"的开销反超临界区本身。内核与无锁数据结构里常见。

// 自旋锁示意:抢不到就循环忙等,不进内核睡眠 while (atomic_test_and_set(&lock) == LOCKED) { // 忙等:仅当锁只持有极短时间才划算 } // 临界区(必须极短) lock = UNLOCKED;
自旋锁在单核上纯浪费

单 CPU 上自旋时,持有锁的线程根本没机会跑(你在占着 CPU 空转),纯耽误事。所以自旋锁只在多核 + 极短临界区才有意义;持锁时间长则应用普通 Mutex 让它去睡。用错场景反而更慢。

5

I/O 模型:阻塞、非阻塞、多路复用、异步 IO、零拷贝

Blocking · Non-blocking · epoll · AIO · Zero-copy

内存纳秒级、磁盘/网络毫秒级,差百万倍。IO 模型决定了"等待时线程在干嘛"。这一章讲清从"一连接一线程"到 epoll 到异步 IO 的演进,以及零拷贝怎么省下无谓的拷贝。

阻塞 vs 非阻塞:read 时线程在干嘛

阻塞 IO:read 没数据就睡,线程卡住啥也干不了,简单但并发差(一连接一线程,千人千线程)。非阻塞 IO:read 立即返回,没数据就返 EAGAIN,线程可以去干别的,稍后重试——为高并发打开大门。

// 非阻塞:设 O_NONBLOCK,read 不睡,没数据立即返 EAGAIN fcntl(fd, F_SETFL, O_NONBLOCK); while ((n = read(fd, buf, sz)) == -1 && errno == EAGAIN) { // 没数据,先去处理别的 fd,稍后再来试 }
模型等待时线程并发能力
阻塞 IO睡眠差(一连接一线程)
非阻塞 IO立即返,可轮询中(需自己轮)
IO 多路复用等一个集中器好(单线程管万连接)
异步 IO完全不等最好

IO 多路复用:select / poll / epoll

多路复用让一个线程同时盯上万个 fd,谁就绪就处理谁。select/poll 要遍历全部 fd(O(n));epoll(Linux)用内核事件表,只返回就绪的(O(1)),是高并发网络服务的基石(Nginx/Redis 都用它)。

efd = epoll_create1(0); ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &ev); // 注册"关心可读" while (1) { n = epoll_wait(efd, events, MAX, -1); // 只返回就绪的 fd for (i = 0; i < n; i++) handle(events[i].data.fd); }

论为什么 epoll 碾压 select

select 每次调用都要把"全部 fd 集合"从用户态拷贝进内核、内核再遍历一遍,fd 多了就慢。epoll 在内核维护一个长期的事件表(epoll_ctl 注册一次),epoll_wait 只取"真正就绪"的,复杂度 O(就绪数)——万级连接下天壤之别。

异步 IO:io_uring 与真正的"交给我、你先走"

epoll 解决了"等哪个 fd",但数据拷贝仍是"内核通知你→你再 read"两步走。异步 IO(Linux 的 io_uring)让你"提交一个读请求就走",内核完成后再通知——提交/完成队列共享内存,几乎无 syscall 开销,是高性能存储的新王者。

// io_uring:提交即返回,内核完成后在 CQ 里给结果 ring = io_uring_queue_init(256, &ring, 0); sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, buf, len, 0); // 准备异步读 io_uring_submit(&ring); // 提交,立即返回 cqe = io_uring_wait_cqe(&ring); // 等完成(或轮询不阻塞)

论异步 IO 与协程是天作之合

协程在"等 IO"时 yield,底层用 io_uring 真正异步提交,等完成再 resume——既保留了"同步写法"的好读性,又拿到异步的高吞吐。Rust tokio、Go 运行时的网络轮询器,本质都在这条路上。

零拷贝:少一次拷贝,吞吐翻几倍

传统"文件发往网络"要:磁盘→内核页缓存→用户缓冲→socket 缓冲→网卡,4 次拷贝 + 多次上下文切换。sendfile/splice 让数据在内核内直接从页缓存搬到网卡,零次用户态拷贝,是文件服务器/消息队列的标配优化。

// 传统:磁盘→内核→用户→socket→网卡(多次拷贝) // sendfile:磁盘→内核缓冲→网卡,零用户态拷贝 sendfile(out_fd, in_fd, &offset, count); // 内核内完成,不进用户态 // 配合 TCP_CORK / 大块写,吞吐显著提升
零拷贝不是"零开销"

它省的是CPU 拷贝和上下文切换,磁盘 IO 和 DMA 仍在。而且 sendfile 对"需要修改数据再发"的场景不适用(数据没进用户态你就改不了),那种情况仍得走常规 read/写回。

Reactor / Proactor:高并发服务的两种组织方式

Reactor(反应堆):事件循环 + 多路复用,事件就绪后由回调/协程处理(epoll 属于这类,仍要自己读数据)。Proactor(前摄器):发起异步操作,内核完成后回调(对应异步 IO)。前者成熟普及,后者理论更优但实现复杂。

模型谁拷数据代表
Reactor就绪后自己 readNetty、Nginx、Go netpoll
Proactor内核完成,回调给结果Windows IOCP、io_uring

论为什么大多数框架选 Reactor

Reactor + 协程(如 Go 把 epoll 藏在 netpoller 后,给你"同步写法")足够快、跨平台、好调试。Proactor 性能上限更高但编程模型复杂、各 OS 支持不一。对绝大多数业务,Reactor 已绰绰有余。

用户态缓冲:别一个字节一个字节地写

每次 write 都是一次系统调用(呼应第 6 章)。若循环里"一次写一个字节",syscall 开销会淹没真实 IO。标准库的缓冲 IO(如 C 的 fwrite、Java 的 BufferedWriter、Go 的 bufio)先在用户态攒一批,再一次性 syscall 写出,吞吐天差地别。

水平触发与边缘触发:epoll 的 LT / ET 模式

epoll 有两种通知模式:水平触发 LT(只要 fd 可读就一直通知,没读完下次还告你)最省心;边缘触发 ET(只在状态变化的那一刻通知一次,必须一次读干净,否则剩余数据不再被唤醒)性能更高但更易写错。ET 下必须用非阻塞 + 循环读到 EAGAIN。

// ET 模式:必须循环读到底,否则剩下的数据不再触发通知 while ((n = read(fd, buf, sz)) > 0) { handle(buf, n); } // 若中途停手,剩余数据 ET 不会再次通知 → 饿死 // LT 模式则宽松:这次没读完,下次 epoll_wait 还会报
ET 模式下忘读干净 = 静默丢数据

很多人上 ET 是为了高性能,却忘了"只通知一次",没循环读到 EAGAIN 就把剩余字节留在内核缓冲区、再也不被唤醒。要么用 LT(稳妥),要么 ET 配非阻塞 + 死循环读到 EAGAIN(高效但坑多)。新手强烈建议先用 LT。

// 无缓冲:每字节一次 write → syscall 爆炸 for (int i = 0; i < N; i++) write(fd, &buf[i], 1); // 缓冲:攒满一块再 write,syscall 次数降到 1/N char wbuf[4096]; int n = 0; for (...) { wbuf[n++] = c; if (n == 4096) { write(fd, wbuf, n); n = 0; } }

论缓冲与"及时性"的权衡

缓冲提升吞吐,但数据攒在用户态、没真正落盘,崩溃会丢。日志/关键写入路径要记得 flush/fflush,或按行/按大小刷。这正是第 3 章"Page Cache 与 fsync"在用户态的对应物——层层缓冲,每层都要想清"何时才真正可靠"。

6

操作系统与性能 / 容器底层:调度、cgroups、namespace、系统调用

Scheduler · cgroups · namespace · syscall overhead

CPU 核心远少于任务数,OS 要决定"谁先跑";容器则是 OS 能力的封装。这一章把调度、系统调用开销、cgroups/namespace、以及"OS 怎么支撑你已学的东西"讲清,让底层知识真正落地。

调度:CPU 该给谁用

调度器在就绪队列里选下一个上 CPU 的线程。早期用时间片轮转(RR);现代 Linux 用 CFS(完全公平调度),按"已跑时间"虚拟记账,谁亏待得最久就先补谁,兼顾公平与吞吐。实时任务另有实时调度类。

调度思路做法取舍
时间片轮转 RR每人轮流跑固定片公平但无视轻重
CFS(Linux 默认)按虚拟运行时间补欠公平+吞吐兼顾
实时调度优先级绝对优先强实时但可能饿死普通任务

论为什么"nice 值"和优先级影响性能

CFS 里每个任务有权重(nice 值越大权重越低),决定它能分到的 CPU 比例。一个低优先级、长占 CPU 的后台任务(如压缩/备份),设高 nice 值就能"有空才跑、不抢前台",这正是 nice/ionice 的价值。

系统调用开销:用户态与内核态的那道墙

应用跑在用户态,碰硬件/资源要syscall陷入内核态——涉及模式切换、寄存器保存、可能的 TLB/缓存影响。单次 syscall 约百纳秒到微秒级,看着小,但高 QPS 下"每请求几百次 syscall"就积少成多。

# 统计某进程各系统调用次数与耗时占比 strace -c -p 1234 # 汇总:哪些 syscall 最频繁/最慢 strace -f -e trace=network ./app # 只看网络相关调用 # 发现大量 read/write → 考虑批量/缓冲/零拷贝降 syscall 数

论怎么降系统调用成本

三招:① 批处理(一次写一批而非逐条)、② 缓冲(用户态攒够再 syscall)、③ 减少往返(如 io_uring 合并提交、sendfile 省拷贝)。很多"CPU 不高但吞吐上不去"的性能问题,根因就是 syscall 太碎。

cgroups:给进程组"划资源配额"

控制组 cgroups 限制一组进程能用多少 CPU、内存、IO。容器"不会吃光宿主机"就是靠它。cgroup v2 用统一层级,memory.max/cpu.max 直接写文件即可设限,超限就 OOM/限流。

# cgroup v2:限制某进程组最多 1 CPU、2GiB 内存 mkdir /sys/fs/cgroup/myapp echo 1234 > /sys/fs/cgroup/myapp/cgroup.procs echo "2000000" > /sys/fs/cgroup/myapp/memory.max echo "100000" > /sys/fs/cgroup/myapp/cpu.max # 1 CPU 配额
cgroup 内存 limit 与 OOM Killer

设了 memory.max 且程序超了,内核 OOM Killer 会杀掉该组里的进程。所以容器内应用(如 Java -Xmx、Go GOMEMLIMIT)要预留余量,别把内存正好顶到 limit——顶到就被杀,毫无商量。

namespace:制造"我独占一台机器"的错觉

命名空间 namespace 让进程看到"受限的全局资源视图":PID namespace 让它以为自己是 1 号进程,network namespace 让它有独立网栈,mount namespace 有独立挂载点……容器的"隔离"基本就是这些 namespace 的组合。

namespace隔离了什么
PID进程号空间(容器内看不到宿主机进程)
Network网络栈(独立 IP/端口/路由)
Mount文件系统挂载点
UTS主机名/域名
User用户/组 ID 映射

容器底层:cgroup + namespace 拼出的"轻量虚拟机"

所谓容器,本质就是:用 clone() 带上各 namespace 创建隔离的进程,再套一层 cgroup 限制资源,外面套个镜像作文件系统。它共享宿主机内核,所以启动秒级、开销极小——和虚拟机"各跑各的内核"完全不同。

# 用 unshare 手动体验 namespace 隔离(迷你容器) unshare --mount --uts --net --pid --fork bash # 进入新命名空间 # Docker 本质:clone() + 各 namespace + cgroup 限制 docker run --memory=2g --cpus=1 nginx

论一条贯通上下的知识链

Go 的 goroutine → 用户态调度,减少内核切换(呼应第 1 章);容器 → 用 namespace 做隔离、cgroup 限资源,都是 OS 能力的封装(呼应本页);数据库缓存 → 利用 Page Cache 与缓冲池(呼应第 3 章);分布式一致性 → 本质是"多进程并发同步"放大到多机(呼应第 4 章)。底层懂了,上层都通透。

性能观测:从 top 到 perf 火焰图

线上慢,不能靠猜。先用 top/vmstat 看是 CPU 满、IO 等还是内存换页;再用 perf 采样定位 CPU 热点,生成火焰图——横轴是调用栈、宽度是耗时占比,最宽的"柱子"就是优化重点。

# 采样 CPU 热点 30s,生成火焰图 perf record -F 99 -a -g -- sleep 30 perf script | ./stackcollapse-perf.pl | ./flamegraph.pl > fg.svg # 看内存换页是否严重 vmstat 1 # si/so 列非零 = 正在 swap,系统会黏

论观测要先分清"瓶颈在哪一层"

先判断是CPU 型(计算密集,看火焰图)、IO 型(等磁盘/网络,看 await/iowait)、还是内存型(swap、OOM)。方向错了优化白做——这正是 OS 原理的价值:让你知道该盯哪个指标。

结
本页要点

OS 是硬件之上的抽象层:进程提供隔离的虚拟地址空间、线程共享内存提供并发、协程在用户态调度极致轻量,而上下文切换(尤其换页表/刷 TLB)有真实成本。内存上,虚拟内存用页表制造"独占"假象,分页+TLB 加速翻译,缺页中断按需调页,页面置换(FIFO/LRU/Clock)决定谁被换出,mmap 省拷贝;超卖换来 swap 卡顿与 OOM。文件系统里 inode 存元数据、目录映射名字、Page Cache 让读极快但写需 fsync 保持久。并发要懂临界区与 Mutex、信号量/条件变量、死锁四条件与预防、原子操作的边界、读者-写者锁。IO 模型从阻塞到非阻塞到 epoll 多路复用到 io_uring 异步,零拷贝省掉无谓拷贝。底层调度(CFS)、系统调用开销、cgroups 限资源、namespace 做隔离,三者拼出容器;底层原理是语言运行时、数据库、分布式的共同地基。

自测 · 看你是否真懂

1.进程和线程最核心的两个区别是什么?为什么高并发要警惕"线程数暴涨"?

查看答案

进程有独立内存空间、隔离强但创建/切换重;线程共享进程内存、轻量但需同步、一个崩全进程崩。线程数远超 CPU 核心时,大量时间消耗在上下文切换(保存/恢复现场、刷 TLB)而非业务计算,吞吐量反而下降——这也是协程/异步模型被推崇的原因。

2.虚拟内存解决了哪三个问题?又带来了什么代价?

查看答案

隔离(进程互不看到对方内存)、内存超卖(用 swap 顶超出部分)、共享(多进程映射同一只读库)。代价是换页会产生磁盘 IO 卡顿(系统变黏),以及容器内存超上限触发 OOM Killer 杀进程。

3.死锁的四个必要条件是什么?至少说出两种破坏方法。

查看答案

互斥、持有并等待、不可剥夺、循环等待。破坏方法:① 全局固定加锁顺序(破坏循环等待);② 一次性申请所有资源(破坏持有并等待);③ 申请不到就释放已持有的(破坏不可剥夺)。还可用 try_lock + 超时回退避免硬等。

4.epoll 相比 select/poll 的核心优势是什么?零拷贝又解决了什么?

查看答案

select/poll 每次都要把全部 fd 拷进内核并遍历(O(n));epoll 在内核维护长期事件表,只返回就绪的 fd(O(就绪数)),万级连接下优势巨大。零拷贝(sendfile)则省掉"内核→用户→socket"的多余拷贝与上下文切换,让文件转发吞吐大幅提升。

5.容器的"隔离"和"资源限制"分别对应 OS 的哪两项机制?为什么容器内要给内存留余量?

查看答案

隔离靠 namespace(PID/network/mount 等各自独立的视图),资源限制靠 cgroups(memory.max/cpu.max 等)。因为 cgroup 内存超限会触发 OOM Killer 杀进程——应用(如 Java -Xmx、Go GOMEMLIMIT)要预留余量,别正好顶到 limit。

下一步往哪走

路学完 OS 原理之后

① 回 tech-linux:你会从"敲命令"变成"懂它在操作什么内核机制"(top 看调度、free 看内存、strace 看 syscall)。

② 串语言运行时:Java 虚拟线程、Go goroutine、Rust async,本质都是 OS 调度的上层封装——本章第 1 章就是它们的地基。

③ 串数据库与分布式:Page Cache、WAL/fsync、IO 模型、并发同步,正是 DB 与"多机一致性"的底层语汇,理解成本会骤降。