Python 並行計算那點事(第2部分) -- 譯文 [原創]


Python 並行計算的那點事(第2部分)(The Python Concurrency Story - Part 2)

英文原文:https://powerfulpython.com/blog/python-concurrency-story-pt2/
本文:https://www.cnblogs.com/popapa/p/python_concurrency2.html
采集日期:2021-05-03

第1部分已經討論了Python並行計算編程的主要難點。簡而言之,對於處理器受限(CPU-bound)的任務而言,Python的線程將會於事無補。那有什么其他辦法嗎?

概括起來,現實世界中需要面對的並行計算問題有兩種:

  • 多個CPU可以加速任務
  • 再多的CPU也於事無補

本文將介紹第1種情況:可以由多CPU進行加速的任務。通過編寫跨越多個進程的Python代碼,即可達到目的。最理想的情況下,N個CPU將會讓程序運行速度提高N倍。

(第2種情況將在第3部分中介紹,涉及非處理器受限的多任務所需應對的問題。那種情況下,多CPU或多核沒什么大的用處。為了取得勝利,必須榨取單個線程的最大收益,最新版的Python中就提供了一些出色的工具。)

並發編程的兩種途徑(Two Approaches to Concurrent Programming)


就Python而言,好程序員與傑出程序員有一點差別,那就是對現代操作系統提供的並發原語(Concurrency Primitive)的理解程度,包括進程和線程,以及他們的各種使用模式。其實這與編程語言無關。正規的學校教育,或者用其他語言實現並發程序時的經驗,都可能讓您有所了解。

概括起來,並行計算的實現主要有兩種途徑:共享內存(Shared Memory)消息傳遞(Message Passing)。這兩者的主要區別在於:共享內存意味着程序的設計目標是要讓兩個以上的線程讀寫同一塊內存——就像跳舞時不會相互踩腳。而采用消息傳遞方式時,任何內存塊都只能由單個線程訪問。如果兩個以上線程需要同步、交互或通信,要把數據從一個線程發送給另一個,也即傳遞一條消息。

兩個線程可以位於同一個進程中,也可以在兩個不同的進程中——每個進程都有自己的主線程。因此,共享內存或消息傳遞都可以用[注1]。但通常共享內存只會在單進程、多線程的系統中使用。多進程程序可以通過類似mmap的結構(Construct)或IPC來訪問共享內存,但性能不佳且實現起來比較復雜。[注2]

如果可以避開的話,建議多進程程序不要采用共享內存。干成功的人都知道原因:要想做好很難,而微妙的競態條件(Race Condition)、死鎖和其他錯誤卻很容易生成。有時或許無法避免共享內存的使用。但如果可以,請換成消息傳遞方式吧。

無論如何,消息傳遞方式都更適合多進程程序。原因有兩個:首先,與單進程、多線程的程序相比,共享內存在多​​進程中的性能和優雅程度均不高。更重要的是,大部分想用多進程實現的程序都更適合采用消息傳遞的架構,尤其是在Python中。

編寫Python多核應用(Writing Multicore Python)


Python基本上是強制要求用多進程來充分利用多個CPU。[注3]跨進程共享內存的方案有很多,而用較新版本的Python,實現起來相對容易一些。但這里重點還是介紹消息傳遞方法。

現在真的容易多了。從前如果想讓Python善加利用多個CPU,必須使用os.fork進行一些驚悚且不可移植的操作。而現在,標准庫中就有了很好的multiprocessing模塊。這是一個不錯的Python風格(Pythonic)接口,用它處理多進程能讓許多困難的事情變得簡單[注4]。有關的文章相當多了,也帶了很多漂亮、整潔的演示代碼。但讓我們看下更現實的實例如何?

下面介紹一下Thumper吧。這個Python程序將為數量龐大的圖像庫生成縮略圖。假設某個Web應用程序每天凌晨2點都要抓取一批10萬張的圖片,且需要在合理的時間內為這些圖片生成縮略圖,那么用Thumper就正合適。這個案例利用多CPU實現十分理想:生成縮略圖是處理器受限的任務,並且兩張不同圖片的計算過程是完全獨立的。

看看漂亮的結果吧:

以上是在NASA TIFF圖像數據集上多次運行thumper之后的結果,用的是AWS c3.2 xlarge主機實例。橫坐標是工作進程的數量。

這張圖中出現了一次LOT的情況。我正在研究高級Python精通課程,該課程將深入研究其中的內容,讓我的學生獲得蝙蝠俠般的並發編程超級能力。下面將重點關注Thumper的實現,以及它是如何繞開GIL用足CPU核心的。

核心代碼非常簡單,這多虧有了先進的Pillow,它是PIL(Python圖像庫)的分支代碼。

# PIL is actually Pillow. Confusing, I know,
# but nicely backwards compatible.
from PIL import Image
def create_thumbnail(src_path, dest_path, thumbnail_width, thumbnail_height):
    image = Image.open(src_path)
    image.thumbnail((thumbnail_width, thumbnail_height))
    os.makedirs(os.path.dirname(dest_path), exist_ok=True)
    image.save(dest_path)

很直白吧。然后要將其推進為多個工作進程。以下是一些注意事項:

  • 用多少個進程呢? 正如即將看到的那樣,這是個驚人的難題,事實證明這是一個不斷變化的值。因此,我們需要靈活性——在運行時指定進程數量的能力。
  • 如何避免創建的流程太多或太少?
  • 如何高效地等待一個工作進程的完成,然后立即把一張新圖片交給它生成縮略圖呢?

Python的multiprocessing池(Python's multiprocessing Pools)

multiprocessing模塊包含了一個非常有用的抽象概念,並以Pool類的形式給出。如下即可創建它:

import multiprocessing
pool = multiprocessing.Pool(num_processes)

哇,有點難!單詞“multiprocessing”的字符太多了,要打這么多字。謝天謝地,總算干完了,就只剩向每個工作進程分發圖片了。Pool有好幾個方法可供使用。每種方法的參數都是一個可調用的函數(Callable)及調用的參數,在這里將是create_thumbnail

下面列出Pool的一部分方法:

apply_async

參數為一個函數及一些參數,將其送入*某個*工作進程中運行。結果對象會立即返回,有點像future,完成后可以馬上獲取返回值。

map和map_async

類似於Python內置的`map()`函數,只是操作是對子進程進行的。`map`將阻塞至所有任務完成為止;`map_async`則會立即返回結果對象。限制:可調用函數只能帶有一個參數。

imap和imap_async

類似於`map`和`map_async`,只是返回的是迭代器而不是序列全體。可被視為`lazy_map`。

starmap和starmap_async

類似於`map`和`map_async`,只是其可調用函數可以接受多個參數。

大多數[注5]要用到的方法就是以上這些,如您所見,他們幾乎都是概念類似的變體。因為我們的create_thumbnail函數要用到多個參數,所以Thumper選用了starmap_async

# src_dir是存放全尺寸圖片的文件夾
# dest_dir是要寫入縮略圖的地方
# in是並行處理的文件集
    
def gen_child_args():
    for (dirpath, dirnames, filenames) in os.walk(src_dir):
        for filename in filenames:
            src_path = os.path.join(dirpath, filename)
            dest_path = find_dest_path(src_dir, dest_dir, src_path)
            yield (src_path, dest_path, thumbnail_width, thumbnail_height)
pool.starmap_async(create_thumbnail, gen_child_args())

沒錯,startmap_async的第2個參數是可迭代。因為Thumper實際需要處理數百萬個圖像,所以這里編寫了一個節省內存的生成器對象,該生成器將根據需要創建參數元組,而不是生成一個巨大的列表(為每個圖像生成一個元組)。

對性能的理解(Understanding Performance)


程序運行成果如何呢?起初確實需要費點腦子才能理解。想象一下,您用的是一台有8個CPU(或核心)的機器。想要給Thumper用多少個CPU呢?理論上會是8個,也就是核心總數。但也可能是別的什么數字,具體要取決於(a)系統的其他狀況,(b)應用程序的狀況。通常,進程數量少於CPU數量即可獲得完全相同的性能。這意味着已經達到了硬件CPU不再是瓶頸的地步。信不信由你,我還見過用了工作進程太多引起資源競爭、拖慢運行速度的情況。

以我自己的經驗,確定進程數量的唯一方法就是測試:重復執行,每次只改變所用的CPU數量。就是以上我做的那樣。下面着重看下耗時情況,因為我們的重點是要讓耗時最短:

以上是在c3.2 xlarge EC2實例上完成的,這是一台擁有8個CPU的重武器。橫坐標是Thumper產生的工作進程數。縱坐標是為所有圖片生成縮略圖的總耗時,因此越小越好。

如圖所示,隨着工作進程的加入,耗時逐漸減少,直至CPU數為6為止。為什么不是8或7呢?原因可能有很多,可以是和特定的應用程序密切相關。既然已經盡可能在CPU方面進行了擴展,一般情況下是遇到了別的瓶頸。或許是耗盡了所有可用的內存,已開始了分頁。或許CPU緩存飽和了。或許I/O吞吐到了極限,也即每秒加載的圖片數據不能增加了。

如您所見,學習multiprocessing提供的類、方法和函數只是第一步。

無論用什么語言,對於一般的編程而言,涉足多CPU只會讓您越走越偏。另外,投入的CPU越多,代價就更高。如果真想榨取機器的最大收益,還必須掌握榨取單個線程的最大收益。這就是第3部分將要討論的內容。關鍵就是真正擊中單個線程要害,秘籍就是……

好吧,發布第3部分時您就會知曉了。請訂閱郵件吧。


[注1] 對於單進程、多線程的程序而言,可以通過全局的線程安全隊列或其他數據結構來實現消息傳遞;多進程則可通過IPC完成。

[注2] 除非使用Python的multiprocessing模塊。但還是不要太超前了。

[注3] 這太簡單了。用C語言擴展就能以與C相似的方式充分利用多CPU,至少在Python應用程序的某個有限部分內可以實現。對於CPython之外的其他Python實現,情形會有所差別。

對於當今大多數使用官方解釋器的純Python應用程序,要用多CPU就意味着要用多進程。

[注4] 或許,至少能降低難度吧。不那么棘手了(And with fewer sharp pointy jagged edges)。

[注5] 還有一些其他方法,比如apply其實就是apply_async,只是在返回之前會阻塞。想不通為什么要在用到multiprocessing的程序里用它吧?我也想不通。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM