Python异步编程入门协程到底是什么与线程、进程的区别

Written by

in

文章目录
  • 你可能已经遇到过这样的场景:写了一个爬虫,但请求网页时总是卡住;或者搭了个Web服务,同时处理几个请求就变得慢吞吞。这时候别人可能会告诉你:“试试异步编程吧,用协程。” 但协程到底是什么?它跟线程、进程有什么区别?为什么大家都在说asyncio?今天咱们就来好好聊聊这个话题。
  • 目录
    • Python异步编程入门:协程到底是什么?
      • 从瓶颈说起:程序为什么卡?
      • 协程的本质:可暂停的函数
      • 协程 vs 线程 vs 进程:什么时候选哪个?
      • Python协程的演进:从yield到async/await
      • 深入理解async和await
      • 事件循环:协程的调度中心
      • 实战对比:同步 vs 异步下载
      • 常见的协程使用场景
      • 协程学习的难点和坑
      • 性能真的提升了吗?一个简单测试
      • 现在开始用协程还太早吗?
      • 下一步该学什么?

    你可能已经遇到过这样的场景:写了一个爬虫,但请求网页时总是卡住;或者搭了个Web服务,同时处理几个请求就变得慢吞吞。这时候别人可能会告诉你:“试试异步编程吧,用协程。”

    但协程到底是什么?它跟线程、进程有什么区别?为什么大家都在说asyncio?今天咱们就来好好聊聊这个话题。

    想象一下你去快餐店点餐。如果只有一个收银员,前面的人点单特别慢,你就得一直等着——这就是典型的同步阻塞。你的代码可能正在等网络响应、等文件读写、等数据库查询,而CPU就在那里空转。

    换成多线程呢?好比开了几个收银台,同时服务。但每个收银台还是要等顾客慢慢掏钱、找零,而且收银台之间还要协调(比如共用一台打印机),协调不好就会出问题。在程序里,这就是线程安全问题和上下文切换开销。

    协程的做法不太一样:一个收银员同时接待多个顾客。A顾客在掏钱包时,收银员转头问B顾客要什么;B顾客犹豫时,又去给C顾客结账。看起来收银员一直在忙,但实际没有真的“同时”做多件事,只是切换得很快。

    协程本质上是一种特殊的函数,它能在执行到一半时暂停,把控制权交出去,过一会儿又能从暂停的地方继续执行。

    async def fetch_data():
        print("开始请求数据")
        await asyncio.sleep(2)  # 在这里暂停,让其他协程运行
        print("数据返回了")
    

    这个await asyncio.sleep(2)就像告诉程序:“我先歇会儿,你去干点别的,2秒后再叫我。”这种机制让单个线程可以处理多个任务,而不是傻等。

    你可能会问,这和生成器(generator)的yield有点像吧?确实,Python的协程就是从生成器进化来的。早期的协程就是用yield实现的,但async/await语法更清晰,专门为异步编程设计。

    先看个简单的对比:

    • 多进程:开多个厨房,各自独立,但沟通成本高(进程间通信)。适合CPU密集型计算,能利用多核优势。
    • 多线程:一个厨房里多个厨师,共用设备,但要小心互相干扰(线程安全)。适合I/O操作,但线程数量多了开销大。
    • 协程:一个厨师同时照看几口锅,哪口锅需要搅拌就搅一下,不需要时就处理别的。适合高并发I/O,但单个协程不能阻塞。

    协程最大的优势就是轻量。创建一个线程需要几MB内存,而一个协程可能只要几KB。一个线程能跑成千上万个协程,切换开销极小,特别适合I/O密集的场景——比如网络请求、文件读写,这些操作大部分时间都在等,而不是真的在计算。

    但协程不是银弹。如果你的任务是纯计算型的(比如图像处理、复杂算法),协程帮不上什么忙,因为计算本身不会主动让出CPU。这时候多进程或者直接优化算法可能更有效。

    Python实现协程的方式有过几次大的变化,了解这段历史有助于理解为什么现在是这样的设计:

    # 1. 生成器时代(Python 2.5+) - 用yield模拟
    def old_style_coroutine():
        print("开始")
        data = yield "请给我数据"
        print(f"收到数据:{data}")
    # 使用方式:
    coro = old_style_coroutine()
    next(coro)  # 启动,输出"开始",返回"请给我数据"
    coro.send("hello")  # 发送数据,输出"收到数据:hello"
    # 2. 装饰器时代(Python 3.4)- 引入asyncio
    @asyncio.coroutine
    def decorator_style():
        yield from asyncio.sleep(1)
        print("完成")
    # 3. 现代写法(Python 3.5+)- 现在的标准
    async def modern_coroutine():
        await asyncio.sleep(1)
        print("完成")

    现在基本都用async/await这套语法,清晰直观。但你可能在旧代码里看到前两种写法,知道它们是一回事就行。有趣的是,async/await在C#、JavaScript等语言中也是类似的语法,学会一次,多语言受益。

    第一次见async def可能会有点懵:这函数怎么调用后不执行啊?

    async def hello():
        print("Hello, async!")
    coro = hello()  # 注意:这里不会打印任何东西!
    print(type(coro))  # <class 'coroutine'>

    这里有个重要概念:协程函数被调用时返回的是一个协程对象,而不是直接执行。要让它跑起来,需要事件循环的调度。这就像给了你一张任务卡,但需要有人(事件循环)来执行它。

    await则是协程世界里的“等待”符号。但它不是阻塞等待,而是“我这儿暂时没事,你先去忙别的”。

    async def main():
        print("开始煮面")
        print("水烧开了,下面条")
        await asyncio.sleep(3)  # 等面煮熟,但程序可以去干别的
        print("面熟了,捞起来")
        print("开始炒菜")
        await asyncio.sleep(2)  # 等菜炒熟
        print("菜好了")
        print("开饭!")

    有个常见的误解:await就是异步。其实await本身不创造异步,它只是告诉程序“这里可以切换”。真正的异步能力来自那些支持异步的操作,比如asyncio.sleep()aiohttp的网络请求等。

    另一个常见误区:以为用了async/await就自动变快。实际上,如果所有操作都是顺序的await,那和同步没什么区别。真正的并发要靠asyncio.gather()asyncio.wait()这样的结构。

    如果协程是演员,事件循环就是导演。导演决定哪个演员什么时候上场、什么时候休息。

    在Python 3.7之前,你需要自己管理事件循环:

    # Python 3.6及以前
    import asyncio
    async def task():
        print("任务执行中")
        await asyncio.sleep(1)
    # 手动管理事件循环
    loop = asyncio.get_event_loop()
    loop.run_until_complete(task())
    loop.close()

    Python 3.7引入了asyncio.run(),简化了这一切:

    # Python 3.7+
    async def main():
        await task()
    asyncio.run(main())  # 一行搞定

    asyncio.run()帮我们做了三件事:创建新的事件循环、运行协程、关闭循环。对于大多数应用,这就够了。

    事件循环的工作方式有点像餐厅的叫号系统:不断检查有没有新的“事件”(比如网络数据到达、定时器到期),然后唤醒对应的协程继续工作。它维护着一个待办事项列表,哪个能处理就处理哪个。

    理论说了这么多,来个实际例子感受一下区别。

    假设你要下载10张网络图片,用传统同步方式大概是这样:

    import requests
    import time
    def download_sync(url, filename):
        response = requests.get(url)
        with open(filename, 'wb') as f:
            f.write(response.content)
    urls = [...]  # 10个图片URL
    start = time.time()
    for i, url in enumerate(urls):
        download_sync(url, f"image_{i}.jpg")
        print(f"下载完第{i+1}张")
    print(f"总耗时: {time.time()-start:.2f}秒")
    # 如果每张图要1秒,这里大概要10秒

    这是典型的顺序执行,一张下完再下一张,大部分时间都在等网络响应。

    换成协程版本:

    import aiohttp
    import asyncio
    import time
    async def download_async(session, url, filename):
        async with session.get(url) as response:
            content = await response.read()
            with open(filename, 'wb') as f:
                f.write(content)
    async def main():
        urls = [...]  # 同样的10个URL
        async with aiohttp.ClientSession() as session:
            tasks = []
            for i, url in enumerate(urls):
                task = download_async(session, url, f"image_{i}.jpg")
                tasks.append(task)
            await asyncio.gather(*tasks)  # 并发下载!
    start = time.time()
    asyncio.run(main())
    print(f"总耗时: {time.time()-start:.2f}秒")
    # 可能只要1-2秒就全部下载完了

    区别很明显:同步版本是等一张下完再下一张;协程版本是同时发起所有请求,哪张先到就先处理哪张。对于I/O密集型任务,速度提升可能非常显著。

    不过要注意,并不是所有场景都能这样简单替换。requests库是同步的,不能直接在协程里用,需要换用异步版本的aiohttp。这也是很多初学者容易踩的坑:用了async/await,但调用的库不支持异步,结果还是同步执行。

    什么时候该考虑用协程呢?这里有几个典型场景:

    • Web服务器:比如用FastAPI、Sanic或aiohttp框架。每个请求都可能涉及数据库查询、外部API调用,用协程可以同时处理大量连接。
    • 网络爬虫:需要爬取大量网页,每个网页的下载都是I/O操作,协程能显著提高效率。
    • 实时通信:聊天应用、消息推送,需要维持大量长连接,协程的内存开销比线程小得多。
    • 批量文件处理:比如读取大量文件、图片处理(注意:图片处理本身是CPU密集型,但读取写入文件是I/O密集型)。
    • 微服务调用:一个服务需要调用多个其他微服务,然后合并结果,协程可以并行发起所有调用。

    刚开始学协程时,有几个常见的困惑点:

    第一,忘记加await

    async def get_data():
        return "数据"
    async def main():
        result = get_data()  # 错误!应该加await
        print(result)  # 打印出来是个协程对象,不是字符串

    第二,在同步函数里调用协程

    def sync_func():
        data = await get_data()  # 语法错误!await只能在async函数里用
    

    第三,阻塞事件循环

    async def bad_example():
        # 这个函数会阻塞整个事件循环!
        time.sleep(5)  # 应该用await asyncio.sleep(5)
        # CPU密集型计算也会阻塞
        sum(range(10**7))  # 应该放到线程池里执行

    第四,以为所有库都支持异步:实际上很多常用库(比如requestspymysql的同步API)都是同步的。需要用异步替代库(aiohttpaiomysql)或者把同步调用放到线程池里。

    我们来做个简单实验,看看协程在I/O密集型任务上的实际表现:

    import asyncio
    import time
    import aiohttp
    import requests
    # 测试用的URL,请求会延迟1秒返回
    TEST_URL = "http://httpbin.org/delay/1"
    # 同步版本
    def sync_test(n=10):
        start = time.time()
        for i in range(n):
            requests.get(TEST_URL)
        return time.time() - start
    # 异步版本
    async def async_test(n=10):
        start = time.time()
        async with aiohttp.ClientSession() as session:
            tasks = [session.get(TEST_URL) for _ in range(n)]
            await asyncio.gather(*tasks)
        return time.time() - start
    # 运行测试
    print("同步版,10个请求:")
    print(f"耗时:{sync_test(10):.2f}秒")
    print("n异步版,10个请求:")
    print(f"耗时:{asyncio.run(async_test(10)):.2f}秒")

    在我的测试中,同步版大约需要10秒(顺序执行,每个1秒),而异步版大约只要1秒多(并发执行)。当请求数量增加到100时,差异会更加明显。

    如果你的项目主要是CPU密集型计算(比如数据分析、图像处理),协程带来的提升可能有限。但如果是Web服务、爬虫、聊天机器人这类I/O密集的应用,协程几乎成了标配。

    学习曲线呢?确实需要一点时间适应。从同步思维切换到异步思维,就像从单线程转到多线程一样,需要重新考虑程序的组织方式。不过一旦掌握,代码的性能和可读性都会有很大改善。

    Python的异步生态已经相当成熟了。Web框架有FastAPI(性能强悍,还自动生成API文档)、Sanic;数据库有aiomysql、asyncpg;HTTP客户端有aiohttp;甚至机器学习领域也开始出现异步支持。

    今天我们从协程的基本概念讲到了实际应用,但这只是异步编程的起点。下次我们会深入更多实用话题:

    1. 协程间的通信:多个协程怎么安全地共享数据?asyncio.Queue怎么用?
    2. 同步原语:协程版本的锁(Lock)、信号量(Semaphore)、事件(Event)是什么?
    3. 错误处理:协程里的异常怎么捕获和处理?
    4. 与线程/进程结合:如何在协程里调用同步代码?怎么利用多核CPU?
    5. 实际项目结构:大型异步项目该怎么组织代码?

    这些话题我会在下一篇文章中详细讲解。如果你已经跃跃欲试,建议先从一个小项目开始,比如写个异步爬虫,或者用FastAPI搭个简单的Web服务。

    你平时写代码时,遇到过哪些适合用协程解决的场景?或者对协程的哪些部分感到困惑?欢迎在评论区聊聊你的经验或问题,我们一起探讨。

    到此这篇关于Python异步编程入门:协程到底是什么?的文章就介绍到这了,更多相关Python协程内容请搜索风君子博客以前的文章或继续浏览下面的相关文章希望大家以后多多支持风君子博客!

    您可能感兴趣的文章:

    • python中强制关闭线程与协程与进程方法
    • 一文搞懂Python中的进程,线程和协程
    • 实例详解Python的进程,线程和协程
    • 一篇文章带你了解Python的进程,线程和协程
    • python并发编程之多进程、多线程、异步和协程详解
    • python线程、进程和协程详解
    • 深入浅析python中的多进程、多线程、协程
    • 简述Python中的进程、线程、协程

    站内搜索