什么是协程?
协程是实现并发编程的一种方式。事件循环启动一个统一的调度器,让调度器来决定一个时刻去运行哪个任务,于是省却了多线程中启动线程、管理线程、同步锁等各种开销。同一时期的NGINX,在高并发下能保持低资源低消耗高性能,相比Apache 也支持更多的并发连接。
从一个简单的爬虫开始说起
import time
def crawl_page(url):
print('crawling {}'.format(url))
# 简化爬虫的 scrawl_page 函数为休眠数秒,休眠时间取决于 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)
start_time = time.time()
main(['url_1', 'url_2', 'url_3', 'url_4'])
end_time = time.time()
print("Wall Time: {}".format(end_time-start_time))
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.01677417755127
[Finished in 10.2s]
这是一个很简单的爬虫,main()函数执行时,调取crawl_page()函数进行网络通信,经过若干秒等待后收到结果,然后执行下一个。运行一下我们就能看到耗时到达了10s,这显然效率低下。那么该怎么优化呢?
这种爬取操作完全可以并发化。重新使用协程来编写一下:
import time
import asyncio
# async 修饰词声明异步函数,调用异步函数,便可以得到一个协程对象。如果你 print(crawl_page('')),就可以看到这是一个Python 的协程对象。
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
# await执行的效果,和python正常执行相同。程序会阻塞在这里,进入被调用的协程函数,执行完毕后再继续
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
for url in urls:
await crawl_page(url)
start_time = time.time()
# asyncio.run来触发运行。在程序运行周期内,只调用一次asyncio.run
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
end_time = time.time()
# 因为await是同步调用,所以crawl_page(url)在当前调用结束之前,是不会触发下一次调用的。所以耗时是一样的。
print("Wall Time: {}".format(end_time-start_time))
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.018541812896729
但是我们发现了耗时还是10s,这是为什么呢?因为await是同步调用。因此,在crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。相当于我们用异步接口写了个同步代码。
那现在应该怎么做呢?
其实很简单,我们需要用到协程中的一个重要概念,任务(Task)
import time
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):
# 有了协程对象后,可以通过asyncio.create_task来创建任务。任务创建后很快会被调度执行,这样,代码也不会阻塞在任务这里。
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
# 等所有任务都结束才行
for task in tasks:
await task
start_time = time.time()
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
end_time = time.time()
print("Wall Time: {}".format(end_time-start_time))
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: 4.003253936767578
这个时候我们就看到效果了,运行总时长等于运行时长最长的爬虫。
其实,对于执行tasks,还有另一种方法,最终效果是一样的:
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
# *tasks解包列表,将列表变成了函数的参数;与之对应的是,**dict将字典变成了函数的参数。
await asyncio.gather(*tasks)
协程是如何运行的呢?
我们先来看一段代码
import time
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('await worker_1')
await task2
print('await worker_2')
start_time = time.time()
asyncio.run(main())
end_time = time.time()
print("Wall Time: {}".format(end_time-start_time))
before await
worker_1 start
worker_2 start
worker_1 done
await worker_1
worker_2 done
await worker_2
Wall Time: 2.0052011013031006
那么这段代码的运行过程是怎样的呢(只针对协程运行部分)?
- asyncio.run(main()),程序进入main()函数,事件循环开启;
- task1和task2任务被创建,并进入事件循环等待运行;运行到print,打印before await;
- await task1执行,用户选择从当前主任务中切出,事件调度器开始调度worker_1;
- worker_1开始运行,运行print输出worker_1 start,然后运行到await asyncio.sleep(1),从当前任务切出,时间调度器开始调度worker_2;
- worker_2开始运行,运行print输出worker_2 start,然后运行到await asyncio.sleep(2),从当前任务切出;
- 以上所有事件的运行事件,都应该在1ms到10ms之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
- 1s后,worker_1的sleep完成,事件调度器将控制权重新传给task1,输出worker_1 done,task1完成任务,从事件循环中退出;
- await task1完成,事件调度器将控制器传给主任务,输出await worker_1,然后再await task2处继续等待;
- 2s后,worker_2的sleep完成,事件调度器将控制权重新传给task2,输出worker_2done,task2完成任务,从事件循环中退出;
- 主任务输出await worker_1,协程全任务结束,事件循环结束。
协程的一些特殊处理
那如果我们想给某些协程任务限定运行时间,一旦超时就取消,或者某些协程运行出现错误,这些情况又该怎么做呢?
import time
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():
task1 = asyncio.create_task(worker_1())
task2 = asyncio.create_task(worker_2())
task3 = asyncio.create_task(worker_3())
await asyncio.sleep(2)
task3.cancel()
# 不设置return_exceptions=True,会导致其他还没被执行的任务会被全部取消掉
res = await asyncio.gather(task1, task2, task3, return_exceptions=True)
print(res)
start_time = time.time()
asyncio.run(main())
end_time = time.time()
print("Wall Time: {}".format(end_time-start_time))
[1, ZeroDivisionError('division by zero'), CancelledError()]
Wall Time: 2.009469747543335
协程实现回调函数
借助add_done_callback()函数:
import time
import asyncio
async def crawl_page(url):
print('crawling {}'.format(url))
sleep_time = int(url.split('_')[-1])
await asyncio.sleep(sleep_time)
return 'OK {}'.format(url)
async def main(urls):
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
# 对task对象调用add_done_callback()函数,即可绑定特定回调函数。回调函数接受一个future对象,可以通过future.result()来获取协程函数的返回值
task.add_done_callback(lambda future: print("result: ", future.result()))
await asyncio.gather(*tasks)
start_time = time.time()
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
end_time = time.time()
print("Wall Time: {}".format(end_time-start_time))
crawling url_1
crawling url_2
crawling url_3
crawling url_4
result: OK url_1
result: OK url_2
result: OK url_3
result: OK url_4
Wall Time: 4.003777027130127
如何理解await
协程里主要就是对await这个关键字的理解,async表示其修饰的是协程任务即task,await表示执行到这一句,task会在此处挂起,然后调度器去执行其他task,当这个挂起的部分处理完,会调用回调函数告诉调度器我已经执行完了,那么调度器就返回来处理这个task的余下语句。
所以使用者要提前知道一个任务的哪个环节会造成I/O阻塞,然后把这个环节的代码异步化处理,并且通过await来标识在任务的该环节中断该任务执行,从而去执行下一个事件循环任务。这样可以充分利用CPU资源,避免CPU等待I/O造成CPU资源白白浪费。当之前任务的那个环节的I/O完成后,线程可以从await获取返回值,然后继续执行没有完成的剩余代码。
由上面分析可知,如果一个任务不涉及到网络或磁盘I/O这种耗时的操作,而只有CPU计算和内存I/O的操作时,协程并发的性能还不如单线程loop循环的性能高。
Asynico原理
Asysnico和其他Python程序一样,是单线程的,它只有一个主线程,但是可以进行多个不同的任务(task)。这里的任务,就是特殊的future对象。这些不同的任务,被一个叫作event loop的对象所控制。
我们可以假设任务只有2个状态:预备状态(任务目前空闲,但随时待命准备运行。)以及等待状态(任务已经运行,但等待外部的操作完成,比如I/O操作)。
在这种情况下,event loop会维护两个任务列表分别对应两个状态,并选取预备状态的一个任务(选择哪个任务跟其等待时间长短、占用资源等等有关),使其运行,一直到这个任务把控制权交还给event loop为止;当任务把控制权交还给event loop时,event loop会根据其是否完成,把任务放到预备或等待状态的列表,然后遍历等待状态列表的任务,查看他们是否完成。
- 如果完成,则将其放到预备状态的列表;
- 如果未完成,则继续放在等待状态的列表。 而原先在预备状态列表的任务位置依旧不变,因为它们还未运行。这样,当所有任务都被重新放置在合适的列表后,新的一轮循环又开始了~直至所有任务完成。
值得一提的是,对于Asynico来说,它的任务在运行时不会被外部的一些因素打断,因此Asyncio内的操作不会出现race condition(竞争风险)的情况,这样就无需担心线程安全的问题。
# 只有aiohttp库兼容Asyncio,requests不兼容。这也是Asyncio的缺陷之处。
import aiohttp
import time
import asyncio
# Async和await关键字是Asyncio的最新写法,表示这个语句/函数是non-block的。
async def download_one(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
print('Read {} from {}'.format(resp.content_length, url))
async def download_all(sites):
# asyncio.create_task(coro)表示对输入的协程coro创建一个任务,安排它的执行,并返回此任务对象。
tasks = [asyncio.create_task(download_one(site)) for site in sites]
# asyncio.gather(*tasks)表示在event loop中运行aws序列的所有任务
await asyncio.gather(*tasks)
def main():
sites = [
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.jj20.com%2Fup%2Fallimg%2Ftp01%2F1ZZQ20QJS6-0-lp.jpg&refer=http%3A%2F%2Fimg.jj20.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483214&t=bd84c2a586297fbb979c686f04a96044',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.jj20.com%2Fup%2Fallimg%2Ftp05%2F1Z9291TIBZ6-0-lp.jpg&refer=http%3A%2F%2Fimg.jj20.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483214&t=aa7c006cf9128d8218af0e9556a8ed98',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.jj20.com%2Fup%2Fallimg%2F1113%2F0F420110430%2F200F4110430-6-1200.jpg&refer=http%3A%2F%2Fimg.jj20.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483214&t=3eddaac284aa313f76dacfc8667d18dc',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.jj20.com%2Fup%2Fallimg%2F1113%2F041620104229%2F200416104229-2-1200.jpg&refer=http%3A%2F%2Fimg.jj20.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483214&t=2736914ba8f6ea000c3ee64c39f64277',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic.jj20.com%2Fup%2Fallimg%2F911%2F121915124R8%2F151219124R8-6-1200.jpg&refer=http%3A%2F%2Fpic.jj20.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483214&t=c802dfa1c721cdd5543b174f2ab07c91',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fitem%2F202003%2F29%2F20200329043918_2FUvk.thumb.400_0.gif&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=1d606264ab022e1f8582d5c8e8790914',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg95.699pic.com%2Fphoto%2F40142%2F4204.gif_wh860.gif&refer=http%3A%2F%2Fimg95.699pic.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=650b430eb5fcf8f23c6c4c9332803a60',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fitem%2F202002%2F15%2F20200215151942_iyvmq.thumb.1000_0.gif&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=b713c2a3fd7f0381a19d79d12d780fc2',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fitem%2F201705%2F16%2F20170516165553_TaRwF.gif&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=39a84b14fe960db197750014947a861f',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimglf3.nosdn0.126.net%2Fimg%2FWEFHU2lQKzFVM1lGbWFTcG84YXdudFl2dnhPN3ZSY0g4UFRzSlpzMjZ1TGs5MEF4YTRYbjN3PT0.gif&refer=http%3A%2F%2Fimglf3.nosdn0.126.net&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=7e9c90e6ebc7bd932cb5eb925ca06498',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fmedia.giphy.com%2Fmedia%2FcPxRDvlSj9QKA%2Fgiphy-tumblr.gif&refer=http%3A%2F%2Fmedia.giphy.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=2b7752e23ac39b832736fd059f541c82',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fhbimg.b0.upaiyun.com%2Fb6fd9d520826f523488949a42f5f7b35a87e63724f3a4-W31Fzy_fw658&refer=http%3A%2F%2Fhbimg.b0.upaiyun.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=534378e7315167924e7a090894748e29',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fhbimg.b0.upaiyun.com%2F7e92c2317e9787bd1af93a66a4321cec226b042a290dd0-MXdT3e_fw658&refer=http%3A%2F%2Fhbimg.b0.upaiyun.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=5dd992846c0b426fcd4a512b52f652e7',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.soogif.com%2FPf9pmVpjVDFHsZB6yqk16izd6XVMHkOx.gif&refer=http%3A%2F%2Fimg.soogif.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=43c9e6f87f296aff5beb2ffda2d31fdb',
'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fc-ssl.duitang.com%2Fuploads%2Fitem%2F201802%2F17%2F20180217224704_EiyxJ.thumb.400_0.gif&refer=http%3A%2F%2Fc-ssl.duitang.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1651483691&t=ce740f1abb9656a3fb56135c2958660a'
]
start_time = time.perf_counter()
# asycnio.run(coro)是Asyncio的root call,表示拿到event loop,运行输入的coro,直到它结束,最后关闭这个event loop。
asyncio.run(download_all(sites))
end_time = time.perf_counter()
print("Download {} sites in {} seconds".format(len(sites), end_time-start_time))
if __name__ == '__main__':
main()
协程和线程的区别
主要在于两点:
- 协程为单线程;
- 协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
遇到实际问题,如何选择多线程和Asyncio呢?
- 如果是I/O bound,并且I/O操作很慢,需要很多任务/线程协同实现,那么使用Asyncio更合适。
- 如果是I/O bound,但I/O操作很快,只需要有限数量的任务/线程,那么使用多线程就可以了。
- 如果是CPU bound,则需要使用多进程来提高运行效率。