python协程

99 阅读9分钟

什么是协程?

协程是实现并发编程的一种方式。事件循环启动一个统一的调度器,让调度器来决定一个时刻去运行哪个任务,于是省却了多线程中启动线程、管理线程、同步锁等各种开销。同一时期的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

那么这段代码的运行过程是怎样的呢(只针对协程运行部分)?

  1. asyncio.run(main()),程序进入main()函数,事件循环开启;
  2. task1和task2任务被创建,并进入事件循环等待运行;运行到print,打印before await;
  3. await task1执行,用户选择从当前主任务中切出,事件调度器开始调度worker_1;
  4. worker_1开始运行,运行print输出worker_1 start,然后运行到await asyncio.sleep(1),从当前任务切出,时间调度器开始调度worker_2;
  5. worker_2开始运行,运行print输出worker_2 start,然后运行到await asyncio.sleep(2),从当前任务切出;
  6. 以上所有事件的运行事件,都应该在1ms到10ms之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
  7. 1s后,worker_1的sleep完成,事件调度器将控制权重新传给task1,输出worker_1 done,task1完成任务,从事件循环中退出;
  8. await task1完成,事件调度器将控制器传给主任务,输出await worker_1,然后再await task2处继续等待;
  9. 2s后,worker_2的sleep完成,事件调度器将控制权重新传给task2,输出worker_2done,task2完成任务,从事件循环中退出;
  10. 主任务输出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()

协程和线程的区别

主要在于两点:

  1. 协程为单线程;
  2. 协程由用户决定,在哪些地方交出控制权,切换到下一个任务。

遇到实际问题,如何选择多线程和Asyncio呢?

  • 如果是I/O bound,并且I/O操作很慢,需要很多任务/线程协同实现,那么使用Asyncio更合适。
  • 如果是I/O bound,但I/O操作很快,只需要有限数量的任务/线程,那么使用多线程就可以了。
  • 如果是CPU bound,则需要使用多进程来提高运行效率。