原创作者: 法狗狗 NLP 数据科学家 - 大头队长
前言
关于网络 IO, 同步, 异步或者阻塞非阻塞永远是绕不开的话题. 对于爬虫这种 IO 密集型任务, 如果使用同步的方式进行网络请求, 只要某个请求被阻塞了, 则会造成整个流程时间的拖长, 极大的降低了爬虫的速度. 这篇文章将介绍 Python 实现异步的其中一种方式, 协程. 并通过一些实例来说明协程的用法, 以及好处.
基本知识
同步, 异步
上文提到了同步与异步两个术语, 那么这两者到底有什么区别呢? 拿爬虫场景举个例子, 比如说现在爬虫需要点开十个链接, IO 过程就是打开这十个链接的过程, CPU 负责点击链接的事件. 显而易见, 点击事件是非常快速的, 而链接显示的过程是比较缓慢的. 同步 IO 就是爬虫点击了一个网址, 等到获得了彻底响应, 才去点击下一个网址. 而异步 IO 就是爬虫点击了一个网址, 不等获得响应, 立刻点击下一个网址, 最后等待响应. 两者对比, 异步的效率会更加高一些.
IO 密集与计算密集
IO 密集型任务指的是磁盘 IO 或者网络 IO 占主要的任务, 计算量很小, 比如请求网页, 读写文件等. 计算密集型任务指的是 CPU 计算占主要的任务, 比如图形渲染中矩阵的运算(当然现在都用 GPU 来完成).
为什么不使用多线程
一般来说, 解决并行事件的传统思路可能是使用多线程. 但是多线程有几个劣势, 第一是资源的开销, 第二是由于 Python GIL(全局解释锁)的存在, 多线程并非并行执行, 而是交替执行, 造成多线程在计算密集型任务的效率并不高.
协程是什么
协程的概念比较容易理解, 它在单线程中, 允许一个执行过程 A 中断, 然后转到执行过程 B, 在合适的时间又可以转回来, 从而在单线程中实现了类似多线程的效果. 而它有以下几点优势:
数量理论上可以是无限个, 因为是在单线程上进行, 没有线程间的切换操作, 效率比较高.
不需要 "锁" 的机制, 所有的协程都在一个线程中.
比较容易 Debug, 因为代码是顺序执行.
上面的内容解释了使用协程的背景, 与传统多线程的区别以及协程的概念. 接下来用一段代码来详细说明协程工作的过程.
Python 中的协程实现
StackOverflow 上曾经有个人问, 如何使用 Python 以最快的方式发送 10000 个 HTTP 请求 https://stackoverflow.com/questions/2632520/what-is-the-fastest-way-to-send-100-000-http-requests-in-python , 其最佳答案是这么实现的:
- from urlparse import urlparse
- from threading import Thread
- import httplib, sys
- from Queue import Queue
- concurrent = 200
- def doWork():
- while True:
- url = q.get()
- status, url = getStatus(url)
- doSomethingWithResult(status, url)
- q.task_done()
- def getStatus(ourl):
- try:
- url = urlparse(ourl)
- conn = httplib.HTTPConnection(url.netloc)
- conn.request("HEAD", url.path)
- res = conn.getresponse()
- return res.status, ourl
- except:
- return "error", ourl
- def doSomethingWithResult(status, url):
- print status, url
- q = Queue(concurrent * 2)
- for i in range(concurrent):
- t = Thread(target=doWork)
- t.daemon = True
- t.start()
- try:
- for url in open('urllist.txt'):
- q.put(url.strip())
- q.join()
- except KeyboardInterrupt:
- sys.exit(1)
这段代码的主要思想是, 使用了多线程从队列中获取数据, 利用 IO 阻塞等待的时间, 执行其它线程, 提升了效率. 正如上文解释 Python 的多线程所述, 多线程并非同时执行任务而是交替执行. 那么是否可以利用协程的方式, 只使用一个线程就能完成这个 IO 任务呢?
我们先来看看 Python 实现协程的基本原理, Python 中的协程是通过生成器的概念实现的, 我们来看一个例子:
- def consumer():
- print("[Consumer] Init Consumer ......")
- r = "init ok"
- while True:
- # Consumer 通过 yield 拿到消息, 又通过 yield 把结果返回
- n = yield r
- print("[Consumer] conusme n = %s, r = %s" % (n, r))
- r = "consume %s OK" % n
- def produce(c):
- print("[Producer] Init Producer ......")
- # 调用 c.send(None)启动生成器
- r = c.send(None)
- print("[Producer] Start Consumer, return %s" % r)
- n = 0
- while n < 5:
- n += 1
- print("[Producer] While, Producing %s ......" % n)
- # 一旦生产了东西, 通过 c.send(n)切换到 consumer 执行
- r = c.send(n)
- print("[Producer] Consumer return: %s" % r)
- c.close()
- print("[Producer] Close Producer ......")
- produce(consumer())
上面的例子来自廖雪峰的 Python 教程, 其执行结果如下:
[Producer] Init Producer ......
[Consumer] Init Consumer ......
[Producer] Start Consumer, return init ok
[Producer] While, Producing 1 ......
- [Consumer] conusme n = 1, r = init ok
- [Producer] Consumer return: consume 1 OK
[Producer] While, Producing 2 ......
- [Consumer] conusme n = 2, r = consume 1 OK
- [Producer] Consumer return: consume 2 OK
[Producer] While, Producing 3 ......
- [Consumer] conusme n = 3, r = consume 2 OK
- [Producer] Consumer return: consume 3 OK
[Producer] While, Producing 4 ......
- [Consumer] conusme n = 4, r = consume 3 OK
- [Producer] Consumer return: consume 4 OK
[Producer] While, Producing 5 ......
- [Consumer] conusme n = 5, r = consume 4 OK
- [Producer] Consumer return: consume 5 OK
[Producer] Close Producer ......
可以看出生产者和消费者之间的任务是来回切换的, 传统的生产者 - 消费者模式是一个线程写消息, 一个线程取消息, 通过锁机制控制队列和等待, 有可能发生死锁的情况. 而协程在生产者生产完消息后, 直接通过 yield 跳转到消费者开始执行, 待消费者执行完毕后, 切换回生产者继续生产, 效率很高.
针对网络请求 IO 的场景, 我们可以做个实验来比较一下同步 IO 与异步 IO 的速度,
- import requests
- import time
- def consumer():
- r = ''
- while True:
- n = yield r
- if not n:
- return
- print('[CONSUMER] Consuming %s...' % n)
- r = requests.get(n).status_code
- def produce(c):
- c.send(None)
- for i in range(73000, 73100):
- request_url = "http://www.jb51.net/article/%d.htm" % i
- print('[PRODUCER] Producing %s...' % request_url)
- r = c.send(request_url)
- print('[PRODUCER] Consumer return: %s' % r)
- c.close()
- async_start = time.time()
- produce(consumer())
- print(time.time() - async_start)
- sync_start = time.time()
- for i in range(73000, 73100):
- url = "http://www.jb51.net/article/%d.htm" % i
- response = requests.get(url)
- print(time.time() - sync_start)
运行了一次的最后输出结果, 通过协程实现的异步 IO9.8 秒就完成了请求任务, 而同步 IO 使用了 28.6 秒. 可以看出异步 IO 的效率确实有了很大提升.
写在之后
上面的文字简单的介绍了一下协程的基本特性以及如何使用 Python 实现协程从而达到异步 IO 的效果. 关于网络 IO 还有很多可以讨论的东西, 这里给出一个学习路线,
有兴趣可以去看看 Python3 对于异步 IO 的支持, 比如 aiohttp 这些库, 极大的降低了编码难度.
另外像 Tornado 这类支持异步非阻塞的 web 框架也是特别有意思的, 这次写问答系统的爬虫 Web 服务就是基于这个库.
如果还想继续深入了解, 建议去看看Linux/Unix 系统编程手册关于 socket 的几章, 对于理解 IO 机制的底层原理 (比如 epoll,select) 特别有帮助(我其实没看过, 以后有时间会继续研究).
来源: https://juejin.im/entry/5b87a2eee51d45389005bb31