分享

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

 lihuimail 2017-11-08

前言

对我来说,编程的乐趣之一是想办法让程序执行的越来越快,代码越写越优雅。在刚开始学习并发编程时,相信你它会有一些困惑,本文将解释协程的Web爬虫问题并帮助你快速了解并发编程的不同场景和应该使用的解决方案。小编推荐一个企鹅群,群里分子非常踊跃交流经验遇坑问题。也有初学者交流讨论,群内整理了也整理了大量的PDF书籍和学习资料。程序员也很热心的帮助解决问题,还有讨论工作上的解决方案,非常好的学习交流地方!群内大概有好几千人了,喜欢python的朋友可以加入python群:526929231欢迎大家交流讨论各种奇技淫巧,一起快速成长

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

经典的计算机科学强调高效的算法,尽可能快地完成计算。但是很多网络程序的时间并不是消耗在计算上,而是在等待许多慢速的连接或者低频事件的发生。这些程序暴露出一个新的挑战:如何高效的等待大量网络事件。一个现代的解决方案是异步I/O。

这一章我们将实现一个简单的网络爬虫。这个爬虫只是一个原型式的异步应用,因为它等待许多响应而只做少量的计算。一次爬的网页越多,它就能越快的完成任务。如果它为每个动态的请求启动一个线程的话,随着并发请求数量的增加,它会在耗尽套接字之前,耗尽内存或者线程相关的资源。使用异步I/O可以避免这个的问题。

我们将分三个阶段展示这个例子。首先,我们会实现一个事件循环并用这个事件循环和回调来勾画出一个网络爬虫。它很有效,但是当把它扩展成更复杂的问题时,就会导致无法管理的混乱代码。然后,由于Python的协程不仅有效而且可扩展,我们将用Python的生成器函数实现一个简单的协程。在最后一个阶段,我们将使用Python标准库”asyncio”中功能完整的协程和异步队列完成这个网络爬虫。

The Task

网络爬虫寻找并下载一个网站上的所有网页,也许还会把它们存档,为它们建立索引。从根URL开始,它获取每个网页,解析出没有遇到过的链接加到队列中。当网页没有未见到过的链接并且队列为空时,它便停止运行。

我们可以通过同时下载大量的网页来加快这一过程。当爬虫发现新的链接,它使用一个新的套接字并行的处理这个新链接,解析响应,添加新链接到队列。当并发很大时,可能会导致性能下降,所以我们会限制并发的数量,在队列保留那些未处理的链接,直到一些正在执行的任务完成。

The Traditional Approach

怎么使一个爬虫并发?传统的做法是创建一个线程池,每个线程使用一个套接字在一段时间内负责一个网页的下载。比如,下载xkcd.com网站的一个网页:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

套接字操作默认是阻塞的:当一个线程调用一个类似connect和recv方法时,它会阻塞,直到操作完成.1因此,为了同一时间内下载多个网页,我们需要很多线程。一个复杂的应用会通过线程池保持空闲的线程来分摊创建线程的开销。同样的做法也适用于套接字,使用连接池。

到目前为止,线程是昂贵的,操作系统对一个进程,一个用户,一台机器能使用线程做了不同的硬性限制。在Jesse系统中,一个Python线程需要50K的内存,开启上万个线程会失败。每个线程的开销和系统的限制就是这种方式的瓶颈所在。

在Dan Kegel那一篇很有影响力的文章”The C10K problem”2中,它提出多线程方式在I/O并发上的局限性。他在开始写道,

是时候网络服务器要同时处理成千上万的客户啦,你不这样认为么?毕竟,现在网络是个很大的地方。

Kegel在1999年创造出”C10K”术语。一万个连接在今天看来还是可接受的,但是问题依然存在,只不过大小不同。回到那时候,对于C10K问题,每个连接启一个线程是不切实际的。现在这个限制已经成指数级增长。确实,我们的玩具网络爬虫使用线程也可以工作的很好。但是,对于有着千万级连接的大规模应用来说,限制依然存在:会消耗掉所有线程,即使套接字还够用。那么我们该如何解决这个问题?

Async异步

异步I/O框架在一个线程中完成并发操作。让我们看看这是怎么做到的。

异步框架使用*非阻塞*套接字。异步爬虫中,我们在发起到服务器的连接前把套接字设为非阻塞:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

对一个非阻塞套接字调用connect方法会立即抛出异常,即使它正常工作。这个异常模拟了底层C语言函数的行为,它把errno设置为EINPROGRESS,告诉你操作已经开始。

现在我们的爬虫需要一种知道连接何时建立的方法,这样它才能发送HTTP请求。我们可以简单地使用循环来重试:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

这种方法不仅消耗CPU,也不能有效的等待多个套接字。在远古时代,BSD Unix的解决方法是select,一个C函数,它在一个或一组非阻塞套接字上等待事件发生。现在,互联网应用大量连接的需求,导致select被poll代替,以及BSD的kqueue和Linux的epoll。它们的API和select相似,但在大数量的连接中也能有较好的性能。

Python 3.4的DefaultSelector使用你系统上最好的类select函数。去注册一个网络I/O事件,我们创建一个非阻塞套接字,并使用默认的selector注册。

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

我们不理会这个伪造的错误,调用selector.register,传递套接字文件描述符,一个表示我们想要监听什么事件的常量。为了当连接建立时收到提醒,我们使用EVENT_WRITE:它表示什么时候这个套接字可写。我们还传递了一个Python函数,connected,当对应事件发生时被调用。这样的函数被称为回调。

我们在一个循环中处理I/O提醒,随着selector接收到它们。

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

connected回调函数被保存在event_key.data中,一旦这个非阻塞套接字建立连接,它就会被取出来执行。

不像我们前面那个快速重试的循环,这里的select调用会阻塞,等待下一个I/O事件,接着执行等待这个事件的回调函数。

到目前为止我们展现了什么?我们展示了如何开始一个I/O操作和当操作准备好时调用回调函数。异步框架,它在单线程中执行并发操作,建立在两个功能之上,非阻塞套接字和事件循环。

Programming With Callbacks

用我们刚刚建立的异步框架,怎么才能完成一个网络爬虫?即使是一个简单的网页下载程序也是很难写的。

首先,我们有一个未获取的URL集合,和一个已经解析过的URL集合。

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

两个集合加在一起就是所有的URL。用”/“初始化它们。

获取一个网页需要一系列的回调。在套接字连接建立时connected回调触发,它向服务器发送一个GET请求。但是它要等待响应,所以我们需要注册另一个回调函数,当回调被调用,它也不能一次读取完整的请求,所以,需要再一次注册,如此反复。

让我们把这些回调放在一个Fetcher对象中,它需要一个URL,一个套接字,还需要一个地方保存返回的字节:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

我们的入口点在Fetcher.fetch:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

fetch方法从连接一个套接字开始。但是要注意这个方法在连接建立前就返回了。它必须返回到事件循环中等待连接建立。为了理解为什么要要这样,假设我们程序的整体结构如下:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

所有的事件提醒都在事件循环中的select函数后处理。所以fetch必须把控制权交给事件循环。这样我们的程序才能知道什么时候连接已建立,接着循环调用connected回调,它已经在fetch方法中注册过。

这里是我们connected方法的实现:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

这个方法发送一个GET请求。一个真正的应用会检查send的返回值,以防所有的信息没能一次发送出去。但是我们的请求很小,应用也不复杂。它只是简单的调用send,然后等待响应。当然,它必须注册另一个回调并把控制权交给事件循环。接下来也是最后一个回调函数read_response,它处理服务器的响应:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

这个回调在每次selector发现套接字可读时被调用,可读有两种情况:套接字接受到数据或它被关闭。

这个回调函数从套接字读取4K数据。如果没有4k,那么有多少读多少。如果比4K多,chunk只包4K数据并且这个套接字保持可读,这样在事件循环的下一个周期,会在次回到这个回调函数。当响应完成时,服务器关闭这个套接字,chunk为空。

没有展示的parselinks方法,它返回一个URL集合。我们为每个新的URL启动一个fetcher。注意一个使用异步回调方式编程的好处:我们不需要为共享数据加锁,比如我们往seenurls增加新链接时。这是一种非抢占式的多任务,它不会在我们代码中的任意一个地方中断。

我们增加了一个全局变量stopped,用它来控制这个循环:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

一旦所有的网页被下载下来,fetcher停止这个事件循环,程序退出。

这个例子让异步编程的一个问题明显的暴露出来:意大利面代码。

我们需要某种方式来表达一串计算和I/O操作,并且能够调度多个这样的操作让他们并发的执行。但是,没有线程你不能把这一串操作写在一个函数中:当函数开始一个I/O操作,它明确的把未来所需的状态保存下来,然后返回。你需要考虑如何写这个状态保存的代码。

让我们来解释下这到底是什么意思。考虑在线程中使用通常的阻塞套接字来获取一个网页时是多么简单。

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

在一个套接字操作和下一个操作之间这个函数到底记住了什么?它有一个套接字,一个URL和一个可增长的response。运行在线程中的函数使用编程语言的基本功能,栈中的局部变量来保存临时的状态。这样的函数有一个”continuation”—-在I/O结束后它要执行的代码。运行时通过线程的指令指针来记住这个continuation。你不必考虑怎么在I/O操作后恢复局部变量和这个continuation。语言本身的特性帮你解决。

但是用一个基于回调的异步框架,这些语言特性不能提供一点帮助。当等待I/O操作时,一个函数必须明确的保存它的状态,因为它会在I/O操作完成之前返回并清除栈帧。为了在我们基于回调的例子中代替局部变量,我们把sock和response作为Fetcher实例self属性。为了代替指令指针,它通过注册connnected和read_response回调来保存continuation。随着应用功能的增长,我们手动保存回调的复杂性也会增加。如此繁复的记账式工作会让编码者感到头痛。

更糟糕的是,当我们的回调函数抛出异常会发生什么?假设我们没有写好parse_links方法,它在解析HTML时抛出异常:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

这个堆栈回溯只能显示出事件循环调用了一个回调。我们不知道是什么导致了这个错误。这条链的两边都被破坏:不知道从哪来也不知到哪去。这种丢失上下文的现象被称为”stack ripping”,它还会阻止我们为回调链设置异常处理。

所以,除了关于多线程和异步那个更高效的争议,还有一个关于这两者之间的争论,谁更容易出错。如果在同步上出现失误,线程更容易出现数据竞争的问题,而回调因为”stack ripping”问题而非常难于调试。

Coroutines

还记得我们对你许下的承诺么?我们可以写出这样的异步代码,它既有回调方式的高效,也有多线程代码的简洁。这个结合是同过一种称为协程的模式来实现的。使用Python3.4标准库asyncio和一个叫”aiohttp”的包,在协程中获取一个网页是非常直接的:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

也是可扩展的。在Jesse系统上,与每个线程50k内存相比,一个Python协程只需要3k内存。Python很容易就可以启动上千个协程。

协程的概念可以追溯到计算机科学的远古时代,它很简单,一个可以暂停和恢复的子过程。线程是被操作系统控制的抢占式多任务,而协程是可合作的,它们自己选择什么时候暂停去执行下一个协程。

有很多协程的实现。甚至在Python中也有几种。Python3.4标准库asyncio中的协程,它是建立在生成器,一个Future类和”yield from”语句之上。从Python3.5开始,协程变成了语言本身的特性。然而,理解Python3.4中这个通过语言原有功能实现的协程,是我们处理Python3.5中原生协程的基础。

How Python Generators Work

在你理解生成器之前,你需要知道普通的Python函数是怎么工作的。当一个函数调用一个子过程,这个被调用函数获得控制权。直到它返回或者有异常发生,才把控制权交给调用者:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

标准的Python解释器是C语言写的。一个Python函数被调用对应的C函数是PyEval_EvalFrameEx。它获得一个Python栈帧结构并在这个栈帧的上下文中执行Python字节码。这里是foo的字节码:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

foo函数在它栈中加载bar并调用它,然后把bar的返回值从栈中弹出,加载None值并返回。

当PyEval_EvalFrameEx遇到CALL_FUNCTION字节码时,它会创建一个新的栈帧,并用这个栈帧递归的调用PyEval_EvalFrameEx来执行bar函数。

非常重要的一点是,Python的栈帧在堆中分配!Python解释器是一个标准的C程序,所以他的栈帧是正常的栈帧。但是Python的栈帧是在堆中处理。这意味着Python栈帧在函数调用结束后依然可以存在。我们在bar函数中保存当前的栈帧,交互式的看看这种现象:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

现在该说Python生成器了,它使用同样构件–code object和栈帧–去完成一个不可思议的任务。

这是一个生成器函数:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

在Python把gen_fn编译成字节码的过程中,一旦它看到yield语句就知道这是一个生成器函数而不是普通的函数。它就会设置一个标志来记住这个事实:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

当你调用一个生成器函数,Python看到这个标志,就不会运行它而是创建一个生成器:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

Python生成器封装了一个栈帧和函数体代码:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

所有通过调用gen_fn的生成器指向同一段代码,但都有各自的栈帧。这些栈帧不再任何一个C函数栈中,而是在堆空间中等待被使用:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

栈帧中有一个指向最后执行指令的指针。初始化为-1,意味着它没开始运行:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

当我们调用send时,生成器一直运行到第一个yield语句处停止。并且send返回1,yield语句后的表达式的值。

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

现在生成器的指令指针是3,字节码一共有56个字节:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

这个生成器可以在任何时候,任何函数中恢复运行,因为它的栈帧并不在真正的栈中,而是堆中。在调用链中它的位置也是不确定的,它不必遵循普通函数先进后出的顺序。它像云一样自由。

我们可以传递一个hello给生成器,它会成为yield语句的结果,并且生成器运行到第二个yield语句处。

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

现在栈帧中包含局部变量result:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

其它从gen_fn创建的生成器有着它自己的栈帧和局部变量。

当我们在一次调用send,生成器从第二个yield开始运行,以抛出一个特殊的StopIteration异常为结束。

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

这个异常有一个值”done”,它就是生成器的返回值。

Building Coroutines With Generators

所以生成器可以暂停,可以给它一个值让它恢复,并且它还有一个返回值。这些特性看起来很适合去建立一个不使用回调的异步编程模型。我们想创造一个协程:一个在程序中可以和其他过程合作调度的过程。我们的协程将会是标准库asyncio中协程的一个简化版本,我们将使用生成器,futures和yield from语句。

首先,我们需要一种方法去代表协程需要等待的未来事件。一个简化的版本是:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

一个future初始化为未解决的,它同过调用set_result来解决。

让我们用futures和协程来改写我们的fetcher。我们之前用回调写的fetcher如下:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

fetch方法开始连接一个套接字,然后注册connected回调函数,它会在套接字建立连接后调用。现在我们使用协程把这两步合并:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

在,fetch是一个生成器,因为他有一个yield语句。我们创建一个未决的future,然后yield它,暂停执行直到套接字连接建立。内函数on_connected解决这个future。

但是当future被解决,谁来恢复这个生成器?我们需要一个协程驱动器。让我们叫它task:

python3的新特性协程爬虫 比之前快了2.3倍速度 经典案例 超详细

task通过传递一个None值给fetch来启动它。fetch运行到它yeild一个future,这个future被task捕获作为next_future。当套接字连接建立,事件循环运行回调函数on_connected,这里future被解决,step被调用,生成器恢复运行。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多