本文继续上一节的话题:异步网络爬虫的实现。
Python 中的生成器
在讲解生成器之前,我们先来回忆一下Python中常规函数的实现。一般一个Python函数也可以称为一个子程序,当Python调用一个子程序的时候,子程序将接管整个程序直到程序返回或抛出一个异常。
def foo():
bar()
def bar():
pass
标准的Python解释器是用C语言写的,因此对Python子程序的调用也就是对C语言函数的调用,Python通过PyEval_EvalFrameEx
管理堆栈并调用Python的字节码。下面是foo函数的字节码:
>>> import dis
>>> dis.dis(foo)
2 0 LOAD_GLOBAL 0 (bar)
3 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
6 POP_TOP
7 LOAD_CONST 0 (None)
10 RETURN_VALUE
在foo
函数里,它会将bar
函数加载到堆栈并调用,之后会从堆栈读取返回值并返回,这里是将None
压栈,读取返回None
。
实际PyEval_EvalFrameEx
碰到一个函数调用字节码,它就会创建一个堆栈开始递归调用,也就是再次生成一个栈进行调用,比如这里的bar
。
理解Python中程序调用的堆栈操作对协程的理解至关重要。Python的解释器本身是一个C语言程序,所以它的堆栈就是常用的堆栈。但是Python堆栈框架上所有操作都是在栈顶操作的,也就是堆栈的生命长于函数的调用。为了更好地理解这一部分,我们来看一下和bar
有关的堆栈:
>>> import inspect
>>> frame = None
>>> def foo():
... bar()
...
>>> def bar():
... global frame
... frame = inspect.currentframe()
...
>>> foo()
>>> # The frame was executing the code for 'bar'.
>>> frame.f_code.co_name
'bar'
>>> # Its back pointer refers to the frame for 'foo'.
>>> caller_frame = frame.f_back
>>> caller_frame.f_code.co_name
'foo'
我们再来看一下生成器函数:
def gen_fn():
result = yield 1
print('Restult of yield: {}'.format(result))
result2 = yield 2
print('Restult of 2nd yield: {}'.format(result2))
return 'done'
当Python编译gen_fn
成字节码的时候,当它碰到yield
时,Python会知道这是一个生成器函数,而不是平常的函数,Python就会生成一个标志来区分这个函数。
>>> # The generator flag is bit position 5.
>>> generator_bit = 1 << 5
>>> bool(gen_fn.__code__.co_flags & generator_bit)
True
在调用生成器函数的时候,Python就会通过之前定义的标志知道当前调用的是一个生成器函数,它不会直接调用这个函数,而是先创建一个生成器:
>>> gen = gen_fn()
>>> type(gen)
<class 'generator'>
Python的生成器包含一个堆栈和相应的一些代码,比如:
>>> gen.gi_code.co_name
'gen_fn'
所有调用gen_fn
的生成器都指向相同的代码,但是它们都有各自的堆栈结构体,它们位于堆栈的顶端,等待被调用。
堆栈结构体有一个指向上次执行指令的指针,最开始这个指针的值为-1,意味着这个生成器没有被调用过。
>>> gen.gi_frame.f_lasti
-1
当使用send
进行调用的时候,生成器执行到第一个yield
并暂停,send
的返回值是1:
>>> gen.send(None)
1
而这时生成器的指令指针的值相对于开始的值的偏移是3(总共56):
>>> gen.gi_frame.f_lasti
3
>>> len(gen.gi_code.co_code)
56
生成器可以在任何时间任何函数中恢复,因为它的堆栈结构体实际并不在栈上:它在堆上。它的调用结构并不是固定的,也不遵循一般函数先进后出的顺序。它是完全自由的。
我们还可以向生成器中发送数据,发送的数据将变为yield
表达式的值,比如我们继续向之前定义的结构体中发送hello
,生成器通过yield
获得hello
的值并继续执行直到yield 2
>>> gen.send('hello')
result of yield: hello
2
生成器中的局部变量也会保存在堆栈的结构体中:
>>> gen.gi_frame.f_locals
{'result': 'hello'}
通过gen_fn
生成的其它生成器也会有自己的堆栈和本地变量。
如果我们再次通过send
调用生成器,生成器会从上次执行到的地方yield 2
继续向下执行,如果执行结束,它会产生一个StopIteration
的异常来通知调用方生成器调用结束。
>>> gen.send('goodbye')
result of 2nd yield: goodbye
Traceback (most recent call last):
File "<input>", line 1, in <module>
StopIteration: done
这个异常还含有一个返回值,同时也是生成器的返回值,这个是在生成器的return
中定义的,比如这里是done
.
通过生成器建立协程
生成器是一个可以暂停,可以传入变量恢复,可以返回值的函数。听起来非常适合之前的异步编程模型,但是代码会十分简洁。我们的目标是建立这样一个“协程”:一个可以和其它子程序有序地合作的子程序。我们写的协程可以看作是Python中自带的异步IO库的简略版本,我们将在程序中加入生成器yield from
代码。
首先我们要告诉协程去等待什么,下面就是一个缩减的版本:
class Future:
def __init__(self):
self.result = None
self._callbacks = []
def add_done_callback(self, fn):
self._callbacks.append(fn)
def set_result(self, result):
self.result = result
for fn in self._callbacks:
fn(self)
这里所有的等待结束之后都是调用set_result
。
之后我们再为fetcher添加相应的协程操作,我们编写一个含有回调的fetch方法:
class Fetcher:
def fetch(self):
self.sock = socket.socket()
self.sock.setblocking(False)
try:
self.sock.connect(('xkcd.com', 80))
except BlockingIOError:
pass
selector.register(self.sock.fileno(),
EVENT_WRITE,
self.connected)
def connected(self, key, mask):
print('connected!')
# And so on....
这个方法首先建立一个连接,然后注册一个回调函数connected
,之后等待连接建立。现在我们将这两步合并:
def fetch(self):
sock = socket.socket()
sock.setblocking(False)
try:
sock.connect(('xkcd.com', 80))
except BlockingIOError:
pass
f = Future()
def on_connected():
f.set_result(None)
selector.register(sock.fileno(),
EVENT_WRITE,
on_connected)
yield f
selector.unregister(sock.fileno())
print('connected!')
现在fetch
变为一个生成器方法,而不是平常的方法,因为代码中有一个yield
,这是就实现了一个等待的功能,这个等待会一直持续到连接建立,连接建立之后就会执行我们的回调on_connected
。
那么回调结束之后呢?怎样恢复生成器?我们需要给协程一个driver
,这里姑且称为task
:
class Task:
def __init__(self, coro):
self.coro = coro
f = Future()
f.set_result(None)
self.step(f)
def step(self, future):
try:
next_future = self.coro.send(future.result)
except StopIteration:
return
next_future.add_done_callback(self.step)
# Begin fetching http://xkcd.com/353/
fetcher = Fetcher('/353/')
Task(fetcher.fetch())
loop()
这个task
首先向fetcher
生成器发送None
启动生成器,之后fetch
会一直运行到yield
返回,task
会捕获这个返回值。当连接建立,生成器返回,task
将返回值发送到生成器中再次触发生成器运行。
通过yield from
构建协程
一旦连接建立,我们需要向服务器发送一个GET
请求并读取服务器的返回数据。这几个步骤不再需要各种回调实现,我们同样可以通过生成器实现:
def fetch(self):
# ... connection logic from above, then:
sock.send(request.encode('ascii'))
while True:
f = Future()
def on_readable():
f.set_result(sock.recv(4096))
selector.register(sock.fileno(),
EVENT_READ,
on_readable)
chunk = yield f
selector.unregister(sock.fileno())
if chunk:
self.response += chunk
else:
# Done reading.
break
上述代码中看起来很实用,但是怎样才能将fetch定义为一个子程序调用?这里我们可以采用python3中新引入的yield from
语句,将一个生成器委派给另一个生成器。
我们先来看一个例子:
>>> def gen_fn():
... result = yield 1
... print('result of yield: {}'.format(result))
... result2 = yield 2
... print('result of 2nd yield: {}'.format(result2))
... return 'done'
...
上面是一个简单的生成器,在另一个生成器中我们通过yield from
来调用定义的这个生成器。
>>> # Generator function:
>>> def caller_fn():
... gen = gen_fn()
... rv = yield from gen
... print('return value of yield-from: {}'
... .format(rv))
...
>>> # Make a generator from the
>>> # generator function.
>>> caller = caller_fn()
这样caller
就如同gen
一样,它会将相应的调用分配给gen_fn
。
>>> caller.send(None)
1
>>> caller.gi_frame.f_lasti
15
>>> caller.send('hello')
result of yield: hello
2
>>> caller.gi_frame.f_lasti # Hasn't advanced.
15
>>> caller.send('goodbye')
result of 2nd yield: goodbye
return value of yield-from: done
Traceback (most recent call last):
File "<input>", line 1, in <module>
StopIteration
通过上面的例子换位看到,当caller
通过yields
返回数据的时候,caller
并没有继续往下执行,而是还停留在15,也就是yield
代码的位置,不过从外面来看,我们并不能区分得到的数据是来自caller
还是他的委派,即使是在gen
内部我们也不能区分数据到底是从哪里来的,因此,yield from
实际是对用户透明的。
协程可以通过yield from
将数据生成的过程委派给另一个子协程,并很好的工作。不过,需要注意,上面例子中打印了一个return value of yield-from: done
。也就是当gen
结束的时候,它的返回值会成为调用者caller
中yield from
代码段的返回值。
在之前的例子中我们说到异步编程可能出现跳栈的行为:如果一个回调函数产生了一个异常,那么堆栈信息对于调试将完全没有作用。它仅仅能告诉我们相应的回调函数被调用了而已,而不能表现到底为何会调用这个回调函数(如果回调函数在多个地方被注册,那么程序会异常难以调试。那么协程遇到类似的问题会怎样呢?
>>> def gen_fn():
... raise Exception('my error')
>>> caller = caller_fn()
>>> caller.send(None)
Traceback (most recent call last):
File "<input>", line 1, in <module>
File "<input>", line 3, in caller_fn
File "<input>", line 2, in gen_fn
Exception: my error
可以看里面的信息很多,可以清楚地看到程序执行的顺序,因此可以直接在协程函数里面添加必要的异常处理。并可以抛出异常。
>>> def gen_fn():
... yield 1
... raise Exception('uh oh')
...
>>> def caller_fn():
... try:
... yield from gen_fn()
... except Exception as exc:
... print('caught {}'.format(exc))
...
>>> caller = caller_fn()
>>> caller.send(None)
1
>>> caller.send('hello')
caught uh oh
到这里我们已经重构了一部分子协程,现在我们继续对fetcher
函数进行必要地重构。首先我们将读取操作转换成协程:
def read(sock):
f = Future()
def on_readable():
f.set_result(sock.recv(4096))
selector.register(sock.fileno(), EVENT_READ, on_readable)
chunk = yield f # Read one chunk.
selector.unregister(sock.fileno())
return chunk
我们再建立一个read_all
的函数来读取所有结果:
def read_all(sock):
response = []
# Read whole response.
chunk = yield from read(sock)
while chunk:
response.append(chunk)
chunk = yield from read(sock)
return b''.join(response)
现在,yield from
部分代码就像是普通的函数代码,不过它们可以完成异步操作,实际上read
和read_all
都是协程。read
的yield
返回会暂停read_all
直到所有的IO事件完成,而read_all
则在read
有结果返回的时候继续运行。
而在这一系列的操作中,首先fetch
调用read_all
class Fetcher:
def fetch(self):
# ... connection logic from above, then:
sock.send(request.encode('ascii'))
self.response = yield from read_all(sock)
之后就是见证奇迹的时刻了,我们不需要改动Task
类,程序就可以按我们的预期运行!
Task(fetcher.fetch())
loop()
当read
返回一个数据时,它会通过yield from
向上返回数据,数据直到fetch
接收,程序收到数据之后又会向fetch
发送结果,这个结果又会驱动程序继续向下执行。
简单地说,我们就需要在需要等待的地方添加yield
,这样它就会等待一个未来的事件,而在需要分配给一个子进程的时候,我们就使用yield from
委派到其它地方。不过更好的方式是我们在任何需要等待的地方都添加yield from
,这样协程就不需要知道它到底在等待什么东西了。
如果我们再利用一下python中生成器和迭代器的关系,可以看出对于调用者来说,它们并没有什么区别,所以我们可以在Future
类中定义一个特殊的方法:
# Method on Future class.
def __iter__(self):
# Tell Task to resume me here.
yield self
return self.result
这个__iter__
方法是一个迭代器方法,它直接返回自身对象,不过地未来的自身对象。这样,Task
通过send
获取未来的数据,当未来数据返回的时候,它会发送新的请求,以获取新的数据。
相比于之前的实现方式,这种实现方式耦合性更低,方法的实现不再影响调用者的操作。
好了,到目前为止,我们已经粗略地实现了基于协程的异步算法。我们深入介绍了生成器的机制,并阐述了基于协程的优势。当然,这里只是简单地一个实现,实际实现可能还需要考虑更多的地方,比如零拷贝IO,公平调度,异常处理实现都还没有考虑。
不过,对于一般地异步编程,协程的实现方式一般都会比较简单。比如最开始我们用到回调,任务等复杂的代码,而现在这些代码都不需要了,现在你可以像下面的方式一般优雅地实现对一个URL的抓取。
@asyncio.coroutine
def fetch(self, url):
response = yield from self.session.get(url)
body = yield from response.read()