经过网络图片小爬虫对比Python中单线程与多线(进)程的效率

批评 Python 的人一般都会说 Python 的多线程编程太困难了,众所周知的全局解释器锁(Global Interpreter Lock,或称 GIL)使得多个线程的 Python 代码没法同时运行。所以,若是你并不是 Python 开发者,而是从其余语言如 C++ 或者 Java 转过来的话,你会以为 Python 的多线程模块并无以你指望的方式工做。但必须澄清的是,只要以一些特定的方式,咱们仍然可以编写出并发或者并行的 Python 代码,并对性能产生彻底不一样的影响。若是你还不理解什么是并发和并行,建议你百度或者 Google 或者 Wiki 一下。html

在这篇阐述 Python 并发与并行编程的入门教程里,咱们将写一小段从 Imgur 下载最受欢迎的图片的 Python 程序。咱们将分别使用顺序下载图片和同时下载多张图片的版本。在此以前,你须要先注册一个 Imgur 应用。若是你尚未 Imgur 帐号,请先注册一个。python

这篇教程的 Python 代码在 3.4.2 中测试经过。但只需一些小的改动就能在 Python 2中运行。两个 Python 版本的主要区别是 urllib2 这个模块。git

注:考虑到国内严酷的上网环境,译者测试原做的代码时直接卡在了注册 Imgur 帐号这一步。所以为了方便起见,译者替换了图片爬取资源。一开始使用的某生产商提供的图片 API ,但不知道是网络缘由仍是其余缘由致使程序在读取最后一张图片时没法退出。因此译者一怒之下采起了原始爬虫法,参考着 requests 和 beautifulsoup4 的文档爬取了某头条 253 张图片,觉得示例。译文中的代码替换为译者使用的代码,如需原始代码请参考原文 Python Multithreading Tutorial: Concurrency and Parallelismgithub

Python 多线程起步

首先让咱们来建立一个名为 download.py 的模块。这个文件包含全部抓取和下载所需图片的函数。咱们将所有功能分割成以下三个函数:编程

  • get_linksjson

  • download_linkapi

  • setup_download_dir安全

第三个函数,setup_download_dir 将会建立一个存放下载的图片的目录,若是这个目录不存在的话。网络

咱们首先结合 requests 和 beautifulsoup4 解析出网页中的所有图片连接。下载图片的任务很是简单,只要经过图片的 URL 抓取图片并写入文件便可。多线程

代码看起来像这样:

download.py

import json
import os
import requests

from itertools import chain
from pathlib import Path

from bs4 import BeautifulSoup

# 结合 requests 和 bs4 解析出网页中的所有图片连接,返回一个包含所有图片连接的列表
def get_links(url):
    req = requests.get(url)
    soup = BeautifulSoup(req.text, "html.parser")
    return [img.attrs.get('data-src') for img in
            soup.find_all('div', class_='img-wrap')
            if img.attrs.get('data-src') is not None]

# 把图片下载到本地
def download_link(directory, link):
    img_name = '{}.jpg'.format(os.path.basename(link))
    download_path = directory / img_name
    r = requests.get(link)
    with download_path.open('wb') as fd:
            fd.write(r.content)

# 设置文件夹,文件夹名为传入的 directory 参数,若不存在会自动建立
def setup_download_dir(directory):
    download_dir = Path(directory)
    if not download_dir.exists():
        download_dir.mkdir()
    return download_dir

接下来咱们写一个使用这些函数一张张下载图片的模块。咱们把它命名为single.py。咱们的第一个简单版本的 图片下载器将包含一个主函数。它会调用 setup_download_dir 建立下载目录。而后,它会使用 get_links 方法抓取一系列图片的连接,因为单个网页的图片较少,这里抓取了 5 个网页的图片连接并把它们组合成一个列表。最后调用 download_link 方法将所有图片写入磁盘。这是 single.py 的代码:

single.py

from time import time
from itertools import chain

from download import setup_download_dir, get_links, download_link


def main():
    ts = time()

    url1 = 'http://www.toutiao.com/a6333981316853907714'
    url2 = 'http://www.toutiao.com/a6334459308533350658'
    url3 = 'http://www.toutiao.com/a6313664289211924737'
    url4 = 'http://www.toutiao.com/a6334337170774458625'
    url5 = 'http://www.toutiao.com/a6334486705982996738'
    download_dir = setup_download_dir('single_imgs')
    links = list(chain(
        get_links(url1),
        get_links(url2),
        get_links(url3),
        get_links(url4),
        get_links(url5),
    ))
    for link in links:
        download_link(download_dir, link)
    print('一共下载了 {} 张图片'.format(len(links)))
    print('Took {}s'.format(time() - ts))


if __name__ == '__main__':
    main()

"""
一共下载了 253 张图片
Took 166.0219452381134s
"""

在个人笔记本上,这段脚本花费了 166 秒下载 253 张图片。请注意花费的时间因网络的不一样会有所差别。166 秒不算太长。但若是咱们要下载更多的图片呢?2530 张而不是 253 张。平均下载一张图片花费约 1.5 秒,那么 2530 张图片将花费约 28 分钟。25300 张图片将要 280 分钟。但好消息是经过使用并发和并行技术,其将显著提高下载速度。

接下来的代码示例只给出为了实现并发或者并行功能而新增的代码。为了方便起见,所有的 python 脚本能够在 这个GitHub的仓库 获取。(注:这是原做者的 GitHub 仓库,是下载 Imgur 图片的代码,本文的代码存放在这:concurrency-parallelism-demo)。

使用多线程实现并发和并行

线程是你们熟知的使 Python 获取并发和并行能力的方式之一。线程一般是操做系统提供的特性。线程比进程要更轻量,且共享大部份内存空间。

在咱们的 Python 多线程教程中,咱们将写一个新的模块来替换 single.py 模块。这个模块将建立一个含有 8 个线程的线程池,加上主线程一共 9 个线程。我选择 8 个工做线程的缘由是由于个人电脑是 8 核心的。一核一个线程是一个不错的选择。但即便是同一台机器,对于不一样的应用和服务也要综合考虑各类因素来选择合适的线程数。

过程基本上面相似,只是多了一个 DownloadWorker 的类,这个类继承自 Thread。咱们覆写了 run 方法,它执行一个死循环,每一次循环中它先调用 self.queue.get()方法,尝试从一个线程安全的队列中获取一个图片的 URL 。在线程从队列获取到 URL 以前,它将处于阻塞状态。一旦线程获取到一个 URL,它就被唤醒,并调用上一个脚本中的 download_link 方法下载图片到下载目录中。下载完成后,线程叫发送完成信号给队列。这一步很是重要,由于队列或跟踪记录当前队列中有多少个线程正在执行。若是线程不通知队列下载任务已经完成,那么 queue.join() 将使得主线程一直阻塞。

thread_toutiao.py

import os
from queue import Queue
from threading import Thread
from time import time
from itertools import chain

from download import setup_download_dir, get_links, download_link


class DownloadWorker(Thread):

    def __init__(self, queue):
        Thread.__init__(self)
        self.queue = queue

    def run(self):
        while True:
            # Get the work from the queue and expand the tuple
            item = self.queue.get()
            if item is None:
                break
            directory, link = item
            download_link(directory, link)
            self.queue.task_done()


def main():
    ts = time()

    url1 = 'http://www.toutiao.com/a6333981316853907714'
    url2 = 'http://www.toutiao.com/a6334459308533350658'
    url3 = 'http://www.toutiao.com/a6313664289211924737'
    url4 = 'http://www.toutiao.com/a6334337170774458625'
    url5 = 'http://www.toutiao.com/a6334486705982996738'
    download_dir = setup_download_dir('thread_imgs')
    # Create a queue to communicate with the worker threads
    queue = Queue()

    links = list(chain(
        get_links(url1),
        get_links(url2),
        get_links(url3),
        get_links(url4),
        get_links(url5),
    ))

    # Create 8 worker threads
    for x in range(8):
        worker = DownloadWorker(queue)
        # Setting daemon to True will let the main thread exit even though the
        # workers are blocking
        worker.daemon = True
        worker.start()

    # Put the tasks into the queue as a tuple
    for link in links:
        queue.put((download_dir, link))

    # Causes the main thread to wait for the queue to finish processing all
    # the tasks
    queue.join()
    print('一共下载了 {} 张图片'.format(len(links)))
    print('Took {}s'.format(time() - ts))


if __name__ == '__main__':
    main()

"""
一共下载了 253 张图片
Took 57.710124015808105s
"""

在同一机器上运行这段脚本下载相同张数的图片花费 57.7 秒,比前一个例子快了约 3 倍。尽管下载速度更快了,但必须指出的是,由于 GIL 的限制,同一时间仍然只有一个线程在执行。所以,代码只是并发执行而不是并行执行。其比单线程下载更快的缘由是由于下载图片是 IO 密集型的操做。当下载图片时处理器便空闲了下来,处理器花费的时间主要在等待网络链接上。这就是为何多线程会大大提升下载速度的缘由。当当前线程开始执行下载任务时,处理器即可以切换到其余线程继续执行。使用 Python 或者其余拥有 GIL 的脚本语言会下降机器性能。若是的你的代码是执行 CPU 密集型的任务,例如解压一个 gzip 文件,使用多线程反而会增加运行时间。对于 CPU 密集型或者须要真正并行执行的任务咱们可使用 multiprocessing 模块。

尽管 Python 的标准实现 CPython 有 GIL,但不是全部的 python 实现都有 GIL。例如 IronPython,一个基于 。NET 的 Python 实现就没有 GIL,一样的,Jython,基于 Java 的 Python 实现也没有。你能够在 这里 查看 Python 的实现列表。

使用多进程

multiprocessing 模块比 threading 更容易使用,由于咱们不用像在上一个例子中那样建立一个线程类了。咱们只需修改一下 main 函数。

为了使用多进程,咱们建立了一个进程池。使用 multiprocessing 提供的 map 方法,咱们将一个 URLs 列表传入进程池,它会开启 8 个新的进程,并让每个进程并行地去下载图片。这是真正的并行,但也会付出一点代价。代码运行使用的存储空间在每一个进程中都会复制一份。在这个简单的例子中固然可有可无,但对一些大型程序可能会形成大的负担。

代码:

process_toutiao.py

from functools import partial
from multiprocessing.pool import Pool
from itertools import chain
from time import time

from download import setup_download_dir, get_links, download_link


def main():
    ts = time()

    url1 = 'http://www.toutiao.com/a6333981316853907714'
    url2 = 'http://www.toutiao.com/a6334459308533350658'
    url3 = 'http://www.toutiao.com/a6313664289211924737'
    url4 = 'http://www.toutiao.com/a6334337170774458625'
    url5 = 'http://www.toutiao.com/a6334486705982996738'
    download_dir = setup_download_dir('process_imgs')
    links = list(chain(
        get_links(url1),
        get_links(url2),
        get_links(url3),
        get_links(url4),
        get_links(url5),
    ))

    download = partial(download_link, download_dir)
    with Pool(8) as p:
        p.map(download, links)
    print('一共下载了 {} 张图片'.format(len(links)))
    print('Took {}s'.format(time() - ts))

if __name__ == '__main__':
    main()

这里补充一点,多进程下下载一样了花费约 58 秒,和多线程差很少。可是对于 CPU 密集型任务,多进程将发挥巨大的速度优点。

将任务分配到多台机器

这一节做者讨论了将任务分配到多台机器上进行分布式计算,因为没有环境测试,并且暂时也没有这个需求,所以略过。感兴趣的朋友请参考本文开头的的原文连接。

结论

若是你的代码是 IO 密集型的,选择 Python 的多线程和多进程差异可能不会太大。多进程可能比多线程更易使用,但须要消耗更大的内存。若是你的代码是 CPU 密集型的,那么多进程多是不二选择,特别是对具备多个处理器的的机器而言。

相关文章
相关标签/搜索