探索asyncio中的协程嵌套,以及在爬虫设计上的应用

目录

1. 前言

2. 协程嵌套

2.1 单个协程

2.2 嵌套(封装)为main()协程

2.3 方法1:直接对协程返回的结果进行处理

2.4 方法2:定义自己的 callback function(推荐)

3. 协程嵌套在爬虫设计上的应用

4. 一些有点坑但必须了解的知识点

4.1 coro() 与 asyncio.create_task(coro())

4.2 Awaitable objects: Coroutine, Task 和 Future

4.3 asyncio.wait() 与 asyncio.gather()

4.4 协程中Exception的捕获

5. 参考资料


1. 前言

本文介绍Python库asyncio中的协程嵌套与并行,以及在爬虫设计中的应用。

2. 协程嵌套

为什么要嵌套协程?现实中的程序往往是一环套一环,比如爬虫获取网页后,可能还需要进行解析,可能还需要再进入一堆页面,可能还需要再对这些页面进一步解析,然后返回解析后的结果。

2.1 单个协程

单个协程的情况非常简单:

async def task(num):
    '''
    定义一个任务,该任务模拟等待1秒IO操作后,直接返回输入的数
    '''
    result = await asyncio.sleep(1, result=num)
    return result

loop = asyncio.get_event_loop()
response = loop.run_until_complete(task(1))  # 在事件循环中运行task(1)事件,直到事件结束
print(f"response: {response}")
loop.close()

# 运行结果:
# response: 1

2.2 嵌套(封装)为main()协程

为了便于操作多个协程,最基本的操作就是将Coroutine封装到main()协程中,这其实就已经是一个嵌套的过程了。

import asyncio

async def task(num):
    '''
    定义一个任务,该任务模拟等待1秒IO操作后,直接返回输入的数
    '''
    result = await asyncio.sleep(1, result=num)
    return result

async def main():
    '''
    主函数,事件循环的入口
    '''
    result = await task(1) # task()是一个协程,是协程那就需要await 
    return result

loop = asyncio.get_event_loop()
response = loop.run_until_complete(main())  # 在事件循环中运行main()事件,直到事件结束
print(f"response: {response}")
loop.close()

# 运行结果:
# response: 1

为了便于理解上面发生了什么,我们把上面主函数的步骤写多一些,这对理解后面的回调函数有帮助。

import asyncio

async def task(num):
    '''
    定义一个任务,该任务模拟等待1秒IO操作后,直接返回输入的数
    '''
    result = await asyncio.sleep(1, result=num)
    return result

async def main():
    '''
    主函数,事件循环的入口
    '''
    task1 = task(1)            # 定义一个任务task1
    print("task1: ", task1)    # 通过print的结果发现,task1是一个协程对象
    result = await task1       # 通过await操作获取task1协程的输出
    print("result: ", result)  # 获取的输出就是task(1)的输出:1
    return result              # 将task(1)的结果返回给主事件循环,得到response: 1

loop = asyncio.get_event_loop()
response = loop.run_until_complete(main())  # 在事件循环中运行main()事件,直到事件结束
print(f"response: {response}")
loop.close()

# 运行结果:
# task1:  
# result:  1
# response: 1

2.3 方法1:直接对协程返回的结果进行处理

假设我们需要对task产出的结果进行一些操作,然后再返回操作后的结果,我们可以这样做:

async def task(num):
    '''
    定义一个任务,该任务模拟等待1秒IO操作后,直接返回输入的数
    '''
    result = await asyncio.sleep(1, result=num)
    return result

async def sub_task(input_num):
    '''
    定义一个子任务,该子任务模拟进行一个计算任务后,返回计算后的结果
    '''
    output_num = input_num + 1
    return output_num
    
async def main():
    '''
    主函数,事件循环的入口
    '''
    task1 = task(1)                   # 定义一个任务task1
    print("task1: ", task1)           # 通过print的结果发现,task1是一个协程对象
    result = await task1              # task1是一个协程,通过await操作可以获取协程的输出
    print("result: ", result)         # 获取的输出就是task(1)的输出:1
    sub_task1 = sub_task(result)      # 定义子任务1
    print("sub_task1: ", sub_task1)   # 子任务也是一个协程对象
    sub_result = await sub_task1      # 通过await操作获取子任务的输出
    print("sub_result: ", sub_result) # 处理后的结果为2
    return sub_result                 # 将最终的结果返回给主事件循环,得到response: 2

loop = asyncio.get_event_loop()
response = loop.run_until_complete(main())  # 在事件循环中运行main()事件,直到事件结束
print(f"response: {response}")
loop.close()

# 运行结果:
# task1:  
# result:  1
# sub_task1:  
# sub_result:  2
# response: 2

当然,上面的main()函数可以直接简化为:

async def main():
    '''
    主函数,事件循环的入口
    '''
    sub_result = await sub_task(await task(1))
    return sub_result

# 运行结果:
# response: 2

而除了这种方法,我们其实有一个更好的选择,那就是使用回调函数。

2.4 方法2:定义自己的 callback function(推荐)

在asyncio的官方文档中,给出了一个add_done_callback()回调方法,但是官方建议这个方法只应用在底层代码。所以我们就可以自己定义自己的回调函数,让task成功完成后,运行某个操作。(此处参考StackOverflow)

async def task(num):
    '''
    定义一个任务,该任务模拟等待1秒IO操作后,直接返回输入的数
    '''
    result = await asyncio.sleep(1, result=num)
    return result

async def sub_task(input_num):
    '''
    定义一个子任务,该子任务模拟进行一个计算任务后,返回计算后的结果
    '''
    output_num = input_num + 1
    return output_num

async def add_success_callback(future, callback):
    '''
    定义一个类似于函数装饰器的协程,
    当future完成后,执行func,并返回处理的结果
    '''
    result = await future
    print("Future retrieved, got: ", result)
    result = await callback(result)
    print("Processed with future, returned value: ", result)
    return result

async def main():
    '''
    主函数,事件循环的入口
    '''
    result = await add_success_callback(task(1), sub_task)
    return result


loop = asyncio.get_event_loop()
response = loop.run_until_complete(main())  # 在事件循环中运行main()事件,直到事件结束
print(f"response: {response}")
loop.close()

# 运行结果:
# Future retrieved, got:  1
# Processed with future, returned value:  2
# response: 2

这里的 add_success_callback()这个函数的本质,实际上是把 task(1) 协程和 sub_task 函数嵌套到了一起,然后返回了另一个协程对象,然后通过 await / await asyncio.create_task() / await asyncio.wait() / await asyncio.gather() 这个协程对象,我们就可以注册这个wrapped coroutine,获取输出的结果。(通过print也可以发现这个回调函数本身也是一个

第二种方法的好处是,在程序设计过程中大部分时候我们只需要修改callback函数就行了,例如在爬虫中我们下载网页,下载网页的过程都是一样的,但是网页不同,我们需要定义的parser也不同,因此我们只需自己定义parser,然后用add_success_callback()嵌套进去即可。

3. 协程嵌套在爬虫设计上的应用

(写得有点晚了,这部分改天再补)

4. 一些有点坑但必须了解的知识点

4.1 coro() 与 asyncio.create_task(coro())

这里首先要提的是,不论如何定义各种协程和任务,所有的协程只有丢进asyncio.run()或者loop.run_until_complete()等函数后,才会开始运行。

在实操过程中,某个协程对象 coro() 和 asyncio.create_task(coro()) 实际使用上非常相似,因为他们都可以且需要被 await 然后被丢进事件循环里执行,并且运行起来几乎没什么两样。而他们的区别主要在于:

  • coro() 定义的是一个Coroutine对象
  • asyncio.create_task() 定义的是一个Task对象,是对传入Coroutine的一个封装

从官方文档可以看出,Task其实是Future的一个子类,它提供了诸多非常有用的方法,比如说:

  • 使用.cancel()取消一个任务
  • 使用.cancelled()判断一个任务是否被取消了
  • 使用.done()判断一个任务是否已经完成
  • 使用.result()获取结果
  • ...(详见官方文档)

使用这些方法你是不是已经想到可以写出一些定制化更强的程序了呢?

4.2 Awaitable objects: Coroutine, Task 和 Future

4.1中已经提到了Task和Coroutine,但没有提到Future。从官方文档中可以看到,Future其实是一个用来桥接高层async/await代码(也就是Coroutine)的底层代码。它是用来存放未来返回结果的一个容器,一般不建议直接调用。

这里再提一下,Task和Future在源代码中都有类定义,但我在源代码中并没有看到Coroutine类的定义,但包括官方文档以及谷歌等种种资料都表明,Coroutine就是用async def/await定义的函数或对象,例如来自官方文档的描述:

Important: In this documentation the term “coroutine” can be used for two closely related concepts:

  • coroutine function: an async def function;

  • coroutine object: an object returned by calling a coroutine function

因此,Coroutine对象是几乎没有什么方法的(比如Future好歹还有一些诸如.done(), .cancelled()方法),但Coroutine的底层就是用Future对象来实现的。

另外,Coroutine, Task 和 Future 三者都是Awaitable object. 

4.3 asyncio.wait() 与 asyncio.gather()

如果说 asyncio.create_task() 是对单个协程的封装,那么 asyncio.wait() 与 asyncio.gather() 就是对多个协程的封装了,封装后的东西,依旧是一个awaitable对象,也必须被await后才能在事件循环中执行。(其中.wait()封装后为一个Coroutine对象,而.gather()封装后为一个继承自future.Future对象的_GatheringFuture对象——来自源码)

两者的不同是,asyncio.wait() 接受协程列表输入,返回2个set,第一个set是Futures done,第二个是Futures pending:

done, pending = await asyncio.wait([coro1, coro2, coro3, ...])

等等,不是已经await等待列表中所有任务完成了吗?怎么还会有pending的呢?看一看源码:

async def wait(fs, *, loop=None, timeout=None, return_when=ALL_COMPLETED):
    """Wait for the Futures and coroutines given by fs to complete.

    The sequence futures must not be empty.

    Coroutines will be wrapped in Tasks.

    Returns two sets of Future: (done, pending).

    Usage:

        done, pending = await asyncio.wait(fs)

    Note: This does not raise TimeoutError! Futures that aren't done
    when the timeout occurs are returned in the second set.
    """

原来这个函数还可以接受return_when这个参数,当FIRST_COMPLETED, FIRST_EXCEPTION, ALL_COMPLETED时都可以返回结果,如果选第一个first_completed,那么第一个完成的协程就会触发其结束,并放入done set里,余下所有的pending Futures自然就进入pending set了。

另外上面注释中也可以看到,如果定义了timeout参数,那么在倒计时结束后,也会自动触发返回,并且分出done和pending的任务,而并不是会报出TimeoutError Exception。

注意用.wait()时,要获取某个future的返回值需要用.result()函数。

另外一边的asyncio.gather()函数,则简单粗暴,接受*futures作为输入,然后直接输出这些futures的返回值。

def gather(*coros_or_futures, loop=None, return_exceptions=False):

可以看到也并没有.wait()中的那么多可选的参数。

4.4 协程中Exception的捕获

协程和普通程序一样,可以通过try...except...else...finally...来捕获与处理异常;并且也和普通程序一样你可以考虑把异常捕获设计在程序的顶层,或者底层,完全取决于自己的设计思路。例如:

async def main():
    '''
    主函数,事件循环的入口
    '''
    try:
        result = await add_success_callback(task(1), sub_task)
    except Exception as e:
        result = None
        print(repr(e))
    finally:    
        return result

 

5. 参考资料

Python Asyncio官方文档

StackOverflow - Add Done Callback with async def

Segmentfault - 通读Python官方文档之协程、Future与Task

你可能感兴趣的:(Python,学习,开发)