當前位置:
首頁 > 知識 > 使用並發加速你的python程序(1)

使用並發加速你的python程序(1)

如果你聽過很多關於將asyncio添加到Python中的討論,並對它與其它並發方法的比較結果很好奇,或者想知道並發是什麼,以及它如何加速程序,那麼你就來對了地方。

在本文中,你將學習以下內容:

  • 什麼是並發
  • 什麼是並行
  • 比較Python的一些並發方法,包括threading, asyncio, 和 multiprocessing
  • 在程序中什麼時候使用並發,以及使用哪個模塊

本文假設你對Python有基本的了解,並且使用至少3.6版來運行示例。你可以從Real Python GitHub 源下載示例。(https://github.com/realpython/materials/tree/master/concurrency-overview )

什麼是並發?

並發的字典定義是同時發生。在Python中,同時發生的事情用不同的名稱(線程、任務、進程)來調用,但是在更高的級別上,它們都指向一個按順序運行的指令序列。

我喜歡把它們看作是不同的思路。每一個都可以在某一點停止,處理它們的CPU或大腦可以切換到另一個。每一個的狀態都會被保存,這樣就可以在它們被中斷的地方重新啟動。

你可能想知道為什麼Python對同一個概念使用不同的單詞。事實證明,線程、任務和進程只有在從高級層面查看時才相同。一旦你開始挖掘細節,你就會發現它們都代表了稍微不同的東西。隨著示例的深入,你將看到更多它們的不同之處。

現在我們來討論一下這個定義中有關「同時運行」的部分。你必須小心一點,因為當你深入到細節時,只有multiprocessing才是真正意義上的同時運行。 Threading 和 asyncio 都運行在一個處理器上,因此它們兩個一次只能運行一個。它們只是非常聰明地找到輪流加速整個過程的方法。儘管它們沒有真正地同時運行,我們仍然將其稱為並發。

線程或任務輪流執行的方式是 threading 和 asyncio之間的主要區別。在threading中,操作系統實際上知道每個線程,並可以在任何時候中斷它,以開始運行不同的線程。這稱為搶佔式多任務處理(https://en.wikipedia.org/wiki/Preemption_%28computing%29#Preemptive_multitasking ),因為操作系統可以搶佔線程來進行切換。

搶佔式多任務處理非常方便,因為線程中的代碼不需要做任何事情來進行切換。它也可能是困難的,因為「在任何時候」這句話。這種切換可以發生在單個Python語句的中間,甚至是像x = x + 1這樣的簡單語句。

另一方面,asyncio使用協作多任務處理(https://en.wikipedia.org/wiki/Cooperative_multitasking )。這些任務必須通過宣布它們何時可以被切換出來相互配合。這意味著任務中的代碼必須稍做修改才能實現這一點。

提前做這些額外工作的好處是,你總是知道你的任務將被交換到哪裡。除非在Python語句的中間做了標記,否則它不會被交換出去。稍後你會看到這將如何簡化你的設計。

什麼是並行?

到目前為止,你已經了解了發生在單個處理器上的並發。你那酷斃了的新筆記本電腦具有的CPU內核呢?你如何利用它們? multiprocessing就是答案。

使用 multiprocessing,Python可以創建新的進程。這裡的進程幾乎可以看作是一個完全不同的程序,儘管從技術上講,它們通常被定義為一組資源集合,其中的資源包括內存、文件句柄等。一種考慮方式是,每個進程都運行在自己的Python解釋器中。

因為它們是不同的過程,所以多進程程序中的每個任務都可以運行在不同的核心上。在不同的核心上運行意味著它們實際上可以同時運行,這非常棒。這樣做會產生一些複雜的問題,但是Python在大多數情況下都能很好地處理這些問題。

既然你已經了解了什麼是並發和並行,那麼讓我們來回顧一下它們之間的差異,然後我們來看看它們為什麼很有用:

使用並發加速你的python程序(1)

並發類型中的每一個都很有用。我們來看看它們可以幫助你加速哪種類型的程序。

並發在什麼時候有用?

對於兩種類型的問題,並發性可以產生很大的影響。這些通常稱為CPU密集型和I/ O密集型。

I/O密集型問題會導致程序變慢,因為它經常必須等待來自外部資源的輸入/輸出(I/O)。當你的程序處理事務比CPU慢得多時,這些問題就經常出現。

比CPU慢的東西的例子有很多,但幸運的是,你的程序並沒有與它們中的大多數進行交互。你的程序與之交互最多的比較慢的東西通常是文件系統和網路連接。

讓我們看看它是什麼樣的:

使用並發加速你的python程序(1)

在上面的圖表中,藍色框表示程序執行工作的時間,紅色框表示等待I/O操作完成的時間。此圖沒有按比例顯示,因為internet上的請求可能比CPU指令要多花費幾個數量級的時間,所以你的程序可能會花費大部分時間進行等待。這是你的瀏覽器大部分情況下正在做的事情。

另一方面,有一些程序在不與網路通信或訪問文件的情況下執行重要計算。它們就是CPU密集型程序,因為限制程序速度的資源是CPU,而不是網路或文件系統。

下面是一個CPU密集型程序的對應圖:

使用並發加速你的python程序(1)

當你推敲下一節中的示例時,你將看到不同形式的並發在處理CPU密集型和I/ O密集型程序時運行良好或更差。將並發添加到你的程序中會增加額外的代碼和複雜性,因此你需要決定潛在的提速是否值得付出額外的努力。在本文結束之前,你應該已經具備了足夠的信息來做出這個決定。

這裡有一個快速總結來闡明這個概念:

使用並發加速你的python程序(1)

你將首先查看I/ O密集型程序。然後,你將看到一些處理CPU密集型程序的代碼。

如何加速I/ O密集型程序

我們從關注I/ O密集型程序的一個常見問題開始: 通過網路下載內容。對於我們的示例,你將從幾個站點下載web頁面,但實際上可以是任何網路流量。只是可視化和設置網頁更容易一些。

同步版本

我們將從這個任務的一個非並發版本開始。注意,這個程序需要requests模塊。在運行這個程序之前,你應該先運行pip install requests命令,可能是使用virtualenv環境。這個版本一點也沒有使用並發:

使用並發加速你的python程序(1)

如你所見,這是一個相當短的程序。download_site()只從一個URL下載內容並列印其大小。需要指出的一件小事是,我們這裡用到了一個來自requests的Session對象。

也可以直接使用來自requests的get() 函數,但是創建一個Session對象可以讓requests運用一些神奇的網路小技巧,從而真正使程序加速。

download_all_sites()會創建Session對象,然後遍歷站點列表,依次下載每個站點。最後,它會列印出這個過程花費了多長時間,這樣你就可以在下面的示例中滿意地看到並發為我們帶來了多大的幫助。

這個程序的處理關係圖看起來很像上一節中的I/ O密集型關係圖。

注意: 網路流量依賴於許多因素,這些因素可能隨時間而變化。由於網路問題,我看到這些測試的時間從一個運行到另一個運行時間翻了一番。

為什麼同步版本很棒

這個版本代碼的優點是,它很簡單,並且編寫和調試相對容易。這樣考慮起來也更直接。只有一個思路貫穿其中,所以你可以預測下一步是什麼,以及它將如何運行。

同步版本的問題

這裡最大的問題是,與我們提供的其他解決方案相比,它的速度相對較慢。下面是我的機器的最終輸出結果的一個例子:

使用並發加速你的python程序(1)

注意: 你的結果可能有很大差異。運行這個腳本時,我看到時間從14.2秒到21.9秒不等。對於本文,我以三次運行中最快的一次的時間作為測試時間。兩種方法之間的區別仍然很明顯。

然而,速度變慢並不總是一個大問題。如果你正在運行的程序使用同步版本只需要2秒,並且很少運行,那麼它可能不值得添加並發。你可以在此停止了。

如果你的程序經常運行怎麼辦?如果要運行好幾個小時呢?讓我們通過使用threading重寫這個程序來繼續討論並發。

threading 版本

正如你可能猜到的,編寫應用線程的程序需要更多的工作。然而,你可能會驚訝地發現,對於簡單的情況,你只需要很少的額外工作。下面是使用threading的相同程序:

使用並發加速你的python程序(1)

當你添加threading時,整個結構是相同的,你只需要做一些更改。download_all_sites()函數從每個站點調用函數一次改為更複雜的結構。

在這個版本中,你將創建一個ThreadPoolExecutor,它看起來很複雜。讓我們把它分解開: ThreadPoolExecutor =Thread+Pool+ Executor。

你已經了解了Thread部分。那只是我們之前提到的一個思路。Pool部分是開始變得有趣的地方。這個對象將創建一個線程池,其中的每個線程都可以並發運行。最後,Executor是控制線程池中的每個線程如何以及何時運行的部分。它將在線程池中執行請求。

對我們很有幫助的是,標準庫將ThreadPoolExecutor實現為一個上下文管理器,因此你可以使用with語法來管理Threads池的創建和釋放。

一旦有了ThreadPoolExecutor,你就可以使用它方便的.map()方法。此方法在列表中的每個站點上運行傳入函數。最重要的是,它使用自己管理的線程池自動並發地運行它們。

來自其他語言,甚至Python 2的人可能想知道,在處理threading時,管理你習慣的細節的常用對象和函數在哪裡,比如Thread.start()、Thread.join()和Queue。

這些都還在那裡,你可以使用它們來實現對線程運行方式的精細控制。但是,從Python 3.2開始,標準庫添加了一個更高級別的抽象,稱為Executor,如果你不需要精細控制,它可以為你管理許多細節。

本例中另一個有趣的更改是,每個線程都需要創建自己的request . Session()對象。當你查看requests的文檔時,不一定就能很容易地看出,但在閱讀這個問題(https://github.com/requests/requests/issues/2766 )時,你會清晰地發現每個線程都需要一個單獨的Session。

這是threading中有趣且困難的問題之一。因為操作系統可以控制任務何時中斷,何時啟動另一個任務,所以線程之間共享的任何數據都需要被保護起來,或者說是線程安全的。不幸的是,requests . Session()不是線程安全的。

根據數據是什麼以及如何你使用它們,有幾種策略可以使數據訪問變成線程安全的。其中之一是使用線程安全的數據結構,比如來自 Python的queue模塊的Queue。

這些對象使用低級基本數據類型,比如threading.Lock,以確保只有一個線程可以同時訪問代碼塊或內存塊。你可以通過ThreadPoolExecutor對象間接地使用此策略。

這裡要使用的另一種策略是線程本地存儲。Threading.local()會創建一個對象,它看起來像一個全局對象但又是特定於每個線程的。在我們的示例中,這是通過threadLocal和get_session()完成的:

使用並發加速你的python程序(1)

ThreadLocal是threading模塊中專門用來解決這個問題的。它看起來有點奇怪,但是你只想創建其中一個對象,而不是為每個線程創建一個對象。對象本身將負責從不同的線程到不同的數據的分開訪問。

當get_session()被調用時,它所查找的session是特定於它所運行的線程的。因此,每個線程都將在第一次調用get_session()時創建一個單個的會話,然後在整個生命周期中對每個後續調用使用該會話。

最後,簡要介紹一下選擇線程的數量。你可以看到示例代碼使用了5個線程。隨意改變這個數字,看看總時間是如何變化的。你可能認為每次下載只有一個線程是最快的,但至少在我的系統上不是這樣。我在5到10個線程之間找到了最快的結果。如果超過這個值,那麼創建和銷毀線程的額外開銷就會抵消程序節省的時間。

這裡比較困難的答案是,從一個任務到另一個任務的正確線程數不是一個常量。需要進行一些實驗來得到。

為什麼 threading版本很棒

它很快!這是我測試中運行最快的。記住,非並發版本花費的時間超過14秒:

使用並發加速你的python程序(1)

以下是它的執行時序表:

使用並發加速你的python程序(1)

它使用多個線程同時向web站點發出多個開放的請求,允許你的程序重疊等待時間並更快地獲得最終結果! 哦耶! !這就是我們的目標。

Threading版本的問題

正如你從示例中看到的,實現這一點需要更多的代碼,而且你確實需要考慮線程之間共享哪些數據。

線程可以以微妙且難以檢測的方式進行交互。這些交互可能會導致競態條件,而這些競態條件常常會導致很難發現的隨機、間歇性的bug。那些不熟悉競態條件概念的人可能需要擴展並閱讀以下部分。

競態條件

競態條件是在多線程代碼中可能而且經常發生的一類細微的bug。競態條件的發生是因為程序員沒有有效地保護數據訪問,以防止線程之間相互干擾。在編寫使用線程的代碼時,你需要採取額外的步驟來確保數據是線程安全的。

這裡發生的事情是,操作系統控制著你的線程何時運行,以及它何時被交換出去,讓另一個線程運行。這種線程交換可以在任何時候發生,甚至在執行Python語句的子步驟時也是如此。舉個簡單的例子,看看這個函數:

使用並發加速你的python程序(1)

這段代碼與上麵線程示例中使用的結構非常相似。不同之處在於,每個線程都訪問相同的全局變數counter並對其進行遞增。counter不受任何保護,因此它不是線程安全的。

為了增加counter,每個線程都需要讀取當前值,並向其中增加一,並將該值保存回變數。這個過程發生的代碼行是counter += 1。

因為操作系統對你的代碼一無所知,並且可以在執行過程中的任何時刻交換線程,所以有可能線程在讀取了該值之後,但在有機會將其寫回之前被操作系統交換了出去。如果正在運行的新代碼也修改了counter,那麼第一個線程就會有一個過期的數據副本,問題就會隨之而來。

正如你所能想像的,發生這種情況是相當罕見的。你可以運行這個程序上千次,卻永遠看不到問題。這就是為什麼調試這類問題相當困難,因為它可能非常難以重現,並可能導致出現隨機查找錯誤。

作為一個進一步的示例,我想提醒你request . Session()不是線程安全的。這意味著,如果多個線程使用同一個Session,那麼在某些地方可能會發生上面描述的交互類型問題。我提出這個問題不是為了詆毀requests,而是要指出這些問題很難解決。


英文原文:https://realpython.com/python-concurrency/ 譯者:Nothing

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 Python部落 的精彩文章:

在Python中使用PDF:閱讀和拆分
Google出品的Python代碼靜態類型分析器:Pytype

TAG:Python部落 |