1. 为什么操作系统非得引入“线程”这个概念
1.1 从进程说起:一个进程到底“重”在哪
大学上操作系统课的时候,老师通常先讲进程,再讲线程。当时很多同学没想明白一个问题:进程不是已经能跑程序了吗,为什么还要搞一个线程出来?我当年也困惑了很久,直到后来自己写并发服务,被性能问题虐了几轮,才真正理解线程是对“进程太重”这个问题的直接回应。
进程这个概念本身没有错,它解决的是“程序在内存里怎么被隔离、怎么被调度”的问题。每个进程有独立的地址空间、独立的文件描述符表、独立的信号处理方式,这保证了多个程序同时跑在系统里互不干扰。但问题恰好出在这个“独立”上——它太贵了。
进程切换的时候,操作系统要干一堆脏活累活:保存当前进程的寄存器现场、程序计数器、栈指针,更新进程控制块(PCB)里的各种状态信息,刷新TLB(快表),让CPU缓存失效,再加载新进程的上下文。整个过程涉及特权级切换,时间开销是纳秒级还是微秒级先不说,关键是它触发得太频繁了。
还有一个问题是进程间通信。因为地址空间是隔离的,进程A和进程B想交换数据,只能走管道、消息队列、共享内存、Socket这些“绕路”机制。每次通信都要进内核,都要做数据拷贝,效率大打折扣。比如你用两个进程去实现一个生产者-消费者模型,光数据搬运和同步的开销就能吃掉你一大半CPU。
所以业界面临一个很现实的矛盾:我们希望多个任务能并发执行,但又不想承担进程切换和通信的巨大开销。线程就是在这个背景下被引入的。线程不是要替代进程,而是把进程变成“资源容器”,让线程作为真正的执行单元在里面跑。
1.2 线程的本质:一个进程内部的多条“执行流”
线程到底是什么?从概念上讲,线程是进程内部的一个执行流,是CPU调度的最小单位。一个进程可以包含一个或多个线程,这些线程共享进程的地址空间、全局变量、打开的文件等资源,但每个线程有自己的程序计数器、寄存器集合和栈空间。
这里的“共享”是线程最大的优势,也是最大的坑。优势在于,线程之间通信几乎不需要内核干预——同一个进程里的线程直接读写同一块内存就行,不需要管道、消息队列这些复杂机制。数据共享的效率比进程间通信高几个数量级。坑在于,共享意味着竞争,竞争就带来数据不一致、死锁、竞态条件这一系列问题,后面我会专门讲。
在学习线程的时候,有一个经典的比喻特别形象:进程是“一个公司”,有独立的办公楼(地址空间)、财务(文件描述符表)、人事(信号处理器)。线程是公司里的“员工”,他们共享办公楼、财务和人事,但每个员工有自己的工位(栈)、自己的笔记本(寄存器)和工作清单(程序计数器)。公司可以只有一个人,也可以有几百号人;一个人挂了公司未必倒闭,但整栋办公楼塌了,所有员工都得完蛋。
这个比喻能帮你理解两个关键点。第一,线程同步问题为什么难解决——因为员工之间抢打印机、抢会议室是常态。第二,为什么线程崩溃可能导致整个进程崩溃——因为线程没有独立的地址空间保护,一个线程越界访问内存,可能把整个进程的数据都踩坏。
1.3 线程与进程的对照:一张表说清核心差异
很多计算机网络和操作系统教材都会给一张线程和进程的对比表,我这里也整理一份,但会加上一些实际场景的注释,帮助你把抽象概念落到具体问题上。
| 对比维度 | 进程 | 线程 | 实际影响 |
|---|---|---|---|
| 资源拥有 | 拥有独立地址空间、文件、信号等资源 | 共享进程资源,只拥有栈和寄存器上下文 | 线程创建开销远小于进程 |
| 调度单位 | 早期OS以进程为调度单位 | 线程是CPU调度的基本单位 | 线程切换更快,但不代表可随意创建过多线程 |
| 通信方式 | 需要IPC(管道、共享内存、Socket等) | 直接读写共享内存,天然共享数据 | 线程通信方便但需要同步机制 |
| 系统开销 | 创建/切换/销毁开销大 | 创建和切换成本低 | 高并发服务大量使用线程模型 |
| 健壮性 | 进程间隔离,一个进程崩溃不影响其他进程 | 一个线程崩溃(如段错误)可能导致整个进程崩溃 | 需要在线程内做异常防护 |
| 同步方式 | 相对简单,系统级IPC自带阻塞机制 | 必须用锁、条件变量、信号量等专门工具 | 同步复杂性是并发编程的核心难点 |
补充一个面试高频考点:线程是“资源调度的基本单位”,而进程是“资源分配的基本单位”。这句话听起来绕,翻译一下就是:CPU给谁分配时间片,看的是线程;内存、文件这些资源给谁用,看的是进程。你启动一个Java程序,操作系统分配给它的是进程级别的资源;但里面跑多个业务线程时,CPU调度的是线程。
1.4 线程带来的三个核心收益
引入线程之后,操作系统在三个维度上获得了实实在在的收益,这也是后面的线程模型、线程池、同步机制都建立在这些收益之上的原因。
第一,并发度提升。在一个多核CPU的机器上,如果一个进程是单线程的,那它无论怎么跑都只能用上一个核。有了多线程之后,一个进程可以同时利用多个CPU核心,把计算能力吃满。这也是为什么现在所有高性能服务(Nginx、Redis虽然是单线程模型,但也依赖于多进程;像Tomcat、Netty这类是典型的多线程模型)都要用多线程/多进程来压榨多核硬件。
第二,响应性提升。在GUI程序里尤其明显——主线程负责界面刷新和用户交互,后台线程负责下载文件、处理图片、查询数据库。如果所有事情都挤在一个线程里做,用户点一个按钮,界面就卡死几秒钟,这种体验是不可接受的。多线程可以把耗时操作“挪到后台”,让界面保持流畅。
第三,资源共享与通信成本降低。线程共享进程的地址空间,所以多个线程操作同一个全局数据结构时不需要走内核IPC。比如一个Web服务器需要统计总请求数,用一个全局计数器,每个请求线程直接自增就行。如果是多进程设计,你就得用共享内存加信号量,麻烦得多。
不过我必须提醒一句:多线程不是免费的午餐。每创建一个线程,内核就要给它分配栈空间(默认通常是1MB甚至8MB),线程一多,内存就被吃掉了。线程切换依然有上下文切换开销,只是比进程切换轻而已。更重要的是,共享内存带来的同步问题极其容易引入隐蔽的Bug——这些问题比单线程的Bug难排查十倍都不止。
所以线程的正确使用方式是“适度并发”,而不是“拼命开线程”。我见过不少初学并发编程的同学,一上来就是一个连接开一个线程,结果压测到两三千并发,系统直接卡死,最后发现线程栈内存把堆内存挤爆了。这个话题我们后面讲线程池的时候会深入展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程的生命周期与核心操作
2.1 线程的状态流转:五态模型其实不复杂
操作系统的教材里,线程状态模型一般有两种划分方式,一种是简化的三态模型——就绪、运行、阻塞,还有一种是更完整的五态模型,在就绪和运行之外加入了创建、阻塞挂起、退出等状态。考试和面试常考的是前者,但实际工程中后者的思考方式更有用。
我先按教材口径梳理一下线程从创建到销毁的全过程。一个线程诞生之后,会在下面几个状态之间流转:
- 新建(New):线程对象已经创建,但还没调用start方法,此时线程还没有被操作系统内核感知,只是一个普通的Java对象或者C++对象。
- 就绪(Runnable/Ready):调用了start方法之后,线程已经具备运行条件,等待操作系统的调度器分配CPU时间片。一个线程从运行态被抢占(时间片用完了)也会回到就绪态。
- 运行(Running):线程真正在CPU上执行指令。注意,在单核CPU上,任意时刻只有一个线程在Running状态;在多核上,Running状态可以有多个。
- 阻塞(Blocked/Waiting/Timed_Waiting):线程因为等待某个条件而暂停执行。比如等待获取一把锁、等待I/O完成、主动调用了sleep或wait。阻塞是线程生命周期里最复杂的部分,因为阻塞的原因五花八门。
- 结束(Terminated):线程的run方法执行完毕,或者抛出了未捕获的异常而退出。线程一旦结束,就不能再调用start方法重新启动了。
这里有几个容易混淆的点,我单独拿出来说。首先是sleep和wait的区别。sleep是“睡一会儿再说”,线程暂时不参与调度,但持有的锁不会释放;wait是“等别人叫醒我”,调用wait会释放当前持有的锁,让出CPU给其他线程。很多并发Bug就出在这个地方——本应该用wait却用了sleep,导致锁一直被占着,其他线程永远等不到。
其次是阻塞和就绪的区别。就绪态的线程是“可以跑但没分到CPU”,一旦拿到时间片立刻能执行;阻塞态的线程是“就算给你CPU你也执行不了”,因为它在等待某个外部条件。比如一个线程在etc.读磁盘文件,数据没到位之前,即使CPU空闲,它也无法前进。
2.2 线程的创建:两种主流方式与取舍
线程怎么创建,不同语言有不同姿势,但原理是相通的。C语言用POSIX标准下的pthread库,Java用Thread类和Runnable接口,Go用goroutine(这是另一套协程模型,先不说)。我从工程实践的角度推荐两种主流方式,并分析它们的适用场景。
第一种是**继承
