你正在开视频会议,浏览器里有十几个标签页,聊天软件不断收消息,后台还在同步文件。任务管理器会列出上百个进程,可电脑也许只有 8 个 CPU 核心。一个核心在同一瞬间只能沿着一条指令流向前执行,为什么这么多工作还能同时推进?
关键不在于把一颗 CPU 真的掰成许多份,而在于操作系统把每项正在进行的工作包装成可管理的对象:它知道这项工作执行到哪里、能访问哪些内存、打开了哪些文件、正在等什么事件,也知道什么时候该让它暂停,把处理器交给别人。这个对象就是进程。
进程解决了两个互相拉扯的问题。一边是隔离:程序最好只看到自己的地址空间和资源,一个程序出错不该随便改坏另一个程序。另一边是共享:CPU、内存和设备终究属于整台机器,所有程序必须轮流使用。进程提供边界,调度和进程间通信则在边界之上重新建立受控的共享。
先看一个最容易混淆的场景。磁盘里放着一个浏览器可执行文件时,它只是字节、指令和初始数据的集合。你连续启动两次浏览器,文件没有变成两份,但操作系统会创建两个不同的运行实例。它们有不同的进程标识、执行位置、内存内容和打开的资源。程序是静态配方,进程是一次正在发生的烹饪过程。

程序从磁盘加载后,代码、数据与操作系统分配的资源共同构成运行中的进程。
进程也不只是“正在执行的代码”。更准确地说,它是一组执行状态与资源的容器,常见内容包括:
这份清单解释了为什么“结束一个进程”不等于删掉程序文件。进程退出时,操作系统回收的是这次运行占用的内存、句柄和内核记录;磁盘上的程序仍然存在,下次还能重新启动。
一个普通用户进程看到的地址通常不是物理内存条上的直接位置,而是自己的虚拟地址。相同的数值地址可以同时出现在两个进程中,却由各自的页表翻译到不同的物理页。这样,进程甲写入自己的某个地址,默认不会改动进程乙在同一数值地址处的数据。

从低地址到高地址展示进程各内存区域,以及堆和栈的相向增长方向。
典型地址空间可以从低地址到高地址理解:
图里常把堆画成向高地址增长、栈画成向低地址增长,这有助于理解,但不能把它当成所有体系结构和所有进程都必须遵守的物理定律。地址空间还会受装载器、随机化、共享库和运行时实现影响。真正可靠的判断应来自系统提供的内存映射信息。
虚拟地址空间带来的是“看起来独占”的视图,不代表每个虚拟页此刻都占着一个物理页。页可能尚未实际分配,也可能来自文件映射;不同进程还可以在操作系统允许下把同一物理页映射到各自的地址空间。
如果进程完全无法共享数据,浏览器就不能把下载任务交给独立进程,命令行也不能把一个程序的输出接到另一个程序的输入。操作系统因此提供两类受控通道:一类让多个进程映射同一块内存,另一类让数据通过内核管理的消息、管道或套接字传递。
这是一种刻意的设计:默认隔离,需要时再显式共享。代价是程序员必须说明谁可以访问、何时访问、数据怎样解释,以及一方突然退出时另一方怎么办。
想象老师突然让你合上书,十分钟后再从原处继续。仅仅记住“你在看这本书”不够,还要知道页码、读到哪一行、草稿纸上的中间结果和借走了哪些资料。操作系统暂停进程时也面对同样的问题,它需要一份可以恢复执行的记录。
这类内核记录通常概括为进程控制块,也常简称 PCB。不同操作系统的具体结构差异很大,但需要解决的问题相近:标识进程、描述状态、保存执行现场、关联地址空间与资源、记录调度和记账信息。Linux 内核用任务结构管理可调度实体,并通过相关结构继续找到内存、文件和信号等信息;它不是一本固定字段的教科书表格,而是一组相互连接的内核数据结构。

内核保存进程甲的执行现场后,再恢复进程乙的执行现场。
入门时可以用五个状态理解进程的一生:

进程随调度、时间片和事件变化,在新建、就绪、运行、阻塞与终止状态间转换。
最关键的区别是“就绪”和“阻塞”。就绪进程有活可干,只差 CPU;阻塞进程即使立刻得到 CPU 也无法继续,因为它等待的事件还没发生。调度器不应该把时间浪费在阻塞进程上,所以发起阻塞式 I/O 后,当前进程会让出处理器,设备完成工作再把它唤醒到就绪状态。
五状态模型是骨架,不是系统监控工具的逐字翻译。真实操作系统还会区分可中断睡眠、不可中断等待、被信号停止、正在跟踪等情况。Linux 工具里看到的“运行”通常还包含“可运行但正在等 CPU”的任务;“睡眠”则可能对应不同种类的等待。
僵尸进程更容易被误解。它不是还在后台干活的进程,而是已经结束执行,只保留少量退出信息,等待父进程读取。它不会继续占用用户地址空间和 CPU,却仍占一个进程标识与退出记录。若父进程一直不处理退出结果,僵尸条目就会积累。
运行中的进程可能主动进入内核发起系统调用,也可能被时钟中断、设备中断或异常打断。内核处理完事件后会判断:原线程是否继续、是否因等待而阻塞、是否有更合适的线程应该运行。状态转换因此和中断、系统调用、I/O 完成、同步原语以及调度策略连在一起。
打开一个文档编辑器时,你可能一边输入文字,一边看到拼写检查和自动保存。若把每项工作都放进独立进程,隔离很强,但共享文档内容会很麻烦;若把它们放在同一条执行流里,保存稍慢就可能拖住界面。线程是在这两端之间做出的折中。
同一进程里的线程通常共享地址空间、代码、全局数据和大部分打开资源,但每个线程必须保留自己的程序计数器、寄存器和栈。共享让传递数据变得直接,也让错误更容易扩散:一个线程写坏公共内存,整个进程都可能崩溃;两个线程同时修改同一对象,还可能产生竞态条件。
“并发”和“并行”也要分开。单核 CPU 可以让多个线程交替推进,这是并发;多个核心在同一时刻执行多条线程,才是并行。线程数量变多不会自动带来加速,锁竞争、缓存争用、内存带宽和任务拆分方式都可能抵消多核收益。
当 CPU 从进程甲转去运行进程乙,内核至少要保存甲恢复执行所需的处理器现场,再装入乙的现场。常见内容包括程序计数器、栈指针、通用寄存器、状态寄存器以及与体系结构相关的扩展状态。若切换跨越不同地址空间,还要切换内存映射上下文;处理器缓存和地址翻译缓存的有效性也可能受到影响。
上下文切换本身不推进用户任务。保存现场、运行调度器、恢复现场都消耗 CPU 时间,新进程接手后还可能遇到较冷的指令缓存和数据缓存。所以时间片不能无限缩短:短时间片能改善交互响应,却会让切换比例上升;长时间片减少开销,却可能让其他任务等得太久。
线程切换通常比跨进程切换需要改变的隔离上下文更少,但不能简单说“线程切换一定快”。是否进入内核、缓存是否命中、处理器架构、调度类别和工作集大小都会影响实际代价。
在命令行输入一个命令后,外壳程序不能直接把自己变成那个命令,否则命令结束时外壳也没了。它通常需要建立一个子进程,让子进程执行新程序,自己则选择等待或继续接收输入。这条路径把“复制现有执行环境”和“装入新程序”拆成两个动作。
fork() 创建调用进程的子进程。父子进程从同一个调用点继续,各自得到不同返回值,从而知道自己是谁。概念上,子进程获得父进程地址空间的副本,也继承一组打开文件描述符等状态;实现上通常使用写时复制,让父子一开始共同指向相同的物理页,只有某一方写入时才复制相关页面。这减少了“刚复制完就马上换程序”造成的浪费。
子进程接着调用 exec 家族函数,把当前进程映像替换成目标程序。成功后,原来的代码、数据、堆和栈被新程序的映像取代,进程标识仍沿用。exec 不是再创建一个子进程,它是在当前进程外壳里换入另一套程序。
父进程通过 wait 一类接口收取子进程的退出状态。这个动作既用于同步,也让内核可以释放保留的退出记录。如果父进程完全不等待,已经退出的子进程可能短暂处于僵尸状态;若父进程先退出,仍在运行的子进程会由系统中的适当进程接管后续回收责任。
Windows 常用 CreateProcess 一次指定可执行映像、命令行、环境、工作目录、安全属性和创建标志,同时得到新进程及其主线程的信息。这和 fork 后再 exec 的接口形态不同,但底层仍要完成相似工作:建立地址空间、内核对象、初始线程和资源继承关系。
句柄继承必须谨慎。若子进程意外继承了不该拥有的文件、套接字或安全对象句柄,可能导致资源迟迟不能释放,甚至扩大访问权限。稳妥做法是默认不继承,再明确列出确实需要传给子进程的对象。
进程可以从主函数返回、显式调用退出接口,也可能因未处理异常、非法内存访问、外部终止请求或系统策略而结束。无论路径如何,操作系统都要停止其线程、关闭内核对象或递减对象的使用计数、解除内存映射并记录退出结果。
退出码只是约定好的小块信息,不能替代完整错误报告。调用方需要先区分“程序正常返回了非零结果”和“程序被信号或异常终止”,再决定是否重试、报警或回滚。
调度器面对的不是一道只有唯一答案的排序题。视频通话希望尽快响应,批量压缩希望尽快完成,后台索引可以晚一点,但又不能永远得不到 CPU。系统还希望处理器尽量不空闲,同时把上下文切换控制在合理范围。提高某个指标,常常会牺牲另一个指标。

三种调度策略在响应时间、等待时间、公平性和切换开销之间各有取舍。
这些指标不能混用。让短任务插队可能降低平均等待时间,却让长任务更晚完成;把时间片切得很短可以缩短首次响应,却增加切换开销。实时调度追求的也不一定是平均速度最快,而是在给定条件下满足时间约束。
进程通常在一段 CPU 计算和一段 I/O 等待之间交替。压缩、渲染等任务可能有较长的 CPU 执行段;编辑器、终端和网络服务经常运行很短一会儿就等待输入。调度器如果让刚被交互事件唤醒的任务很快运行,用户会觉得系统更灵敏;若完全偏爱短执行段,持续计算的任务又可能受损。
不要给应用永久贴上“CPU 密集”或“I/O 密集”的标签。同一进程会随工作阶段改变行为,现代调度器更倾向依据近期运行情况和调度类别动态处理。
先来先服务按到达顺序运行,规则简单、切换少。问题是前面若有一个很长的任务,后面一串短任务都会被拖住,这常被称为护航效应。
最短作业优先优先选择预计执行时间最短的任务,在理想已知执行时间的条件下能改善平均等待时间。现实里很难准确预知下一段 CPU 执行时间,只能根据历史估计;持续到来的短任务还可能让长任务长期等待。抢占式版本会在更短的新任务到达时重新选择。
优先级调度让更重要或更紧急的任务先运行。它符合系统分级需求,却可能造成低优先级任务饥饿。逐渐提高等待任务的优先级,也就是老化,是常见缓解办法。锁还会引出优先级反转:高优先级任务等待低优先级任务持有的锁,而中优先级任务不断抢占低优先级任务。优先级继承等机制用来缩短这种倒置。
时间片轮转让就绪任务轮流运行一个时间片,适合强调交互的分时场景。时间片太长时接近先来先服务;太短则大部分时间花在切换上。
多级反馈队列设置多个优先级队列,新任务通常先得到较短延迟;频繁用完整个时间片的任务逐步下移,快速阻塞的交互任务则更容易留在上层。它不需要事先知道任务长度,却必须处理时间片、升降规则和防饥饿策略,参数本身就是政策。
非抢占式调度让任务一直运行到主动阻塞或结束,逻辑简单,但一个长计算段就可能让交互任务迟迟得不到响应。抢占式调度允许内核在时钟中断或更高优先级任务就绪时收回 CPU,改善响应和控制能力,同时要求内核正确保存现场,并处理共享数据在任意时刻被打断的问题。
多核系统还多出两层权衡。调度器要在核心之间平衡负载,避免一个核心排长队、另一个核心闲着;又要考虑处理器亲和性,让任务尽量留在近期运行过的核心上,以复用缓存。频繁迁移能把队列摊平,却可能让缓存局部性变差。现代通用操作系统因此不会只照搬某个课堂算法,而是结合优先级、虚拟运行时间、截止期、CPU 拓扑和能耗策略作出选择。
浏览器常把页面渲染、网络服务和界面放在不同进程里。隔离能缩小崩溃和攻击的影响范围,可地址空间一隔开,界面进程不能直接拿到渲染进程里的普通指针。它们必须通过进程间通信,也就是 IPC,交换数据和控制信息。

进程可通过共享内存、消息队列、管道和套接字通信,其中共享内存需要额外同步。
选择 IPC 方式时,可以先问五个问题:数据量多大,是否需要保留消息边界,通信双方是否在同一台机器,是否允许阻塞,谁负责处理一方崩溃后的残留状态。没有一种机制在所有维度都最好。
共享内存把同一组物理页映射进多个进程的地址空间。进程甲写入后,进程乙可直接读取,特别适合大块数据和高吞吐场景。数据不必每次都在用户空间与内核缓冲区之间来回复制,这是它快的主要原因。
可“看见同一块内存”不等于会“正确合作”。双方若同时修改队列头尾,仍会发生竞态。程序需要互斥锁、信号量、条件变量或无锁协议来规定所有权和可见顺序,还要设计初始化、版本兼容以及异常退出后的恢复办法。共享内存把数据搬运成本降下来,却把一致性协议交给了应用。
匿名管道常用于有亲缘关系的进程,最经典的例子是命令行中的 前一个命令 | 后一个命令。写端按字节流送入,读端按顺序取出;不用的端点必须关闭,否则接收方可能一直等不到“所有写端已关闭”的结束信号。
命名管道为通道提供可查找的名字,使不相关进程也能建立连接。消息队列则按一条条消息组织数据,接收方可以在稍后读取,某些实现还支持优先级。内核帮助管理排队和阻塞,使用起来比裸共享内存直接,但消息需要进入受管理的缓冲区,容量也不是无限的。
套接字提供统一的收发接口。本机进程可以使用本地域套接字,跨机器则可使用网络协议族。面向字节流的套接字不替应用保留每次发送的边界:发送两次不保证接收端恰好读取两次,因此应用必须自己定义长度、分隔符或固定头部。数据报套接字保留消息边界,但可靠性、顺序和大小限制取决于所用协议。
套接字的优势是通信范围广、接口统一;代价是协议设计更复杂。程序要处理半包、粘连、断线、超时、重连、身份验证和背压,不能把一次 send 想象成对端的一次完整 receive。
发送方比接收方快时,数据先放哪里?固定容量缓冲区满了以后怎么办?常见选择是让发送方阻塞、返回失败、丢弃数据或施加背压。缓冲太小会让双方频繁互相等待;缓冲太大则增加内存占用,并可能把拥塞隐藏到延迟已经很高时才暴露。
生产者—消费者模型把问题说得很清楚:生产者只应在缓冲区有空位时放入数据,消费者只应在有数据时取走;修改队列结构时还要保证互斥。这里至少有“空位数量”“已有数量”“队列互斥”三种不同含义,不能用一把含糊的锁代替全部协议。
当通信有明确的请求方和服务方时,我们常把它组织成客户端—服务器模型。客户端提出请求,服务器维护资源或执行操作并返回结果。两者可以在同一台机器,也可以隔着网络;可以一对一,也可以许多客户端共享一个服务。
这种模型首先要解决的是命名:客户端怎样找到服务器?本地域套接字可以用路径或抽象名字,网络服务使用地址与端口,命名管道和消息队列也有自己的命名空间。名字只是入口,还要配合权限检查,防止不该访问的进程冒充客户端或服务器。
远程过程调用让客户端看起来像调用普通函数。客户端存根把方法标识和参数序列化为消息,通信系统送到服务器;服务器存根解包后调用真正实现,再把结果编码返回。它减少了手工拼消息的工作,但“像本地”不代表“就是本地”。
本地函数调用通常要么返回,要么在当前进程里抛出异常;RPC 还可能遇到网络延迟、服务器崩溃、请求已经执行但响应丢失、客户端超时后重复发送。接口设计必须考虑超时、取消、幂等、版本兼容和部分失败。把 RPC 当成没有代价的本地调用,往往会写出既慢又难恢复的系统。
Android 里,不同应用进程和系统服务常通过 Binder 通信。接口可以用 AIDL 描述,工具生成代理与存根代码。客户端调用代理时,方法标识和参数被打包成事务,经 Binder 驱动送到目标进程,由目标进程线程池中的线程解包并调用服务对象,结果再沿反向路径返回。
Binder 让跨进程调用的代码形态接近方法调用,也结合对象标识和权限信息管理设备内服务。不过边界依旧存在:参数需要封送,目标进程可能死亡,同步调用会阻塞当前线程,大对象传输也有成本。界面线程若直接进行耗时的同步 Binder 调用,用户看到的就是卡顿。
学进程管理最有效的办法不是背完状态名字,而是看到现象时能沿着机制往回推。
CPU 使用率不高但程序很慢,可能是线程在等磁盘、网络、锁或另一个进程。先区分“就绪队列里等 CPU”和“阻塞等待事件”,排查方向会完全不同。
进程数量持续上涨,要看是正常工作进程、线程被误显示成任务,还是子进程退出后没有被回收。僵尸进程数量增长说明父进程没有及时取得退出状态;真正消耗大量内存的往往是仍存活的进程,而不是僵尸条目本身。
切换次数很高但吞吐下降,可能是线程开得过多、时间片与工作粒度不匹配,或许多线程在锁上反复唤醒。增加线程之前,应先确认任务是否可并行、临界区是否过大,以及瓶颈是不是 I/O。
管道读取一直不结束,除了检查生产者是否还在工作,还要检查是否有某个进程意外保留着写端。只要仍有写端处于打开状态,读端就不能把“暂时没数据”当成“以后不会再有数据”。
服务偶尔重复执行请求,可能不是服务器故意做两次,而是客户端超时重试时无法确认第一次是否成功。此时需要请求标识、幂等操作或去重记录,而不是简单把超时时间拉长。
把一项进程问题拆成四问通常很有效:它现在是什么状态?在等哪种资源或事件?谁能让条件发生变化?这次等待和切换付出了什么代价?四个答案能把“程序卡了”变成可以验证的假设。