当前位置:首页 > 技术 > 正文内容

Python异步编程核心原理与实践

访客 技术 2026年8月9日 3

在Python中,asyncio 模块是实现单线程、单进程并发的关键工具。本文将深入探讨 asyncio 如何在不增加线程或进程开销的情况下,有效提升程序的执行效率,并厘清异步代码的适用边界。

并发处理的现实映照

为了更好地理解异步编程的概念,我们首先通过几个日常情境来区分不同类型的任务处理模式。

情景一:可交替执行的多任务

想象一下你需要完成三件事:用电饭煲煮饭(30分钟)、用洗衣机洗衣(40分钟)和打电话邀请朋友(50分钟后到达)。这些任务的总耗时如果串行执行将是 30 + 40 + 50 = 120 分钟。

然而,在实际生活中,你通常会在大约50分钟内完成:

  1. 首先打电话给朋友,让他们即刻出发。
  2. 接着把衣物放入洗衣机并启动。
  3. 然后淘米、将米放入电饭煲并启动。

此后,你只需等待。这三项任务虽然看似耗时漫长,但其中大部分时间是机器在"自主"运行或是在"等待"中度过,而你可以在这段时间内处理其他事务。

情景二:纯串行任务的限制

现在,你需要独立完成语文、数学和英语三张试卷,每张试卷需要一小时。由于这些任务要求你全身心投入,没有可以"等待"的空闲,因此你必须一张一张地完成。总耗时将是 1 + 1 + 1 = 3 小时,无法缩短。

情景三:混合任务的调度

如果任务组合变成:煮饭(30分钟)、洗衣(40分钟)和完成一张数学试卷(1小时)。你无需等待所有任务串行完成,总耗时也不会是 30 + 40 + 60 = 130 分钟。你可以这样安排:

  1. 启动洗衣机。
  2. 启动电饭煲。
  3. 开始完成数学试卷。

最终,你大约在洗衣机或电饭煲完成后的70分钟左右,也能完成大部分或全部任务。

异步编程的适用边界:I/O密集型与计算密集型

上述情景揭示了两种不同的任务类型:

  • I/O密集型任务:如情景一中的煮饭、洗衣、等待朋友。这些任务的特点是,实际需要人(或CPU)操作的时间很短,大部分时间都在等待外部事件(如水烧开、衣物洗净、朋友抵达、网络响应等)。
  • 计算密集型任务:如情景二中的做试卷。这些任务的特点是,CPU需要持续不断地进行大量计算,几乎没有空闲的等待时间。

在程序设计中,当我们通过 requests 发起HTTP请求、查询远程数据库、或读写本地文件时,这些都属于I/O操作。这些操作通常涉及向外部系统发送请求,然后等待其响应。例如,一个网络请求可能在0.01秒内发送完毕,但程序会"卡住"长达数秒,等待数据从服务器通过网络返回。这段漫长的等待时间,正是异步编程可以大展身手的地方。

相反,对于大量涉及CPU运算的程序,例如图像处理、复杂数学计算、数据加密解密等,CPU几乎始终处于满负荷状态。由于没有"等待"时间可供利用,异步编程在此类任务中并无显著优势。

因此,异步编程主要适用于I/O密集型任务,而非计算密集型任务。

Python异步编程机制解析

许多开发者可能会对Python的异步编程有一个直观的误解,认为可以在一个耗时的异步操作等待期间,随意插入其他同步代码执行,例如以下伪代码:

异步请求 https://example.com/api
在等待响应期间:
    执行 a = 1 + 1
    执行 b = 2 + 2
    执行 c = 3 + 3
接收到响应后,继续处理

这种思考方式类似于现实生活中,你把电饭煲插上电后,可以自由地看书、打电话或看电视。然而,Python的异步模型并非如此。

我们将通过一个实际案例来演示这种误解所导致的问题。首先,我们需要一个简单的Web服务来模拟网络延迟。当访问 http://127.0.0.1:8000/delay/<num> 时,服务器将等待 num 秒后才返回响应。例如,请求 http://127.0.0.1:8000/delay/3 会导致服务器延迟3秒返回。

以下是模拟服务器响应的示意图:

![]()

现在,我们使用 aiohttp 库发送三次请求,分别模拟1秒、2秒、3秒的延迟:

import aiohttp
import asyncio
import time

async def fetch_data(delay_sec: int):
    """异步函数:模拟网络请求,并打印结果。"""
    async with aiohttp.ClientSession() as session:
        target_url = f'http://127.0.0.1:8000/delay/{delay_sec}'
        async with session.get(target_url) as response_obj:
            data_payload = await response_obj.json()
            print(f"收到响应: {data_payload['time']}秒")

async def run_sequential_requests():
    """主函数:演示异步请求的串行执行。"""
    start_time = time.perf_counter()

    await fetch_data(1)  # 第一个请求,等待1秒
    x = 10 + 20
    y = 30 + 40
    print(f'同步代码在第一个请求等待期间执行了吗? x={x}, y={y}')

    await fetch_data(2)  # 第二个请求,等待2秒
    print('同步代码在第二个请求等待期间执行了吗?')

    await fetch_data(3)  # 第三个请求,等待3秒

    end_time = time.perf_counter()
    print(f'总计耗时:{end_time - start_time:.2f}秒')

if __name__ == '__main__':
    asyncio.run(run_sequential_requests())

运行上述代码,其输出效果如下:

![]()

从输出结果可以看出,程序首先执行第15行的 await fetch_data(1),并等待该请求完成(约1秒)后,才继续执行后面的同步代码(第16-18行)。随后,程序再执行第19行的 await fetch_data(2),并等待其完成(约2秒),以此类推。三次请求的执行是严格串行的,总耗时约为 1 + 2 + 3 = 6 秒。

这与我们的期望有所出入。程序并没有在I/O等待期间执行其他同步代码,而是等上一个请求完全结束后才开始下一个。问题出在哪里?

关键在于,Python异步代码中的任务切换并非由开发者直接管理。await 语句的作用是告诉 asyncio 事件循环,其后的协程(可等待对象)可以在执行过程中暂停,将控制权交还给事件循环。至于事件循环是否以及何时切换到其他任务,则由其内部调度机制决定。

当Python检测到当前是一个I/O操作(例如网络请求),并且这个操作需要等待时,它才会利用这段等待时间切换到其他已就绪的任务。开发者能做的,是将所有可异步执行的操作打包成"任务",然后统一提交给 asyncioasyncio 的事件循环会负责协调和调度这些任务,充分利用等待时间。

因此,我们需要对上述代码进行修改,以实现真正的并发:

import aiohttp
import asyncio
import time

async def fetch_data(delay_sec: int):
    """异步函数:模拟网络请求,并打印结果。"""
    async with aiohttp.ClientSession() as session:
        target_url = f'http://127.0.0.1:8000/delay/{delay_sec}'
        async with session.get(target_url) as response_obj:
            data_payload = await response_obj.json()
            print(f"收到响应: {data_payload['time']}秒")
            return data_payload['time']

async def run_concurrent_requests():
    """主函数:演示异步请求的并发执行。"""
    start_time = time.perf_counter()

    # 创建一组异步任务
    concurrency_tasks = [
        asyncio.create_task(fetch_data(1)),
        asyncio.create_task(fetch_data(2)),
        asyncio.create_task(fetch_data(3)),
    ]

    # 等待所有任务并发完成
    results = await asyncio.gather(*concurrency_tasks)
    print(f"所有任务结果: {results}")

    end_time = time.perf_counter()
    print(f'总计耗时:{end_time - start_time:.2f}秒')

if __name__ == '__main__':
    asyncio.run(run_concurrent_requests())

运行这段修正后的代码,其输出效果如下:

![]()

可以看到,现在总耗时约为3秒。这表明3个请求确实在后台并发执行,利用了彼此的I/O等待时间。我们通过 asyncio.create_task() 将协程包装成可由事件循环调度的任务,并将这些任务收集起来,通过 asyncio.gather() 一次性提交给事件循环。这样,Python就能够自动调度这批任务,最大限度地利用I/O等待时间。

这种机制在其他框架中也有体现,例如在编写 Scrapy 爬虫时,我们可能会看到如下代码:

# ...
yield scrapy.Request(url_a, callback=self.parse_page)
next_url = url_a + '&page=2'
yield scrapy.Request(next_url, callback=self.parse_page)
# ...

这里的 yield scrapy.Request 并非立即发起HTTP请求,而仅仅是将一个请求对象放入Scrapy的请求队列。程序会继续执行下一行代码,例如计算 next_url。当队列中累积到一定数量的请求,或经过一定时间后,Scrapy的下载器才会统一调度并真正发送这些HTTP请求。请求返回后,Scrapy会将其封装成 Response 对象,并传递给相应的回调函数处理。

总结来说,在Python异步编程中,你需要将一组可并发执行的异步操作封装成任务,然后统一提交给 asyncio 事件循环进行调度。你不能直接在某个异步操作的等待间隙,手动插入任意同步代码来执行。

异步环境中调用同步函数的考量

在异步函数内部调用同步函数是允许的。例如,print 函数就是一个同步函数,但由于其执行耗时极短,通常不会对异步任务造成影响。

然而,如果调用的同步函数是耗时很长的计算密集型任务,它将会"阻塞"整个事件循环,导致其他异步任务无法及时被调度。我们来创建一个基于递归的斐波那契数列计算函数,它具有典型的计算密集型特点:

def compute_fibonacci_sync(n: int) -> int:
    """同步函数:递归计算斐波那契数列的第n项 (耗时操作)。"""
    if n <= 2:
        return 1
    return compute_fibonacci_sync(n - 1) + compute_fibonacci_sync(n - 2)

async def calculate_fib_async(n: int):
    """异步函数:调用同步斐波那契计算,并打印结果。"""
    result = compute_fibonacci_sync(n)
    print(f'斐波那契数列第 {n} 项计算完成,结果是:{result}')
    return result

由于递归方式计算斐波那契数列效率极低,计算第36项大约需要5秒钟:

![]()

现在,我们将这个耗时的斐波那契计算(CPU密集型)与之前的网络请求(I/O密集型)任务一起放入异步事件循环中运行,观察其影响:

import aiohttp
import asyncio
import time

def compute_fibonacci_sync(n: int) -> int:
    """同步函数:递归计算斐波那契数列的第n项 (耗时操作)。"""
    if n <= 2:
        return 1
    return compute_fibonacci_sync(n - 1) + compute_fibonacci_sync(n - 2)

async def fetch_data(delay_sec: int):
    """异步函数:模拟网络请求,并打印结果。"""
    async with aiohttp.ClientSession() as session:
        target_url = f'http://127.0.0.1:8000/delay/{delay_sec}'
        async with session.get(target_url) as response_obj:
            data_payload = await response_obj.json()
            print(f"收到网络响应: {data_payload['time']}秒")
            return data_payload['time']

async def calculate_fib_async(n: int):
    """异步函数:调用同步斐波那契计算,并打印结果。"""
    result = compute_fibonacci_sync(n)
    print(f'斐波那契数列第 {n} 项计算完成,结果是:{result}')
    return result

async def run_mixed_tasks():
    """主函数:演示混合任务的执行。"""
    start_time = time.perf_counter()

    mixed_tasks = [
        asyncio.create_task(fetch_data(1)),
        asyncio.create_task(fetch_data(2)),
        asyncio.create_task(calculate_fib_async(36)), # 耗时约5秒
        asyncio.create_task(fetch_data(3)),
    ]

    await asyncio.gather(*mixed_tasks)

    end_time = time.perf_counter()
    print(f'总计耗时:{end_time - start_time:.2f}秒')

if __name__ == '__main__':
    asyncio.run(run_mixed_tasks())

运行效果如下:

![]()

从输出中可以看到,总耗时大约为8秒左右。其中,计算斐波那契数列第36项本身耗时约5秒,而3次网络请求的最大等待时间是3秒。由于同步的斐波那契计算会完全占用CPU,阻塞事件循环,导致其他网络请求也必须等待其完成后才能继续处理I/O事件。因此,整体耗时叠加了最长的CPU密集型任务时间和最长的I/O密集型任务时间(如果它们是严格并行的,总时间应该取两者最大值,但这里由于阻塞,所以是加和)。

这个例子明确说明了,当一个异步函数内部调用了耗时极长的同步函数时,它会阻塞整个事件循环,阻止其他异步任务的正常调度。这也是为什么在异步编程中,通常不建议直接使用 time.sleep() 等耗时长的同步操作的原因,而应使用 asyncio.sleep()

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

linux screen 用法详情 (nohup 的替代方案)

一、screen 是什么?能干嘛?screen 是一个终端复用器,可以:在一个 SSH 会话中开多个“虚拟终端”SSH 断线后,程序仍然在后台运行随时重新连接到原来的会话特别适合:nohup 的替代方案跑脚本 / 爬虫 / 训练模型运维、远程开发二、安装 screen# CentOS / Rocky / Almayum install -y screen# Debian / Ubuntuapt i...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。