图片由 Paul Esch-Laurent 制作,来自 Unsplash
Python 提供了三种主要的方式来并行处理多个任务:线程、进程和并发 asyncio。
选择合适的模型对于提升程序性能和高效地使用系统资源至关重要。(顺便说一句,这也是一个常见的面试问题!)
没有并发,一个程序一次只能处理一个任务。在执行这些操作的过程中,如文件加载、网络请求或用户输入,程序会闲置,浪费CPU周期。并发通过同时高效运行多个任务解决了这个问题。
但你应该用哪个模型呢?咱们开始吧!
内容目录-
并发基础知识
- 并发与并行
- 程序的概念
- 进程的概念
- 线程
- 操作系统如何管理线程和进程? -
Python中的并发模型
- 多线程
- Python的GIL
- 多进程
- Asyncio - 何时应该选择哪种并发模型?
在我们深入探讨Python的并发模型之前,让我们快速回顾一些基础概念。
1. 并发 vs 并行并发与并行的可视化(我画的)
并发是指同时管理多个任务,但不一定真正同时进行。这些任务可能轮流处理,从而产生多任务处理的假象。
并行是关于同时执行多个任务,通常通过使用多个CPU核心。
2. 程序部分我们现在来聊一聊一些基本的操作系统概念,包括程序、任务和子任务这些概念。
多线程可以同时在一个进程中存在——被称为多线程(由我画)
程序其实就像一个静态代码文件,比如Python脚本或可执行文件。
程序存储在磁盘上,处于静止状态直到操作系统(OS)将其加载到内存中执行。一旦加载完成,程序就变成一个进程。
3: 流程可以说,进程就是一个正在执行的程序的独立实例。
每个进程都有自己独立的内存空间、资源和执行状态。进程之间相互隔离,这意味着,除非有特别设计,否则一个进程无法干扰另一个进程。通过如进程间通信 (IPC)之类的机制,进程可以被明确设计用来实现这种交互。
流程通常被分为两种类型。
- I/O密集型任务:
大部分时间都花在等待输入/输出操作完成上,例如文件访问、网络通信或用户输入。在这期间,CPU会闲置。 - CPU密集型任务:
大部分时间都在进行繁重的计算(例如视频编码、数值分析)。这些任务会消耗大量的CPU时间。
进程的生命历程:
- 一个进程在创建时处于一个新的状态。
- 它进入就绪状态,等待 CPU 时间。
- 如果进程等待 I/O 等事件,它会进入等待状态。
- 最后,完成任务后,它会结束。
线程是构成进程的最小执行单位。
进程充当线程的“容器”,并且可以在进程的整个生命周期中创建和销毁多个线程。
每个进程至少有一个线程,这是主线程,但是它也可以创建更多的线程。
线程在同一进程中共享内存和资源,从而实现高效通信。然而,如果不谨慎管理,这种共享可能导致同步问题,如竞态或死锁。与进程不同,单个进程中的多个线程并不是隔离的——一个捣乱的线程可能导致整个进程挂掉。
第五 操作系统如何管理线程和进程?CPU 每次每个核心只能执行一个任务,而不是多个任务。操作系统使用抢占式任务切换。为了同时处理多个任务,这是必要的。
在上下文切换过程中,操作系统会暂停当前的任务,保存它的状态,并加载下一个任务的状态。
这种快速切换创造了在同一CPU核心上同时执行的错觉效果。
对于进程来说,上下文切换会更消耗资源,因为操作系统需要保存和加载各自的内存空间。对于线程,切换会更快,因为线程在一个进程中共享内存。然而,频繁切换会带来开销,这会减慢性能,会导致性能下降。
真正的进程并行执行只能在有多颗CPU核心可用时才能实现。每个核心同时处理一个独立的进程,这样每个进程都能得到独立的处理资源。
- Python的并发模型
现在,一起来探索 Python 的特定的并发模式。
不同并发模型的总结(由我画的)
1. 多线程技术多线程可以让一个程序同时运行多个线程,这些线程共享相同的内存和资源等(请参见图2和图4)。
然而,Python的全局解释器锁(GIL,Global Interpreter Lock)限制了多线程机制在CPU密集型任务上的有效性。
Python的GIL(全局解释器锁)GIL是一个锁具,确保任何时候只有一个线程能控制Python解释器,这意味着任何时刻只能有一个线程运行Python字节码指令。
GIL被引入以简化Python的内存管理,因为许多内部操作(如对象创建)默认情况下不是线程安全的。如果没有GIL,多个线程访问共享资源时,将需要复杂的锁和同步机制来避免竞态条件和数据损坏问题。
什么时候GIL会成为瓶颈?
- 对于单线程程序,GIL是无关的,因为线程独占了Python解释器的访问。
- 对于多线程的I/O程序,GIL的影响较小,线程在等待I/O操作时会暂时释放GIL。
- 对于多线程的CPU操作,GIL会成为一个显著的瓶颈。多个线程在争夺GIL时必须依次执行Python指令。
一个有趣的案例是 Python 将 time.sleep
视为 I/O 操作的使用。time.sleep
函数在睡眠期间并不涉及主动计算或执行 Python 字节码。相反,在这段时间里,跟踪经过的时间的责任交给了操作系统。在此期间,线程释放了 GIL,从而允许其他线程运行和使用解释器。
多进程允许系统并行运行多个进程,每个进程都有自己独立的内存、GIL和资源。在每个进程中,可以有一个或多个线程(如图3和图4所示)。
使用多进程克服了GIL带来的限制。因此适合需要大量计算的任务。
不过,使用多个进程由于需要单独管理内存和每个进程的额外开销,更消耗资源。
3. 异步IO与线程或进程不同,asyncio 使用一个线程来同时运行多个任务。
当你用 asyncio
库编写异步代码时,你会用 async
和 await
关键字来处理任务。
- 协程: 使用
async def
定义的函数,是asyncio
的核心,可以暂停后继续运行。 - 事件循环: 管理任务的执行流程。
- 任务: 协程的包装器,当你希望协程开始运行时,可以使用
asyncio.create_task()
将其转换为任务。 **await**
: 暂停协程,将控制权交给事件循环。
Asyncio 运行一个事件循环来安排任务。当任务在等待网络响应或文件读取时,它们会自愿“暂停”自己。在任务暂停期间,事件循环会切换到其他任务,确保不会在等待中浪费时间。
这使得 asyncio
在涉及许多需要长时间等待的小型任务的情况下非常理想,例如处理成千上万个网络请求或管理数据库查询。由于所有操作都在单个线程上运行,asyncio
避免了线程切换带来的额外开销和复杂性。
异步IO和多线程之间的关键区别在于它们处理等待异步任务的方式。
- 多线程依赖操作系统在某一线程等待时进行抢占式的上下文切换。
- 使用
**asyncio**
时,只有一个线程,并且依赖任务在需要等待时暂停来协作,从而实现协作式多任务处理。
**方法1:等待协程完成**
当你直接 await
一个协程时,当前协程会在遇到 await
语句时暂停,直到被等待的协程完成。这样一来,任务在当前协程中是按顺序执行的。
当你需要立即得到__**_的协程结果以便继续下一步时,使用这种方法。
虽然这听起来像同步代码,但实际上不是。在同步代码里,程序会在暂停期间完全停止。
使用
asyncio
时,只有当前的协程会被暂停,而程序的其他部分可以继续运行。这使得asyncio
在程序层面不会阻塞。
例子:
事件循环会暂停当前协程,直到fetch_data
完成为止。
async def fetch_data():
print("正在获取数据...")
await asyncio.sleep(1) # 模拟网络请求
print("数据获取完成")
return "data"
async def main():
result = await fetch_data() # 协程在此处暂停
print(f"结果是 {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)
# 等待任务完成并获取结果
result = await task
print(result)
asyncio.run(main())
其他重要的方面
- 你可以混合使用同步和异步代码。
由于同步代码是阻塞的,可以使用asyncio.to_thread()
将其移到单独的线程中去处理,这样就使你的程序实际上实现了多线程的效果。
在下面的例子中,异步IO事件循环在主线程上运行,而一个单独的后台线程用来执行sync_task
。
import asyncio
import time
# 定义一个同步任务函数,该函数会等待2秒后返回“已完成”
def 同步任务():
time.sleep(2)
return "已完成"
# 定义一个异步主函数,该函数会调用同步任务,并打印结果
async def 主函数():
结果 = await asyncio.to_thread(同步任务)
print(结果)
# 运行主函数
asyncio.run(主函数())
这是一个Python代码示例,展示了如何使用异步函数调用同步任务。
应将计算密集型的任务移至一个单独的进程中。
我应该何时使用哪种多线程模型呢?这是一种很好的方法,可以帮助我们决定何时使用某物。
我画的流程图,参考了这个 stackoverflow 讨论
- 多进程
- 最适合计算密集型任务。
- 当需要绕过GIL时——这样每个进程都有自己的Python解释器,从而实现真正的并行处理。 - 多线程
- 最适合快速的I/O密集型任务,因为减少了上下文切换的频率,Python解释器会长时间保持在单个线程上。
- 不适合CPU密集型任务,因为存在GIL。 - Asyncio
- 适合慢速的I/O密集型任务,例如长时间的网络请求或数据库查询,因为它能高效管理等待时间,使其具有很好的扩展性。
- 不适合CPU密集型任务,除非通过其他进程来分担任务。
就这样吧,伙计们。这个话题还有很多可以讲的,但我希望我已经向你们介绍了各种概念的大概,以及在什么情况下使用每种方法。
感谢您的阅读!我经常写关于Python、软件开发以及我正在构建的项目。所以请关注我,就不会错过任何文章哦。下篇文章见啦!
共同学习,写下你的评论
评论加载中...
作者其他优质文章