带你简单了解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()