进程与线程
在操作系统和软件工程领域,进程 (Process) 和 线程 (Thread) 是最基础也是最重要的两个概念。
1. 核心概念解析
1.1 进程 (Process)
- 定义:进程是计算机中已运行程序的实体,是操作系统进行资源分配和调度的基本单位。
- 通俗理解:你可以把进程想象成一个“工厂”。当你双击打开一个微信(或 Chrome 浏览器、Word 文档)时,操作系统就为你启动了一个进程。
- 组成部分:每个进程都有自己独立的一块地盘(内存空间)。这个空间里存放着:
- 代码段:程序编译后的机器指令。
- 数据段:全局变量和静态变量。
- 堆 (Heap):程序运行时动态分配的内存。
- 栈 (Stack):函数调用的局部变量和返回地址。
- 文件描述符 / 打开的文件列表等系统资源。
- 独立性:进程与进程之间是相互隔离的。微信的进程崩溃了,绝对不会影响到正在播放音乐的网易云进程。这是操作系统为了安全做出的保护机制。
1.2 线程 (Thread)
- 定义:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
- 通俗理解:你可以把线程想象成工厂里的“流水线工人”。一个工厂(进程)里至少有一个工人(主线程),也可以有多个工人(多线程)同时干活。
- 资源共享:同一个进程内的所有线程,共享该进程的全部系统资源(如代码、数据、堆、打开的文件)。
- 独立性:虽然共享资源,但每个线程拥有自己独立的:
- 程序计数器 (PC):记录当前线程执行到哪一行代码了。
- 一组寄存器:保存线程运行时的临时数据。
- 线程栈 (Stack):保存每个线程自己函数调用的局部变量。
- 轻量级:因为线程不需要拥有独立的内存空间(借用进程的),所以线程的创建、销毁和切换代价,远比进程小得多。因此线程被称为“轻量级进程”。
2. 进程与线程的详细对比
这是一张非常经典且必须掌握的对比表:
| 维度 | 进程 (Process) | 线程 (Thread) |
|---|---|---|
| 根本属性 | 资源分配的最小单位。 | CPU 调度和执行的最小单位。 |
| 包含关系 | 一个进程至少包含一个线程(主线程)。 | 一个线程必定属于某个进程。 |
| 内存与资源 | 每个进程拥有独立的地址空间和系统资源。 | 同一进程下的线程共享该进程的地址空间和资源。但每个线程有独立的栈和寄存器。 |
| 开销 (Overhead) | 非常大。创建、销毁进程需要操作系统分配/回收内存等资源。 | 很小。创建、销毁线程只需分配少量的栈空间。 |
| 上下文切换 | 慢。需要切换页表(虚拟内存映射),刷新 TLB 缓存,开销极大。 | 快。不需要切换虚拟内存空间,只切换少量的寄存器和栈指针。 |
| 通信机制 (IPC) | 困难且复杂。需要操作系统的介入(如管道、消息队列、共享内存、信号量、Socket)。 | 非常容易。由于共享内存空间,线程之间可以直接通过读写全局变量通信(但需要加锁同步)。 |
| 安全性/健壮性 | 极高。一个进程崩溃通常不会影响其他进程(隔离机制)。 | 较差。一个线程崩溃(如引发段错误段溢出),往往会导致整个进程随之崩溃。 |
3. 什么时候用多进程?什么时候用多线程?
多进程适用场景:
- 需要极高稳定性和安全性的应用:典型的例子是现代浏览器(如 Chrome)。每一个标签页(Tab)或插件都在一个独立的进程中运行。如果某个网页崩溃了,只会关掉那个标签页,其他网页和整个浏览器都不受影响。如果用多线程,一个恶意的 JavaScript 代码就能搞垮整个浏览器。
- CPU 密集型的任务(特别是 Python):由于 Python 中存在 GIL (Global Interpreter Lock,全局解释器锁) 的限制,Python 的多线程在处理计算密集型任务时无法真正利用多核 CPU(依然是串行执行)。此时,使用多进程 (Multiprocessing) 是唯一的解药,因为每个进程都有自己独立的 GIL。
多线程适用场景:
- I/O 密集型任务:典型的如 Web 服务器处理并发请求(如下载文件、数据库查询、网络通信)。因为这些操作大部分时间都在等待数据返回(阻塞),在此期间 CPU 是空闲的。多线程可以充分利用这些等待时间,让其他线程继续处理别的请求。
- 需要频繁共享大量数据的任务:因为线程间共享内存空间,所以通信成本极低,不需要像进程那样通过复杂的机制来拷贝数据。
4. 常见问题 (FAQ) 与面试题
4.1 什么是线程安全?如何保证线程安全?
- 概念:当多个线程同时访问同一个共享资源(如修改同一个全局变量)时,如果不加任何控制,就会产生数据错乱或不可预期的结果。如果一段代码在多线程环境下运行,其结果和单线程环境完全一致,这就是线程安全的。
- 保证方式:
- 互斥锁 (Mutex) / 同步机制:当一个线程在修改数据时,把资源锁住,其他线程必须排队等待。这是最常用的方法(如 Java 中的
synchronized关键字或ReentrantLock)。 - 使用线程局部变量 (ThreadLocal):每个线程在本地维护该变量的副本,互不干扰,从而避免共享(空间换时间的策略)。
- 无锁编程 (Lock-Free):利用底层的 CAS (Compare-And-Swap) 原子操作。
- 互斥锁 (Mutex) / 同步机制:当一个线程在修改数据时,把资源锁住,其他线程必须排队等待。这是最常用的方法(如 Java 中的
4.2 什么是死锁 (Deadlock)?产生的条件是什么?如何避免?
- 概念:两个或多个线程(或进程)互相持有对方需要的锁,并在等待对方释放锁。结果大家都卡死在那里,谁也无法继续执行。
- 比喻:A 拿着钥匙,想要开门;B 守在门边,想要拿钥匙。两人都不肯松手,于是僵持不下。
- 产生死锁的四个必要条件(缺一不可):
- 互斥条件:一个资源每次只能被一个线程使用。
- 请求与保持条件:一个线程因请求被占用的资源而阻塞时,对自己已经获得的资源保持不放。
- 不剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺。
- 循环等待条件:多个线程之间形成一种头尾相接的循环等待资源的关系。
- 如何避免/预防死锁(核心是破坏上面四个条件之一):
- 破坏互斥条件:不可行,锁的意义就是互斥。
- 破坏请求与保持:规定线程在开始执行前,必须一次性申请它需要的所有资源。如果申请不到,就什么也不拿,干等着。(缺点:资源利用率极低)。
- 破坏不剥夺条件:如果一个已经持有部分资源的线程去申请新资源被拒绝,它必须主动释放自己当前持有的所有资源,过一会再重新尝试。(缺点:实现复杂,且可能导致线程反复尝试而做无用功,即“活锁”)。
- 破坏循环等待条件(最实用):对系统中所有的资源进行编号,规定所有线程必须严格按照编号的递增顺序来申请锁。这样就永远不会出现循环等待的情况。
4.3 什么是协程 (Coroutine)?它和线程的区别是什么?
协程是一个更加轻量级的概念,有时被称为“用户态的线程”。
- 调度者不同:
- 线程是由操作系统内核 (OS Kernel) 进行调度和管理的,切换成本较高。
- 协程是由程序员在应用代码层面 (User Space) 通过程序库(如 Python 的
asyncio、Go 的goroutine)自行控制调度的。操作系统根本不知道协程的存在。
- 切换开销极小:因为协程的切换完全在用户态进行,不需要陷入内核态,不需要保存和恢复复杂的寄存器状态,只需要保存极少的上下文信息。所以一台机器上可以轻松创建几十万甚至上百万个协程(如 Golang),而线程通常只能创建几千个。
- 并发模型:线程是抢占式的并发(操作系统随时可以中断你),协程通常是协作式的并发(协程主动让出执行权
yield/await给别的协程)。
4.4 上下文切换 (Context Switch) 到底在切换什么?为什么进程切换比线程切换慢得多?
- 上下文切换在切换什么:当 CPU 从执行任务 A 切换到任务 B 时,它必须保存任务 A 当前的状态(比如程序计数器指到哪行代码了、各个寄存器里存的临时变量是什么),然后加载任务 B 之前保存的状态。这就叫上下文切换。
- 为什么进程切换更慢:
- 对于线程切换,由于它们属于同一个进程,共享同一个虚拟内存空间。所以只需要切换极少数的寄存器和栈指针。
- 对于进程切换,由于每个进程有自己独立的虚拟内存空间。切换进程时,不仅要切换寄存器,更要命的是必须切换页表 (Page Table),从而导致 TLB (Translation Lookaside Buffer,页表缓存) 彻底失效并被清空。TLB 的失效会导致接下来的一段时间内,所有的内存访问都变慢(因为要去查慢速的主存),这是进程切换开销巨大的根本原因。