资讯专栏INFORMATION COLUMN

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

W4n9Hu1 / 479人阅读

摘要:批评的人通常都会说的多线程编程太困难了,众所周知的全局解释器锁,或称使得多个线程的代码无法同时运行。多线程起步首先让我们来创建一个名为的模块。多进程可能比多线程更易使用,但需要消耗更大的内存。

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

在这篇阐述 Python 并发与并行编程的入门教程里,我们将写一小段从 Imgur 下载最受欢迎的图片的 Python 程序。我们将分别使用顺序下载图片和同时下载多张图片的版本。在此之前,你需要先注册一个 Imgur 应用。如果你还没有 Imgur 账号,请先注册一个。

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

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

Python 多线程起步

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

get_links

download_link

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 密集型的,那么多进程可能是不二选择,特别是对具有多个处理器的的机器而言。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/45522.html

相关文章

  • Python协程(真才实学,想学的来)

    摘要:所以与多线程相比,线程的数量越多,协程性能的优势越明显。值得一提的是,在此过程中,只有一个线程在执行,因此这与多线程的概念是不一样的。 真正有知识的人的成长过程,就像麦穗的成长过程:麦穗空的时候,麦子长得很快,麦穗骄傲地高高昂起,但是,麦穗成熟饱满时,它们开始谦虚,垂下麦芒。 ——蒙田《蒙田随笔全集》 上篇论述了关于python多线程是否是鸡肋的问题,得到了一些网友的认可,当然也有...

    lykops 评论0 收藏0
  • Python中单线程、多线程和多程的效率对比实验

    摘要:中单线程多线程与多进程的效率对比实验多线程多进程中多线程和多进程的对比是运行在解释器中的语言,查找资料知道,中有一个全局锁,在使用多进程的情况下,不能发挥多核的优势。 title: Python中单线程、多线程与多进程的效率对比实验date: 2016-09-30 07:05:47tags: [多线程,多进程,Python]categories: [Python] meta: Pyt...

    niceforbear 评论0 收藏0
  • 关于Python爬虫种类、法律、轮子的一二三

    摘要:一般用进程池维护,的设为数量。多线程爬虫多线程版本可以在单进程下进行异步采集,但线程间的切换开销也会随着线程数的增大而增大。异步协程爬虫引入了异步协程语法。 Welcome to the D-age 对于网络上的公开数据,理论上只要由服务端发送到前端都可以由爬虫获取到。但是Data-age时代的到来,数据是新的黄金,毫不夸张的说,数据是未来的一切。基于统计学数学模型的各种人工智能的出现...

    lscho 评论0 收藏0
  • Python爬虫学习路线

    摘要:以下这些项目,你拿来学习学习练练手。当你每个步骤都能做到很优秀的时候,你应该考虑如何组合这四个步骤,使你的爬虫达到效率最高,也就是所谓的爬虫策略问题,爬虫策略学习不是一朝一夕的事情,建议多看看一些比较优秀的爬虫的设计方案,比如说。 (一)如何学习Python 学习Python大致可以分为以下几个阶段: 1.刚上手的时候肯定是先过一遍Python最基本的知识,比如说:变量、数据结构、语法...

    liaoyg8023 评论0 收藏0
  • python大佬养成计划----协程

    摘要:协程,又称微线程,纤程。最大的优势就是协程极高的执行效率。生产者产出第条数据返回更新值更新消费者正在调用第条数据查看当前进行的线程函数中有,返回值为生成器库实现协程通过提供了对协程的基本支持,但是不完全。 协程,又称微线程,纤程。英文名Coroutine协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。 最大的优势就是协程极高...

    svtter 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<