500 line or less | crawler篇阅读笔记


花了大概三天时间阅读了这篇500 line or less|A Web Crawler With asyncio Coroutines

这应该就是真正的深入浅出吧,不仅对python3.4 coroutine进行了详细的阐述,而且源码写的很清晰(主要逻辑大概一共的是300-400行代码,写的很Pythonic

就放一下自己的学习记录和整理,方便以后查阅和理解,感兴趣的同学强烈建议去看原文。



全文都在围绕一个coroutine来进行讲解,从并发和并行的问题,讲到使用事件循环和非阻塞实现的异步框架,讲到yield生成器,以及yield from 生成器代理这么一个东西、以及如何asyncio的简单实现,最后是基于生成器的coroutine程序,也就是爬虫的实现。

重新理解yield

yield 常用于生成器,我们都知道当解释器看到yield不会立刻执行它,一直等到将初始化,如给它send(None),然后它会运行到yield处暂停将。

定义一个fib生成器:

>>> def gen_fn():
...     a,b = 1,1
...     yield a
...     n = 0
...     while n<100:
...         a,b = b,a+b
...         yield a
...         n += 1

python编译后字节码

dis.dis(gen_fn)
  2           0 LOAD_CONST               4 ((1, 1))
              3 UNPACK_SEQUENCE          2
              6 STORE_FAST               0 (a)
              9 STORE_FAST               1 (b)

  3          12 LOAD_FAST                0 (a)
             15 YIELD_VALUE
             16 POP_TOP

  4          17 LOAD_CONST               2 (0)
             20 STORE_FAST               2 (n)

  5          23 SETUP_LOOP              48 (to 74)
        >>   26 LOAD_FAST                2 (n)
             29 LOAD_CONST               3 (100)
             32 COMPARE_OP               0 (<)
             35 POP_JUMP_IF_FALSE       73

  6          38 LOAD_FAST                1 (b)
             41 LOAD_FAST                0 (a)
             44 LOAD_FAST                1 (b)
             47 BINARY_ADD
             48 ROT_TWO
             49 STORE_FAST               0 (a)
             52 STORE_FAST               1 (b)

  7          55 LOAD_FAST                0 (a)
             58 YIELD_VALUE
             59 POP_TOP

  8          60 LOAD_FAST                2 (n)
             63 LOAD_CONST               1 (1)
             66 INPLACE_ADD
             67 STORE_FAST               2 (n)
             70 JUMP_ABSOLUTE           26
        >>   73 POP_BLOCK
        >>   74 LOAD_CONST               0 (None)
             77 RETURN_VALUE
>>> len(fn.gi_code.co_code)
78

可以看到一共78行字节码,注意第15行: 15 YIELD_VALUE

>>> fn.send(None)
1

可以看到,当给它发送一个None,程序就会开始运行,此时程序解释到yield便停下来了

>>> fn.gi_frame.f_lasti
15

这就是生成器的定位变量,last position,这个记录了生成器运行停止的位置。当下次运行就可以继续从这里运行

>>> fn.gi_frame.f_locals
{'a': 1, 'b': 1}

这就是生成器的模拟栈的保存变量的数据结构

500 line or less | crawler篇阅读笔记_第1张图片
>>> fn.send(None)
1
>>> fn.gi_frame.f_locals
{'n': 0, 'a': 1, 'b': 2}
>>> fn.send(None)
2
>>> fn.gi_frame.f_locals
{'n': 1, 'a': 2, 'b': 3}

所以,基于yield的生成器就有个懒加载的特性。

需要注意的是生成器和传统的函数不一样,它的切换是不是靠实打实的栈来保存上下文切换的相关信息的,而是靠在堆中创建的栈模拟对象来实现的,所以对于python程序而言,利用yield就可以自己调度自己的程序。这就是一个coroutine的原型,之后再提

传统的函数调用是这样: Python解释器是一个正常的C程序,所以它的堆栈帧是正常的堆栈帧,当一个函数调用一个子函数,这个被调用函数获得控制权。直到它返回或者有异常发生,才把控制权交给调用者。

>>> import inspect
>>> frame = None
>>> def bar():
...     a = 1
...     foo(a)
... 
>>> def foo(a):
...     print(a)
...     global frame
...     frame = inspect.currentframe()
>>> bar()
1
>>> frame.f_code.co_name
'foo'
>>> frame.f_back

>>> caller_frame = frame.f_back
>>> caller_frame.f_code.co_name
'bar'

可以看出记录关系

500 line or less | crawler篇阅读笔记_第2张图片

yield from

看看yield from 这也是一个很有意思的东西:具备yield from的函数我们也将其作为一个生成器,它的特点是:内部还有一个生成器。

>>> def gen_fn():
...     result = yield 1
...     print('result of yield: {}'.format(result))
...     result2 = yield 2
...     print('result2 of yield: {}'.format(result2))
...     return 'done'
... 
>>> def call_fn():
...     gen = gen_fn()
...     rv = yield from gen
...     print('return values of yield-from: {}'.format(rv))
>>> caller = call_fn()
>>> caller.send(None)
1
>>> caller.gi_frame.f_lasti
15
>>> caller.send('hello')
result of yield: hello
2
>>> caller.gi_frame.f_lasti
15
>>> import dis
>>> dis.dis(caller)
  2           0 LOAD_GLOBAL              0 (gen_fn)
              3 CALL_FUNCTION            0 (0 positional, 0 keyword pair)
              6 STORE_FAST               0 (gen)

  3           9 LOAD_FAST                0 (gen)
             12 GET_YIELD_FROM_ITER
             13 LOAD_CONST               0 (None)
             16 YIELD_FROM
             17 STORE_FAST               1 (rv)

  4          20 LOAD_GLOBAL              1 (print)
             23 LOAD_CONST               1 ('return values of yield-from: {}')
             26 LOAD_ATTR                2 (format)
             29 LOAD_FAST                1 (rv)
             32 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             35 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             38 POP_TOP
             39 LOAD_CONST               0 (None)
             42 RETURN_VALUE

函数在执行每个语句之前递增其指令指针(PC),但是在外部生成器执行yield from之后,它从其指令指针中减去1以保持自己固定在yield from语句。所以caller.gi_frame.f_lasti停在了15行

它的效果是,使用yield from的调用者和内部的生成器之间建立了一个数据交流的管道。

比如caller再通过send(xxx)发送一个值,会直接发送到gen这个生成器中。生成器接着执行任务,再执行到yield语句再返回,这个数据会通过yield from传递到最外面的caller,线程切换到最外层逻辑执行,直到caller再次发送信息,以此循环。直到内部发生器抛出StopIteration。期间,caller.gi_frame.f_lasti一直停在了15行

需要注意的是这个StopIteration可以在caller的逻辑中catch,也可以在内部yield from处进行捕获。如果你想让yield from下的程序继续执行的话就需要在yield from捕获

异步io和事件循环

在看异步io之前先看段同步的代码:

#!/usr/bin/python
# coding: utf-8
import socket

def fetch(link):
    sock = socket.socket()
    sock.connect(('www.zhxfei.com',80))

    request = 'GET {} HTTP/1.0\r\nHOST: zhxfei.com\r\n\r\n'.format(link)
    sock.send(request.encode('ascii'))
    response = b''
    chunk = sock.recv(4096)
    while chunk:
        response += chunk
        chunk = sock.recv(4096)

    print(response)

fetch('/')

这是一个简单的同步阻塞的套接字客户端程序,调用sock.connect(())是阻塞的,其在client端发送第三次握手报文之后返回,即在TCP前两次握手,此程序不能继续往下执行,等待connect返回。

而一个非阻塞的程序是这样的:

import socket

sock = socket.socket()
sock.setblocking(False)
try:
    sock.connect(('zhxfei.com', 80))
except BlockingIOError:
    pass

request = 'GET /353/ HTTP/1.0\r\nHost: zhxfei.com\r\n\r\n'
encoded = request.encode('ascii')

... so on

sock.connect发起调用便直接返回了,去准备待会要发送的request,非阻塞经常和事件驱动连用,在linux上就是epoll了。

epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。epoll支持水平触发和边缘触发,最大的特点在于「边缘触发」,它只告诉进程哪些刚刚变为就绪态,并且只会通知一次。
epoll技术的编程模型就是异步非阻塞回调,也可以叫做Reactor、事件驱动、事件轮循(EventLoop)、libevent、Tornado、Node.js这些就是epoll时代的产物。

这里提到了事件循环:事件循环就“是一种等待程序分配事件或消息的编程架构”。基本上来说事件循环就是,“A发起调用前,注册时间循环,当A发起调用,直接返回到事件循环,当循环监听到A注册的具体事件,执行B”。引用网上的一段翻译文档:

最简单的例子来解释这一概念就是用每个浏览器中都存在的JavaScript事件循环。当你点击了某个东西(“当A发生时”),这一点击动作会发送给JavaScript的事件循环,并检查是否存在注册过的 onclick 回调来处理这一点击(“执行B”)。只要有注册过的回调函数就会伴随点击动作的细节信息被执行。事件循环被认为是一种循环是因为它不停地收集事件并通过循环来发如何应对这些事件。事件循环也经常用于在别的线程或子进程中执行代码,并将事件循环作为调节机制(例如,合作式多任务)。如果你恰好理解 Python 的 GIL,事件循环对于需要释放 GIL 的地方很有用

举一个例子:

#!/usr/bin/env python3
# coding:utf-8
import threading
import socket
import time
from selectors import DefaultSelector,\
                        EVENT_WRITE,\
                            EVENT_READ


host = 'baidu.com'
selector = DefaultSelector()

class Fetcher:
    def __init__(self,url):
        self.url = url
        self.res = b''
        self.sock = None

    def fetch(self):
        self.sock = socket.socket()
        self.sock.setblocking(False)
        print('Thread {} : socket init'.format(threading.currentThread()))
        try:
            self.sock.connect((host,80))
        except BlockingIOError:
            pass

        selector.register(self.sock.fileno(),
                            EVENT_WRITE,
                            self.connneted)

    def connneted(self,key,mask):
        selector.unregister(key.fd)
        print('connected....')
        print('Thread {} : socket connected'.format(
                        threading.currentThread()))

        request = 'GET {} HTTP/1.1\r\nHOST: {}\r\n\r\n'.format(
                        self.url,host).encode('utf-8')

        self.sock.send(request)
        selector.register(key.fd,
                            EVENT_READ,
                            self.read_res)

    def read_res(self,key,mask):
        print('Thread {} : socket readable'.format(
                        threading.currentThread()))

        chunk = self.sock.recv(4096)
        if chunk:
            self.res += chunk
        else:
            selector.unregister(key.fd)
            '''
            a crawler may have some code just like:

                global stopped #全局停止标志位
                links = self.parse_link(self.response)  #解析出link
                to_do_link.add(links)   #将link添加到to do list
                seen_link.add(self.url) #将self.url添加到已经download过的集合中
                to_do_link.remove(links)    #将self.url在to do list集合中删除
                if not to_do_link:
                    stopped == True
            '''

def loop():
    fetcher = Fetcher('/')
    fetcher.fetch()
    while True:
        events = selector.select()
        for event_key,event_mask in events:
            callback = event_key.data
            callback(event_key,event_mask)
            print('callback time:{}'.format(time.time()))
        print(fetcher.res)
loop()
zhxfei@zhxfei-HP-ENVY-15-Notebook-PC:~/code/python/500lines$ sudo python3 non_blocking_test.py
Thread <_MainThread(MainThread, started 139838592571136)> : socket init
connected....
Thread <_MainThread(MainThread, started 139838592571136)> : socket connected
callback time:1489293273.1518176
b''
Thread <_MainThread(MainThread, started 139838592571136)> : socket readable
callback time:1489293273.182348
b'HTTP/1.1 200 OK\r\nDate: Sun, 12 Mar 2017 04:34:33 GMT\r\nServer: Apache\r\nLast-Modified: Tue, 12 Jan 2010 13:48:00 GMT\r\nETag: "51-47cf7e6ee8400"\r\nAccept-Ranges: bytes\r\nContent-Length: 81\r\nCache-Control: max-age=86400\r\nExpires: Mon, 13 Mar 2017 04:34:33 GMT\r\nConnection: Keep-Alive\r\nContent-Type: text/html\r\n\r\n\n\n\n'

这个demo执行逻辑就是:发起调用前在事件循环上注册信息和回调函数,当非阻塞的调用返回之后,回到事件循环,由事件循环监听对应的注册的信息,如套接字可读,可写等事件发生,发起调用。

在这里connect注册了可写,发起connect非阻塞调用,之后回到事件循环,监听事件发生,socket可写之后,调用callback函数connected,在回调函数中,发送http请求,并在此注册可读事件,以及读事件发生的回调函数,之后再次回到事件循环,事件循环监听事件读发生,再次调用read_res,就这样,我们的逻辑将靠着注册->event loop->callback回调运行下去。需要注意的是,这里进行操作的是一个线程。

这个程序使用了一个selector的模块,这个模块将epollkqueue等等系统级异步IO接口抽象成Selector类型,规定了统一的对外接口,于是程序只管使用selector的接口就行了。一般使用selectors.DefaultSelector就好了,它是这个模块根据你的系统自动帮你选择的最合适的Selector

我们可以知道当等待I/O操作时,一个函数必须明确的保存它的状态,因为它会在I/O操作完成之前返回并清除栈帧。一个基于回调的异步框架,语言特性不能帮助我们保存程序的状态。

为了在我们基于回调的例子中代替局部变量,我们把sockresponse作为Fetcher实例self属性。为了代替指令指针,通过注册connnectedread_response回调来保存continuation。

这种完全依赖于callback的程序有很多问题,如

  • 意大利面条式的程序
  • 因为”stack ripping”问题而非常难于调试

所以大神们想出了使用yield生成器进行回调,上面提到过,生成器可以保存相关的信息,在适当的时候再切换回生成器继续执行,这样看起来,这天生就是一个callback

但是需要注意的是,既然我们用生成器,就要考虑几个问题。

  • 如何优雅的初始化和退出生成器
  • 如何控制生成器让其进行回调
  • 每次切换回生成器如何恢复其状态

如下面这段代码:

#!/usr/bin/env python
# coding:utf-8
import socket
from selectors import DefaultSelector,\
                        EVENT_READ,\
                        EVENT_WRITE

selector = DefaultSelector()
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)


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)



def connect(sock, address):
    f = Future()
    sock.setblocking(False)
    try:
        sock.connect(address)
    except BlockingIOError:
        pass

    def on_connected():
        f.set_result(None)

    selector.register(sock.fileno(), EVENT_WRITE, on_connected)
    yield from f
    selector.unregister(sock.fileno())


def read(sock):
    f = Future()

    def on_readable():
        f.set_result(sock.recv(4096))  # Read 4k at a time.

    selector.register(sock.fileno(), EVENT_READ, on_readable)
    chunk = yield from f
    selector.unregister(sock.fileno())
    return chunk


def read_all(sock):
    response = []
    chunk = yield from read(sock)
    while chunk:
        response.append(chunk)
        chunk = yield from read(sock)

    return b''.join(response)


class Fetcher:
    def __init__(self, url):
        self.response = b''
        self.url = url

    def fetch(self):

        sock = socket.socket()
        yield from connect(sock, ('zhxfei.com', 80))
        get = 'GET {} HTTP/1.0\r\nHost: zhxfei.com\r\n\r\n'.format(self.url)
        sock.send(get.encode('ascii'))
        self.response = yield from read_all(sock)
        '''
        the program is not end
        
        '''

def loop():
    fetcher = Fetcher('/')
    Task(fetcher.fetch())
    while True:
        events = selector.select()
        for event_key,event_mask in events:
            callback = event_key.data
            callback(event_key,event_mask)

loop()

终于到这段程序了,这段程序困扰了我一个晚上(仅供分析),上面的程序是简化版本。放两张图感受下:


OK,上面我们可以看见:

它用了一个Task来控制生成器的初始化和结束,并且控制生成器的回调,它是一个生成器最顶层的caller。在它控制的子程序中,还嵌套yield from,在子子程序中,也许还嵌套着yield from,到处使用这种yield from的好处是可以解耦这个协同工作的程序的实现和调用,符合了开闭原则。

500 line or less | crawler篇阅读笔记_第3张图片

它用一个 future表示了生成器在切换的时候希望保存的一些信息如 results以及什么时候进行回调。我们在事件循环上进行了回调函数的操作,这个回调函数是希望生成器继续运行的保障,它实际上注册的是一个这个生成器调度的一段主程序。

这是一段设计十分精巧的逻辑,也许是我太too young too native,不得不说,我叹服你的技巧

基于yield的coroutine

这种基于生成器协调工作的程序,就是协程(coroutine)。源码关于这个逻辑大概300行,我将其简化下,去掉了一些日志、命令行解析、爬虫等等具体实现。

其实我就是想看看这个py3.4版本的coroutine是如何工作及应用的,我保留了生产者和消费者的模式,线程在对象初始化的时候向Queue写入了5个task,之后这个协程在self.queue.join被暂停,等待queue中的任务被消费

from asyncio import Queue
import aiohttp
import asyncio
import pdb
import threading
import time

roots = [
    'www.zhxfei.com',
    'www.baidu.com',
    'www.google.com',
    'www.tencent.com',
    'www.qq.com'
    ]


class Crawler:
    def __init__(self,roots,max_task=5):
        self.roots = roots
        self.queue = Queue()
        self.max_task = max_task
        #self.session = aiohttp.ClientSession()
        self.seen_urls = set()
        for root in roots:
            self.add_url(root)

    def add_url(self,root):
        if root in self.seen_urls:
            return
        self.queue.put_nowait(root)

    @asyncio.coroutine
    def crawler(self):
        tasks = [asyncio.Task(self.work())
                        for _ in range(self.max_task)]
        #pdb.set_trace()
        yield from self.queue.join()
        for w in tasks:
            w.cancel()  # w is the object of Task

    @asyncio.coroutine
    def work(self):
        '''consume coroutine'''
        try:
            while True:
            # when the queue has no links , the queue.get blocked
                url = yield from self.queue.get()
                print('{}'.format(threading.currentThread()))
                yield from self.fetch(url)
                self.queue.task_done()
        except asyncio.CancelledError:
            pass

    @asyncio.coroutine
    def fetch(self,url):
        yield from asyncio.sleep(1)

def main():
    t1 = time.time()
    loop = asyncio.get_event_loop()
    crawler = Crawler(roots)
    #pdb.set_trace()
    loop.run_until_complete(crawler.crawler())
    loop.close()
    print('COST:{}'.format(time.time() - t1))

if __name__ == '__main__':
    main()

使用pdb运行调试,你会发现程序

zhxfei@zhxfei-HP-ENVY-15-Notebook-PC:~/code/python/500lines$ python3 asyncio_crawler.py 
> /home/zhxfei/code/python/500lines/asyncio_crawler.py(59)main()
-> loop.run_until_complete(crawler.crawler())
(Pdb) p crawler.queue._unfinished_tasks
5
(Pdb) c
> /home/zhxfei/code/python/500lines/asyncio_crawler.py(35)crawler()
-> yield from self.queue.join()
(Pdb) c

之后1s就退出了程序

zhxfei@zhxfei-HP-ENVY-15-Notebook-PC:~/code/python/500lines$ python3 asyncio_crawler.py 
<_MainThread(MainThread, started 140192578610944)>
<_MainThread(MainThread, started 140192578610944)>
<_MainThread(MainThread, started 140192578610944)>
<_MainThread(MainThread, started 140192578610944)>
<_MainThread(MainThread, started 140192578610944)>
COST:1.0039052963256836

可以看到,每个consume程序都自己睡眠,而一个线程就可以在多个代码段跳转运行,完全省去了多进程/线程的创建、维护、调度、上下文切换等等开销。最重要的是:当多个线程去操作临界量的时候,为了保证线程安全,往往需要带锁操作,比如会出现临界区变量不一致的情况发生,甚至对于面对超高并发的情况下,甚至需要做出对accept加锁这样及其消耗性能的事情,而在coroutine看来一切都很简单。

让我们再回到上面的程序,考虑这么个问题程序是如何运行的呢?

首先程序什么时候开始?更或者说,上帝如何推动这个小球呢?

loop = asyncio.get_event_loop()
crawler = Crawler(roots)
loop.run_until_complete(crawler.crawler())

首先,我们创建了一个类似事件循环这样的东西,然后创建了crawler这个实例并且将这个实例的主逻辑程序crawler放到了run_until_complete的这个方法,我们需要搞清楚这个event_loop如何工作的

class EventLoop:
    def run_until_complete(self, coro):
        """Run until the coroutine is done."""
        task = Task(coro)
        task.add_done_callback(stop_callback)
        try:
            self.run_forever()
        except StopError:
            pass

class StopError(BaseException):
    """Raised to stop the event loop."""

def stop_callback(future):
    raise StopError

可以看到我们的主线程被Task包装了以下,并生成了一个task,在初始化的时候,同时创建了5个经过Task包装的消费者进程,即work,其也是一个协同程序,并且也用Task包装了一下。这个task其实是future的子类。
我们仔细看看这个Task是如何初始化协程的:

class Task(Future):

    def step(self, future):
        try:
            next_future = self.coro.send(future.result)
        except CancelledError:
            self.cancelled = True
            return
        except StopIteration as exc:

            # Task resolves itself with coro's return
            # value.
            self.set_result(exc.value)
            return

        next_future.add_done_callback(self.step)

可以看到,在step方法中,我们向传入的或者说包装的coroutine发送了一个值,让其可以初始化。它和上面的future还不一样,它是一个需要自我驱动且自我记录状态的future

直到这个corotinecrawler()运行到对应的协调状态保存对应的状态,也就是self.queue.join(),其还是一个协程,并内部创建了一个Future()的实例,并将其返回。

class Queue:
    def __init__(self):
        self._join_future = Future()
        self._unfinished_tasks = 0
        # ... other initialization ...

    def put_nowait(self, item):
        self._unfinished_tasks += 1
        # ... store the item ...

    def task_done(self):
        self._unfinished_tasks -= 1
        if self._unfinished_tasks == 0:
            self._join_future.set_result(None)

    @asyncio.coroutine
    def join(self):
        if self._unfinished_tasks > 0:
            yield from self._join_future

且在这个future实例也就是task记录的状态中增加stop_callback,并等待self.queue.join()运行完成

之后循环会调度出其他的协调程序,即work,work就一直运行到self.queue.task_done(),直到所有的协程都运行完成并停在self.queue.get(),即队列中self._unfinished_tasks清零。

crawler又被唤醒,之后依次调用task对象的cancel,向每个work协程中抛进去一个异常asyncio.CancelledErrorwork将其捕获之后退出,之后crawler退出,抛出StopIterationTaskTask捕获到并用set_results消费之前注册的回调stop_callback,抛出StopError给事件循环,Eventloop捕获StopError之后close。这个程序就结束了

逻辑可能略显混乱和复杂...

最后

其实我想说,这是python3.5之前版本实现的基于生成器的协程

在python3.5 ,只有async def关键字定义的函数才能被叫做协程,所以。。。。

我花了几天,学到了个假协程

>>> async def fn():
...     await asyncio.sleep(1)
...     print('hello world')
... 
>>> f = fn()
>>> f

>>> inspect.iscoroutine(f)
True
>>> @asyncio.coroutine
... def fm():
...     yield from asyncio.sleep(1)
...     print('this is a coroutine')
... 
>>> m = fm()
>>> m

>>> inspect.iscoroutine(m)
False

还是 too young too native
学无止境,慢慢来呗

你可能感兴趣的:(500 line or less | crawler篇阅读笔记)