为了账号安全,请及时绑定邮箱和手机立即绑定

Python多线程、多进程和异步IO的深度讲解

标签:
Python
如何选对并发模型

图片由 Paul Esch-Laurent 制作,来自 Unsplash

Python 提供了三种主要的方式来并行处理多个任务:线程、进程和并发 asyncio。

选择合适的模型对于提升程序性能和高效地使用系统资源至关重要。(顺便说一句,这也是一个常见的面试问题!)

没有并发,一个程序一次只能处理一个任务。在执行这些操作的过程中,如文件加载、网络请求或用户输入,程序会闲置,浪费CPU周期。并发通过同时高效运行多个任务解决了这个问题。

但你应该用哪个模型呢?咱们开始吧!

内容目录
  1. 并发基础知识
    - 并发与并行
    - 程序的概念
    - 进程的概念
    - 线程
    - 操作系统如何管理线程和进程?

  2. Python中的并发模型
    - 多线程
    - Python的GIL
    - 多进程
    - Asyncio

  3. 何时应该选择哪种并发模型?
并发基础知识

在我们深入探讨Python的并发模型之前,让我们快速回顾一些基础概念。

1. 并发 vs 并行

并发与并行的可视化(我画的)

并发是指同时管理多个任务,但不一定真正同时进行。这些任务可能轮流处理,从而产生多任务处理的假象。

并行是关于同时执行多个任务,通常通过使用多个CPU核心。

2. 程序部分

我们现在来聊一聊一些基本的操作系统概念,包括程序、任务和子任务这些概念。

多线程可以同时在一个进程中存在——被称为多线程(由我画)

程序其实就像一个静态代码文件,比如Python脚本或可执行文件。

程序存储在磁盘上,处于静止状态直到操作系统(OS)将其加载到内存中执行。一旦加载完成,程序就变成一个进程

3: 流程

可以说,进程就是一个正在执行的程序的独立实例。

每个进程都有自己独立的内存空间、资源和执行状态。进程之间相互隔离,这意味着,除非有特别设计,否则一个进程无法干扰另一个进程。通过如进程间通信 (IPC)之类的机制,进程可以被明确设计用来实现这种交互。

流程通常被分为两种类型。

  1. I/O密集型任务:
    大部分时间都花在等待输入/输出操作完成上,例如文件访问、网络通信或用户输入。在这期间,CPU会闲置。
  2. CPU密集型任务:
    大部分时间都在进行繁重的计算(例如视频编码、数值分析)。这些任务会消耗大量的CPU时间。

进程的生命历程:

  • 一个进程在创建时处于一个新的状态。
  • 它进入就绪状态,等待 CPU 时间。
  • 如果进程等待 I/O 等事件,它会进入等待状态。
  • 最后,完成任务后,它会结束
4. 线程 (Threads)

线程是构成进程的最小执行单位。
进程充当线程的“容器”,并且可以在进程的整个生命周期中创建和销毁多个线程。

每个进程至少有一个线程,这是主线程,但是它也可以创建更多的线程。

线程在同一进程中共享内存和资源,从而实现高效通信。然而,如果不谨慎管理,这种共享可能导致同步问题,如竞态或死锁。与进程不同,单个进程中的多个线程并不是隔离的——一个捣乱的线程可能导致整个进程挂掉。

第五 操作系统如何管理线程和进程?

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,从而允许其他线程运行和使用解释器。

2. 多处理.

多进程允许系统并行运行多个进程,每个进程都有自己独立的内存、GIL和资源。在每个进程中,可以有一个或多个线程(如图3和图4所示)。

使用多进程克服了GIL带来的限制。因此适合需要大量计算的任务。

不过,使用多个进程由于需要单独管理内存和每个进程的额外开销,更消耗资源。

3. 异步IO

与线程或进程不同,asyncio 使用一个线程来同时运行多个任务。

当你用 asyncio 库编写异步代码时,你会用 asyncawait 关键字来处理任务。

几个重要的概念
  1. 协程: 使用 async def 定义的函数,是 asyncio 的核心,可以暂停后继续运行。
  2. 事件循环: 管理任务的执行流程。
  3. 任务: 协程的包装器,当你希望协程开始运行时,可以使用 asyncio.create_task() 将其转换为任务。
  4. **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 讨论

  1. 多进程
    - 最适合计算密集型任务。
    - 当需要绕过GIL时——这样每个进程都有自己的Python解释器,从而实现真正的并行处理。
  2. 多线程
    - 最适合快速的I/O密集型任务,因为减少了上下文切换的频率,Python解释器会长时间保持在单个线程上。
    - 不适合CPU密集型任务,因为存在GIL。
  3. Asyncio
    - 适合慢速的I/O密集型任务,例如长时间的网络请求或数据库查询,因为它能高效管理等待时间,使其具有很好的扩展性。
    - 不适合CPU密集型任务,除非通过其他进程来分担任务。
收尾

就这样吧,伙计们。这个话题还有很多可以讲的,但我希望我已经向你们介绍了各种概念的大概,以及在什么情况下使用每种方法。

感谢您的阅读!我经常写关于Python、软件开发以及我正在构建的项目。所以请关注我,就不会错过任何文章哦。下篇文章见啦!

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消