ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

小林Coding操作系统笔记:进程内存IO与性能排查

小林Coding操作系统笔记:进程内存IO与性能排查 学操作系统这门课我前后啃过三本教材、刷过两轮网课最后真正让我把零散知识点串成一条线的是一份叫小林Coding 操作系统读书笔记的东西。它不是教材也不是题库更像一张把操作系统这个庞然大物拆成可单独攻克的模块的地图——进程、线程、内存、文件、IO、中断每一块都标了这块常考什么、实际跑起来是什么样、我该用什么命令去验证它。如果你是准备期末考试或者面试的在校生它能帮你把散落的知识点快速收拢如果你已经工作几年、天天写业务代码但对底层只有模糊印象它同样能拿来当索引顺着某个关键词往下钻。我自己就是用后一种姿势在读读的过程里不断往笔记旁边补命令、补实验、补踩过的坑最后攒出了这篇文章。1. 先搞清楚这份笔记到底解决什么问题1.1 操作系统这门课的知识地图长什么样操作系统最让人头疼的地方不是某个概念难而是概念之间的层级关系太乱。你单独看进程和虚拟内存都能懂但要问进程切换时页表发生了什么很多人就卡住了。这份笔记的第一个价值就是它先给了一张分层地图把整个体系切成四块。第一块是硬件与抽象层CPU、内存、磁盘、中断控制器这些物理部件以及操作系统在上面盖的第一层假象——进程是CPU的假象虚拟内存是物理内存的假象文件是磁盘块的假象。第二块是资源管理器进程调度管CPU时间内存管理管物理页框文件系统管块设备空间设备管理管IO通道。第三块是接口层系统调用、中断、信号这些用户态和内核态之间的通道。第四块是并发与同步锁、信号量、死锁这一块横跨前三块哪里都可能出问题也是面试最爱问的地方。我建议你先花半小时把这张地图画在纸上每个方块下面写三到五个关键词。比如内存管理下面写分页、页表、TLB、缺页中断、页面置换、OOM。画完你就会发现后面读到的每个细节都能往某个方块里挂不会变成一堆漂浮的名词。这一步看似浪费时间实际上能省掉后面大量的读了就忘。提示这张地图不要照抄别人的一定要自己手画一遍。画的过程就是在建立索引抄的过程只是在搬运。1.2 我为什么把它当索引而不是教材教材的写法是自底向上、严谨完整但代价是慢。你顺着《操作系统概念》第10版从第1章读到第10章可能两周过去了还停留在调度算法连一次真实的进程切换长什么样都没见过。笔记类材料的优势在于密度它把结论关键推导考点压缩在一起你可以在一天之内把进程那一块扫完然后再回头挑感兴趣的细节深挖。我的具体用法是三遍法。第一遍只读标题和加粗结论快速判断哪些是我已经会的、哪些是彻底陌生的用不同颜色标记。第二遍专攻陌生部分每个概念都逼自己回答两个问题它在真实系统里对应什么我能不能用一条命令或者十行代码把它演示出来第三遍是隔一周之后回看只看自己的标记和批注检验哪些已经内化成常识了。这个用法有个前提就是你必须配上自己的实验。笔记告诉你进程有五种状态这只是文字你自己写一个 fork 之后不 wait 的程序用ps看到defunct状态的僵尸进程那才是你的知识。这篇文章后面会给出具体的实验清单都是我自己跑过的。2. 进程、线程与调度从概念到可观测的数字2.1 进程、线程、协程的边界到底在哪这三者的区别几乎每次面试都会被问但答案的深度差别很大。浅层回答是进程是资源分配单位线程是调度单位这套话术能过初面但撑不住追问。往深一层你要能说清楚它们各自共享什么、隔离什么。进程之间是完全隔离的独立的虚拟地址空间、独立的页表、独立的文件描述符表、独立的信号处理设置。线程共享同一地址空间、同一页表、同一份文件描述符表但各自有独立的栈、寄存器上下文、线程本地存储TLS。协程则是彻头彻尾的用户态概念内核完全不知道它的存在切换时不经过系统调用只在自己维护的栈上做寄存器保存和恢复。这个差异直接决定了开销量级。一台普通 x86 服务器上同一个进程内的线程切换大概在 1 到 2 微秒跨进程切换因为要换页表、刷新 TLB通常要 3 到 5 微秒而协程切换可以做到 100 到 200 纳秒。差了整整一个数量级。所以高并发网络服务用协程本质是用用户态调度换掉内核态调度的开销。但协程不是免费的。它把复杂度转移给了程序员阻塞式系统调用必须全部改成非阻塞加事件循环否则一个协程卡住整个线程就完了。而且协程没法利用多核除非开多个线程各自跑事件循环——这就是 Go 的 GMP 模型和 Python asyncio 要解决的事。进程隔离性最好开销最大适合做故障隔离和资源限制的边界线程共享内存方便要注意数据竞争适合 CPU 密集型的并行计算协程切换最便宜适合 IO 密集型的高并发场景但要求全链路非阻塞注意不要在生产环境里为了性能盲目上协程。如果你的瓶颈是 CPU协程一点用都没有反而多了一层调度开销。2.2 五种进程状态与不可中断睡眠这个坑Linux 内核里进程状态主要用TASK_*系列常量表示常见的有TASK_RUNNING可运行包括在跑和在就绪队列排队、TASK_INTERRUPTIBLE可中断睡眠等事件、TASK_UNINTERRUPTIBLE不可中断睡眠、TASK_ZOMBIE僵尸、TASK_STOPPED暂停。教科书上的五状态模型新建、就绪、运行、阻塞、终止是简化版真实内核里状态更多。这里面最值得记住的是TASK_UNINTERRUPTIBLE也就是ps里看到的D 状态。它意味着进程正在等某个不能被信号打断的内核操作完成。为什么不可中断因为内核认为这个等待不能半途放弃否则会破坏数据结构的一致性。D 状态的现实影响是kill -9杀不掉它。很多人第一次遇到时非常崩溃以为系统坏了。实际上你只能等那个内核操作自己返回通常是磁盘 IO 完成或者驱动超时。如果 D 状态进程长期堆积八成是存储链路或者网络文件系统出了问题这时候该查的是磁盘健康状态和dmesg里的 IO 报错而不是继续尝试杀进程。僵尸进程则是另一个常见困惑点。子进程退出后内核要保留它的一小部分信息退出码、资源使用统计等父进程通过wait或waitpid取走。父进程一直不取子进程就一直处于僵尸态。僵尸本身不占内存但会占 PID。如果父进程先退出子进程会被 initPID 1接管并回收这时候叫孤儿进程反而不会有问题。复现很简单#include stdio.h #include unistd.h int main(void) { pid_t pid fork(); if (pid 0) { _exit(0); /* 子进程立刻退出 */ } /* 父进程故意不 wait睡眠一分钟 */ sleep(60); return 0; }编译运行后另开一个终端执行ps -ef | grep defunct你会看到那个defunct标记的进程。再用cat /proc/子进程pid/status能看到State: Z (zombie)。这比背十遍定义都管用。2.3 调度器与上下文切换开销实测Linux 默认的普通进程调度器是CFS完全公平调度核心思想是用红黑树按虚拟运行时间 vruntime 排序每次挑 vruntime 最小的进程上 CPU。vruntime 增长的速度和进程权重成反比权重由 nice 值换算而来weight 1024 / (1.25 ^ nice)nice 值范围 -20 到 19。nice 越大权重越小vruntime 涨得越快就越容易被排在后面。nice 每降低 1权重大约增加 25%这就是1.25这个数字的来源。算几个值你就明白了nice 值权重相对 nice0 的 CPU 占比-5约 3355约 3.28 倍010241 倍5约 335约 0.33 倍10约 110约 0.107 倍这个表能解释一个常见现象你把某个进程nice -n 19以为它会让出 CPU然后就微乎其微地慢。实际上当系统负载不高时它依然能拿到接近满额的 CPU 时间——CFS 的公平是在有竞争时才体现的。上下文切换开销怎么测最直接的是看内核计数器。运行vmstat 1输出里的cs列就是每秒上下文切换次数。用pidstat -w 1可以看单个进程的cswch/s自愿切换和nvcswch/s非自愿切换。自愿切换通常是在等 IO 或等锁非自愿切换是被时间片抢占或者被更高优先级进程踢下去。如果 nvcswch/s 很高说明 CPU 竞争激烈如果 cswch/s 很高但 CPU 利用率不高说明大量时间花在等锁或等 IO 上。想看更细的用perf stat观察perf stat -e context-switches,cpu-migrations,page-faults,instructions,cycles ./your_programcpu-migrations是进程被迁移到其他 CPU 的次数如果这个数字异常高说明负载均衡太激进或者绑核没做好会严重伤害缓存命中率。如果想横向比较进程切换和线程切换的实际延迟可以用 lmbench 里的 lat_ctxlat_ctx -s 0 2 # 测两个进程之间的切换延迟 lat_ctx -P 2 2 # 加 -P 参数测线程实测在一台常规云主机上进程切换大概 2 到 4 微秒线程切换 1 微秒上下和前面说的量级一致。你自己跑一遍比看任何结论印象都深。2.4 进程间通信选型对照笔记里把 IPC 方式列了六七种真正需要记住的是各自主打什么场景。我把它们整理成一张决策表方式数据量性能适用场景主要坑管道/匿名管道小中父子进程流式传递半双工容量默认 64KB命名管道 FIFO小中无亲缘关系进程需要文件系统节点消息队列中中有边界的消息传递有大小上限内核拷贝共享内存大最高高频大数据量交换必须自己做同步信号量--配合共享内存做同步只能同步不传数据信号极小高事件通知不可靠会丢处理函数限制多本地 socket中大较高通用、支持双向流有系统调用开销实际工程里的排序通常是共享内存 信号量 本地 socket 管道 消息队列。共享内存快是因为它只做一次映射之后读写都不需要内核参与零拷贝。但代价是同步必须自己写sem_wait/sem_post漏一个就是死锁或者数据损坏。本地 socket 是通用性最好的选择因为它同时支持流式和数据报还能传递文件描述符用SCM_RIGHTS跨进程传句柄这个能力在服务治理里非常有用。代价是每条消息都要陷入内核。心得不要一上来就选共享内存。我见过太多项目为了性能用共享内存结果同步逻辑写得千疮百孔最后反而比 socket 方案慢因为调试和修 bug 的时间成本远大于那点性能收益。3. 内存管理虚拟地址到物理页的完整链路3.1 虚拟内存解决的两个问题虚拟内存被讲烂了但很多人只记得每个进程有独立地址空间这一层。它真正解决的是两个独立的问题隔离和抽象。隔离是安全问题。没有虚拟内存进程 A 可以直接读写进程 B 的内存任何指针错误都可能破坏别的程序甚至内核。有了页表和 MMU进程只能访问自己页表里映射过的物理页越界会触发缺页异常内核直接给一个 SIGSEGV。抽象是工程问题。虚拟内存让每个进程都以为自己独占一整片连续的地址空间编译器不用关心物理内存碎片链接器可以把代码段固定放在0x400000这样的地址上加载器可以按需把代码页从磁盘映射进来而不是一次性读全部。这直接带来了 mmap、按需分页、写时复制COW这些机制。写时复制值得单独说一句因为它是 fork 效率的关键。早期 fork 要完整复制父进程地址空间一个几百 MB 的进程 fork 一次就很慢。现在 fork 只复制页表把父子的页表项都标记为只读父子共享同一批物理页任何一方写入时触发写保护缺页内核才真正复制那一页。所以fork之后立刻exec这种模式shell 执行命令就是这么干的几乎是零拷贝的因为根本没有写操作。3.2 多级页表到底省了多少内存含计算这是笔记里少有的几个必须动手算的地方因为算一遍你就再也忘不掉为什么要用多级页表。先看单级页表。假设 32 位地址空间4GB页大小 4KB那么页内偏移占 12 位页号占 20 位也就是 2^20 1048576 个页表项。每个页表项如果按 4 字节算一张完整的页表就是 4MB。每个进程都要一张100 个进程就是 400MB——纯用来存地址映射还什么都没干。再看两级页表。把 20 位页号拆成 10 位 10 位。顶级页表有 2^10 1024 个表项每项 4 字节固定占用 4KB。每个顶级表项指向一个二级页表二级页表也有 1024 项覆盖 1024 × 4KB 4MB 的地址范围。关键在于二级页表按需分配。假设某个进程实际只用了 1MB 内存也就是 256 个页这些页落在同一个 4MB 区间里所以只需要一个二级页表。总内存 4KB顶级 4KB一个二级 8KB。相比单级的 4MB省了 500 倍。当然有代价地址转换从一次访存查表变成两次。64 位系统更夸张Linux 用四级页表PGD → PUD → PMD → PTE48 位有效虚拟地址拆成 999912最坏情况要四次访存才能找到物理地址。这就是 TLB 存在的理由。TLB 是一块很小的相联缓存通常几十到几百个条目专门缓存最近用过的页表项。假设 TLB 命中率 99%TLB 访问耗时 1ns内存访问 100ns那么有效访问时间EAT 0.99 × (1 100) 0.01 × (1 100 100) 99.99 2.01 102 ns如果没有 TLB每次都要走完整的多级查表代价接近 400ns 以上。所以 TLB 命中率是内存性能的命门。这也是为什么进程切换代价高——切进程要换页表、刷 TLB接下来的几百次访存全是 TLB miss。优化手段包括大页HugePage把 4KB 页换成 2MB 页一个 TLB 条目覆盖的地址范围扩大 512 倍和给关键进程绑核。3.3 缺页、置换算法与 OOM 排查缺页中断分两类。次要缺页minor fault是页已经在物理内存里只是当前进程的页表没建立映射比如 COW 复制、共享内存首次访问。主要缺页major fault是页不在内存需要读磁盘或触发 IO。主要缺页的代价是次要缺页的几千倍所以排查性能问题时要重点看 major fault。用/usr/bin/time -v ./program能看到完整的统计Minor (reclaiming a frame) page faults: 3421 Major (requiring I/O) page fault: 0如果 major fault 数量很大说明工作集超过了可用内存或者程序在做大范围的随机访问破坏了局部性。页面置换算法里教科书重点是 OPT最优无法实现用作基准、FIFO有 Belady 异常、LRU理论好实现贵。Linux 实际用的是近似 LRU 的双列表机制每个页框挂在 active 或 inactive 链表上新页进 inactive被访问两次就升到 active回收时优先从 inactive 尾部拿。这种二次机会式的设计避免了维护精确访问时间戳的开销。Belady 异常值得单独记一下FIFO 算法下增加物理页框数量反而可能导致缺页次数增加。这是反直觉的现象考试爱考。LRU 是栈式算法不会出现这个问题。OOM内存不足是生产环境最烦人的问题之一。Linux 的策略由/proc/sys/vm/overcommit_memory控制0 是启发式检查1 是永远允许超分2 是严格按比例限制。默认的 0 在大多数场景下够用但如果你跑的是内存敏感的数据库建议监控到位而不是靠内核兜底。真发生 OOM 时内核的 OOM Killer 会挑一个进程杀掉。挑选依据是oom_score主要看进程占用的内存量RSS加上一些调整值。可以通过/proc/pid/oom_score_adj手动调整优先级范围 -1000 到 1000值越小越不容易被杀。现代做法是用 cgroup v2 做限制比全局 OOM Killer 精细得多echo 2G /sys/fs/cgroup/myapp/memory.max echo 1536M /sys/fs/cgroup/myapp/memory.highmemory.max是硬限制超了就在这个 cgroup 内部触发 OOM不会影响别的服务。memory.high是软限制超了会先做内存回收、放慢分配速度相当于一个缓冲区。我在实际项目里通常把 high 设在 max 的 75% 左右给回写和突发留余量。注意memory.max设得比进程实际工作集小会导致频繁回收表现为 CPU 飙高但吞吐下降比直接 OOM 更难排查。上线前一定要压测确认工作集大小。4. 并发与死锁锁的代价和踩坑记录4.1 死锁四个条件与三种处理策略死锁的四个必要条件——互斥、持有并等待、不可剥夺、循环等待——这四条必须背熟因为所有解决方案都是打破其中一条。反过来只要你确认这四条同时成立就不用费劲去查是不是死锁了直接往这四个方向找。对应的处理策略有三类。预防是让四个条件中的至少一个永远不成立资源一次性全部分配打破持有并等待、允许抢占打破不可剥夺、按全局顺序加锁打破循环等待。避免是运行时动态判断银行家算法是典型代表但它要求预先知道每个进程的最大资源需求实际系统里几乎不可用。检测与恢复是允许死锁发生定期构建资源分配图找环找到就回滚某个进程。工程里真正落地的是第一种而且九成九是按全局顺序加锁。比如一个转账系统要锁两个账户规则定为始终先锁 ID 小的账户。只要所有人遵守就不会出现 A 等 B、B 等 A 的环。但按序加锁有个变种陷阱如果锁是运行时动态获取的比如锁一个容器里的两个元素顺序取决于容器实现你没法保证顺序。这时候要么用trylock加超时回退要么把整个操作放到一个粗粒度锁里。后者听起来很土但在争用不激烈的场景下简单可靠比精巧正确重要。4.2 锁的类型与选型Linux 上你能用到的锁大致是这几类锁类型是否阻塞上下文限制典型场景互斥锁 mutex阻塞可睡眠能在进程上下文用用户态临界区自旋锁 spinlock忙等不可睡眠中断上下文可用内核短临界区读写锁 rwlock阻塞/忙等视实现读多写少RCU无锁读读侧零开销内核读极多写极少原子操作无无计数器、标志位用户态最常用的是pthread_mutex。它内部会根据争用情况做优化无争用时直接在用户态用一条原子指令完成加锁完全不进内核有争用时才通过 futex 系统调用挂起线程。这就是快速路径和慢速路径的设计也是为什么一个没有被争用的 mutex 开销只有几十纳秒。读写锁的坑在于写者饥饿。如果读操作持续不断写者可能永远拿不到锁。有些实现加了写优先策略但会反过来导致读者饥饿。如果你的场景读远多于写考虑 RCU 风格的方案或者定期做快照。自旋锁不能乱用。它适合临界区极短几条指令且不允许睡眠的场合比如内核中断处理。在用户态用它基本是自找麻烦因为忙等会浪费整个时间片。踩过的坑曾经为了减少锁竞争把一个全局 mutex 拆成 64 个分片锁结果性能没提升反而下降。原因是每个分片只有 4 字节有效数据全挤在同一条 cache line 上加了 64 个分片反而制造了 64 倍的伪共享。4.3 伪共享与 CPU 缓存伪共享false sharing是并发优化里最隐蔽的坑之一。CPU 缓存以 cache line 为单位x86 上通常是 64 字节。如果两个线程分别修改位于同一条 cache line 的不同变量虽然逻辑上没有共享但硬件层面这条 line 会在两个核心之间反复弹跳每一次修改都要让对方的缓存副本失效。结果是明明没有数据竞争性能却像有竞争一样差。检测方法是用perf c2cperf c2c record -a -- sleep 5 perf c2c report它会明确告诉你哪条 cache line 被多个核心争抢以及争抢的来源地址。解决方法是填充对齐让每个热点变量独占一条 cache line。C 语言里最简单的做法是加__attribute__((aligned(64)))C 里可以用alignas(64)。Java 里历史上靠数组填充现在有Contended注解。我自己实测过一个计数器数组的场景8 个线程各自累加自己的计数器不做对齐时总耗时 2.3 秒加上 64 字节对齐后降到 0.4 秒。五倍多的差距代码逻辑一行没改。所以并发优化的顺序应该是先减少锁争用再检查伪共享最后才考虑无锁算法。无锁队列写起来漂亮但错误处理和内存回收ABA 问题、安全回收极其麻烦非必要不用。5. 文件系统与 IO一次 read 到底走了多少路5.1 从 read() 到磁盘的完整路径很多人写了几年代码从没想过read(fd, buf, 4096)这一行到底发生了什么。拆开来看大概是这样一条链路用户态调用read触发系统调用CPU 从用户态切到内核态参数拷贝到内核栈VFS 层根据 fd 找到对应的 file 结构再找到 inode判断是普通文件还是设备检查页缓存page cache如果目标数据已经在缓存里直接从内核缓冲区拷贝到用户 buf返回缓存未命中文件系统层把请求转成块设备请求算出数据在磁盘上的逻辑块号请求交给块层可能经过 IO 调度器合并、排序机械盘时代很重要SSD 上作用变小驱动层把请求转成设备命令DMA 引擎负责把数据搬进内存数据到内存后填入页缓存再从页缓存拷贝到用户 buf唤醒进程整条链路里有两个容易忽略的点。第一现代 Linux 的 read 只有一次 CPU 拷贝页缓存到用户 buf磁盘到页缓存是 DMA 做的。第二预读机制会猜你接下来要读什么提前把后面的页拉进来。顺序读时预读窗口会不断增大随机读时预读基本无效这就是为什么顺序 IO 吞吐能比随机 IO 高一个数量级。文件元数据同样重要。inode 里存的是权限、大小、时间戳、数据块指针不存文件名文件名和 inode 号的对应关系在目录项dentry里。这解释了一个经典面试题为什么硬链接不能跨文件系统因为 inode 号只在单个文件系统内唯一跨文件系统无法通过 inode 号定位文件。软链接存的是路径字符串所以可以跨文件系统但原文件删除后就成了断链。5.2 IO 多路复用select/poll/epoll这三个是面试常客但很多人只会背epoll 更快。真正的差异在实现细节上。select有三个致命限制一是 fd 数量上限默认 1024由FD_SETSIZE决定二是每次调用都要把整个 fd 集合从用户态拷贝到内核态三是返回后你不知道哪些 fd 就绪必须遍历整个集合复杂度 O(n)。还有一个隐蔽的坑select 会修改传入的集合所以每次调用前都要重新构造。poll用数组取代了位图解除了 1024 的上限但拷贝和遍历的问题依然存在还是 O(n)。epoll的核心是把注册和等待分开。epoll_ctl在内核里维护一棵红黑树存所有关注的 fd同时维护一个就绪链表epoll_wait只返回就绪链表里的元素复杂度 O(1)。而且 fd 只在注册时拷贝一次之后不再重复拷贝。epoll 还有 LT水平触发和 ET边缘触发两种模式。LT 是只要缓冲区有数据就一直通知编程简单不容易漏事件ET 是只在状态变化时通知一次必须一次读到EAGAIN为止否则会丢数据。ET 效率略高因为减少了epoll_wait的返回次数但写法要求严格。新手建议先用 LT确认逻辑正确后再考虑改 ET。一个我实际踩过的坑把 epoll fd 设成 ET 模式后在监听 socket 上只accept一次就返回了。结果多个连接同时到达时后到的连接在就绪链表里被漏掉表现为服务端随机不响应。修法是在 ET 模式下必须循环accept直到返回EAGAIN。5.3 零拷贝的边界与适用场景零拷贝这个词有误导性它不是完全没有拷贝而是消除 CPU 参与的数据拷贝。传统readwrite发送文件要经历磁盘 → 内核页缓存DMA→ 用户 bufCPU→ socket 缓冲区CPU→ 网卡DMA四次拷贝、四次上下文切换。sendfile把中间两步省掉数据从页缓存直接进 socket 缓冲区变成三次拷贝两次 DMA、一次 CPU、两次上下文切换。如果网卡支持 SG-DMA分散聚集还能把 socket 缓冲区那次也省掉变成两次纯 DMA 拷贝CPU 完全不参与数据搬运。Nginx 的静态文件服务、Kafka 的消息投递都吃到了这个红利。mmapwrite是另一条路把文件映射到用户地址空间写时直接从映射区发往 socket。省掉了内核到用户的拷贝但多了一次缺页中断的开销而且小文件场景下反而更慢。关键限制是sendfile 只能发送文件内容不能做数据修改。如果你的流程需要在中间对数据加密或者改 header就得退回read 处理 write。这就是为什么 HTTPS 场景下 sendfile 收益比 HTTP 小。实测经验在 10Gbps 网卡上做静态文件分发从 readwrite 换成 sendfileCPU 占用从 60% 降到 12%吞吐从 3.2GB/s 提到 8.5GB/s。收益巨大但前提是文件不需要任何加工。6. 中断、系统调用与内核态切换6.1 一次系统调用的开销构成系统调用的开销不是切换两个字能概括的它由几部分组成保存用户态寄存器上下文、切换内核栈、执行安全检查比如参数地址合法性校验、执行实际功能、恢复上下文、返回用户态。在 x86-64 上一条普通系统调用比如getpid大概在 100 到 300 纳秒之间具体取决于 CPU 型号、是否有安全缓解机制如 KPTI以及缓存状态。strace -c能统计每个系统调用的次数和耗时占比strace -c -f ./your_server输出会给出每个 syscall 的调用次数、总耗时、平均耗时、错误次数。我排查过一个服务启动慢的问题用 strace 一看openat调了 3 万多次平均每次 20 微秒总耗时 600 毫秒占了启动时间的大头。原因是配置文件散落在几千个小文件里每次启动都要全量扫描。改成单个大文件后用一次read读完启动时间直接降到 80 毫秒。有个例外值得知道vDSO。像gettimeofday、clock_gettime、getcpu这类只读的、性能敏感的调用内核把实现直接映射到用户空间调用时完全不进内核开销接近普通函数调用。所以高频取时间戳的代码不用太担心成本但要注意某些语言的运行时可能没走 vDSO 而是走了真正的系统调用。6.2 中断上下半部为什么要拆中断处理必须快因为处理期间同级中断会被屏蔽拖太久会丢事件。但很多中断要干的活并不快比如网卡收到包之后要交给协议栈处理、要唤醒等待的进程。所以内核把中断拆成上半部和下半部。上半部硬中断只做最紧急的事确认硬件状态、把数据从设备寄存器搬到内存、标记下半部待处理然后立刻返回。下半部软中断、tasklet、工作队列在之后的开销较小的时机执行剩下的工作。三者的区别是软中断运行在中断上下文不能睡眠tasklet 基于软中断实现同一个 tasklet 不会并发执行工作队列运行在进程上下文可以睡眠、可以使用阻塞式 API。选择标准很简单——如果需要睡眠就选工作队列否则用软中断或 tasklet。排查中断相关问题看cat /proc/interrupts能知道每个 CPU 上各设备的中断次数如果某个 CPU 的中断数远高于其他说明中断亲和性没配好可以用irqbalance或手动写/proc/irq/n/smp_affinity调整。/proc/softirqs则显示各类软中断的累计次数。注意网卡多队列配合中断亲和性绑定是提升网络吞吐的标准操作。原理是让每个队列的中断固定到不同的 CPU避免所有软中断堆在一个核上形成瓶颈。7. 把笔记变成实验我的动手清单7.1 观测工具与命令光读笔记不够下面这些命令我建议你每个都跑三遍分别在空闲系统和压力系统上跑感受差异。工具主要看什么关键列top / htopCPU、内存、负载load average、%sy、%wavmstat 1系统整体状态r、b、si/so、cs、us/sy/id/wapidstat -w 1 -p PID单进程切换cswch/s、nvcswch/spidstat -r 1 -p PID单进程内存minflt/s、majflt/s、VSZ、RSSiostat -x 1磁盘 IO%util、await、aqu-szperf top热点函数采样占比/proc/ /status进程详情State、VmRSS、Threads/proc/meminfo内存全局MemAvailable、Dirty、SwapCached几个容易被忽略的判断技巧。vmstat里的%wa是 CPU 等 IO 的时间占比注意它只在 CPU 有空闲时才可能高如果 CPU 已经 100% 用来算东西了等待时间根本显示不出来这时候要看b列等待 IO 的进程数。load average包含不可中断睡眠的进程所以磁盘故障时 load 会飙高但 CPU 空闲。/proc/meminfo里的MemAvailable才是真正可用的内存不要看MemFree。Linux 会把大量内存用作页缓存MemFree很低是正常的MemAvailable是内核估算的、在不触发 swap 的前提下能满足新分配的量。7.2 亲手复现几个经典现象第一个实验是观察写时复制。写一个程序先malloc1GB 并填满然后 fork在父子进程里分别读和写这块内存用pidstat -r观察 RSS 变化。你会看到 fork 之后父子 RSS 之和没有翻倍只有真正写入的那部分页才各自独立。这个实验能直观理解为什么 COW 让 fork 变便宜。第二个实验是构造 CPU 密集和 IO 密集两种负载分别看vmstat的输出差异。CPU 密集时us高、r列大于核数IO 密集时wa高、b列大于 0。同一个系统在不同负载下的长相完全不同多看几次就有直觉了。第三个实验是制造一个 D 状态进程。最简单的方法是挂载一个会卡住的网络文件系统或者用dd直接读写裸设备并故意让磁盘出错。这个实验有点破坏性建议在虚拟机里做。重点是体验kill -9 无效这件事然后学会去看dmesg。第四个实验是页缓存的影响。先echo 3 /proc/sys/vm/drop_caches清掉缓存用time cat bigfile /dev/null计时再重复一次同样的命令第二次会快好几倍因为数据都在页缓存里了。这一步能在 30 秒内让你彻底理解页缓存的价值。第五个实验是 epoll 的 ET 陷阱。写一个最小化的 TCP 服务用 ET 模式但不循环 accept然后用脚本瞬间建立 100 个连接观察有多少连接没被处理。亲手踩一次比读十篇文章记得牢。8. 常见问题速查表下面这些是我在复习和实际排查中反复遇到的困惑整理成问答形式方便对照。现象或问题常见原因排查方向进程 D 状态杀不掉内核在等不可中断 IOdmesg、iostat、检查存储链路大量僵尸进程父进程未回收子进程检查父进程 wait 逻辑、SIGCHLD 处理load 很高但 CPU 空闲大量进程等 IOvmstat 的 b 列、iostat 的 await内存充足却触发 OOMcgroup 限制或 overcommit 策略检查 memory.max、oom_score_adj主要缺页数很高工作集超出物理内存缩小数据集、检查随机访问模式多线程扩展性差锁争用或伪共享perf c2c、检查共享变量布局ET 模式丢连接未循环 accept/read 到 EAGAIN改为循环读取系统调用占用 CPU 高高频小读写、未批量处理strace -c 定位改批量或缓存上下文切换异常高锁竞争或线程数过多pidstat -w检查线程池大小TLB miss 严重进程切换频繁、随机访问大内存用大页、绑核、优化访问局部性补充几个不那么常见但很坑的点。第一fork在多线程程序里几乎不能用因为子进程只保留调用 fork 的那个线程其他线程持有的锁在子进程里处于已加锁状态且永远不会释放一旦子进程碰到那把锁就死锁。正确做法是用posix_spawn或者 fork 之后立刻 exec。第二信号处理函数里能做的事极少只能调用异步信号安全的函数。printf、malloc都不在安全列表里在信号处理函数里调用它们可能在极端情况下死锁。标准做法是在处理函数里只设一个标志位或者往 pipe 里写一个字节实际处理放到主循环。第三SIGKILL和SIGSTOP不可捕获、不可忽略这是设计上的保证。如果你需要优雅退出用SIGTERM并在程序里注册处理器。第四文件描述符泄漏比内存泄漏更隐蔽因为它不会立刻表现为资源耗尽只有到ulimit -n上限时才突然爆发Too many open files。用ls /proc/pid/fd | wc -l可以实时看写个定时监控比事后救火强。9. 复习节奏我的三段式安排我在实际复习中的体会是操作系统这门课最忌讳摊大饼式的匀速推进。我的做法是分成三段每段目标完全不同。第一段是建立骨架时间控制在两三天。只看目录、标题和每章开头的概要把整个体系的分层和模块关系搞清楚不做任何细节笔记。这一段的目标是让后面所有的细节都有地方挂。第二段是逐块攻破这是最耗时的部分我按进程 → 内存 → 并发 → 文件IO → 中断的顺序推进每块配一个可运行的实验或者一条能出结果的命令。这一段的检验标准很粗暴关掉笔记能不能在白纸上把这个模块的核心机制画出来能不能说出每个设计决策解决了什么问题。说不出来就回去重看。第三段是交叉验证也是最能拉开差距的部分。做法是找那些跨模块的问题自己问自己比如进程切换时页表发生了什么变化写文件的时候内存管理参与了哪些环节一个网络包从网卡到应用层要经过几次拷贝和几次上下文切换。能把这些串起来回答说明知识真的连成网了。最后分享一个我一直在用的小技巧把每个核心概念都写成一句话定义 一个真实场景 一条验证命令的三元组存在自己的笔记里。等到复习末期你只需要扫这一百来个三元组就能在半小时内把整门课过一遍而且每一条都是能落地的不是漂浮的名词。这套方法用下来比单纯刷题的留存率高得多因为你在复习的同时一直在做验证这个动作而验证带来的记忆强度是任何文字复述都比不了的。
返回列表