刚接触并发编程那阵子,我也被“进程和线程”这两个词搅得头疼。教科书上写“进程是资源分配的最小单位,线程是CPU调度的最小单位”,每个字都认识,连在一起却像绕口令。后来在真实的项目里排查线上问题、调优接口耗时、设计定时任务框架,才慢慢把这些概念从抽象定义变成了肌肉记忆。这篇文章不讲长篇大论,就用实际开发中能看到的案例、能跑的代码、能用的命令,把进程和线程这层窗户纸捅破,帮你在写代码时知道自己在用什么,出了问题也知道往哪个方向查。如果你正在学编程、准备面试,或者是写了好几年业务代码但没机会深入系统底层的人,这篇文章应该能给你一个清晰、能落地的认知框架。我会先从生活场景切入,再逐步进入操作系统层面的细节,最后给出一批可以直接上手的实测命令和代码,以及我踩过的坑。整个过程尽量说人话,复杂的地方用类比补上。1. 先别背定义:用一个后厨场景搞懂两个词的含义1.1 后厨、灶台、厨师和菜单想象你开了一家餐厅。整个餐厅有自己独立的店面、厨房设备、库房和菜单,这套完整的经营体系就像一个进程。厨房里的灶台、锅铲、食材,对应着进程拥有的内存、文件句柄和其他资源。但光有餐厅和厨房,菜不会自己跑出来,还得有厨师在里面动手干活。厨师在同一个厨房里忙活,共用灶台、共用食材、共用调味料,但是每一位厨师手里正在颠勺的动作、此刻脑子里想着“这道菜下一步是放盐还是放醋”的状态,是各自独立的。这些独立动作和当前状态,就是线程的贴身财产。这个类比能立刻解释几个关键点:进程是资源的容器,它管着厨房里的所有家当;线程是容器里干活的人,它真正消耗CPU时间。同一个进程里的多个线程,共享厨房里的公共资源,所以线程之间交流很方便,但也容易因为“抢同一个锅”而产生冲突。如果餐厅整体倒闭,里面的厨师自然也就失业了;但个别厨师手滑烧了一个灶头,可能整个厨房都要停业整顿,这也是进程与线程在崩溃隔离上的差异。1.2 从餐厅到操作系统:资源与执行的关系操作系统里的进程,就是程序的一次运行实例。每启动一个程序,操作系统会给它分配一块独立的虚拟地址空间、一份文件描述符表、一组环境变量和至少一个执行流。这个“执行流”就是主线程。所以严格来说,任何进程都至少有一个线程,线程是进程内部的实际执行者。“资源分配”和“执行调度”这两件事被拆开,是现代操作系统最重要的设计之一。如果一个进程既持有资源又只能自己单线操作,那当它等待磁盘数据时,整块资源都闲着,CPU也停下来,太浪费。有了线程之后,进程内的多个执行流可以分时使用CPU:一个线程在等I/O,另一个线程可以继续计算,资源始终在转,厨房的师傅们可以交替干活。这个分工在浏览器里看得特别清楚。现在主流浏览器普遍采用多进程架构,每个标签页可能是一个独立的进程,这样某个标签页崩溃不会把整个浏览器拖垮;而进程内部,渲染、网络请求、用户交互又有多个线程协同工作。你一边看视频一边滚动页面,就是多个线程在背后不停地切换。2. 深入一点:进程和线程在系统里分别是什么2.1 进程的“家当”:地址空间、文件表、信号处理器在Linux里,每个进程都由一个task_struct结构体描述,这个结构体大得惊人,包含进程状态、调度信息、打开的文件、文件系统上下文、信号处理、内存管理等等。我们把进程当作一个独立的“虚拟世界”:地址空间:每个进程有自己独立的页表,虚拟地址从0到最大地址都只属于自己。A进程在地址0x1000写一个值,B进程即使在同一个地址也不会受影响,因为经过页表映射后,它们落在完全不同的物理页上。文件表:进程打开的文件、socket、管道,都记录在文件描述符表里。多个线程共享这张表,但两个不同进程之间是完全隔离的,所以要跨进程传数据,就得靠管道、共享内存、socket这类专门的IPC机制。信号处理器:进程可以对信号(比如SIGINT、SIGTERM)注册统一的处理函数。同一个进程里的线程触发的信号,会由进程的主线程或某个约定线程来处理,这也和线程的私有状态不同。这些“家当”都是为了隔离。进程是安全边界的执行单位,一个进程不能直接读写另一个进程的内存,这就是为什么多进程程序更稳、更安全,但也更重。2.2 线程的“私人物品”:栈、寄存器、线程控制块线程不是完全没有自己的资源。每个线程至少要有:线程栈:存放函数调用的局部变量、返回地址、参数。线程栈通常从几MB到几十MB(用ulimit -s可以查看),这个大块内存是从进程的虚拟地址空间中分配的,但是每个线程只能用自己的那块区域。寄存器上下文:程序计数器、栈指针、通用寄存器等。线程切换时,操作系统保存当前线程的寄存器值,恢复下一个线程的寄存器值,这个工作比进程切换轻量。线程控制块(TCB):记录线程ID、线程状态、调度优先级、栈基址等。同一进程内的多个线程,共享代码段、数据段、堆、打开的文件描述符和当前工作目录。也就是说,线程A里new出来的对象,线程B可以直接引用它的地址;线程A打开的文件,线程B可以直接读写同一个文件描述符。这既是方便,也是危险的根源。数据竞争、死锁、资源争用,全都诞生在这里。2.3 线程模型的差异:用户级、内核级与混合模型线程在不同的操作系统上实现方式不一样。在Linux上,线程本质上是一种“轻量级进程”,创建线程时使用clone系统调用,并且可以传参控制共享哪些资源;但内核会的调度单位是线程,不是进程。在早期的Solaris系统上,出现过用户级线程(用户空间实现,内核并不知道)和内核级线程(内核知道并调度)的区分。现在主流语言(Java、Go、C++的std::thread)使用的线程,几乎都会对应到内核级线程或者由运行时调度的协程。为什么不用纯用户级线程?因为内核级线程可以享受多核并行,用户级线程在单线程进程里无法利用多核。但用户级线程切换开销极小,所以Go语言另辟蹊径,用协程(goroutine)自己做调度,底层再映射到少量内核线程上,兼顾了并发和性能。3. 什么时候该上多进程,什么时候该上多线程3.1 看业务是CPU密集还是I/O密集这是选型时最重要的判断标准。如果任务是纯计算,比如视频编码、加密解密、矩阵运算,这类任务几乎不等待外部资源,主要消耗CPU。多线程在单核上不会加速,只能来回切换;在多核上,如果语言和运行时没有特殊的锁竞争限制,多线程可以打满多个核心。但要注意,如果计算任务之间需要频繁同步,性能会被锁拖垮。这种情况下,多进程配合消息传递,有时比多线程更干净。如果任务是I/O密集,比如读写数据库、调用远程接口、读写文件,大部分时间线程都阻塞在等待结果上。此时用多线程很合适:一个线程等在I/O上,CPU可以去跑另一个线程。Java里动辄上千个线程也没问题,因为大量线程都在阻塞,实际占用CPU的很少。Python因为有GIL,多线程在纯Python的CPU密集任务上基本没有加速效果,但做I/O密集任务时可以用线程池,因为等待I/O时会释放GIL。3.2 看数据隔离的要求多进程的核心优势是内存隔离。一个进程崩溃,不会直接把另一个进程的内存搞坏;子进程内存越界,最多把自己所在的地址空间冲掉,父进程通常还能善后并重启。这在需要高可靠的系统里非常重要,比如浏览器、数据库。但如果业务数据本来就存在一个大对象模型里,多个任务需要频繁修改同一个结构,多线程就更适合。拿电商库存来举例,一个订单扣减库存需要同时修改订单表和库存表,如果拆成不同进程,必须用IPC同步,代码复杂度会直线上升;用线程加锁,直接访问共享内存,写起来更直白。当然,加锁要小心,锁的粒度、顺序都要想清楚,否则死锁就会找上门。3.3 看团队熟悉的调试方式这个因素很容易被忽略,但在真实项目里往往是决定性的。多进程程序的问题往往表现为通信错乱、数据不同步,排查时要靠日志把多个进程的时序拼起来,难度很大。多线程程序虽然也有资源竞争和死锁问题,但至少所有线程的堆栈可以在一个进程里用调试器抓出来,许多IDE都能直观展示线程状态。我的建议是:小团队、快速迭代的内部服务,优先用多线程,因为好排查、好维护;需要稳定性强、模块边界清晰的系统,考虑多进程。两者并不互斥,比如一个后台服务可以由多个进程组成,每个进程内部再用线程池处理请求。4. 动手验证:用代码感受进程和线程的差异4.1 Python里的threading和multiprocessing实测Python是感受进程和线程差异最方便的语言。我们写一个极度消耗CPU的函数,分别用线程池和进程池跑,看时间差别。import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def calculate(n): s = 0 for i in range(n): s += i * i return s def run_in_thread(workers): with ThreadPoolExecutor(max_workers=workers) as ex: list(ex.map(calculate, [3000000] * workers)) def run_in_process(workers): with ProcessPoolExecutor(max_workers=workers) as ex: list(ex.map(calculate, [3000000] * workers)) if __name__ == '__main__': workers = 4 t0 = time.perf_counter() run_in_thread(workers) print(f"threads: {time.perf_counter() - t0:.2f}s") t0 = time.perf_counter() run_in_process(workers) print(f"processes: {time.perf_counter() - t0:.2f}s")在我的一台四核虚拟机上,线程版的耗时大约4秒出头,进程版大约1秒出头。原因就是Python的GIL:同一时刻只有一个线程能执行Python字节码。多线程在CPU密集任务上不但没有加速,反而有切换开销。而多进程各自有一个独立的解释器,可以真正跑满多个核心。这个测试不是劝退线程,而是提醒你:不同语言运行时机制不同,不要拿Java的经验直接套Python。4.2 Java里用Thread和ProcessBuilder做对比Java的每个线程都映射到操作系统线程,CPU密集任务可以天然多核并行。先看线程共享变量的效果:public class ThreadDemo { static int count = 0; public static void main(String[] args) throws Exception { Thread[] threads = new Thread[10]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < 10000; j++) { count++; } }); threads[i].start(); } for (Thread t : threads) t.join(); System.out.println("count = " + count); } }这个程序的结果大概率不是100000,因为count++不是一个原子操作,两个线程可能同时读到同一个旧值,加完写回,造成丢失更新。改成AtomicInteger或者加synchronized就能得到正确结果,但这同时引入了同步开销。再看进程隔离的版本,用ProcessBuilder启动一个子进程。子进程里的变量修改,父进程完全看不见,只能通过输出流拿到结果。这就像两个独立的餐厅,后厨再热闹,隔壁餐厅也听不到。import java.io.*; public class ProcessDemo { public static void main(String[] args) throws Exception { Process p = new ProcessBuilder("java", "-version") .redirectErrorStream(true) .start(); BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); String line; while ((line = reader.readLine()) != null) { System.out.println(line); } System.out.println("exit code = " + p.waitFor()); } }4.3 用系统命令查看进程和线程纸上得来终觉浅,直接在系统里看更直观。ps -eLf:每一行代表一个进程里的一个线程,LWP就是线程ID。你可以看到同一个PID下有多条LWP记录。top -H -p <pid>:按线程维度展示CPU占用,可以快速发现哪个线程在疯狂消费CPU。cat /proc/<pid>/status | grep Threads:直接看到这个进程当前有多少个线程。Java程序可以用jstack <pid>打印出所有线程的堆栈,排查死锁时几乎必用。上次排查一个接口卡死问题,我先用top -H -p看到某个线程CPU持续100%,再用jstack抓到线程栈,发现是在一个正则匹配的死循环里。如果没有线程维度的观察命令,根本不可能在杂乱的服务里定位到这么细。5. 真正干活时才会踩的坑和排查思路5.1 线程死锁:怎么发现和避免死锁的经典场景是线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。一旦形成循环等待,两个线程就永久卡住。线上表现通常是某个接口不再返回,但是线程还在。排查方法:Java环境先用jstack抓线程转储,里面会明确提示Found one Java-level deadlock,并列出线程各自持有的锁和等待的锁。Python环境可以分别访问线程的信号?实际上Python没有内置死锁检测,但可以每隔一段时间把当前线程栈打印出来,或者用faulthandler.dump_traceback_later(10)。预防死锁最简单的方法是给锁排序:所有线程都按固定顺序获取多个锁,不要交叉使用。比如先锁A再锁B,永远不要先锁B再锁A。5.2 进程通信要选对IPC:管道、共享内存、消息队列多进程之间没有共享内存,所以必须用IPC。管道适合父子进程之间单向传递简单数据,用起来最简单;共享内存适合传递大量数据,性能最好,但需要自己处理同步和生命周期;消息队列和socket则适合跨机器、跨语言的场景。我见过一个系统,为了追求性能,用了共享内存,却忘了在共享内存里加锁。结果两个进程同时写一个槽位,数据一会儿对一会儿错,非常难排查。后来改成环形缓冲区加自旋锁,才稳定下来。IPC没有银弹,选型时要把数据量、实时性、开发成本排个优先级,而不是只看性能。5.3 线程池的线程数到底设多少这个问题几乎每个项目都会遇到。一个经验公式:计算密集型任务:线程数约等于CPU核心数(或者核心数+1,留一点余地)。I/O密集型任务:线程数可以设到核心数的几倍甚至几十倍,因为大部分线程在等待I/O,不占CPU时间。更准确的方法是压测,观察线程池队列增长、CPU利用率和响应时间,再动态调整。很多框架都有动态线程池的配置,比如Java的ThreadPoolExecutor允许核心线程数和最大线程数调整,不要把参数写死成永远不变的值。我见过有人把线程池最大线程数设成10000,结果每个线程默认占用1MB栈,光栈内存就吃了10GB,服务刚上线就OOM。5.4 崩溃隔离:进程挂了线程还在吗这是一个常见的误解。进程是资源容器,线程是容器内的执行流。进程一旦退出,内核会回收该进程的所有资源,包括它的所有线程。所以“主线程结束了,子线程还能不能继续跑”这个问题的答案是:取决于你怎么结束。在C/C++里,从main函数返回会调用exit,所有线程立即终止;但如果你调用pthread_exit只退出当前线程,其他线程可以继续。在Java里,当所有非守护线程都结束时,JVM才会退出;守护线程会自动终止。Python里主线程结束,非守护子线程通常也会被强制结束。所以设计生命周期时,一定要明确线程的退出时机,否则可能出现任务还没跑完,进程就已经散场的情况。6. 从调度视角再往后看一眼6.1 操作系统到底在调度什么现代Linux内核调度的基本实体是线程。内核看到的是一个个task,每个task有独立的上下文和调度策略。所以当你创建一个进程时,实际就是创建了一个包含主线程的task;在这个进程里再创建线程,就是增加共享同一地址空间的新task。这种设计让线程切换只涉及寄存器上下文切换,而进程切换需要处理地址空间切换(换页表、刷新TLB)。同一个进程内线程切换成本远低于进程切换,这就是为什么线程被称为“轻量级进程”。但这个“轻量”是相对的:线程创建和销毁也需要内核调用,频繁创建销毁线程同样会让系统性能受损,所以线程池和进程池都是减少这种开销的常用手段。6.2 协程和线程的关系:并发不一定是并行把线程往更轻的方向推一步,就出现了协程。协程是用户态的调度单元,它没有独立的内核栈,切换时只需要保存少量寄存器,所以切换成本比线程低一个数量级。但协程一般是协作式调度,一个协程阻塞了,如果没有特殊机制,整个线程就卡住了。在实际工程里,很多语言已经把协程包装得很完善。Go的goroutine会在某个协程发起阻塞I/O时,自动把该线程让给其他协程;Java的虚拟线程也走类似路线。理解协程之前,先理解线程是很有必要的,因为协程最终还是跑在线程上,操作系统的调度依然以线程为单位。个人体会我在实际项目中见过太多因为没分清进程和线程而导致的故障:有把进程通信写成共享内存导致数据错乱的,有为了并发把线程池线程数调到离谱结果的,也有在线程里直接修改全局变量导致线上偶发问题的。这些问题的根源,往往不是因为不懂API,而是没建立“资源归属”和“执行流”分离的思维模型。每次写并发代码之前,我都会先问自己:这里的数据需要共享吗?如果不需要,优先拆进程;如果需要共享,再想清楚同步方式。这个习惯帮我省下了大量排查时间。理解进程和线程不是背概念,而是在自己的代码里画一条清晰的边界:什么资源属于整个容器,什么状态只属于当前这条执行流。边界画清楚了,并发编程就成功了一大半。
刚接触并发编程那阵子,我也被“进程和线程”这两个词搅得头疼。教科书上写“进程是资源分配的最小单位,线程是CPU调度的最小单位”,每个字都认识,连在一起却像绕口令。后来在真实的项目里排查线上问题、调优接口耗时、设计定时任务框架,才慢慢把这些概念从抽象定义变成了肌肉记忆。这篇文章不讲长篇大论,就用实际开发中能看到的案例、能跑的代码、能用的命令,把进程和线程这层窗户纸捅破,帮你在写代码时知道自己在用什么,出了问题也知道往哪个方向查。如果你正在学编程、准备面试,或者是写了好几年业务代码但没机会深入系统底层的人,这篇文章应该能给你一个清晰、能落地的认知框架。我会先从生活场景切入,再逐步进入操作系统层面的细节,最后给出一批可以直接上手的实测命令和代码,以及我踩过的坑。整个过程尽量说人话,复杂的地方用类比补上。1. 先别背定义:用一个后厨场景搞懂两个词的含义1.1 后厨、灶台、厨师和菜单想象你开了一家餐厅。整个餐厅有自己独立的店面、厨房设备、库房和菜单,这套完整的经营体系就像一个进程。厨房里的灶台、锅铲、食材,对应着进程拥有的内存、文件句柄和其他资源。但光有餐厅和厨房,菜不会自己跑出来,还得有厨师在里面动手干活。厨师在同一个厨房里忙活,共用灶台、共用食材、共用调味料,但是每一位厨师手里正在颠勺的动作、此刻脑子里想着“这道菜下一步是放盐还是放醋”的状态,是各自独立的。这些独立动作和当前状态,就是线程的贴身财产。这个类比能立刻解释几个关键点:进程是资源的容器,它管着厨房里的所有家当;线程是容器里干活的人,它真正消耗CPU时间。同一个进程里的多个线程,共享厨房里的公共资源,所以线程之间交流很方便,但也容易因为“抢同一个锅”而产生冲突。如果餐厅整体倒闭,里面的厨师自然也就失业了;但个别厨师手滑烧了一个灶头,可能整个厨房都要停业整顿,这也是进程与线程在崩溃隔离上的差异。1.2 从餐厅到操作系统:资源与执行的关系操作系统里的进程,就是程序的一次运行实例。每启动一个程序,操作系统会给它分配一块独立的虚拟地址空间、一份文件描述符表、一组环境变量和至少一个执行流。这个“执行流”就是主线程。所以严格来说,任何进程都至少有一个线程,线程是进程内部的实际执行者。“资源分配”和“执行调度”这两件事被拆开,是现代操作系统最重要的设计之一。如果一个进程既持有资源又只能自己单线操作,那当它等待磁盘数据时,整块资源都闲着,CPU也停下来,太浪费。有了线程之后,进程内的多个执行流可以分时使用CPU:一个线程在等I/O,另一个线程可以继续计算,资源始终在转,厨房的师傅们可以交替干活。这个分工在浏览器里看得特别清楚。现在主流浏览器普遍采用多进程架构,每个标签页可能是一个独立的进程,这样某个标签页崩溃不会把整个浏览器拖垮;而进程内部,渲染、网络请求、用户交互又有多个线程协同工作。你一边看视频一边滚动页面,就是多个线程在背后不停地切换。2. 深入一点:进程和线程在系统里分别是什么2.1 进程的“家当”:地址空间、文件表、信号处理器在Linux里,每个进程都由一个task_struct结构体描述,这个结构体大得惊人,包含进程状态、调度信息、打开的文件、文件系统上下文、信号处理、内存管理等等。我们把进程当作一个独立的“虚拟世界”:地址空间:每个进程有自己独立的页表,虚拟地址从0到最大地址都只属于自己。A进程在地址0x1000写一个值,B进程即使在同一个地址也不会受影响,因为经过页表映射后,它们落在完全不同的物理页上。文件表:进程打开的文件、socket、管道,都记录在文件描述符表里。多个线程共享这张表,但两个不同进程之间是完全隔离的,所以要跨进程传数据,就得靠管道、共享内存、socket这类专门的IPC机制。信号处理器:进程可以对信号(比如SIGINT、SIGTERM)注册统一的处理函数。同一个进程里的线程触发的信号,会由进程的主线程或某个约定线程来处理,这也和线程的私有状态不同。这些“家当”都是为了隔离。进程是安全边界的执行单位,一个进程不能直接读写另一个进程的内存,这就是为什么多进程程序更稳、更安全,但也更重。2.2 线程的“私人物品”:栈、寄存器、线程控制块线程不是完全没有自己的资源。每个线程至少要有:线程栈:存放函数调用的局部变量、返回地址、参数。线程栈通常从几MB到几十MB(用ulimit -s可以查看),这个大块内存是从进程的虚拟地址空间中分配的,但是每个线程只能用自己的那块区域。寄存器上下文:程序计数器、栈指针、通用寄存器等。线程切换时,操作系统保存当前线程的寄存器值,恢复下一个线程的寄存器值,这个工作比进程切换轻量。线程控制块(TCB):记录线程ID、线程状态、调度优先级、栈基址等。同一进程内的多个线程,共享代码段、数据段、堆、打开的文件描述符和当前工作目录。也就是说,线程A里new出来的对象,线程B可以直接引用它的地址;线程A打开的文件,线程B可以直接读写同一个文件描述符。这既是方便,也是危险的根源。数据竞争、死锁、资源争用,全都诞生在这里。2.3 线程模型的差异:用户级、内核级与混合模型线程在不同的操作系统上实现方式不一样。在Linux上,线程本质上是一种“轻量级进程”,创建线程时使用clone系统调用,并且可以传参控制共享哪些资源;但内核会的调度单位是线程,不是进程。在早期的Solaris系统上,出现过用户级线程(用户空间实现,内核并不知道)和内核级线程(内核知道并调度)的区分。现在主流语言(Java、Go、C++的std::thread)使用的线程,几乎都会对应到内核级线程或者由运行时调度的协程。为什么不用纯用户级线程?因为内核级线程可以享受多核并行,用户级线程在单线程进程里无法利用多核。但用户级线程切换开销极小,所以Go语言另辟蹊径,用协程(goroutine)自己做调度,底层再映射到少量内核线程上,兼顾了并发和性能。3. 什么时候该上多进程,什么时候该上多线程3.1 看业务是CPU密集还是I/O密集这是选型时最重要的判断标准。如果任务是纯计算,比如视频编码、加密解密、矩阵运算,这类任务几乎不等待外部资源,主要消耗CPU。多线程在单核上不会加速,只能来回切换;在多核上,如果语言和运行时没有特殊的锁竞争限制,多线程可以打满多个核心。但要注意,如果计算任务之间需要频繁同步,性能会被锁拖垮。这种情况下,多进程配合消息传递,有时比多线程更干净。如果任务是I/O密集,比如读写数据库、调用远程接口、读写文件,大部分时间线程都阻塞在等待结果上。此时用多线程很合适:一个线程等在I/O上,CPU可以去跑另一个线程。Java里动辄上千个线程也没问题,因为大量线程都在阻塞,实际占用CPU的很少。Python因为有GIL,多线程在纯Python的CPU密集任务上基本没有加速效果,但做I/O密集任务时可以用线程池,因为等待I/O时会释放GIL。3.2 看数据隔离的要求多进程的核心优势是内存隔离。一个进程崩溃,不会直接把另一个进程的内存搞坏;子进程内存越界,最多把自己所在的地址空间冲掉,父进程通常还能善后并重启。这在需要高可靠的系统里非常重要,比如浏览器、数据库。但如果业务数据本来就存在一个大对象模型里,多个任务需要频繁修改同一个结构,多线程就更适合。拿电商库存来举例,一个订单扣减库存需要同时修改订单表和库存表,如果拆成不同进程,必须用IPC同步,代码复杂度会直线上升;用线程加锁,直接访问共享内存,写起来更直白。当然,加锁要小心,锁的粒度、顺序都要想清楚,否则死锁就会找上门。3.3 看团队熟悉的调试方式这个因素很容易被忽略,但在真实项目里往往是决定性的。多进程程序的问题往往表现为通信错乱、数据不同步,排查时要靠日志把多个进程的时序拼起来,难度很大。多线程程序虽然也有资源竞争和死锁问题,但至少所有线程的堆栈可以在一个进程里用调试器抓出来,许多IDE都能直观展示线程状态。我的建议是:小团队、快速迭代的内部服务,优先用多线程,因为好排查、好维护;需要稳定性强、模块边界清晰的系统,考虑多进程。两者并不互斥,比如一个后台服务可以由多个进程组成,每个进程内部再用线程池处理请求。4. 动手验证:用代码感受进程和线程的差异4.1 Python里的threading和multiprocessing实测Python是感受进程和线程差异最方便的语言。我们写一个极度消耗CPU的函数,分别用线程池和进程池跑,看时间差别。import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def calculate(n): s = 0 for i in range(n): s += i * i return s def run_in_thread(workers): with ThreadPoolExecutor(max_workers=workers) as ex: list(ex.map(calculate, [3000000] * workers)) def run_in_process(workers): with ProcessPoolExecutor(max_workers=workers) as ex: list(ex.map(calculate, [3000000] * workers)) if __name__ == '__main__': workers = 4 t0 = time.perf_counter() run_in_thread(workers) print(f"threads: {time.perf_counter() - t0:.2f}s") t0 = time.perf_counter() run_in_process(workers) print(f"processes: {time.perf_counter() - t0:.2f}s")在我的一台四核虚拟机上,线程版的耗时大约4秒出头,进程版大约1秒出头。原因就是Python的GIL:同一时刻只有一个线程能执行Python字节码。多线程在CPU密集任务上不但没有加速,反而有切换开销。而多进程各自有一个独立的解释器,可以真正跑满多个核心。这个测试不是劝退线程,而是提醒你:不同语言运行时机制不同,不要拿Java的经验直接套Python。4.2 Java里用Thread和ProcessBuilder做对比Java的每个线程都映射到操作系统线程,CPU密集任务可以天然多核并行。先看线程共享变量的效果:public class ThreadDemo { static int count = 0; public static void main(String[] args) throws Exception { Thread[] threads = new Thread[10]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < 10000; j++) { count++; } }); threads[i].start(); } for (Thread t : threads) t.join(); System.out.println("count = " + count); } }这个程序的结果大概率不是100000,因为count++不是一个原子操作,两个线程可能同时读到同一个旧值,加完写回,造成丢失更新。改成AtomicInteger或者加synchronized就能得到正确结果,但这同时引入了同步开销。再看进程隔离的版本,用ProcessBuilder启动一个子进程。子进程里的变量修改,父进程完全看不见,只能通过输出流拿到结果。这就像两个独立的餐厅,后厨再热闹,隔壁餐厅也听不到。import java.io.*; public class ProcessDemo { public static void main(String[] args) throws Exception { Process p = new ProcessBuilder("java", "-version") .redirectErrorStream(true) .start(); BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); String line; while ((line = reader.readLine()) != null) { System.out.println(line); } System.out.println("exit code = " + p.waitFor()); } }4.3 用系统命令查看进程和线程纸上得来终觉浅,直接在系统里看更直观。ps -eLf:每一行代表一个进程里的一个线程,LWP就是线程ID。你可以看到同一个PID下有多条LWP记录。top -H -p <pid>:按线程维度展示CPU占用,可以快速发现哪个线程在疯狂消费CPU。cat /proc/<pid>/status | grep Threads:直接看到这个进程当前有多少个线程。Java程序可以用jstack <pid>打印出所有线程的堆栈,排查死锁时几乎必用。上次排查一个接口卡死问题,我先用top -H -p看到某个线程CPU持续100%,再用jstack抓到线程栈,发现是在一个正则匹配的死循环里。如果没有线程维度的观察命令,根本不可能在杂乱的服务里定位到这么细。5. 真正干活时才会踩的坑和排查思路5.1 线程死锁:怎么发现和避免死锁的经典场景是线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。一旦形成循环等待,两个线程就永久卡住。线上表现通常是某个接口不再返回,但是线程还在。排查方法:Java环境先用jstack抓线程转储,里面会明确提示Found one Java-level deadlock,并列出线程各自持有的锁和等待的锁。Python环境可以分别访问线程的信号?实际上Python没有内置死锁检测,但可以每隔一段时间把当前线程栈打印出来,或者用faulthandler.dump_traceback_later(10)。预防死锁最简单的方法是给锁排序:所有线程都按固定顺序获取多个锁,不要交叉使用。比如先锁A再锁B,永远不要先锁B再锁A。5.2 进程通信要选对IPC:管道、共享内存、消息队列多进程之间没有共享内存,所以必须用IPC。管道适合父子进程之间单向传递简单数据,用起来最简单;共享内存适合传递大量数据,性能最好,但需要自己处理同步和生命周期;消息队列和socket则适合跨机器、跨语言的场景。我见过一个系统,为了追求性能,用了共享内存,却忘了在共享内存里加锁。结果两个进程同时写一个槽位,数据一会儿对一会儿错,非常难排查。后来改成环形缓冲区加自旋锁,才稳定下来。IPC没有银弹,选型时要把数据量、实时性、开发成本排个优先级,而不是只看性能。5.3 线程池的线程数到底设多少这个问题几乎每个项目都会遇到。一个经验公式:计算密集型任务:线程数约等于CPU核心数(或者核心数+1,留一点余地)。I/O密集型任务:线程数可以设到核心数的几倍甚至几十倍,因为大部分线程在等待I/O,不占CPU时间。更准确的方法是压测,观察线程池队列增长、CPU利用率和响应时间,再动态调整。很多框架都有动态线程池的配置,比如Java的ThreadPoolExecutor允许核心线程数和最大线程数调整,不要把参数写死成永远不变的值。我见过有人把线程池最大线程数设成10000,结果每个线程默认占用1MB栈,光栈内存就吃了10GB,服务刚上线就OOM。5.4 崩溃隔离:进程挂了线程还在吗这是一个常见的误解。进程是资源容器,线程是容器内的执行流。进程一旦退出,内核会回收该进程的所有资源,包括它的所有线程。所以“主线程结束了,子线程还能不能继续跑”这个问题的答案是:取决于你怎么结束。在C/C++里,从main函数返回会调用exit,所有线程立即终止;但如果你调用pthread_exit只退出当前线程,其他线程可以继续。在Java里,当所有非守护线程都结束时,JVM才会退出;守护线程会自动终止。Python里主线程结束,非守护子线程通常也会被强制结束。所以设计生命周期时,一定要明确线程的退出时机,否则可能出现任务还没跑完,进程就已经散场的情况。6. 从调度视角再往后看一眼6.1 操作系统到底在调度什么现代Linux内核调度的基本实体是线程。内核看到的是一个个task,每个task有独立的上下文和调度策略。所以当你创建一个进程时,实际就是创建了一个包含主线程的task;在这个进程里再创建线程,就是增加共享同一地址空间的新task。这种设计让线程切换只涉及寄存器上下文切换,而进程切换需要处理地址空间切换(换页表、刷新TLB)。同一个进程内线程切换成本远低于进程切换,这就是为什么线程被称为“轻量级进程”。但这个“轻量”是相对的:线程创建和销毁也需要内核调用,频繁创建销毁线程同样会让系统性能受损,所以线程池和进程池都是减少这种开销的常用手段。6.2 协程和线程的关系:并发不一定是并行把线程往更轻的方向推一步,就出现了协程。协程是用户态的调度单元,它没有独立的内核栈,切换时只需要保存少量寄存器,所以切换成本比线程低一个数量级。但协程一般是协作式调度,一个协程阻塞了,如果没有特殊机制,整个线程就卡住了。在实际工程里,很多语言已经把协程包装得很完善。Go的goroutine会在某个协程发起阻塞I/O时,自动把该线程让给其他协程;Java的虚拟线程也走类似路线。理解协程之前,先理解线程是很有必要的,因为协程最终还是跑在线程上,操作系统的调度依然以线程为单位。个人体会我在实际项目中见过太多因为没分清进程和线程而导致的故障:有把进程通信写成共享内存导致数据错乱的,有为了并发把线程池线程数调到离谱结果的,也有在线程里直接修改全局变量导致线上偶发问题的。这些问题的根源,往往不是因为不懂API,而是没建立“资源归属”和“执行流”分离的思维模型。每次写并发代码之前,我都会先问自己:这里的数据需要共享吗?如果不需要,优先拆进程;如果需要共享,再想清楚同步方式。这个习惯帮我省下了大量排查时间。理解进程和线程不是背概念,而是在自己的代码里画一条清晰的边界:什么资源属于整个容器,什么状态只属于当前这条执行流。边界画清楚了,并发编程就成功了一大半。