2026/10/22 4:51:17

进程与线程全解析:从原理到排障,一篇文章讲透并发基础

进程与线程全解析:从原理到排障,一篇文章讲透并发基础 我到现在都还记得第一次被一个多线程 bug 搞到头秃的那个下午。程序在测试环境跑得好好的一上生产就偶发卡死日志看完一圈最后一行停在某个中间状态重试好几次才过去。后来我把进程和线程这两个概念真正想清楚了才发现那个问题从设计阶段就注定了。这篇文章我想用最直白的方式把操作系统里这两个最基础的执行单元讲透它们到底是什么、有什么区别、日常排查时该怎么用。不管你是刚学编程的学生还是写了两年代码但概念一直靠背的开发者或者经常要跟线上进程打交道的运维、数据分析同学这篇都值得花十分钟认真看一遍。1. 为什么一个普通开发者也要搞懂进程与线程1.1 你其实每天都在和进程线程打交道打开任务管理器或者 Linux 的top你会看到一行行的 PID每个 PID 基本就是一个进程。你自己写的 Python 脚本跑起来是一个进程很多浏览器把每个标签页当成一个独立进程来隔离IDE 里启动的调试服务也是一个进程。进程内部还藏着线程界面要响应点击后台要拉取数据计算任务要并行处理这些事通常由同一个进程里的不同线程协作完成。很多同学觉得“我写代码能跑就行管它什么进程线程”。但真到了线上出问题的时候这个概念差就会变成天壤之别。程序表现为卡死、CPU 飙高、内存一直涨这些现象全部落在进程和线程身上。如果你连自己的程序当前有多少线程、每个线程在等什么都说不清那排查就只能靠瞎猜和重启。猜测式排障偶尔能蒙对但问题会反复出现因为你根本没有看到事情的全貌。所以我的建议是不要把这部分当成面试背诵题而是当成排障基本功。理解进程和线程之后你再看日志、看监控、看线程栈会突然清楚很多。它们不是两个孤立的名词而是观察程序运行状态的两个核心窗口。1.2 用“厨房团队”类比理解核心差异想象一条美食街上有两家餐厅。每家餐厅有自己独立的厨房、自己独立的食材储物间、自己的菜单和员工这家餐厅就是“进程”。餐厅内部有几个厨师主厨在炒菜帮厨在切配甜品师在做甜品。他们共享同一个厨房的灶台、冰箱和食材这些厨师就是“线程”。进程和进程之间食材和厨具是严格隔离的你的餐厅不能用隔壁的鸡蛋线程和线程之间共享同一个后厨资源协作方便但也正因为共享很容易出现互相抢东西、互相等东西的问题。操作系统相当于美食街的管理方负责分配营业时间、后厨空间并且确保一家餐厅着火时尽量不烧到隔壁。这个类比还能解释很多真实场景。比如某个厨师拿着一把不撒手其他厨师只能排队等这是锁竞争主厨等着帮厨递菜帮厨又非要等主厨先签字两个人谁都不退让这就是死锁。你带着这样的画面去看后面的技术内容会比死记概念轻松很多。1.3 概念不清的代价排错只能靠猜我在不同团队里看到过太多次类似的场景线上 CPU 飙到 100%大家第一反应是“上机器看看”。但看什么呢只会看 load看内存然后开始怀疑代码有死循环。实际上很多高 CPU 问题不是进程级别的而是某个线程在疯狂空转。如果你只看进程看到的是一个健康但忙碌的进程只有把视角切到线程才能发现那个线程卡在自旋锁里。同样程序“假死”但 CPU 不高更常见的是锁等待、远程调用没有超时、线程池被占满。这些问题在日志里往往只有一些模糊的迹象真正有用的现场是线程转储也就是把每个线程此刻在干什么打印出来。可如果你对线程这个概念没有感觉根本想不到去抓线程栈。所以我常说进程和线程不只是操作系统的概念它们是你和线上问题之间的望远镜。2. 进程到底长什么样内存结构与生命周期2.1 进程不是“正在运行的程序”那么简单程序是磁盘上的静态文件比如一个可执行文件或者.py脚本进程是操作系统把程序加载到内存之后创建的一个运行单位。程序躺在那儿只是数据进程跑起来才真正占用 CPU、内存、文件句柄这些资源。每个进程都拥有自己的虚拟地址空间。虚拟地址空间通常包含代码段、数据段、堆和栈代码段保存机器指令数据段保存全局变量和静态变量堆用于动态分配内存栈保存函数调用关系和局部变量。操作系统通过内存映射单元MMU把这些虚拟地址映射到真实物理内存同时把不同进程的地址空间隔开。这就是进程隔离的基础某个进程写坏了它自己的内存通常不会直接污染另一个进程的数据。在内核里每个进程还有一个对应的进程控制块不同操作系统叫法不同Linux 里常叫task_struct。里面记录着 PID、PPID、运行状态、打开的文件描述符、寄存器上下文、调度优先级等等。你在用户态用ps看到的信息只是内核数据结构上露出来的冰山一角。记住一句话进程是系统资源分配的基本单位它承载了内存、文件、网络连接这些资源。2.2 一个程序如何变成进程进程的诞生可以粗略分成几步我们在终端敲下一条命令shell 调用系统创建进程的接口内核分配一个 PID 和进程控制块完成地址空间初始化随后把可执行文件的内容映射进内存接着设置好栈和入口地址跳转到程序入口函数。对 C 程序来说这个入口通常是main但实际在main之前运行库还会做一堆初始化工作。这里值得展开的是两个经典系统调用fork和exec。fork会复制出一个和当前进程几乎一样的子进程子进程从 fork 返回处继续执行exec则会用新的可执行文件替换当前进程的代码和数据。现代操作系统常用写时复制Copy-On-Write优化fork子进程一开始和父进程共享同一份物理内存只在需要写入时才真正复制页面所以“克隆”一个进程的开销远小于完全复制全部内存。整理时间线就是程序 → 内核创建进程控制块并分配地址空间 → 加载代码和数据 → 进程运行 → 退出或被杀掉 → 系统回收资源。进程结束之后如果父进程没有调用回收函数接走子进程的退出状态子进程会短暂变成僵尸进程如果父进程先结束子进程会被某个收养进程接管变成孤儿进程。这些状态看着绕但在排障时经常遇到值得记一下。2.3 进程状态与排障场景进程不是只有“运行”和“结束”两个状态。Linux 的ps输出里状态字段通常是一个字母R表示正在运行或处在可运行队列S表示可中断睡眠D表示不可中断睡眠Z表示僵尸进程T表示停止。这些字母和真实问题有很强关联。举个例子很多数据库进程大量处于D状态往往意味着磁盘子系统响应不过来进程卡在内核 IO 等待中连信号都杀不掉。看到Z状态说明有子进程退出但没有被父进程回收通常是父进程有问题。而 Windows 任务管理器里的“未响应”往往就是进程里负责消息循环的线程被阻塞住了但进程本身还活着。学会看状态比盲目重启有价值得多。2.4 用命令查看进程的日常操作排查进程最基础的就是查 PID、PPID、CPU、内存和命令行。Linux 上我常用这样几条ps -ef top -p PID pidstat -p PID 1 lsof -p PIDps -ef看进程全量信息top -p PID盯住某个进程的资源占用pidstat看连续采样lsof看这个进程打开了哪些文件、Socket。Windows 上也可以用tasklist或 PowerShell 的Get-Process查看进程基本信息。真实场景里有一次我收到告警说某台机器 CPU 持续很高登录后用top看到一个进程占 300% CPU再通过ps -fp PID看命令行发现是一个之前部署的定时任务脚本没有按预期退出和业务主进程完全无关。如果只看业务进程这个问题就会很难定位。3. 线程是进程里的“轻量级分身”创建与共享逻辑3.1 线程共享什么、独享什么线程是进程内部的执行路径。同一个进程下的线程共享代码段、堆、全局变量、打开的文件描述符和信号处理器但每个线程有自己独立的栈、寄存器上下文和线程局部存储。之所以说线程“轻”是因为创建线程不需要重新分配完整地址空间只需要给新线程准备一块栈和内核里的线程控制块即可。这个共享关系非常关键。共享堆意味着两个线程可以直接读写同一个对象协作高效但共享也意味着你要处理并发访问问题。如果两个线程同时修改一个全局变量可能互相覆盖对方的修改。换个角度理解进程像一栋楼线程像楼里各户人家楼里的水电暖是共享的但每户自己装修的隔断是独立的。共享很方便但也要约定好规则否则楼上楼下会因为用水高峰打架。3.2 三种语言下新建线程的直观例子动手写一下会非常直观。Python 里最简单的写法import threading def work(): print(线程运行中) t threading.Thread(targetwork) t.start() t.join()Java 里更常见的写法Thread t new Thread(() - System.out.println(线程运行中)); t.start();C 用标准线程库#include iostream #include thread void work() { std::cout 线程运行中 std::endl; } int main() { std::thread t(work); t.join(); return 0; }这段例子背后要留个心眼。Python 的线程受全局解释器锁GIL影响在纯 CPU 计算场景里多个线程并不能真正同时使用多个核心Java 的线程通常会映射到操作系统线程能利用多核并行C 的线程离系统更近但生命周期管理必须自己负责join用来等待线程结束detach则是让线程在后台运行后者用不好很容易悬空访问。语言运行时的差异会在选型那一章进一步放大。3.3 线程状态与上下文切换代价线程同样有新建、就绪、运行、阻塞、终止等状态。Java 里用jstack能看到线程处于RUNNABLE、BLOCKED、WAITING、TIMED_WAITINGLinux 的线程本质上也被内核当作轻量级进程来调度。阻塞的线程不消耗 CPU但占着内存和内核资源。上下文切换是指操作系统把 CPU 从一个执行流切换到另一个执行流它需要保存当前线程的寄存器状态恢复下一个线程的状态。线程切换比进程切换轻因为线程之间共享地址空间不需要切换页表但线程切换同样不是零成本栈、寄存器、缓存这些都要处理。一个进程里创建几千个线程哪怕大部分都在休眠光是每个线程默认分配的栈空间就可能消耗巨大虚拟内存。更常见的问题是线程数量过多导致调度抖动吞吐反而下降。3.4 进程线程对比表整理一张表日常决策可以直接参考。对比维度进程线程资源隔离强独立地址空间弱共享进程内内存创建开销较大需要分配地址空间较小只需要分配栈和线程结构通信难易复杂依赖 IPC方便直接共享内存但要加锁崩溃影响子进程崩溃一般不拖垮其他进程一个线程访问非法内存可能导致整个进程崩溃调度单位线程是 CPU 调度基本单位进程是资源分配单位同左适用场景需要稳定隔离、模块独立进程内高频协作、需要共享状态这张表不是绝对的但它能帮你快速判断某个架构大概付出什么代价。很多新人以为“线程比进程高级”其实两者只是针对不同问题的解法没有绝对的优劣。4. 并发正在搞乱共享数据线程安全与资源竞争4.1 一行 count 1 是怎么丢数据的并发编程里最常见的坑就是多个线程同时修改同一个变量。很多人不理解count 1不就是一行代码吗怎么还会出问题实际上在 CPU 层面这条语句至少拆成三步读取count到寄存器、寄存器加一、把结果写回内存。两个线程完全可能交错执行线程 A 读到了 count5还没写回线程 B 也读到了 count5然后把它改成 6线程 A 接着把自己手里的 6 写回。最终结果还是 6实际上做了两次加一却只增加了一次。这不是理论推演。我做过一个简单实验两个 Python 线程同时对一个全局变量自增一百万次最后结果很多时候不是两百万而是几十万到一百多万之间浮动。原因就是读取和写回被打断。你可以自己在本地跑一下看到结果的那一刻才能真正理解“竞态条件”四个字到底有多离谱。这个问题在单线程程序里完全不会出现所以默认单线程思维的人很容易踩进去。4.2 加锁与原子操作保住正确性的三条路解决并发修改的思路大致有三条。第一条是加互斥锁进入临界区前加锁离开临界区时解锁保证同一时刻只有一个线程能执行这段对共享变量的操作。Python 里可以写成import threading lock threading.Lock() count 0 def increment(): global count for _ in range(1_000_000): with lock: count 1加锁之后原本三步操作变成一个整体线程 B 必须等线程 A 释放锁才能进入。代价是牺牲并发度而且锁外等待会带来阻塞。只要两个线程都抢同一把锁临界区越大并发收益越小。第二条是用原子操作借助硬件提供的 CAS比较并交换或者原子整数类型让“读改写”在硬件层面不可分割。原子操作通常比加锁轻因为不需要睡眠和唤醒线程但也只能解决单变量更新问题复杂操作组合依然需要锁。第三条更省心的思路是尽量避免共享。让线程各自处理自己的局部数据最后合并结果或者在线程之间传递不可变对象、用消息队列做通信。这条很容易被忽略但往往是性能和正确性最好的选择。能设计成无共享就别为了“炫技”去搞复杂锁。4.3 死锁现场必要条件与排查工具加锁之后如果设计得不仔细又会遇到新的问题死锁。形成死锁通常需要四个必要条件互斥、持有并等待、不可剥夺、循环等待。翻译成人话就是每个线程都占着一把锁不释放同时等着对方手里的锁最后谁也没法往前走。我遇到过一个很经典的转账场景。两个线程分别执行 A 转给 B 和 B 转给 A。程序为了安全每次转账都先锁转出账户再锁转入账户。两个线程一个先锁了 A、想锁 B另一个先锁了 B、想锁 A结果就在那儿互相僵持。表面现象是业务请求全部超时但进程的 CPU 不高日志里也没有异常堆栈。排查方法不是靠猜而是抓线程栈。Java 服务可以jstack 进程号输出里不仅能看每个线程的状态还能直接看到Found one Java-level deadlock这样的提示C 程序可以用调试器 attach 后用thread apply all bt列出所有线程栈Python 程序可以用py-spy dump --pid 进程号查看线程正在执行的代码。看到两个线程互相等待锁问题就锁定了。修复方式是统一账户加锁顺序或者给锁加超时获取Java 里可以用tryLock拿不到就快速失败再重试。死锁没有银弹最好的办法是在设计上收敛锁的顺序和锁的使用范围。4.4 进程通信和线程通信的思路差异进程间通信常见方式有管道、共享内存、消息队列、Unix 域套接字、网络套接字。因为进程地址空间是隔离的数据通常要通过内核拷贝或者显式协议传递成本和复杂度都比线程共享内存高很多。但好处是数据不会在毫无防备的情况下被另一个进程改坏。线程通信则简单粗暴直接读共享变量配一把锁就行。再进阶一点可以用条件变量做等待/通知用信号量控制数量用有界阻塞队列做生产消费模型。在工程上我倾向于用队列把生产者线程和消费者线程解耦而不是让一堆线程去抢同一份复杂数据结构。这个习惯帮我规避了很多锁设计和死锁问题。总而言之能少共享就少共享能靠消息传递就靠消息传递这是并发代码稳定性的核心心法。5. 实战视角一个多线程程序“卡死”的完整排查链路5.1 现象与初判CPU不高但任务不回应有一天我负责的后端服务突然不再响应新请求。收到告警后我按固定套路处理先登录服务器用top看进程发现业务进程还活着CPU 占用很低内存也没有明显上涨。这种情况下基本可以排除“死循环空转”和“内存被打爆”。再看磁盘和网络 IO磁盘正常网络连接数有点堆积但没有明显异常。接下来就该把视角从进程切到线程。我执行了pidstat -p 进程ID -t 1 3这条命令会按线程分组显示 CPU 使用情况。结果发现大部分业务线程长期处于空闲或等待状态而线程池里的 worker 数量没有回到池的大小说明有多个任务被占住不释放。CPU 不高但请求卡住最常见的解释就是锁等待、远程调用没有设置超时、或者线程池被耗尽了而不是程序真的在“拼命干活”。5.2 抓取线程转储复现等待关系为了看现场我用线程转储工具把那一刻所有线程的状态打了出来。简化后的关系大概是这样的主线程卡在一个队列的take()上等待一个工作线程往队列里放结果那个工作线程却一直阻塞在一个外部接口调用上而那次调用的超时时间被设置成了无限等待持有某个业务锁的线程又在等待另一个永远无法完成的任务。几个条件叠在一起整个服务就进入了“看着活着实际停摆”的状态。这个排查过程里最重要的一步是复现。光看一次线程栈还不够我又在压测环境下用并发请求持续触发了十几分钟每隔一段时间采集一次线程栈。反复出现的等待关系就是实锤。这里有个经验给线程起个有业务含义的名字比如order-worker-3、rpc-call-5对快速识别线程角色帮助巨大。很多团队嫌麻烦不起线程名排障时面对一屏Thread-42全靠猜。5.3 修复、验证与复盘定位到根因后修复动作很明确所有外部网络调用必须设置超时持锁范围内的串行耗时操作被移出锁区队列消费采用超时等待而不是永久等待锁的获取也加了超时上限拿不到就快速失败并记录上下文。改动不算大但效果很直接。验证阶段我先在小流量环境灰度再在压测环境用之前能稳定复现问题的那批并发用例继续压。观察指标包括线程池活跃线程数、锁等待次数、单次请求耗时、错误率。连续跑几轮没有复现再逐步放量。事后复盘时我们意识到这次问题的核心不是“线程不好”而是“把锁和远程 IO 耦合在了一起”。从此之后我做代码评审时一定会额外注意锁内不要有网络调用锁内不要有大计算锁的持有时间尽量短。这个原则帮我又躲开了好几个潜在事故。6. 什么场景选进程什么场景选线程6.1 稳定性与隔离需求优先选进程如果你的团队里有一个模块稳定性很可疑比如要做第三方的动态库调用、要解析不可信输入、或者模块崩溃后不希望影响主流程那选择独立进程更稳妥。线程共享进程的地址空间一个线程写出越界地址操作系统通常会杀掉整个进程殃及池鱼。进程之间则有天然隔离子进程崩溃父进程可以感知到并重启它其他服务照常运行。所以很多现代浏览器宁可给每个标签页都分配一个进程也不愿意所有标签页共享一个线程模型。代价很明显进程数量多了以后内存开销大进程间通信也要走额外机制比如用消息队列或共享内存来交换数据。可一旦你经历过“一个插件崩溃导致整个应用退出”的场面就会觉得这些代价值得。6.2 IO密集与CPU密集的经典选型选型不能只看术语还要看真实计算特征。CPU 密集型任务是持续进行数学计算目标是利用多核并行Java 和 C 的多线程能有效利用多个核心Python 因为 GIL纯线程方案在多核并行上很吃亏常被换成多进程方案让每个进程跑在不同核心上。IO 密集型任务刚好相反程序大量时间在等待网络响应、磁盘读写、数据库返回CPU 大部分时候是闲着的。这时用多线程或者异步协程都能提高吞吐因为等待期间 CPU 可以切去处理其他任务。协程是比线程更轻的并发单位通常运行在少量线程之上由运行时自己调度任务切换线程数量可以大大减少连接数高、内存受限的场景优势很明显。真实项目里不要只看“线程 vs 进程”还要看语言生态给你提供了什么。同样是并发Java 的高并发服务常用线程池和虚拟线程Go 程序员主要用 goroutine 配合 channelNode 则常见单线程 异步 IO。理解底层进程线程模型之后你再看这些框架才能知其然也知其所以然。6.3 常见混合架构与决策表多数中大型系统不会只用一种模型而是“进程 线程”混合。外层用多个独立进程做故障隔离比如网关进程、业务进程、缓存填充进程彼此分开每个进程内部再用线程池处理并发请求不同进程之间通过消息队列、共享缓存或 RPC 传递数据。我给你一张我常用的快速决策参考表需求特征推荐模型原因模块易崩溃、需要隔离多进程崩溃不扩散便于重启大量共享状态、低延迟协作多线程 并发队列/锁通信开销低但要小心并发问题高并发网络 IO多线程/协程 异步 IO等待时不占 CPU吞吐更高纯 CPU 并行计算多进程或真正的并行线程绕开单线程解释器限制利用多核资源受限、需要大量轻量任务协程/虚拟线程比线程更省内存和调度开销这个表不适合无脑套用但它能帮你把需求特征和模型对应起来。选型不是越底层越厉害适合当前团队维护水平和业务特性的方案才是好方案。6.4 自己动手验证概念的小实验纸上谈兵再多不如动手验证几个现象。我建议你花半小时做三个小实验第一启动一个最简单的进程用top记录它的线程数然后在代码里循环创建大量线程观察内存和 CPU 的变化你会直观感受到线程不是没有成本的。第二让两个线程同时对同一个整数自增一百万次分别记录加锁和不加锁的结果数据到你眼前时比任何教科书都管用。第三故意写一个死锁程序然后抓线程转储亲眼看一眼 BUG 现场长什么样。做完这三个实验之后你再看进程线程相关的文档会发现每一句话都对应着你刚才见过的现象。很多概念不是难而是缺少一个“哦原来如此”的触发点。最后说一个很实在的操作体会。我现在排查问题第一件事永远是打开线程列表而不是盯着错误日志猜。理解了进程是资源容器、线程是执行路径之后那些所谓“诡异”问题都会变得有迹可循。启动程序之后花点时间用top和线程转储看看你的代码正在干什么比背多少面试题都管用。