线程定义、进程线程核心区分、进程内存结构、程序与进程对比简答+选择题解析
这篇东西最初是我给考研学生整理的复习讲义,后来发现每次讲完他们还是会在"缺CPU到底是就绪还是阻塞"这种题上栽跟头,索性把讲义扩写成一篇完整的文章。内容覆盖线程定义、进程线程的本质区别、进程内存布局、程序与进程的对比关系,最后附一道经典选择题的逐选项拆解。期末、考研、面试都能用。
目录
- 一、线程是什么
- 二、进程是什么
- 三、进程与线程的核心区分
- 四、进程内存结构
- 五、程序与进程的对比
- 六、一道经典选择题
- 七、容易踩的坑
- 八、补充问题
- 九、自测与参考
一、线程是什么
1.1 从一个场景说起
你开着 Chrome,后台在下一个 2G 的安装包,前台在看 B 站视频,旁边还挂着一个在线文档。如果浏览器内部只有一条执行流,那下载的时候视频就得卡住,所有任务排队来。
但实际上这三件事是"同时"在跑的。原因很朴素:浏览器内部有多条线程,各管各的事。单核上它们交替执行(并发),多核上真正同时跑(并行)。
线程存在的意义就一句话:让一个程序能同时干多件事,不用傻等。
1.2 定义
线程(Thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
这句话信息量不小,我习惯拆成四个点来讲:
第一,“运算调度”。CPU 时间片是发给线程的,不是发给进程的。调度器挑的是"下一个该跑哪个线程",它不关心进程这个概念。
第二,“最小单位”。操作系统不会再往下拆了。你可能会想到协程,但协程是用户态的东西,内核根本不知道它的存在,不算在操作系统的调度范畴内。
第三,“被包含在进程中”。线程不能脱离进程单独活着。进程被 kill,底下所有线程一起死,没有例外。
第四,“实际运作单位”。进程本身不执行任何一条指令。你在任务管理器里看到的"进程",真正在 CPU 上跑的是它里面的线程。
教材里还有一句补充描述:
一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
这里要区分"并发"和"并行",面试老爱问:
并发(单核):──A──B──A──B──A──B──→ 交替执行,宏观上"同时" 并行(多核): 核心1:──A──A──A──A──→ 核心2:──B──B──B──B──→ 物理上真正同时一句话:并发是逻辑上的同时,并行是物理上的同时。
1.3 内核线程和用户线程
在 Unix System V 和 SunOS 的老文献里有个词叫"轻量进程(LWP)",第一次看到很容易懵。其实对应关系很简单:
- 轻量进程(LWP)= 内核线程,由内核调度器直接管理
- 线程(Thread)= 用户线程,由用户态线程库(比如 pthread)管理,内核不感知
现代 Linux 里,pthread_create()底层调的是clone()系统调用,创建出来的"线程"本质上就是一个共享了地址空间的task_struct。Linux 内核眼里没有"线程"这个独立概念,只有"任务",区别在于clone()时传了什么 flags(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD)。
这个知识点在 Linux 内核设计与实现 第三章讲得很清楚,想深入的同学可以翻翻。
1.4 生命周期
新建 ──start()──→ 就绪 ──被调度──→ 运行 ↑ │ │ │ 等待I/O │ ▼ └──I/O完成── 阻塞 │ 终止 ←───────────┘有一条铁律必须记住:阻塞不能直接跳到运行,必须先回就绪态等调度。这条考试特别爱考,后面选择题还会碰到。
1.5 写段代码感受一下
光说概念太虚,跑一段代码直观得多:
publicclassThreadDemo{publicstaticvoidmain(String[]args){// main方法本身就跑在主线程上Threaddownload=newThread(()->{for(inti=0;i<=100;i+=20){System.out.println(" [下载] "+i+"%");try{Thread.sleep(400);}catch(InterruptedExceptione){}}});Threadrender=newThread(()->{for(intf=1;f<=5;f++){System.out.println(" [渲染] 第"+f+"帧");try{Thread.sleep(250);}catch(InterruptedExceptione){}}});download.start();render.start();System.out.println("[主线程] 子线程已启动");}}跑一下你会发现输出顺序每次都不太一样。这就是线程调度的不确定性——三个线程共享同一个 JVM 进程的堆,但各自有独立的栈和 PC,谁先抢到 CPU 谁先输出。
二、进程是什么
2.1 定义
进程(Process)是操作系统进行资源分配的最小单元;是程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。
注意核心词是资源分配。操作系统分内存、分文件描述符、分 I/O 设备,对象都是进程,不是线程。线程只是"借用"进程的资源来干活。
2.2 PCB
PCB(Process Control Block)是进程存在的唯一标志。操作系统不认你的源代码,只认 PCB。没有 PCB,操作系统就不知道这个进程的存在。
Linux 里对应的结构体是task_struct,东西很多,挑关键的列一下:
structtask_struct{pid_tpid;// 进程IDpid_ttgid;// 线程组IDstructtask_struct*parent;// 父进程volatilelongstate;// 状态// TASK_RUNNING(0) → 运行/就绪// TASK_INTERRUPTIBLE(1) → 可中断阻塞// TASK_UNINTERRUPTIBLE(2) → 不可中断阻塞intprio;// 动态优先级intstatic_prio;// 静态优先级unsignedintpolicy;// 调度策略structmm_struct*mm;// 虚拟地址空间structfiles_struct*files;// 文件描述符表structthread_structthread;// CPU上下文structlist_headchildren;// 子进程链表};创建进程的本质就是创建并初始化一个 PCB,撤销进程就是回收 PCB。这个在汤小丹《计算机操作系统》第二章有详细展开,也可以参考 王道考研复习指导 的对应章节。
2.3 状态转换
三态模型:
就绪 ←──────────────┐ │ │ │ 被调度 │ I/O完成 ▼ │ 运行 ──等待I/O──→ 阻塞 │ │ 时间片到/被抢占 ▼ 就绪(回去排队) │ │ 执行完毕 ▼ 终止两条不能走的路线,考试反复考:
- 阻塞 → 运行?不行,必须经过就绪。
- 就绪 → 阻塞?不行,只有运行中的进程才能主动阻塞。
三、进程与线程的核心区分
3.1 一句话
进程是资源分配的最小单元,线程是运算调度的最小单位。
整篇文章如果只能记一句话,就记这句。选择题、简答题、面试,这一句话能覆盖绝大多数考法。
3.2 对比
| 维度 | 进程 | 线程 |
|---|---|---|
| 定位 | 资源分配的最小单元 | 运算调度的最小单位 |
| 地址空间 | 独立的 | 共享进程的 |
| 资源 | 拥有(内存、文件、设备) | 借用进程的,自己只有栈、PC、寄存器 |
| 创建开销 | 大(要分配地址空间、页表) | 小(共享地址空间,分配个栈就行) |
| 切换开销 | 大(切页表、刷TLB) | 小(同进程内不用切页表) |
| 通信 | IPC(管道、共享内存、Socket…) | 直接读写共享变量(但要加锁) |
| 独立性 | 强,一个崩了不影响别人 | 弱,一个线程段错误整个进程完蛋 |
| 终止 | 进程死 → 所有线程死 | 一个线程死不影响其他线程 |
3.3 打个比方
我上课喜欢用这个类比:进程是公司,线程是员工。
公司有办公室、电脑、打印机(资源),员工用公司的设备干活。公司间沟通走邮件、合同(IPC,慢),员工间直接喊一嗓子就行(共享变量,快)。一家公司倒闭不影响其他公司(进程隔离),一个员工搞出严重事故可能把整个团队拖下水(线程崩溃拖垮进程)。注册公司贵(创建进程开销大),招个员工便宜(创建线程开销小)。
3.4 实测创建开销
说"进程创建比线程贵",到底贵多少?跑个数据看看:
importtime,threading,multiprocessingdefnoop():passdefbench_process(n=100):t0=time.perf_counter()ps=[multiprocessing.Process(target=noop)for_inrange(n)]forpinps:p.start()forpinps:p.join()returntime.perf_counter()-t0defbench_thread(n=100):t0=time.perf_counter()ts=[threading.Thread(target=noop)for_inrange(n)]fortints:t.start()fortints:t.join()returntime.perf_counter()-t0if__name__=="__main__":pt=bench_process()tt=bench_thread()print(f"100个进程:{pt:.4f}s")print(f"100个线程:{tt:.4f}s")print(f"倍数:{pt/tt:.1f}x")我机器上(Ubuntu 22.04, i7-12700)跑出来大概是 18 倍左右。不同机器会有差异,但量级上差一个数量级是稳定的。这就是为什么 Nginx 用多进程 + 多路复用而不是为每个连接 fork 一个进程——fork 太贵了。
3.5 切换开销差在哪
进程切换比线程切换慢,主要慢在两步:
- 切换页目录基址寄存器(x86 上是 CR3)——告诉 CPU “现在用新进程的页表了”
- 刷新 TLB——TLB 是页表的缓存,换了页表旧缓存就全废了,后续访存全部 miss,要重新从内存加载页表项
同进程内的线程切换不需要这两步,因为页表是同一个。所以快很多。
但有个前提:同进程内。如果是不同进程的线程之间切换,该切页表还是得切,跟进程切换没区别。"线程切换快"这个结论只在同进程内成立,考试出判断题的时候注意这个限定条件。
四、进程内存结构
4.1 全景
这张图我画了很多遍,每次给学生讲都会强调,建议截图存下来:
高地址 ┌─────────────────────────────────────────────┐ │ 内核空间(用户态不可访问) │ ├─────────────────────────────────────────────┤ │ 线程A栈 │ 线程B栈 │ 线程C栈 │ ... │ ← 私有 │ (局部变量、参数、返回地址) │ ├─────────────────────────────────────────────┤ │ ↓ 空闲区域 ↓ │ ├─────────────────────────────────────────────┤ │ 堆 (Heap) │ ← 共享 │ malloc / new 动态分配 │ ├─────────────────────────────────────────────┤ │ BSS(未初始化全局/静态变量) │ ← 共享 ├─────────────────────────────────────────────┤ │ Data(已初始化全局/静态变量) │ ← 共享 ├─────────────────────────────────────────────┤ │ Text(代码段,只读) │ ← 共享 └─────────────────────────────────────────────┘ 低地址4.2 共享与私有
共享的(所有线程都能访问):
- 代码段——大家执行的是同一份机器指令,复制多份纯属浪费
- 数据段 / BSS——全局变量、静态变量,天然需要被所有线程读写
- 堆——动态分配的对象,线程间要协作访问
私有的(每个线程独立一份):
- 栈——每个线程有自己的函数调用链,不能混
- 程序计数器(PC)——记录"我执行到哪了",各线程进度不同
- 寄存器集合——线程切换时要保存/恢复的上下文
设计思路很直觉:共享的是数据,私有的是执行状态。数据是协作的基础,执行状态是独立性的保证。
你想想,如果栈也共享了会怎样?线程 A 调func_a()压栈,线程 B 同时调func_b()也压栈,两者的局部变量和返回地址互相覆盖,程序立刻崩溃。所以栈必须私有,这不是设计偏好,是逻辑必然。
4.3 用代码验证
#include<stdio.h>#include<pthread.h>intglobal_counter=0;// 全局变量,Data段,共享void*worker(void*arg){intlocal=0;// 局部变量,栈上,私有for(inti=0;i<1000000;i++){global_counter++;// 有竞争local++;// 安全}printf("线程%ld: local=%d\n",(long)arg,local);returnNULL;}intmain(){pthread_tt1,t2;pthread_create(&t1,NULL,worker,(void*)1);pthread_create(&t2,NULL,worker,(void*)2);pthread_join(t1,NULL);pthread_join(t2,NULL);printf("global_counter=%d (应该是2000000)\n",global_counter);return0;}编译运行:
gcc-O2-odemo demo.c-lpthread&&./demo我跑出来的结果:
线程1: local=1000000 线程2: local=1000000 global_counter=1387452 (应该是2000000)local永远是精确的一百万——栈上的私有变量,别的线程碰不到。global_counter少了六十多万——两个线程同时做global_counter++,这个操作在 x86 上至少是三条指令(load、add、store),不是原子的,大量更新被覆盖了。
解决办法就是加锁:
pthread_mutex_tmtx=PTHREAD_MUTEX_INITIALIZER;pthread_mutex_lock(&mtx);global_counter++;pthread_mutex_unlock(&mtx);或者用原子操作(__sync_fetch_and_add或 C11 的_Atomic),性能比互斥锁好不少。这块内容在 APUE 第 11 章 讲得很细。
五、程序与进程的对比
5.1 定性
程序是静态的,进程是动态的。二者不是一一对应的关系。
5.2 三个维度
动静特性不同。程序是静态的,就是磁盘上躺着的一个文件(.exe、.class、.py),不运行也存在,可以永久保存。进程是动态的,是程序跑起来之后的那个"活动",有创建、运行、终止的生命周期,是暂时的。
我常用的比方:程序是乐谱,进程是演奏。乐谱放一百年还是那个乐谱,演奏完了就没了。
组成结构不同。程序就是代码指令的集合,没别的了。进程 = 程序段 + 数据段 + PCB。PCB 是进程存在的唯一标志——没有 PCB 就不能叫进程。
对应关系不同。不是一一对应的,有两种"一对多":
1 个程序 → 多个进程(多开):你双击三次计算器,就出来三个计算器进程。一个 chrome.exe 可以对应好几个浏览器进程。
1 个进程 → 多个程序(调用):一个 LOL 进程里面跑着 UI 模块、渲染模块、网络模块、音频模块。一个进程调用了多个程序模块。
5.3 答题模板
考试碰到这道简答题,按这个结构写基本不会丢分:
题目:比较程序与进程的区别。
答:
(1)动静特性不同。进程是动态的,程序是静态的。程序是有序代码的集合,可永久保存;进程是程序的执行过程,有生命周期,是暂时的。
(2)组成结构不同。进程由程序段、数据段和进程控制块(PCB)组成,PCB 是进程存在的唯一标志;程序仅由代码指令构成。
(3)对应关系不同。一个程序可多次执行对应多个进程(如浏览器多开);一个进程可包含多个程序模块(如游戏进程包含 UI、渲染、网络等子程序)。二者不是一一对应的关系。
六、一道经典选择题
题目
下面关于进程的描述中,正确的是( )
A. 进程获得 CPU 运行是通过调度得到的
B. 优先级是进程调度的重要依据,一旦确定就不能改变
C. 在单 CPU 的系统中,任意时刻都有一个进程处于运行状态
D. 进程申请 CPU 得不到满足时,其状态变为阻塞
答案:A
逐项分析
A 是对的。调度器的活儿就是决定"下一个谁上 CPU"。进程从就绪变运行,唯一的路就是被调度器选中。没什么好纠结的。
B 错在"不能改变"。优先级分两种:静态优先级确实创建后就不变了,但动态优先级是会根据运行情况调整的。比如等太久了给你提提优先级防止饿死,跑太久了给你降降防止垄断 CPU。Linux 的 CFS 调度器里,进程的 vruntime 就是在动态调整的。选项说"一旦确定就不能改变",一竿子打死了。
C 错在"任意时刻"。想想这个场景:所有进程都在等磁盘 I/O,全部阻塞了,CPU 在那空转(执行 HLT 指令进入低功耗状态)。这时候没有任何进程处于运行态。"任意时刻"太绝对了。
D 是本题最大的坑,也是最高频的易错点。
申请 CPU 得不到满足,状态还是就绪,不是阻塞。
为什么?回到定义想:
- “就绪"的意思是"万事俱备只欠 CPU”——你缺的就是 CPU,那你当然还在就绪队列里排着。
- “阻塞"的意思是"给你 CPU 你也干不了活”——比如在等磁盘数据,数据没来之前给你 CPU 你也不知道执行什么。
所以:
- 缺 CPU → 就绪(排队等着)
- 缺 I/O / 缺锁 / 缺资源 → 阻塞(等外部事件)
这条搞清楚,D 选项就不会选错了。我每年阅卷,D 选项的错误率都在 40% 以上,就是因为很多同学把"得不到满足"一律理解成"阻塞",没有区分缺的是什么。
七、容易踩的坑
“线程就是轻量级进程,所以线程约等于进程?”
不是。“轻量进程"只是早期 Unix 对内核线程的叫法。线程没有独立地址空间,不能脱离进程存在,不能被操作系统独立分配资源。进程是资源的"拥有者”,线程是资源的"使用者",这是本质区别。
“多进程一定比多线程好?”
不一定。多进程隔离性好,但创建切换贵、通信麻烦、数据共享困难。该用多线程的场景(数据库连接池、Web 服务器处理请求)你硬上多进程,性能反而更差。Chrome 用多进程是因为标签页之间需要强隔离,Nginx 用多路复用是因为连接之间共享大量状态。看场景选方案,没有银弹。
“阻塞态可以直接变运行态?”
不行。I/O 完成后进程只是"具备了继续跑的条件",但 CPU 给不给、什么时候给,是调度器的事。必须先回就绪态排队。
“线程切换一定比进程切换快?”
同进程内的线程切换确实快(不用切页表)。但如果是不同进程的线程之间切换,该切页表还是得切,跟进程切换没区别。这个限定条件考试出判断题时经常挖坑。
“程序和进程是一回事?”
程序是死的文件,进程是活的执行实例。双击一个程序,OS 把代码加载到内存、创建 PCB、分配资源、创建主线程、开始执行——这一系列操作完成后才有了进程。而且一个程序可以跑出多个进程(多开),一个进程里可以包含多个程序模块。
八、补充问题
协程和线程什么关系?
协程是用户态的东西,由程序员或运行时库调度,不经过内核。切换开销极小(不用陷入内核态,通常几十纳秒),但单线程内的协程没法利用多核。Go 的 goroutine、Python 的 asyncio、Kotlin 的 coroutine 都是协程实现。调度粒度:进程 > 线程 > 协程,切换开销也是这个顺序。
Linux 里线程和进程到底啥区别?
内核层面都是task_struct,没有独立的数据结构。线程就是clone()时共享了地址空间、文件描述符这些东西的 task。pthread是对clone()的封装。想看源码的话,kernel/fork.c里的copy_process()函数是入口。
Chrome 为什么用多进程?
隔离性。每个标签页可能跑不可信的 JS,如果用多线程,一个标签页段错误整个浏览器就没了。多进程下一个渲染进程崩了只影响一个标签页。代价是内存占用高——你开 20 个标签页,任务管理器里能看到 20 多个 chrome 进程,每个都占几十到几百 MB。
Java 线程是内核线程还是用户线程?
HotSpot JVM(JDK 1.2 以后)是 1:1 映射,每个new Thread()对应一个内核线程。能利用多核,但创建开销也比纯用户线程大。顺带提一句,Python 的 CPython 实现有 GIL(全局解释器锁),同一时刻只有一个线程能执行 Python 字节码,所以 CPython 的多线程没法利用多核做 CPU 密集型并行,这是个很常见的坑。
进程切换 TLB 一定要刷吗?
传统实现是的。但现代 x86 有 PCID(Process Context Identifier),ARM 有 ASID(Address Space ID),可以在 TLB 表项里标记所属进程,切换时不用全刷,只更新当前标识就行,开销小很多。这个在 Intel SDM Volume 3 里有详细说明。
九、自测与参考
自测
- 线程是______的最小单位,进程是______的最小单元。
- 同一进程内的线程共享______,私有______。
- 阻塞态能直接变运行态吗?
- 一个程序能对应多个进程吗?举个例子。
- 缺 CPU 是什么态?缺 I/O 是什么态?
- 优先级有几种?哪种能变?
- 单 CPU 系统任意时刻都有进程在运行吗?
答案:
- 运算调度;资源分配
- 堆、数据段(代码段);栈、PC、寄存器
- 不能,必须经过就绪态
- 能,双击三次计算器就是三个进程
- 就绪;阻塞
- 静态(不变)和动态(可变)两种
- 不一定,所有进程都阻塞时 CPU 空闲
参考
- 汤小丹、梁红兵.《计算机操作系统》(第四版). 西安电子科技大学出版社. 第 2、3 章.
- Andrew S. Tanenbaum.《现代操作系统》(第四版). 机械工业出版社. 第 2 章.
- 王道考研.《操作系统考研复习指导》. 第 2 章.
- Robert Love.《Linux 内核设计与实现》(第三版). 第 3、4 章.
- W. Richard Stevens.《UNIX 环境高级编程》(第三版). 第 11、12 章.
- B 站"南京大学 操作系统"课程(蒋炎岩),讲得体系化,适合从头捋一遍。
最后
这些概念本身真不复杂,但很多人就是记不住、用不对。我带了几年课,觉得问题出在学习方式上——大部分人在"背结论",而不是在"理解为什么"。
你理解了"栈为什么必须私有"(因为每个线程的调用链不同,共享了就乱套),就不用死记"线程私有栈"。你理解了"就绪的定义就是只缺 CPU",就不会把 D 选项选错。
理解到位了,考场上不需要回忆,答案自己就出来了。
2026 年 7 月更新