带你简单了解python协程和异步

带你简单了解python的协程和异步

前言

对于学习异步的出发点,是写爬虫。从简单爬虫到学会了使用多线程爬虫之后,在翻看别人的博客文章时偶尔会看到异步这一说法。而对于异步的了解实在困扰了我好久好久,看了N遍廖雪峰python3协程和异步的文章,一直都是一知半解,也学不会怎么使用异步来写爬虫。于是翻看了其他关于异步的文章,才慢慢了解python的异步机制并学会使用,但是没看到有特别全面的文章,所以在参考别人的文章基础上,加上了自己的理解,写了出来,也算是自己的一个小总结。

一.认识生成器

生成器的产生其实比较容易理解,例如当我们要创建了0到1000000这样一个很大的列表但同时我们只需要取出部分数据,这样的需要并不少见,而显然这种做法浪费了大量的内存空间。而生成器的作用就是为了解决上述的问题,利用生成器我们只需要能够保持一个整数的内存即可遍历数组列表。生成器的使用是通过yield实现,看下面代码样例。

def l_range(num):
    index = 0
    while index < num:
        yield index    # (1)
        index += 1

l = l_range(5)
print(next(l))    #0
print(next(l))    #1
print(next(l))    #2

很多人会混淆yield和send(后面会提到)的使用,上面的代码中 yield index,配合next(l)的使用。简单可以这样理解,函数l_range的while循环中,每次程序运行到(1)处都"暂停"了,向调用函数处返回index参数,注意此时并没有执行(1)这条语句!!!而每调用一次next(l)循环就会执行一次,而当index>num的时候,假若再调用next(l),因为此时已经跳出了while循环,yield不会再执行,所以会抛出异常。
除了使用next()调用生成器,但是实际上还可以用for循环遍历,可知生成器也是可迭代对象。

for i in l_range(5):
    print(i)

明白了“暂停”的概念,生成器就变得非常好理解了!

二.认识协程

从上面的demo中,我们可以得知生成器的引入使得函数的调用能够“暂停”并且向外传递数据,既然可以向外传递数据,那么是否能够向函数里传递数据呢?生成器send的引入就是为了实现这个需求!send能够从生成器(函数)调用处传递数据到yield处。
来看下面这个demo。

def jumping_range(up_to):
    index = 0
    while index < up_to:
        jump = yield index    # (1)
        # print('index = %s, jump = %s' % (index, jump))
        if jump is None:
            jump = 1
        index += jump

iterator = jumping_range(5)
print(next(iterator))         #0
print(iterator.send(2))        #2
print(next(iterator))         #3
print(iterator.send(-1))      #2
print(next(iterator))         #3
print(next(iterator))         #4

下面解释下每一个输出,当第一次next(iterator),程序执行到(1)处,但是未执行,只是把index传递出去,所以此时输出的是0(index=0)。接着执行iterator.send(2),这里把2从调用处传递给了生成器里并赋值给jump,注意yield index是传递index参数出去,而jump=yield是把参数传递进去给jump!!!然后执行完while的第一次循环回到(1),此时index 执行了一次 index+=jump,并且jump=2。所以iterator.send(2)的输出是2!而后面的输出请各位独自推算一下,若实在想不通可以尝试在生成器中print一下各参数出来,方便理解。
要搞明白协程,对于这句代码的理解尤为重要。

jump = yield index

其实意思上可以理解为

jump = yield
yield index

即 jump接受从外面传递进来的参数,而index则是要传递出去的参数。但是当然,这只是我为了方便理解拆分出来的代码,实际上这样拆分会导致不同的结果。

来看看拆分出来的代码

def jumping_range(up_to):
    index = 0
    while index < up_to:
        jump = yield    #(a)
        yield index    #(b)
        # print('index = %s, jump = %s' % (index, jump))
        if jump is None:
            jump = 1
        # print('jump = %d' % jump)
        index += jump


iterator = jumping_range(5)
print(next(iterator))         #None
print(iterator.send(2))        #0
print(next(iterator))         #None
print(iterator.send(-1))      #2
print(next(iterator))         #None
print(next(iterator))         #1

简单讲解上述的输出,首先当程序执行到a(注意a处的代码未执行),此时yield 右边并没有参数,所以第一个print返回的是None。而当执行iterator.send(2),程序在a处把2传递给参数jump,然后往下执行,当遇到第二个yield,程序又“暂停”了,即一个while循环里暂停2次!而执行到b处(b处的代码未执行)把index传递到出去,所以此时print返回的是0(index=0)。接着来的可以如此类推了!

只要明白了上述2个demo,相信对于协程已经有一定的理解了。最后再提一下yield from的使用。yield from的使用类似函数调用,作用是让重构变得简单,也让你能够将生成器串联起来,使返回值可以在调用栈中上下浮动,不需对编码进行过多改动。

def bottom():
    return (yield 42)


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


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



gen = top()
value = next(gen)
print(value)
try:
    value = gen.send(value * 2)
except StopIteration as exc:
    print(exc)
    value = exc.value
print(value)

三.认识异步

对于异步IO,就是你发起一个IO操作,却不用等它结束,你可以继续做其他事情,当它结束时,你会得到通知。而要理解异步async/await,首先要理解什么是事件循环。
事件循环,在维基百科的解释是“一种等待程序分配事件或消息的编程架构”。简单的说事件循环就是“当A发生时,执行B”。对python来说,用来提供事件循环的asyncio被加入标准库,asyncio 重点解决网络服务中的问题,事件循环在这里将来自套接字(socket)的 I/O 已经准备好读和/或写作为“当A发生时”(通过selectors模块)。和多线程和多进程一样,Asyncio是并发的一种方式。但由于GIL(全局解释器锁)的存在,python的多线程以及Asyncio不能带来真正的并行。而可交给asyncio执行的任务,就是上述的协程!一个协程可以放弃执行,把机会给其他协程(即yield from 或await)。

1.定义协程

定义协程有2种常用的方式,

  • 在定义函数的时候加上async作为前缀
  • 使用python装饰器。

前者是python3.5的新方式,而后者是3.4的方式(3.5也可用)。

async def do_some_work(x):
    print("Waiting " + str(x))
    await asyncio.sleep(x)
@asyncio.coroutine
def do_some_work2(x):
    print("Waiting " + str(x))
    yield from asyncio.sleep(x)

这样一来do_some_work便是一个协程,准确来说是一个协程函数,并且可以用asyncio.iscoroutinefunction来验证

print(asyncio.iscoroutinefunction(do_some_work)) # True

在解释await之前,我们先来说明一下协程可以做什么事

  • 等待另一个协程
  • 产生一个结果给正在等它的协程
  • 引发一个异常给正在等它的协程

demo中asyncio.sleep()也是一个协程,await asyncio.sleep(x),顾名思义就是等待,等待asyncio.sleep(x)执行完后返回do_some_work这个协程。

2.运行协程

协程函数的调用与普通函数不同,要让协程对象运行的话,常用的方式有2中

  • 在另一个已经运行的协程用‘await’等待它(或者yield from)
  • 通过 ‘ensure_future’ 函数计划它的执行

简单来说,只有loop运行了,协程才可能运行。所以在运行协程之前,必须先拿到当前线程缺省的loop,然后把协程对象交给loop.run_until_complete,协程对象随后会在loop里得到运行。

loop = asyncio.get_event_loop()
loop.run_until_complete(do_some_work(3))

run_until_complete 是一个阻塞(blocking)调用,知道调用运行结束,才返回。而它的参数是一个future,但是我们上面传进去的确实协程对象,之所以可以这样,是因为它内部做了检查,对于协程会通过ensure_future函数把协程对象包装(wrap)成了future。
所以我们可以改为:

loop.run_until_complete(asyncio.ensure_future(do_some_work(3))

上面的demo这都是用ensure_future函数计划它的执行, 来看看使用第一种方法

tasks = [
  asyncio.ensure_future(do_some_work(1)),
  asyncio.ensure_future(do_some_work(3))
]
loop.run_until_complete(asyncio.wait(tasks))

注意: asyncio.wait本身是一个协程

3.回调

有时候当协程运行结束的时候,我们希望得到通知,以便判断程序执行的情况以及下一步数据的处理。这一需求可以通过往future添加回调来实现。

def done_callback(cor):
    """
    协程的回调函数
    :param cor:
    :return:
    """
    print('Done')

cor = asyncio.ensure_future(do_some_work(3))
cor.add_done_callback(done_callback)
loop = asyncio.get_event_loop()
loop.run_until_complete(cor)

4.多个协程

在实际运行异步中,往往是有多个协程,同时在一个loop里运行。于是需要使用asyncio.gather函数把多个协程交给loop。

loop.run_until_complete(asyncio.gather(do_some_work(1), do_some_work(3)))

当然协程一多起来,一条语句写起来就不方便了,可以先把协程存在列表里。

coros = [do_some_work(1), do_some_work(3)]
loop.run_until_complete(asyncio.gather(*coros))

由于这两个协程是并发运行的,所以等待时间并不是1+3=4,而是以耗时比较长的那个。
上面也提到run_until_complete的参数是future,而gather起聚合的作用,把多个futures包装成一个future,因为loop.run_until_complete只接受单个future。上述代码也可以改为:

coros = [asyncio.ensure_future(do_some_work(1)),
             asyncio.ensure_future(do_some_work(3))]
loop.run_until_complete(asyncio.gather(*coros))

5.结束协程

常用的结束协程的方法有2种:

  • run_until_complete
  • run_forever

run_until_complete看函数名就大概明白,即是直到所有协程工作(future)结束才返回

async def do_some_work(x):
    print('Waiting ' + str(x))
    await asyncio.sleep(x)
    print('Done')


loop = asyncio.get_event_loop()

coro = do_some_work(3)
loop.run_until_complete(coro)

输出:
程序等待3秒钟后输出'Done'返回

试试改为run_forever:

async def do_some_work(x):
    print('Waiting ' + str(x))
    await asyncio.sleep(x)
    print('Done')


loop = asyncio.get_event_loop()

coro = do_some_work(3)
asyncio.ensure_future(coro)

loop.run_forever()

输出:
程序等待3秒钟后输出'Done'但并没有返回。
run_forever会一直运行,直到loop.stop()被调用,但是不能在run_forever后调用stop,因为run_forever永远都不会返回,所以stop永远都不能被调用。

loop.run_forever()
loop.stop()

正确的使用方法应该是在协程中调用stop,所以需要在协程参数中传入loop:

async def do_some_work(loop, x):
    print('Waiting ' + str(x))
    await asyncio.sleep(x)
    print('Done')
    loop.stop()

这样看来似乎没有什么问题,但是当有多个协程在loop里运行呢?

asyncio.ensure_future(do_some_work(loop, 1))
asyncio.ensure_future(do_some_work(loop, 3))

loop.run_forever

运行程序时会发现,只输出了一个‘Done’程序就返回了。这说明了第二个协程还没有结束,loop就停止了,被先结束的那个协程给停掉了。要解决这个问题,可以用gather把多个协程合并在一起,通过回调的方式调用loop.stop。

async def do_some_work(loop, x):
    print('Waiting ' + str(x))
    await asyncio.sleep(x)
    print('Done')

def done_callback(loop, futu):
    loop.stop()

loop = asyncio.get_event_loop()

futus = asyncio.gather(do_some_work(loop, 1), do_some_work(loop, 3))
futus.add_done_callback(functools.partial(done_callback, loop))

loop.run_forever()

6. Close loop

对于同一个loop,只要没有close,那么loop还可以继续添加协程并且再运行。

loop.run_until_complete(do_some_work(loop, 1))
loop.run_until_complete(do_some_work(loop, 3))

但是关闭了就不能再运行了。

loop.run_until_complete(do_some_work(loop, 1))
loop.close()
loop.run_until_complete(do_some_work(loop, 3))    # 抛出异常

最后提一下yield from 和 await虽然内部机制有所不同,但是从作用来看基本上是一样的,这里就不探讨具体的区别了。
另外关于asyncio.gather和asyncio.wait的区别请看StackOverflow的讨论Asyncio.gather vs asyncio.wait

7.爬虫小demo

使用asyncio异步抓取豆瓣电影top250

# -*- coding: utf-8 -*-
from lxml import etree
from time import time
import asyncio
import aiohttp

__author__ = 'lateink'

url = 'https://movie.douban.com/top250'


async def fetch_content(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()


async def parse(url):
    page = await fetch_content(url)
    html = etree.HTML(page)

    xpath_movie = '//*[@id="content"]/div/div[1]/ol/li'
    xpath_title = './/span[@class="title"]'
    xpath_pages = '//*[@id="content"]/div/div[1]/div[2]/a'

    pages = html.xpath(xpath_pages)
    fetch_list = []
    result = []

    for element_movie in html.xpath(xpath_movie):
        result.append(element_movie)

    for p in pages:
        fetch_list.append(url + p.get('href'))

    tasks = [fetch_content(url) for url in fetch_list]
    pages = await asyncio.gather(*tasks)

    for page in pages:
        html = etree.HTML(page)
        for element_movie in html.xpath(xpath_movie):
            result.append(element_movie)

    for i, movie in enumerate(result, 1):
        title = movie.find(xpath_title).text
        print(i, title)


def main():
    loop = asyncio.get_event_loop()
    start = time()
    for i in range(5):
        loop.run_until_complete(parse(url))
    end = time()
    print('Cost {} seconds'.format((end - start)/5))
    loop.close()


if __name__ == '__main__':
    main()
posted @ 2017-09-16 21:55  lateink  阅读(1505)  评论(0编辑  收藏  举报