12.1 协程的概念
根据维基百科给出的定义,“协程 ,英文Coroutines,是一种比线程更加轻量级的存在,是为非抢占式多任务产生子程序的计算机程序组件,协程允许不同入口点在不同位置暂停或开始执行程序”。它是实现并发编程的一种方式。
以往这种方式都是由多线程和锁来实现,但是线程之间的切换需要用到操作系统内核中的TCB(Thread Control Block)模块来改变线程的状态,这一过程需要耗费一定的CPU资源。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。
协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
12.2 协程的代码实现
下面以一个简化的爬虫代码为例,描述如何让一个同步执行的爬虫变为异步执行的爬虫。
同步执行的爬虫:
import time
def crawl_page(url): # 简化爬虫的scrawl_page函数为休眠数秒,休眠时间取决于url最后的那位数,即下面的1,2,3,4
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
time.sleep(sleep_time)
print('OK {}'.format(url))
def main(urls):
for url in urls:
crawl_page(url)
%time main(['url_1', 'url_2', 'url_3', 'url_4'])
########## 输出 ##########
crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
Wall time: 10 s
同步执行的爬虫依次爬取了4个页面,加起来一共用了10s,效率低下,这种爬取操作完全可以并发化:
import asyncio
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
await task
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
########## 输出 ##########
crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
Wall time: 3.99 s
可见使用协程写异步程序非常简单,只需改变几处写法就行。
- import asyncio,这个库包含了大部分我们实现协程所需要的魔法工具
- async声明异步函数,于是,这里的crawl_page和main都变成了异步函数,而调用异步函数,我们便可以得到一个协程对象(coroutine object)。
- 协程的执行由多种方法,上面代码中主要用到了await、asyncio.create_task()和asyncio.run()三种。
- await:执行效果和python正常执行是一样的,也即程序会堵塞在这里,进入被调用的协程函数,执行完返回再继续,而这也是await的字面意思。代码中await asyncio.sleep(sleep_time)会在这里休息若干秒,await crawl_page(url)则会执行crawl_page()函数
- asyncio.create_task():有了协程对象后,便可用来创建任务。任务创建后很快就会被调度执行,这样就使得代码不会堵塞在任务这里。
- asyncio.run():触发运行,一个非常好的编程规范是,asyncio.run(main())作为主程序的入口函数,在程序运行周期内,只会调用一次asyncio.run
对于执行task,有两种方法:
一个是如上面代码所示的:
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
await task
另一个就是使用asyncio.gather()函数:
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
await asyncio.gather(*tasks)
要注意的是,*tasks表示解包列表,将列表变成函数的参数;与之对应的是,**dict将字典变为函数的参数
12.3 解析协程如何运行
import asyncio
async def worker_1():
print('worker_1 start')
await asyncio.sleep(1)
print('worker_1 done')
async def worker_2():
print('worker_2 start')
await asyncio.sleep(2)
print('worker_2 done')
async def main():
print('before await')
await worker_1()
print('awaited worker_1')
await worker_2()
print('awaited worker_2')
%time asyncio.run(main())
########## 输出 ##########
before await
worker_1 start
worker_1 done
awaited worker_1
worker_2 start
worker_2 done
awaited worker_2
Wall time: 3 s
以上代码没有创建task,即用异步接口写同步代码,故执行时间为两个时间的总和。
import asyncio
async def worker_1():
print('worker_1 start')
await asyncio.sleep(1)
print('worker_1 done')
async def worker_2():
print('worker_2 start')
await asyncio.sleep(2)
print('worker_2 done')
async def main():
task1 = asyncio.create_task(worker_1())
task2 = asyncio.create_task(worker_2())
print('before await')
await task1
print('awaited worker_1')
await task2
print('awaited worker_2')
%time asyncio.run(main())
########## 输出 ##########
before await
worker_1 start
worker_2 start # 注意这里与第一个代码不同,此处实现了并发执行
worker_1 done
awaited worker_1
worker_2 done
awaited worker_2
Wall time: 2.01 s
对于第二个代码,以下是详细执行步骤:
取消超时协程任务和处理出错的协程任务
如以下代码所示,work_1正常运行,work_2运行中出现错误,work_3执行时间过长被取消,这些信息最终会全部体现在最终的返回结果res中
import asyncio
async def worker_1():
await asyncio.sleep(1)
return 1
async def worker_2():
await asyncio.sleep(2)
return 2 / 0
async def worker_3():
await asyncio.sleep(3)
return 3
async def main():
task_1 = asyncio.create_task(worker_1())
task_2 = asyncio.create_task(worker_2())
task_3 = asyncio.create_task(worker_3())
await asyncio.sleep(2)
task_3.cancel()
res = await asyncio.gather(task_1, task_2, task_3, return_exceptions=True)
print(res)
%time asyncio.run(main())
########## 输出 ##########
[1, ZeroDivisionError('division by zero'), CancelledError()]
Wall time: 2 s
对于以下代码:
res = await asyncio.gather(task_1, task_2, task_3, return_exceptions=True)
其中的参数return_exception=True,若不设置这个参数,错误就会完整地throw到我们这个执行层,从而需要用try except来捕捉,这样会使得其他还没被执行的任务会被全部取消掉。为了避免这个局面,将参数return_exception设置为True即可
用协程实现经典的生产消费者模型:
import asyncio
import random
async def consumer(queue, id):
while True:
val = await queue.get()
print('{} get a val: {}'.format(id, val))
await asyncio.sleep(1)
async def producer(queue, id):
for i in range(5):
val = random.randint(1, 10)
await queue.put(val)
print('{} put a val: {}'.format(id, val))
await asyncio.sleep(1)
async def main():
queue = asyncio.Queue()
consumer_1 = asyncio.create_task(consumer(queue, 'consumer_1'))
consumer_2 = asyncio.create_task(consumer(queue, 'consumer_2'))
producer_1 = asyncio.create_task(producer(queue, 'producer_1'))
producer_2 = asyncio.create_task(producer(queue, 'producer_2'))
await asyncio.sleep(10)
consumer_1.cancel()
consumer_2.cancel()
await asyncio.gather(consumer_1, consumer_2, producer_1, producer_2, return_exceptions=True)
%time asyncio.run(main())
########## 输出 ##########
producer_1 put a val: 5
producer_2 put a val: 3
consumer_1 get a val: 5
consumer_2 get a val: 3
producer_1 put a val: 1
producer_2 put a val: 3
consumer_2 get a val: 1
consumer_1 get a val: 3
producer_1 put a val: 6
producer_2 put a val: 10
consumer_1 get a val: 6
consumer_2 get a val: 10
producer_1 put a val: 4
producer_2 put a val: 5
consumer_2 get a val: 4
consumer_1 get a val: 5
producer_1 put a val: 2
producer_2 put a val: 8
consumer_1 get a val: 2
consumer_2 get a val: 8
Wall time: 10 s
12.4 总结
- 协程与线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在那些地方交出控制权,切换到下一个任务
- 协程的写法更加简洁清晰,把async / await语法和create_task结合来用,对于中小级别的并发需求已经毫无压力
- 写协程程序时,脑海里要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待I/O,什么时候要一并执行到底
协程不一定总是最优选择,多线程也有其有点,应该要懂得在什么时候用什么模型能达到工程上的最优。