1 什么是 asyncio
asyncio是异步I/O的缩写,是一个python库,允许使用异步编程模型运行代码,即可以在一次性处理多个I/O操作的同时,仍然允许应用程序保持对外界的响应。
什么是异步编程?
- 有一个特定的长时间运行的任务可以在后台运行,与主应用程序分开,系统可以自由的执行不依赖该任务的其他工作,而不是阻止所有应用程序代码直到长时间运行的任务完成。
- 一旦长时间的任务运行完成,主应用程序会受到已经完成的通知,以便对结果进行处理。
在python3.4中,asyncio引入了装饰器和生成器,通过yield from
来定义协程。
协程是一种方法,当一个可能长时间运行的任务时,它可以被暂停,然后在任务完成时恢复。
asyncio也可以通过多线程和多处理进行互操作来处理其他类型的操作,使得这个库不仅适用于基于I/O的并发性,还可以用于CPU密集型代码。下面将详细介绍这两种操作之间的差异。
1.2 什么是I/O密集型和CPU密集型
(1)I/O密集型:
- 特点是大部分时间都花在等待输入输出操作完成上。
- I/O操作通常包括与文件系统、网络、数据库等进行交互。
- 异步编程模型非常适合处理I/O密集型任务,因为它允许程序在等待I/O操作的同时去做其他事情。
(2)CPU密集型:
- 指那些需要大量CPU计算的任务,比如图像处理、视频编码、复杂的数学运算等。
- 并行处理可以显著加速CPU密集型任务
1.3 并发、并行、多任务处理
(1)并发: 并发指的是多个计算或操作似乎同时进行的能力。这里的“似乎”很重要,因为实际上在单核处理器上,操作系统通过快速切换线程或进程的方式使得这些任务看起来像是同时运行的。
(2)并行: 并行是指真正的同时进行多个计算或操作。这通常需要多核或多处理器系统来支持。
(3)多任务处理:
多任务处理是一种让计算机在同一时间间隔内执行多个任务的技术。它既可以指操作系统层面的同时管理多个应用程序,也可以指单个应用内部同时执行多个任务。
-
抢占式多任务处理: 操作系统可以强制暂停一个正在运行的任务,转而执行另一个任务。
-
协同多任务处理: 任务必须显式地交出控制权才能让其他任务运行,这种方式较少使用,因为它依赖于任务的良好行为。
1.3.1 协同多任务处理的优势
asyncio使用协同多任务来实现并发性。
当应用程序到达可以等待一段时间以返回结果的时间点时,在代码中显式地标记它。然后允许其他代码进行运行,一旦标记的任务完成,应用程序就“醒来”继续执行任务。注意,这部时并行模式,因为不是同时执行的。
1.4 进程、线程、多线程、多处理
(1)进程: 进程是操作系统进行资源分配和调度的基本单位。每个进程拥有独立的地址空间、内存、数据栈和其他资源
- 每个进程都有自己的执行环境,包括代码段、数据段、堆栈等。
- 进程之间的通信通常需要通过特定的机制,如管道、套接字或共享内存。
- 创建和销毁进程的成本相对较高,因为涉及到大量资源的分配和回收。
(2)线程: 线程是进程中可独立执行的最小单元。
- 线程之间切换开销较小,因为它们共享大部分上下文信息。
- 同一进程内的线程可以直接访问该进程的数据,这使得线程间的通信更为简单快捷。
- 如果一个线程崩溃,它可能会影响到整个进程中的其他线程,除非采取了适当的保护措施。
(3)多线程: 多线程是指在一个单一的程序或进程中同时存在并运行多个线程的技术。
(4)多处理: 多处理指的是利用多个CPU核心来并行执行多个进程或线程,从而加速计算密集型任务的处理速度。
- 跨多个进程的多处理: 不同的进程可以在不同的CPU核心上并行运行。
- 跨多个线程的多处理: 对于支持多核的处理器,不同线程也可以被分配到不同的核心上并行执行。
1.5 理解全局解释器锁
全局解释锁的缩写是GIL,是Python解释器(CPython实现)中的一种机制。它是一个互斥锁,用于保护对Python对象的访问,确保在任何时刻只有一个线程在执行Python字节码。
什么是CPython?
CPython 是 Python 编程语言的参考实现和最广泛使用的实现。
这对希望利用多线程来提高应用程序性能是一个挑战。
1.5.1 GIL会在特定情况下释放
- I/O 操作: 当一个线程执行 I/O 操作(例如读写文件、网络请求等)时,CPython 会临时释放 GIL,允许其他线程获得 GIL 并继续执行。
- 显式释放: 开发者也可以通过使用 Python 的
threading
模块中提供的低级接口,如Py_BEGIN_ALLOW_THREADS
和Py_END_ALLOW_THREADS
宏,在 C 扩展代码中显式地释放和重新获取 GIL。
1.5.2 asyncio和GIL
asyncio利用I/O操作释放GIL来提供并发性。
当使用asyncio时,创建了名为协程的对象,可以被看作是一个轻量级的线程,可以让多个协程一起运行。在等待I/O密集型的协程完成时,仍可以执行其他python代码,从而提供并发性。注意,asyncio并没有绕过GIL。
1.6 单线程并发
现在介绍如何在一个进程和一个线程的范围内,使用非阻塞套接字实现并发性。
关于什么是套接字,可以参考这篇文章。
套接字默认情况下是阻塞的,但在操作系统系中,可以在非阻塞模式下运行。
当我们向套接字写入字节时,可以直接触发,从而不必在意写入或读取操作,应用程序可以继续执行其他任务。之后,由系统通知,我们收到字节了,并开始处理它。这样可以使应用程序在等待字节返回的同时,做任意数量的其他事情。不再阻塞和等待数据的返回,让程序响应更迅速。
以下是不同操作系统的事件通知系统:
这些系统会跟踪非阻塞套接字,并在准备好让我们做某事是进行通知。这个通知就是asyncio实现并发的基础。
在asyncio的并发模型中,只有一个线程在待定的时间执行Python。当遇到一个I/O操作时,会把它交给事件通知系统进行跟踪,然后Python可以继续执行其他代码,当I/O操作完成时,事件通知系统会进行通知,然后继续运行该I/O操作之后出现的其他代码。如下图所示:
但是,如何跟踪哪些是正在等待I/O的任务呢?这得依赖于“事件循环”的构造。
1.7 事件循环的工作原理
事件循环是每个asyncio应用程序的核心。
- 初始化: 事件循环开始运行前,会初始化一些资源,比如设置监听的套接字、注册定时器等。
- 轮询事件: 事件循环使用操作系统提供的接口(如Linux的epoll、BSD的kqueue或者Windows的IOCP)来监视多个文件描述符(包括套接字)上的活动。这些接口允许事件循环高效地跟踪大量连接,并且只有当有事件发生时才通知应用程序。
- 调度任务: 当检测到某个文件描述符上有事件发生(例如,数据到达或套接字准备好发送数据),事件循环会调用相应的回调函数或继续执行之前暂停的协程来处理这个事件。
- 执行回调/协程: 一旦事件循环触发了回调函数或恢复了一个协程,它将执行相关的代码逻辑来处理该事件。对于非阻塞操作,如果一个操作不能立即完成(例如,没有数据可读或不能立刻发送所有数据),那么它会立即返回,允许事件循环去处理其他事件。
- 重复: 上述过程不断重复,直到应用程序明确要求停止事件循环或者没有更多的事件需要处理。
我们假设有三个任务,每个任务都发出一个异步Web请求。当任务1开始执行代码,其他两个任务进行等待,当任务1中的遇到了一个I/O密集型操作,就会暂停任务1:“我正在等待I/O”。
此时,就可以执行任务2,当任务2遇到了一个I/O密集型操作,则会暂停任务2:“我也正在等待I/O”。
此时开始执行任务3,当任务3遇到同样的情况暂停时,此时任务1的I/O操作完成,事件通知系统会进行通知,此时继续执行**任务1。**流程如下图所示:
可以发现,在任何时间上,最多只有一个CPU密集型任务在执行,但时却有I/O密集型任务在同时执行,最多的时候,还有两个I/O密集型任务同时执行,这就是asyncio节省时间的地方。