Python併發:線程和鎖

目錄

概述

入門

陷阱:時序性和一致性

陷阱:訪問共享內存

陷阱:死鎖

陷阱:異形方法和依賴關係

多線程日誌記錄

concurrent.futures

結論

概述

線程和鎖是硬件底層的軟件定義形式化,因此包含最簡單的可能併發模型。它構成了其他構建在其頂層的併發抽象基礎,因此理解這一點很重要。然而,直接在這些基礎上構建可靠,可擴展的系統是很困難的或著說是不可能的。

雖然大多數語言都支持線程和鎖,但CPython仍然使用全局解釋器鎖來防止線程同時訪問共享內存,因為CPython的內存管理是非線程安全的。雖然阻塞操作發生在GIL之外並且可能提高性能,但是線程切換所需的系統調用開銷可能會降低性能。這意味著Python中的線程主要用於I/O受限的場景,而不是CPU受限的場景。

說句題外話,我提到了CPython,因為Python規範的其他部分實現,例如Jython,沒有全局解釋器鎖。然而,這些實現在實踐中並沒有被廣泛使用,因為第一:沒有人想要支持多Python實現,除非他們不得不這樣;第二:它們還不夠豐富;第三:由於需要原生支持C/C++擴展API,Python語言定義與C/C++緊密耦合,與其說是技術規範不如說是一個參考實現。

Python通過高級模塊threading模塊和低級模塊_thread直接支持線程。想要獲得更多有關這些模塊如何工作的信息,可以在線獲取源代碼。

入門

Python中典型的單線程“Hello World”執行非常簡單:

Python併發:線程和鎖

多線程模擬並沒有太大的不同:

Python併發:線程和鎖

基於我有限數量的測試,上面的腳本運行結果如下所示:

Python併發:線程和鎖

我用了get_ident()打印“線程標識符”(一個魔法值,除了在運行時消除不同線程之間的歧義之外,沒有任何意義)。你可以看到在某些情況下,線程標識符是如何不同的,而在其他一些情況下,線程標識符又是相同的。相同的線程標識符並不意味著仍工作在同一個線程上,但如果工作不重疊並且不需要不同的線程標識符,Python會重新使用該標識符。

陷阱:時序性和一致性

如果你用threading.current_thread().getName()將線程標識符與線程名交換,你可能會獲得有序結果,很大的原因可能是因為每個線程使用相同的函數和源碼路徑,因此,每個線程之間的延遲差異是微不足道的,僅次於解釋器的延遲。然而,這並不意味著有序執行能夠得到保證;這是WikiBooks上“Python Programming”的一個例子,其中每個線程的創建和每個線程的執行具有明顯不同的時序性:

Python併發:線程和鎖

以下結果是同一個樣本運行的輸出:

Python併發:線程和鎖

這日誌表示線程創建/執行是交錯的。由於增加功能的可變性增加,隨著線程創建和執行之間的時序越來越不一致,這些結果將變得越來越不可預測。但原理仍然是相同的;使用多個線程時無法保證一致的行為。

陷阱:訪問共享內存

當不同的線程訪問共享內存時,這可能導致不正確的行為。你可以擴展此示例以在使用多個線程進行計數時查看競爭條件:

Python併發:線程和鎖

這會在一次示例運行時生成如下輸出:

Python併發:線程和鎖

此結果因創建的線程數而異,但你可以看到結果28與預期值100有多大區別。Counter().count是非線程安全的,在這裡進行了演示(如果你有與我不同的機器,你可能會得到與28不同的結果)。如果遇到競爭條件,沒有足夠的日誌記錄,可能很難找到相關的代碼部分。

陷阱:死鎖

當兩個代理嘗試獲取共享內存的相同區域時,最終就會發生死鎖。當在處理線程和鎖的低級抽象時,唯一的解決方案是確保每個代理有一種方法能正確地管理其鎖,或者具有鎖協調的整體規範。例如,用餐哲學問題強調了流程同步的重要性。Rosetta Code的用餐哲學python解決方案解決了這個同步問題:如果你(代理)不能及時獲取這兩個分叉,你可以釋放你已經擁有的任何叉子,以便另一個代理可以同時獲得這兩個叉子

Python併發:線程和鎖

此方法不排除其他鎖定方法,像鎖定順序,或涉及流程同步的系統設計,像使用信號量的生產者-消費者模型,但在Python中可能不如在其他語言中普遍。

陷阱:異形方法和依賴關係

如果要在Python應用程序中應用多線程,那麼你希望保證整個堆棧的正確性,你必須手動驗證核實線程安全性和依賴項的線程模型。有些為企業級多服務環境使用而設計的依賴項,例如redis,可以在設計階段首先考慮它們的併發模型(請參閱黑客新聞antirez關於多線程版本redis的評論)。有些依賴可能不會;使用boto2時,並行使用multiprocessing.pool.Pool從S3並行下載文件時,我可能遇到了死鎖,這需要重寫一個函數。因此,另一個依賴性的困難出現了;它們無法被同化,這意味著如果你在你的應用使用依賴模型之前沒有驗證所有將使用的依賴關係,那麼在嘗試為特定用途添加依賴項時,你可能陷入項目的死衚衕。

多線程日誌記錄

如果你選擇使用Python中的原生線程模型,你可能會驚喜地發現logging模塊不僅是線程安全的,而且還支持從任何特定線程或進程進行日誌記錄(示例在logging手冊)。然後,難點是在你的應用程序中哪裡更可能觸發異常,這如何影響你的線程模型以及確保在這些代碼段周圍進行可靠的日誌記錄。將日誌添加到你的應用可能會產生不小的延遲,正如pylint通過警告模塊logging-lazy-interpolation通知你那樣,這也可能會給你的線程模型帶來困難。

concurrent.futures

在撰寫這篇文章時發現Python

multiprocessing.pool.ThreadPool實現從未被記錄或測試過,因為它從未完成,這讓我感覺非常不愉快。它在Python3.7中仍然還是這樣,因為它出現在GitHub鏡像的源代碼中。鑑於全局解釋器鎖的無所不在,以及未來併發程序主要是並行I/O相關的工作,使用Python3.x中提供的像concurrent.futures.Executor或類似的新異步模式可能更有意義,因為他們更全面。我沒有使用過這個模塊,但我想與multiprocessing相比,它不會產生顯著的性能損耗。

結論

Python對線程和鎖具有基本的支持,它可能不像其他語言(例如Java)中的線程和鎖那樣功能全面且有用。在使用像Python等更高級別的解釋語言進行操作時,最好避免使用線程和鎖。然而,Python確實提供了關於線程和鎖定的足夠友好的曝光度,以便對線程和鎖的工作方式進行良好的學術練習,也給併發界提供了激動人心的介紹。

要了解有關生產中的線程和鎖定的更多信息,請查看Paul Butcher撰寫的“七週七種併發模型”。

英文原文:https://bytes.yingw787.com/posts/2019/01/12/concurrency_with_python_threads_and_locks/


分享到:


相關文章: