浅析协程

前言

以前我们的程序都是单线程,只有一个控制流,在像Web服务这样的应用里不能同时服务多个用户。后来我们使用多线程,一个用户由一个线程全程负责,CPU根据时间片在线程之间切换,只要CPU切换得够快,用户就感受不到延迟。

但是多线程切换是由操作系统调度的,我们的应用代码无法控制。虽然说,被IO操作阻塞的线程,内核会把它挂起,不参与线程切换。但是线程也不能无限增加,否则CPU时间就花在线程切换和挂起唤醒上了,真正运行代码的时间就少了。

这时候我们就会想,多线程不就是多个控制流嘛,能不能只有一个线程,但有多个控制流,什么时候切换由我们自己决定。当然可以,这就是 **协程(Coroutine)**。

协程和线程的区别

线程由操作系统抢占式调度,一旦开启就不会停下。而协程可以主动暂停、让出。

协程最核心的点:执行到一半的函数或程序片段能够被挂起,稍后再在挂起的地方恢复

挂起和恢复是应用程序自己控制的。所谓协程,协作式线程也。协程通过主动挂起,让出运行权来实现协作,因此当我们在讨论协程时,我们讨论的是一种程序控制流程的机制。


Python 中的协程

generator

Python 的协程是通过 生成器(generator) 来实现的。如果一个函数定义中包含 yield 关键字,那么这个函数就不再是一个普通函数,而是一个generator

generator 和 函数的区别

先来看函数

1
2
3
4
5
6
7
8
9
10
def funny():
print(1)
print(5)
print(8)

k = funny() # k是 funny 调用的结果, funny 调用了

g = funny # g 指向 funny ,funny没有调用
g() # g() 等同于 funny(), 函数调用了

g = funny 说明 g 指向了 funny ,但没有调用, g() 才是调用。但是如果函数里有 yield 关键字,情况就不同了:

1
2
3
4
5
6
7
8
9
10
def funny():
yield 1
yield 5
yield 8


g = funny()
next(g) # 1
next(g) # 5
next(g) # 8

这里 funny() 是一个 generator,并不是函数调用,所以这里 funny 并没有执行。g 是一个 generator 。执行 next(g) 会返回 yield 后面的值,下一次 next(g) 时,会从上一次 yield 的地方接着往下执行,直到又遇到 yield 又返回。

上面的 next(g) 还可以写成 g.send(None),是一样的。

1
2
3
4
5
6
7
8
9
10
def funny():
yield 1
yield 5
yield 8


g = funny()
g.send(None) # 1
g.send(None) # 5
g.send(None) # 8

yield 接收参数

g.send(None) 调用 generator (在这个例子中,即 funny()),实际上,generator 还可以接收参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
def funny():
param = yield 5 # 第 2 步 yield 5,返回5 | 第 5 步 param = 666
print(param) # 第 6 步 打印 666
yield 8 # 第 7 步 yield 8


g = funny()

a = g.send(None) # 第 1 步
print(a) # 第 3 步,打印 5

b = g.send(666) # 第 4 步
print(b) # 第 8 步,打印 8

第一次启动 generator 时,只能用 None 作参数。第二次就可以传参了。输出结果为:

1
2
3
5
666
8

执行过程如下:

  1. a = g.send(None),进入 funny()
  2. 协程执行 yield 5,返回5
  3. 主程序print(a),打印了5
  4. 主程序g.send(666)
  5. 协程从 yield 5 处继续执行,注意,yield 5 之后,不是 print(param),而是赋值语句 param =
  6. print(param),把 666 打印出来。
  7. yield 8 ,返回 8
  8. 主程序print(b),打印了8

Python协程常用范式

比如我有一个 +1 服务,每次 send 就把参数 + 1,实现如下,关键是要理解,第一次会直接 yield response,第二次开始,每次都是从 param = (send的参数) 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def plus_one():
response = 'init..'
while True:
param = yield response
if not param:
return
response = param + 1
print(response)


po = plus_one()
po.send(None)
po.send(1)
po.send(55)
po.send(108)

Python协程实现生产者消费者模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def consumer():
r = ''
while True:
n = yield r
if not n:
return
print('[CONSUMER] Consuming %s...' % n)
r = '200 OK'

def produce(c):
c.send(None)
n = 0
while n < 5:
n = n + 1
print('[PRODUCER] Producing %s...' % n)
r = c.send(n)
print('[PRODUCER] Consumer return: %s' % r)
c.close()

c = consumer()
produce(c)

例子来自廖雪峰的 Python 教程,就不多说了,自己到 PyCharm 里调试一下,很快就能明白执行过程。

asyncio 和 async/await

asyncio 是 Python 3.4 引入的标准库, async/await 则是 Python 3.5 引入对使用 asyncio 更好的语法。

简而言之,当我们有多个任务,可以丢到 asyncio 模块的 EventLoop 去,当其中某个任务遇到IO等阻塞操作时,线程不会等待,而是执行 EventLoop 里的下一个任务。

Python 3.4

1
2
3
4
5
6
7
8
9
10
11
@asyncio.coroutine
def hello():
print("Hello world!")
r = yield from asyncio.sleep(1)
print("Hello again!")

# 获取EventLoop:
loop = asyncio.get_event_loop()
# 执行coroutine
loop.run_until_complete(hello())
loop.close()

Python 3.5

假设我们有两个 hello() 任务要执行,一个中间会阻塞1秒,另一个中间会阻塞2秒。如果用串行编程,则至少需要3秒。但是如果用协程,在第一个任务阻塞时,第二个任务可以立即开始,这样就节省了1秒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import asyncio
import time


async def hello(time):
print(f"Hello world! wait {time} s")
r = await asyncio.sleep(time)
print(f"Hello again! come back from {time} s")


async def main():
print(f"started at {time.strftime('%X')}")
await asyncio.gather(hello(1), hello(2))
print(f"finished at {time.strftime('%X')}")

asyncio.run(main())

输出(只用了2秒)

1
2
3
4
5
6
started at 21:50:36
Hello world! wait 1 s
Hello world! wait 2 s
Hello again! come back from 1 s
Hello again! come back from 2 s
finished at 21:50:38

Go 中的协程

待补充


Kotlin 中的协程

待补充


参考: