ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

彻底搞懂进程线程协程:从调度原理到并发排障实战

彻底搞懂进程线程协程:从调度原理到并发排障实战 带新人的时候我很喜欢先问一个问题“进程、线程、协程你觉得自己真正搞懂了吗”大多数人的回答是“懂一点”但再往下问一句“它们之间到底怎么切换、怎么通信、各自适合解决什么问题”往往就沉默了。这三个概念可以背得很顺——进程是资源分配的基本单位线程是CPU调度的基本单位协程是用户态的轻量级线程——但背下来和用起来是两回事。这篇文章不打算复述课本而是从操作系统底层的调度逻辑出发结合线程池、asyncio、死锁排查、进程杀不干净这类真实场景把进程、线程、协程拆开揉碎讲清楚。无论你是刚入门准备面试还是写了好几年业务代码想补补基础这套模型都值得认真过一遍。1. 三个角色进程是“档口”线程是“厨师”协程是“一个人分时干几件事”1.1 进程操作系统发的“独立档口”进程是一个正在运行的程序实例。你在Windows上打开资源管理器在Linux上跑一个nginx每个实例在系统里就是一个进程拥有自己独立的地址空间、文件描述符表、环境变量和资源配额。为什么非要做成“独立”的核心目的是隔离进程A程序越界访问内存不能把进程B的数据改坏操作系统通过页表和虚拟内存把每个进程的“地盘”隔开。现代浏览器把每个标签页做成进程也是这个思路一个页面崩溃不会把整个浏览器拖垮。从操作系统内核视角看每个进程对应一个task_structLinux下叫进程描述符Windows下叫EPROCESS概念等价里面记录着进程ID、内存描述符、打开的文件、信号处理等一堆信息。进程创建成本不低因为要复制/继承地址空间和资源表所以现在实际干活时我们很少裸创建进程更多是操作系统启动后由init进程派生或者使用进程池复用。进程是资源分配的基本单位这个定位很精确。一个进程要能跑起来至少得有内存存放代码和数据、有CPU去执行、有文件句柄做输入输出这些资源以进程为单位分配。但进程本身并不直接“跑”真正在CPU上执行的是它内部的东西——线程。1.2 线程共享“档口”的多个厨师一个进程内部可以有一个或多个线程。线程共享进程的地址空间、全局变量和文件描述符每个线程只保留自己独立的那部分执行现场寄存器、程序计数器、调用栈。你可以把进程理解成一个独立的餐厅档口档口里有灶台、食材和厨具线程就是档口里的厨师大家共用同一个灶台和食材但每个人手里拿着自己的那份菜谱和切菜进度。为什么操作系统要引入线程如果每件事都开一个进程就意味着每件事都要一套独立的内存空间和资源表创建、切换、通信成本都高得吓人。文本编辑器需要同时处理键盘输入、自动保存、语法高亮、网络检查如果这些各开一个进程共享文档内容会非常痛苦——要么用IPC把数据传来传去要么共享内存再做一堆同步。用线程就简单了所有逻辑在同一个进程地址空间里直接访问同一份数据创建成本比进程低一个数量级。这里要澄清一个经典表述线程是CPU调度的基本单位。也就是说操作系统调度器真正分配CPU时间片的对象是线程不是进程。进程更像一个“容器”为线程提供共享资源线程才是容器里真正干活的执行流。Linux实现时这层关系更直接所谓进程在调度层面就是一个或多个线程的集合。1.3 协程一个厨师自己“让勺”干几件事协程是又一个层次的执行流。一个线程内部可以跑很多协程区别在于线程是抢占式调度内核觉得你的时间片用完了就强制切走协程是协作式调度协程自己执行到某个await或者yield就主动让出控制权事件循环再决定调度哪个协程继续跑。用食堂例子继续比线程是档口里有多个厨师系统定时摇铃换人协程是只有一个厨师他炒两下A菜不等A菜熟就转身去切B菜的葱切完葱再回头继续炒A菜。反正都是自己掌控节奏不需要系统摇铃。这种主动让权的设计让协程切换成本极其便宜后面会细讲。协程还分无栈协程和有栈协程。Python的asyncio、JavaScript的Promise属于无栈协程协程的挂起状态被编译器转换成状态机存不了很深的调用栈Go的goroutine、Kotlin的协程、Lua的coroutine属于有栈协程每个协程有独立的栈可以随挂随恢复。理解这个区别很重要在Python里写协程时不能把一个需要深度递归或者长调用链的逻辑无限加深栈状态已经被打包成对象了在Go里则不用太担心因为goroutine有自己的动态栈可以自由嵌套调用。三者关系一句话就能串起来进程是资源和隔离的边界线程是内核调度和执行的基本单元协程是用户态下在线程内部再细分出来的轻量执行流。一个进程通常有若干线程一个线程在不同时刻可以承载不同协程——协程挂在某个线程上运行挂起之后线程可以去跑别的协程。维度进程线程协程资源归属独立地址空间、文件表、环境变量共享进程资源仅有独立栈和寄存器复用所属线程资源额外一块独立栈或状态机调度者操作系统内核操作系统内核用户态程序/运行时切换方式抢占式抢占式协作式主动让出切换成本最高地址空间切换内核态切换中等内核态切换最低用户态上下文保存/恢复通信方式IPC管道、消息队列、共享内存、socket共享内存加锁/原子操作通道、队列、消息传递典型代表浏览器多进程、微服务多实例Java线程、C std::threadPython asyncio、Go goroutine2. 联系不是“包含关系”一句话切换成本、通信方式和调度博弈2.1 切换代价从进程到协程是几个数量级的差距不少人对三个概念的印象停留在“一个比一个轻”但到底轻多少心里没数。进程切换时CPU要进入内核态保存当前线程的上下文然后切换地址空间——具体到硬件上就是CR3寄存器要换成新进程的页表基地址TLB快表几乎全部失效接下来一段时间内存访问都会因为TLB miss变慢。如果涉及跨核迁移还有缓存失效的问题。所以进程切换通常做到微秒级以上系统繁忙时几十微秒也不稀奇。线程切换不需要换页表因为同一进程的线程共享地址空间但毕竟要陷入内核态由内核调度器完成上下文切换包括保存寄存器、栈指针、指令指针再加上系统调用本身的开销。线程切换量级是微秒级以内比进程便宜不少但和高频业务场景下成千上万的线程切换需求相比仍然是不小的开销。协程切换完全不同。协程挂起时只需要保存少量寄存器和栈指针恢复时再加载回来整个过程完全在用户态不涉及系统调用不需要切换特权级。量级上一次协程切换能做到几十纳秒到一两百纳秒比线程切换快一到两个数量级。所以遇到高并发IO场景比如网关、爬虫、消息转发单机能撑起成千上万个协程而不会明显增加调度负担但开成千上万个线程就很可能被频繁切换拖垮。2.2 通信方式进程靠“寄快递”线程靠“共享房间”协程靠“发消息”进程因为地址空间隔离通信必须走操作系统提供的IPC机制。管道适合父子进程之间串行传数据消息队列允许异步收发共享内存是性能最高的IPC但用完要自己处理同步socket则可以跨机器通信。为什么要这么麻烦因为进程之间的安全边界就是地址空间边界跨边界传数据必须经过内核这既是隔离的代价也是隔离的保障。线程之间通信简单粗暴直接读写共享变量。但这恰恰是并发Bug的温床。一个线程写变量另一个线程读变量会涉及三个经典问题可见性写的结果什么时候对其他线程可见、原子性复合操作是否会被打断、有序性编译器和CPU是否会重排指令。解决思路也经典加锁、用原子类、用volatile保证可见性。热词里那个“AtomicInteger线程安全吗”就是典型的坑——单个read、write、CAS操作线程安全但如果你先get再判断再set组合起来就是非原子的需要配合compareAndSet或整个同步块使用。协程之间的通信又不一样了。在同一线程内协程本来就不会同时运行天然不存在数据竞争所以很多时候不需要加锁。但协程之间需要在不同执行点之间传递结果于是Go的channel、Python asyncio.Queue这类消息通道成了主流方案。这种设计背后的思想是“不要通过共享内存来通信而要通过通信来共享内存。”把交互数据放在通道里每个协程只和通道打交道心智负担比手工维护一堆锁低很多。2.3 调度哲学抢占、让出与“自由线程”为什么操作系统要抢占式调度线程因为用户态程序不老实如果每个线程拿到CPU后想运行多久就多久一个死循环就能把整个系统卡住。内核必须在时间片耗尽或者更高优先级任务就绪时强制切换保证公平和实时性。协程的协作式调度则是另一个极端它默认协程会主动让出如果某个协程里出现了死循环那么不经过任何等待点事件循环就被卡死整个线程上的其他协程全部停摆。所以写协程代码时所有可能长时间阻塞的操作都必须变成可挂起的调用这条纪律比写好业务逻辑更重要。调度策略里还有一个有意思的新动向Python 3.13的free-threaded实验去掉GIL让同一进程内的线程真正并行执行Python字节码。以前CPython的全局锁让多线程在CPU密集场景几乎等于串行很多人被迫用多进程绕过去但进程通信的成本又上来了。去掉GIL之后常规多线程编程的模型就能直接用代价是单线程性能可能略微下降。另外现代处理器流行大小核架构调度器还得考虑“异类线程调度策略”——把渲染、前台交互这类高优先级线程放到大核把后台任务放到小核性能和功耗两手抓。这些现象说明线程、协程并不是非此即彼的替代关系它们在不同约束下各自演化服务于同一个目标在有限CPU资源下安排尽可能多的执行流同时把切换代价控制在可接受范围。3. 从代码和排障实战看联系线程池、asyncio、守护进程与死锁3.1 Java线程池核心线程、阻塞队列、最大线程的配合逻辑很多人把线程池当成一个“随便调参数的连接池”出了问题只会改大小这是对线程池最大的误解。以Java的ThreadPoolExecutor为例构造函数里的核心线程数、最大线程数、阻塞队列、拒绝策略四个参数构成了一个非常精确的任务接纳流程提交任务时如果当前线程数小于核心线程数直接新建线程执行。如果线程数已经达到核心线程数任务先放入阻塞队列排队。如果队列满了再尝试把线程数扩到最大线程数。如果最大线程数也满了触发拒绝策略。这个顺序不是拍脑袋定的而是为了平滑应对负载波动。突发流量先进入队列缓冲队列满代表缓冲不够用才开新线程提高处理能力如果连最大线程都扛不住那就拒绝外部请求而不是让系统继续恶化。阻塞队列选型是面试里最常见的连环追问。有界队列ArrayBlockingQueue能限制积压任务数但需要配合合理的拒绝策略否则突发流量一到就疯狂拒绝无界队列LinkedBlockingQueue不会拒绝但任务无限堆积会导致内存暴涨适合后台任务量平稳、不允许丢弃的场景SynchronousQueue不缓存任务来一个任务必须立即交接给工作线程如果没有空闲线程就新建适合任务执行时间极短的场景。这是一份我在实际项目里验证过比较稳的配置思路ThreadPoolExecutor pool new ThreadPoolExecutor( 4, // corePoolSize平时常驻的线程数按CPU核数附近取 16, // maximumPoolSize峰值时最多扩到多少 30, TimeUnit.SECONDS, // 空闲线程存活时间 new ArrayBlockingQueue(1000), // 有界队列积压上限1000 new ThreadPoolExecutor.CallerRunsPolicy() // 满了之后让提交任务的线程自己执行 );CallerRunsPolicy比AbortPolicy温和任务丢不出去只是让调用方线程“背锅”执行起到天然限流作用。这里要补一句调整堆大小解决不了线程池问题线程池问题要看的是队列积压、拒绝策略、线程状态而不是JVM堆。热点里那个“堆大小调到8000还报OOM”的案例多半是有人把这里搞混了。3.2 Python协程实战别让协程干线程的活儿Python的asyncio是协程最典型的落地场景。它的核心是一个事件循环循环负责调度一个协程队列中的任务。协程执行到await时挂起事件循环把控制权交给另一个可运行的协程。真实IO操作网络请求、数据库查询本来就是等外部响应线程在等待时CPU完全空闲协程就是在这些等待间隙里穿插运行别的任务让CPU利用率拉满。import asyncio async def fetch_one(url): print(fstart {url}) await asyncio.sleep(1) # 模拟网络IO等待这里会主动让出控制权 return url async def main(): urls [https://a.com, https://b.com, https://c.com] results await asyncio.gather(*(fetch_one(url) for url in urls)) return results但注意如果协程里出现了同步阻塞调用比如直接用requests.get()整个事件循环都会被卡住因为那个线程在等网络响应协程没有机会让出来。解决办法是用loop.run_in_executor把阻塞调用扔到默认线程池里协程await它返回的Futureimport asyncio import requests async def fetch_old_style(url): loop asyncio.get_running_loop() return await loop.run_in_executor(None, requests.get, url)这里其实体现了一个很微妙的层级关系顶层是协程业务逻辑用await编排中间是线程池承载同步阻塞任务底层是操作系统的进程/线程调度。一个项目里同时出现协程、线程、进程是非常自然的理解它们各自的边界才不会把系统写成一个“所有阻塞都堆在主线程”的灾难现场。再提一个热词“python线程嵌套线程”Python里创建线程成本不高但管理多个线程的退出、异常传递、资源释放非常麻烦。我见过有人在线程里再开线程做定时任务主线程一退出子线程还没来得及清理就直接被终止留下半写文件。如果说Python有什么比Java更容易踩线程坑的地方就是太多库设计成隐式创建线程你得像侦探一样把后台线程找出来给它们明确的退出信号。3.3 死锁、守护线程和原子类把并发基础补扎实线程互斥的本质是当多个线程要修改同一份共享数据时必须保证同一时刻只有一个线程在修改其他线程要么等待要么读到旧值但绝不能读到中间态。最直接的方式是加锁。但加锁是有代价的其中一个代价就是死锁。死锁的经典场景是两个线程各持有一把锁都在等对方手里的另一把锁线程A执行流程获取锁X → 获取锁Y → 释放锁Y → 释放锁X线程B执行流程获取锁Y → 获取锁X → 释放锁X → 释放锁Y两个线程同时抢占到第一把锁后都在等对方释放第二把锁谁也不会先放。排查这种问题Java里用jstack打印线程dumpLinux下用gdb attach进程然后看线程栈思路是一致的找出一组线程它们的持有锁和等待锁关系形成环。解决方式也简单粗暴全局约定锁的获取顺序所有人都按同样的顺序获取锁环就断了。再聊几个高频基础点。线程等待Java用Thread.join()等待指定线程结束原理是让当前线程进入WAITING状态直到目标线程终止这和Linux下父进程用wait/waitpid回收子进程退出状态是同一套思想都是“等待我关心的执行流结束”。守护线程Java里setDaemon(true)表示该线程不阻止JVM退出主线程结束它就被强杀适合做一些清理临时文件、心跳上报的活这在操作系统层面对应UNIX的daemonize用setsid脱离终端会话避免关掉终端导致SIGHUP把服务误杀。获取当前线程名一句Thread.currentThread().getName()排查日志时很有用建议所有线程日志前缀带上线程名不然线上很难定位是哪条线程在跑。3.4 进程池与进程守护从进程视角看服务架构协程、线程解决的是“一个进程内怎么并发”但很多场景需要的是真正意义上的多进程比如CPU密集计算因为GIL或内存隔离线程帮不上忙。Python的multiprocessing.Pool、Java的ForkJoinPool进程池版本、Linux命令行里的xargs -P都是在复用进程资源避免频繁创建销毁进程。选择多进程意味着接受更高的创建和切换成本但换来的是更强的隔离性和多核并行能力这是结构性的取舍。服务型进程还有一个常见设计守护进程daemon。一个守护进程会调用fork然后让父进程退出、调用setsid创建新会话、把工作目录切到不受卸载影响的路径、关闭无用文件描述符。这么做的目的是让服务进程不依赖启动它的终端存在终端关掉也不会把服务带走。你在Linux服务器上看到一个后台服务一直跑着它的进程树往往挂在init/systemd下而不是挂在你的shell下就是daemon化的结果。Windows虽然没有完全对应的概念但Windows服务由服务控制管理器SCM管理进程被杀掉后可以配置为自动重启这也是热词里“杀了一个PID又换了一个”的一个常见来源——杀掉的只是一个服务实例服务管理器马上又拉起一个新的。4. 常见问题与排查技巧实录从“杀不掉的进程”到“资源图化简”4.1 进程“杀不干净”先找到谁在拉起它很多人在Windows上遇到过一个现象某个进程占CPU很高或者占着某个文件不放把它杀了过几秒又出现新的PID。这时候第一反应千万别是无脑强杀而是先回答一个问题这个进程是谁创建的、谁在监管它。排查顺序推荐这样走第一步用Process Explorer按PID排列查看该进程的父进程只要不是系统核心进程一般都能顺着树找到根第二步在管理员命令行执行tasklist /svc把PID映射到Windows服务如果它挂在某个服务下杀进程无效必须停服务第三步如果是网盘、下载器的加速进程这类组件它们往往由主程序在后台拉起强杀后主程序检测到进程退出又自动重建正确做法是在主程序的设置里关闭相关功能而不是和PID作斗争。文件占用问题也同理。“F盘被另一个进程锁定”这类报错本质是某个进程打开了F盘上的文件目录句柄导致盘符无法弹出或格式化。用Process Explorer的Find - Find Handle or DLL输入盘符路径直接能看到是哪个进程持有句柄。如果是WebView2这类网页视图组件导致的它通常是主应用比如某个编辑器或桌面客户端的子进程关掉主应用即可单独强杀它只会让主应用瞬间重新拉起一个新的实例。4.2 夺回“CPU占用率100%”的现场Windows任务管理器里看到CPU占用100%第一件事不是结束进程而是先定位到底哪个进程、哪个线程在烧CPU。任务管理器默认只显示进程级别双击进程切到线程页能看到该进程内各线程的CPU占用排序更好的做法是用Process Explorer双击目标进程按CPU列排序线程找到占用最高的那条线程ID。Linux下对应命令是top -Hp PID或者ps -L -p PIDJava应用配合jstack分析那条线程的状态到底是在执行用户代码RUNNABLE、等待锁BLOCKED还是等IOWAITING。我踩过的坑是线上服务CPU飙高直接重启然后过了两天又复现白白错过了抓现场的机会。正确做法是先采集线程dump和CPU火焰图再处理故障。Java进程可以执行jstack stack.logLinux下可以用perf record -p PID -g采集几秒钟然后perf report看火焰图的瓶颈函数。多数CPU打满的真相都能在火焰图里现形要么是GC占大头要么是某个序列化/加解密函数变成热点要么是代码真死循环了。4.3 那些“意外终止”的进程OOM、服务崩溃与RTOS任务切不动“进程意外终止”听起来像是玄学但打开系统日志和进程自己的日志基本都能找到明确线索。Windows上MySQL的1079/1067错误这类场景服务进程启动后立即退出常见原因无非几个数据目录没有写权限、端口被占、配置文件路径错误、内存不足。先看错误日志再看Windows事件查看器里的应用程序日志顺序别反。Java进程的OutOfMemoryError要区分类型。如果是java.lang.OutOfMemoryError: Java heap space堆内对象确实把-XX:HeapDumpOnOutOfMemoryError设置的dump文件打出来用MAT分析支配树看看是不是某个缓存/列表借助了90%的堆。把堆从4G调到8G仍然报错十有八九不是堆不够大而是代码泄漏或数据量爆炸调大堆只是把爆炸时间往后拖。如果是Metaspace或native memory相关报错那是非堆内存问题调-Xmx一点用都没有得去看线程栈相关的RSS内存增长和direct buffer使用。嵌入式领域那个“FreeRTOS切不了线程”的热词本质也是任务调度的问题。RTOS里的每个任务相当于一个系统线程如果任务无法切换通常要查三个地方中断优先级是否把临界区包得太长、某个任务是否在禁用调度器的前提下执行了阻塞操作、任务栈是否溢出导致调度器崩溃。方法不同思路一致调度器切换执行流的前提是当前上下文安全且可保存任何破坏这个前提的操作都会导致“切不动”。4.4 用“进程资源图”检测死锁五步化简法操作系统教材里的进程资源图化简很多人觉得是纯理论其实它就是死锁检测的图形化思考方式。画法很简单进程用一个圆资源用方形方形里的黑点表示资源实例数量进程到资源画一条箭头表示“请求”资源到进程画箭头表示“分配”。化简规则也清楚先找那些“当前能获得全部所需资源”的进程——也就是所有请求边指向的资源都有剩余实例它就不会被阻塞让它运行完释放已占用的资源这些资源实例归还到资源节点里重复这个过程。如果到最后所有进程都能被标记为“可完成”说明无死锁如果某一轮开始没有任何进程能被选中剩下的进程就是死锁进程集合。这个化简过程和线上排查死锁用的jstack逻辑一模一样找出所有线程需要的锁再看哪些锁空闲、哪些被其他线程持有、哪些形成等待环。你只要在纸上化简过一次资源图再看线程dump的时候就会有一种“这图我见过”的感觉。它最大的价值不是应付考试而是把死锁从“玄学”变成“有向图的环检测”一切靠推理不靠猜。5. 收尾前再聊一个实操习惯我个人带团队时定过一个“并发三件套”规矩遇到任何并发问题先把进程树、线程dump、协程栈三张截图拼在一起看。为什么是三张因为很多线上怪问题的根源正是三个层级在互相影响。进程本身活得好好的但它某条线程陷入死锁把整个协程池的任务全卡住某个进程CPU被打满可能只是因为它某条线程在等一把永远持在他自己手里的锁。只盯一个层级很容易得出错误结论。这套分析方法比背再多“进程是资源分配单位、线程是调度单位”的定义都管用。希望这篇文章不只是帮你过面试更能让你在真正排查问题的时候脑子里有一张清晰的分层地图。
返回列表