这是David Beazley 在 Pycon 2009 做的讲座,下文是初步的翻译
总体概述
- 协程是什么?
- 我们可以用协程做什么?
- 我们应该在意协程吗?
- 使用协程是否是一个好主意?
图片概述
头部爆炸指数图表, 随着本文的持续推进, 难度逐渐上升
纵轴分别是 起点, 引起头疼, 开玩笑(基本不可能)
在继续往下阅读时必须对生成器和生成器表达式非常熟悉, 关于生成器可以参考作者在08年PyCon 的演讲
协程和生成器
- 在 python2.5 版本中, 生成器添加了一些新的功能来支持“协程”
- 其中最特别的是一个send()方法
- 在一些python的入门指南书籍中, 这个部分可能是占用最少篇幅的一部分,因为它显然没有任何用处。。
ohh, 你现在可以将值传递给生成器去生成fibonacci数列
def fibonacci(max):
n, a, b = 0, 0, 1
while n < max:
a, b = a + b, a
yield a
n += 1
声明
- 协程, 可能是最晦涩的一种python特性
- 并发, 在计算机科学中最复杂的话题之一
- 这篇文章将两者混合在一起
进一步声明
作为一个80年代到90年代的程序员, 在python出现协程之前, 我从来没有用过任何一种其他语言支持协程的
在60年代到70年代,可以随处可见协程工作的身影, 但是后来 被线程和延续性等其他方式逐渐替代
我想知道这里是否有什么特殊原因使得python和其他语言重新关注协程?
延续性: 计算续体(continuation)是计算机程序的控制状态的一种抽象表现。 延续性实化了程序状态信息。可以理解为,一个计算续体以数据结构的形式表现了程序在运行过程中某一点的计算状态,相应的数据内容可以被编程语言访问,而不是被运行时环境所隐藏掉。这对实现编程语言的某些控制机制,如异常处理、协程、生成器非常有用。
计算续体包含了当前程序的栈(包括当前周期内的所有数据,也就是本地变量),以及当前运行的位置。一个计算续体的实例可以在将来被用做控制流,被调用时它从所表达的状态开始恢复执行。 from wikipedia
更进一步声明
- 我是一个中立的第三方
- 我与PEP-342没有任何关系(PEP 342 – Coroutines via Enhanced Generators)
- 这边不推荐任何的库和框架
最后声明
- 这个不是学术演讲
- 没有现有技术的概括
- 没有编程语言的理论
- 没有锁的相关证明
- 这里没有fibonacci
- 实际应用是主要的关注点
part1 生成器和协程的简介
generator functions
yield produces a value but suspends the function,
Instead of returning a value, it generate a series of values
一个小练习
让我们写一个python版本的 ‘tail -f’
see https://github.com/mowangdk/leetcode_question python_related
用生成器实现流水线程序
- 生成器应用之一就是用来建立处理流水线
- 与Unix的管道非常相似
input_sequence –> generator –> generator –> generator –> for x in s
流水线例子
打印所有的包含‘python’的server-log。
yield 作为一个表达式
在python2.5里面, 对yield声明进行了轻微修改, 你现在可以将yield当做表达式来用
def grep(pattern):
print "looking for %s" % pattern
while True
line = (yield)
if pattern in line:
print line
协程
如果你更普遍的使用yield, 那么你就会得到一个协程,协程做的不是生产数据。 相反,方法将会暂停,直到一个新的value send进去。
g = grep("python")
g.next()
Looking for python
g.send("Yeah, but no, but yeah, but no")
g.send("A series of tubes")
g.send("python generators rock")
python generators rock
send进去的value,将由(yield)返回
协程执行
协程执行与生成器相同, 当你调用一个协程什么都不会发生, 它只会返回给你一个result去调用next()和send()
g = grep("python") # 注意, 这里没有任何的output
g.next()
looking for python
协程启动
所有的协程必须由首次调用(.next() or .send(None))来启动,这个操作将执行协程到第一个(yield)处。这时协程已经随时准备好接收数据了
使用装饰器
首次调用.next()很容易忘记, 所以我们可以将协程用装饰器包装起来
def coroutine(func):
def start(*args, **kwargs):
cr = func(*args, **kwargs)
cr.next()
return cr
return start
关闭协程
协程关闭可以被捕捉, 通过(GeneratorExit)
g = grep('python')
g.close()
def grep(pattern):
print "looking for %s" % pattern
try:
while True:
line = (yield)
if pattern in line:
print line
except GeneratorExit:
print "going away . Goodbye"
你不能忽略这个异常, 只有清理和返回合理的做法
抛出异常
异常可以在协程内部被抛出来
g = grep("python")
g.next()
looking for python
g.send("python generators rock")
python generators rock
g.throw(RuntimeError, "you're hosed")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 4, in grep
RuntimeError: You're hosed
异常会被yield 表达式抛出, 可以以通常方式处理
小插曲
- 尽管有一些相似之处, 从根本上来说协程和生成器是两个不同的概念
生成器生成值, 协程一般会消费值
- 很容易被误解, 因为方法也就是协程,有时候被描述为
一种在迭代中调整生成器的行为方式的方法(例如, 重置value)
def countdown(n):
print "counting down from : {}".format(n)
while n >= 0:
new_value = (yield n)
if new_value is not None:
n = newvalue
else:
n -= 1
c = countdown(5)
for n in c:
print n
if n == 5:
c.send(3)
协程并不与迭代相关联
在协程里有一种通过yield 生产数据的方式, 但是这个并不依赖于迭代
part2 协程,流水线 和 数据流
处理流水线
- 协程可以用来设置管道
send() -> coroutine -> send()-> coroutine -> send() -> coroutine
你可以将协程连接在一起, 用send()操作通过管道来push 数据
流水线源
- 流水线需要一个初始源(一个生产者)
source -> send() -> coroutine -> send()
def source(target):
while not done:
item = produce_an_item()
...
target.send(item)
...
target.close()
这个生产者通常不是协程
管道接收器
- 流水线一定也会有一个终点(sink)
send() -> coroutine -> send() -> sink
@coroutine
def sink():
try:
while True:
item = (yield)
...
except GeneratorExit:
# Done
source & sink example
我们来模拟unix的 ‘tail -f’ 的source 和 sink
source_follow() -> send() -> sink_follow()
source_follow通过读文件中的line,和将line推到sink_follow()中来驱动整个协程
流水线过滤器
- 中间阶段既接收数据又发送数据
send() -> coroutine -> send()
- 这个过程通常执行某种数据转换,过滤,路由等功能
@coroutine
def filter(target):
while True:
item = (yield)
# transform/filter item
...
# send it along to the next stage
target.send(item)
filter example
- 一个 grep 过滤协程
@coroutine
def grep(pattern, target):
while True:
lien = (yield) # receive a line
if pattern in line:
target.send(line) # send to next stage
小插曲
- 协程其实是生成器的反转
generators/iteration
input_sequence -> generator -> generator -> generator -> for x in s
coroutines
source -> send() -> coroutine -> send() -> coroutine
生成器以遍历的方式通过流水线pull data, 协程以send()方法向流水线push data
分支
- 通过协程, 你可以向不同的目的地发送数据
source 只是简单的send数据, 进一步的路由可以是任意复杂的
广播例子
@coroutine
def broadcast(targets):
while True:
item = (yield)
for target in targets:
target.send(item)
f = open('access-log')
source_follow(f, broadcast([grep('python', sink_follow(),
grep('ply', sink_follow()),
grep('swig', sink_follow())]))
or
sink = sink_follow()
source_follow(f, broadcast([grep('python', sink,
grep('ply', sink),
grep('swig', sink)]))
小插曲
- 协程提供了一种比简单的迭代器更加强大的数据路由方式
- 如果你建立了一个简单数据处理集合, 你可以将他们粘合成复杂的管道,分支结构等
- 当然这个有一些限制
协程vs对象
协程某种程度上与面向对象设计模式在调用的简单处理对象的方式比较相似
oo version
class GrepHandler(object):
def __init__(self, pattern, target):
self.pattern = pattern
self.target = target
def send(self, line):
if self.pattern in line:
self.target.send(line)
coroutine
def grep(pattern, target):
while True:
line = (yield)
if pattern in line:
target.send(line)
- 协程是一个函数定义
- 如果你定义一个处理类
- 你需要定义一个类
- 需要定义两个方法
- 或许有一个基类和包引用
协程的方式更加快, 而且写起来更加简单
part3 协程和事件分发
处理事件
- 协程可以用来写各种处理事件流的组件
问题
芝加哥运输局在它名下的大多数的公交上装上了gps系统,来实时监控bus的位置,你可以通过一个巨大的xml表来观察所有巴士的实时数据
我们用sax来解析xml
sax_handler
import xml.sax
class EventHandler(xml.sax.ContentHandler):
def __init__(self, target):
self.target = target
def startElement(self, name, attrs):
self.target.send(('start', (name, attrs._attrs)))
def characters(self, text):
self.target.send(('text', text))
def endElement(self, name):
self.target.send(('end', name))
这个类没有做任何事情,只是将事件发送给了target
事件处理(handler)
对event事件流进行处理, 例子, 将bus数据转换成字典
def buses_to_dicts(target):
while True:
event, value = (yield)
# look for the start of a <bus> element
if event == 'start' and value[0] == 'bus':
busdict = dict()
fragment = list()
# capture text of inner elements in a dict
while True:
event, value = (yield)
if event == 'start':
fragment = list()
elif event == 'text':
fragment.append(value)
elif event == 'end':
if value != 'bus':
busdict[value] = ''.join(fragment)
else:
target.send(busdict)
break
上述代码是通过实现一个简单的状态机来工作的, 协程对这方面的处理是完美的
- 状态A, 收集,查找bus信息
- 状态B, 收集bus状态属性
过滤元素
@coroutine
def filter_on_field(fieldname, value, target):
while True:
d = (yield)
if d.get(fieldname) == value:
target.send(d)
filter_on_field('route', "22", target)
处理元素
@coroutine
def bus_locations():
while True:
bus = (yield)
print "%(route)s,%(id)s,\"%(direction)s\","\"%(latitude)s,%(longitude)s" % bus
调用
xml.sax.parse('allroutes.xml',
EventHandler(
buses_to_dicts(
filter_on_field('route', "22",
filter_on_field('direction', 'north bound', bus_locations())))))
part4 从数据处理到并发编程
到现在为止。。
- 协程与生成器十分相似
- 你可以将多个小的功能处理单元连接在一起
- 你可以通过设置管道,数据流等方式来处理数据
- 你可以使用协程来编写具有棘手的执行方式的代码
- 但是,协程还可以做更多的事情
共同的主题
- 你将数据发送给协程
- 你将数据发送给线程(通过队列)
- 你将数据发送给进程(通过消息传递)
- 协程天然的被并入与线程和分布式系统相关的问题
基本并发
- 你可以将协程打包进协程中或者将协程封装到子进程中
@coroutine
def threaded(target):
messages = Queue()
def run_target():
while True:
item = messages.get()
if item is GeneratorExit:
target.close()
return
else:
target.send(item)
Thread(target=run_target).start()
try:
while True:
item = (yield)
message.put(item)
except GeneratorExit:
messages.put(GeneratorExit)
- 首先声明一个消息队列
- 之后, 一个线程,不断去轮询从消息队列中拉取数据,并且将拉取到的数据给target
- 从外部获取数据并且将其推送到线程中
- 当外部没有数据传进来的时候, 确保程序被正确关闭
xml.sax.parse("allroutes.xml",
EventHandler(
buses_to_dicts(
threaded(
filter_on_field('route', '22',
filter_one_field('direction', 'north bound', bus_locations())))))
注:新加的线程使这个例子慢了 50%
使用子进程
@coroutine
def sendto(f):
try:
while True:
item = (yield)
pickle.dump(tiem, f)
f.flush()
except StopIteration:
f.close()
def recvfrom(f, target):
try:
while True:
item = pickle.load(f)
target.send(item)
except EOFError:
target.close()
- 这只是大致的实现方式, 细节处有很多坑
- 除非你可以cover底层通信的成本,否则不会试着使用这种方式的
使用协程你可以将任务的实现和任务的执行环境分离开来
警告
- 创建一个巨大的协程 线程和进程的混合体是一种创建不可维护代码的好方法
- 它有可能使你的程序变得很慢
- 你需要谨慎的学习这个问题,来判断使用协程是否值得
一些潜在的危险
- 协程中的send() 方法必须是保持同步的
- 如果你在一个已经运行的协程中调用send方法, 那么这个这个程序就会崩溃掉
多线程同时send数据到同一个target中,会引发这种情况
你不能在协程调用中创建循环
- 堆栈sends() 其实是构建了一种调用栈(但是send()并不会返回数据, 直到target yield为止)
- send 不会暂停协程执行
part 5 协程作为任务
任务概念
- 在并发编程中, 一个典型的做法是将一个问题细分为几个“任务”
- 任务有一些必要的特性
- 独立的控制流
- 有内部状态
- 可以被控制(暂停/恢复)
- 任务是可以通信的
我们将从上述四点来判断,协程是否是一个任务
- 首先 协程有自己的控制流,并且只是一系列的声明,与python其他函数一样
- 协程是有自己的内部状态的(局部变量locals), 只要协程没有退出, locals一直存在, 并且协程是会建立一个执行环境的
- 协程也是可通信的,通过send()方法
- 协程是可以控制的, yield 暂停执行, send 恢复执行,close结束执行
我坚信
- 非常清楚的, 协程非常像任务
- 但是协程并没有与threads或者子进程绑定
- 一个问题, 你能不用thread或者process来实现一个多任务吗?
- 如何只用协程来实现多任务?
part 6 操作系统里面的崩溃课程
单核CPU基础上
程序执行
当程序转换成指令执行在cpu上执行的时候, cpu在某一时刻只执行一个指令或者进行任务切换
多任务问题
- cpu 根本不知道什么多任务
- 当然应用程序也不知道
- 但是显然必须有人知道这个事情
- 只能是操作系统了
操作系统
- 操作系统对机器上所有运行着的程序负责
- 就像你观察到的, 操作系统确实允许多个进程同时执行
- 他是通过快速切换多个task来实现的
一个难题
- 当你的cpu在执行你的程序的时候,它是没有在运行操作系统的
- 这里有一个问题, 操作系统是如何在没有运行的情况下去切换一个正在运行的任务的呢?
- 这是”context-switching”问题
traps
指的是当异常或者中断发生时,处理器捕捉到一个执行线程,并且将控制权转移到操作系统中某一个固定地址的机制。
中断和陷阱
- 通常有两种机制来让操作系统重新获取控制权
- 中断 某种硬件信号(数据到达, 计时器, 按键操作等)
- 陷阱 一种软件生成的信号
- 两种情况下, cpu都会暂停现在正在做的工作并且开始执行操作系统
就是在这个情况下, 操作系统会进行任务切换
traps 和操作系统调用
- 底层系统调用通常是陷阱
- 陷阱通常是一种特殊的cpu 指令
read(fd, buf, nbytes) -> mov 0x10(%esp), %edx
mov 0xc(%esp), %ecx
...
int $0x80 # this is trap
当陷阱指令执行的时候程序会在当前点暂停执行, 然后操作系统继续执行
宏观来看
- 现代操作系统是由中断驱动的
- 操作系统将你的程序放在cpu上运行
- 程序会一直运行直到遇见一个trap(system call)
- 程序暂停, os开始运行
- 一直重复这个流程
任务切换
在每个trap发生的时候, 操作系统来切换不同的任务
洞察力
- yield 就相当于一种trap
- 但是不是真正的trap
- 当生成器方法执行到了yield, 它会立即停止执行
- cpu执行权会被转移到任何可以使这个生成器执行的地方(隐式的)
- 如果你将yield看成trap, 那么你可以用python创建一个多任务的mini操作系统
part 7 让我们来建立一个操作系统吧
see https://github.com/mowangdk/leetcode_question python_related/build_python_os
part 8 栈问题
限制
- 当你使用协程的时候, 你不能编写产生子程序的函数
def Accept(sock):
yield ReadWait(sock)
return sock.accept()
def server(port):
while True:
client, addr = Accept(sock)
yield NewTask(handle_client(client, addr))
以上代码控制流会完全乱掉
yield语句只可以被用来暂停最外层的协程, 你不能将其放入一个嵌套方法里面
def bar():
yield
def foo():
bar()
上述代码bar中的yield无法将foo中的代码暂停
解决方案
- 这里有一种方法可以实现可暂停的子协程
- 但是这个只能在任务调度器中实现
- 必须严格的遵守yield的声明
- 这种技巧被称作“trampolining”
# A subroutine
def add(x, y):
yield x + y
# A function that calls a subroutine
def main():
r = yield add(2, 2)
print r
yield
def run():
m = main()
sub = m.send(None)
result = sub.send(None)
m.send(result)
这里就是就是“蹦床”, 如果你想使用subroutine, 那么所有数据都必须通过调度器
part9 一些最后的话
进一步的主题
对于我们的task scheduler, 还有很多可以改进的点
- task之间的沟通
- 对于阻塞操作的处理(比如数据库的访问)
- 协程多任务和线程
- 异常处理
python的生成器比人们认为的还要复杂的多
- 它可以实现定制的迭代模式
- 它可以实现流水线处理和数据流处理
- 它可以实现事件处理
- 合作多任务
许多教程和文档都没有对生成器进行详细的说明以及深层的探索,到生成fibonacci就结束了
对于协程的性能表现
- 协程有很好的性能表现
协程 vs 线程
- 我不确定使用协程可以比一般的多任务处理要快
- 线程编程已经很好的建立了范例
- python多线程由于GIL的原因, 往往是一个很糟糕的选择
- 但是我不太清楚,自己写一个调度系统是否比让操作系统进行task调度要好
风险
- 协程最初在1960年被开发出来, 然后在这之后静静的死去了
- 也许它是由于一个很好的理由死去了
- 我想一个合理的程序员都会抱怨在生产软件中使用协程是很恶毒的
- 这个时候可以对我们之前的代码,或者其他用协程编写的代码进行code review
注意
- 如果你将要使用协程, 其中非常关键的一点是, 你不可以混用编程范例
- yield的三种使用场景
- 遍历(数据生产者)
- 收数据 (数据消费者)
- trap (合作多任务)
- 一个生成器只用来完成一项功能, 不可以再一个生成器中完成上述多个功能
小心的使用它
- 我认为协程就像是一个高爆炸药
- 我们应该小心的保存它
- 如果创建一个特别纠结混乱的协程,那么他所属的线程或者子进程很有可能崩溃
- 举个栗子, 在我们的操作系统中, 协程是不能访问task和scheduler的, 这就非常好