什么是 asyncio?如何基于单线程实现并发?事件循环又是怎么工作的?

深入理解 asyncio 的基本概念、工作原理、IO 密集型与 CPU 密集型区别,以及事件循环机制

分类: basics 难度: 中级 更新: 2024-01-15
asyncio 事件循环 协程 异步编程 单线程并发

什么是 asyncio?如何基于单线程实现并发?事件循环又是怎么工作的?

📝 概述

asyncio 是 Python 3.4 引入的异步编程库,通过单线程事件循环实现高并发处理。本文全面介绍 asyncio 的核心概念、并发与并行的区别、IO 密集型与 CPU 密集型操作,以及协同多任务处理模型。

🎯 学习目标

  • 理解 asyncio 的核心概念和应用场景
  • 掌握并发、并行、多任务处理的区别
  • 理解 IO 密集型和 CPU 密集型操作的特点
  • 学会识别何时使用异步编程
  • 掌握进程、线程的基本概念和差异

📋 前置知识

  • Python 函数和基本语法
  • 对进程和线程有基本了解
  • 理解同步和异步的概念

🔍 详细内容

楔子

许多应用程序,尤其在当今的 Web 应用程序领域,严重依赖 IO 操作。这些类型的操作包括从 Internet 下载网页的内容、通过网络与一组微服务进行通信,或者针对 MySQL、Postgres 等数据库同时运行多个查询。Web 请求或与微服务的通信可能需要数百毫秒,如果网络很慢,甚至可能需要几秒钟。数据库查询可能耗费大量时间,尤其是在该数据库处于高负载或查询很复杂的情况下,而且 Web 服务器可能需要同时处理数百或数千个请求。

一次发出许多这样的 IO 请求会导致严重的性能问题,如果像在顺序运行的应用程序中那样一个接一个地执行这些请求,将看到复合的性能影响。例如,如果正在编写一个需要下载 100 个网页或执行 100 个查询的应用程序,每个查询需要 1 秒的执行时间,那么应用程序将至少需要 100 秒才能运行完成。但是,如果利用并发性,同时启动下载和等待,理论上可在短短 1 秒内完成这些操作。

asyncio 最初是在 Python3.4 中引入的,作为在多线程和多进程之外,处理这些高度并发工作负载的另一种方式。对于使用 IO 操作的应用程序来说,适当地利用这个库可以极大地提高性能和资源利用率,并可同时启动许多长时间运行的任务。

什么是 asyncio?

在同步应用程序中,代码按顺序运行,下一行代码在前一行代码完成后立即运行,并且一次只发生一件事。该模型适用于许多应用程序,但如果一行代码运行特别慢怎么办?这种情况下,这个运行很慢的代码行之后的其它所有代码都将被卡住,必须等待该行代码执行完成。这些潜在的运行较慢的代码行可能会阻止应用程序运行任何其他代码。许多人在操作图形界面应用程序时遇到过这种情况,我们在界面上四处单击,直到应用程序冻结卡死,出现一个微调器或一个无响应的用户界面。这是一个应用程序被阻止从而导致糟糕用户体验的示例。

尽管任何操作都可以阻塞应用程序,如果它花费的时间足够长,许多应用程序会阻塞等 IO。IO 是指计算机的输入和输出设备,例如键盘、硬盘驱动器,以及最常见的网卡,这些操作等待用户输入内容或从基于 Web 的 API 检索内容。在同步应用程序中(相对异步应用程序而言),我们将等待这些操作完成,然后才能运行其它操作。这可能导致性能和响应性问题,因为我们只能在同一时间运行一个长时间的操作,并且该操作将阻止应用程序执行其它任何操作。

此问题的一种解决方案是引入并发性。简单来说,并发意味着允许同时处理多个任务。在并发 IO 的情况下,允许同时发出多个 Web 请求或允许多个客户端同时连接到 Web 服务器。

有几种方法可以在 Python 中实现这种并发性,Python 生态系统的最新成员之一是 asyncio 库。asyncio 是异步 IO 的缩写,它是一个 Python 库,允许使用异步编程模型运行代码。这让我们可以一次处理多个 IO 操作,同时仍然允许应用程序保持对外界的响应。

那什么是异步编程呢?这意味着一个特定的长时间运行的任务可以在后台运行,与主应用程序分开。系统可以自由地执行不依赖于该任务的其他工作,而不是阻止其他所有应用程序代码等待该长时间运行的任务完成。一旦长时间运行的任务完成会收到它已经完成的通知,以便对结果进行处理。

在 Python 3.4 中,asyncio 首先引入了装饰器和生成器通过 yield from 来定义协程。协程是一种方法,当有一个可能长时间运行的任务时,它可以暂停,然后在任务完成时恢复。在 Python3.5 中,当关键字 async 和 await 被显式添加到语言中时,该语言实现了对协程和异步编程的顶级支持。这种语法在 C# 和 JavaScript 等其他编程语言中很常见,使异步代码看起来像是同步运行的。这样的异步代码易于阅读和理解,因为它看起来像大多数软件工程师熟悉的顺序流。而 asyncio 是一个库,使用称为单线程事件循环的并发模型以异步方式执行这些协程。

虽然 asyncio 的名字可能会让我们认为这个库只适用于 IO 操作,但它也可通过与多线程和多进程相互操作来处理其他类型的操作。通过这种互操作性,可使用线程和进程的 async 与 await 语法,使这些工作流更容易理解。这意味着这个库不仅适用于基于 IO 的并发性,还可以用于 CPU 密集型代码。为更好地理解 asyncio 可以帮助我们处理何种类型的工作负载,以及哪种并发模型最适合哪种并发类型,下面探索 IO 密集型和 CPU 密集型操作之间的差异。

什么是 IO 密集型和 CPU 密集型

将一个操作称为 IO 密集型或 CPU 密集型时,指的是阻止该操作更快地运行的限制因素。这意味着,如果提高操作所绑定的对象的性能,该操作将在更短时间内完成。

在 CPU 密集型操作的情况下,如果 CPU 更强大,它将更快地完成任务,例如将其时钟速度从 2GHz 提高到 3GHz。在 IO 密集型操作的情况下,如果 IO 设备能在更短的时间内处理更多数据,程序将变得更快。这可通过 ISP 增加网络带宽或升级到更快的网卡来实现。

CPU 密集型操作通常是 Python 中的计算和处理代码,例如计算 pi 的数值,或者应用业务逻辑循环遍历字典的内容。在 IO 密集型操作中,大部分时间将花在等待网络或其他 IO 设备上,例如向 Web 服务器发出请求或从机器的硬盘驱动器读取文件。

import httpx

res = httpx.get("http://www.baidu.com")  # IO 密集型(web 请求)

items = res.headers.items()

headers = [f"{key}: {val}" for key, val in items]  # CPU 密集型(响应处理)

formatted_headers = "\n".join(headers)  # CPU 密集型(字符串连接)

with open("headers.txt", "w", encoding="utf-8") as f:
    f.write(formatted_headers)  # IO 密集型(磁盘写入)

IO 密集型和 CPU 密集型操作通常并存,我们首先发出一个 IO 密集型请求来下载 http://www.baidu.com 的内容。一旦得到响应,将执行一个 CPU 密集型循环来格式化响应头,并将它们转换为一个由换行符分隔的字符串。然后打开一个文件,并将字符串写入该文件,这两种操作都是 IO 密集型操作。

异步 IO 允许在执行 IO 操作时暂停特定程序的执行,可在后台等待初始 IO 完成时运行其他代码。这允许同时执行许多 IO 操作,从而潜在地加快应用程序的运行速度。

了解并发、并行和多任务处理

为了深刻地理解并发如何帮助应用程序更好地运行,首先应学习并充分理解并发编程的术语。本节,我们将学习更多关于并发的含义,以及 asyncio 如何使用一个称为多任务的概念来实现它。并发性和并行性是两个概念,可以帮助我们理解安排和执行各种任务、方法与驱动动作的例程。

并发

当我们说两个任务并发时,是指它们在一个时间段内同时执行。例如一个面包师要烘焙两种不同的蛋糕,要烘焙这些蛋糕,需要预热烤箱。根据烤箱和烘焙温度的不同,预热可能需要几十分钟,但不需要等烤箱完成预热后才开始其他工作,比如把面粉、糖和鸡蛋混合在一起。在烤箱预热过程中,我们可以做其他工作,直到烤箱发出提示音,告诉我们它已经预热完成了。

我们也不需要限制自己在完成第一个蛋糕之后才开始做第二个蛋糕。开始做蛋糕面糊,将其放进搅拌机,当第一团面糊搅拌完毕,就开始准备第二团面糊。在这个模型中,同时在不同的任务之间切换。这种任务之间的切换(烤箱加热时做其他事情,在两个不同的蛋糕之间切换)是并发行为。

并行

当我们说两个任务并行时,是指它们在同一个时间点同时执行。回到蛋糕烘焙示例,假设有第二个面包师的帮助。这种情况下,第一个面包师可以制作第 1 个蛋糕,而第二个面包师同时制作第 2 个蛋糕。两个人同时制作面糊属于并行运行,因为有两个不同的任务同时运行。

一句话总结并发和并行之间的区别:

  • 并发:有多个任务在运行,但在特定的时间点,只有一个任务在运行;
  • 并行:有多个任务在运行,但在特定的时间点,仍然有多个任务同时运行;

回到操作系统和应用程序,想象有两个应用程序在运行。在只有并发的系统中,可以在这些应用程序之间切换,先让一个应用程序运行一会儿,然后让另一个应用程序运行一会儿。如果切换的速度足够快,就会出现两件事同时发生的现象(或者说是假象)。但在一个并行的系统中,两个应用程序同时运行,系统同时全力运行两个任务,而不是在两个任务之间来回切换。

并发和并行的概念相似,并且经常容易混淆,一定要对它们之间的区别有清楚的认识。

并行与并发的区别

并行与并发的区别

并发关注的是可以彼此独立发生的多个任务,可以在只有一个内核的 CPU 上实现并发,因为该操作将采用抢占式多任务的方式在任务之间切换。然而并行意味必须同时执行两个或多个任务,在只具有一个核心的机器上,这是不可能的。如果想成为可能,我们需要一个可同时运行多个任务的多核CPU。

虽然并行意味着并发,但并发并不总是意味着并行。在多核机器上运行的多线程应用程序既是并发的又是并行的。在此设置中,同时运行多个任务,并有多个内核独立执行与这些任务相关的代码。但是,通过多任务处理,可同时执行多个任务,而在给定时间只有一个任务在执行。

什么是多任务

多任务处理在当今世界无处不在,人们一边做早餐一边看电视,一边接电话一边等水烧开来泡茶。甚至在旅行途中,人们也可以多任务处理,例如在搭乘飞机时读自己喜欢的书。本节讨论两种主要的多任务处理:抢占式多任务处理和协同多任务处理。

抢占式多任务处理

在这个模型中,由操作系统决定如何通过一个称为时间片的过程,在当前正在执行的任务之间切换。当操作系统在任务之间切换时,我们称之为抢占。这种机制如何在后台工作取决于操作系统本身。它主要是通过使用多个线程或多个进程来实现的。

协同多任务处理

在这个模型中,不是依赖操作系统来决定何时在当前正在执行的任务之间切换,而是在应用程序中显式地编写代码,来让其他任务先运行。应用程序中的任务在它们协同的模型中运行,明确地说:”先暂停我的任务一段时间,让其他任务先执行”。

协同多任务处理的优势

asyncio 使用协同多任务来实现并发性,当应用程序达到可以等待一段时间以返回结果的时间点时,在代码中显式地标记它,并让其它代码执行。一旦标记的任务完成,应用程序就”醒来”并继续执行该任务。这是一种并发形式,因为可同时启动多个任务,但重要的是,这不是并行模式,因为它们不会同时执行代码。

协同多任务处理优于抢占式多任务处理。首先,协同式多任务处理的资源密集度较低。当操作系统需要在线程或进程之间切换时,将涉及上下文切换。上下文切换是密集操作,因为操作系统只有保存有关正在运行的进程或线程的信息之后才能重新进行加载。

了解进程、线程、多线程和多处理

为更好地了解 Python 中并发的工作原理,首先需要了解线程和进程如何工作的基础知识,然后研究如何将它们用于多线程和多进程以同时执行任务。让我们从进程和线程的定义开始学习。

进程

进程是具有其它应用程序无法访问的内存空间的应用程序运行状态,创建 Python 进程的一个例子是运行一个简单的 “hello world” 应用程序,或在命令行输入 Python 来启动 REPL(交互式环境)。

多个进程可以在一台机器上运行,如果有一台拥有多核 CPU 的机器,就可以同时执行多个进程。在只有一个核的 CPU 上,仍可通过时间片,同时运行多个应用程序。当操作系统使用时间片时,它会在一段时间后自动切换下一个进程并运行它。确定何时发生此切换的算法因操作系统而异。

线程

线程可以被认为是轻量级进程,此外线程是操作系统可以管理的最小结构,它们不像进程那样有自己的内存空间,相反,它们共享进程的内存。线程与创建它们的进程关联,一个进程总是至少有一个与之关联的线程,通常称为主线程。一个进程还可以创建其他线程,通常称为工作线程或后台线程,这些线程可与主线程同时执行其他工作。线程很像进程,可以在多核 CPU 上并行运行,操作系统也可通过时间片在它们之间切换。当运行一个普通的 Python 应用程序时,会创建一个进程以及一个负责执行具体代码的主线程。

进程是操作系统分配资源的最小单元,线程是操作系统用来调度 CPU 的最小单元。进程好比一个房子,而线程是房子里面干活的人,所以一个进程里面可以有多个线程,线程共享进程里面的资源。因此真正用来工作的是线程,进程只负责提供相应的内存空间和资源。

import os
import threading

print(f"进程启动,pid 为 {os.getpid()}")
print(f"该进程内部运行 {threading.active_count()} 个线程")
print(f"当前正在运行的线程是 {threading.current_thread().name}")
"""
进程启动,pid 为 16900
该进程内部运行 1 个线程
当前正在运行的线程是 MainThread
"""

进程还可创建新的线程,这些线程可通过所谓的多线程技术同时完成其他工作,并共享进程的内存。

import threading

def hello_from_threading():
    print(f"Hello {threading.current_thread().name} 线程")

hello_thread = threading.Thread(target=hello_from_threading)
hello_thread.start()

print(f"该进程内部运行 {threading.active_count()} 个线程")
print(f"当前正在运行的线程是 {threading.current_thread().name}")
hello_thread.join()
"""
Hello Thread-1 线程
该进程内部运行 2 个线程
当前正在运行的线程是 MainThread
"""

注意:当你执行这段代码时,你可能会看到来自两个线程将消息输出在了同一行,这是一个竞态条件,后续讨论。

多线程应用程序是在许多编程语言中,实现并发的常用方法。然而在 Python 中利用线程的并发性存在一些挑战,因为会受到全局解释器锁的限制,导致多线程仅对 IO 密集型工作有用。

但多线程并不是实现并发的唯一方法,还可创建多个进程来同时工作,这称为多进程。在多进程中,父进程创建一个或多个由它管理的子进程,然后可将任务分配给子进程。

Python 也提供了多进程模块来处理这个问题,它的 API 类似于 threading 模块。

import multiprocessing
import os

def hello_from_process():
    print(f"当前子进程的 pid 为 {os.getpid()}")

# 在 Windows 上必须加上 if __name__ == '__main__'
# 否则多进程乎启动失败
if __name__ == '__main__':
    hello_process = multiprocessing.Process(target=hello_from_process)
    hello_process.start()
    hello_process.join()
"""
当前子进程的 pid 为 12532
"""

💡 实际应用

何时使用 asyncio

  1. IO 密集型应用:网络请求、文件读写、数据库查询
  2. 高并发服务器:Web 服务器、API 服务
  3. 爬虫应用:同时抓取多个网页
  4. 实时应用:聊天应用、游戏服务器

基本使用示例

import asyncio
import aiohttp

async def fetch_url(url):
    """异步获取URL内容"""
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    """主函数"""
    urls = [
        'http://httpbin.org/delay/1',
        'http://httpbin.org/delay/2',
        'http://httpbin.org/delay/3'
    ]
    
    # 并发执行多个任务
    tasks = [fetch_url(url) for url in urls]
    results = await asyncio.gather(*tasks)
    
    print(f"获取了 {len(results)} 个响应")

# 运行异步程序
asyncio.run(main())

⚠️ 注意事项

  • GIL 限制:asyncio 仍然受到 GIL 限制,但通过协作式调度避免了上下文切换开销
  • CPU 密集型任务:纯 CPU 密集型任务不适合 asyncio,应考虑多进程
  • 兼容性:确保使用的第三方库支持异步操作
  • 调试困难:异步代码的调试和错误追踪相对复杂

🔗 相关内容

📚 扩展阅读

🏷️ 标签

asyncio 事件循环 协程 异步编程 单线程并发


最后更新: 2024-01-15
作者: Python 编程指南
版本: 1.0

作者: Python 编程指南

版本: 1.0

讨论与反馈

欢迎在下方留言讨论,分享你的学习心得或提出问题。评论基于GitHub Issues,需要GitHub账号。