【Python高性能编程学习】专题1.1 基于asyncio的Python并发(一):初识asyncio

参考:

  • Matthew Fowler. Python Concurrency with asyncio. First Edition
  • Micha Gorelick, Ian Ozsvald. High Performance Python: Practical Performant Programming for Humans. Second Edition

第一部分:基础概念辨析

1.1.1 并发操作与Asyncio

常规的代码运行遵循同步模型(Synchronous Model),其执行顺序是线性的,即执行一个任务之前必须等待上一个任务完成,并且一次只能进行一个任务。如果中途有一个任务需要耗时等待,那么整个任务就会暂停等待,这样会造成大量的时间浪费。这种现象很常见,比如I/O请求。I/O是指电脑的输入设备(键盘、鼠标等)和输出设备(屏幕、音响等),以及网络。电脑在进行I/O操作时会发生延时(尤其是网络请求),在同步模型下会因为需要等待I/O响应导致整个任务暂停。

如何解决这个问题呢?一种解决方法就是并发(Concurrency)(还有一种是并行,后续讲)。并发操作使得程序能够在处理同一时间出现的多个任务。并发操作的一个重要模型就是异步模型(Asynchronous Model),其基本原理是,将一个耗时长的且与主程序相互独立的程序同时地、单独地运行,从而节约时间。这里有两个重点:

  1. 同时。即这个耗时长的程序与主程序是同时进行的。
  2. 独立。即这个主程序的运行不会依赖于耗时长的程序的结果,否则还是会等待。

asyncio就是基于异步模型开发的Python并发库,通过装饰器和生成器来定义协程(coroutine)对象。协程是一种可以暂停和继续任务的特殊方法,通常要结合关键字async和await来使用。

1.1.2 I/O密集型任务与CPU密集型任务

前面我们学习的高性能方法更多的是处理CPU密集型任务,它们强调CPU计算的能力,如果CPU算力更强大,那么对于CPU密集型任务的优化会更好。对于I/O密集型任务来说,其主要的耗时在于等待网络或I/O设备的响应,大量请求或单次请求非常耗时的情况就属于I/O密集型任务了。举个例子:

import requests
response = requests.get('https://www.example.com')  # I/O密集型

items = response.headers.items()
headers = [f'{key}: {header}' for key, header in items]  # CPU密集型

formatted_headers = '\n'.join(headers)  # CPU密集型

with open('headers.txt', 'w') as file:
    file.write(formatted_headers)       # I/O密集型

所有的网络请求、读入写入都属于I/O密集型工作,所有的计算都属于CPU密集型工作。

1.1.3 并发、并行与多任务

并发与并行常常是两个容易混淆的概念。

  • 并发(Concurrency)是指同一时间发生多个任务,就好比一个蛋糕师要烤制两个不同的蛋糕,他可以先把烤箱打开预热,同时再去混合两个蛋糕的原料,因为烤箱预热的过程与调配蛋糕原料这两个过程是独立的。
  • 并行(Parallelism)除了在同一时间发生多个任务,还能够在这个时间里同时执行,就好比有两个蛋糕师来烤制两个不同的蛋糕。对于每一个蛋糕师来说,他可以采用先预热烤箱再混合原料的并发模式,而对于整体来看,这两个蛋糕的制作是同时进行的。

迁移到代码运行当中来,并发就是多个任务交替运行,在其中一个任务等待时先执行另外一个任务,涉及到任务之间的切换,靠着单核CPU就能完成;并发就是使用多个CPU来完成多个任务,可以同时进行。

那么多任务又是什么呢?多任务(Multitasking),顾名思义,就是要同时处理多个任务。多任务有两种不同的模型:

  • 抢占式多任务(Preemptive Multitasking),即由时间切片(time slicing)来进行任务之间的转换,由操作系统本身来决定。
  • 合作式多任务(Cooperative Multitasking),即显式地声明任务的进行顺序,由代码开发者自己决定。

asyncio使用的就是合作式多任务模型,也就是需要我们显式地标注任务的执行顺序,这也我们就可以实现并发。合作式多任务模型的优点有:

  • 减少资源使用,尤其是context switch,这个概念在之前的课程中有提及到。
  • 精细的颗粒度,因为可以自由地选择任务切换点,而不依赖于操作系统的默认,个性化和精准化程度更高。

1.1.4 进程、多进程、线程、多线程

进程(Process),是一个应用运行的空间,这个空间包含独立的内存空间且不能被其他应用访问。你打开浏览器、打开聊天软件等都分别对应一个进程。

多进程(Multiple Processes),即多个进程,多核CPU的机器可以同时运行多个进程(现在的电脑一般都是多核的),单核CPU的机器可以通过时间切片来实现多进程。

线程(Thread),是更轻量级的进程,是操作系统可以控制的最小结构体,它们没有自己的内存空间并且依附于某一个进程,共享内存空间。一般地,一个进程会创造一个线程,被称为主线程(main thread)。

多线程(Multiple Threads),即在一个进程创造了一个线程的基础上,再创造多个线程,它们被称为工作线程(worker thread)或背景线程(background thread),这些线程依附于同一个进程,使用同一个内存空间。

多线程是实现并发的主流方式,但是对于Python来说,由于GIL的存在(后续解释),多线程的Python实现受到一定的限制;

多进程也是实现并发的一种方式,它可以通过创建父进程与子进程来实现,在Python中通过multiprocessing库来实现。对于CPU密集型任务,使用多进程的效果会更好。

 1.1.5 全局解释锁

全局解释锁(Global Interpreter Lock, GIL),是Python的一个重要特性,它可以限制Python进程中多线程的发生,在一个进程中只允许一个线程运行。

不过,每一个Python进程有自己的GIL,所以多进程在Python当中是可行的。

为什么Python有GIL呢?Python的内置解释器是CPython,其使用的内存管理方法是引用计数(Reference Counting),它可以记录每一个Python对象被引用的次数,如果被引用就会次数+1,不引用则次数-1,当引用次数为0时,这个Python对象就会从内存空间删除。

这种机制有什么问题呢?这种机制不是线程安全(Thread Safe)的,因为当多个线程引用同一个变量时就会发生竞争条件(Race Condition),该变量的执行结果由于依赖操作执行时间和条件顺序而不可预测。我们来看一个例子:

由于不同线程在同一时间对同一变量的操作先后不可预测,就会导致这样一个现象:某一个线程使得reference count为0,应当删除内存空间的同时,另一个线程却会再引用这个变量,从而造成歧义。 

当然,GIL不是始终存在的,比如I/O操作。I/O操作是一种低等级系统调用,独立于Python runtime的,也就是不直接和Python对象交互,所以GIL是释放的,这也是为什么我们可以对I/O操作实现并发。另外,CPU密集型工作只在特定情况下才能释放GIL,后续我们再提及。

1.1.6 单线程并发原理与事件循环

前面我们提到,由于I/O操作并不直接与Python对象交互,所以不存在GIL的限制。不过,我们仍然可以在单线程中实现并发。在此之前,先介绍socket的概念。

Socket,学名“套接字”,本意“插座”,是一种底层的抽象概念,是数据在客户端和服务器之间传输的基础工具。如果你把服务器当作排插,客户端是手机,线程就是充电线,那么socket就是充电头。socket的基本工作原理如下图所示,更贴切的理解就是一个邮箱:

当我们向网站发起请求时,我们就会打开一个与网址连接的socket,并把我们的请求放置交给socket,等待socket返回我们想要的信息。

Socket有个重要的特性:blocking,即“阻塞的”,当我们在等待服务器返回数据时,整个应用会被暂停,这就类似于我们在运行代码时等待某一段耗时长的代码运行。不过,在操作系统层面,socket可以切换为非阻塞模式,即继续执行其他应用直到操作系统告诉我们socket已经准备好数据了,再访问socket。

从底层来看,这种非阻塞模式是通过通知系统(notification systems)来实现的,这个与操作系统的类型有关,比如MacOS使用的是kqueue,Linux使用的是epoll,Windows使用的是IOCP。Asyncio库会根据操作系统的不同选择不同的通知系统,而通知系统就是asyncio实现单线程并发的基础。其基本原理如下图所示:

当我们使用Python发送请求时,通知系统会添加一个对应的socket,并让操作系统监视追踪。当socket完成后操作系统会提示我们并运行对响应结果的处理代码。

那么如何追踪哪些是需要等待的I/O,哪些是普通代码呢?这就需要事件循环了。

事件循环(event loop)是每一个asyncio应用的核心,是非常常见的一种设计模式,对于Windows的图形界面应用,它们都在使用事件循环(或者“消息循环”)。最简单的一种事件循环就是队列(如果你学习过数据结构就应该不陌生),将事件放置在队列当中,通过不断的弹出事件和加入事件来完成循环。事件循环的工作原理如下图所示:

当我们创建一个事件循环,其实就是创造了一个空的任务队列。主线程会将任务提交到事件循环当中,当遇到阻塞I/O类任务就会暂停,并注册一个socket来监视。直到阻塞任务完成,我们就唤醒并继续执行任务。下面再看一个简单的例子:

def make_request():
    cpu_bound_setup()
    io_bound_web_request()
    cpu_bound_postprocess()
task_one = make_request()
task_two = make_request()
task_three = make_request()

在make_request函数中,只有中间的io_bound_web_request是阻塞的,如果我们使用基于事件循环的asyncio来实现,可能的效果就是这样的:

最理想的情况下,事件循环使得任意时刻都只有一个功能在被执行,其余任务在等待或已经结束,大大提高了整个程序的运行效率。

第二部分:asyncio的基础语法

1.2.1 协程的创建与使用

协程(coroutine)是asyncio库中的核心对象,你可以把它理解为一个普通的Python函数,这个函数可以暂停执行和恢复执行,当其被暂停时可以运行其他的函数,这样就能实现并发。

如果你要创建一个协程,可以使用async def关键字,本质上就是把不同的函数定义为协程函数,举例:

async def coroutine_add_one(number: int) -> int:
    return number + 1

coroutine_result = coroutine_add_one(1)
print(f'Coroutine result is {coroutine_result} and the type is {type(coroutine_result)}')

可以看到,如果是普通函数,返回的结果应该是一个int类型,结果为2;但对于协程来说,不仅其返回的结果的类型是协程类型,返回的结果也是一个协程对象。这是非常重要的一个点,即协程不会直接运行,需要我们显式地在事件循环中调用。

在Python 3.7以后,asyncio库增加了一个方便的函数asyncio.run,可以直接调用协程返回结果,不需要我们手动地添加事件循环:

import asyncio

async def coroutine_add_one(number: int) -> int:
    return number + 1

result = asyncio.run(coroutine_add_one(1))
print(result)

在这里,asyncio.run做了很多工作:创建新的事件循环、加入协程、运行协程、返回结果并清理、关闭事件循环。asyncio.run确实很方便,但是也有缺点——即一次只能运行一个协程。同时这也就意味着它是主要的entry point,类似于代码中的主函数,通过这一个entry point就可以执行其他协程。

除了创建和运行,协程的一个重要功能就是暂停执行。我们可以使用await关键字来暂停一个协程调用。举例:

import asyncio

async def add_one(number: int) -> int:
    return number + 1

async def main() -> None:
    one_plus_one = await add_one(1)
    two_plus_one = await add_one(2)
    print(one_plus_one)
    print(two_plus_one)

asyncio.run(main())

可以看到,在协程调用前使用await关键字,可以使得主程序暂停,等待协程执行完毕后再继续。

当然,在这个例子当中,没有长时间的等待,我们使用asyncio.sleep来造成一段时间的等待:

import asyncio

async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

async def add_one(number: int) -> int:
    return number + 1

async def hello_world_message() -> str:
    await delay(1)
    return "Hello World!"

async def main() -> None:
    message = await hello_world_message()
    one_plus_one = await add_one(1)
    print(one_plus_one)
    print(message)

asyncio.run(main())

我们定义了一个delay函数来造成任意时长的等待,并把它用于hello_world_message函数中。由于调用该函数时会等待1秒,所以整段代码的运行顺序如下:

这就体现了await的功效。

1.2.2 任务的创建与使用

前面我们学习了async def, await, asyncio.run等方法,可以直接创建、调用一个协程并且不需要使用事件循环。然而,我们并没有真正地实现并发。如果要实现并发,我们需要学习“任务”的概念。

任务(Task)是对协程的进一步封装,它可以让协程尽快加入事件循环并运行。任务的执行是非阻塞的,也就是说在任务运行的同时,我们可以执行其他代码。

如果要创建一个任务,就需要使用asyncio中的create_task函数,并且结合await提交任务至事件循环,就可以实现并发。举一个简单例子:

import asyncio

async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

async def main():
    sleep_for_three = asyncio.create_task(delay(3))
    sleep_again = asyncio.create_task(delay(3))
    sleep_once_more = asyncio.create_task(delay(3))

    await sleep_for_three
    await sleep_again
    await sleep_once_more

asyncio.run(main())

我们创建了3个任务,分别都是延迟3秒。通过await的提交,这三个任务几乎同时启动,并发执行。执行顺序如下图所示,整个过程总用时大约就是3秒,成功实现并发。

这种并发的好处是明显的,即如果我们发起10个任务,总用时也大概就是3秒而非30秒,相当于有10倍的提升。

当然,上述的这种并发存在也存在一个缺点:这些任务会持续不停运行,直到返回结果。但是,有时候我们会因为提交的某个任务有错误,想取消任务;或是因为网络连接失败等原因,超过某个时间就不想等待了,又该如何处理呢?我们分别来看。

如果你要取消一个任务,这是比较直接的:一个被取消的任务如果被await,会抛出异常CancelledError。我们可以根据这个特性来判断合适取消任务。任务的取消可以直接调用cancel()方法。看一个例子,假设我们不想让某个任务超过5秒:

import asyncio
from asyncio import CancelledError

async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

async def main() -> None:
    long_task = asyncio.create_task(delay(10))
    seconds_elapsed = 0
    while not long_task.done():
        print('Task not finished, checking again in a second.')
        await asyncio.sleep(1)
        seconds_elapsed += 1
        if seconds_elapsed == 5:
            long_task.cancel()
    try:
        await long_task
    except CancelledError:
        print('Our task was cancelled!')

asyncio.run(main())

我们设计了一个延迟10秒的任务,并且每一秒都去check完成情况,当运行时间到达5秒后,就使用cancel()方法取消。值得注意的是,虽然我们取消了任务,这个任务依然是运行的,直到遇见下一个await(如果有的话)或者我们显式地抛出了CancelledError才真正的停止(如上述代码),只使用cancel()方法是不够的

当然,上面这种方法比较繁琐。asyncio提供了更简单的wait_for()函数,它可以直接设置timeout参数,当任务运行时间超过timeout时,就会抛出TimeoutException异常,并且任务自动停止。我们看一个例子:

import asyncio
from asyncio.exceptions import TimeoutError

async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} seconds(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} seconds(s)')
    return delay_seconds

async def main() -> None:
    delay_task = asyncio.create_task(delay(2))
    try:
        result = await asyncio.wait_for(delay_task, timeout=1)
        print(result)
    except TimeoutError:
        print('Got a timeout!')
        print(f'Was the task cancelled? {delay_task.cancelled()}')

asyncio.run(main())

可以看到,对于一个耗时2秒的任务,我们设置timeout为1秒,超时就会停止任务。

另外在有些时候,我们希望系统告知我们超时,但是继续完成任务,这个时候我们就要对任务使用shield()函数,相当于一层保护罩,在任务抛出TimeoutError时仍然可以继续完成任务。举例:

import asyncio
from asyncio.exceptions import TimeoutError

async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

async def main() -> None:
    task = asyncio.create_task(delay(10))
    try:
        result = await asyncio.wait_for(asyncio.shield(task), timeout=5)
        print(result)
    except TimeoutError:
        print('Task took longer than 5 seconds, it will finish soon!')
        result = await task
        print(result)

asyncio.run(main())

可以看到,在5秒时代码抛出异常,但是任务并没有停止,而是通过await继续运行,最终完成任务。

1.2.3 协程、任务的性能与缺陷

前面介绍了关于协程与任务的基础语法,那么接下来的问题就是如何测试协程或任务的性能。一个最直接的办法就是计算时间,这个在我们之前的笔记中也涉及过。这里我们设计一个测量并发执行时间的装饰器,并保存为async_timer.py:

import functools
import time
from typing import Callable, Any

def async_timed():
    def wrapper(func: Callable) -> Callable:
        @functools.wraps(func)
        async def wrapped(*args, **kwargs) -> Any:
            print(f'starting {func} with args {args} {kwargs}')
            start = time.time()
            try:
                return await func(*args, **kwargs)
            finally:
                end = time.time()
                total = end - start
                print(f'finished {func} in {total:.4f} second(s)')
        return wrapped
    return wrapper

接下来我们来使用一下,计算并发执行两个延迟任务的总耗时:

import asyncio
from async_timer import async_timed

@async_timed()
async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds

@async_timed()
async def main():
    task_one = asyncio.create_task(delay(2))
    task_two = asyncio.create_task(delay(3))
    await task_one
    await task_two

asyncio.run(main())

我们分别对延迟函数delay和主函数添加了装饰器,分别计算了耗时。可以看到,两个任务分别执行了2秒和3秒(看finished function delay),但是总用时却也只有3秒(看finished function main),这就说明了并发的确发生了,整个任务的时长只取决于耗时最长的单任务。

这个测量并发时间的装饰器比较常用,可以自行保存使用。

除了看到并发运行的优点,但也不要忘记了,不是用了async def和创建任务就一定会提升代码性能,这个要根据实际问题来确定。下面这两种情况要慎用async和任务:

① CPU密集型代码。当我们想要做大循环,或者数学计算,并且想要多个函数并发执行时,是没办法直接使用async和任务的。要记得,asyncio库的特点是单线程并发(见1.1.6),我们仍然是受限于GIL的。不过,如果我们仍然想用async,可以等待后续进程池(process pool)的介绍。

② 阻塞API。前面我们提到的socket请求是存在非阻塞模式的,但是对于其他的I/O比如连接数据库等,其API占用主线程,使得我们没办法直接使用async。像网络请求中的requests库、时间管理的time.sleep函数等都是阻塞的(这也是为什么我们使用async.sleep而非time.sleep)。一般来说,任何非协程开发的I/O操作函数都是阻塞的。不过,解决方案也有很多,比如针对阻塞的requests库,我们可以改用aiohttp,这个是使用非阻塞socket完成的;更广义的结合async关键字的做法就是使用线程池(thread pool),后续介绍。

拓展知识:协程、任务、future与awaitables的关系

协程与任务究竟是什么关系?为什么都能使用await关键字?它们的相似之处是什么?

为了解决这些问题,我们首先要了解两个概念:future和awaitable。

Future是一个Python对象,它可以存储一个“未来”的值——也就是到未来某一刻存储某个值,但是此刻并没有存储。当你创建一个future对象时,它是没有值的,只有你去设置它才会有值。就好比你去食堂占了个位置,但是还没有买饭。

Future对象的创建使用类的实例化,设置值使用set_result()方法。举例:

from asyncio import Future
my_future = Future()
print(f'Is my_future done? {my_future.done()}')
my_future.set_result(42)
print(f'Is my_future done? {my_future.done()}')
print(f'What is the result of my_future? {my_future.result()}')

Future也可以使用await关键字,await future意味着暂停程序直到future存入了值才继续。

Awaitable是一个Python抽象基类,这个基类定义了一个方法__await__,包含了这个方法的Python对象就被称为awaitable对象,可以使用await关键字。当然,我们不会去深究__await__是怎么写的。

有了这两个概念,就可以画一张图展示它们之间的关系了:

任务(Task)是对future的直接继承,但是是任务与协程的结合体。当我们创建任务时,等价于创建一个空future并运行协程。协程运行完毕后将结果存储在future对象中。Future和协程都继承于基类Awaitable,所以两个都是可以使用await关键字的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值