第2阶段:线程与并发
目标问题:
“为什么有了进程还要有线程?线程到底节省了什么?”
学习重点:
- 线程与进程的对比(资源独立性 vs. 共享性)
- 用户级线程 / 内核级线程
- 并发与并行的区别(CPU层面 vs. 程序逻辑层面)
📘 建议实践:
用 Python 或 C 写个多线程程序(比如两个线程交替打印数字),观察调度的随机性。
🧩 一、为什么有了进程还要有线程?
我们先从一个非常现实的问题开始:
假如一个浏览器是单进程的,当你下载文件时,它在等待网络数据(阻塞 I/O),那整个浏览器是不是就“卡死”了?
💡 是的。因为这个进程在等待I/O,无法处理其他任务。
为了让一个进程能同时做多件事,比如:
- 下载文件
- 渲染页面
- 响应键盘输入
于是就引入了:
🧠 线程(Thread)——轻量级的执行单位
⚙️ 二、进程 vs. 线程:到底省了什么?
| 对比点 | 进程 | 线程 |
|---|---|---|
| 定义 | 系统资源分配的最小单位 | CPU 调度的最小单位 |
| 资源 | 拥有独立的内存空间(代码段、堆、栈) | 共享同一进程的内存空间(代码段、堆),但每个线程有自己的栈 |
| 创建/切换开销 | 大,需要建立独立地址空间、PCB | 小,只需保存寄存器和栈指针 |
| 通信方式 | 进程间通信(IPC)需要内核介入 | 线程间可直接读写共享变量 |
| 故障影响 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程挂掉 |
👉 一句话总结:
线程节省的是内存与切换开销,但牺牲了隔离性。
🧠 三、线程与并发、并行的区别
很多人混淆这几个词,下面用图帮你彻底区分👇
| 概念 | 含义 | 举例 |
|---|---|---|
| 并发(Concurrency) | 多个任务在“时间上交替”运行,看起来像同时 | 单核CPU在多个线程之间快速切换 |
| 并行(Parallelism) | 多个任务在“空间上同时”运行 | 多核CPU各自执行一个线程 |
| 异步(Asynchronous) | 任务启动后不等待结果,回调或事件通知 | Python asyncio, JS await |
📖 例子:
单核CPU执行两个线程 A、B,看起来像这样:
时间线 →
A A A | B B | A A | B | A ...
虽然是交替执行,但由于切换极快,程序员看到的就是“两个同时在动”——这就是并发。
🧩 四、线程的两种实现方式
-
用户级线程(User-Level Threads)
- 由应用程序自己管理(库函数)
- 切换速度快,不需要内核参与
- 缺点:一个线程阻塞,整个进程都会阻塞
-
内核级线程(Kernel-Level Threads)
- 由操作系统内核调度
- 一个线程阻塞不会影响其他线程
- 缺点:切换成本比用户级线程高
现代操作系统(Linux、Windows、macOS)通常采用的是:
“1:1 模型” —— 一个用户线程对应一个内核线程。
关于I/O阻塞”与“锁阻塞的本质区别
我们来慢慢解开👇:
阻塞(Blocked) ≠ “什么都没做”
CPU执行 ≠ “系统整体在执行”
🚦 情况1:锁阻塞(Lock Blocked)
这是你上面说的那种:
一个线程想操作共享数据,但发现锁被别人拿着,于是只能等。
此时:
- 它没有CPU执行权(CPU去干别的线程的活了);
- 它也没在“干I/O”或“算东西”,只是等待锁释放;
- 所以它“不在处理数据”;
- 它的等待是“被动”的。
🧠 类比:
一间洗手间只有一个坑位(锁),A在线上厕所,B只能在门口排队等。
B没在“使用厕所”,只是“等待使用”。
💾 情况2:I/O阻塞(I/O Blocked)
这是另一种情况:
程序在“请求外部设备”工作(例如:读硬盘、读网卡、打印机输出)。
此时:
- CPU 已经发出 “读数据” 的指令;
- 真正干活的是 外部设备(硬盘/网卡);
- CPU 不能干别的,只能等待设备完成;
- 操作系统会让该进程进入“阻塞态”,腾出CPU执行别的程序;
- 当数据准备好后,设备通过中断告诉CPU:“我好了”,进程再被唤醒。
🧠 类比:
你点了外卖(发出I/O请求),
外卖员(硬盘/网卡)在路上送餐,
你不能吃饭,只能等。
但你(CPU)可以先去刷个视频干别的。
🔍 对比总结表:
| 类型 | 谁在干活 | CPU是否执行 | 是否在操作数据 | 举例 |
|---|---|---|---|---|
| 锁阻塞 | 谁都没干活,等锁 | ❌ CPU执行别人 | ❌ 等待别人释放资源 | 等别人上完厕所 |
| I/O阻塞 | 外设(硬盘、网卡)在干活 | ❌ CPU执行别人 | ✅ 外设在处理数据 | 读硬盘、网络请求 |
🧪 五、竞态条件
1、线程共享的“资源”确实在内存中
-
线程属于同一个进程,它们共享进程的资源:
- 代码区(可执行指令)
- 数据区(全局变量、堆等)
- 打开的文件描述符(比如某个文件、socket 等)
这些资源都属于进程的内存空间。
所以,只要两个线程都在读写同一块内存区域(例如同一个全局变量),就可能发生竞态条件(Race Condition)。
🧩 2、I/O 操作的确主要与“外部设备”打交道
比如:
- 从磁盘读取文件;
- 从网络接口读取数据;
- 向打印机发送内容;
- 向数据库写入数据。
这些操作都不是在进程的普通内存里发生的,而是通过**系统调用(System Call)**让操作系统和设备控制器打交道。
所以,当线程发起一个 I/O 请求(无论是读还是写)时:
- 线程自己会被操作系统挂起(进入阻塞态);
- I/O 任务交给操作系统/设备驱动执行;
- 该线程此时不会继续运行任何用户代码,也不会访问共享内存;
- I/O 完成后,操作系统再把线程唤醒,让它从上次停下的地方继续执行。
👉 因此,在阻塞期间,它不会修改共享内存,也就不会触发竞态条件。
🧩 3、但是要注意两种特殊情况
这里有两个“例外”,是很多人第一次学操作系统容易忽略的:
① 异步 I/O 或 DMA(直接内存访问)
某些 I/O 操作(比如网络、GPU、磁盘 DMA)是允许设备直接读写内存的。
也就是说,外设可能在后台修改内存中的缓冲区数据。
举个例子:
你发起网络接收操作,驱动告诉网卡“把数据直接写入内存中的 buffer”,
然后 CPU 去干别的事,网卡就在后台往内存里填数据。
这时候虽然线程被阻塞,但内存内容可能被外设异步修改。
不过这种访问是受操作系统控制的,不属于线程并发访问,不会造成竞态条件。
② 多线程同时读写 I/O 缓冲区
如果两个线程同时对同一个 I/O 缓冲区进行读写(例如同时 recv() 到同一个 socket 缓冲区),
那就不是 I/O 阻塞引起的安全问题,而是线程对同一内存的并发访问问题。
这时仍然会有竞态条件,需要加锁保护。
🧩 4、所以正确的理解应该是:
- 阻塞 I/O 期间,线程暂停执行,不再操作共享内存。
- I/O 操作确实主要和外部设备交互,因此线程自身不会引起竞态条件。
- 但如果多个线程共享同一个 I/O 缓冲区或资源(如文件描述符),仍然需要同步机制(锁、信号量等)保护。
- 异步 I/O 中设备可能直接写入内存,但受内核控制,不会造成线程间的数据竞争。
六、同步与互斥
解决这两种情况的关键是同步与互斥,确保在多线程访问共享资源(无论是 I/O 缓冲区还是直接修改内存的外部设备)时,数据一致性和线程安全得到保障。下面我会分别介绍这两种情况的常见解决方案:
1️⃣ 多线程访问同一 I/O 缓冲区时的竞态条件
当多个线程同时访问同一个 I/O 缓冲区时,需要保证它们在访问和修改共享数据时不会发生竞态条件,即每次只有一个线程能访问共享资源。通常我们可以使用以下方法来解决:
解决方案:
-
互斥锁(Mutex):
使用互斥锁可以确保一次只有一个线程能够访问 I/O 缓冲区。每个线程在访问共享缓冲区时,都必须先获取锁,执行完后再释放锁,这样就避免了并发访问时的数据错乱。示例:
import threading # 共享缓冲区 buffer = [] # 创建锁 lock = threading.Lock() def write_to_buffer(data): with lock: # 获取锁 buffer.append(data) # 操作共享缓冲区 # 锁在这里会自动释放 def thread_func(): for i in range(5): write_to_buffer(i) threads = [] for _ in range(10): t = threading.Thread(target=thread_func) threads.append(t) t.start() for t in threads: t.join() print(buffer)通过使用
with lock,我们确保每次只有一个线程能访问并修改buffer,避免多个线程同时对同一缓冲区进行写操作。 -
条件变量(Condition Variable):
如果多线程之间有特定的执行顺序要求,比如线程A写数据,线程B读取数据,可以使用条件变量来让线程等待或通知其他线程。这样可以确保某些条件满足时,线程才能执行。示例:
import threading import time buffer = [] condition = threading.Condition() def producer(): with condition: for i in range(5): buffer.append(i) print(f"生产者写入: {i}") condition.notify() # 通知消费者 time.sleep(1) def consumer(): with condition: while len(buffer) < 5: condition.wait() # 等待通知 print("消费者读取:", buffer) t1 = threading.Thread(target=producer) t2 = threading.Thread(target=consumer) t1.start() t2.start() t1.join() t2.join()这样我们用
condition.wait()和condition.notify()来控制生产者和消费者的执行顺序。 -
队列(Queue):
Python 提供了线程安全的队列(如queue.Queue),适合用于线程间的消息传递和任务调度。它内部已经处理了同步问题,可以安全地在多线程之间共享数据。示例:
import threading import queue q = queue.Queue() def producer(): for i in range(5): q.put(i) # 放入队列 print(f"生产者写入: {i}") def consumer(): while not q.empty(): item = q.get() # 获取队列中的数据 print(f"消费者读取: {item}") t1 = threading.Thread(target=producer) t2 = threading.Thread(target=consumer) t1.start() t2.start() t1.join() t2.join()queue.Queue会自动处理多线程的同步问题,可以避免竞态条件。
2️⃣ 外部设备直接修改内存数据时
当外部设备通过DMA(直接内存访问)等技术直接修改内存时,确实可能引发数据一致性问题,因为 CPU 线程并不直接控制这些操作,它们由硬件驱动程序和操作系统控制。为了避免外部设备和线程对同一内存区域进行并发操作,我们需要采取一定的同步策略。
解决方案:
-
内存屏障(Memory Barrier):
操作系统和硬件可能会使用内存屏障来确保访问顺序。在进行 DMA 操作时,硬件可能会用屏障来保证在某些操作完成之前,不会让其他操作访问内存。内存屏障是硬件提供的机制,它可以在访问特定内存区域时,强制线程按照顺序执行,避免设备和 CPU 操作的冲突。
-
设备锁和同步机制:
在多线程环境下,硬件驱动程序会使用锁和同步机制来保证设备与内存交互时不会发生并发冲突。例如,操作系统可能会为 DMA 操作提供锁来确保在某个时刻只有一个线程与设备交互。这种锁通常是在操作系统级别实现的,并且它们控制着设备的访问,防止多个线程同时访问同一块内存区域。
示例:
设备驱动程序通常会在请求 DMA 操作时使用互斥锁,确保同一时间只有一个线程可以进行设备访问。这样,在设备完成 I/O 操作之前,其他线程不会干扰内存中的数据。 -
内存映射(Memory-Mapped I/O):
当外部设备和内存直接交互时,操作系统通常会使用内存映射来映射硬件设备的 I/O 空间到进程的虚拟内存中。这通常会通过操作系统提供的 API 来处理,避免多个线程直接访问被映射的内存区域。示例:
在 Linux 中,mmap()函数可以将外部设备的 I/O 内存区域映射到进程的地址空间,操作系统会保证在访问这些内存区域时,避免并发修改。
3️⃣ 总结:
对于多线程访问同一 I/O 缓冲区的竞态条件:
- 使用互斥锁、条件变量、线程安全队列等同步机制,确保每次只有一个线程能够访问和修改缓冲区。
对于外部设备直接修改内存数据时:
- 操作系统和硬件通常会使用内存屏障、设备锁和内存映射等机制,确保设备和 CPU 线程之间的数据访问不会产生冲突。
通过这些同步和互斥机制,我们可以避免多个线程或设备同时修改同一内存区域时引发的竞态条件,确保数据一致性,避免程序错误或崩溃。
2171

被折叠的 条评论
为什么被折叠?



