mirror of
https://github.com/LCTT/TranslateProject.git
synced 2026-08-26 04:13:30 +08:00
PRF:20160917 A Web Crawler With asyncio Coroutines.md
part
This commit is contained in:
@@ -1,24 +1,29 @@
|
||||
一个使用asyncio协程的网络爬虫
|
||||
一个使用 asyncio 协程的网络爬虫
|
||||
===
|
||||
|
||||
本文作者:
|
||||
|
||||
A. Jesse Jiryu Davis 是纽约 MongoDB 的工程师。他编写了异步 MongoDB Python 驱动程序 Motor,也是 MongoDB C 驱动程序的开发领袖和 PyMongo 团队成员。 他也为 asyncio 和 Tornado 做了贡献,在 http://emptysqua.re 上写作。
|
||||
|
||||
Guido van Rossum 是主流编程语言 Python 的创造者,Python 社区称他为 BDFL (仁慈的终生大独裁者 (Benevolent Dictator For Life))——这是一个来自 Monty Python 短剧的称号。他的主页是 http://www.python.org/~guido/ 。
|
||||
|
||||
### 介绍
|
||||
|
||||
## 介绍
|
||||
经典的计算机科学强调高效的算法,尽可能快地完成计算。但是很多网络程序的时间并不是消耗在计算上,而是在等待许多慢速的连接或者低频事件的发生。这些程序暴露出一个新的挑战:如何高效的等待大量网络事件。一个现代的解决方案是异步 I/O。
|
||||
|
||||
经典的计算机科学强调高效的算法,尽可能快地完成计算。但是很多网络程序的时间并不是消耗在计算上,而是在等待许多慢速的连接或者低频事件的发生。这些程序暴露出一个新的挑战:如何高效的等待大量网络事件。一个现代的解决方案是异步I/O。
|
||||
这一章我们将实现一个简单的网络爬虫。这个爬虫只是一个原型式的异步应用,因为它等待许多响应而只做少量的计算。一次爬的网页越多,它就能越快的完成任务。如果它为每个动态的请求启动一个线程的话,随着并发请求数量的增加,它会在耗尽套接字之前,耗尽内存或者线程相关的资源。使用异步 I/O 可以避免这个的问题。
|
||||
|
||||
这一章我们将实现一个简单的网络爬虫。这个爬虫只是一个原型式的异步应用,因为它等待许多响应而只做少量的计算。一次爬的网页越多,它就能越快的完成任务。如果它为每个动态的请求启动一个线程的话,随着并发请求数量的增加,它会在耗尽套接字之前,耗尽内存或者线程相关的资源。使用异步I/O可以避免这个的问题。
|
||||
我们将分三个阶段展示这个例子。首先,我们会实现一个事件循环并用这个事件循环和回调来勾画出一只网络爬虫。它很有效,但是当把它扩展成更复杂的问题时,就会导致无法管理的混乱代码。然后,由于 Python 的协程不仅有效而且可扩展,我们将用 Python 的生成器函数实现一个简单的协程。在最后一个阶段,我们将使用 Python 标准库“asyncio”中功能完整的协程, 并通过异步队列完成这个网络爬虫。(在 [PyCon 2013](http://pyvideo.org/video/1667/keynote) 上,Guido 介绍了标准的 asyncio 库,当时称之为“Tulip”。)
|
||||
|
||||
我们将分三个阶段展示这个例子。首先,我们会实现一个事件循环并用这个事件循环和回调来勾画出一个网络爬虫。它很有效,但是当把它扩展成更复杂的问题时,就会导致无法管理的混乱代码。然后,由于Python的协程不仅有效而且可扩展,我们将用Python的生成器函数实现一个简单的协程。在最后一个阶段,我们将使用Python标准库"asyncio"中功能完整的协程和异步队列完成这个网络爬虫。
|
||||
### 任务
|
||||
|
||||
## 任务
|
||||
|
||||
网络爬虫寻找并下载一个网站上的所有网页,也许还会把它们存档,为它们建立索引。从根URL开始,它获取每个网页,解析出没有遇到过的链接加到队列中。当网页没有未见到过的链接并且队列为空时,它便停止运行。
|
||||
网络爬虫寻找并下载一个网站上的所有网页,也许还会把它们存档,为它们建立索引。从根 URL 开始,它获取每个网页,解析出没有遇到过的链接加到队列中。当网页没有未见到过的链接并且队列为空时,它便停止运行。
|
||||
|
||||
我们可以通过同时下载大量的网页来加快这一过程。当爬虫发现新的链接,它使用一个新的套接字并行的处理这个新链接,解析响应,添加新链接到队列。当并发很大时,可能会导致性能下降,所以我们会限制并发的数量,在队列保留那些未处理的链接,直到一些正在执行的任务完成。
|
||||
|
||||
## 传统方式
|
||||
### 传统方式
|
||||
|
||||
怎么使一个爬虫并发?传统的做法是创建一个线程池,每个线程使用一个套接字在一段时间内负责一个网页的下载。比如,下载xkcd.com网站的一个网页:
|
||||
怎么使一个爬虫并发?传统的做法是创建一个线程池,每个线程使用一个套接字在一段时间内负责一个网页的下载。比如,下载 xkcd.com 网站的一个网页:
|
||||
|
||||
```python
|
||||
def fetch(url):
|
||||
@@ -37,19 +42,19 @@ def fetch(url):
|
||||
q.add(links)
|
||||
```
|
||||
|
||||
套接字操作默认是阻塞的:当一个线程调用一个类似`connect`和`recv`方法时,它会阻塞,直到操作完成.[^15]因此,为了同一时间内下载多个网页,我们需要很多线程。一个复杂的应用会通过线程池保持空闲的线程来分摊创建线程的开销。同样的做法也适用于套接字,使用连接池。
|
||||
套接字操作默认是阻塞的:当一个线程调用一个类似 `connect` 和 `recv` 方法时,它会阻塞,直到操作完成。(即使是 `send` 也能被阻塞,比如接收端在接受外发消息时缓慢而系统的外发数据缓存已经满了的情况下)因此,为了同一时间内下载多个网页,我们需要很多线程。一个复杂的应用会通过线程池保持空闲的线程来分摊创建线程的开销。同样的做法也适用于套接字,使用连接池。
|
||||
|
||||
到目前为止,线程是昂贵的,操作系统对一个进程,一个用户,一台机器能使用线程做了不同的硬性限制。在Jesse系统中,一个Python线程需要50K的内存,开启上万个线程会失败。每个线程的开销和系统的限制就是这种方式的瓶颈所在。
|
||||
到目前为止,使用线程的是成本昂贵的,操作系统对一个进程、一个用户、一台机器能使用线程做了不同的硬性限制。在 作者 Jesse 的系统中,一个 Python 线程需要 50K 的内存,开启上万个线程就会失败。每个线程的开销和系统的限制就是这种方式的瓶颈所在。
|
||||
|
||||
在Dan Kegel那一篇很有影响力的文章"The C10K problem"[^8]中,它提出多线程方式在I/O并发上的局限性。他在开始写道,
|
||||
在 Dan Kegel 那一篇很有影响力的文章“[The C10K problem](http://www.kegel.com/c10k.html)”中,它提出了多线程方式在 I/O 并发上的局限性。他在开始写道,
|
||||
|
||||
>是时候网络服务器要同时处理成千上万的客户啦,你不这样认为么?毕竟,现在网络是个很大的地方。
|
||||
> 网络服务器到了要同时处理成千上万的客户的时代了,你不这样认为么?毕竟,现在网络规模很大了。
|
||||
|
||||
Kegel在1999年创造出"C10K"术语。一万个连接在今天看来还是可接受的,但是问题依然存在,只不过大小不同。回到那时候,对于C10K问题,每个连接启一个线程是不切实际的。现在这个限制已经成指数级增长。确实,我们的玩具网络爬虫使用线程也可以工作的很好。但是,对于有着千万级连接的大规模应用来说,限制依然存在:会消耗掉所有线程,即使套接字还够用。那么我们该如何解决这个问题?
|
||||
Kegel 在 1999 年创造出“C10K”这个术语。一万个连接在今天看来还是可接受的,但是问题依然存在,只不过大小不同。回到那时候,对于 C10K 问题,每个连接启一个线程是不切实际的。现在这个限制已经成指数级增长。确实,我们的玩具网络爬虫使用线程也可以工作的很好。但是,对于有着千万级连接的大规模应用来说,限制依然存在:它会消耗掉所有线程,即使套接字还够用。那么我们该如何解决这个问题?
|
||||
|
||||
## 异步
|
||||
### 异步
|
||||
|
||||
异步I/O框架在一个线程中完成并发操作。让我们看看这是怎么做到的。
|
||||
异步 I/O 框架在一个线程中完成并发操作。让我们看看这是怎么做到的。
|
||||
|
||||
异步框架使用*非阻塞*套接字。异步爬虫中,我们在发起到服务器的连接前把套接字设为非阻塞:
|
||||
|
||||
@@ -62,9 +67,9 @@ except BlockingIOError:
|
||||
pass
|
||||
```
|
||||
|
||||
对一个非阻塞套接字调用`connect`方法会立即抛出异常,即使它正常工作。这个异常模拟了底层C语言函数的行为,它把`errno`设置为`EINPROGRESS`,告诉你操作已经开始。
|
||||
对一个非阻塞套接字调用 `connect` 方法会立即抛出异常,即使它可以正常工作。这个异常复现了底层 C 语言函数令人厌烦的行为,它把 `errno` 设置为 `EINPROGRESS`,告诉你操作已经开始。
|
||||
|
||||
现在我们的爬虫需要一种知道连接何时建立的方法,这样它才能发送HTTP请求。我们可以简单地使用循环来重试:
|
||||
现在我们的爬虫需要一种知道连接何时建立的方法,这样它才能发送 HTTP 请求。我们可以简单地使用循环来重试:
|
||||
|
||||
```python
|
||||
request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url)
|
||||
@@ -80,9 +85,9 @@ while True:
|
||||
print('sent')
|
||||
```
|
||||
|
||||
这种方法不仅消耗CPU,也不能有效的等待*多个*套接字。在远古时代,BSD Unix的解决方法是`select`,一个C函数,它在一个或一组非阻塞套接字上等待事件发生。现在,互联网应用大量连接的需求,导致`select`被`poll`代替,以及BSD的`kqueue`和Linux的`epoll`。它们的API和`select`相似,但在大数量的连接中也能有较好的性能。
|
||||
这种方法不仅消耗 CPU,也不能有效的等待*多个*套接字。在远古时代,BSD Unix 的解决方法是 `select`,这是一个 C 函数,它在一个或一组非阻塞套接字上等待事件发生。现在,互联网应用大量连接的需求,导致 `select` 被 `poll` 所代替,在 BSD 上的实现是 `kqueue` ,在 Linux 上是 `epoll`。它们的 API 和 `select` 相似,但在大数量的连接中也能有较好的性能。
|
||||
|
||||
Python 3.4的`DefaultSelector`使用你系统上最好的类`select`函数。去注册一个网络I/O事件,我们创建一个非阻塞套接字,并使用默认的selector注册。
|
||||
Python 3.4 的 `DefaultSelector` 会使用你系统上最好的 `select` 类函数。要注册一个网络 I/O 事件的提醒,我们会创建一个非阻塞套接字,并使用默认 selector 注册它。
|
||||
|
||||
```python
|
||||
from selectors import DefaultSelector, EVENT_WRITE
|
||||
@@ -103,9 +108,9 @@ def connected():
|
||||
selector.register(sock.fileno(), EVENT_WRITE, connected)
|
||||
```
|
||||
|
||||
我们不理会这个伪造的错误,调用`selector.register`,传递套接字文件描述符,一个表示我们想要监听什么事件的常量。为了当连接建立时收到提醒,我们使用`EVENT_WRITE`:它表示什么时候这个套接字可写。我们还传递了一个Python函数,`connected`,当对应事件发生时被调用。这样的函数被称为*回调*。
|
||||
我们不理会这个伪造的错误,调用 `selector.register`,传递套接字文件描述符和一个表示我们想要监听什么事件的常量表达式。为了当连接建立时收到提醒,我们使用 `EVENT_WRITE` :它表示什么时候这个套接字可写。我们还传递了一个 Python 函数 `connected`,当对应事件发生时被调用。这样的函数被称为*回调*。
|
||||
|
||||
我们在一个循环中处理I/O提醒,随着selector接收到它们。
|
||||
在一个循环中,selector 接收到 I/O 提醒时我们处理它们。
|
||||
|
||||
```python
|
||||
def loop():
|
||||
@@ -116,13 +121,17 @@ def loop():
|
||||
callback()
|
||||
```
|
||||
|
||||
`connected`回调函数被保存在`event_key.data`中,一旦这个非阻塞套接字建立连接,它就会被取出来执行。
|
||||
`connected` 回调函数被保存在 `event_key.data` 中,一旦这个非阻塞套接字建立连接,它就会被取出来执行。
|
||||
|
||||
不像我们前面那个快速重试的循环,这里的`select`调用会阻塞,等待下一个I/O事件,接着执行等待这个事件的回调函数。
|
||||
不像我们前面那个快速轮转的循环,这里的 `select` 调用会暂停,等待下一个 I/O 事件,接着执行等待这些事件的回调函数。没有完成的操作会保持挂起,直到进到下一个事件循环时执行。
|
||||
|
||||
到目前为止我们展现了什么?我们展示了如何开始一个I/O操作和当操作准备好时调用回调函数。异步*框架*,它在单线程中执行并发操作,建立在两个功能之上,非阻塞套接字和事件循环。
|
||||
到目前为止我们展现了什么?我们展示了如何开始一个 I/O 操作和当操作准备好时调用回调函数。异步*框架*,它在单线程中执行并发操作,其建立在两个功能之上,非阻塞套接字和事件循环。
|
||||
|
||||
我们这里达成了“并发性(concurrency)”,但不是传统意义上的“并行性(parallelism)”。也就是说,我们构建了一个可以进行重叠 I/O 的微小系统,它可以在其它操作还在进行的时候就开始一个新的操作。它实际上并没有利用多核来并行执行计算。这个系统是用于解决 I/O 限定(I/O-bound)问题的,而不是解决 CPU 限定(CPU-bound)问题的。(Python 的全局解释器锁禁止在一个进程中以任何方式并行执行 Python 代码。在 Python 中并行化 CPU 限定的算法需要多个进程,或者以将该代码移植为 C 语言并行版本。但是这是另外一个话题了。)
|
||||
|
||||
所以,我们的事件循环在并发 I/O 上是有效的,因为它并不用为每个连接拨付线程资源。但是在我们开始前,我们需要澄清一个常见的误解:异步比多线程快。通常并不是这样的,事实上,在 Python 中,在处理少量非常活跃的连接时,像我们这样的事件循环是慢于多线程的。在运行时环境中是没有全局解释器锁的,在同样的负载下线程会执行的更好。异步 I/O 真正适用于事件很少、有许多缓慢或睡眠的连接的应用程序。(Jesse 在“[什么是异步,它如何工作,什么时候该用它?](http://pyvideo.org/video/2565/what-is-async-how-does-it-work-and-when-should)”一文中指出了异步所适用和不适用的场景。Mike Bayer 在“[异步 Python 和数据库](http://techspot.zzzeek.org/2015/02/15/asynchronous-python-and-databases/)”一文中比较了不同负载情况下异步 I/O 和多线程的不同。)
|
||||
|
||||
## 回调
|
||||
### 回调
|
||||
|
||||
用我们刚刚建立的异步框架,怎么才能完成一个网络爬虫?即使是一个简单的网页下载程序也是很难写的。
|
||||
|
||||
@@ -1081,8 +1090,8 @@ class Task(Future):
|
||||
--------------------------------------
|
||||
via: http://aosabook.org/en/500L/pages/a-web-crawler-with-asyncio-coroutines.html
|
||||
|
||||
作者: A. Jesse Jiryu Davis and Guido van Rossum
|
||||
作者:A. Jesse Jiryu Davis , Guido van Rossum
|
||||
译者:[qingyunha](https://github.com/qingyunha)
|
||||
校对:
|
||||
校对:[wxy](https://github.com/wxy)
|
||||
|
||||
本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创翻译,[Linux中国](http://linux.cn/) 荣誉推出
|
||||
|
||||
Reference in New Issue
Block a user