Python协程光速入门

1,266 阅读6分钟
原文链接: zhuanlan.zhihu.com

Python协程光速入门

原创作者:法狗狗 NLP 数据科学家 - 大头队长

前言

关于网络IO,同步,异步或者阻塞非阻塞永远是绕不开的话题。对于爬虫这种IO密集型任务,如果使用同步的方式进行网络请求,只要某个请求被阻塞了,则会造成整个流程时间的拖长,极大的降低了爬虫的速度。这篇文章将介绍Python实现异步的其中一种方式,协程。并通过一些实例来说明协程的用法,以及好处。

基本知识

  • 同步,异步

上文提到了同步与异步两个术语,那么这两者到底有什么区别呢?拿爬虫场景举个例子,比如说现在爬虫需要点开十个链接,IO过程就是打开这十个链接的过程,CPU负责点击链接的事件。显而易见,点击事件是非常快速的,而链接显示的过程是比较缓慢的。同步IO就是爬虫点击了一个网址,等到获得了彻底响应,才去点击下一个网址。而异步IO就是爬虫点击了一个网址,不等获得响应,立刻点击下一个网址,最后等待响应。两者对比,异步的效率会更加高一些。

  • IO密集与计算密集

IO密集型任务指的是磁盘IO或者网络IO占主要的任务,计算量很小,比如请求网页,读写文件等。计算密集型任务指的是CPU计算占主要的任务,比如图形渲染中矩阵的运算(当然现在都用GPU来完成)。

  • 为什么不使用多线程

一般来说,解决并行事件的传统思路可能是使用多线程。但是多线程有几个劣势,第一是资源的开销,第二是由于Python GIL(全局解释锁)的存在,多线程并非并行执行,而是交替执行,造成多线程在计算密集型任务的效率并不高。

  • 协程是什么

协程的概念比较容易理解,它在单线程中,允许一个执行过程A中断,然后转到执行过程B,在合适的时间又可以转回来,从而在单线程中实现了类似多线程的效果。而它有以下几点优势:

    • 数量理论上可以是无限个,因为是在单线程上进行,没有线程间的切换操作,效率比较高。
    • 不需要“锁”的机制,所有的协程都在一个线程中。
    • 比较容易Debug,因为代码是顺序执行。

上面的内容解释了使用协程的背景,与传统多线程的区别以及协程的概念。接下来用一段代码来详细说明协程工作的过程。

Python中的协程实现

StackOverflow上曾经有个人问,如何使用Python以最快的方式发送10000个HTTP请求,其最佳答案是这么实现的:

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)特别有帮助(我其实没看过,以后有时间会继续研究)。

文章发布自法狗狗官方技术博客 - 法狗狗的技术窝