学习了生成器以后(额…如果你还没学习,请速速去学😝),我们就可以开始学习什么是协程了。要分清二者的联系去差别😯 —— 松言松语
协程
什么是协程
协程(Coroutine,又名微线程)和多线程一样,都是用来执行某一种计算(任务)的,所以协程是一个消费者,它接受生产者发送的消息并处理。
进程/线程/协程
在python中,需要注意python多线程和其他编程语言多线程的差异。
Python的多线程
Python的解释器有一个历史遗留问题:GIL锁,即Global Interpreter Lock。
- 每个进程有有一个独立的GIL锁。
- Python每个线程执行前,都要先获得所在进程的GIL锁,执行100条字节码(python2.x采用ticks计数器,python3.0+改用计时器)后,解释器自动释放GIL锁,未持有GIL锁的线程都处于等待状态。
- 一个进程里的不同线程在同一个时间内只会有一个线程在执行,所以只用到一个CPU核心,所以只是并发而无法并行。
- 若要充分利用多个CPU,充分挖掘CPU的计算能力,仅仅用多线程是行不通的,还得加上多进程。
Java的多线程则不同,Java的多线程是能够用到多个核心的。
堆、栈的对比
内存占用 | 堆 | 栈 | 调度 | |
---|---|---|---|---|
进程 | 总之就是大 | 独立 | 独立 | 操作系统调度 |
线程 | 4M+ | 共享 | 独立 | 操作系统调度 |
协程 | 4k+ | 共享 | 独立 | 代码块中显示调度 |
协程适合的多任务场景
通常有两种类型的多任务场景:CPU密集型、IO密集型。
CPU密集型
要充分利用多核CPU,充分挖掘CPU的计算能力进行大量计算。
瓶颈主要在于CPU的核心数、CPU计算能力、代码效率上,而不在于IO操作;切换任务的开销反而会影响CPU的使用效率。
python是脚本语言,代码效率不算高,所以CPU密集型任务并不太适合用python来处理。
如果要充分利用CPU多个核心,python中可以利用多进程来实现。
常见的场景有:加解密、视频解码等。
IO密集型
花费的时间主要在各种IO操作的阻塞等待上,CPU消耗不高。
常见的场景有:读取文件、网络请求、数据库操作等;
IO密集型的应对方案通常通过任务切换(线程切换、协程切换)来解决:
场景 | 多线程 | 协程 |
---|---|---|
IO密集,适量任务 | 适合。 1. 线程(假设是a)调用IO操作,a进入阻塞状态; 2.自动切换到其他线程处理任务,不浪费CPU; 3. 待数据返回后,a等待时间片(什么时候执行由系统决定)继续执行; |
适合。 1. 配合异步IO使用,防止单线程阻塞,其他协程一起受影响; 2. 代码层直接可以决定时间分配; |
IO密集,大量任务 | 不适合。 1. 线程开销大(一个线程4M+内存); 2. 线程间切换时间较长; |
适合。 1. 协程运行在线程上,内存小; 2. 切换时间短; 3. 配合异步IO使用,以防止单线程阻塞; |
python中的协程
在python中,协程和生成器长得差不多。直接来看示例:
def consumer():
result = ''
print('开始啦')
while True:
yield result
上面定义了一个consumer
,乍一看他就是一个生成器。如果你什么都不做,那事实上它的确就只是一个生成器。只不过这个生成器没什么用处(它只是一直在返回空字符串result
)
现在加上以下代码执行看看:
cc = consumer()
next(cc)
发现只打印了一个“开始啦”,嗯,这的确就是一个没什么用的生成器(当然啦,有没有用取决于里面的代码逻辑)。
但是如果你要向他发消息,那就可以把它变成一个协程。
cc = consumer()
cc.send(None)
这里先临时解释一下,cc.send(None)
意思是启动一个协程。
发现和生成器的执行结果是一样,也是打印了一个“开始啦”。看来所谓生成器和协程,其实只是同一种形式的两种不同用法罢了,只不过是看里面的代码怎么写:
- 生成器是用来生成数据的;
- 协程是用来消费数据的;
下面我们来构造一个稍微像样的协程:把上面的consumer
函数稍微改造一下,然后再多定义一个生产者,用来给consumer
发消息。
# 这是一个协程,协程是一个消费者
def consumer():
result = ''
while True:
# 启动协程后,代码进入循环体,将result返回给生产者,并在yield关键字处等待消息
data_to_consume = yield result
print(f"[消费者]:消费 {data_to_consume} ing")
result = ("生产数据 " + str(data_to_consume) + " 消费完成")
# 这是一个生产者,向协程发送消息
def produce(c):
# 启动协程
c.send(None)
n = 2
while n >= 0:
print(f"[生产者]:生产 {n} 完成")
# 向协程发送消息
result = c.send(n)
print(f"[生产者]:消费者返回 ———— {result}")
n -= 1
# 关闭协程
c.close()
produce(consumer())
执行结果为
[生产者]:生产 2 完成
[消费者]:消费 2 ing
[生产者]:消费者返回 ———— 生产数据 2 消费完成
[生产者]:生产 1 完成
[消费者]:消费 1 ing
[生产者]:消费者返回 ———— 生产数据 1 消费完成
[生产者]:生产 0 完成
[消费者]:消费 0 ing
[生产者]:消费者返回 ———— 生产数据 0 消费完成
在一个线程中来回控制不同任务的执行。这就是协程。
启动协程的两种方式
上面我们提到了用send(None)
来启动协程,其实还有其他方式。
启动协程有两种方式,他们的效果是一样的。假设c
为协程返回的迭代对象
c.send(None)
next(c)
上述两种方式都可以启动协程。
def consumer():
result = ''
print("启动")
while True:
# 启动协程后,代码进入循环体,将result返回给生产者,并在yield关键字处等待消息
data_to_consume = yield result
if not data_to_consume:
return
print(f"[消费者]:消费 {data_to_consume} ing")
result = "生产数据 " + str(data_to_consume) + " 消费完成"
c = consumer()
next(c)
启动协程后,代码会进入协程中的循环体,并且在yield
的位置中等待数据的传入。
上述代码调用 c.send(None)
或 next(c)
的时候,都会打印“启动”。
协程与生成器的对比
项目 | 生成器 | 协程 |
---|---|---|
是否能接收外部传入的数据 | 否。 | 是。用send(数据)。 |
是否能对外生成/返回数据 | 是。用yield。 | 是。用yield。 |
是否需要启动/关闭 | 否。 | 是。需要用send(None)或者next()来启动,用close()来关闭。 |
切换控制权方式/迭代方式 | 调用next() 或者__next()__。 | 调用send()。 |
2.11 异步IO
CPU的处理速度远远高于网络IO和磁盘IO,所以“卡顿”的感觉通常是来自各类IO操作。
-
CPU执行的时候,遇到IO操作,主线程等待IO操作完成以后才继续往下执行,这叫做同步IO。
-
CPU执行的时候,遇到IO操作,主线程不等待IO操作完成,继续往下执行,等IO处理完了,再通知CPU来处理(执行预先注册的回调函数),这叫做异步IO。
利用多线程?
多线程是处理同步IO的常用操作:把同步IO丢到一个独立的子线程去处理,主线程继续往下执行,子线程处理完了再把结果通知给主线程,或者子线程直接执行预先注册好的回调函数。但因为线程比较耗资源以及线程切换的效率问题,所以不宜开过多的线程。
协程不够用?
既然多线程耗资源,也听说协程可以替代多线程,既然已经有了协程,难道就可以为所欲为了吗?
No,别忘了协程是在线程之上的。如果协程调用了耗时的IO操作,其所在线程也一样会阻塞。
要想做到异步IO,需要一种新的机制来支持。在python2.x中,则通常使用Gevent
。python3.4+内置了一个asyncio
库,他们都提供了一种叫做eventloop的机制,来将同步转为异步。
关于python协程的发展可以参考👇这篇文章
[谈谈Python协程技术的演进](https://segmentfault.com/a/1190000012291369)
asyncio
协程如果单独使用,那并没有什么优势,本质上还是一个单线程,但是如果与异步结合,它的真正优势就能够发挥出来了
asycnio中,有两个关键字
- async,用于将一个方法标注为一个协程;
- await,表示等待操作返回;
简单的示例如下所示:
# since python3.4
import asyncio
async def task(n):
print(f"start a task({n})......")
await asyncio.sleep(1)
print(f"back from task({n})......")
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait([task(1), task(2)]))
loop.close()
上述代码中,async
将task
标注为协程。
-
通过
asyncio
拿到一个eventloop
循环队列,依次放入两个协程; -
队列执行
task(1)
的时候,执行到await asyncio.sleep(1)
处,协程task(1)
中断等待于此,eventloop
继续执行循环队列中的其他下协程:task(2)
; -
等
task(1)
和task(2)
的await
返回时,再分别继续往下执行代码;
输出结果为:
start a task(2)......
start a task(1)......
back from task(2)......
back from task(1)......
大致如此,这里不对asycnio库及其方法做更多介绍。