1. 从一个经典需求说起:进程与线程到底解决什么问题
很多人在学操作系统的时候,都会在“进程和线程”这里卡一下,尤其是到了第8天、第9天这种学习节奏,前面刚弄明白CPU怎么调度、内存怎么分页,突然又冒出两个新概念,而且很多教材上来就甩定义:“进程是资源分配的最小单位,线程是CPU调度的最小单位。”字面意思好背,但你真的把这两个东西塞进自己的程序里,让它们跑起来、协作起来、不出错,就是另一回事了。
我自己的理解路径是这样的:先别管学术定义,先看需求。假设你要做一个文件批量转码工具,输入是一堆视频文件,输出是转码成功的文件。如果你是单线程程序,那么逻辑就是循环打开一个文件、转码、写盘、再打开下一个。这里有个很直观的问题:转码期间CPU在忙,但磁盘I/O在等待,甚至网络如果涉及拉取字幕也是空闲的,整条流水线被最慢的那个环节死死拖住。你当然可以说“我用异步回调”,但回调地狱写起来难受,调试也麻烦。后来换成开多个线程或进程,每个任务各干各的,整体吞吐立刻上来了。所以进程和线程,本质上是为了在“等待”发生的时候,让CPU还有其他活可干,这是并发要解决的最原始问题。
如果你是初学者,这篇文章适合你作为第8天之后的一份补充笔记;如果你已经写过不少并发代码但总在死锁、资源竞争上翻车,那这篇里的踩坑实录和排查思路,应该也能给你一些参考。我会尽量把原理和实操揉在一起讲,避免那种“概念看懂、代码写崩”的尴尬。
1.1 单程序顺序执行的瓶颈到底在哪
先说一个特别容易被忽略的事实:CPU是现代计算机里最快的部件之一,但磁盘、网络、内存访问甚至用户输入,都远远慢于CPU的主频。比如一次SSD随机读可能要几十微秒,而CPU执行一条指令只要零点几纳秒,差了三四个数量级。这就意味着,如果你的程序每做一步都要等一次I/O,CPU几乎全程在空转。
多进程、多线程的核心思路,就是利用这段空闲时间,去做别的任务。这就像餐厅里只有一个厨师,他既要负责接单、又要切菜、又要炒菜、还要洗碗,流程是串行的,客人一等就是半天。多线程的思路不是让厨师一秒变三个人,而是让他在“等水烧开”的时候,先去切下一道菜的葱姜蒜。线程切换的是“执行上下文”,进程切换的是“整间厨房的归属权”。
还有一个容易混淆的点:多线程和并行不是一回事。如果你的CPU只有一个核,线程之间其实是时间片轮转,只是切换得足够快,看起来像同时跑。多核CPU出现之后,多个线程才可以在不同物理核上真正同时执行。理解了这一点,后面你就明白为什么某些场景用多进程优势明显,某些场景用多线程就够了。
1.2 进程和线程的本质区别:资源拥有者与调度单位
学术界有一个很精炼的区分方式:进程是资源拥有的实体,线程是CPU调度执行的基本单位。什么意思呢?每个进程都有自己独立的地址空间、打开的文件描述符表、信号处理器、当前工作目录等。而线程寄生在进程里,同一进程下的多个线程共享这些资源,但每个线程保留自己的栈、寄存器状态、程序计数器。
用大白话讲:进程是一个“独立的脸盆”,各自装着水、肥皂、毛巾,你把这个脸盆端走,不影响另一个脸盆里的水。线程则是“共享浴池里的几个人”,水面波纹互相影响,大家互相看得见,但要小心别把别人的肥皂碰掉。共享带来的是效率,也带来同步问题。
正因为进程之间默认完全隔离,所以一个进程崩了,通常不会直接把另一个进程带走。这在大系统里很重要。比如你用多进程做worker,其中一个worker因为野指针崩溃了,主进程还能把它重新拉起来。但多线程就不行了,一个线程把共享内存写坏,整个进程可能直接退出。这也是很多长期运行的服务,架构上宁可多开几个进程而不是疯狂加线程的原因。
2. 深入进程:地址空间、生命周期与通信
2.1 进程的组成与状态切换
一个进程在内存里,主要包含四块区域:代码段、数据段、堆、栈。代码段是只读的指令;数据段放全局变量和静态变量;堆是运行时动态分配的内存,向高地址增长;栈是函数调用和局部变量,向低地址增长。操作系统用页表把这些虚拟地址映射到物理内存,这层映射关系是进程私有的,因此一个进程的地址空间天然隔离。
进程从生到死,有几种经典状态:新建、就绪、运行、阻塞、终止。其中最容易理解的是就绪和阻塞的区别。就绪表示进程万事俱备,只差CPU分配时间片;阻塞表示进程正在等待某个事件,比如等磁盘I/O完成、等用户输入,此时哪怕CPU空闲,它也没法执行。调度器只会在就绪队列里挑选进程上CPU运行,所以如果你写的程序一直在阻塞等待,它的状态会一直是阻塞,不会消耗CPU,但整体进度也停着。
调试多进程程序时,看进程状态非常有用。比如你用某个并发框架,发现某个worker进程一直处于S状态(可中断睡眠),说明它在等I/O;如果一直处于R状态(运行或就绪)但业务进度不动,大概率是陷入死循环或者锁竞争。这能帮你快速缩小排查范围。
2.2 进程间通信的几种常用手段
因为进程之间地址空间隔离,想交换数据就得通过操作系统提供的“公共通道”。常见的有管道、消息队列、共享内存、信号量、套接字、信号。各自特点不同,选型逻辑也不同:
- 管道:适合父子进程或亲属进程之间的简单字节流传输,数据单向流动,实现简单,但缓冲区有限,容量太小或者读写节奏不匹配容易阻塞。
- 消息队列:本质是一串有格式的消息结构,发送方把消息放进队列,接收方按类型读取,优点是不需要双方同时在线,缺点是有消息长度限制,且内核态和用户态之间拷贝有额外开销。
- 共享内存:把同一块物理内存映射到多个进程的虚拟地址空间,速度最快,但并发控制要自己用信号量或锁来保证,是高性能场景的首选,也是出bug最多的地方。
- 套接字:不止用于网络通信,同一台机器上也能用Unix Domain Socket,适合跨机器的通用需求,数据可以走完整TCP/IP协议栈,但性能比共享内存低。
选型建议很简单:优先选能解决问题的简单方案。如果只是2个小进程做一次性数据传递,管道就够了;如果频繁读写大量结构化数据,优先考虑共享内存+信号量;如果要跨机器扩展,就上消息队列或套接字。别一开始就上一套复杂的消息中间件,很多时候性能瓶颈根本不在通信手段上。
2.3 进程开销为什么这么大
很多人问:既然进程通信这么麻烦,那为什么不全部用线程?因为进程的开销和隔离性也是实实在在的收益。创建进程时,内核要分配独立的地址空间、页表、文件描述符表、信号处理结构等。在Linux上,fork()要复制页表,即便有写时复制优化,第一次访问各个内存页也免不了缺页中断。线程创建开销比进程小一个量级,因为线程共享进程大部分资源,只需要分配栈和线程控制块。
但如果一个任务本身会崩溃,进程的隔离价值就远大于额外开销。我见过一些项目把每个任务都放进一个独立子进程,任务崩了自动重启,宿主程序毫发无伤。这种“以空间换安全”的取舍,在长期运行的服务里非常常见。另外,进程之间的CPU调度也相对独立,不会因为一个进程内部某个线程死循环把整个机器的其他进程拖死——当然,如果那个线程占满了所有CPU核心,调度器还是会把它时间片切走的,影响没那么夸张。
3. 线程详解:轻量在哪里,同步机制怎么选
3.1 线程的共享资源与独立资源
同一进程里的线程,共享哪些东西?非常直接:共享代码段、数据段、堆、打开的文件、信号处理、当前工作目录。不共享哪些东西?每个线程有独立的线程ID、栈、寄存器上下文、程序计数器、信号掩码、errno值。这里面对写代码影响最大的是:栈是独立的,堆是共享的。
这意味着,你在线程函数里声明的局部变量,天然线程私有;你用malloc或者new出来的对象,在线程之间传递时需要小心。如果两个线程同时修改同一个指针指向的内存,没有加锁,那就是典型的竞争条件。很多人写并发程序出问题,不是不知道锁,而是搞不清哪些数据结构是共享的。一个经验做法:在代码注释里明确标注每个全局对象被哪些线程访问,凡是超过一个线程访问且存在写操作,就必须有同步。
3.2 锁、条件变量、信号量与读写锁
线程同步的“五件套”是互斥锁、条件变量、信号量、读写锁、自旋锁。我按实际使用频率排一下:
- 互斥锁:最基础的排它锁,同一时刻只允许一个线程进入临界区。适合临界区执行时间不长、竞争不激烈的情况。标准库里的lock_guard、with语句本质都是这个。
- 条件变量:它不是用来保护数据的,而是用来等待“某个条件成立”。典型应用是生产者-消费者模型:消费者发现队列空,就wait,生产者往队列里放数据后notify,消费者被唤醒。注意条件变量必须和互斥锁配合使用,避免lost wakeup。
- 信号量:一个整型计数器,P操作减一,V操作加一,为0时P操作阻塞。它既可以当互斥锁用(初始值1),也可以限制并发数(初始值N)。但信号量的语义更宽泛,自己管理初始值和等待逻辑,容易出错,能用互斥锁和条件变量解决就别硬用信号量。
- 读写锁:允许多个线程同时读,但写的时候独占。适合读多写少场景。要注意的是,读写锁可能出现写者饿死——如果读者源源不断进来,写者一直等不到机会。需要选择带写优先策略的实现。
- 自旋锁:加锁失败后不会睡眠,而是忙等待,反复检查锁是否释放。好处是避免线程切换开销,但坏处是占满一个CPU核心。只建议在临界区极短且明确不会阻塞的场景用,比如多核CPU上的原子操作补充。
选锁的原则永远是“够用就好,越简单越好”。不要为了炫技搞复杂的无锁编程,除非你能证明这块真是性能热点。
3.3 线程安全与可重入
很多新手会混淆“线程安全”和“可重入”。线程安全是指多个线程同时调用同一个函数时,不会产生未定义行为,通常通过内部加锁或使用线程局部存储实现。可重入是指同一个线程在函数执行过程中再次进入该函数(比如信号处理函数里调用它)也不会出错。两者并不等价。
以C库的strtok为例,它内部保存了上次扫描的位置,用静态变量,不是线程安全的。strtok_r把它改成显式传入上下文,就变成可重入的。但可重入的函数不一定是线程安全的,如果上下文对象本身被多个线程共享且不加锁,照样出问题。写并发代码时,优先使用线程安全的库函数和容器,被迫用非安全版本时,一定要在外部加锁。这事看起来简单,实际是很多隐蔽bug的来源。
4. 多线程/多进程的实战:代码怎么写才不容易出问题
4.1 用 Python 给一个爬虫任务提速
Python因为GIL的存在,很多人说多线程没用。实际上,如果你的任务是I/O密集,比如网络爬虫、文件读取、数据库查询,多线程的好处非常显著。GIL在线程等待I/O时会被释放,其他线程可以继续执行。只有纯CPU密集的Python计算任务,多线程才会因为GIL导致几乎无法并行。
我用一个爬虫例子说:抓取1000个网页,单线程串行大约需要10分钟,因为每个请求要等响应。用ThreadPoolExecutor起10个线程,总时间能压到1分钟多一点点。核心代码大概是这样:
from concurrent.futures import ThreadPoolExecutor, as_completed import requests urls = [f"https://example.com/page/{i}" for i in range(1000)] def fetch(url): resp = requests.get(url, timeout=5) return url, len(resp.content) with ThreadPoolExecutor(max_workers=10) as pool: futures = [pool.submit(fetch, u) for u in urls] for future in as_completed(futures): url, size = future.result() print(f"{url} -> {size}")注意max_workers不要拍脑袋填100,线程太多反而增加上下文切换开销。先测几个梯度,10、20、50,看哪个吞吐最高。很多情况下10-20个就够跑满带宽了,再多的线程只是在排队等网络事件。
如果是CPU密集任务,Python要用multiprocessing,也就是多进程,绕开GIL。但进程间通信和序列化开销会变高,数据小的情况下反而不划算。
4.2 线程池和进程池的取舍
无论线程还是进程,频繁创建销毁都很贵。解决思路是用池子,预先创建一批worker,任务进来就分发,干完活继续待命。
线程池适合I/O密集且共享状态不太多的任务,进程池适合CPU密集或者需要隔离的任务。关键指标是任务粒度:如果每个任务只需要1毫秒,用池子更合适,省去创建线程的时间;如果每个任务要跑几分钟,那直接按需创建线程也没问题。
在Java里,线程池的构造参数其实很有讲究:核心线程数、最大线程数、工作队列容量、拒绝策略。很多人只用默认的newFixedThreadPool,却忽略了队列可能积压无限导致内存爆炸。推荐在生产场景使用有界队列,并配置合理拒绝策略,比如CallerRunsPolicy让提交任务的线程自己执行,起到天然背压的作用。
C++的std::thread没有池化方案,一般配合线程池框架或者自己封装。我自己常用的简单方案是:固定N个线程,每个线程循环从任务队列里取任务执行。配合条件变量通知新任务到达,效果非常稳定。N取值一般等于CPU核数+1,这是很多框架默认经验值,但实际要用压测验证。
4.3 操作系统线程 vs 用户态协程
协程是很多现代语言(Go、Python async、Kotlin协程)的底层基础。协程运行在用户态,切换不经过内核,所以开销比线程小得多。你可以开十万个协程,但十万个线程大概率直接把系统打挂。
协程适合I/O密集的场景,本质上是把你的函数拆成可挂起、可恢复的小片段,在等待I/O时主动让出控制流。但协程也有缺点:它依赖事件循环和非阻塞I/O,如果你在协程里调用了一个阻塞的库函数,整个事件循环都会被卡住。所以Go里有个忠告:不要在goroutine里做阻塞的系统调用,要用异步版本或者把阻塞操作放到单独的worker池里。
线程和协程不是二选一,很多架构是两者结合:外层用少量线程跑事件循环,每个事件循环里跑大量协程。网络服务器、游戏网关基本都是这个套路。学习的时候建议先弄清线程的同步原语,再接触协程,否则直接上手协程,遇到数据竞争反而更糊涂。
5. 高频踩坑现场:死锁、竞争、资源耗尽怎么办
5.1 死锁产生的四个条件与排查实例
死锁是并发编程的经典噩梦。形成死锁要同时满足四个条件:互斥、持有并等待、不可抢占、循环等待。实际操作中,最常见的写法就是“两把锁交叉加锁”。
举个例子,线程A持有锁1去申请锁2,线程B持有锁2去申请锁1,两边都不松手,直接死锁。排查时,先看代码里有没有这种反向加锁的顺序。经验法则是:给所有锁排序,严格按照同一个全局顺序加锁,就能从根本上避免循环等待。
实际排查死锁的工具很成熟。Linux下用gdb attach到进程,执行thread apply all bt查看所有线程的堆栈,能看出来谁等在哪把锁上。Java里可以用jstack打印线程转储,直接显示BLOCKED状态和持有锁的线程。Python的faulthandler可以在超时后dump所有线程栈。遇到诡异卡顿,别急着反复看代码,先dump栈,十有八九锁的位置立刻暴露。
我经历过的真实案例是一个任务分发服务,偶尔卡死,概率非常低。最后用核心转储分析发现,一个线程在持有缓存锁的同时等待网络响应,而网络回调线程在处理数据前需要拿缓存锁,于是形成互相等待。解决方法是把网络回调里的加锁操作改成异步提交,不在回调里直接竞争锁。
5.2 竞争条件和内存可见性
竞争条件是指程序的结果依赖于多个线程的调度顺序,未定义行为随时可能发生。最常见的是“先检查后更新”模式:比如双线程对同一个变量执行if (count > 0) { count--; },两个线程都检查到count==1,然后先后减一,结果count变成0,但实际上应该减两次,或者只允许一次减操作。
还有内存可见性问题。在编译器优化和CPU乱序执行的影响下,一个线程修改的变量不一定会立刻被另一个线程看到。C++里要用std::atomic或者加锁,Java里要用volatile或者加锁。Python因为GIL的存在,单个变量的简单读写实际上是原子的,但复合操作依然不安全,比如self.count += 1,它是读-加-写三步,GIL可能在中间切换线程。
避免竞争的根本方法是设计数据所有权:让每份可变数据只被一个线程写,其他线程只读。或者使用不可变对象,修改时复制一份。能不用共享可变状态就不共享,这是最省心的路子。
5.3 文件描述符与线程数量上限
并发程序还有一类头疼问题不是锁,而是资源耗尽。Linux下每个进程能打开的文件描述符数量默认是1024,如果你用多线程同时发起大量网络请求,很快会报Too many open files。解决办法是先调高ulimit,再在代码里合理限制并发数,尽量复用连接。
线程数量上限也很容易踩。每创建一个线程,内核要分配栈空间(默认8MB虚拟内存),所以虽然总虚拟内存看起来很大,但32位系统上或者并发线程过多时,栈空间直接吃满。另外线程切换本身也要消耗CPU,线程数超过核心数后,吞吐往往不升反降。一个健康的监控指标是:线程数量不随任务量无限增长,该排队排队,该限流限流,让系统处于稳定状态。
6. 一些值得保留的经验和工具
6.1 从“第8天”的学习路径看,后面还要补哪些知识
如果你今天是第8天学进程与线程,后面我建议再花几天把这几块补上:第一,I/O模型,包括阻塞、非阻塞、多路复用(select、poll、epoll)以及异步I/O,因为绝大多数高性能服务都在这里下功夫;第二,并发模型的思想,不只是语言API,而是回头看生产者消费者、读者写者、哲学家就餐这些经典问题,锻炼自己找同步点的能力;第三,内存模型,理解乱序执行、内存屏障,以及每种语言的内存序保证。学完这些,再回去看协程和actor模型会顺畅很多。
学习方式上,别只盯着理论,多写小程序做实验。比如写一个多线程累加一个共享变量,分别用加锁、原子操作、局部汇总三种方式,看看耗时差异和正确性变化。自己跑一遍比背十遍书管用。
6.2 推荐几个调试和监控工具
我这几年用得顺手的工具,简单列一下:
top/htop:看进程和线程的CPU占用率。top -H可以查看每个线程的CPU使用情况,定位是哪个线程在烧CPU。strace:跟踪进程的系统调用,能看出进程卡在哪个内核函数上,比如futex等待、read阻塞等,排查死锁和I/O卡住很好用。gdb+thread apply all bt:多线程C/C++程序的调试利器,死锁、崩溃都能从这里找到线索。jstack/jcmd Thread.print:Java服务线程转储,配合jstat看GC和锁竞争。- Python的
py-spy:不需要改代码,就能dump正在运行的Python程序的线程栈,解决GIL、死循环问题特别管用。
遇到并发问题,我的建议流程是:先看监控,是CPU高、内存高、还是卡住没反应;然后dump线程栈,拿到堆栈证据;其次看代码条件,复现问题;最后再改代码。最忌讳的就是没定位到根因,靠猜测换写法,结果问题更隐蔽。
最后说一点个人感受。进程和线程学了很多年,其实每天都要和它们打交道,只是很多时候被框架封装住了。但一旦出了问题,发丝一样的细节都能被放大成线上事故。认真理解它们的本质,花时间练同步手段,是值得的。第8天只是一个开始,后面还有锁优化、无锁数据结构、分布式一致性等在等着你,基础扎实了,这些进阶的路会好走很多。