併發體驗:Python抓圖的8種方式

Python之禪發表於2018-06-13

本文系作者「無名小妖」的第二篇原創投稿文章,作者通過用爬蟲示例來說明併發相關的多執行緒、多程式、協程之間的執行效率對比。如果你喜歡寫部落格,想投稿可微信我,有稿費酬勞。

640?wx_fmt=png

假設我們現在要在網上下載圖片,一個簡單的方法是用 requests+BeautifulSoup。注:本文所有例子都使用python3.5)

單執行緒

示例 1:get_photos.py

import os
import time
import uuid

import requests
from bs4 import BeautifulSoup

def out_wrapper(func):  # 記錄程式執行時間的簡單裝飾器
   def inner_wrapper():
       start_time = time.time()
       func()
       stop_time = time.time()
       print('Used time {}'.format(stop_time-start_time))
   return inner_wrapper

def save_flag(img, filename):  # 儲存圖片
   path = os.path.join('down_photos', filename)
   with open(path, 'wb') as fp:
       fp.write(img)

def download_one(url):  # 下載一個圖片
   image = requests.get(url)
   save_flag(image.content, str(uuid.uuid4()))

def user_conf():  # 返回30個圖片的url
   url = 'https://unsplash.com/'
   ret = requests.get(url)
   soup = BeautifulSoup(ret.text, "lxml")
   zzr = soup.find_all('img')
   ret = []
   num = 0
   for item in zzr:
       if item.get("src").endswith('80') and num < 30:
           num += 1
           ret.append(item.get("src"))
   return ret

@out_wrapper
def download_many():
   zzr = user_conf()
   for item in zzr:
       download_one(item)

if __name__ == '__main__':
   download_many()

示例1進行的是順序下載,下載30張圖片的平均時間在60s左右(結果因實驗環境不同而不同)。

這個程式碼能用但並不高效,怎麼才能提高效率呢?

參考開篇的示意圖,有三種方式:多程式、多執行緒和協程。下面我們一一說明:

我們都知道 Python 中存在 GIL(主要是Cpython),但 GIL 並不影響 IO 密集型任務,因此對於 IO 密集型任務而言,多執行緒更加適合(執行緒可以開100個,1000個而程式同時執行的數量受 CPU 核數的限制,開多了也沒用)

不過,這並不妨礙我們通過實驗來了解多程式。

多程式

示例2

from multiprocessing import Process
from get_photos import out_wrapper, download_one, user_conf

@out_wrapper
def download_many():
   zzr = user_conf()
   task_list = []
   for item in zzr:
       t = Process(target=download_one, args=(item,))
       t.start()
       task_list.append(t)
   [t.join() for t in task_list]  # 等待程式全部執行完畢(為了記錄時間)

if __name__ == '__main__':
   download_many()

本示例重用了示例1的部分程式碼,我們只需關注使用多程式的這部分。

筆者測試了3次(使用的機器是雙核超執行緒,即同時只能有4個下載任務在進行),輸出分別是:19.5s、17.4s和18.6s。速度提升並不是很多,也證明了多程式不適合io密集型任務。

還有一種使用多程式的方法,那就是內建模組futures中的ProcessPoolExecutor。

示例3

from concurrent import futures
from get_photos import out_wrapper, download_one, user_conf

@out_wrapper
def download_many():
   zzr = user_conf()
   with futures.ProcessPoolExecutor(len(zzr)) as executor:
       res = executor.map(download_one, zzr)
   return len(list(res))

if __name__ == '__main__':
   download_many()

使用 ProcessPoolExecutor 程式碼簡潔了不少,executor.map 和標準庫中的 map用法類似。耗時和示例2相差無幾。多程式就到這裡,下面來體驗一下多執行緒。

多執行緒

示例4

import threading
from get_photos import out_wrapper, download_one, user_conf

@out_wrapper
def download_many():
   zzr = user_conf()
   task_list = []
   for item in zzr:
       t = threading.Thread(target=download_one, args=(item,))
       t.start()
       task_list.append(t)
   [t.join() for t in task_list]

if __name__ == '__main__':
   download_many()

threading 和 multiprocessing 的語法基本一樣,但是速度在9s左右,相較多程式提升了1倍。

下面的示例5和示例6中分別使用內建模組 futures.ThreadPoolExecutor 中的 map 和submit、as_completed

示例5

from concurrent import futures
from get_photos import out_wrapper, download_one, user_conf

@out_wrapper
def download_many():
   zzr = user_conf()
   with futures.ThreadPoolExecutor(len(zzr)) as executor:
       res = executor.map(download_one, zzr)
   return len(list(res))

if __name__ == '__main__':
   download_many()

示例6:

from concurrent import futures
from get_photos import out_wrapper, download_one, user_conf

@out_wrapper
def download_many():
    zzr = user_conf()
    with futures.ThreadPoolExecutor(len(zzr)) as executor:
        to_do = [executor.submit(download_one, item) for item in zzr]
        ret = [future.result() for future in futures.as_completed(to_do)]
    return ret

if __name__ == '__main__':
    download_many()

Executor.map 由於和內建的map用法相似所以更易於使用,它有個特性:返回結果的順序與呼叫開始的順序一致。不過,通常更可取的方式是,不管提交的順序,只要有結果就獲取。

為此,要把 Executor.submit 和 futures.as_completed結合起來使用。

最後到了協程,這裡分別介紹 gevent 和 asyncio。

gevent

示例7

from gevent import monkey
monkey.patch_all()

import gevent
from get_photos import out_wrapper, download_one, user_conf

@out_wrapper
def download_many():
    zzr = user_conf()
    jobs = [gevent.spawn(download_one, item) for item in zzr]
    gevent.joinall(jobs)

if __name__ == '__main__':
    download_many()

asyncio

示例8

import uuid
import asyncio

import aiohttp
from get_photos import out_wrapper, user_conf, save_flag

async def download_one(url):
   async with aiohttp.ClientSession() as session:
       async with session.get(url) as resp:
           save_flag(await resp.read(), str(uuid.uuid4()))

@out_wrapper
def download_many():
   urls = user_conf()
   loop = asyncio.get_event_loop()
   to_do = [download_one(url) for url in urls]
   wait_coro = asyncio.wait(to_do)
   res, _ = loop.run_until_complete(wait_coro)
   loop.close()
   return len(res)

if __name__ == '__main__':
   download_many()

協程的耗時和多執行緒相差不多,區別在於協程是單執行緒。具體原理限於篇幅這裡就不贅述了。

但是我們不得不說一下asyncio,asyncio是Python3.4加入標準庫的,在3.5為其新增async和await關鍵字。或許對於上述多執行緒多程式的例子你稍加研習就能掌握,但是想要理解asyncio你不得不付出更多的時間和精力。

另外,使用執行緒寫程式比較困難,因為排程程式任何時候都能中斷執行緒。必須保留鎖以保護程式,防止多步操作在執行的過程中中斷,防止資料處於無效狀態。

而協程預設會做好全方位保護,我們必須顯式產出才能讓程式的餘下部分執行。對協程來說,無需保留鎖,在多個執行緒之間同步操作,協程自身就會同步,因為在任意時刻只有一個協程執行。想交出控制權時,可以使用 yield 或 yield from(await) 把控制權交還排程程式。

總結

本篇文章主要是將python中併發相關的模組進行基本用法的介紹,全做拋磚引玉。而這背後相關的程式、執行緒、協程、阻塞io、非阻塞io、同步io、非同步io、事件驅動等概念和asyncio的用法並未介紹。大家感興趣的話可以自行google或者百度,也可以在下方留言,大家一起探討。

640?wx_fmt=png
(如果本文對你有幫助,可以對作者打賞)

相關文章