PRF:20160917 A Web Crawler With asyncio Coroutines.md

PART
This commit is contained in:
wxy 2017-03-03 10:37:01 +08:00
parent 09256736ba
commit 1e8f4d3077

View File

@ -135,18 +135,18 @@ def loop():
用我们刚刚建立的异步框架,怎么才能完成一个网络爬虫?即使是一个简单的网页下载程序也是很难写的。
首先我们有一个未获取的URL集合和一个已经解析过的URL集合。
首先,我们有一个未获取的 URL 集合,和一个已经解析过的 URL 集合。
```python
urls_todo = set(['/'])
seen_urls = set(['/'])
```
这两个集合加在一起就是所有的URL。用"/"初始化它们。
`seen_urls` 集合包括 `urls_todo` 和已经完成的 URL。用根 URL `/` 初始化它们。
获取一个网页需要一系列的回调。在套接字连接建立时`connected`回调触发它向服务器发送一个GET请求。但是它要等待响应所以我们需要注册另一个回调函数,当回调被调用,它也不能一次读取完整的请求,所以,需要再一次注册,如此反复。
获取一个网页需要一系列的回调。在套接字连接建立时会触发 `connected` 回调,它向服务器发送一个 GET 请求。但是它要等待响应,所以我们需要注册另一个回调函数;当该回调被调用,它仍然不能读取到完整的请求时,就会再一次注册回调,如此反复。
让我们把这些回调放在一个`Fetcher`对象中它需要一个URL一个套接字还需要一个地方保存返回的字节
让我们把这些回调放在一个 `Fetcher` 对象中,它需要一个 URL一个套接字还需要一个地方保存返回的字节
```python
class Fetcher:
@ -156,7 +156,7 @@ class Fetcher:
self.sock = None
```
我们的入口点在`Fetcher.fetch`:
我们的入口点在 `Fetcher.fetch`
```python
# Method on Fetcher class.
@ -174,7 +174,7 @@ class Fetcher:
self.connected)
```
`fetch`方法从连接一个套接字开始。但是要注意这个方法在连接建立前就返回了。它必须返回到事件循环中等待连接建立。为了理解为什么要这样,假设我们程序的整体结构如下:
`fetch` 方法从连接一个套接字开始。但是要注意这个方法在连接建立前就返回了。它必须将控制返回到事件循环中等待连接建立。为了理解为什么要这样,假设我们程序的整体结构如下:
```python
# Begin fetching http://xkcd.com/353/
@ -188,9 +188,9 @@ while True:
callback(event_key, event_mask)
```
所有的事件提醒都在事件循环中的`select`函数后处理。所以`fetch`必须把控制权交给事件循环。这样我们的程序才能知道什么时候连接已建立,接着循环调用`connected`回调,它已经在`fetch`方法中注册过。
当调用 `select` 函数后,所有的事件提醒才会在事件循环中处理,所以 `fetch` 必须把控制权交给事件循环,这样我们的程序才能知道什么时候连接已建立,接着循环调用 `connected` 回调,它已经在上面的 `fetch` 方法中注册过。
这里是我们`connected`方法的实现:
这里是我们`connected` 方法的实现:
```python
# Method on Fetcher class.
@ -206,7 +206,7 @@ while True:
self.read_response)
```
这个方法发送一个GET请求。一个真正的应用会检查`send`的返回值,以防所有的信息没能一次发送出去。但是我们的请求很小,应用也不复杂。它只是简单的调用`send`,然后等待响应。当然,它必须注册另一个回调并把控制权交给事件循环。接下来也是最后一个回调函数`read_response`,它处理服务器的响应:
这个方法发送一个 GET 请求。一个真正的应用会检查 `send` 的返回值,以防所有的信息没能一次发送出去。但是我们的请求很小,应用也不复杂。它只是简单的调用 `send`,然后等待响应。当然,它必须注册另一个回调并把控制权交给事件循环。接下来也是最后一个回调函数 `read_response`,它处理服务器的响应:
```python
# Method on Fetcher class.
@ -231,13 +231,13 @@ while True:
stopped = True
```
这个回调在每次selector发现套接字*可读*时被调用,可读有两种情况:套接字接受到数据或它被关闭。
这个回调在每次 `selector` 发现套接字*可读*时被调用,可读有两种情况:套接字接受到数据或它被关闭。
这个回调函数从套接字读取4K数据。如果没有4k那么有多少读多少。如果比4K多`chunk`只包4K数据并且这个套接字保持可读这样在事件循环的下一个周期会在次回到这个回调函数。当响应完成时,服务器关闭这个套接字,`chunk`为空。
这个回调函数从套接字读取 4K 数据。如果不到 4k那么有多少读多少。如果比 4K 多,`chunk` 中只包 4K 数据并且这个套接字保持可读,这样在事件循环的下一个周期,会再次回到这个回调函数。当响应完成时,服务器关闭这个套接字,`chunk` 为空。
没有展示的`parse_links`方法它返回一个URL集合。我们为每个新的URL启动一个fetcher。注意一个使用异步回调方式编程的好处我们不需要为共享数据加锁比如我们往`seen_urls`增加新链接时。这是一种非抢占式的多任务,它不会在我们代码中的任意一个地方断。
这里没有展示的 `parse_links` 方法,它返回一个 URL 集合。我们为每个新的 URL 启动一个 fetcher。注意一个使用异步回调方式编程的好处我们不需要为共享数据加锁比如我们往 `seen_urls` 增加新链接时。这是一种非抢占式的多任务,它不会在我们代码中的任意一个地方被打断。
我们增加了一个全局变量`stopped`,用它来控制这个循环:
我们增加了一个全局变量 `stopped`,用它来控制这个循环:
```python
stopped = False
@ -250,13 +250,13 @@ def loop():
callback()
```
一旦所有的网页被下载下来fetcher停止这个事件循环程序退出。
一旦所有的网页被下载下来fetcher 停止这个事件循环,程序退出。
这个例子让异步编程的一个问题明显的暴露出来:意大利面代码。
我们需要某种方式来表达一串计算和I/O操作并且能够调度多个这样的操作让他们并发的执行。但是没有线程你不能把这一串操作写在一个函数中当函数开始一个I/O操作它明确的把未来所需的状态保存下来然后返回。你需要考虑如何写这个状态保存的代码。
我们需要某种方式来表达一系列的计算和 I/O 操作,并且能够调度多个这样的系列操作让它们并发的执行。但是,没有线程你不能把这一系列操作写在一个函数中:当函数开始一个 I/O 操作,它明确的把未来所需的状态保存下来,然后返回。你需要考虑如何写这个状态保存的代码。
让我们来解释下这到底是什么意思。考虑在线程中使用通常的阻塞套接字来获取一个网页时是多么简单。
让我们来解释下这到底是什么意思。先来看一下在线程中使用通常的阻塞套接字来获取一个网页时是多么简单。
```python
# Blocking version.
@ -276,11 +276,11 @@ def fetch(url):
q.add(links)
```
在一个套接字操作和下一个操作之间这个函数到底记住了什么它有一个套接字一个URL和一个可增长的`response`。运行在线程中的函数使用编程语言的基本功能,栈中的局部变量来保存临时的状态。这样的函数有一个"continuation"----在I/O结束后它要执行的代码。运行时通过线程的指令指针来记住这个continuation。你不必考虑怎么在I/O操作后恢复局部变量和这个continuation。语言本身的特性帮你解决。
在一个套接字操作和下一个操作之间这个函数到底记住了什么状态?它有一个套接字,一个 URL 和一个可增长的 `response`。运行在线程中的函数使用编程语言的基本功能来在栈中的局部变量保存这些临时状态。这样的函数也有一个“continuation”——它会在 I/O 结束后执行这些代码。运行时环境通过线程的指令指针来记住这个 continuation。你不必考虑怎么在 I/O 操作后恢复局部变量和这个 continuation。语言本身的特性帮你解决。
但是用一个基于回调的异步框架这些语言特性不能提供一点帮助。当等待I/O操作时一个函数必须明确的保存它的状态因为它会在I/O操作完成之前返回并清除栈帧。为了在我们基于回调的例子中代替局部变量我们把`sock`和`response`作为Fetcher实例`self`属性。为了代替指令指针,它通过注册`connnected`和`read_response`回调来保存continuation。随着应用功能的增长我们手动保存回调的复杂性也会增加。如此繁复的记账式工作会让编码者感到头痛。
但是用一个基于回调的异步框架,这些语言特性不能提供一点帮助。当等待 I/O 操作时,一个函数必须明确的保存它的状态,因为它会在 I/O 操作完成之前返回并清除栈帧。在我们基于回调的例子中,作为局部变量的替代,我们把 `sock``response` 作为 Fetcher 实例 `self` 的属性来存储。而作为指令指针的替代,它通过注册 `connected``read_response` 回调来保存它的 continuation。随着应用功能的增长我们需要手动保存回调的复杂性也会增加。如此繁复的记账式工作会让编码者感到头痛。
更糟糕的是,当我们的回调函数抛出异常会发生什么?假设我们没有写好`parse_links`方法它在解析HTML时抛出异常
更糟糕的是,当我们的回调函数抛出异常会发生什么?假设我们没有写好 `parse_links` 方法,它在解析 HTML 时抛出异常:
```
Traceback (most recent call last):
@ -295,13 +295,13 @@ Traceback (most recent call last):
Exception: parse error
```
这个堆栈回溯只能显示出事件循环调用了一个回调。我们不知道是什么导致了这个错误。这条链的两边都被破坏:不知道从哪来也不知到哪去。这种丢失上下文的现象被称为"stack ripping"它还会阻止我们为回调链设置异常处理。
这个堆栈回溯只能显示出事件循环调用了一个回调。我们不知道是什么导致了这个错误。这条链的两边都被破坏:不知道从哪来也不知到哪去。这种丢失上下文的现象被称为“堆栈撕裂stack ripping经常会导致无法分析原因。它还会阻止我们为回调链设置异常处理即那种用“try / except”块封装函数调用及其调用树(对于这个问题的更复杂的解决方案,参见 http://www.tornadoweb.org/en/stable/stack_context.html
所以,除了关于多线程和异步那个更高效的争议,还有一个关于这两者之间的争论,谁更容易出错。如果在同步上出现失误,线程更容易出现数据竞争的问题,而回调因为"stack ripping"问题而非常难于调试。
所以,除了关于多线程和异步哪个更高效的长期争议之外,还有一个关于这两者之间的争论:谁更容易跪了。如果在同步上出现失误,线程更容易出现数据竞争的问题,而回调因为"堆栈撕裂(stack ripping"问题而非常难于调试。
## 协程
### 协程
还记得我们对你许下的承诺么?我们可以写出这样的异步代码,它既有回调方式的高效,也有多线程代码的简洁。这个结合是同过一种称为协程的模式来实现的。使用Python3.4标准库asyncio和一个叫"aiohttp"的包,在协程中获取一个网页是非常直接的[^10]
还记得我们对你许下的承诺么?我们可以写出这样的异步代码,它既有回调方式的高效,也有多线程代码的简洁。这个结合是同过一种称为协程coroutine的模式来实现的。使用 Python3.4 标准库 asyncio 和一个叫“aiohttp”的包在协程中获取一个网页是非常直接的 `@asyncio.coroutine` 修饰符并非魔法。事实上,如果它修饰的是一个生成器函数,并且没有设置 `PYTHONASYNCIODEBUG` 环境变量的话,这个修饰符基本上没啥用。它只是为了框架的其它部分方便,设置了一个属性 `_is_coroutine` 而已。也可以直接使用 asyncio 和裸生成器,而没有 `@asyncio.coroutine` 修饰符)
```python
@asyncio.coroutine
@ -310,15 +310,17 @@ Exception: parse error
body = yield from response.read()
```
它也是可扩展的。在Jesse系统上与每个线程50k内存相比一个Python协程只需要3k内存。Python很容易就可以启动上千个协程。
它也是可扩展的。在作者 Jesse系统上,与每个线程 50k 内存相比,一个 Python 协程只需要 3k 内存。Python 很容易就可以启动上千个协程。
协程的概念可以追溯到计算机科学的远古时代,它很简单,一个可以暂停和恢复的子过程。线程是被操作系统控制的抢占式多任务,而协程是可合作的,它们自己选择什么时候暂停去执行下一个协程。
协程的概念可以追溯到计算机科学的远古时代,它很简单,一个可以暂停和恢复的子过程。线程是被操作系统控制的抢占式多任务,而协程的多任务是可合作的,它们自己选择什么时候暂停去执行下一个协程。
有很多协程的实现。甚至在Python中也有几种。Python3.4标准库asyncio中的协程它是建立在生成器一个Future类和"yield from"语句之上。从Python3.5开始协程变成了语言本身的特性。然而理解Python3.4中这个通过语言原有功能实现的协程是我们处理Python3.5中原生协程的基础。
有很多协程的实现。甚至在 Python 中也有几种。Python 3.4 标准库 asyncio 中的协程是建立在生成器之上的,这是一个 Future 类和“yield from”语句。从 Python 3.5 开始,协程变成了语言本身的特性([“PEP 492 Coroutines with async and await syntax”](https://www.python.org/dev/peps/pep-0492/) 中描述了 Python 3.5 内置的协程)。然而,理解 Python 3.4 中这个通过语言原有功能实现的协程,是我们处理 Python 3.5 中原生协程的基础。
要解释 Python 3.4 中基于生成器的协程,我们需要深入生成器的方方面面,以及它们是如何在 asyncio 中用作协程的。我很高兴就此写点东西,想必你也希望继续读下去。我们解释了基于生成器的协程之后,就会在我们的异步网络爬虫中使用它们。
## 生成器如何工作
### 生成器如何工作
在你理解生成器之前你需要知道普通的Python函数是怎么工作的。当一个函数调用一个子过程这个被调用函数获得控制权直到它返回或者有异常发生,才把控制权交给调用者:
在你理解生成器之前,你需要知道普通的 Python 函数是怎么工作的。正常情况下,当一个函数调用一个子过程,这个被调用函数获得控制权直到它返回或者有异常发生,才把控制权交给调用者:
```python
>>> def foo():
@ -328,7 +330,7 @@ Exception: parse error
... pass
```
标准的Python解释器是C语言写的。一个Python函数被调用对应的C函数是`PyEval_EvalFrameEx`。它获得一个Python栈帧结构并在这个栈帧的上下文中执行Python字节码。这里是`foo`的字节码:
标准的 Python 解释器是C 语言写的。一个 Python 函数被调用对应的 C 函数是 `PyEval_EvalFrameEx`。它获得一个 Python 栈帧结构并在这个栈帧的上下文中执行 Python 字节码。这里是 `foo` 函数的字节码:
```python
>>> import dis
@ -340,13 +342,11 @@ Exception: parse error
10 RETURN_VALUE
```
`foo`函数在它栈中加载`bar`并调用它,然后把`bar`的返回值从栈中弹出,加载`None`值并返回。
`foo` 函数在它栈中加载 `bar` 函数并调用它,然后把 `bar` 的返回值从栈中弹出,加载 `None` 到堆栈并返回。
当`PyEval_EvalFrameEx`遇到`CALL_FUNCTION`字节码时,它会创建一个新的栈帧,并用这个栈帧递归的调用`PyEval_EvalFrameEx`来执行`bar`函数。
`PyEval_EvalFrameEx` 遇到 `CALL_FUNCTION` 字节码时,它会创建一个新的栈帧,并用这个栈帧递归的调用 `PyEval_EvalFrameEx` 来执行 `bar` 函数。
非常重要的一点是Python的栈帧在堆中分配Python解释器是一个标准的C程序所以他的栈帧是正常的栈帧。但是Python的栈帧是在堆中处理。这意味着Python栈帧在函数调用结束后依然可以存在。我们在`bar`函数中保存当前的栈帧,交互式的看看这种现象:
非常重要的一点是Python 的栈帧在堆中分配Python 解释器是一个标准的 C 程序,所以它的栈帧是正常的栈帧。但是 Python 的栈帧是在堆中处理。这意味着 Python 栈帧在函数调用结束后依然可以存在。我们在 `bar` 函数中保存当前的栈帧,交互式的看看这种现象:
```python
>>> import inspect
@ -367,8 +367,10 @@ Exception: parse error
>>> caller_frame.f_code.co_name
'foo'
```
![Figure 5.1 - Function Calls](http://aosabook.org/en/500L/crawler-images/function-calls.png)
现在该说Python生成器了它使用同样构件--code object和栈帧--去完成一个不可思议的任务。
现在该说 Python 生成器了,它使用同样构件——代码对象和栈帧——去完成一个不可思议的任务。
这是一个生成器函数:
@ -382,7 +384,7 @@ Exception: parse error
...
```
在Python把`gen_fn`编译成字节码的过程中,一旦它看到`yield`语句就知道这是一个生成器函数而不是普通的函数。它就会设置一个标志来记住这个事实:
Python `gen_fn` 编译成字节码的过程中,一旦它看到 `yield` 语句就知道这是一个生成器函数而不是普通的函数。它就会设置一个标志来记住这个事实:
```python
>>> # The generator flag is bit position 5.
@ -391,7 +393,7 @@ Exception: parse error
True
```
当你调用一个生成器函数Python看到这个标志就不会运行它而是创建一个生成器
当你调用一个生成器函数Python 看到这个标志,就不会实际运行它而是创建一个生成器:
```python
>>> gen = gen_fn()
@ -399,32 +401,32 @@ True
<class 'generator'>
```
Python生成器封装了一个栈帧和函数体代码
Python 生成器封装了一个栈帧和函数体代码的引用
```python
>>> gen.gi_code.co_name
'gen_fn'
```
所有通过调用`gen_fn`的生成器指向同一段代码但都有各自的栈帧。这些栈帧不再任何一个C函数栈中而是在堆空间中等待被使用
所有通过调用 `gen_fn` 的生成器指向同一段代码但都有各自的栈帧。这些栈帧不再任何一个C函数栈中而是在堆空间中等待被使用
![Figure 5.2 - Generators](http://aosabook.org/en/500L/crawler-images/generator.png)
栈帧中有一个指向最后执行指令的指针。初始化为-1意味着它没开始运行
栈帧中有一个指向最后执行指令的指针。初始化为 -1意味着它没开始运行
```python
>>> gen.gi_frame.f_lasti
-1
```
当我们调用`send`时,生成器一直运行到第一个`yield`语句处停止。并且`send`返回1yield语句后的表达式的值。
当我们调用 `send` 时,生成器一直运行到第一个 `yield` 语句处停止,并且 `send` 返回 1因为这是 `gen` 传递给 `yield` 表达式的值。
```python
>>> gen.send(None)
1
```
现在生成器的指令指针是3字节码一共有56个字节
现在生成器的指令指针是 3所编译的Python 字节码一共有 56 个字节:
```python
>>> gen.gi_frame.f_lasti
@ -433,9 +435,9 @@ Python生成器封装了一个栈帧和函数体代码
56
```
这个生成器可以在任何时候任何函数中恢复运行,因为它的栈帧并不在真正的栈中,而是堆中。在调用链中它的位置也是不定的,它不必遵循普通函数先进后出的顺序。它像云一样自由。
这个生成器可以在任何时候任何函数中恢复运行,因为它的栈帧并不在真正的栈中,而是堆中。在调用链中它的位置也是不定的,它不必遵循普通函数先进后出的顺序。它像云一样自由。
我们可以传递一个`hello`给生成器它会成为yield语句的结果并且生成器运行到第二个yield语句处。
我们可以传递一个`hello` 给生成器,它会成为 `yield` 语句的结果,并且生成器会继续运行到第二个 `yield` 语句处。
```python
>>> gen.send('hello')
@ -443,16 +445,16 @@ result of yield: hello
2
```
现在栈帧中包含局部变量`result`:
现在栈帧中包含局部变量 `result`
```python
>>> gen.gi_frame.f_locals
{'result': 'hello'}
```
其它从`gen_fn`创建的生成器有着它自己的栈帧和局部变量。
其它从 `gen_fn` 创建的生成器有着它自己的栈帧和局部变量。
当我们在一次调用`send`生成器从第二个yield开始运行以抛出一个特殊的`StopIteration`异常为结束。
当我们再一次调用 `send`,生成器继续从第二个 `yield` 开始运行,以抛出一个特殊的 `StopIteration` 异常为结束。
```python
>>> gen.send('goodbye')
@ -462,9 +464,9 @@ Traceback (most recent call last):
StopIteration: done
```
这个异常有一个值"done",它就是生成器的返回值。
这个异常有一个值 `"done"`,它就是生成器的返回值。
## 使用生成器构建协程
### 使用生成器构建协程
所以生成器可以暂停,可以给它一个值让它恢复,并且它还有一个返回值。这些特性看起来很适合去建立一个不使用回调的异步编程模型。我们想创造一个协程:一个在程序中可以和其他过程合作调度的过程。我们的协程将会是标准库`asyncio`中协程的一个简化版本我们将使用生成器futures和`yield from`语句。