刚进公司,发现公司的后台程序是基于异步和协程的,而我之前一直在用同步的方式写 web 后台,所以就花两天时间 google 了几乎所有的讲解文章,总结成此文。本文特点有:适合不懂协程的人建立一些基本的概念,但是没有进阶内容。
需要一些爬虫的基本概念。
会用范例程序来解释相对难懂的概念。
由于作者刚接触这种编程方法,所以难免会有一些错漏,欢迎指出。
协程
先抛出协程的概念: 协程就是一个函数,只是它满足以下几个特征:有 I/O 依赖的操作。
可以在进行 I/O 操作时暂停。
无法直接执行。
它的作用就是对有大量 I/O 操作的程序进行加速。
来看个例子:众所周知一个简单的爬虫可以分为三个步骤:1、构造 URL;2、发送请求获取响应;3、从响应获取数据。在第二步的时候,CPU 其实是处于闲置状态的,我们称之为「阻塞状态」。假设我们要爬取一百个网页,那么就要进入一百次阻塞状态,这就是整个程序的性能瓶颈。我们当然希望在阻塞状态时可以把 CPU 利用起来。
那么如何改进呢?这就要引入「非阻塞」和「异步」了。之前在第二步时,用一种编程方法,在执行爬取 A 网页的第二步时可以先去执行 B 网页的爬取程序,也就是说对于 A 网页的爬取程序,并不是遵循 1》2》3 的步骤进行的,这就是「异步」,反之则为「同步」。异步可以让程序进入「非阻塞」状态。
执行单个协程没有给程序加速的效果,而为了执行多个协程,我们需要一个「工具」去调度它们,这个工具就是「事件循环」。这里又涉及到 future 和 Task,future 指的是保证可以在未来执行并得到结果的一段程序,当 future 里包含协程时就成为了一个 task,在 python 的协程里我们可以把 future 和 task 当做一个概念。当调用了相应的 api 以后,我们可以用一个协程生成一个 task,然后就可以在事件循环中调度,当 A task 执行 I/O 操作时,事件循环就会执行 B task,当 A task 执行完 I/O 操作时,它就会恢复执行 A task。
先用同步编写一个爬虫:
import time
import requests
def get_response(arg):
r = requests.get(arg[1])
print('task {}'.format(arg[0]))
if __name__ == '__main__':
urls = [('A', 'https://movie.douban.com/top250'), ('B', 'https://movie.douban.com/top250?start=25&filter='),
('C', 'https://movie.douban.com/top250?start=50&filter=')]
start = time.time()
for u in urls:
get_response(u)
end = time.time()
print('running time', end - start)
显示结果是:
task A
task B
task C
running time 1.2088820934295654
可以清楚地看到它的执行顺序,以及运行时间。
再用异步编写一个爬虫:
import time
import asyncio
import aiohttp
async def get_response(arg):
print(arg[0])
async with aiohttp.ClientSession() as session:
async with session.get(arg[1]) as resp:
pass
if __name__ == '__main__':
urls = [('A', 'https://movie.douban.com/top250'),
('B', 'https://movie.douban.com/top250?start=25&filter='),
('C', 'https://movie.douban.com/top250?start=50&filter=')]
loop = asyncio.get_event_loop()
tasks = [asyncio.ensure_future(get_response(urls[0])),
asyncio.ensure_future(get_response(urls[1])),
asyncio.ensure_future(get_response(urls[2]))
]
start = time.time()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print('running time', end - start)
显示结果是:
task A
task B
task C
running time 0.4606208801269531
可以看到运行速度快了三倍左右。
线程和协程
正如线程是对进程的划分,协程又是对线程的划分。事件循环里的 tasks 都是跑在同一个线程里的,这也是我写爬虫时没有用 requests 的原因,因为它是线程阻塞的。
生成器
相信熟悉生成器的读者在看到之前描述协程特征的「暂停」二字就想到了生成器,不熟悉也没有关系。
生成器与协程有着很强的关联,他们都是利用让程序暂停的思路来对程序做优化,只是协程是针对速度,生成器是针对内存。
先假设这样一个场景:你的 CPU 内存很小,但是想要它打印出一亿个数,怎么实现?首先列表是不可行的,因为内存不够用,这就引出生成器这样一个工具:它是基于运算规律生成的一个算式,每次只能返回一个值。这个值可以用 next() 或者 for...in...来获取。
def infinite():
i = 0
while True:
yield i
i += 1
if __name__ == '__main__':
for i in infinite():
print(i)
这个程序可以把递增的整数一直打印下去,直到地老天荒。
它的内部原理是每次运行到 yield,程序就会暂停,在下次调用时会接着上一次调用时暂停的指令,继续执行。
在 web 开发中的应用场景
1、基于协程的框架 sanic sanic api 非常接近于 flask,但是它会在同一个进程内基于协程对不同的请求做处理。
2、大量的 I/O 操作 比如某个函数需要从多家供应商那里调取数据,那么就可以把调取某家供应商的函数当做 task,用事件循环对这些 tasks 进行调度。
可以感受到,协程和异步其实是很优美的思路,没有坚深的数学公式,也没有消耗额外的 CPU 资源,就轻易地提升了程序的性能。