学林漫录:Python 协程

参考文章

协程的本质

程序变慢的原因

  1. 涉及到同步锁。
  2. 涉及到线程阻塞状态和可运行状态之间的切换。
  3. 涉及到线程上下文的切换。

协程与线程主要区别是它将不再被内核调度,而是交给了程序自己,而线程是将自己交给内核调度。线程本身获得了自己的主导权。

协程本质上就是用户空间下的线程。

协程的实现

当协程执行到 yield 关键字时,会暂停在那一行,等到主线程调用 send 方法发送了数据,协程才会接到数据继续执行。 但是,yield让协程暂停,和线程的阻塞是有本质区别的。协程的暂停完全由程序控制,线程的阻塞状态是由操作系统内核来进行切换。 因此,协程的开销远远小于线程的开销。 【手动滑稽】

yield 可以理解成为,做到这里,然后暂停一下,然后产出结果,等待上一个上下文对自己调度

协程并不是“线程”,理由是: 它并不会参与 CPU 时间调度,并没有均衡分配到时间。

线程的话,在多核心处理器里面,是并行的,你启动一个线程之后你要想控制它,你得做系统调用 thread_cancel 或者最好情况是发送信号告诉线程里面的条件变量让线程自己去退出。简单来说,对用户其实是不可控的,就像小孩子又不是他们父母本人,他们很多行为是不由父母控制的,父母只能告诉他做什么,要不就家庭暴力(目前被证明是违法的,每个人毕竟都是独立的生命个体)。

所以进程可以理解为地球上(操作系统中)每一个生命个体(有自行运行的空间的独立个体),在做着自己特定的事情(每一个程序肯定都是一个从 main 开始的逻辑),然后线程就是每一个生命个体当中自由独立的细胞,每一个细胞也关联了自己对应的生存行为(吞噬细胞以吞噬动作为生)。

对于个人来说,是垂直进行的,可能不明显,但是 CPU 从古至今,从单核心发展起来的,一台电脑需要跑好几个程序,不可能先跑完了一个程序再跑另一个,也就是多任务。

所以,多个协程协作好比就是你一个人其实同时只能做一件事,但是你把几个任务拆成几截来交叉执行。

说明一个问题,两个协程并不是线程,它们根本【既不并发也不并行】的,协程实际上是一个很普通的函数(对于 C 语言理解),或者一个代码块(ObjC),或者子过程(Pasacal Perl),反正就是只是中间加了 yield,让它跑了一半暂停执行,然后产出结果给调度它(这个协程)的父级上下文,如果父级不再需要执行下去了可以先调用别的函数,等别的 yield 了再 transfer 回去执行这个。

因此: 协程就是一串比函数粒度还要小的可手动控制的过程

线程(Thread)则不一样,Thread 就算其中一个要 STDIN.read 需要从外部 IO 读东西堵塞了,但是都不会影响别的线程运行的,要不然设计线程就没意义了。

如果其它文章说它并发,或许说的是因为协程能把小过程串起来,让人们看起来并发(同时进行)。不过,总上观察,或许你看到很多文章、回答说的协程是用户态的线程,或许他们的理解是这个意思。但不代表协程是线程,其实不是一个概念。协程都没参与多核 CPU 并行处理,协程是不并行的,而线程 在多核处理器上是并行在单核处理器是受操作系统调度的,所以本身就差太远了。如果协程真的是线程,真的那么好用,那么 Android iOS 开发早就用多协程而不是多线程来处理 HTTP 请求了。咱们来论证一下这个观点:说协程性能好的,其实真正的原因是因为瓶颈在 IO 上面,而这个时候真正发挥不了线程的作用。IO 瓶颈可以有,但是要注意 IO 是系统调用,这个 IO 不是用户态能处理的,协程是没办法绕开的,所以最终还是给堵了。如果协程真的能处理堵塞问题,那么很多经典的 Unix 网络编程书籍里面应该有多协程模式才对。正确的方案应该是多线程,所以有多进程或多线程服务器的模型,不至于一堵全堵。应该用 select / poll / epoll / kqueue ,让系统调用来应对系统调用,像 epoll 让 IO 堵塞的调用加以消息通信回调来解决。针对有朋友给出的疑问关于协程不能并发和并行这点不同意,可能在某些语言里确实不能并行。具体实现,跟你用的是 Qt 的 coroutine 还是 goroutine 的 implementation 有差异。但是,我再次敲黑板点题: 我这里只讨论纯粹的协程,协程是一组过程,至于你想给它加入 上下文信息(userinfo、context)做成有栈协程,还是混入线程来进行并发时候切换与启停来实现多个协程并发,这个跟协程它本身没有任何关系。我的回答也是为了让大家剥离 并发 并行 协程 三个概念而阐述的。另外,协程的发明主要是为了解决 Concurrency(并发)问题,而线程的发明主要解决的 Parallelism(并行)问题。

现代协程已经逐渐衍生出新的概念—— GeneratorPromise,不一定要用某些特定的语法或者库来实现。很多语言更倾向于做成一组执行队列,名词是把 result 传入next,不断 yield 迭代到下一层。ES 6 的 Promise 就解决了 callback hell,当然用协程来解决也是可以的,暂停执行并把上下文的 result 变量判断一下,然而 PromiseGenerator 会显得更自然现代风格一些。大多数对 coroutine 用的是 ucontext 来实现。 Go 的话它针对不同 platform,include 了不同的 ucontext,调用 ucontext init 会获得一个 context 描述符,这个协程 new 出来之后就是一个变量,既然是变量那肯定就有 atomic 的问题。

所以才会有人说协程也要锁,其实你锁的不是 coroutine 而是锁 ucontext 的一系列操作。

单核并发这种 makecontext 然后并手动 switch 调度往往复杂,而且并不能提升性能,只是为了通过调度来实现和模拟并发,现实中更多人用来减少 callback。然而并不比直接 callback 带来的上下文保存各种操作带来的损耗要少。所以上文说说多线程的方案会在现代更占有性能优化优势。因为: 既然你能开一个过程然后让它切换,还不如让它自动的在多个核心参与时间分配调度。对于多核处理器的演进和上层业务逻辑的需求,协程 不再具有优势,于是逐渐演变为用来改进词法编程方式的一种用例,它的衍生品 GeneratorPromise 更简明好用。

协程的好处

  • 用同步的逻辑,写由协程调度的回调
  • 协程和性能无关,只是美化代码流程,让异步看起来像同步那样。
  • 对于计算密集的情况,多线程是最优解(把显卡计算视为多线程之一种),对于 io 密集任务,回调是最优解。

协程样例

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


@asyncio.coroutine
def hello():
print("hello, world")

r = yield from asyncio.sleep(100)

print("hello, again!")


loop = asyncio.get_event_loop()
loop.run_until_complete(hello())
loop.close()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import threading
import asyncio


@asyncio.coroutine
def hello():
print('Hello world! (%s)' % threading.current_thread())
yield from asyncio.sleep(5)
print('hello again! (%s)' % threading.current_thread())


loop = asyncio.get_event_loop()
tasks = [hello(), hello()]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
  1. asyncio 提供了完善的异步 IO 支持;
  2. 异步操作需要在 coroutine 中通过 yield from 完成;
  3. 多个 coroutine 可以封装成一组 Task 然后并发执行。

改进版:

  1. @asyncio.coroutine 替换为async
  2. yield from 替换为await