为什么打开同一个程序,会出现不同的执行活动
磁盘上的浏览器、编辑器或服务器程序,是可以反复读取的文件;屏幕上正在响应操作的应用,则是操作系统正在管理的执行活动。打开相同的程序文件,可以产生多个相互独立的执行实例。每个实例能够处理不同的数据,也可能处于不同的执行位置。理解这种区别,是解释程序为什么会等待、为什么能同时服务多个请求的起点。
在 S1 中,内核负责管理硬件资源,应用通过系统调用请求内核提供服务。本课进一步观察内核管理执行活动时采用的模型:进程组织地址空间和资源,线程承载具体的执行过程,状态描述线程此刻能否继续执行,则让有限的 CPU 在多个执行过程之间轮流工作。后文先用每个进程只有 1 个线程的简化模型说明状态,再扩展到多线程。
设想某个 Web 服务收到 2 个请求。请求甲需要读取文件,请求乙需要计算结果。甲等待存储设备时,CPU 不必跟着空等,可以执行乙的计算。若机器有多个 CPU 核心,部分计算还可能真正同时发生。前者体现并发,即多个任务在相互重叠的时间范围内推进;后者体现并行,即多个任务在同一时刻执行。并发并不保证并行,更不保证总耗时按任务数量等比例缩短。
为了讨论清楚,需要始终区分 3 个层次:程序描述要执行什么,进程提供某次执行所需的环境,线程记录这次执行推进到了哪里。观察工具中的名称、编号和百分比,只有放回这 3 个层次才有意义。本课以 Linux 常见行为和命令为例,建立可迁移的概念;不同系统的字段名称与实现细节可能有所不同。
程序与进程:从文件到受管理的执行实例
程序通常以可执行文件及相关文件的形式保存在存储设备中。文件包含机器指令、初始数据以及装载所需的信息。它本身不会因为存在于磁盘上就占用 CPU 执行。启动程序后,操作系统建立执行环境,将相应内容映射进地址空间,并安排初始执行线程。此时才出现可以被调度、观察和终止的执行实例。
进程是操作系统组织地址空间、资源与执行活动的重要单位。每个进程有进程标识符 PID,内核还维护它的凭据、打开的文件、与父进程的关系等信息。程序文件的名称可以相同,进程的编号和运行时数据却不同。进程编号用于当前系统中的识别,退出后可能被复用,因此记录问题时应同时保留时间、启动时间和命令信息。
进程映象可以理解为某次执行在内存中的整体面貌,包括代码、数据以及运行时使用的内存区域。地址空间则是进程能够使用的虚拟地址范围和对应的映射规则。常见示意包含代码区、全局数据区、堆、共享库映射和栈。堆常用于动态分配对象,栈常用于保存函数调用中的局部信息。实际布局受到系统、运行时和安全机制影响,示意图中的顺序与大小不代表固定布局。

进程甲与进程乙即使使用相同的虚拟地址数值,也不意味着它们读写同一块物理内存。内核和硬件提供地址转换与访问控制,使普通进程无法任意访问其他进程的内存。这个边界让某个进程写坏自身数据时,通常不会直接覆盖另一个进程的堆。共享内存等机制可以显式建立共享区域,但需要另外约定访问方式。
地址空间独立也不等于所有资源都独占。多个进程可以映射相同程序的只读代码页,也可能通过继承或显式传递共享某些内核对象。文件描述符只是进程访问已打开对象的编号,不是文件内容本身。关闭进程内的某个编号与删除磁盘文件也不是同一个操作。分析资源时,应区分进程持有的引用、内核中的对象和存储设备上的数据。
例如,同一份服务程序启动 2 次,可以得到 PID 为 4100 和 4200 的进程。它们分别维护请求计数,某个进程的计数变化不会自动更新另一个进程的计数。如果方案要求所有实例共享计数,就要引入明确的通信或共享存储机制。由此可见,选择多个进程不仅是增加执行活动,还意味着要安排数据如何跨越进程边界。
进程状态:能执行、正在执行与暂时不能执行
先考虑只有 1 个线程的进程。运行态表示它正在某个 CPU 上执行;就绪态表示它已经具备执行条件,但还没有获得 CPU;阻塞态表示它在等待某个条件成立,例如文件读取完成、网络数据到达或锁被释放。就绪与阻塞都可能表现为暂时没有执行指令,但两者的原因不同,解决方法也不同。
运行中的线程发起阻塞读取,如果所需数据尚不可用,就会进入等待。内核可以让其他就绪线程使用 CPU。设备完成操作后,等待条件得到满足,原线程通常先回到就绪态,再等待执行机会。因此,读取完成不等于应用立刻继续运行,中间仍可能存在等待 CPU 的时间。这段区别对理解请求尾部延迟尤其重要。
运行中的线程也可能因为本次执行机会结束而回到就绪态,之后再继续运行。这里可以借助时间片的直觉:CPU 的使用机会被分配给不同执行活动。至于如何选择下一个执行者、怎样考虑优先级和公平性,留到 S3 讨论。本课只需要确认,暂时没有运行不意味着程序出错,也不意味着它一定在等待输入输出。

状态转换的判断依据是执行条件。阻塞线程等到事件后进入就绪,而不是无条件直接进入运行;就绪线程被选中后才进入运行。若没有等待中的线程被唤醒,输入输出设备完成其他工作也不会改变它的状态。图中省略了创建、退出和暂停等状态,以突出最常用的运行关系。
实际工具通常不会直接给出这 3 个中文名称。Linux 的 ps 常用 R 同时表示正在运行或可运行,用 S 表示可中断睡眠,用 D 表示不可中断睡眠,用 T 表示停止或被跟踪,用 Z 表示僵尸。STAT 中还可能附带其他标记。S 和 D 都涉及等待,D 常见于某些内核或输入输出等待路径,不能只凭这个字母认定某块磁盘损坏。
这些状态是观察时刻的快照。某个进程显示 S,并不代表它过去没有消耗 CPU;某次看到 R,也不能推断它始终忙于计算。对于多线程进程,还要说明观察的是线程组中的代表线程,还是每个线程的状态。进程内可能同时存在运行、就绪和阻塞的线程,不能用某条线程的状态概括所有执行活动。
创建与退出:执行活动也有生命周期
进程通常由已有进程发起创建,形成父子关系。Linux 中可以用 fork 与 exec 的概念理解常见启动路径:fork 创建子进程,子进程获得独立的执行身份;exec 用新的程序映象替换当前进程的程序内容,成功后仍沿用该进程的 PID。这里的重点是,创建进程与装入另一个程序是可以区分的操作。
fork 后,父子进程从相应的执行位置继续,但能根据返回值区分自己的角色。它们的地址空间在逻辑上分离,初始内容相似,不意味着今后的普通内存写入会相互可见。实际系统常用写时复制等方法减少立即复制数据的工作量。这里无需掌握具体实现,只需避免把创建子进程理解成马上完整复制所有物理内存。
父子关系有助于组织启动和回收。命令行解释器启动命令后,可以等待子进程结束,读取退出状态,再决定后续动作。正常退出常用退出码表达结果,约定中通常以 0 表示成功,以非 0 表示某种失败或特殊结果,但具体含义由程序约定。异常终止还可能与信号有关,不能把所有终止原因都简化成某个业务错误码。

进程结束时,大部分执行资源被释放,内核仍需保留少量退出信息,供父进程读取。已经退出、退出信息尚未被回收的进程称为僵尸进程。它不会继续执行业务代码,也不会持续消耗 CPU 做计算。它占用的是进程表项等有限信息;若大量积累,仍然说明生命周期管理存在问题,需要检查父进程是否正确等待并回收子进程。
孤儿进程指父进程先结束而自己仍然存在的进程。Linux 会按进程层级和相关机制,将其交给适当的接管者,例如子收割者或初始化进程。孤儿仍可能正常运行,僵尸则已经退出,两者描述的维度不同。看到父进程编号变化,应结合服务管理方式分析;仅凭“孤儿”这个名称,不能断定进程有故障。
服务的退出还涉及业务清理。进程被终止后,内核可以回收它持有的许多资源,但不能替应用补完尚未完成的业务事务。停止接收新请求、等待在途工作、记录必要结果和释放外部租约,都需要应用与运行环境共同安排。生命周期的设计因此应包含正常结束、异常结束以及结束后由谁观察结果。
线程:共享环境中的多个执行过程
在本课采用的常见内核线程模型中,线程是内核调度 CPU 执行的基本单位。每个进程至少有 1 个执行线程,也可以建立多个线程。多个线程归属于同一进程,通常共享代码、全局数据、堆和打开的文件等资源。线程可以直接访问这些共享数据,因此传递对象引用通常不需要跨进程复制或通信。
每个线程仍需保存自己的执行位置和寄存器状态,并使用自己的栈。线程甲可能正在处理某次函数调用,线程乙则停留在另一段函数中;它们的局部调用过程不能混为一体。线程还可以有线程局部存储,用于保存只属于当前线程的运行时数据。共享地址空间描述了可访问的范围,不代表所有数据都必须被所有线程共同使用。

“各自有栈”也不构成进程级内存隔离。若某个栈上的对象地址被传给其他线程,其他线程仍可能访问它。线程退出或函数返回后,该地址还是否有效,需要遵守语言和运行时的生命周期规则。相比独立进程,同进程线程之间缺少同样强的内存保护边界,错误指针或内存破坏可能影响整个进程。
共享计数器说明了并发访问的困难。假设计数当前为 10,线程甲和线程乙都先读取 10,分别计算出 11,再先后写回。虽然处理了 2 次增加操作,最终值却可能只有 11。从业务上看似单步的动作,在执行时可能包含多个步骤。需要通过锁、原子操作或消息传递等方式约束访问次序,才能满足数据正确性的要求。
同步机制也会影响性能。某把锁若保护了过大的处理范围,多个线程虽然存在,关键部分仍只能轮流执行。线程等待锁时可能阻塞,也可能在短时间内反复检查,具体取决于实现。观察到 CPU 使用率高,并不一定代表完成了更多有效业务计算;观察到线程很多,也不代表这些线程正在同时工作。
此外,编程语言显示的“线程”、协程和任务,不一定与内核线程逐个对应。有的运行时会把许多任务安排到较少的内核线程上,有的运行时又对特定代码的并行执行施加限制。因此,讨论多线程收益前,应明确本课所说的是可由内核调度的线程,并确认所用运行时实际如何执行。协程的详细机制不在本课展开。
上下文切换:让出 CPU 后仍能接着执行
线程暂时离开 CPU 时,下次继续执行所需的信息不能丢失。这里的上下文,首先包括指令执行位置、栈指针、通用寄存器等处理器状态;根据架构与使用情况,还可能涉及其他寄存器状态。内核维护线程相关的管理信息,在切换时保存离开者的必要状态,再恢复接替者的状态,使其从正确的位置继续执行。
设线程甲执行到计算中途,因等待读取而不能继续。内核保存甲的执行状态,选中就绪线程乙,恢复乙先前保存的状态。CPU 随后执行乙的指令。甲所需的数据到达后,甲重新具备执行条件,但仍需等待再次被选中;恢复它的上下文后,才能继续尚未完成的计算。这种恢复不要求应用从主函数重新开始。

同一进程内的线程切换通常可以继续使用相同的地址空间;切换到另一个进程的线程时,还需要按实现处理地址空间相关状态。切换本身并不是把进程的整段堆和栈复制到另一个地方。应用数据通常仍保存在原来的内存中,内核改变的是谁继续执行,以及 CPU 访问哪套执行与地址转换环境。
成本有直接部分,也有后续影响。保存与恢复状态、执行调度路径都需要时间。新执行者使用的数据和指令可能尚未处于容易快速访问的位置,原有缓存内容也可能不再适合它;地址转换缓存的使用同样受到架构与系统机制影响。因此,切换后的有效执行效率可能暂时降低。存储层次为何有这种差异,将在 H10 继续说明。
系统调用、用户态与内核态转换、上下文切换之间不能画等号。线程调用内核服务后,可能仍由同一个线程立即继续执行,并未换成另一个线程。只有发生等待、抢占或其他调度原因时,才可能选择别的执行者。同样,中断让 CPU 进入内核处理事件,也不意味着每次都必然更换当前线程。
切换成本没有适用于所有机器的固定数值。线程工作量、切换频率、CPU 核心数量、共享数据访问方式和运行时实现都会改变结果。判断线程数量是否合理,应同时观察完成的工作、响应时间和资源消耗。单独追求更少的切换也不合适:让等待输入输出的线程占着执行机会,反而可能降低系统的整体处理能力。
用 ps、top 与 proc 观察执行活动
Linux 的 ps 适合获得进程快照。下面的命令列出 PID、父进程编号、状态、累计 CPU 时间及命令名。命令输出是观察材料,字段的含义比某次具体编号更重要。某些工具版本支持的选项不同,遇到差异时应查阅本机手册。
ps -eo pid,ppid,stat,time,comm
例如,某条记录中的 PID 为 4100、PPID 为 3000、状态为 Sl。这表示相应记录处于可中断睡眠状态,附加标记 l 在常见 Linux ps 中表示多线程。它并不说明所有线程都在睡眠。TIME 是累计 CPU 使用时间,不是从启动到现在的墙上时间;进程等待网络时,墙上时间增长,却未必增加多少 CPU 时间。
需要继续观察这个进程时,可以用线程视图查看各执行线程。在 Linux 上,下面的命令用 LWP 列展示线程标识。这里的 4100 是示例编号,实际阅读输出时应替换为目标进程的 PID。
ps -L -p 4100 -o pid,lwp,stat,pcpu,time,comm
同一进程下的多条记录可以拥有相同 PID,但 LWP 不同。线程视图有助于发现某条线程长期使用 CPU、其他线程主要等待的情况。ps 的 %CPU 常反映进程生命周期中的平均使用情况,而 top 通常根据刷新间隔计算近期变化,两者不能直接当作同样口径的瞬时测量。
top 提供持续刷新的视图,适合观察负载随时间变化。在常见 Linux 实现中,可用下面的形式观察指定进程的线程。界面和百分比口径受实现与设置影响:以单个逻辑 CPU 为基准时,多线程进程的总使用率可能超过 100%,这不表示统计错误。
top -H -p 4100
/proc 将内核中的部分进程信息呈现为文件接口。/proc/4100/status 常包含名称、状态、线程数及部分内存统计;/proc/4100/maps 展示虚拟内存映射;/proc/4100/task/ 下按线程标识组织线程信息。读取这些路径有助于把抽象模型与系统对象对应起来。
cat /proc/4100/status
cat /proc/4100/maps
ls /proc/4100/task
这些内容随进程运行不断变化。进程可能在命令执行前退出,某些信息也可能受权限限制,因此路径不存在或无法读取,并不自动说明内核异常。内存映射中出现某个库,只能说明有相应映射,不能由此计算该进程独占的物理内存。观察时先确认对象、时间和字段口径,再比较多次记录,才能减少误判。
常见误区:线程数量不等于服务能力
阻塞系统调用的影响要落到具体执行者上。在常见的内核线程模型中,某条线程等待网络读取,并不要求同进程其他可运行线程一起停止。但如果它在等待前持有其他线程必需的锁,其他线程仍可能被间接挡住。若运行时把许多任务安排到同一条执行线程,阻塞这个执行线程又可能影响多个任务,需要结合运行时模型判断。
反过来,选择非阻塞接口也不会自动消除等待。数据没有到达时,业务请求仍然没有完成,只是执行线程可以先处理其他事情。系统仍需记录等待中的请求、保存缓冲数据,并在条件满足后继续处理。进程数、线程数、连接数、在途请求数之间不存在普遍成立的逐个对应关系。
“CPU 使用率低,所以应当继续加线程”也可能判断错误。服务可能被慢存储、下游连接额度或某把锁限制。更多线程会把压力集中到同一个受限位置,并增加超时与排队。应先区分等待 CPU、等待输入输出和等待共享资源,再决定是增加执行者、缩小同步范围,还是限制进入系统的工作量。
“线程退出等于整个进程退出”同样不准确。正常结束某条工作线程,可以只终止该执行过程;终止整个进程则会影响它包含的全部线程。具体语言的未处理异常或某些严重错误也可能触发进程结束。设计故障边界时,需要查明运行时行为,而不能只依据函数名称中的“线程”或“任务”来推断隔离程度。
用一组数量把误区钉死。假设某 API 进程默认给每条工作线程保留 1 MiB 栈,并把 200 条线程配成“一连接一线程”。仅栈保留就约 200 MiB,尚未计入堆上的连接缓冲、解析对象与锁元数据;若每条连接再持有 64 KiB 读缓冲,合计又约 12.5 MiB。把线程数从 50 加到 200,可同时等待的连接变多,但若下游数据库只有 40 个会话额度,多出来的 160 条线程只会在同一把连接锁或同一会话池上排队,p99 延迟上升而 CPU 仍可能低于 20%。更稳妥的对照是:固定 8 条工作线程 + 非阻塞接入,用队列长度与超时拒绝过载,并分别记录 CPU、锁等待与下游会话占用,再决定是否增加执行者。
案例:用工作线程处理 Web 请求
考虑某个 Web worker 进程。这里的 worker 指承担请求处理工作的服务进程,其中有负责接收与分发请求的执行逻辑,以及数量有限的工作线程。请求进入有界队列,空闲线程取出请求并处理。这个结构把接收请求与实际执行连接起来,同时允许明确限制排队和执行规模。
若每次请求主要进行压缩、解析或计算,工作属于 CPU 密集型。假设服务可使用 4 个逻辑 CPU,增加线程最初可能让空闲 CPU 得到利用;当可并行工作已占满这些 CPU 后,继续增加可运行线程通常只会增加轮流执行、同步和管理成本。运行时限制、串行代码及其他系统活动,都可能让收益在更早的时候趋缓。
若请求主要等待网络或存储,工作更接近输入输出密集型。某条线程等待时,其他线程可以继续执行,因此适当增加工作线程可能提高吞吐量。收益的前提是下游有余量、请求能够独立推进,并且内存与连接等资源允许。等待比例发生变化后,原先合适的线程数也可能变得不合适。
这个交互使用教学模型展示线程数、等待比例和管理开销之间的关系。模型将工作平均化,曲线表达“先利用空闲执行能力、再受到上限和开销限制”的趋势,不给出生产环境的最佳线程数。真实服务需要按请求类型、下游延迟和运行时行为测量,尤其要观察较慢请求的响应时间。

线程池还需要队列上限。若服务每秒到达的工作长期多于能够完成的工作,积压就会持续增长;增加等待队列只能推迟问题暴露。队列中的请求也占内存,并且可能在真正开始执行前就耗尽可接受的等待时间。因此,入口可以按明确规则限流、拒绝或超时,工作线程也应在取消后停止不再需要的处理。
例如,工作线程有 8 条,而下游只允许同时执行 4 次访问时,必须明确额外请求在哪里等待。若每条线程都先持有内存缓冲,再等待连接,内存占用会随着等待者增多。把连接额度、线程池上限、队列容量和超时一起检查,才能解释为什么线程已经很多,吞吐量仍然不变。
可以在相同负载下比较若干并发设置,记录完成请求数、响应时间分布、CPU 使用率、内存峰值和下游等待。只有在这些指标共同改善时,增加并发才有明确收益。若吞吐量基本不变而慢请求继续变慢,应回到受限资源与排队位置寻找原因。这里的分析重点是建立因果关系,具体调度策略留给 S3。
放进方案里
选型依据应落到工作特征。方案需要说明主要工作是计算还是等待,服务可使用多少 CPU,下游允许多少并发访问,以及运行时能否并行执行目标代码。线程池大小只是其中的参数,还应同时给出在途请求上限、队列容量、连接额度和超时。每个上限都应标注作用位置,避免入口无限接收,而工作线程或下游已经饱和。
非功能需求即 NFR,应使用可验证的表述。性能部分约定负载类型、持续时间、吞吐量和响应时间分位数;资源部分约定 CPU 配额、内存峰值及进程和线程数量上限;隔离部分约定某个工作进程异常退出时允许影响的请求范围,以及恢复时间目标。这些数值需要来自业务目标与测量,不能直接采用教学交互中的曲线。
运行管理还要写清由谁启动和回收子进程、如何记录退出原因、停止服务时怎样处理在途请求。多进程方案需说明共享状态存放位置与一致性要求,多线程方案需说明阻塞调用是否占住关键执行线程或持有公共锁。对外部写入还应明确重试与重复执行的处理规则,避免把进程重启误认为业务自动恢复。
验收时,在约定负载下保留进程树、线程数量、CPU 使用、内存和队列等待记录,并覆盖下游变慢与工作进程退出的情形。若增加并发后吞吐量不再上升、响应时间却变差,应调整资源或入口上限,再重新测量。最终交付的并发模型应能解释请求在哪里执行、在哪里等待,以及资源耗尽时系统如何表现。

小结:把执行、等待和隔离连起来
程序是执行内容的描述,进程是某次执行的资源与地址空间环境,线程则是环境内可以继续推进的执行过程。进程之间通常拥有独立地址空间;同进程线程共享许多资源,同时各自保留栈和寄存器等执行状态。共享让合作更直接,也让数据正确性和故障影响需要更仔细地设计。
运行、就绪与阻塞回答执行活动此刻处在哪里。阻塞等待条件,就绪等待 CPU,运行才正在使用 CPU。上下文切换通过保存和恢复状态,让不同线程轮流使用 CPU,它既有直接开销,也可能影响后续访问效率。系统调用或进入内核,不必然意味着发生线程切换。
使用 ps、top 和 /proc 时,应先确认观察对象是进程还是线程,再确认时间窗口与指标口径。看到睡眠状态、较高线程数或较低 CPU 使用率,只是分析的入口。把观察结果与请求处理路径、锁、队列和下游条件连起来,才能解释系统行为。
后续 S3 将讨论就绪执行者之间如何分配 CPU 使用机会,H10 将说明访问不同存储层次为什么会带来不同代价。阅读本课后,应能用程序、进程和线程的区别描述某个服务,用状态转换解释等待,并把并发数量与隔离边界写进方案。概念依据可参阅 OSTEP 的进程、进程 API、并发与线程相关章节,本文采用概念级说明。
下一课转向 S4 内存管理入门:把本章验收过的结论带进后续主题,写成可检查的方案约束。