异步网络爬虫的Python实现(3)

27 篇文章 0 订阅

本文继续上一节的话题:异步网络爬虫的实现。

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结束的时候,它的返回值会成为调用者calleryield 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部分代码就像是普通的函数代码,不过它们可以完成异步操作,实际上readread_all都是协程。readyield返回会暂停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()
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Python可以使用多种库和框架来实现异步爬虫,其中最常用的是`asyncio`和`aiohttp`。 首先,你需要确保你的Python版本是3.5或更高版本,因为异步编程在这些版本中得到了很好的支持。 接下来,你可以使用`asyncio`库来创建异步任务。异步任务是使用协程(coroutine)定义的,通过使用`async`关键字来声明一个协程函数。在协程函数中,你可以使用`await`关键字来等待其他的异步任务完成。 以下是一个基本的异步爬虫的示例: ```python import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as response: return await response.text() async def main(): async with aiohttp.ClientSession() as session: url = 'https://example.com' html = await fetch(session, url) print(html) loop = asyncio.get_event_loop() loop.run_until_complete(main()) ``` 在这个示例中,我们定义了一个`fetch`函数来发送HTTP请求并返回响应内容。然后,在`main`函数中,我们创建了一个`ClientSession`对象来处理HTTP请求,并且使用`fetch`函数来获取网页内容。最后,我们使用`asyncio.get_event_loop()`来获取事件循环,并调用`run_until_complete()`方法来运行主函数。 这只是一个简单的例子,你可以根据你的需求对其进行扩展和定制。还有其他的库和方法可以用来实现异步爬虫,例如`scrapy`框架、`httpx`库等,你可以根据自己的需求选择合适的工具。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值