轻松理解 Python 协程: async/await

617 阅读6分钟

在 Python 中支持多种异步方式,比如:协程,多线程和多进程,除此之外还有一些比较传统的方法和第三方的异步库。本文主要是介绍协程,捎带会介绍一下多线程和多进程。

async/await

在 Python 中使用 async 声明的函数就是一个异步函数,这个异步函数也尝尝被称之为协程。如下:

import asyncio


async def hello():
    await asyncio.sleep(1)
    print("hello")

调用方式

异步函数的调用和普通函数的调用有一点不一样,比如普通函数的调用如下:

def hello():
    print("hello")

hello()

而异步函数的调用如下:

import asyncio


async def hello():
    await asyncio.sleep(1)
    print("hello")


h = hello()
asyncio.run(h)

在调用异步函数时,首先使用 h=hello() ,返回的是一个协程对象,而函数中的代码是不会执行的。之后再使用 asycnio.run(h)函数或者 await h的代码才会执行,如下所示:

import asyncio

async def async_function():
    print("This is inside the async function")
    await asyncio.sleep(1)
    return "Async function result"

# 正确的使用方式
async def correct_usage():
    print("Correct usage:")
    result = await async_function()
    print(f"Result: {result}")

# 不使用 await 的调用
def incorrect_usage():
    print("\nIncorrect usage:")
    coroutine = async_function()
    print(f"Returned object: {coroutine}")
    # 注意:这里不会打印 "This is inside the async function"

# 处理未等待的协程
async def handle_unawaited_coroutine():
    print("\nHandling unawaited coroutine:")
    coroutine = async_function()
    try:
        # 使用 asyncio.run() 来运行协程
        result = await coroutine
        print(f"Result after handling: {result}")
    except RuntimeWarning as e:
        print(f"Caught warning: {e}")

async def main():
    await correct_usage()
    incorrect_usage()
    await handle_unawaited_coroutine()

asyncio.run(main())

下面介绍几种常见的调用异步函数的方法。

asyncio.gather()

使用 gather就是同时启动多个任务,并且并发执行,执行完成返回结果之后,后面的代码才会继续执行。如下:

import asyncio


async def num01():
    await asyncio.sleep(1)
    return 1


async def num02():
    await asyncio.sleep(1)
    return 2


async def combine():
    results = await asyncio.gather(num01(), num02())
    print(results)


asyncio.run(combine())

## output:[1,2]

上面有两个异步函数,现在使用 asyncio.gather同时并发执行这两个函数,然后使用 await 等待结果返回,返回结果就放在了 results当中;

直接使用 await

上面的 gather是收集多个异步函数,同时并发执行。除了这种方法以外,也可以直接使用 await关键字,具体如下:

import asyncio


async def hello():
    await asyncio.sleep(1)
    return "hello"


async def exmaple():
    result = await hello()
    print(result)


asyncio.run(exmaple())

# output: hello

在上面的代码 exmaple中,使用 await等待异步函数返回结果,返回之后再输出到控制台。这种方式其实就是顺序执行,因为代码执行到 await时就会等待结果,等返回结果之后才会继续向下执行。

如果此处不等会怎么样呢?如果此处使用 result = hello(),那么 hello() 中的代码不会被执行,返回的 result是一个协程对象

asyncio.create_task()

除了上面的方法之外,还有一种更加灵活的方式,就是使用 asyncio.create_task() 。这种方式会创建任务,并且立即在后台执行,此时主函数可以做一些其他的操作。如果需要获取异步任务的结果时,再使用 await获得得到,具体如下:

import asyncio


async def number():
    await asyncio.sleep(1)
    return 1


async def float():
    await asyncio.sleep(1)
    return 1.0


async def exmaple():
    n = asyncio.create_task(number())
    f = asyncio.create_task(float())
    print("do something...")

    print(await n)
    print(await f)


asyncio.run(exmaple())

# output:
do something...
1
1.0

从上面的输出可以看出, create_task会先去创建任务并开始执行,此时主函数并不会被阻塞,而是会继续执行下面的代码。等到需要使用异步函数的结果的时候,调用 await n就可以获取到结果。这样就可以将一些耗时任务先放到异步代码中执行,等到需要这些异步函数的结果的使用,再去获取

注:上面的使用 create_task调用异步函数和直接像普通函数的方式调用异步函数不一样,使用普通函数的调用方式 number(),此时函数不会执行。而使用 create_task调用异步函数,该函数会立即执行,即便不使用 await获取结果,函数也会在主函数没有退出的情况下执行完成Semaphore

Semaphore

asyncio.Semaphore 是 Python 的 asyncio 库中的一个同步原语,控制对于共享资源的访问。在异步编程中非常有用,可以限制同时访问某个资源的协程数量。如下代码所示:

import asyncio
import aiohttp


async def fetch(url, session, semaphore):
    async with semaphore:
        print(f"Fetching {url}")
        async with session.get(url) as response:
            return await response.text()


async def main():
    urls = [
        "http://example.com",
        "http://example.org",
        "http://example.net",
        "http://example.edu",
        "http://example.io",
    ]

    semaphore = asyncio.Semaphore(2)  # 限制并发请求数为2
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(url, session, semaphore) for url in urls]
        responses = await asyncio.gather(*tasks)

    for url, response in zip(urls, responses):
        print(f"URL: {url}, Response length: {len(response)}")


asyncio.run(main())

上面的代码中创建一个 asyncio.Semaphore(2),限制同时并发数为2个。在异步函数 fetch中,使用 async with semaphore 获取和释放信号量。在进入之前会自动调用 acquire()方法获取,在退出 with方法时,会调用 release()方法释放信号量。使用 Semaphore能够控制并发数,防止对服务器造成压力。在处理有限的资源,如:数据库连接时非常有用。同时能够优化系统性能,找到并发的平衡点。

Semaphore 原理

它在内部维护一个计数器,当计数器大于零时允许访问,当等于零时,禁止访问。获取和释放计数器的方法是分别调用 acquire()release()。在初始化的时候要指定一个初始的计数器数量。之后在代码中通过控制计数器的数量控制并发数。

多线程

多线程是一种传统的任务并发方式,适用于 I/O绑定的任务,如下面的这个例子:

import threading
import time

def worker(name):
    print(f"Worker {name} starting")
    time.sleep(2)  # 模拟耗时操作
    print(f"Worker {name} finished")

def main():
    threads = []
    for i in range(3):
        t = threading.Thread(target=worker, args=(i,))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

    print("All workers finished")

if __name__ == "__main__":
    main()

多线程的 t.join()是等待三个线程的完成。当在一个线程或进程对象上调用 join() 方法时,调用线程(通常是主线程)将被阻塞,直到被调用 join() 的线程或进程执行完毕。

多进程

多进程适用于 CPU 密集的任务,可以充分的理由多核处理器,如下:

import multiprocessing
import time

def worker(name):
    print(f"Worker {name} starting")
    time.sleep(2)  # 模拟耗时操作
    print(f"Worker {name} finished")

if __name__ == "__main__":
    processes = []
    for i in range(3):
        p = multiprocessing.Process(target=worker, args=(i,))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()

    print("All workers finished")

结语

除了上面的异步方式,Python 中还有一些其他的异步方法,比如使用回调函数,或者是第三方的库 Gevent等。每种方法都有自己的优势和局限,比如线程适合 I/O 绑定的任务,但有 GIL(全局解释器锁)的限制;多进程适合 CPU 密集型任务,但有更大的内存开销;第三方库提供专门的功能和优化,但可能增加项目的复杂性。相比之下,async/await 语法提供了一种更现代、更易读的异步编程方式,是目前 Python 中处理异步操作的推荐方法。