首页 / 资讯中心 / 文章详情

从线上故障重学操作系统:系统调用、虚拟内存与性能排查

从线上故障重学操作系统:系统调用、虚拟内存与性能排查 ★ FEATURED ARTICLE
干了几年开发再回头翻操作系统感受和大学课堂上完全是两回事。那时候背“进程是资源分配的基本单位线程是调度执行的基本单位”背完就忘考试一过脑子里只剩几个名词。真正让我重新捡起操作系统是一次线上问题——服务响应时间突然从几十毫秒涨到两秒CPU使用率却只有三成看了半天应用代码没毛病最后定位到是内存吃紧触发了换页磁盘在疯狂倒腾数据。那天之后我才明白操作系统不是一门用来考试的课而是你写的每一行代码最终都要落进去的那层地基。这篇文章就是想把我这几年在操作系统这层反复踩坑、反复回头补课的经验整理出来从它到底在管什么、核心抽象怎么设计、一次系统调用到底走了多远一直讲到动手做实验和常见问题排查。不管你是刚学完计组和数据结构的学生还是已经写了两年业务代码想往下钻一层的开发者或者只是好奇“我点一下鼠标机器里到底发生了什么”的人下面这些内容应该都能给你一些可以直接上手的东西。1. 为什么今天还要认真理解操作系统1.1 一次线上雪崩让我重新翻开课本那次故障的场景我到现在都记得很清楚。某公司的一个内部订单处理服务平时QPS稳定在几百某天上游做压测流量翻了三倍。监控面板上CPU使用率一直在35%上下晃看起来毫无压力但接口的平均响应时间从40ms一路涨到1.8s超时率飙升。第一反应是数据库慢查询查了慢日志没有第二反应是下游接口拖累看链路追踪也不是。最后把机器上的vmstat 1跑起来看到两个数字不对劲si和soswap换入换出一直在几百KB/s跳动cs上下文切换从每秒几千涨到十几万。问题在这里那台机器的物理内存只有8G服务JVM堆开了6G加上页缓存、其他进程物理内存早就撑满了。系统开始把匿名页换到交换分区一旦某次请求触碰到被换出的页就要触发缺页中断、从磁盘读回来单次延迟直接从微秒级跳到毫秒级。上下文切换暴涨则是因为大量线程阻塞在I/O上调度器不停地在就绪队列之间来回切。那次之后我做了三件事把堆内存压到物理内存的60%以下、调整了vm.swappiness、给关键服务加了内存水位告警。这三件事没有一件是应用代码层面的优化全部来自对操作系统内存管理和调度机制的理解。这就是为什么我说写业务代码的人也应该懂一点操作系统你调用的每一个API最后都由内核替你完成内核的行为方式决定了你的程序在压力下的真实表现。1.2 操作系统到底在管哪几摊事把操作系统剥开看它其实就两个身份资源管理者和抽象提供者。作为资源管理者它管四样东西CPU、内存、外设磁盘、网卡、键盘鼠标、以及权限与安全。CPU是稀缺的所以要调度让多个程序轮流用内存是有限的所以要分配回收还要防止A进程读到B进程的数据外设速度慢且种类多所以要统一接口、要缓冲、要排队权限这块则决定了你双击一个来路不明的可执行文件时它能不能直接格式化的你的硬盘。作为抽象提供者它把一堆冷冰冰的硬件封装成程序员能用的概念把CPU封装成“进程/线程”把内存封装成“地址空间”把磁盘封装成“文件”把网卡封装成“套接字”。这个抽象做得成功到什么程度你写Python代码读一个文件完全不需要知道底下是机械硬盘还是固态硬盘、是ext4还是NTFS、数据在哪个磁道哪个扇区——这一层屏蔽就是操作系统最核心的价值。想明白这两重身份很多设计上的“为什么”就顺了。比如为什么系统调用比普通函数调用慢那么多因为它是从用户态跨到内核态的一道门门后面是特权代码必须做上下文保存和权限检查。再比如为什么要有虚拟内存因为物理内存是共享的、有限的而每个程序都希望自己独占一台机器虚拟内存就是那个让所有人都满意的“障眼法”。注意理解操作系统不要一上来就啃内核源码。先把“它管什么、为什么这么管”这两层想清楚再去看具体实现效率会高很多。反过来的顺序很容易陷进代码细节里出不来。2. 操作系统的四层骨架与核心抽象2.1 硬件之上那道叫“特权级”的墙现代CPU基本都提供至少两个特权级。x86上叫Ring 0和Ring 3ARM上叫EL0到EL3。操作系统内核跑在最高特权级应用程序跑在最低特权级。这堵墙不是摆设在低特权级下某些指令是直接禁止执行的比如修改页表基址寄存器、关闭中断、直接访问外设的I/O端口。那应用程序要做这些事怎么办只能“请求”内核帮忙。请求的方式就是系统调用本质上是一条特殊指令x86上是syscall或int 0x80ARM上是svc执行时CPU会做三件事切到高特权级、跳到内核预先注册好的入口地址、把用户态的寄存器现场保存起来。处理完再原路返回。这个设计的精妙之处在于它把“能力”和“权限”分离了应用程序有无限的能力去计算、去处理数据但没有权限去碰硬件。想碰硬件必须走那道窄门而窄门后面内核可以对每个请求做检查——这块内存是不是你的、这个文件你有没有读权限、这个端口是不是已经被别的进程占了。我第一次真正理解这堵墙是写一个简单的字符设备驱动的时候。在用户态代码里访问一个非法地址进程会收到SIGSEGV然后挂掉但整个系统稳如泰山同样的错误如果在驱动里犯内核直接panic整台机器重启。同一个错误两级特权两种后果。这就是隔离的价值。理解完这层再看很多现象就通了比如为什么fork()之后子进程的地址空间和父进程看起来一模一样因为内核给它复制了一份页表为什么mmap映射的文件读写比read/write快因为前者把页表直接指过去少了一次数据拷贝为什么有的语言要搞“绿色线程”因为跨特权级的切换太贵能在用户态解决的就别麻烦内核。2.2 进程与线程调度器切的是什么进程这个概念教科书上说它是“程序的一次执行”我觉得更准确的说法是进程是内核为一次执行准备的全部资源的集合。这个集合里有什么一段独立的虚拟地址空间、一组打开的文件描述符、一个信号处理表、一些统计信息以及至少一个执行流。这堆东西在内核里被组织成一个结构体通常叫task_struct或者PCB进程控制块。线程则是“执行流”本身——它拥有自己的寄存器和栈但和其他线程共享地址空间、文件描述符这些资源。所以同一进程内的线程通信几乎零成本直接读写同一块内存但跨进程通信就得走管道、消息队列、共享内存这些机制。调度器的工作就是在这堆执行流之间做时间分配。这里有个关键点很多人会忽略调度器切的是线程不是进程。Linux内核里其实不区分进程和线程统一叫task创建线程和创建进程用的是同一个系统调用clone()只是传入的标志位不同——共享地址空间就是线程不共享就是进程。这个设计很务实避免了为两种东西写两套调度逻辑。那调度器怎么决定下一个跑谁早期用的是简单的多级反馈队列现在主流的是CFS完全公平调度器或者它的继任者EEVDF。CFS的核心思路是维护一个按“虚拟运行时间”排序的红黑树每次挑虚拟运行时间最小的那个跑。虚拟运行时间怎么算实际运行时间乘上一个权重权重由nice值决定。nice值从-20到19值越小优先级越高权重越大虚拟时间涨得越慢被调度的机会就越多。这个机制解释了一个常见疑惑为什么我把一个进程的nice调到-20它也没有独占CPU因为CFS保证的是“按权重比例分配”不是“绝对优先”。只要还有其他就绪任务你最多只能拿到比别人多若干倍的份额达不到100%。还有个参数值得关注调度周期。CFS默认的目标调度延迟是6ms可调意思是它尽量保证每个就绪任务在6ms内至少被调度一次。任务数少的时候每个任务的时间片就长任务数一多时间片自动缩短。这个自适应的设计就是为什么机器上开了几百个进程交互体验依然能保持流畅的原因。2.3 虚拟内存每个人都以为自己拥有整台机器虚拟内存是我认为操作系统里最漂亮的设计没有之一。它解决的问题很朴素物理内存只有一块但每个程序都想要“从0地址开始的一大片连续空间”而且互不干扰。方案是给每个进程一套独立的页表把虚拟地址映射到物理地址。进程看到的地址全是虚拟的真正落到哪块物理内存由页表说了算。这样做带来三个直接好处隔离进程A的地址永远映射不到进程B的物理页、灵活物理上不连续的页虚拟上可以连续、超售物理内存不够时把暂时不用的页换到磁盘上进程感知不到。映射的基本单位是页x86-64上通常是4KB。每次内存访问CPU的MMU都要拿虚拟地址去查页表。页表是分级的四级或五级一次查找要走好几步内存访问太慢了。所以CPU里有个叫TLB的缓存专门存最近用过的映射关系命中率通常能到99%以上。TLB没命中才去走页表那叫一次“页表遍历”代价高得多。这也是为什么大页huge page能显著提升某些应用的性能把4KB的页换成2MB的页同样大小的内存需要的页表项少了512倍TLB能覆盖的范围大了512倍命中率自然上去。数据库、虚拟化这些吃内存的应用基本都会开大页。另一个绕不开的概念是缺页中断。进程访问一个虚拟地址如果对应的页不在物理内存里比如从没分配过、或者被换出去了CPU会触发一个异常内核接管去磁盘或交换分区把数据读进来填好页表再让指令重新执行。这一切对应用程序完全透明——你只是执行了一句int x *p;完全不知道中间可能经历了一次磁盘I/O。注意正是因为缺页是透明的很多性能问题也藏在这里。一个进程如果有大量“冷启动”式的随机访问模式会不停触发缺页看起来CPU不高、磁盘也忙但就是慢。定位这类问题/proc/pid/stat里的majflt字段主缺页需要读磁盘的次数非常有用。2.4 文件系统与I/O模型一切皆文件的得与失“一切皆文件”是类Unix系统最著名的设计哲学。磁盘上的普通文件是文件目录是文件字符设备是文件管道是文件甚至连网络套接字都能当文件读写虽然不是通过open打开。这层抽象的核心叫VFS虚拟文件系统。它在内核里定义了一套统一的接口open、read、write、close、ioctl……底下挂什么实现都行——ext4、xfs、btrfs这些本地文件系统或者NFS、CIFS这些网络文件系统对上层都长得一样。应用程序完全不需要知道自己读的是一个本地SSD还是一个挂载的网络目录。但“一切皆文件”也有它的代价。最常见的一个坑网络套接字虽然能用read/write但它和磁盘文件的行为特性完全不同。磁盘文件读写通常是就绪的网络套接字却随时可能阻塞网络数据包是流没有边界一次read可能读到半个请求也可能读到两个半请求。把这两者混为一谈是很多新手写网络程序崩溃的原因。另一个需要理解的概念是页缓存。Linux在文件系统和块设备之间插了一层缓存叫page cache。你读一个文件内核先把对应页从磁盘读进内存然后拷贝给你下次再读同样的位置直接从内存给你速度差了几个数量级。写文件也是这样数据先写进页缓存标记为脏页内核在合适的时候批量刷盘这叫延迟写。延迟写的好处是巨大的把随机小写聚合成顺序大块写磁盘吞吐能提升一个量级。坏处是你以为write返回就代表数据落盘了其实没有。机器断电、内核崩溃页缓存里没来得及刷的数据就没了。要让数据真正落盘得调用fsync()或者fdatasync()。这也是为什么数据库类应用总是频繁fsync——它们不能容忍数据丢失。理解了页缓存就能解释很多“反直觉”的现象比如为什么刚用dd写完一个大文件free -h里显示可用内存很少其实那大部分是页缓存在内存紧张时内核会自动回收不必惊慌再比如为什么用cp复制大文件后立刻sync整个系统会卡一下因为它在集中刷脏页。3. 一次系统调用究竟走了多远3.1 用 strace 把黑盒打开我强烈推荐每个开发者都在类Unix系统上跑一次strace。它能把一个程序执行过程中所有的系统调用一条条列出来包括参数、返回值、耗时。这是理解“应用程序和内核交界处发生什么”最直观的工具。先看个最简单的strace -c -f ls /dev/null-c表示统计模式-f表示跟踪子进程。输出大概长这样不同系统略有差异% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 31.42 0.000211 11 19 mmap 21.13 0.000142 9 15 openat 15.34 0.000103 25 4 read 9.87 0.000066 5 12 close ...一个看起来平平无奇的ls背后调了几十次系统调用。openat打开目录和文件、mmap把库文件映射进内存、read读取目录项、write输出到终端。每一个调用都是一次从用户态到内核态的跨越。再看一个更贴近日常性能问题的例子统计一个程序读文件时的调用次数。strace -e traceread,write,openat -T python3 read_demo.py 21 | head -50-T会显示每个调用的耗时。你经常能看到某个read花了十几毫秒——那多半是触发了一次真实的磁盘读。如果想更进一步还能把内核栈也打出来strace -k -e tracewrite python3 write_demo.py 21 | head -40-k打印用户态调用栈能帮你定位到底是哪行代码触发了这次调用。3.2 开销从哪来怎么省一次系统调用的开销大概在几百纳秒到几微秒之间具体取决于CPU、是否命中TLB、是否有安全缓解措施比如各种漏洞修复带来的额外开销。听着不多但如果你的程序要执行几百万次累积起来就是秒级的差距。开销主要来自三块特权级切换保存/恢复寄存器现场切换页表基址刷新某些缓存、参数校验内核要检查你传的指针是不是合法的用户空间地址、权限够不够、以及可能的数据拷贝比如read要把数据从内核缓冲区拷到用户缓冲区。基于这个认知优化思路就很明确了减少调用次数、减少拷贝、避开不必要的同步。手段原理典型收益增大缓冲区一次read/write搬更多数据摊薄切换成本小文件批量处理提升明显用readv/writev一次调用处理多个分散的缓冲区减少一半左右的调用次数用mmap替代read页表映射避免内核到用户的拷贝大文件只读场景效果好用sendfile内核内部直接搬运零次用户拷贝文件传输类服务吞吐提升显著批量提交把多次小操作合并成一次大操作日志、消息类写入收益最大举个我自己踩过的例子。早期写一个日志收集组件每收到一条日志就write一次到文件。压测的时候单机只能跑到每秒3万条CPU的sy系统态占用占了40%。后来改成攒到4KB或者每10ms刷一次吞吐直接跑到每秒12万条sy降到8%。代码逻辑一行没改只是把“每条一写”变成“批量写”收益就来自这里。mmap这个手段要慎用。它确实能省掉一次拷贝但对小文件或者频繁变更的文件反而会引入缺页中断和TLB刷新的开销甚至比read还慢。我的经验是文件大于几十MB、只读或者读多写少的场景值得试其余场景老老实实用带缓冲的read。4. 主流操作系统家族的设计取向与选型4.1 通用系统的路线差异宏内核、微内核与混合操作系统的架构大致分三派理解它们的取舍比记住某个系统的名字有用得多。宏内核把调度、内存管理、文件系统、设备驱动、网络协议栈统统塞进内核空间。好处是各子系统之间的调用就是普通函数调用效率极高上下文切换少坏处是内核体量巨大任何一个驱动出bug都可能拖垮整个系统而且新增功能必须改内核、重新编译。类Unix家族里的Linux和传统BSD都是这条路。微内核反其道而行之内核里只留最基本的东西地址空间管理、线程调度、进程间通信。文件系统、驱动、协议栈全部搬到用户态做成独立的服务进程彼此通过消息传递通信。好处是隔离性好、可裁剪、单点故障可控坏处是消息传递带来的切换开销早期实现性能确实吃亏。一些实时系统和面向高可靠场景的商业内核走的就是这条路。混合内核是折中派把性能敏感的部分留在内核比如文件系统缓存把不那么敏感的部分挪到内核外。桌面和移动端的很多系统都属于这一类。这三种架构没有绝对优劣全看场景。跑在服务器上的通用系统吞吐和生态是第一位的宏内核的路线更合适跑在车载、工控设备上的系统可靠性和可验证性优先微内核的隔离模型更友好。4.2 实时系统与嵌入式系统的取舍“实时”这个词经常被误解。它不表示“快”而表示“能在确定的时间内响应”。一个响应时间是10毫秒但偶尔会变成100毫秒的系统不是实时系统一个响应时间恒定为50毫秒的系统反而是实时系统。所以实时系统的核心指标是最坏情况延迟的可预测性不是平均值。这就决定了实时内核的设计和通用内核完全不同。通用内核追求吞吐愿意为了整体效率让某个任务多等一会儿比如为了减少切换把时间片放大实时内核追求确定性宁可牺牲吞吐也要保证高优先级任务在截止时间内拿到CPU。具体手段包括抢占式内核内核里任何地方都能被打断、优先级继承防止优先级反转、关闭或严格限制中断屏蔽时间。嵌入式系统又是另一套逻辑。它面对的硬件资源通常是几十KB到几MB内存、单核或者少核、没有MMU或者MMU可以简化所以上面那套完整的虚拟内存、多级页表、复杂调度器就显得太重了。这类系统上的内核往往只保留一个抢占式调度器、一套简单的内存分配器、以及必要的设备驱动框架。整套东西可以做到几十KB。我做过一个物联网网关的项目硬件只有192KB RAM。最开始想着能不能塞一个裁剪过的通用系统进去评估了半天光是内核本身就要占掉一大半还得留空间给协议栈和应用。最后还是换了一个更轻量的实时内核任务调度用固定优先级抢占内存用静态分配整个固件跑起来只占了70KB。取舍很清楚牺牲灵活性和通用性换确定性和资源占用。4.3 选型时我实际会看的三张表选型这事不用想得太玄。我通常就拉三张表横向对比很快能筛出两三个候选。维度具体看什么为什么重要资源占用内核镜像大小、最小RAM、最小Flash硬件成本的第一道门槛确定性中断延迟上限、调度延迟上限、是否可抢占控制类场景的硬指标生态驱动覆盖度、工具链成熟度、社区活跃度决定了后续维护成本第二张表还可以再细化一下给几个常看的具体数字指标通用系统典型值实时系统典型值中断响应延迟数十微秒抖动大个位数微秒抖动小上下文切换1~5微秒亚微秒到数微秒可预测内存管理完整虚拟内存、按需换页多为静态分配或无MMU除了这三张表还有一个经常被忽略但很实际的因素调试手段。资源再省、延迟再低出了一堆问题查不出来就是灾难。所以我一般会问一句这个系统支不支持在线抓栈、能不能打时间戳、有没有崩溃现场保留。这些能力在项目后期能救命。5. 动手把手弄脏几个能建立体感的实验5.1 看调度把CPU的去向看清楚光看理论没感觉跑起来才有。第一个实验是观察调度行为。# 看整体负载和上下文切换 vmstat 1 10 # 看每个CPU核在干什么 mpstat -P ALL 1 # 看某个进程的调度统计 cat /proc/pid/schedstatvmstat输出里r列表示就绪队列长度cs表示每秒上下文切换次数in表示每秒中断次数。如果你的程序是CPU密集型r会接近核数如果是I/O密集型r不大但cs很高。再进一步可以用pidstat看单个进程被剥夺CPU的次数pidstat -w -p pid 1输出里的cswch/s是非自愿上下文切换时间片用完被换下nvcswch/s是自愿切换主动阻塞等I/O。前者高说明CPU竞争激烈后者高说明在等I/O。5.2 看内存别被free的数字骗了free -h cat /proc/meminfo | head -20 pmap -x pid | tail -5free -h里最容易引起误判的是buff/cache那一列。它统计的是页缓存、目录项缓存、inode缓存这些内存在应用需要时会自动被回收。所以判断内存是否吃紧看的是available不是free。pmap -x能看一个进程的地址空间分布包括各段的大小、常驻内存RSS、共享内存。如果RSS远小于虚拟大小说明大量内存还停留在虚拟层面没真正占物理页如果RSS接近物理内存上限并且swap在动那就是真的紧张了。有个小技巧想看某个进程实际用掉了多少物理内存不要用pmap的累加值用/proc/pid/smaps_rollup里的Pss按共享比例分摊后的内存这个数最接近真实占用。5.3 看I/O一次读写到底花了多久iostat -x 1 5 iotop -oiostat -x里几个关键列%util是设备带宽利用率await是平均等待时间含排队r_await和w_await分别对应读写。如果%util不高但await很高通常意味着单次I/O本身慢机械盘寻道而不是队列压力大。想精确测一次写入的延迟可以自己写个小程序#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include time.h double now_ms() { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return ts.tv_sec * 1000.0 ts.tv_nsec / 1e6; } int main() { int fd open(/tmp/io_test.bin, O_WRONLY | O_CREAT | O_TRUNC, 0644); char *buf malloc(4096); memset(buf, a, 4096); double t0 now_ms(); for (int i 0; i 10000; i) { write(fd, buf, 4096); } double t1 now_ms(); fsync(fd); double t2 now_ms(); printf(write 耗时: %.2f ms\n, t1 - t0); printf(fsync 耗时: %.2f ms\n, t2 - t1); close(fd); free(buf); return 0; }编译运行一下gcc -O2 io_test.c -o io_test ./io_test典型结果会很有意思10000次write可能只花二三十毫秒因为全写进页缓存了而一次fsync可能要花几百毫秒。这个对比就是理解“延迟写”最直接的体感。5.4 自己写一个最小的“进程管理器”想彻底搞懂fork、exec、wait最好的办法是自己写一个能执行外部命令的小程序。下面这段代码实现了一个极简的shell能读命令、fork子进程、exec执行、等子进程结束。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h int main() { char line[1024]; char *argv[64]; while (1) { printf(mini ); fflush(stdout); if (!fgets(line, sizeof(line), stdin)) break; line[strcspn(line, \n)] 0; if (strcmp(line, exit) 0) break; int argc 0; char *tok strtok(line, ); while (tok argc 63) { argv[argc] tok; tok strtok(NULL, ); } argv[argc] NULL; if (argc 0) continue; pid_t pid fork(); if (pid 0) { execvp(argv[0], argv); perror(exec失败); exit(127); } else if (pid 0) { int status; waitpid(pid, status, 0); if (WIFEXITED(status)) { printf([子进程退出码 %d]\n, WEXITSTATUS(status)); } } else { perror(fork失败); } } return 0; }这段代码只有几十行但它把进程管理的核心流程都串了一遍fork复制出子进程此时子进程拥有和父进程一样的地址空间副本子进程调用execvp把自己的地址空间整个替换成目标程序父进程调用waitpid阻塞等待子进程退出并回收资源不回收就会留下僵尸进程。有个细节值得注意fork之后子进程马上exec的场景下其实没必要复制整个地址空间。现代系统提供了vfork和posix_spawn来避免这个浪费vfork让父子进程共享地址空间直到execposix_spawn则把forkexec打包成一次操作效率更高。5.5 观察系统调用次数对性能的实际影响最后一个实验用两种方式统计一个文件的行数对比耗时。import time # 方式一每次读一个字节 def slow_count(path): with open(path, rb, buffering0) as f: n 0 while f.read(1): n 1 return n # 方式二用大缓冲 def fast_count(path): with open(path, rb, buffering1024 * 1024) as f: return sum(len(chunk) for chunk in iter(lambda: f.read(8192), b)) path /tmp/test_10mb.txt # 先生成一个10MB的文件 with open(path, wb) as f: f.write(bx * 10_000_000) t0 time.time() slow_count(path) t1 time.time() fast_count(path) t2 time.time() print(f逐字节读取: {t1 - t0:.2f} 秒) print(f大缓冲读取: {t2 - t1:.3f} 秒)实测结果通常是逐字节读取一个10MB文件需要好几秒大缓冲读取只要几十毫秒差距上百倍。原因就是前者触发了1000万次read系统调用后者只触发了约1200次。这个实验几乎是“系统调用开销”最直观的证明。6. 常见问题与排查实录6.1 一张速查表先定位方向遇到和操作系统相关的性能问题最怕的是没有方向乱试。我把这几年遇到的情况整理成一张表从现象倒推可能的原因能快速收敛范围。现象先看什么常见原因CPU使用率高但吞吐上不去mpstat -P ALL、pidstat单线程瓶颈、自旋锁竞争、频繁切换CPU不高但响应慢vmstat的si/so、iostat内存不足换页、磁盘I/O排队上下文切换异常高pidstat -w线程数过多、锁竞争、I/O阻塞内存持续增长/proc/pid/status、pmap内存泄漏、页缓存误判、碎片磁盘%util打满iostat -x、iotop大量小I/O、日志同步写、刷脏页进程莫名被杀dmesg触发OOM Killer、内存超限负载高但CPU空闲vmstat的r列大量进程处于不可中断睡眠D状态表格里最后一行特别值得说。“负载高但CPU空闲”这个组合经常迷惑人。系统负载load average统计的是运行队列长度加上不可中断睡眠的任务数。如果一堆进程卡在D状态通常是等磁盘I/O负载会很高但它们并不消耗CPU所以看起来CPU是闲的。这种情况下去优化CPU毫无意义得去看存储。6.2 几个反直觉的坑坑一把页缓存当成内存泄漏。新服务上线free -h显示可用内存一路下降运维报警开发者以为是泄漏查了半天代码没找到问题。实际上那些内存全是页缓存在干活属于“可回收内存”。判断方法很简单看/proc/meminfo里的MemAvailable或者手动触发一下回收echo 3 /proc/sys/vm/drop_caches生产环境慎用会导致后续一段时间I/O飙升内存立刻回来那就不是泄漏。坑二线程数开得越多越快。很多人下意识觉得多开线程能提高并发实际上线程数超过CPU核数到一定程度后收益就开始下降甚至反过来变慢——上下文切换成本、缓存失效、锁竞争都会吃掉收益。我的经验是CPU密集型任务线程数≈核数1I/O密集型任务可以到核数的几倍但一定要压测确认拐点在哪。别拍脑袋定数字。坑三fsync调用太频繁拖垮整体吞吐。一个日志组件为了“不丢数据”每条日志都fsync。压测时单机吞吐只有每秒几百条磁盘%util直接打满。后来改成组提交group commit多个线程的写请求攒在一起由其中一个线程统一执行一次fsync其余线程等结果。吞吐量提升几十倍持久性保证基本不变最多丢一个提交窗口内的数据。这个模式在各类需要持久化的系统里都很常见。坑四以为进程退出资源就都释放了。进程退出时内核会回收它的地址空间、文件描述符、信号量但有两样东西不会自动清共享内存段System V那套和文件锁。如果程序崩溃没走到清理逻辑共享内存段会一直留在系统里占着用ipcs -m能看到用ipcrm手动清。文件锁也是类似进程死了锁通常会被释放但某些场景下比如NFS上的锁表现不一样容易出诡异问题。坑五忽略文件描述符上限。高并发服务跑一段时间后开始报“Too many open files”第一反应是泄漏其实很多时候只是默认上限太低常见是1024。这个值的调整要分三层进程级的ulimit -n、系统级的/etc/security/limits.conf、以及内核级的fs.file-max。三层都对上才有效只改一层往往不生效这是个很常见的坑。注意排查这类问题有个通用顺序——先看监控确认现象再用工具缩小范围CPU/内存/磁盘/网络四选一最后进到具体的进程或线程。跳步直接猜原因十次有八次方向是错的。6.3 关于学习路径的一点个人建议操作系统这块内容太大硬啃容易半途而废。我自己走过的弯路是一上来就去读内核源码结果被各种宏和数据结构绕晕两周后放弃。后来换了个顺序反而顺利很多。第一步是建立“现象—机制”的对应关系。先跑各种观察工具top、vmstat、iostat、strace看到数字变化再去查这个数字背后的机制是什么。这种带着问题学的方式记忆效率比通读教材高得多。第二步是动手写小东西。写一个能跑的shell写一个生产者消费者模型写一段用mmap读写文件的代码写一个统计系统调用次数的工具。每个小东西都不长但写完之后那些抽象概念就变成肌肉记忆了。第三步才是深入某个方向。比如你做后端服务重点看网络I/O模型和内存管理你做嵌入式重点看调度和中断你做数据分析重点看文件系统和页缓存。不用追求面面俱到把和自己工作最相关的那一块吃透收益就已经很大。我自己在实际项目里最常受益的其实不是那些复杂机制而是几个最基础的东西知道虚拟内存怎么映射就不会乱调堆大小知道系统调用有开销就会下意识地合并小I/O知道上下文切换要代价就不会无脑加线程。这些认知不需要读源码但能实实在在避免很多问题。后续如果还想往下走可以顺着页缓存这条线去看看内核的回收策略或者顺着调度器去看看延迟和吞吐的权衡都是很有意思的方向。
阅读完成 · 觉得有帮助?
咨询建站