尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

进程与线程的区别:从虚拟地址空间、线程同步到线程池与死锁排查

进程与线程的区别:从虚拟地址空间、线程同步到线程池与死锁排查 1. 从U盘怎么都弹不出来讲起进程到底是个什么东西你肯定遇到过这种事手贱点了右下角的安全弹出硬件结果系统甩给你一句该设备正在使用中请先关闭占用该设备的程序。你盯着屏幕明明浏览器关了、音乐播放器也关了可就是弹不出来。最后只能硬拔心里还犯嘀咕会不会把数据搞坏。这个场景几乎把进程这个概念最朴素的一面摆到了台面上在你看到的窗口背后有一堆肉眼看不见的东西正攥着U盘的文件句柄不放。它们不一定是程序但它们一定是进程。任务管理器里那一长串滚动的条目就是它们的工作证。搞懂进程和线程很多时候不是为了写代码而是为了在系统跟你耍脾气的时候你知道该从哪下手。这一节我们先把进程这个地基夯瓷实。我尽量不用教科书那套进程是程序的一次执行糊弄过去因为这个定义你听十遍也还是不知道它跟你想解决的问题有什么关系。我们从任务管理器里真实能看到的东西往回拆。1.1 任务管理器那一列滚动的东西每一项代表什么Windows的任务管理器、Linux的ps或top、macOS的活动监视器它们列出来的每一行本质上是内核维护的一个数据结构操作系统教材里管它叫进程控制块PCBProcess Control Block。你可以把它想象成一张人事档案这个进程叫什么名字、进程号PID是多少、它占了多少内存、开了几个线程、当前是什么状态、它的父进程是谁、它打开了哪些文件——全记在这张档案里。所以你看到的一行不是那个exe文件本身而是这份档案的一个视图。同一个程序可以对应好几行因为它们是被启动了多次各自有各自的档案。举个特别能说明问题的例子你打开某个基于Chromium内核的浏览器任务管理器里会冒出十几个甚至几十个进程条目名字都差不多。很多人第一次看到会吓一跳以为中毒了。其实这是浏览器刻意的架构设计——把不同的页面、不同的插件隔离到不同的进程里一个标签页崩了不会连累整个浏览器。这就是典型的用进程做隔离的思路后面讲通信和健壮性的时候我们还会绕回来。顺带说一个高频疑问空进程是什么你在某些系统监控工具里会看到N/A或者内存占用极低的条目网络上一搜有人叫它空进程。严格讲内核里并没有一个叫空进程的正式概念那通常是指只剩一个空壳、核心工作线程都已经退出或者还没启动起来的进程也可能是内核线程在某些监控工具下的显示形式内核线程没有独立的用户态地址空间所以在ps里看起来是空的。别被这个词唬住它不是什么神秘的后门。1.2 为什么每个进程都以为自己独占整台机器这是理解进程最关键的一步也是很多人学了半天没开窍的地方。每个进程都活在一个属于自己的虚拟地址空间里。它在代码里打印一个变量的地址比如0x7ffd1234另一个进程打印同一个地址两者都觉得自己在用这个地址但它们实际上映射到物理内存里完全不同的位置。这层翻译由CPU里的内存管理单元MMU和操作系统维护的页表共同完成。好处有两个而且都非常实在隔离。你的程序里有个野指针越界写到别的地方去了撑死把自己这个进程搞崩污染不了隔壁那个进程的内存。这是现代操作系统稳定性的根基。简化编程。写代码的时候你不用去操心物理内存到底还剩哪块闲着链接器、加载器都按统一的地址约定来处理程序能跑起来的地方就多得多。代价也不是没有。这套地址翻译本身有开销所以CPU里有个叫TLB的高速缓存专门存最近用过的地址映射。一旦发生进程切换页表换了TLB里缓存的映射大部分就失效了得重新填——这就是进程切换比线程切换贵的一个核心原因我在第2节会详细展开。注意虚拟地址空间不等于内存不够就自动上磁盘这么简单。当物理内存吃紧操作系统会挑一些不常用的页写到交换区Linux的swap、Windows的页面文件里腾地方等你再用到时再换回来。所以你在监控里看到某个进程内存占用很高可能一部分其实躺在磁盘上真正驻留在物理内存里的量常叫RSSResident Set Size才是关键指标。1.3 进程活着的时候它到底在哪些状态之间来回跳内核给进程定义了几个基本状态这套模型看着抽象但排查问题的时候极其好用状态含义常见表现新建进程刚被创建资源还在分配一闪而过很少观察到就绪万事俱备就差一个CPU时间片监控里显示为可运行运行正在CPU上执行单个核心同一时刻只有一个进程在跑阻塞在等某个事件IO、锁、信号不占CPU但占着内存和其他资源终止执行完毕或被杀掉资源待回收短暂存在这里有个特别容易被忽略的点阻塞状态不消耗CPU但照样占着内存和文件句柄。回到开头那个U盘弹不出来的问题——占着U盘的那个进程很可能正处在阻塞状态它在等一个永远等不来的读操作完成或者干脆是代码写错了没释放句柄。是你肉眼看不见它的活跃但它对你的U盘的影响实打实地存在。再补一个新手常踩的坑僵尸进程。子进程已经终止了但父进程还没去收尸在Linux里就是还没调用wait读走退出状态此时这个进程就卡在终止状态资源大部分已经释放但在进程表里还占着一个位置。偶尔几个无所谓如果持续增长把进程号用光了系统就没法创建新进程了。反过来如果父进程先走一步子进程会被过继给系统的init进程PID为1接手。这两套机制合起来就是Linux进程管理里最基本的一课很多运维面试就爱在这上面挖坑。2. 线程不是小一号的进程它和进程的边界到底画在哪如果你只记住一句话我希望是这句进程是资源分配的基本单位线程是CPU调度的基本单位。这句话背下来容易但真正理解它得看一个进程内部到底是几家欢喜几家愁。一个进程刚创建时里面其实已经有一个线程了通常叫主线程。程序里main函数跑的就是它。这个主线程如果又fork或者spawn出几个执行流那这些执行流就是同一进程内的其他线程。它们共享的东西比你想的多得多私有的东西又少得让你意外。2.1 一个进程里共享和私有的清单先给你一张对照表这个表我建议直接背下来面试、排查、写代码都用得上资源类型进程内多线程之间堆内存new/malloc出来的共享全局变量、静态变量共享打开的文件描述符、socket共享代码段也就是那些指令共享进程ID、工作目录、信号处理器共享栈局部变量、函数调用链私有寄存器、程序计数器PC私有线程本地存储TLS私有看懂这张表很多诡异的bug就说得通了。你在线程A里new了一个对象把指针通过一个全局变量丢给线程B线程B能直接用——因为堆是共享的。但如果你在线程A的栈上定义了一个局部变量然后把它的地址传出去给线程B用等函数返回后那个栈帧就失效了线程B读到的是垃圾数据甚至直接段错误。栈上的东西不能跨线程传是血泪教训级的规则。私有的栈这件事还有一层现实意义每个线程都要占一块栈空间默认大小因系统而异Linux常见8MBWindows常见1MB。你开几千个线程光栈就把虚拟地址空间吃得差不多了。所以指望多开线程解决一切是行不通的这也是后来线程池思路能流行的底层原因之一。2.2 线程切换为什么比进程切换便宜现在回答上一节埋的伏笔。一次上下文切换说白了就是保存当前执行流的现场、加载下一个执行流的现场。切进程的时候你要换的东西包括寄存器、程序计数器、栈指针、页表地址空间换了、还有各种内核里记着的资源状态。页表一换TLB大面积失效接下来一段时间CPU访问内存全靠页表慢慢查性能掉得肉眼可见。切线程就轻多了。同一进程内的线程共享地址空间页表不用换TLB基本不受影响只需要保存和恢复寄存器、PC、栈指针这几样再加上线程控制块里那些记录。所以线程切换的开销通常比进程切换小一个数量级。但这不代表线程切换不要钱。在超高并发的场景里光切换就能把CPU吃干。我见过一个项目一开始图省事每来一个请求就new一个线程压测到几千并发的时候CPU监控里sys内核态占比飙升真正的业务逻辑反倒没跑多少——时间全耗在创建、销毁和调度这些线程上了。后来换成固定大小的线程池同样的机器吞吐直接翻了好几倍。这个坑我后面会专门用一节讲清楚线程池到底是怎么救场的。2.3 一个真实的对照同样一个下载任务进程和线程的写法差在哪假设你要同时下载10个文件。用多进程你可以起10个进程各下一个某个进程崩了不影响其他用多线程你在一个进程里起10个线程共享一个已经建好的连接池、共享缓存、共享进度统计变量。多线程版本的代码写起来更顺手因为数据直接共享。但共享这东西是把双刃剑你省下了跨进程传递数据的麻烦同时也就把自己暴露在了竞态条件的风险里。两个线程同时往一个计数器上加1如果不加保护结果可能就不是你期望的那个数。这就是为什么线程一多就必须谈同步——这也是第4节的重头戏。3. 把进程和线程的区别讲透一张表加几个反常识的结论网上讲区别的文章一抓一大把但很多都停在进程开销大线程开销小这个层面实用性有限。我想换几个角度顺便纠正几个流传很广但不完全对的结论。3.1 四个维度上它们到底怎么分工维度进程线程资源归属拥有独立的地址空间、文件资源共享所属进程的资源创建/销毁开销大要建地址空间、页表小共享地址空间切换开销大要换页表、刷TLB小只换寄存器和栈通信方式需要借助内核提供的IPC机制直接读写共享内存隔离性/健壮性强一个崩了不连累别人弱一个线程里野指针能带崩整个进程适用场景需要隔离、需要稳定性、CPU密集且独立IO密集、任务间数据耦合紧、追求低开销把这张表吃掉你基本能应对八成的选型讨论了。但下面这三条反常识的结论才是让理解上一个台阶的东西。3.2 反常识一多线程不一定比单线程快很多人有个朴素直觉——线程越多同时干的活越多肯定越快。错得离谱。如果你的任务是纯计算比如算一个巨大的矩阵乘法单核机器上开4个线程和开1个线程的总计算量一样反而多了调度和同步的开销。只有当任务里存在大量等待等IO、等网络、等锁的时候多线程的价值才体现出来——一个线程在等待时CPU可以让另一个线程顶上把空闲的时间利用起来。判断标准很简单问问自己这个任务卡在哪。卡在CPU上计算密集那就开和核数差不多的线程多了纯属添乱卡在等待上IO密集线程数可以适当放大甚至几十上百也未必过分。说到这不得不提Python的全局解释器锁GIL。在标准的CPython实现里同一时刻只有一个线程能执行Python字节码也就是说你写多线程做CPU密集计算基本享受不到多核并行甚至因为争抢GIL反而更慢。这时候正确的选择是用多进程让每个进程各持一把解释器绕开GIL的限制。这也是多进程不是多线程的过时替代品这个认知的由来——它们解决的压根不是同一类问题。3.3 反常识二进程更安全是有前提的分隔性让进程更抗崩溃这话没错。但你以为进程崩了就真的什么都不影响吗如果两个进程靠共享内存通信一个进程写坏了共享区域另一个照样读到脏数据隔离性在这一块就漏了。更重要的是进程之间往往通过文件、数据库、socket产生耦合一个进程占着某个文件不放另一个进程就得干等——就像开头那个U盘问题一样。所以进程隔离性好要建立一个前提它们之间不共享可变状态。一旦引入共享就得把并发控制那套东西重新请回来只是这次跨越的是进程边界。3.4 反常识三进程和线程的区别在有些环境里根本不成立在某些语言和运行环境里你写的所谓线程根本就不是操作系统意义上的线程而是用户态调度的协程或绿色线程。比如Go里的goroutine你开十万个都面不改色因为它们是运行在少量操作系统线程之上的用户态调度单位切换在用户态完成代价极低。再比如前端的异步模型本质上是单线程事件循环压根没有并行线程这东西。所以你以后看到线程这个词先问一句它指的是操作系统内核调度的线程还是语言运行时自己调度的东西这个区分能省下你无数个调不明白bug的夜晚。4. 进程通信与线程同步数据是怎么在边界之间安全流动的到目前为止进程和线程还是两个各自独立的执行流。可现实中它们总得说上话。跨进程怎么传数据同进程里多个线程怎么不打架这两件事分别对应进程通信IPC和线程同步。4.1 进程通信为什么共享内存最快却最不省心进程之间由于地址空间隔离没法直接读写对方内存所以内核得提供一套交通规则这就是IPC机制。常见的几种各有各的脾气管道pipe最古老也最简单适合具有亲缘关系的进程之间单向传数据。你在命令行里用的那个|就是管道。缺点是半双工一个管道通常只能单向流。命名管道FIFO给管道起了个名字放在文件系统里没有亲缘关系的进程也能用。消息队列内核维护的一个链表进程往里投消息、取消息。有边界、可持久化但每次收发都要陷入内核拷贝数据。共享内存把同一块物理内存映射到多个进程的地址空间里读写像访问本地内存一样快。这是所有IPC里性能最高的因为它省掉了内核态和用户态之间的数据拷贝。信号signal不算传数据主要用来通知出事了。异步、粗暴适合事件通知不适合大数据。socket本机的两个进程也能用socket通信甚至用Unix域套接字比网络socket更快。关键点来了共享内存快是因为它跳过了内核但它跳过的恰恰是内核提供的保护。两个进程同时往同一块内存写谁先谁后完全没保证。所以共享内存必须搭配信号量或者文件锁一起用快的代价就是同步得你自己扛。4.2 线程同步互斥锁、信号量、条件变量各管什么同一进程里的线程直接共享内存方便是方便但并发访问同一份可变数据就是一切线程bug的源头。这一节把这几个工具的定位理清楚互斥锁mutex最常用。一个时刻只允许一个线程进入临界区。加锁、操作、解锁三段式。核心不是锁住数据而是锁住访问数据的代码路径。读写锁rwlock读多写少的场景下比普通互斥锁更高效——允许多个读者同时读但写者独占。代价是写操作可能被饿死如果读的人源源不断。信号量semaphore维护一个计数可以控制同时最多N个线程访问。互斥锁本质上就是计数为1的信号量。它更适合表达资源池里有几个可用资源这种语义。条件变量condition variable自己不保护数据配合互斥锁用解决等某个条件成立再继续的问题。经典的生产者-消费者模型里队列空了消费者就挂在条件变量上等生产者往队列里放东西时唤醒它。为什么不用轮询因为轮询白白烧CPU。原子操作对于简单的计数、标志位直接用CPU提供的原子指令比如CAS比加锁轻得多。但原子操作能表达的语义很有限复杂的复合操作还是得靠锁。我在实际项目里最常见的翻车方式是加锁范围太大。把一整个耗时操作都塞进锁里结果是所有线程排队等着多线程退化成单线程串行性能还不如不加。正确做法是只锁住真正访问共享数据那几行把耗时计算挪到锁外。这个尺度感是踩过坑才能练出来的。4.3 线程死锁它为什么发生怎么绕过死锁是并发里最经典的灾难。教科书会告诉你它有四个必要条件互斥、持有并等待、不可剥夺、循环等待。四个同时满足才可能死锁所以破局思路就是从这四个里挑一个破坏掉。最常见的死锁长这样线程A拿着锁1去要锁2线程B拿着锁2去要锁1两人谁都不肯先松手永远僵在那里。破法也很直接统一加锁顺序。所有线程都按同一个顺序比如按锁对象的地址大小去获取多把锁循环等待就凑不出来了。这是最实用的手段。设置超时。获取锁的时候给个超时拿不到就放弃并释放已持有的锁退回去重试。减少嵌套。能一把锁搞定就别叠两把。排查线上死锁的时候Java可以用jstack抓线程转储它甚至会直接告诉你Found one Java-level deadlock并指出是哪两个线程、卡在哪两把锁上。C和Linux下可以用gdb附加到进程上把每个线程的调用栈打出来看谁卡在哪个锁的等待队列里。这类工具的价值在于——死锁发生了不报错、不崩溃只是程序卡住不动了光看日志根本看不出来必须把线程现场挖出来才知道堵在哪。5. 池化思维进程池和线程池到底解决了什么问题第2节提过一个反面案例每来一个任务就新建一个线程压测一上来就崩。这一节我们把池化这个思路讲透因为它是并发编程从能跑到能扛的分水岭。5.1 线程池的核心参数别再照抄别人的配置了线程池说白了就是预先创建一批线程让它们待命任务来了就分配一个去干干完回来继续等。这样省掉了频繁创建销毁线程的开销还能控制并发度防止线程数量失控把系统拖垮。拿Java的ThreadPoolExecutor举例它的核心参数就那几个但每一个背后都有取舍// 一个典型的CPU密集型线程池配置 int cores Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor pool new ThreadPoolExecutor( cores, // 核心线程数常驻不轻易销毁 cores * 2, // 最大线程数队列满了之后能临时扩到多少 60L, TimeUnit.SECONDS, // 空闲线程的存活时间 new ArrayBlockingQueue(200), // 有界队列防止任务无限堆积 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略队列也满了怎么办 );这几个参数怎么配我给几条经过实战检验的判断CPU密集型任务核心线程数设成核数或核数1。多了只会加剧上下文切换没有任何好处。IO密集型任务可以设到核数的几倍甚至十几倍因为线程大部分时间在等待多点线程能把CPU喂饱。具体倍数没有公式得靠压测。别用无界队列比如LinkedBlockingQueue不给容量。它会让最大线程数形同虚设——队列永远装不满永远轮不到扩线程这一步。一旦任务生产速度持续超过消费速度队列会一直涨到OOM。这个坑非常隐蔽很多人配了无界队列压测很久都发现不了直到内存爆掉。5.2 阻塞队列的选择直接决定系统的行为模式线程池里任务排队用哪种队列会影响压力传导的方式很多人没意识到这一点队列类型特点适合场景有界队列ArrayBlockingQueue容量固定满了触发扩容或拒绝需要背压、防止雪崩无界队列LinkedBlockingQueue理论上能一直装任务量可预估、追求平滑同步移交SynchronousQueue不存储任务直接交给线程高吞吐、任务来即处理优先级队列PriorityBlockingQueue按优先级出队任务有轻重缓急之分有界队列 合理的拒绝策略是生产环境的默认推荐。拒绝策略也别乱选AbortPolicy直接抛异常让上游知道压力扛不住了CallerRunsPolicy把任务丢回调用者线程执行相当于给上游限速是一种天然的背压。选哪个取决于你的业务能不能容忍一部分任务被丢弃。5.3 什么时候该用进程池而不是线程池线程池解决的是同一台机器上并发执行任务而进程池解决的是既要并发又要隔离。判断标准有这么几条任务之间需要强隔离一个任务崩了绝不能影响其他任务。比如你跑一批用户提交的、来源不明的计算脚本多进程是底线。要绕开GIL或者语言运行时的全局锁限制。Python里做CPU密集并行多进程几乎是唯一选择。任务本身是独立的、无共享状态的那用进程池反而更简单不用操心中间那些同步问题。单个任务会长时间占用大量CPU用线程池可能因为无法抢占而导致其他任务饿死多次进程各自独立调度更稳。反过来如果任务之间需要频繁共享大量数据用进程池就要付出跨进程序列化/拷贝的代价这时候线程池更划算。核心权衡就一句话隔离性值不值那份数据拷贝的钱。6. 落到排查现场几个和进程线程相关的疑难杂症前面讲的是原理这一节讲我实际遇到过的、最能体现这些知识价值的场景。全是回头看原来如此、但第一次碰到时一头雾水的问题。6.1 文件正被另一个程序占用这类谜题回到开头的U盘。这类占用问题的本质是某个进程持有了这个文件的句柄但它的界面里完全看不到。可能是一个后台服务可能是某个程序崩溃后没来得及清理的残留也可能压根就是个索引器在后台扫描。排查思路是分层递进的先看任务管理器里有没有可疑的常驻进程Windows可以用资源监视器的CPU标签页下的关联的句柄搜索框直接搜盘符或文件名它会列出到底哪个进程攥着句柄Linux下用lsof | grep 文件名或者更现代一点的fuser -v 文件名。找到之后要么优雅地退出那个程序要么如果确认无影响杀掉它。再举个类似的例子虚拟机启动时报另一个程序已锁定该文件一部份。这几乎永远是上一次虚拟机没正常关闭、残留了一个锁文件导致的。这个锁文件就是虚拟机为了防止两个实例同时操作同一份磁盘镜像而设的——本质上和进程锁是同一个思想一份共享资源得有个独占的凭证。找到那个.lck结尾的锁文件删掉问题通常就解决了。理解了这个机制你下次遇到就一点不慌。还有一种情况是文件彻底锁死、进程杀不掉系统提示拒绝访问。这往往涉及权限或者驱动层面的东西普通手段够不着。我的经验是先别硬刚确认那个进程归属哪个软件走它自己的正常退出流程别老想着拿任务管理器一剑封喉——能正常运行的东西就别用暴力的方式干掉它残留状态后面可能给你带来更大的麻烦。6.2 程序卡住不动把线程栈薅出来看看程序没死但也不干活的状态是最难查的。这时候光看CPU占用没用——CPU可能是0因为它压根没在跑也可能是100%因为它在某个死循环里空转。真正的破局点是把每个线程当前停在哪一行挖出来。Java的话jstack pid一条命令把所有线程的调用栈打出来。你要关注的是那些处在WAITING或BLOCKED状态的线程停在哪个方法上。如果一堆线程都卡在同一个锁的获取上那这个锁就是瓶颈甚至是死锁点。jstack还会直接点破Java级别的死锁省了你不少事。C/C或者系统级程序可以用gdb -p pid附加进去然后thread apply all bt把所有线程的栈打出来。再配合pstack能很快看到谁在等、谁在跑。Linux下的/proc/pid/task/目录里每个线程都有一个子目录里面能看到线程的状态、栈、还有它当前运行在哪个CPU上。这里有个特别容易被忽视的点给线程起个有意义的名字。默认情况下线程名就是Thread-1、Thread-2这种出了事你根本对不上是谁。在代码里给每个线程池的线程命名比如order-query-1、order-query-2排查的时候一眼就能定位到是哪块业务出了问题。这是几乎零成本、但能省下大量排查时间的习惯。6.3 Qt这类框架里这个槽函数到底在哪个线程执行这是一个特别有代表性的问题因为它完美整合了本节所有概念。写过Qt的人都问过定时器的槽函数是在子线程里执行的还是主线程答案取决于对象的线程亲和性thread affinity。在Qt里一个QObject归属于哪个线程是在它被创建时决定的而不是在执行它的方法时决定的。信号槽的连接方式也很关键如果是自动连接信号发出线程和接收对象的所属线程相同时是直接调用不同时则变成队列连接槽函数会在接收对象所属的线程的事件循环里执行。所以如果你的定时器对象属于主线程哪怕它是在子线程里被创建的它的槽函数照样在主线程事件循环里跑。反过来如果你想让某个耗时操作真正跑在子线程你得把对象移到子线程moveToThread并确保那个线程有自己跑起来的事件循环。这块的坑在于你以为它跑在子线程其实它跑在主线程结果界面卡了。或者你以为它跑在主线程其实它在子线程结果在里面碰了UI控件程序崩了。记住那条铁律——GUI控件只能在主线程操作然后在心里把对象归谁、信号在哪发、槽在哪收、连接方式是哪种这四件事捋一遍基本就不会错。7. 最后聊几句我自己的体会进程和线程这套东西我第一次学的时候也觉得都是些背了就忘的概念。真正开始懂是在一次次被U盘弹不出、程序无响应、服务莫名卡死这些问题按在地上摩擦之后。你会发现这些看似底层、看似学了用不上的知识其实是你判断问题方向的坐标系看到卡顿你会先问是CPU在算还是在等看到内存涨你会区分是堆泄漏还是栈占用看到崩溃你会想是进程隔离救了你还是线程共享坑了你。如果让我给正在入门的人一个建议我会说别急着背区别。找个机会真的去用ps看一次进程树真的用jstack抓一次卡死现场真的亲手把上万个任务丢进线程池看它怎么排队。当这些名词对应的都是你亲眼见过、亲手处理过的场景时它们就不再是概念而是你手里的工具。下一篇我们要聊的进程池和线程池的更深入用法也是建立在今天这套地基上的——地基打牢了往上盖什么都稳。
返回列表