异步编程基础
异步编程基础

异步编程基础

异步编程基础

我们知道FastAPI框架的最大特性就是异步支持,在深入FastAPI框架的应用之前,需要先简单了解一些关于异步编程方面的知识。
前面提到ASGI是一种接口协议,它是为了规范支持异步的Python Web服务器、框架和应用之间的通信而定制的,同时囊括了同步和异步应用的通信规范,并且向后兼容WSGI协议。由于最新的HTTP支持异步长连接,而传统的WSGI应用支持单次同步调用,即仅在接收一个请求后返回响应,从而无法支持HTTP长轮询或WebSocket连接。在Python 3.5增加async/await特性之后,基于asyncio异步协程的应用编程变得更加方便。ASGI协议规范就是用于asyncio框架中底层服务器/应用程序的接口。

并发编程机制

通常,计算机的任务主要分为两种,一种是计算型密集任务,另一种则是IO密集型任务(如输入/输出阻塞、磁盘IO、网络请求IO)。程序处理并发问题的常见方案是多线程和多进程,那么为什么需要使用多线程和多进程方式来实现并发呢?这就需要回到同步IO编程模式的问题上。

同步IO编程中,由于CPU处理任务计算的速度远高于内存执行任务的速度,所以会遇到IO阻塞引发的执行效率低的问题。即当业务逻辑执行的是一个IO密集型任务时,由于CPU遇到同步的IO任务,因此当前处理IO任务的线程会被挂起,其他需要CPU执行的代码则会处于等待执行的状态,此时需要等待同步的IO任务执行完成后,CPU才可以继续执行后续的任务,这就造成了CPU使用效率低的问题。

引入多线程和多进程方式在某种程度上可以实现多任务并发执行。线程相互之间独立执行,互不影响。对于IO型任务,通常通过多线程调度来实现表面上的并发;对于计算密集型任务,则使用多进程来实现并发。

其实,无论是多线程还是多进程或协程都无法实现真正的并行。

虽然引入多线程和多进程方式在某种程度上可以实现多任务并发执行,但是也相应地存在一定的缺点,特别是在Python中,主要体现为:

  • Python多进程并发缺点:

    • 进程的创建和销毁代价非常高
    • 需要开辟更多的虚拟空间
    • 多进程之间上下文的切换时间长
    • 需要考虑多进程之间的同步问题
  • Python多线程并发缺点:

    • 每一个线程都包含一个内核调用栈(Kenerl Stack)和CPU寄存器上下文表(该表列出了CPU中的寄存器以及它们的名称、大小、功能和对应的指令等信息);

    • 共享同一个进程空间会涉及同步问题

    • 线程之间上下文的切换需要消耗时间

    • 受限于GIL,在Python进程中只允许一个线程处于运行状态,多线程无法充分利用CPU多核

    • 受OS调度管制,线程是抢占式多任务并发的(需要关心同步问题)

相对同步IO而生的异步IO,要解决的问题是在处理任务时,若遇到IO阻塞,则会变为非IO阻塞,也就是说遇到IO任务时,CPU不会等待IO任务执行完成,而是直接继续后续任务的执行。从某种程度上,提高了CPU的使用率。

异步IO本身是一种和语言无关的并发编程设计范例,很多语言对它都有相关实现,它是基于一种单进程、单线程的机制来设计的。

异步IO的本质是基于事件触发机制来实现异步回调。在IO处理上主要采用了IO复用机制来实现非阻塞操作,如在众多的Python Web框架中比较流行的tornado框架,就是比较早出现的一个非阻塞式服务器,它的出现就是为了应对C10K的问题处理。tornado之所以能解决C10K的问题,主要是受益于其非阻塞的方式和对epoll的运用,它每秒甚至可以处理数以千计的连接。这种异步非阻塞是在一个单线程内完成的。在一个线程内可以高效处理更多的IO任务,这就是异步IO的魅力所在。

对于上面内容中涉及的术语,一些读者区分起来可能会有些困难。这些术语是理解协程的关键点。理解了这几个术语,有助于理解什么是异步编程,从而加深对异步编程的理解。

并发与并行

并发

通常是指在单核CPU情况下可以同时运行多个应用程序。然而本质上,操作系统(单核CPU的情况)在处理任务时任一时刻点都只有一个程序在CPU中运行。

人们之所以可以“看到”多个应用程序(多任务)同时执行,是因为操作系统给每个应用程序(任务)都分配了一定的时间片,每个程序(任务)执行完分配的时间片后,操作系统会通过调度切换到下一个任务中去执行,而这个时间片相对于人类来说短到无法被感知,所以就会感觉系统在并发处理相关任务。本质上说,多任务其实是交叉执行的,并发只是一种“假象”。比如,在单核计算机上,当运行QQ客户端后,还会运行微信客户端及其他应用程序,其实这时所有的应用程序都在一个CPU上执行,这些应用是通过时间片调度切换来获取执行权的。

并行

相对于单核CPU而言的。如果计算机是单核的CPU,那么任务的执行就不会存在并行的说法;如果计算机使用的是多核CPU,那么任务就可以分配到不同的CPU上执行,在这种情况下,在多个CPU上执行的任务互不干扰、互不影响,这是真正的多任务同时执行,也是一种真正的并行表现。比如,在双核计算机中,当运行QQ客户端和微信客户端时,有可能QQ客户端运行在CPU1上,而微信客户端运行在CPU2上,它们对CPU的占用是独享的,执行的过程互不影响。

综上所述,读者可以理解为:

  • 并行包含了并发,并发是并行的一种特殊表现。
  • 并发通常是对单核CPU任务执行过程的一种组织结构描述的说明,并行是对程序执行过程中一种状态的描述,其主要目的是充分利用多核CPU加速任务执行。
  • 由于一个系统运行的任务数量远超过CPU数量,所以在现在的操作系统中没有绝对的真正并行的任务。

同步与异步

通常所说的同步(Synchronous),其实是在强调多个任务执行的一个完整过程,其中的某个任务在执行过程中不允许被中断。多个任务的执行必须是协调一致且有序的,某个任务在执行过程中如遇到阻塞,则其他任务需要等待。

相对于同步来说,异步(Asynchronous)强调多个任务可以分开执行,彼此之间互不影响,某一个任务遇到阻塞,其他任务不需要等待,但是任务执行的结果依然是保持一致的。如果一个任务被分为多个任务单元,那么这些任务单元都是可以分开执行的,多任务单元的执行可以是无序的。

这里类比煮米饭这个任务来理解上述两个概念。这个任务可以被分为如下几个步骤来执行:洗锅→下米→放水→点火→煮熟米饭。

用同步来处理,则对于上面的每一个步骤,人们必须按顺序一步一步地完成,中途不可以做其他的事情,即便是“从点火到等待煮熟米饭”这段时间内都不允许做其他事情。这样问题就很明显了,“从点火到等待煮熟米饭”这段时间有10~20min,这段时间只能等,不能做其他事情,很浪费时间。而用异步来处理,则“从点火到等待煮熟米饭”这段时间不需要一直等待,可以去刷剧,此时刷剧和等待煮熟米饭就是异步完成的。

综上所述,读者可以这样理解:

  • 同步和异步是程序“获得关注消息”通知的机制,是与消息的通知机制有关的一种描述。
  • 同步和异步是一种线程处理方式或手段,它们的区别是遇到IO请求是否等待。
  • 同步:代码调用IO操作时,必须等待IO操作完成才返回。
  • 异步:代码调用IO操作时,不必等待IO操作完成就可返回。
  • 异步操作是可以被阻塞的,只不过它不是在处理消息时被阻塞,而是在等待消息通知时被阻塞。

阻塞与非阻塞

阻塞(Blocking)和非阻塞(Nonblocking)都是针对CPU对线程的调度来说的。当调用的函数(任务)遇到IO时会进行线程挂起的操作,此时就需要等待返回IO执行结果,在等待的过程中无法处理其他任务,称此时的任务执行操作处于阻塞状态。比如,使用requests库请求网页地址,从提交请求处理到等待响应报文返回时执行的操作就是处于阻塞状态,这个请求等待服务器响应返回的过程是一个同步请求的处理过程,因为在这个执行过程中不可以去处理其他任务。

当调用的函数遇到IO时,若不会进行线程挂起的操作,则不需要等待返回IO执行结果,此时可以去做其他任务,称此时的任务执行操作处于非阻塞状态。

综上所述,读者可以这样理解:

  • 阻塞和非阻塞描述的是程序的运行状态,表示的是程序在等待消息(无所谓同步或者异步)时的状态。
  • 阻塞和非阻塞是线程的状态,线程要么处于阻塞状态,要么处于非阻塞状态,两者并不冲突。它们的主要区别是在数据没准备好的情况下调用函数时当前线程是否立即返回。
  • 阻塞:调用函数时当前线程被挂起。
  • 非阻塞:调用函数时当前线程不会被挂起,而是立即返回。

asyncio协程概念

我们已经知道,不论是多线程还是多进程,在Python中所用的并发模式都是“假象”。操作系统针对进程和线程进行操作的过程中,需要消耗的资源比较多。而早期多数Web框架都是基于多线程模式来进行并发支持的,在这种情况下进行多用户请求并发处理时,需要为每一个请求创建新的线程并进行对应的处理,这就需要消耗更多资源。

因为系统硬件资源始终有限,所以不可能无限量创建线程或进程来处理更多的并发任务。但是人们对并发的需求却越来越大,怎么办?只能另辟蹊径,考虑在单一进程或线程中是否可以同时处理更多的请求,所以一些IO多路复用模型应运而生。

虽然IO多路复用模型可以让任务执行时不再阻塞在某个连接上,而是当任务处理有数据到达时(阻塞结束)才触发回调并响应请求,但是这种机制依赖于“回调”。这种回调机制使用起来非常复杂,且容易出现链路式回调,编码实现也不够直观,所以后来这种链路式回调机制就慢慢被新的协程机制所替代。

异步IO设计的主要目的是为了提高系统资源(特别是CPU)的利用率,避免在等待I/O操作(如磁盘读写、网络通信等)完成期间CPU处于空闲状态。在传统的同步IO模型中,当程序遇到I/O操作时,会阻塞当前线程或进程,直到I/O操作完成。这意味着在这段时间内,CPU将无法执行其他任务,从而导致资源浪费。

异步IO通过允许程序在发起I/O请求后立即返回并继续执行其他任务,解决了这一问题。当I/O操作完成后,操作系统会通过回调函数、事件通知等方式告知程序I/O操作已完成,程序可以继续处理I/O操作的结果。这样,即使在等待I/O操作的过程中,CPU也可以用来执行其他计算密集型任务,提高了系统的整体效率。

在异步编程中,通常有几种策略来处理这种情况:

  1. 回调函数:这是最原始也是最直接的方式。在发起异步IO操作时,同时提供一个回调函数,当IO操作完成时调用该函数,并将结果作为参数传递给它。后续逻辑可以在回调函数内部实现。
  2. Promises/Futures:这是一种更现代的处理异步操作结果的方法,它允许你注册多个回调,分别处理成功和失败的情况。Promise对象代表了一个最终可能完成(或失败)的异步操作,以及其完成后的值。
  3. Async/Await:这是JavaScript ES2017引入的一种异步编程语法糖,使异步代码看起来更像同步代码。使用async关键字定义异步函数,使用await关键字等待一个Promise对象的解析结果。这种方式使得异步代码更易于理解和维护。
  4. Event Loop和Event Driven架构:在Node.js等环境中,整个程序的执行模型就是基于事件驱动的。所有的异步操作都在事件循环中注册,一旦操作完成,就会触发相应的事件处理器。

在实践中,你可能需要根据具体的编程环境和需求选择最适合的异步处理方式。无论是使用回调、Promises还是async/await,关键在于确保你的程序能够正确地处理异步操作的结果,而不至于因为异步操作的非阻塞性质导致程序状态混乱。

相对于线程来说,协程不存在于操作系统中,它只是一种程序级别上的IO调度。读者可以将它理解为对现有线程进行的一次分片任务处理,线程可以在代码块之间来回切换执行,而非逐行执行,因此能够支持更快的上下文切换,减少线程的创建开销和切换开销,从而大大提高了系统性能。

asyncio是Python官方提供的用于构建协程的并发应用库,是FastAPI实现异步特性的重要组成部分。基于asyncio,可以在单线程模式下处理更多的并发任务,它是一个异步IO框架,而异步IO其实是基于事件触发机制来实现异步回调的,在IO处理上主要采用了IO复用机制来实现非阻塞操作。在开始使用asyncio之前,需要先初步了解asyncio的一些核心知识点。

asyncio的核心是Eventloop(事件循环),它以Eventloop为核心来实现协程函数结果的回调。它提供了相关协程任务的注册、取消、执行及回调等方法来实现并发。

在Eventloop中执行的任务其实就是人们定义的各种协程对象。通常,每一个协程对象内部都会包含自身需要等待处理的IO任务。当Eventloop处理协程任务时,遇到需要等待处理的IO任务,会自动执行权限切换,自动去执行下一个协程任务。当上一个IO任务完成时,会在下一次事件循环返回最终等待结果的状态。这种多任务交替轮换的协同处理机制可以有效提高CPU的使用率,进而提升并发能力。

定义一个协程函数(Coroutine)的核心是async关键词。通过该关键词,人们可以把一个普通的函数转换为一个协程函数,转变后协程函数的运行就不像普通函数那样了,它需要依赖于上面提到的Eventloop。当对协程函数执行“协程函数()”时,“协程函数()”表示一个协程对象。此时执行“await协程函数()”表示创建了对应的Task并放入循环事件(也可以通过asyncio.create_task()来创建Task),该Task就是协程函数要执行的逻辑。

asyncio协程简单应用

本节通过几个简单的案例来说明同步和异步之间的差异,通过案例,我们可以从代码层面体会同步和异步是如何提升运行效率的。

案例说明:这里循环请求50次,以百度首页为对比对象。首先使用单线程、同步代码的方式请求处理,具体示例代码如下。

import requests
import time

def take_up_time(func):
    def wrapper(*args, **kwargs):
        print("开始执行---->")
        now = time.time()
        result = func(*args, **kwargs)
        using = (time.time() - now) * 1000
        print(f"结束执行,消耗时间为:{using}ms")
        return result
    return wrapper

def request_sync(url):
    response = requests.get(url)
    return response

@take_up_time
def run():
    for i in range(0, 50):
        request_sync('https://www.baidu.com')

if __name__ == '__main__':
    run()

上述代码说明:

  • 首先导入了requests库以及time库。
  • 然后定义了一个名为take_up_time的装饰器。它主要用于计算被装饰函数的业务逻辑执行所需的时间。
  • 紧接着定义了request_sync()函数。在函数内部,通过requests向百度首页地址发起请求处理。
  • 最后定义了一个run()函数。该函数使用@take_up_time进行装饰处理,且在run()函数内部,通过for循环的方式进行遍历,通过调用request_sync()函数进行请求处理。

上述代码的执行结果为:

/root/anaconda3/bin/conda run -p /root/anaconda3 --no-capture-output python /root/workspace/fastapi_tutorial/chapter01/asyncio/contrast_sync.py 
开始执行---->
结束执行,消耗时间为:4577.843904495239ms

进程已结束,退出代码为 0

上面的案例在使用requests同步库请求50次百度首页地址的情况下,显示消耗的时间约为4577ms。接下来更换为一个异步的HTTP请求库,改写并发请求处理,具体代码如下。

import aiohttp, asyncio, time

def take_up_time(func):
    def wrapper(*args, **kwargs):
        print("开始执行---->")
        now = time.time()
        result = func(*args, **kwargs)
        using = (time.time() - now) * 1000
        print(f"结束执行,消耗时间为:{using}ms")
        return result

    return wrapper

async def request_async():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://www.baidu.com') as resp:
            pass

@take_up_time
def run():
    tasks = [asyncio.ensure_future(request_async()) for x in range(0, 49)]
    loop = asyncio.get_event_loop()
    tasks = asyncio.gather(*tasks)
    loop.run_until_complete(tasks)

if __name__ == '__main__':
    run()

上述代码说明:

  • 首先导入了aiohttp、asyncio以及time库。其中,asyncio用于异步处理,aiohttp用于异步请求处理。
  • 然后定义了一个名为take_up_time的装饰器。它主要用于计算被装饰函数的业务逻辑执行所需的时间。
  • 紧接着定义了一个request_async()协程函数。在该协程函数内部,主要通过aiohttp发起异步请求处理

最后定义了一个run()函数。该函数使用@take_up_time进行装饰处理,且在run()函数内部,通过for循环的方式批量创建协程任务,并添加到tasks列表中,之后通过调用asyncio.gather(*tasks)批量并发调用request_async()协程函数来进行请求处理。

上述代码的执行结果为:

/root/anaconda3/bin/conda run -p /root/anaconda3 --no-capture-output python /root/workspace/fastapi_tutorial/chapter01/asyncio/cotrast_async.py 
开始执行---->
结束执行,消耗时间为:123.23355674743652ms

进程已结束,退出代码为 0

上面的案例在使用aiohttp异步库请求50次百度首页地址的情况下,显示消耗的时间约为123ms。由此可见,同样是并发处理50次,同样是在单线程的情况下,使用异步协程的方式处理请求比同步处理耗时更短了。这就是异步的特性。

发表回复

您的电子邮箱地址不会被公开。