4

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


YouGakukou
627 声望281 粉丝