經過網絡圖片小爬蟲對比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 密集型的,那麼多進程多是不二選擇,特別是對具備多個處理器的的機器而言。

相關文章
相關標籤/搜索