Python 與 GIL 究竟是怎樣的關系


what's the GIL

  GIL 全稱:全局解釋器鎖(Global Interpreter Lock),是計算機程序設計語言解釋器用於同步線程的一種機制,它使得任何時刻僅有一個線程在執行。即便在多核處理器上,使用 GIL 的解釋器也只允許同一時間執行一個線程,常見的使用 GIL 的解釋器有 CPython 與 Ruby MRI。GIL 並不是 Python 獨有的特性,是解釋型語言處理多線程問題的一種機制而非語言特性。

  GIL 本質是一把互斥鎖,與其他所有互斥鎖的本質一樣,都是將並發運行變成串行,以此來控制同一時間內共享數據只能被一個任務所修改,進而保證數據安全。可以肯定的一點是:保護不同的數據的安全,就應該加不同的鎖。

 

GIL 與 Python 的愛恨糾纏

  Python 是一門解釋器語言,代碼通過解釋器執行,Python 存在多種解釋器,分別基於不同語言開發,每個解釋器有不同的特點。

  • CPython:主流版本的解釋器,使用C語言編寫的,是使用最為廣泛的解釋器,可以方便地和 C/C++ 的類庫進行交互,因此也是最受關注的解釋器。
  • Jython:由 java 語言編寫的 Python 解釋器,是將 Python 編譯成 Java 字節碼然后執行的一種解釋器,可以方便地和 Java 的類庫進行交互。
  • IronPython:將 Python 代碼解釋為 .Net 平台上運行的字節碼進行執行,類似 Jython 解釋器,可以方便的和 .Net 平台上的類庫進行交互。簡稱 IPython,在交互效果上有所增強,但執行過程和功能方面和 CPython 是一樣的。
  • PyPy:使用 JIT(just-in-time) 技術的編譯器,專注於執行速度,對 Python 代碼進行動態編譯,從而提高 Python 的執行速度。PyPy 在處理 Python 代碼的過程中,一小部分功能的處理和 CPython 的執行結果是有差異的,如果項目中要使用 PyPy 來進行執行效率的提升的話,一定要事先了解下 PyPy 和 CPython 的區別。

 

GIL 產生的背景

  Python 是 Guido van Rossum(吉多.范羅蘇姆,被稱為 Python 之父) 在1989年發布的,那個時候計算機的主頻還沒有達到 1G,程序全部都是運行在單核計算機上面,直到 2005 年多核處理器才被 Intel 開發出來。戈登·摩爾 1965 年預測,每個集成電路的元件數量每 18 到 24 個月就會翻一倍,它的適用性預計會持續到 2015-2020 年。摩爾定律未失效前軟件系統可以單純借助硬件的進步來獲得性能的提升或者只需少量改進,就可以坐享性能飛躍。

  然而從 2005 年開始,時鍾速率的增長和晶體管數量的增長已不再同步。由於處理器材料的物理性質限制,時鍾速率已停止增長甚至下降,處理器制造商開始將更多執行單元核心封裝到單個芯片中。這一趨勢給應用程序開發和編程語言設計帶來越來越大的壓力。

  程序員和編程語言決策者不得不考慮如何快速適應多核硬件,來提高軟件性能和編程語言的市場占有率,Python 也不例外受到沖擊。

  在單核時代,崇尚優美、清晰、簡單的 Python 之父選擇在解釋器層面實現了一把全局互斥鎖,來保護 Python 對象從而實現對單核 CPU 的使用率,這種做法在單核時代很奏效。倘若在單核時未選擇 GIL,那么開發者就需要自己實現任務的管理,這樣做對於 CPU 的利用率提高無法做到極致。

  但是隨着多核時代的到來,高效地利用 CPU 核心的有效方法就是使用並行性,多線程是充分實現並行的好方法,但是 CPython 的 GIL 卻阻礙了對多核 CPU 的利用。

 

多核時代難去難從的 GIL

  CPython 的 GIL 給使用者帶來了便利,並且在 GIL 的基礎上開發了許多重要的 Package 和語言功能。但是多核 CPU 的普適和其他語言對 Python 的沖擊,讓 GIL 顯得原始而粗暴,無法有效利用多核處理器成為了弊端。

 

GIL 與並發

  要搞清楚 GIL 對多線程程序的影響就要了解 GIL 的運行基本原理。

  • 單核CPU情況

    CPython 的 Pthread 是通過操作系統調度算法調度執行。

    Python 解釋器每執行一定數量的字節碼,或遇到系統 IO 時,會強制釋放 GIL,然后觸發一次操作系統的線程調度,實現單核 CPU 的充分利用,並且在單核上釋放和重新執行的時間間隔非常短。

  • 多核CPU情況

    多核情況下多線程執行時,一個線程在 CPU-A 執行完之后釋放 GIL,其他 CPU上 的線程都會進行競爭,但 CPU-A 可能又馬上獲取到了 GIL。這就導致其他 CPU 上被喚醒的線程只能眼巴巴地看着 CPU-A 上的線程再次執行,而自己只能等待,直到又被切換到待調度的狀態。這就會產生多核 CPU 頻繁進行線程切換,消耗着資源,但只有一個線程能夠拿到 GIL 真正執行 Python 代碼,從而導致多線程在多核 CPU 情況下,效率還不如單線程執行效率高。

    這種情況非常類似於網絡編程中的多個線程監聽同一端口造成的驚群現象,只不過是CPU級別的,造成的浪費更加奢侈。

 

GIL 與 Python

  首先確定一點:每次執行 Python 程序,都會產生一個獨立的進程。例如執行命令 python test.py,python aaa.py,python bbb.py 會產生 3 個不同的 Python 進程

  以 python test.py 距離,在這個 Python 進程內,不僅有 test.py 的主線程或(者由該主線程開啟的其他線程),還有解釋器開啟的垃圾回收等解釋器級別的線程,所有線程都運行在這一個進程內。該進程內所有數據都是共享的,這其中,代碼作為一種數據也是被所有線程共享的(test.py的所有代碼以及 Cpython 解釋器的所有代碼) 例如:test.py 定義一個函數 work(代碼內容如下圖),在進程內所有線程都能訪問到 work 的代碼。所有線程的任務,都需要將任務的代碼當做參數傳給解釋器的代碼去執行,即所有的線程要想運行自己的任務,首先需要解決的是能夠訪問到解釋器的代碼。

  如果多個線程的 target=work,那么執行流程是:多個線程先訪問到解釋器的代碼,即拿到執行權限,然后將 target 的代碼交給解釋器的代碼去執行。解釋器的代碼是所有線程共享的,所以垃圾回收線程也可能訪問到解釋器的代碼而去執行,這就導致了一個問題:對於同一個數據 100,可能線程 1 執行 x=100 的同時,而垃圾回收執行的是回收 100 的操作,解決這種問題沒有什么高明的方法,就是加鎖處理,如下圖的 GIL,保證 Python 解釋器同一時間只能執行一個任務的代碼

 

 

綜上所述,由於 GIL 的存在,同一時刻同一進程中只有一個線程被執行。這就造成了一個困惑:進程可以利用多核,但是開銷大,而 Python 的多線程開銷小,但卻無法利用多核優勢,貌似 Python 沒用了……

要解決這個問題,需要在幾個點上達成一致:

  1. CPU 是用來做計算的,還是用來做I/O的?
  2. 多 CPU,意味着可以有多個核並行完成計算,所以多核提升的是計算性能
  3. 每個 CPU 一旦遇到 I/O 阻塞,仍然需要等待,所以多核對 I/O 操作沒什么用處

  一個工人相當於 CPU,此時計算相當於工人在干活,I/O 阻塞相當於為工人干活提供所需原材料的過程,工人干活的過程中如果沒有原材料了,則工人干活的過程需要停止,直到等待原材料的到來。如果工廠干的大多數任務都要有准備原材料的過程(I/O密集型),那么有再多的工人,意義也不大,還不如一個人,在等材料的過程中讓工人去干別的活。反過來講,如果工廠原材料都齊全,那當然是工人越多,效率越高(計算密集型,也稱 CPU 密集型)

 

結論:

  • I/O 密集型:在單核 CPU上執行多線程時由解釋器實現了有效的切換,這一點是很有益處的。在 I/O 密集型的(如網絡爬蟲等)類型的程序即使使用 GIL 控制下的多線程程序性能也不會像你想象中那么糟糕。
  • CPU 密集型:對於 CPU 密集型的計算類程序 GIL 就有比較大的問題,因為 CPU 密集型的程序本身沒有太多等待,不需要解釋器介入並且所有任務只能等待 1 個核心,其他核心空閑也無法使用,這么看對多核的使用確實很糟糕。

  對計算來說,CPU 越多越好,但是對於 I/O 來說,再多的 CPU 也沒用

  當然對運行一個程序來說,隨着 CPU  的增多執行效率肯定會有所提高(不管提高幅度多大,總會有所提高),這是因為一個程序基本上不會是純計算或者純 I/O,所以只能相對的去看一個程序到底是計算密集型還是 I/O 密集型,從而進一步分析 Python 的多線程到底有無用武之地

 

拋棄和優化 GIL

  GIL 一直備受爭議,為此 PEP 也多次嘗試刪除或者優化 GIL,但是解釋器本身的復雜性和眾多 GIL 下的類庫都讓 GIL 移除成為遙不可及的想法。

  在 1999 年針對 Python 1.5,一個 free threading 補丁已經嘗試實現了這個想法,該補丁來自 Greg Stein。在這個補丁中,GIL 被完全的移除,且用細粒度的鎖來代替。然而,GIL 的移除給單線程程序的執行速度帶來了一定的代價。當用單線程執行時,速度大約降低了40%。使用兩個線程展示出了在速度上的提高,但除了這個提高,這個收益並沒有隨着核數的增加而線性增長。由於執行速度的降低,這一補丁被拒絕了,並且幾乎被人遺忘。

  1999 年多核還是個幻想,但是在現今移除 GIL 也異常困難,真的移除效果如何也是未知的,只能說回頭太難。

  2009 年 Antoine Pitrou 在Python 3.2 中實現了一個新的 GIL,並且帶着一些積極的結果。這是 GIL 的一次最主要改變,舊的 GIL 通過對 Python 指令進行計數來確定何時放棄 GIL。單條 Python 指令將會包含大量的工作,在新的 GIL 實現中,用一個固定的超時時間來指示當前的線程以放棄這個鎖,使得線程間的切換更加可預測。

 

GIL 缺陷的解決方法

  Python 作為生命力極強的熱門語言,絕對不會在多核時代坐以待斃。即便有 GIL 的限制,仍然有許多方法讓程序擁抱多核。

  • 多進程:Python2.6 引入了 MultiProcess 庫來彌補 Threading 庫中 GIL 帶來的缺陷,基於此開發多進程程序,每個進程有單獨的 GIL,避免多進程之間對 GIL 的競爭,從而實現多核的利用,但是也帶來一些同步和通信問題,這也是必然會出現的。
  • Ctypes:CPython 的優勢就是與 C 模塊的結合,因此可以借助 Ctypes 調用 C 的動態庫來實現將計算轉移,C 動態庫沒有 GIL 可以實現對多核的利用。
  • 協程:協程也是一個很好的手段,在 Python3.4 之前沒有對協程的支持,存在一些三方庫的實現,比如 gevent 和 Tornado。Python3.4 之后就內置了 asyncio 標准庫真正實現了協程這一特性。

GIL 仍然是 Python 語言里最困難的技術挑戰,GIL 問題並不是編程語言的本身問題,換做其他語言只是將問題轉移到了用戶層面,相反 Python 的作者嘗試將這種問題轉移到解釋器給使用者呈現一個優雅的語言。

雖然多核時代的到來暴露了 GIL 的缺陷,但是 Python 決策者和社區開發者已經做出了許多其他措施來擁抱多核,無知地詬病 GIL 是不明智的做法。如同生產關系要適應生產力的發展一樣,拋開歷史背景談機制的優劣,都是有失偏頗的,所以對待 GIL 要辯證看待。

 

 

參考:https://www.cnblogs.com/linhaifeng/articles/7449853.html

  https://mp.weixin.qq.com/s?__biz=MzI0OTc0MzAwNA==&mid=2247491714&idx=1&sn=304399c327c149aaf5882f35ee523f30


免責聲明!

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



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