深入探究多线程、多进程与异步 I/O
如何选择正确的并发模型
Python 提供了三种主要的方法来同时处理多个任务:多线程、多进程以及异步 I/O(asyncio)。
选择正确的模型对于最大化程序性能以及高效利用系统资源至关重要。(附言:这也是一个常见的面试问题哦!)
如果没有并发机制,程序一次只能处理一个任务。在诸如文件加载、网络请求或者用户输入等操作期间,程序会处于空闲状态,浪费宝贵的 CPU 周期。而并发通过使多个任务能够高效运行解决了这一问题。
那么,我们该使用哪种模型呢?让我们深入探讨一下吧!
目录
- 并发基础 - 并发与并行的对比 - 程序 - 进程 - 线程 - 操作系统如何管理线程和进程?
- Python 的并发模型 - 多线程 - Python 的全局解释器锁(GIL) - 多进程 - 异步 I/O(asyncio)
- 何时该使用哪种并发模型?
并发基础
在深入了解 Python 的并发模型之前,让我们先来回顾一些基础概念。
1. 并发与并行
并发与并行的可视化示意图(本人绘制)
并发指的是同时管理多个任务,但这些任务不一定是同时执行的。任务可能会轮流执行,从而营造出一种多任务处理的假象。
并行则是指同时运行多个任务,通常是借助多个 CPU 核心来实现的。
2. 程序
现在让我们来看一些基本的操作系统概念——程序、进程和线程。
在单个进程中可以同时存在多个线程——这就是所谓的多线程(本人绘制)
程序其实就是一个静态文件,比如一个 Python 脚本或者可执行文件。
程序存储在磁盘上,在操作系统(OS)将其加载到内存中运行之前,它是处于被动状态的。一旦被加载,程序就变成了一个 进程。
3. 进程
进程是正在运行的程序的一个独立实例。
一个进程拥有自己的内存空间、资源以及执行状态。进程之间是相互隔离的,这意味着一个进程不能干扰另一个进程,除非通过诸如 进程间通信(IPC) 之类的机制特意进行设计使其可以相互干扰。
进程通常可以分为两类:
- I/O 密集型进程:这类进程大部分时间都在 等待 输入/输出操作完成,比如文件访问、网络通信或者用户输入。在等待期间,CPU 处于空闲状态。
- CPU 密集型进程:这类进程大部分时间都在 进行计算(例如视频编码、数值分析)。这些任务需要大量的 CPU 时间。
进程的生命周期:
- 进程创建时处于 新建 状态。
- 接着进入 就绪 状态,等待 CPU 时间。
- 如果进程等待某个事件(比如 I/O 操作),它就会进入 等待 状态。
- 最后,在完成任务后 终止。
4. 线程
线程是进程内执行的最小单元。进程充当线程的“容器”,在进程的生命周期内,可以创建和销毁多个线程。
每个进程至少有一个线程——即 主线程,不过它也可以创建额外的线程。
线程在同一个进程内共享内存和资源,这使得它们之间能够高效通信。然而,如果管理不当,这种共享可能会导致诸如竞态条件或死锁之类的同步问题。与进程不同的是,单个进程中的多个线程并不是相互隔离的——一个出现问题的线程可能会导致整个进程崩溃。
5. 操作系统如何管理线程和进程?
CPU 在每个核心上一次 只能执行一个任务。为了处理多个任务,操作系统会使用 抢占式上下文切换。
在进行上下文切换时,操作系统会暂停当前任务,保存其状态,并加载下一个要执行的任务的状态。
这种快速切换营造出了在单个 CPU 核心上同时执行多个任务的假象。
对于进程来说,上下文切换的资源消耗更大,因为操作系统必须保存和加载独立的内存空间。而对于线程而言,切换速度更快,因为线程在进程内共享相同的内存。不过,频繁切换会引入额外开销,从而可能会降低性能。
只有当存在多个 CPU 核心时,进程才能真正并行执行。每个核心可以同时处理一个单独的进程。
Python 的并发模型
现在让我们来探究一下 Python 特有的并发模型。
不同并发模型的概述(本人绘制)
1. 多线程
多线程允许一个进程并发地执行多个线程,这些线程共享相同的内存和资源(见示意图 2 和 4)。
然而,Python 的全局解释器锁(GIL)限制了多线程在处理 CPU 密集型任务时的有效性。
Python 的全局解释器锁(GIL)
全局解释器锁(GIL)是一种锁,它允许任何时候只有一个线程控制 Python 解释器,这意味着一次只有一个线程能够执行 Python 字节码。
引入 GIL 是为了简化 Python 中的内存管理,因为许多内部操作(比如对象创建)默认情况下不是线程安全的。如果没有 GIL,多个试图访问共享资源的线程就需要复杂的锁或同步机制来防止竞态条件和数据损坏。
GIL 在什么情况下会成为瓶颈呢?
- 对于单线程程序来说,GIL 无关紧要,因为该线程可以独占 Python 解释器。
- 对于多线程的 I/O 密集型程序,GIL 的影响较小,因为线程在等待 I/O 操作时会释放 GIL。
- 对于多线程的 CPU 密集型操作,GIL 就会成为一个重大的瓶颈。多个竞争 GIL 的线程必须轮流执行 Python 字节码。
有一个值得注意的有趣情况是 time.sleep
的使用,Python 实际上将其视为一种 I/O 操作。time.sleep
函数并非 CPU 密集型的,因为在睡眠期间它不涉及主动计算或 Python 字节码的执行。相反,跟踪经过时间的责任被委托给了操作系统。在此期间,线程会释放 GIL,允许其他线程运行并使用解释器。
2. 多进程
多进程使系统能够并行运行多个进程,每个进程都有自己的内存、GIL 和资源。在每个进程内部,可能会有一个或多个线程(见示意图 3 和 4)。
多进程绕过了 GIL 的限制。这使得它适用于需要大量计算的 CPU 密集型任务。
不过,由于需要独立的内存以及存在进程开销,多进程的资源消耗更大。
3. 异步 I/O(asyncio)
与线程或进程不同,异步 I/O(asyncio)使用单个线程来处理多个任务。
在使用 asyncio
库编写异步代码时,你会使用 async/await
关键字来管理任务。
关键概念
- 协程:这些是使用
async def
定义的函数。它们是 asyncio 的核心,代表可以暂停并稍后恢复的任务。 - 事件循环:它负责管理任务的执行。
- 任务:是协程的包装器。当你希望一个协程实际开始运行时,你可以将它转换为一个任务,例如使用
asyncio.create_task()
。 **await**
:暂停协程的执行,将控制权交回给事件循环。
工作原理
异步 I/O(asyncio)运行一个事件循环来调度任务。任务在等待某些事情(比如网络响应或文件读取)时会主动“暂停”自己。当任务暂停时,事件循环会切换到另一个任务,确保不会浪费时间在等待上。
这使得异步 I/O(asyncio)非常适合涉及 大量需要长时间等待的小任务 的场景,比如处理数千个网络请求或者管理数据库查询。由于所有操作都在单个线程中运行,asyncio 避免了线程切换带来的开销和复杂性。
异步 I/O(asyncio)和多线程之间的关键区别在于它们处理等待任务的方式。
- 多线程依靠操作系统在线程等待时进行线程切换(抢占式上下文切换)。当一个线程等待时,操作系统会自动切换到另一个线程。
- 异步 I/O(asyncio)使用单个线程,并依靠任务在需要等待时“主动配合”暂停(协作式多任务处理)。
两种编写异步代码的方式:
**方法 1:await 协程**
当你直接 await
一个协程时,当前协程在 await
语句处暂停执行,直到被等待的协程执行完成。在当前协程内,任务是 按顺序 执行的。
当你需要 立即 获取协程的结果以继续下一步操作时,可以使用这种方法。
虽然这听起来可能像是同步代码,但其实不是。在同步代码中,整个程序在暂停期间都会阻塞。
而在异步 I/O(asyncio)中,只有当前协程会暂停,程序的其余部分可以继续运行。这使得 asyncio 在程序层面是非阻塞的。
示例:
事件循环会暂停当前协程,直到 fetch_data
完成。
async def fetch_data():
print("Fetching data...")
await asyncio.sleep(1) # 模拟网络调用
print("Data fetched")
return "data"
async def main():
result = await fetch_data() # 当前协程在此处暂停
print(f"Result: {result}")
asyncio.run(main())
**方法 2:asyncio.create_task(协程)**
协程会被安排在 后台并发运行。与 await
不同的是,当前协程会立即继续执行,而无需等待被安排的任务完成。
被安排的协程一旦事件循环有机会就会开始运行,不需要等待显式的 await
。
这里不会创建新的线程,相反,协程会在与事件循环相同的线程内运行,由事件循环来管理每个任务的执行时间。
这种方法能够在程序内实现并发,使多个任务可以高效地重叠执行。之后你需要 await
该任务来获取其结果,并确保它已完成。
当你希望并发运行任务且不需要立即获取结果时,可以使用这种方法。
示例:
当执行到 asyncio.create_task()
这一行时,协程 fetch_data()
就会被安排在 事件循环有空时立即开始运行,这甚至可能在你显式 await
该任务 之前 就发生。相比之下,在第一种 await
方法中,协程只有在执行到 await
语句时才会开始执行。
总体而言,通过让多个任务的执行重叠,这种方式能使程序更加高效。
async def fetch_data():
# 模拟网络调用
await asyncio.sleep(1)
return "data"
async def main():
# 安排 fetch_data 任务
task = asyncio.create_task(fetch_data())
# 模拟做其他工作
await asyncio.sleep(5)
# 现在,await 任务以获取结果
result = await task
print(result)
asyncio.run(main())
其他重要要点
- 你可以混合使用同步和异步代码。 由于同步代码是阻塞式的,所以可以使用
asyncio.to_thread()
将其转移到一个单独的线程中执行。这样一来,你的程序实际上就变成多线程的了。在下面的示例中,asyncio 事件循环在主线程上运行,而一个单独的后台线程用于执行sync_task
。
import asyncio
import time
def sync_task():
time.sleep(2)
return "Completed"
async def main():
result = await asyncio.to_thread(sync_task)
print(result)
asyncio.run(main())
- 你应该将计算密集型的 CPU 密集型任务转移到单独的进程中执行。
何时该使用哪种并发模型?
以下流程是决定何时使用哪种模型的一个好方法。
流程图(本人绘制),参考了这个 Stack Overflow 讨论
- 多进程:最适合计算密集型的 CPU 密集型任务。 - 当你需要绕过 GIL 时——每个进程都有自己的 Python 解释器,能够实现真正的并行。
- 多线程:最适合快速的 I/O 密集型任务,因为这样可以减少上下文切换的频率,并且 Python 解释器能在单个线程上停留更长时间。 - 由于 GIL 的存在,它不太适合 CPU 密集型任务。
- 异步 I/O(asyncio):非常适合诸如长时间网络请求或数据库查询之类的慢速 I/O 密集型任务,因为它能高效地处理等待情况,具备可扩展性。 - 如果不将工作转移到其他进程,它不适合 CPU 密集型任务。
总结
就是这些内容啦,这个话题其实还有很多可以探讨的地方,但我希望已经向大家介绍了各种概念以及何时该使用每种方法。
感谢阅读!我会定期撰写关于 Python、软件开发以及我所构建的项目的文章,所以请关注我,以免错过精彩内容。下篇文章再见啦!