ARTICLE DETAIL

资讯详情

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

Linux进程切换与调度:原理、实验与性能排查指南

Linux进程切换与调度:原理、实验与性能排查指南 写Linux系统编程绕不开进程。尤其是你只要稍微碰一下性能、并发或者实时性“进程切换”和“进程调度”这两个词就会反复出现在perf输出、内核文档和面试题里。我在调试服务器上的多进程服务时发现很多问题最后都能追到这两块CPU莫名其妙飙高、任务延迟波动大、进程在核之间乱跑……说到底是没理解操作系统是怎么把CPU从一个进程“交接”给另一个进程的。这篇文章我就用实际排查和做小实验的方式把进程基础里最核心的切换和调度给你拆开讲透。适合刚开始学Linux系统编程的人也适合那些看vmstat和top犯迷糊、想搞清楚调度器在干嘛的运维或后台开发。1. 先从进程说起到底是任务还是“房子”1.1 进程在Linux里并不是一个“程序”写C语言时我们常说“fork一个子进程”但进程背后是一整套数据结构。Linux内核用task_struct描述进程里面挂着地址空间mm_struct、文件描述符表、信号处理函数、当前工作目录、命名空间、以及最关键的调度实体sched_entity。可以想象每个进程是一个独立房子里面有自己的家具和储物柜门上贴着编号。操作系统是物业手里拿着所有住户的档案。这样一个设计的好处是隔离如果一个进程崩了不能把隔壁进程的地址空间一起破坏。既然进程的“资产”这么多切换进程就不像换一个线程那么简单。线程之间共享地址空间切换时不需要重新处理CR3、页表这些东西进程之间地址空间是隔离的切换时内核必须把当前进程的“现场”完整保存下来再完整恢复下一个进程的“现场”。对初学者来说第一件事就是分清编程时你创建的是任务task但操作系统眼里它是一个带资源、带状态、会被调度器管理的进程实体。1.2 为什么系统编程必须理解切换与调度因为一台机器上的CPU数量远小于进程数量。就算你有16核跑几百个进程也很常见。内核必须让所有进程轮流用CPU这叫“分时复用”。分时的核心就是两个动作切换——把CPU从A进程拿下来给B进程用调度——决定“下一个给谁、给多久”。这个区分很重要。调度是策略按什么原则挑选下一个进程。切换是机制一旦选定了进程硬件和内核如何完成任务“交接”。日常开发中你调用read、sleep、wait时进程可能会主动让出CPU时间片用完时它又会被动被抢走。这些事件全都落在调度和切换的范畴里。不懂这两点遇到性能波动会定位半天。比如你会发现某个进程的CPU占用不高但整个系统load很高打开vmstat一看每秒上下文切换次数五六万那问题多半出在进程切换太频繁而不是业务代码真的算不过来。2. 进程切换一次“CPU换人”的完整过程2.1 从用户态到内核态CPU干了什么当进程A调用read()准备读一个管道假设数据还没准备好它就会睡眠。这时发生系统调用CPU从用户态切到内核态内核发现当前任务需要等待于是调用schedule()。schedule()先把A的寄存器、程序计数器、栈指针等“现场”保存到A自己的内核栈和task_struct里然后从运行队列里选一个进程B恢复B保存的上下文最后返回B的用户态继续执行。这里的“上下文”包括通用寄存器、程序计数器、栈指针、浮点寄存器/向量寄存器、内存管理相关寄存器比如CR3等。这属于硬件上下文。另外每个进程有独立的内核栈切换时内核栈也要跟着换不能混用。如果对一个系统调用流程不熟很容易把“用户态到内核态的模式切换”和“进程切换”混为一谈。mode switch只是CPU特权级变化并不一定会换进程而真正的context switch一定会发生进程切换并且会经历至少两次模式切换进内核、出内核。你可以在自己的进程里看一个直观证据每次进程被切换出去Linux都会把自愿/非自愿切换计数累加到/proc/PID/status里。用下面命令能直接看到cat /proc/$$/status | grep -Ei voluntary|nonvoluntary如果你在终端里输入它结果通常不为0。这说明你每次敲命令shell进程本身就已经被调度器来回切换很多次了。2.2 切换不便宜cache、TLB和分支预测很多人以为context switch就是几十个寄存器存了再取花不了多少时间。但真正的代价在“冷却”进程A跑的时候L1/L2 cache里全是它的数据切到B以后这些缓存对B来说没用B自己的数据可能已经被换出所以一开始跑会疯狂miss。TLB也同样进程地址空间一变CR3切换很多页表缓存失效。因此切换频率越高CPU有效利用率越低。网上有人测过典型的上下文切换开销在微秒量级看起来不大但如果一个服务每秒发生几十万次切换浪费掉的CPU时间就非常可观。我见过一个Java服务线程数设得特别高结果单看sys时间占了30%以上排查出来大部分是内核在做切换。所以说进程切换并不是“自动帮你做好一切”它是有真实成本的。对IO密集、阻塞频繁的任务这个成本会被放大每个read都可能会阻塞阻塞就会触发切换切换就会冷缓存冷缓存就会拖慢后续所有指令。2.3 怎么观察进程切换的动静Linux提供一堆现成工具# 系统级每秒打印一次看cs列 vmstat 1 # 进程级看每个进程的 cswch/s 和 nvcswch/s pidstat -w 1 # 统计一次运行内的切换总数 perf stat -e context-switches ./a.outvmstat里的cs表示每秒上下文切换次数包含所有CPU的合计。pidstat更细能区分自愿切换cswch/s和非自愿切换nvcswch/s。自愿切换通常是因为进程主动让出CPU等待IO、锁或者sleep非自愿切换往往是被抢占时间片用完或者有更高优先级进程。这两类问题的排查方向差很多前者多去看IO、锁竞争后者多去看线程数量、优先级、内核抢占配置。我自己习惯先看整机cs如果它到了几万级别再用pidstat定位是哪些进程在制造切换。很多场景下不是所有进程都高而是少数几个进程反复唤醒、阻塞、再唤醒把全局cs拖上去。3. 进程调度谁来决定下一个该“上场”3.1 从O(n)到CFSLinux调度器的变迁早期的Linux调度器比较简单每次要选进程就遍历整个任务列表复杂度O(n)进程多了扛不住。2.6内核引入O(1)调度器按优先级队列维护但“公平性”和“交互性”处理得一般。2.6.23开始换成CFS完全公平调度器一直沿用多年。CFS的理念不是给每个进程发固定大小的时间片而是维护一个虚拟运行时间vruntime每次都挑vruntime最小的进程来运行尽量让所有进程在统计上获得的CPU时间一样。近些年内核又进了EEVDF加权公平虚拟截止时间它在CFS红黑树基础上改进主要优化休眠进程和新进程的延迟。作为应用开发你不用改变什么但知道这两个词看内核新闻和性能分析报告时不至于发懵。CFS这个“完全公平”也不是绝对平均它说的公平是“按权重公平”权重高的进程跑得多一些这在多任务场景下是合理的。3.2 CFS的vruntime和nice值到底怎么算每个CPU运行队列上有一棵红黑树键值是vruntime。进程每次运行vruntime按照“实际运行时间 / 权重”增长。进程的权重由nice值决定。nice越小权重越大vruntime增长得越慢因此在树里更容易被排在左侧也能分到更多CPU时间。相反nice越大权重越小vruntime涨得快排到右侧享受CPU份额就少。一个方便记忆的例子两个普通进程一个nice 0一个nice -10把它们绑定在同一个CPU上跑同样的CPU密集任务。nice低的那一个获得的CPU时间会明显更多。想精确验证也没那么复杂后面我会给一段小实验命令。CFS里还有一个很关键的概念叫“调度延迟”内核保证在一段时间内所有可运行进程都至少被运行一次。这个目标延迟由sched_latency_ns控制最小运行粒度由sched_min_granularity_ns控制。进程数太多时目标延迟会被均分每个进程分到的时间就变短如果时间片太短切换开销占比就会上升。所以这两个参数对交互体验和吞吐都有影响但我不建议普通应用去乱改默认值在绝大多数机器上已经是经过大量测试的结果。3.3 实时调度策略、普通调度策略怎么选绝大多数进程用的是SCHED_OTHER也就是默认的CFS调度。如果你要跑批处理且不抢交互可以用SCHED_BATCH要完全让系统不做太多活动用SCHED_IDLE。这些都在chrt命令能设的范围内。真正需要小心的是SCHED_FIFO和SCHED_RR这是实时策略优先级范围1-99SCHED_FIFO在没有更高优先级实时任务时当前任务会一直跑直到自己阻塞或退出SCHED_RR则是同优先级之间按时间片轮转。用于音频、控制类任务很合适但一旦你写个while(1)忘了sleep系统里所有普通进程都没法获得CPU表现就是“系统卡死连top都敲不出来”。所以我把这归进“踩坑”而不是“特性”。4. 我在写多进程程序时踩过的坑4.1 频繁fork短命进程切换开销直接爆表很多新手喜欢“每来一个任务就fork一个子进程”看起来代码简单职责清晰。早期我这么写过把任务切得特别碎结果系统软中断高上下文切换频繁。原因不是fork本身多慢而是每次fork要创建task_struct、复制地址空间COW机制可以缓解但仍有页表开销、初始化文件描述符、加入调度队列子进程跑不了几毫秒就退出又触发一次调度和销毁。优化方向很明确用进程池、线程池复用任务载体别一次性创建几百个短命进程。比如一个接收外部请求的服务如果每来一个连接就fork一次高峰期每秒几百个连接系统会花大量时间在处理进程的创建和切换上而不是真正的业务逻辑。# 统计单次forkwait的大致系统调用观察切换相关耗时 strace -c -f ./my_server 21 | tail -20如果输出里clone、wait4、mmap这些系统调用数量巨大说明进程创建/销毁频率太高就该考虑池化。4.2 CPU亲和性没设置进程到处乱跑默认情况下调度器会尽量保持进程之前运行过的CPU但负载均衡也会把它迁移到新CPU。每次迁移cache就白热了。我在多进程并行计算程序里遇到过总进程数和CPU核数一样算力却不稳定。用taskset绑定每个进程到固定核之后吞吐明显提升。如果你在代码里做可以用sched_setaffinity()。但也不是所有场景都适合绑核比如机器上还有别的业务强行绑核会浪费空闲CPU。我的经验是明显的高吞吐计算型任务且机器资源基本独享可以绑核通用服务型进程保持默认让调度器做负载均衡反而更好。# 把进程固定在CPU0上运行 taskset -c 0 ./calc # 查看某个进程当前亲和性掩码 taskset -p PID4.3 误把普通进程调到“高优先级”反而更糟Windows、嵌入式里都习惯了“高优先级线程”这套在Linux下如果只是设置进程nice值为负数普通用户还会被拒绝得sudo。而且即使设了负nice也只是在CFS里多分权重不等于硬实时。如果直接上SCHED_FIFO且优先级设成99又容易把其他进程饿死。我踩过最惨的一次是给一个紧急业务做成实时进程结果同一台机器上的监控和ssh全连不上了。所以实时策略要慎用尤其要设置好rt_runtime限制和监控。你可以在设置实时策略之前先写一个“提前降级”的看门狗脚本一旦发现实时进程CPU占用长期100%就把它调度策略改回普通。# 查看某个PID的调度策略和优先级 chrt -p PID # 修改为SCHED_FIFO优先级50 sudo chrt -f 50 PID5. 自己动手验证写一个切换与调度的小实验5.1 用管道让两个子进程“打乒乓球”上下文切换开销最直观的实验是用管道让两个子进程互相传消息。一个读管道A、写管道B另一个写管道A、读管道B每次消息传递都可能触发进程切换。#include stdio.h #include unistd.h #include sys/wait.h #include time.h int main() { int p1[2], p2[2]; pipe(p1); pipe(p2); pid_t pidA fork(); if (pidA 0) { // 子进程A从p1读往p2写 char c; for (int i 0; i 10000; i) { read(p1[0], c, 1); write(p2[1], a, 1); } _exit(0); } pid_t pidB fork(); if (pidB 0) { // 子进程B往p1写从p2读 char c; for (int i 0; i 10000; i) { write(p1[1], a, 1); read(p2[0], c, 1); } _exit(0); } struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, start); wait(NULL); wait(NULL); clock_gettime(CLOCK_MONOTONIC, end); double ms (end.tv_sec - start.tv_sec) * 1000.0 (end.tv_nsec - start.tv_nsec) / 1000000.0; printf(elapsed %.2f ms\n, ms); return 0; }编译运行gcc -o pingpong pingpong.c perf stat -e context-switches ./pingpong我跑过一次10000次消息传递耗时在10毫秒级以上context-switches在4万左右。这个数字会受内核版本、CPU频率、是否开了抢占等功能影响但结论是一致的每次“打乒乓球”式的小消息传递背后都在发生真实的进程切换。5.2 用taskset和nice跑一个CPU份额实验写一个死循环程序busy.c#include stdio.h #include unistd.h #include sys/resource.h int main() { volatile unsigned long long x 0; for (;;) { x; if (x % 100000000 0) { printf(pid%d x%llu\n, getpid(), x); x 0; } } return 0; }然后在一个CPU上同时跑两个一个nice 0一个nice -10gcc -O2 -o busy busy.c sudo taskset -c 0 sh -c nice -n -10 ./busy nice -n 0 ./busy wait观察输出或者用top看两个busy进程的CPU时间。不出意外的话nice -10那个进程的累计CPU时间会明显高于nice 0。如果不想动sudo也可以把“nice -n -10”改成“nice -n 19”对比两个正数nice的影响。5.3 用chrt设置实时策略验证风险如果你想看SCHED_FIFO的威力可以在虚拟机或者一台不影响业务的测试机上跑sudo chrt -f 50 ./busy跑起来之后这台机器基本就“卡”了因为busy是个无限循环且不会被普通进程抢占。这个实验我不建议在线上机器做就算要做也得开一个随时能重启机器或能远程KVM进去的通道。实时调度的正确使用方式是把实时任务限定在极短关键段里大部分时间仍然主动睡眠或阻塞。6. 调度与切换的常见问题速查表现象可能的根因排查命令解决建议系统负载高但业务CPU不高进程切换频繁大量时间耗在内核上下文切换vmstat 1看cs列减少线程/进程数检查锁竞争和IO唤醒单个进程自愿切换量很大进程频繁阻塞在IO、锁、等待事件上pidstat -w 1、cat /proc/PID/status优化IO模式使用非阻塞或批量处理减少唤醒多进程并行计算算力不稳进程在不同CPU之间迁移cache失效taskset -p PID用taskset或sched_setaffinity绑定固定CPU调了nice但效果不明显可能是多核负载均衡、或进程本身IO密集而不是CPU密集top看%Cpu和进程状态CPU密集任务才适合用nice调整权重同时关注绑核实时进程导致系统卡死SCHED_FIFO任务优先级过高且死循环通过串口/远程带外管理重启或Kill避免写死循环实时进程设置rt_runtime限制这张表是我实际排查中反复用到的几个方向。注意上下文切换高不等于“切换本身是坏事”关键看它是不是瓶颈。有些业务天然阻塞多切换是必要的真正要警惕的是切换占据了大量CPU却没能带来有效吞吐提升。最后分享一个小技巧我在排查切换问题时最常看的不是vmstat的cs列而是/proc/PID/status里的两个字段voluntary_ctxt_switches和nonvoluntary_ctxt_switches。前者代表主动让出CPU的次数后者代表被抢占的次数。如果主动切换高说明进程经常在等待资源应该去查锁、IO、网络如果被动切换高说明进程在跟别人抢CPU应该去查线程数、优先级和调度器配置。有一次线上服务延迟抖动我抓了top里CPU占用最高的几个PID发现它们nonvoluntary_ctxt_switches每秒都在涨但业务处理耗时本身很低。后来把线程数从128降到32问题立刻缓解。切换和调度不只是内核的知识点它就是你程序跑得快不快的底层原因。你在写多进程程序时如果能先想清楚“这个进程是CPU密集还是IO密集、要不要实时、要不要绑核”再配合这些工具去验证很多性能坑都能提前避开。
返回列表