tech-linux 教你"怎么用"操作系统,本页教你"它到底在干什么"。进程/线程/协程、虚拟内存、文件系统、调度、并发同步、I/O 模型——这些是所有上层技术(语言运行时、数据库、容器、分布式)的共同地基。本页按六章把每条知识拆成"是什么 → 为什么 → 怎么用 → 踩什么坑",学完你看并发 bug、OOM、IO 慢,都能往底层归因,而不是只会重启。
进程 / 线程 / 协程:执行的基本单位
程序是"躺"在磁盘上的静态文件,进程是它"跑"起来的实例;进程里可以有很多条执行流,叫线程;而协程是更轻的用户态执行流。三者一个比一个轻,理解它们的边界与成本,是高并发的地基。
进程:被隔离的"资源容器"
进程拥有独立的虚拟地址空间——A 进程根本"看不进"B 进程的内存,所以一个进程崩了不会污染别人。它也是资源记账单位(内存、打开的文件、权限)。代价是创建/切换重、进程间通信(IPC)要走内核。
| 进程地址空间段 | 存什么 | 谁可读写 |
|---|---|---|
| 代码段 (.text) | 编译后的机器指令 | 只读、可执行 |
| 数据段 (.data/.bss) | 全局/静态变量 | 读写 |
| 堆 (heap) | malloc 动态分配 | 向上增长 |
| 栈 (stack) | 局部变量、函数调用帧 | 向下增长 |
| 内核区 | 内核代码/数据(映射) | 用户态不可直接访问 |
线程:共享地址空间里的"执行流"
一个进程里的多个线程共享堆和全局数据,但各有独立的栈和寄存器。共享带来高效率,也带来竞态——后续第 4 章专门讲怎么保护。线程切换比进程轻,因为不用换页表。
线程太多、频繁切换,CPU 大量时间花在"保存/恢复寄存器、切换页表/TLB"而非干活——这就是高并发下"线程爆炸反而更慢"的根因。协程(Go/Rust async)正是为减少切换成本而生。
协程:用户态的"轻量线程"
协程不对应操作系统线程,由程序自身(或运行时)在用户态调度。切换只改几个寄存器和栈指针,不进内核、无系统调用,成本比线程低一到两个数量级。Go 的 goroutine、Python 的 asyncio、C++ 的 coroutine 都是它。
论为什么协程能扛百万并发
一个 OS 线程默认栈 8MB,百万线程就是 8TB,不可能。协程栈初始几 KB、按需增长,百万协程只需几 GB。而且它们的切换在用户态完成,不用内核介入——这正是现代高并发服务的根本解法。
进程间通信 IPC:隔离之后怎么"说话"
进程隔离了,但彼此常要协作。OS 提供几种 IPC 机制,各有取舍:管道简单但单向、共享内存最快但需自己同步、消息队列解耦、Socket 可跨机。
| IPC 方式 | 速度 | 特点 | 适用 |
|---|---|---|---|
| 管道 pipe | 中 | 单向、父子进程 | shell 流水线 |
| 共享内存 | 最快 | 零拷贝,但需自己加锁 | 超高频交换 |
| 消息队列 | 中 | 解耦、可缓冲 | 模块解耦 |
| Socket | 慢 | 可跨机、通用 | 网络/跨进程 |
进程的诞生:fork / exec 两步走
Unix 创建进程是两步:fork() 复制出一个几乎一模一样的子进程(写时复制,Copy-On-Write,不真拷贝内存),exec() 把子进程的内存换成新程序。Shell 跑命令、Docker 起容器底层都是这套。
论为什么是 fork+exec 而不是一步"创建进程"
fork 出来的子进程天然继承了父的文件描述符、环境变量、信号处理——shell 重定向、管道正是靠这个。再 exec 换程序,既保留了"上下文",又装了"新脑子"。这种组合比"一步创建"灵活得多,是 Unix 哲学的经典设计。
线程与协程:一图看清代价差
常有人问"协程和线程到底差在哪"。一句话:协程是"用户态线程",切换不进内核、栈更省,所以能开巨多;线程是"内核态",隔离与调度由 OS 管,稳但重。选型看并发规模与语言生态——写高并发服务优先协程,写计算型多核并行仍靠线程/进程。
| 维度 | 线程(OS) | 协程(用户态) |
|---|---|---|
| 调度者 | 内核 | 运行时/程序自身 |
| 切换成本 | 高(进内核 + 刷 TLB) | 低(仅寄存器/栈指针) |
| 默认栈 | MB 级 | KB 级、按需增长 |
| 可开规模 | 上千到顶 | 百万级 |
| 调试可见性 | 系统工具直接看 | 依赖运行时追踪 |
进程状态与僵尸进程:为什么必须 wait
进程在生命周期里会在运行、就绪、阻塞等状态间切换;退出后若父进程没调用 wait 回收,它会变成僵尸进程——已经死了却仍占着 PID 和内核结构不释放,积累多了会耗尽 PID 表,导致新进程创建失败。若父进程先挂,子进程变孤儿,被 init(PID 1)领养并最终回收,问题不大。
孤儿被 PID 1 接管后会正常回收,不必慌;真正要防的是僵尸堆积:父进程是长生命周期服务(如常驻 daemon)却忘了 wait,僵尸越积越多,最终 PID 耗尽,连 fork 都失败。监控用 ps 看 STAT 列是否有 Z,代码里用 waitpid 循环回收。
论别神话协程
协程省的是"调度与切换成本",不是计算本身。CPU 密集任务开再多协程也不会更快,反而可能因抢占式调度引入额外开销。协程的价值在"等待密集型"(IO/网络/锁等待)场景——这点和第 5 章的 IO 模型、第 1 章的"等 IO 就 yield"一脉相承。
内存管理:虚拟内存、分页、TLB、缺页、页面置换
你写的每个指针都是"虚拟地址",OS 默默把它翻译成"物理地址"。这一层抽象带来了隔离、超卖与共享,也带来了 swap 卡顿与 OOM。这一章把翻译过程讲透。
虚拟内存:让你以为独占一整片内存
每个进程都以为自己独占从 0 到 Max 的连续地址空间,其实是 OS 用页表把虚拟页映射到物理页框。好处三连:隔离(A 看不到 B)、超卖(实际不够时用磁盘 swap 顶)、共享(多进程映射同一份只读库,如 libc)。
论为什么会有 OOM 和 swap 卡顿
分配超过物理内存时,OS 把冷页换到磁盘(swap),访问它要"换页"——一次磁盘 IO,程序瞬间卡住,这就是"内存快满时系统变黏"的原因。容器设了内存上限还会触发 OOM Killer 直接杀进程。
分页与页表:地址翻译的"字典"
虚拟地址被切成"页号 + 页内偏移"。页表就是"页号→物理页框号"的映射表。地址翻译硬件(MMU)查表得到物理地址。多级页表让"稀疏的大地址空间"不必真占满内存。
| 概念 | 作用 |
|---|---|
| 虚拟页 (4KB 常见) | 地址空间的基本单位 |
| 页表项 PTE | 记录"映射谁、是否有效、是否脏" |
| 多级页表 | 节省稀疏地址空间的存储 |
| MMU | 硬件加速地址翻译 |
TLB:页表的"缓存"
每次访存都查页表太慢,CPU 内置转址旁路缓冲 TLB缓存最近用过的"虚拟页→物理框"映射。TLB 命中是 1 个周期,未命中要走内存查页表(几十周期)。切换进程要刷 TLB(因为页表换了),这也是上下文切换的隐性成本。
如果工作集(同时要用到的页)远大于物理内存,页不断被换进换出,TLB 和页表缓存反复失效,CPU 大部分时间花在"等内存"而非算业务——系统看起来 100% 忙却几乎没产出。这时加 CPU 没用,得加内存或减负载。
缺页中断:访问"还没在内存的页"时发生
程序访问的页如果不在物理内存(首次访问、或被换出),MMU 触发缺页中断,内核介入:分配物理页、从磁盘/swap 调入、更新页表、刷新 TLB,再重新执行那条指令。分"合法缺页"(正常按需调页)和"非法访问"(段错误 SIGSEGV)。
页面置换:内存满了先扔谁
物理页不够时,内核要挑一页换出。目标是"尽量少换"(换出去的待会又要换回来最亏)。经典算法:FIFO(先进先出,简单但可能扔掉常用页)、LRU(最近最少用,理想但实现贵)、Clock(LRU 的近似,环形扫描)。
| 算法 | 思路 | 优缺点 |
|---|---|---|
| FIFO | 最早进来的先换 | 实现简单,但可能扔热页 |
| LRU | 最久没用的先换 | 效果好,但要记录访问时间 |
| Clock | 环形扫描,借/清访问位 | LRU 的廉价近似,实用首选 |
| 最优 OPT | 换"最远才用"的 | 理论最优,但未来不可知 |
mmap:把文件/共享内存直接映射进地址空间
mmap 把文件或匿名内存映射到进程的虚拟地址,访问它就像访问数组,省掉了 read/write 的用户态缓冲拷贝。多个进程 MAP_SHARED 同一文件即实现共享内存。
论mmap 是把双刃剑
它快(省拷贝、利用页缓存),但映射大文件时"按需调页"会让首次访问零星缺页;而且 mmap 出错不像 read 返回 -1 那么直观(可能 SIGBUS)。数据库(如 RocksDB/mmap 索引)爱用它,普通文件读写还是 read/write 更可控。
写时复制 COW:fork 为什么那么快
fork() 看似"复制了整个进程内存",实则用了写时复制:父子先共享同一份物理页、标记为只读;谁要写某一页,才真的拷贝那一页。所以 fork 本身几乎零成本,只有"真写"才付拷贝代价——这也是它敢被高频调用的原因。
论COW 是"超卖"思路的延伸
和虚拟内存"超卖"一样,COW 是"能共享就先共享、真改再分"的懒惰哲学。它让 fork+exec 极快(exec 前几乎不拷贝),也是写时复制快照、容器镜像分层的思想雏形——镜像层叠,改动的才真复制。
文件系统:inode、目录、缓存页、IO 栈
文件是"字节流 + 元数据"的抽象。它落在磁盘上要经 inode、目录项、页缓存、块设备多层。这一章讲清"一次 read 到底经过了什么",以及为什么"写完了"不等于"存稳了"。
inode:文件的"身份证 + 地图"
磁盘上真正描述一个文件的是 inode,它存元数据(权限、大小、时间戳、数据块指针),但不包括文件名。文件名存在于目录里——目录本身也是文件,内容是"文件名→inode 号"的映射。
| inode 里的字段 | 说明 |
|---|---|
| 文件类型/权限 | 普通文件/目录/设备,rwx |
| 大小 / 时间戳 | 字节数、atime/mtime/ctime |
| 数据块指针 | 直接/间接块,指向真实数据 |
| 链接数 | 几个名字指向它(硬链接) |
论为什么"删了文件磁盘没变小"
删文件只是把目录项删掉、inode 链接数减一;只有当链接数为 0 且没进程打开它,数据块才被回收。所以"文件被进程占着、你却 rm 了"——磁盘空间要等进程关闭才释放。这也是 lsof 能查到"deleted but open"的原因。
目录与路径解析:从 "/" 一路查下去
解析 /a/b/c 是从根 inode 出发,逐段在目录的数据块里查子项,拿到下一级 inode,直到末级。这是个"链式查找",所以路径越深、目录项越多,查找越慢(dentry 缓存就是为加速它)。
页缓存 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 耗尽"出错。
忘了 close、或长连接没管好,fd 会一直涨,直到撞上 ulimit -n 上限,新连接/新文件全失败报 EMFILE。排查用 lsof -p PID | wc -l。生产服务要把 ulimit -n 调大,但更要确保"打开必关闭"。
持久化保证:fsync 与日志式文件系统
write 进 Page Cache 后,宕机就丢。要真"存稳"得 fsync 强制刷盘。现代文件系统(ext4/xfs)用日志(journal)保证"要么全写要么没写",避免崩溃后文件系统结构不一致——数据库 WAL 正是同一思想的用户态实现。
论性能 vs 持久化的权衡
每写都 fsync 最安全也最慢;完全不 fsync 最快但会丢数据。实务折中:批量/分组提交(group commit)+ 适当 fsync 频率,或用带电池/BBU 的 RAID 卡把"写缓存"变安全。这正对应数据库"每秒 fsync 几次"的调优旋钮。
硬链接与软链接:一个文件几个名字
硬链接是"同一个 inode 的多个目录项"——删除一个名字只是链接数减一,数据还在;软链接(符号链接)是存了"另一个路径"的特殊文件,像快捷方式,原文件删了软链接就断。两者都是"一个数据、多个入口"的不同实现。
| 对比 | 硬链接 | 软链接 |
|---|---|---|
| 跨文件系统 | 不能 | 能 |
| 指向 | 同一 inode | 另一个路径名 |
| 原文件删除 | 数据仍在 | 链接失效 |
| 目录链接 | 通常不允许 | 允许 |
| 占用空间 | 仅一个目录项 | 一个特殊文件 |
软链接可指向目录,配置不当会形成循环(find 会绕晕、rm -r 可能钻进去)。删目录时若没注意,可能顺着软链接删到外面。生产做清理脚本务必先 realpath 确认目标边界,别误删。
并发与同步:互斥、信号量、死锁、原子操作
多线程共享数据时,得保证"同一时刻只有一个在改",否则出现竞态。锁、信号量、原子操作是工具;死锁是它们用不好时的噩梦。这一章既给工具,也教你躲坑。
临界区与互斥锁 Mutex
临界区是"同时只允许一个线程进入"的代码段。互斥锁(Mutex)保证这一点:谁拿到锁谁进,出来才放。它简单直接,是并发保护的第一选择。
一旦临界区提前 return 或抛异常而没解锁,其他线程就永远拿不到锁——死锁。C 里要用 goto/统一出口;C++/Java/Go 用 RAII/defer/try-finally 保证"进必出"。这是并发 bug 的头号来源。
信号量与条件变量:锁之外的协作
信号量维护一个计数器,控制"最多 N 个线程同时进"(N=1 就是互斥锁,N>1 是限流/连接池);条件变量用于"某个条件满足再唤醒"的等待(如队列非空再消费)。
| 机制 | 语义 | 典型用途 |
|---|---|---|
| Mutex | 互斥,非你即我 | 保护临界区 |
| Semaphore | 计数,限 N 个并发 | 连接池、限流 |
| Cond Var | 等条件满足再唤醒 | 生产者/消费者 |
论条件变量为什么必须配互斥锁
判断"条件是否满足"和"睡下去等"之间若没有锁保护,可能条件刚变好你就睡了,永远醒不来(丢失唤醒)。条件变量规定:wait 时要持有锁,wait 内部会原子地"放锁+睡",被唤醒后再重新拿锁——这套约定才保证不丢唤醒。
死锁:四个必要条件与怎么预防
死锁是"大家互相等对方手里的资源,谁都动不了"。四个必要条件:互斥、持有并等待、不可剥夺、循环等待。破坏任一即可预防。
| 必要条件 | 破坏方法 |
|---|---|
| 互斥 | 一般保留(资源本就独占) |
| 持有并等待 | 一次性申请所有资源 |
| 不可剥夺 | 申请不到就释放已持有的 |
| 循环等待 | 全局固定加锁顺序 |
活锁与饥饿:死锁之外的"假死"
活锁:线程都在动(互相谦让重试),却始终没进展,像两个人在走廊互相让路。饥饿:某线程一直抢不到资源(被高优先级/贪心者长期挤掉)。死锁是完全停,活锁/饥饿是"在忙但没用"。
论怎么避免活锁
活锁常源于"检测到冲突就立即重试"。解法:加随机退避(重试前随机等一小会儿),让双方错开;或用"谁先退让"的确定性规则,而不是大家都退。这和分布式里"冲突重试"的教训如出一辙。
原子操作:无锁的"读-改-写"
像"自增"这种复合操作,用原子指令(CPU 单条指令完成,中间不被打断)就能线程安全,无需锁、无上下文切换。适合简单计数器等。但原子只保证"这条操作"不可分割,多个原子操作之间仍可能竞态。
原子能保证单个变量读改写安全,但"先判断再操作"(如 if x>0 then x--)不是原子整体,仍要锁。别以为"用了 atomic 就线程安全了"——要看操作本身是不是单一不可拆。
读者-写者问题:读多写少怎么办
很多数据是"读远多于写"。若用普通互斥锁,读者之间也互斥,白白串行。读者-写者锁(RWLock)允许多个读者并发,但写者独占——大幅提升读多场景吞吐。
论写者饥饿是 RWLock 的常见病
读者源源不断,写者可能一直等不到"零读者"的瞬间而饿死。解法:写者优先(来了写者就不再放新读者进),或限时升级。这也解释了为什么 Go 的 sync.RWMutex 在写多时表现不佳——它是为读多设计的。
自旋锁:极短临界区的取舍
普通互斥锁抢不到就"睡"(让出 CPU,附带上下文切换成本)。自旋锁抢不到就"空转忙等",不睡——适合临界区极短(几条指令)的场景,避免"睡眠/唤醒"的开销反超临界区本身。内核与无锁数据结构里常见。
单 CPU 上自旋时,持有锁的线程根本没机会跑(你在占着 CPU 空转),纯耽误事。所以自旋锁只在多核 + 极短临界区才有意义;持锁时间长则应用普通 Mutex 让它去睡。用错场景反而更慢。
I/O 模型:阻塞、非阻塞、多路复用、异步 IO、零拷贝
内存纳秒级、磁盘/网络毫秒级,差百万倍。IO 模型决定了"等待时线程在干嘛"。这一章讲清从"一连接一线程"到 epoll 到异步 IO 的演进,以及零拷贝怎么省下无谓的拷贝。
阻塞 vs 非阻塞:read 时线程在干嘛
阻塞 IO:read 没数据就睡,线程卡住啥也干不了,简单但并发差(一连接一线程,千人千线程)。非阻塞 IO:read 立即返回,没数据就返 EAGAIN,线程可以去干别的,稍后重试——为高并发打开大门。
| 模型 | 等待时线程 | 并发能力 |
|---|---|---|
| 阻塞 IO | 睡眠 | 差(一连接一线程) |
| 非阻塞 IO | 立即返,可轮询 | 中(需自己轮) |
| IO 多路复用 | 等一个集中器 | 好(单线程管万连接) |
| 异步 IO | 完全不等 | 最好 |
IO 多路复用:select / poll / epoll
多路复用让一个线程同时盯上万个 fd,谁就绪就处理谁。select/poll 要遍历全部 fd(O(n));epoll(Linux)用内核事件表,只返回就绪的(O(1)),是高并发网络服务的基石(Nginx/Redis 都用它)。
论为什么 epoll 碾压 select
select 每次调用都要把"全部 fd 集合"从用户态拷贝进内核、内核再遍历一遍,fd 多了就慢。epoll 在内核维护一个长期的事件表(epoll_ctl 注册一次),epoll_wait 只取"真正就绪"的,复杂度 O(就绪数)——万级连接下天壤之别。
异步 IO:io_uring 与真正的"交给我、你先走"
epoll 解决了"等哪个 fd",但数据拷贝仍是"内核通知你→你再 read"两步走。异步 IO(Linux 的 io_uring)让你"提交一个读请求就走",内核完成后再通知——提交/完成队列共享内存,几乎无 syscall 开销,是高性能存储的新王者。
论异步 IO 与协程是天作之合
协程在"等 IO"时 yield,底层用 io_uring 真正异步提交,等完成再 resume——既保留了"同步写法"的好读性,又拿到异步的高吞吐。Rust tokio、Go 运行时的网络轮询器,本质都在这条路上。
零拷贝:少一次拷贝,吞吐翻几倍
传统"文件发往网络"要:磁盘→内核页缓存→用户缓冲→socket 缓冲→网卡,4 次拷贝 + 多次上下文切换。sendfile/splice 让数据在内核内直接从页缓存搬到网卡,零次用户态拷贝,是文件服务器/消息队列的标配优化。
它省的是CPU 拷贝和上下文切换,磁盘 IO 和 DMA 仍在。而且 sendfile 对"需要修改数据再发"的场景不适用(数据没进用户态你就改不了),那种情况仍得走常规 read/写回。
Reactor / Proactor:高并发服务的两种组织方式
Reactor(反应堆):事件循环 + 多路复用,事件就绪后由回调/协程处理(epoll 属于这类,仍要自己读数据)。Proactor(前摄器):发起异步操作,内核完成后回调(对应异步 IO)。前者成熟普及,后者理论更优但实现复杂。
| 模型 | 谁拷数据 | 代表 |
|---|---|---|
| Reactor | 就绪后自己 read | Netty、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 是为了高性能,却忘了"只通知一次",没循环读到 EAGAIN 就把剩余字节留在内核缓冲区、再也不被唤醒。要么用 LT(稳妥),要么 ET 配非阻塞 + 死循环读到 EAGAIN(高效但坑多)。新手强烈建议先用 LT。
论缓冲与"及时性"的权衡
缓冲提升吞吐,但数据攒在用户态、没真正落盘,崩溃会丢。日志/关键写入路径要记得 flush/fflush,或按行/按大小刷。这正是第 3 章"Page Cache 与 fsync"在用户态的对应物——层层缓冲,每层都要想清"何时才真正可靠"。
操作系统与性能 / 容器底层:调度、cgroups、namespace、系统调用
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"就积少成多。
论怎么降系统调用成本
三招:① 批处理(一次写一批而非逐条)、② 缓冲(用户态攒够再 syscall)、③ 减少往返(如 io_uring 合并提交、sendfile 省拷贝)。很多"CPU 不高但吞吐上不去"的性能问题,根因就是 syscall 太碎。
cgroups:给进程组"划资源配额"
控制组 cgroups 限制一组进程能用多少 CPU、内存、IO。容器"不会吃光宿主机"就是靠它。cgroup v2 用统一层级,memory.max/cpu.max 直接写文件即可设限,超限就 OOM/限流。
设了 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 限制资源,外面套个镜像作文件系统。它共享宿主机内核,所以启动秒级、开销极小——和虚拟机"各跑各的内核"完全不同。
论一条贯通上下的知识链
Go 的 goroutine → 用户态调度,减少内核切换(呼应第 1 章);容器 → 用 namespace 做隔离、cgroup 限资源,都是 OS 能力的封装(呼应本页);数据库缓存 → 利用 Page Cache 与缓冲池(呼应第 3 章);分布式一致性 → 本质是"多进程并发同步"放大到多机(呼应第 4 章)。底层懂了,上层都通透。
性能观测:从 top 到 perf 火焰图
线上慢,不能靠猜。先用 top/vmstat 看是 CPU 满、IO 等还是内存换页;再用 perf 采样定位 CPU 热点,生成火焰图——横轴是调用栈、宽度是耗时占比,最宽的"柱子"就是优化重点。
论观测要先分清"瓶颈在哪一层"
先判断是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 与"多机一致性"的底层语汇,理解成本会骤降。