blog
blog copied to clipboard
Python 协程
参考:
- 协程与任务 https://docs.python.org/zh-cn/3.8/library/asyncio-task.html
- 极客时间·景霄 - Python核心技术与实战
- 知乎 -【面试高频问题】线程、进程、协程
- segmentfault - 聊一聊python和golang协程的区别
一、协程简介
Coroutine 协程是子例程的更一般形式。 子例程可以在某一点进入并在另一点退出。 协程则可以在许多不同的点上进入、退出和恢复。
协程,又称微线程,纤程。一句话说明什么是线程:协程是一种用户态的轻量级线程。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。
协程使用发展史:
随着互联网的快速发展,你逐渐遇到了 C10K 瓶颈,也就是同时连接到服务器的客户达到了一万个。于是很多代码跑崩了,进程上下文切换占用了大量的资源,线程也顶不住如此巨大的压力,这时, NGINX 带着事件循环出来拯救世界了(在高并发下能保持低资源低消耗高性能): 事件循环启动一个统一的调度器,让调度器来决定一个时刻去运行哪个任务,于是省却了多线程中启动线程、管理线程、同步锁等各种开销。
再到后来,出现了一个很有名的名词,叫做回调地狱 callback hell(JavaScript),这种工具完美地继承了事件循环的优越性,同时还能提供 async / await 语法糖,解决了执行性和可读性共存的难题。于是,协程逐渐被更多人发现并看好,也有越来越多的人尝试用 Node.js (JavaScript 是一门单线程的语言,无法多线程实现并发,但可以使用协程实现并发)做起了后端开发。
使用生成器,是 Python 2 开头的时代实现协程的老方法了,Python 3.7 提供了新的基于 asyncio 和 async / await 的方法。
进程、线程、协程 的上下文切换比较
进程process | 线程thread | 协程coroutine | |
---|---|---|---|
切换者 | 操作系统(进程是资源分配的最小单位) | 操作系统(线程是CPU调度的最小单位) | 用户(编程者/应用程序) |
切换时机 | 根据操作系统自己的切换策略,用户不感知 | 根据操作系统自己的切换策略,用户不感知 | 用户自己(的程序)决定 |
切换内容 | 页面全局目录 内核栈 硬件上下文 |
内核栈 硬件上下文 |
硬件上下文 |
切换内容的保存位置 | 内核栈 | 内核栈 | 用户自己的变量(用户栈或堆) |
切换过程 | 用户态-内核态-用户态 | 用户态-内核态-用户态 | 用户态(没有陷入内核态) |
切换效率 | 低 | 中 | 高 |
协程总结:
- 协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
- 协程的写法更加简洁清晰,把 async / await 语法和 create_task 结合来用,足以应对中小级别的并发需求。
- 写协程程序的时候,脑海中要有清晰的事件循环概念,知道程序在什么时候需要(await 以执行下一个事件循环任务)暂停、等待 I/O,什么时候需要一并执行到底。
进程、线程、协程应用场景
- 协程的主要应用场景是 IO 密集型任务,总结几个常见的使用场景:
- IO密集型任务(例如网络调用):线程或协程
- CPU密集型任务:使用多个进程,绕开 GIL 的限制,利用所有可用的CPU核心,提高效率。
- 大并发下的最佳实践:多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。
二、协程示例
示例1:同步(不使用协程)爬取4个URL
import time
import timeit
def crawl_page(url):
print('crawling {}'.format(url))
# 休眠时间取决于 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)
if __name__ == "__main__":
start = timeit.default_timer()
main(['url_1', 'url_2', 'url_3', 'url_4'])
stop = timeit.default_timer()
print('Time:', stop - start)
$ python sync_crawl
# 输出 crawling url_num 后,等待 num 秒后输出对应的 OK url_num
crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
Time: 10.012153034
示例2:异步(使用协程)爬取4个URL
import timeit
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):
# for url in urls:
# await crawl_page(url)
# await 是同步调用,因此,crawl_page(url) 在当前的调用结束之前,
# 是不会触发下一次调用的。这里相当于用异步接口写了个同步代码。
# docs.python.org/zh-cn/3.8/tutorial/datastructures.html#list-comprehensions
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
await task
# 以上for循环也可写作如下。其中,*tasks 解包列表,将列表变成了函数的参数;
# 与之对应的是, ** dict 将字典变成了函数的参数。
# await asyncio.gather(*tasks)
start = timeit.default_timer()
# asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
asyncio.run(main(['url_4', 'url_3', 'url_2', 'url_1']))
stop = timeit.default_timer()
print('Time:', stop - start)
$ python async_crawl
# 立刻输出4个 crawling url_num 后,第1秒后输出 OK url_1,第2秒后输出 OK url_2 ... 第4秒后输出 OK url_4
crawling url_4
crawling url_3
crawling url_2
crawling url_1
OK url_1
OK url_2
OK url_3
OK url_4
Time: 4.00415018