async python两个_Python `asyncio` 引发的迷思

b4109ef3e771f3aca3508b345ac32571.png

最近用了一下asyncio库,没想到却引发了一系列的迷思,发现对一些概念并不是很清晰,比如concurrency, parallelism, asynchronous, coroutines(并发、并行、异步、协程)。为了在Python里面应用这些概念,我还需要明确一些python里面一些关键字和概念,比如yield, coroutine, async, await, generator,特别是这个asyncio库到底跟这些概念有什么关系?

这篇文章就是为了搞清楚这些概念的关系。文章分三个部分:

  1. 背景
  2. asyncio的由来
  3. 如何进行异步编程
  4. 总结

1. 一点背景

基本上我们要讨论的问题是关于如何在同一时间(至少看起来是同一时间)做大于一件事情。在弄清楚这些概念之前,我们先来看看操作系统是如何工作的,因为操作系统就是一个允许同一时间(至少看起来是)做多个事情的应用程序。如果你把时间放慢10,000倍,你会发现你的操作系统并没有真的同时做很多事情,它只是很快的在不同的任务中间切换,快到你感觉不到(就像看电影,虽然每秒30帧,但是你的眼睛感觉他是连续的)。

这里跑个题,CPU的工作原理(想象成一大堆连在一起的开关)导致整个计算机系统就是离散的,并没有所谓连续,只不过CPU的频率那么高(4GHz),看起来连续而已。跑题更远一些,这个世界真的存在连续体吗?至少物质本身就是离散的(基本粒子)。

言归正传,为了在不用的任务之间切换,我们需要一个可行的方案。假设我们现在有两个任务,A和B,我们需要看起来同时执行A和B但是我们只有一个工人(thread)。如何让领导看起来工人在同时进行A和B呢?工人先进行A,1分钟后,他记录A的状态,停止执行A,转而执行B,1分钟后,记录B的状态,停止执行B,读取A的状态,继续执行A。如此循环,直到A和B都完成。

这么做有什么好处呢?我们接着上面的例子,A任务的内容其实煮10瓶牛奶,B任务是写报告。工人先煮一瓶牛奶,然后回去写报告,过1分钟,他发现牛奶已经好了了,他就会煮第二瓶,然后接着写报告。这样他就节约了很多时间,而不用等着10瓶牛奶都煮好,再去写报告。

当然,为了解决上面的问题,我们还可以有另一个方案,就是雇两个工人,一个人煮牛奶,一个人写报告。理论上,这个方案更快,但是可能会花更多钱。(其实这里就是并行的概念了)

其实,方案1就是并发(concurrent),方案2就是并行(parallel)。

2. asyncio是怎么来的?

背景介绍完了,接下来我们来asyncio是怎么来的,也就是看看Python是怎么处理并发(concurrent)。想弄明白它是怎么来的,我们得先看看python为了并发编程做了那些努力。

2.1 Generator

上面一个小节我们其实已经指出了并发的核心工作:在不同的任务之间切换。为了实现这样的切换,我们必须记住每一个任务的状态,然后有能力中断并重启任务。

在编程的背景下,任务最简单的形式就是函数。所以问题变成了:如何让一个函数可以记住自己的状态,可以中断并且中断后可以重启?

Python最早给出的解答(在2.3版本中)是generator,对应到具体语法就是yield关键字。 Generator就是这样一个类函数,它可以记住自己的状态,并中断,直到下一次调用。这里插一嘴,Generator其实是一个Iterator,这也是为什么generator可以被直接for loop。如果感兴趣你可以参考这个:https://zhuanlan.zhihu.com/p/91086822

我举个例子:

# 这是一个普通任务,不能中断
def eager_range(up_to):
    """从0数到up_to"""
    sequence = []
    index = 0
    while index < up_to:
        sequence.append(index)
        index += 1
    return sequence # 函数会一直运行到所有数字生成,并返回
 
# 这是一个Generator,就是可以中断的任务
def lazy_range(up_to):
    """从0数到up_to"""
    index = 0
    while index < up_to:
        (yield index)  # <--- 函数就中断在这里,但是维持自己的内部状态,比如index,up_to
        index += 1

# 实验一下
lz = lazy_range(10)
# lz: <generator object lazy_range at 0x000002264844F748>
# 这是一个Generator对象
next(lz)  # 0
next(lz)  # 1
next(lz)  # 2
# next 会重启函数,直到yield中断函数

简单的说,generator提供一个可以中断、重启的函数,为并发做好了准备。但是这里面有个问题,就是一旦genetor对象生成,它内部的状态就被决定了,外界不能对他进行任何改变。后来Python社区意识到了这个问题,给Generator增加了一个功能,send(),这样外界就可以动态的控制generator了!请仔细阅读注释,并且自己试验一下。

def jumping_range(up_to):
    """Generator for the sequence of integers from 0 to up_to, exclusive.

    Sending a value into the generator will shift the sequence by that amount.
    """
    index = 0
    while index < up_to:
        jump = yield index  # 
        if jump is None:
            jump = 1
        index += jump

# 试一试
iterator = jumping_range(5)
print(next(iterator))  # 0,这里我们得到了第一中断的值,此时index==0
print(iterator.send(2))  # 2,然后我们把2发送进函数,jump变成了2,运行到下一个中断,得到2(0+2)
print(next(iterator))  # 3,继续下一个执行,这是jump没有注入,仍然为None,但是index是2,所以2+1=3
print(iterator.send(-1))  # 2, jump注入-1,3-1=2
for x in iterator:
    print(x)  # 3, 4,没有注入,继续执行直到index超过up to

很好!现在我们通过generator实现了任务的中断、重启以及与外界交互。为了更好的使用generator,Python3中引进了yield from关键字,通过它我们可以实现generator的链式调用,极大的简化了多重generator的代码。

def g(x):
    yield from range(x, 0, -1)
    yield from range(x)

list(g(5))
# [5, 4, 3, 2, 1, 0, 1, 2, 3, 4]
# 惊喜是,我们可以直接从上层generator send值到底层generator!
def bottom():
    # Returning the yield lets the value that goes up the call stack to come right back
    # down.
    return (yield 42)

def middle():
    return (yield from bottom())

def top():
    return (yield from middle())

# Get the generator.
gen = top()
value = next(gen)
print(value)  # Prints '42'.
try:
    value = gen.send(value * 2)
except StopIteration as exc:
    value = exc.value
print(value)  # Prints '84'.

稍微总结一下:

  1. Python 2.2, generator使得函数可以中断、重启。yield
  2. Python 2.5, 增加了send函数,generator可以接受外界注入。这时generator已经是变成了协程任务(coroutines)
  3. Python 3.3, 增加了yield from,generator可以进行链式调用

2.2 Event loop

好,现在我们有了实现可中断任务的方法,为了并发我们需要解决下一个问题:就是如何调度这些可中断的任务。最直接的方案就是事件驱动,即如果A发生,执行B。官方的名字就是事件循环。

具体到Python的标准库,asyncio就是Python给出的一个事件循环方案。只不过这个方案提供了更好的IO和网络方面的支持(如果你仔细看这个库的名字async + io,你也基本上直到这个库是干啥的)。如果你了解Python的GIL,那么这个方法可以很好地利用单线程做很多事情,某种程度上说,释放了GIL。

但是!做并发,asyncio 并不是唯一的选择!其实我们只是需要一个事件循环来调度我们的任务而已。

2.3 Coroutine

写到这里,Python已经有足够的工具支持用并发的形式(Concurrent)实现异步编程(Asynchronous)了。什么是并发编程形式?就是在单一的线程内,多个任务相互独立的执行。什么是异步编程?就是多个任务的执行顺序在执行前是不确定的。所以,并发是实现异步的一种方法

举个例子:

import asyncio

# Borrowed from http://curio.readthedocs.org/en/latest/tutorial.html.
@asyncio.coroutine  # 表明这是一个coroutine,而不是一般的generator
def countdown(number, n):
    while n > 0:
        print('T-minus', n, '({})'.format(number))
        yield from asyncio.sleep(1)
        n -= 1

loop = asyncio.get_event_loop()
tasks = [
    asyncio.ensure_future(countdown("A", 2)),
    asyncio.ensure_future(countdown("B", 3))]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()

上面的例子可以看出来,asyncio.coroutine装饰器表明了下面的函数不是简单的generator,而是一个coroutine,这个类型是在Python3.4中引进的。

coroutine的定义是:它是泛化的subroutine,一个subroutine只能在一个点进入,在一个点离开,但是coroutine可以在不同的点进入、离开或者继续调用。coroutine类型在Python中并定义在这里。简单的说,coroutine类型接口有三个函数:send, throw, close。

继续解释上面的例子,事件循环会开始执行tasks中的countdown任务,当遇到yield from时, 会返回一个asyncio.Future对象,这个对象被传入事件循环对象,同时该任务也会中断。事件循环对象会监控它控制的Future对象,当2秒中到了,事件循环对象会把Future对象传入该协程任务,继续该任务。如此循环,指导该任务不再返回任何Future对象(也就是不再执行yield from语句了,而是return,如果没有默认是return None)。

2.4 async 和 await

但是,你可能已经发现了协程混合了generator的语法,可是并不是所有generator都是作为协程使用的!所以,在Python3.5,引进了两个新的关键字来表达协程:asyncawait

#  
@asyncio.coroutine
def py34_coro():
    yield from stuff()
# 这是3.4的语法
# 以下是3.5的新语法
async def py35_coro():
    await stuff()

上面的例子中,有两个主要的区别:1、asyncio.coroutine变成了async。2、yield from 变成了await。这样做最直接的作用就是区别开了作为协程generator和一般的generator。

另外一个值得注意的是await表达,理论上只有awaitable object可以被用于await表达式。上面我们提到的Future对象就属于这个类型。

稍微总结一下,我们已经基本看到了asyncio是怎么一步步发展出来的,以及基本的关键字async 和await。

关于如何使用asyncio我会在另一个专门的文章介绍,接下来,我们看看如何只用async/await进行异步编程。

3. 利用async/await进行异步编程

重申一下观点:async和await并不是为了asyncio库出现的,而是为了实现并发编程,而并发编程是异步的一种实现方式而已。异步就是指单个任务不会阻塞整个进程,多个任务相互独立进行。

这里引用这篇文章的例子。

import datetime
import heapq
import types
import time


class Task:

    """Represent how long a coroutine should wait before starting again.

    Comparison operators are implemented for use by heapq. Two-item
    tuples unfortunately don't work because when the datetime.datetime
    instances are equal, comparison falls to the coroutine and they don't
    implement comparison methods, triggering an exception.
    
    Think of this as being like asyncio.Task/curio.Task.
    """

    def __init__(self, wait_until, coro):
        self.coro = coro
        self.waiting_until = wait_until

    def __eq__(self, other):
        return self.waiting_until == other.waiting_until

    def __lt__(self, other):
        return self.waiting_until < other.waiting_until

class SleepingLoop:

    """An event loop focused on delaying execution of coroutines.

    Think of this as being like asyncio.BaseEventLoop/curio.Kernel.
    """

    def __init__(self, *coros):
        self._new = coros
        self._waiting = []

    def run_until_complete(self):
        # Start all the coroutines.
        for coro in self._new:
            wait_for = coro.send(None)
            heapq.heappush(self._waiting, Task(wait_for, coro))
        # Keep running until there is no more work to do.
        while self._waiting:
            now = datetime.datetime.now()
            # Get the coroutine with the soonest resumption time.
            task = heapq.heappop(self._waiting)
            if now < task.waiting_until:
                # We're ahead of schedule; wait until it's time to resume.
                delta = task.waiting_until - now
                time.sleep(delta.total_seconds())
                now = datetime.datetime.now()
            try:
                # It's time to resume the coroutine.
                wait_until = task.coro.send(now)
                heapq.heappush(self._waiting, Task(wait_until, task.coro))
            except StopIteration:
                # The coroutine is done.
                pass

@types.coroutine
def sleep(seconds):
    """Pause a coroutine for the specified number of seconds.

    Think of this as being like asyncio.sleep()/curio.sleep().
    """
    now = datetime.datetime.now()
    wait_until = now + datetime.timedelta(seconds=seconds)
    # Make all coroutines on the call stack pause; the need to use `yield`
    # necessitates this be generator-based and not an async-based coroutine.
    actual = yield wait_until
    # Resume the execution stack, sending back how long we actually waited.
    return actual - now

async def countdown(label, length, *, delay=0):
    """Countdown a launch for `length` seconds, waiting `delay` seconds.

    This is what a user would typically write.
    """
    print(label, 'waiting', delay, 'seconds before starting countdown')
    delta = await sleep(delay)
    print(label, 'starting after waiting', delta)
    while length:
        print(label, 'T-minus', length)
        waited = await sleep(1)
        length -= 1
    print(label, 'lift-off!')


def main():
    """Start the event loop, counting down 3 separate launches.

    This is what a user would typically write.
    """
    loop = SleepingLoop(countdown('A', 5), countdown('B', 3, delay=2),
                        countdown('C', 4, delay=1))
    start = datetime.datetime.now()
    loop.run_until_complete()
    print('Total elapsed time is', datetime.datetime.now() - start)


if __name__ == '__main__':
    main()

4、总结

  • concurrent == event loop + coroutine
  • coroutine就是一个可以中断、重启、注入的函数
  • concurrent是实现异步(非阻塞)的一种方式
  • async/await是Python中新的实现concurrent的工具
  • async/await把coroutine与一般的generator区别开来
  • asyncio != async / await

参考

  • [Reuven M. Lerner] https://www.linuxjournal.com/content/understanding-pythons-asyncio
  • [Principal software engineer at Microsoft] https://snarky.ca/how-the-heck-does-async-await-work-in-python-3-5/
  • [官方文档的一部分] https://docs.python.org/2.5/whatsnew/pep-342.html
  • https://realpython.com/async-io-python/
  • https://faculty.ai/blog/a-guide-to-using-asyncio/
  • https://stackoverflow.com/questions/4844637/what-is-the-difference-between-concurrency-parallelism-and-asynchronous-methods
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值