最令人頭疼的Python問題
Python中由于使用了全局解釋鎖(GIL)的原因,代碼并不能同時在多核上并發的運行,也就是說,Python的多線程不能并發,很多人會發現使用多線程來改進自己的Python代碼后,程序的運行效率卻下降了。這篇文章對Python中的全局解釋鎖(GIL)進行了介紹。作者認為這是Python中最令人頭疼的問題。
十年多年來,Python 的全局解釋器鎖(GIL)給新手和專家們帶來了巨大的挫折感和好奇心。
懸而未決的問題
每個領域都會有這么一個問題:它難度大、耗時多,僅僅是嘗試解決這個問題都會讓人震驚。整個社區在很久以前就放棄了這個問題,現在只有少數人在努力試圖解決它。對于初學者來說,解決這樣高難度的問題,會給他帶來足夠的聲譽。計算機科學領域中的 P = NP 就是這樣的問題。如果能用多項式時間復雜度解決這個問題,那簡直就可以改變世界了。Python 中最困難的問題比 P = NP 要容易一些,不過迄今仍然沒有一個滿意的答案,解決這個問題和解決 P = NP 問題一樣具有革命性。正因為如此, Python 社區會有如此多的人關注于這個的問題: “對于全局解釋器鎖(GIL)能做什么?”
Python 的底層
要理解 GIL 的含義,我們需要從 Python 的基礎說起。像 C++ 這樣的語言屬于編譯型語言,顧名思義,該類型語言的代碼輸入到編譯器,由編譯器根據語言的語法進行解析,生成與語言無關的中間表示,***鏈接成由高度優化的機器碼組成的可執行程序。因為編譯器可以獲取全部代碼(或者是一大段相對獨立的代碼),所以編譯器可以對代碼進行深度優化。這使得它可以對不同的語言結構之間的交互進行推理,從而做出更有效的優化。
相反,Python 是解釋型語言。代碼被輸入到解釋器來運行。解釋器在執行之前對代碼一無所知;它只知道 Python 的規則,以及如何在執行過程中動態地應用這些規則。它也有一些優化,但是和編譯型語言的優化完全不同。由于解釋器不能很好地對代碼進行推導,Python 的大部分優化其實是解釋器本身的優化。更快的解釋器自然意味著更快的程序運行速度,而這種優化對開發者來說是免費的。也就是說,解釋器優化后,開發者不用修改 Python 代碼就可以坐享優化帶來的好處。
這是非常重要的一點,這里有必要在強調一下。在同等條件下,Python 程序的運行速度與解釋器的“速度”直接相關相關。無論開發者怎樣優化自己的代碼,程序的執行速度還是受限于解釋器的執行效率。很明顯,這就是為什么做了如此多的工作去優化 Python 解釋器。這大概是離 Python 開發者最近的免費的午餐。
免費午餐結束了
還是沒有結束?摩爾定律告訴了我們硬件提速的時間表,同時,整整一代程序員學會了如何在摩爾定律下編寫代碼。如果程序員寫了比較慢的代碼,最簡單的辦法通常是稍稍等待一下更快的處理器問世即可。事實上,摩爾定律仍然是并且會在很長一段時間內是有效的,不過它生效的方式有了根本的變化。時鐘頻率不會穩定增長到一個高不可攀的速度,取而代之的是通過多核來利用晶體管密度提高帶來的好處。想要程序能夠充分利用新處理器的性能,就必須按照并發方式對代碼進行重寫。
大部分開發者聽到“并發”通常會馬上想到多線程程序。目前,多線程仍是利用多核系統最常見的方式。多線程編程比傳統的“順序”編程要難很多,不過仔細的程序員可以在代碼中充分利用多線程的并發性。既然幾乎所有應用廣泛的現代編程語言都支持多線程編程,語言在多線程方面的實現應該是事后添加上去的。
意外的事實
現在我們來看一下問題的癥結所在。想要利用多核系統,Python 必須支持多線程。作為解釋型語言,Python 的解釋器對多線程的支持必須是既安全又高效的。我們都知道多線程編程帶來的問題。解釋器必須避免不同的線程操作內部共享的數據。同時還要保證用戶線程能完成盡量多的計算。
那么在不同線程同時訪問數據時,怎樣才能保護數據呢?答案是全局解釋器鎖。顧名思義,這是一個加在解釋器上的全局鎖(從互斥量或者類似意義上來看)。這種方式是很安全,但是(對于 Python 初學者來說)這也就意味著:對于任何 Python 程序,不論有多少線程,多少處理器,任何時候都只有一個線程在執行。
許多人都是偶然發現這個事實。網上的討論組和留言板充斥著來自 Python 初學者和專家提出的類似的問題:為什么我全新的多線程 Python 程序運行得比其只有一個線程的時候還要慢?在問這個問題時,許多人還覺得自己像個傻瓜,因為如果程序確實是可并行的,那么兩個線程的程序顯然要比單線程要快。事實上,問及這個問題的次數實在太多了,Python 的專家們已經為它準備了一個標準答案:不要使用多線程,請使用多進程。但這個答案比問題本身更加讓人困惑:難道我不能在 Python 中使用多線程?在 Python 這樣流行的語言中使用多線程究竟是有多糟糕,連專家都建議不要使用。是我哪里沒有搞明白嗎?
很遺憾,并不是。由于 Python 解釋器的設計,使用多線程以提高性能可以算是一個困難的任務。在最壞的情況下,多線程反而會降低(有時很明顯)程序的運行速度。一個計算機科學專業的新生就可以告訴你:當多個線程競爭一個共享資源時將會發生什么。結果通常不理想。很多情況下多線程都能很好地工作,對于解釋器的實現和內核開發人員來說,不要對 Python 多線程性能有太多抱怨可能是他們***的心愿。
現在該怎么辦呢?慌了嗎?
我們現在能做什么呢?難道作為 Python 開發人員的我們要放棄使用多線程來實現并行嗎?為什么 GIL 在某一時刻只允許一個線程在運行呢?在并發訪問時,難道不可以用粒度更細的鎖來保護多個獨立對象?為什么沒有人做過類似的嘗試呢?
這些問題很實用,它們的答案也十分有趣。GIL 為很多對象的訪問提供這保護,比如當前線程狀態和為垃圾回收而用的堆分配對象。這對 Python 語言來說沒什么奇怪的,它需要使用一個 GIL 。這是該實現的一種產物。現在也有不使用 GIL 的 Python 解釋器(和編譯器)。但是對于 CPython 來說,從其產生到現在 GIL 就一直在存在了。
那么為什么我們不拋棄 GIL 呢?許多人也許不知道,1999年的時候,Greg Stein 針對 Python 1.5 提交了一個名為“free threading”的補丁,這個補丁經常被提到卻不怎么被人理解。這個補丁就嘗試了將 GIL 完全移除,并用細粒度的鎖來代替。然而,GIL 移除的代價是單線程程序的執行速度下降,下降的幅度大概有 40%。使用兩個線程可以讓速度有所提升,但是速度的提升并沒有隨著核數的增加而線性增長。由于執行速度的降低,這一補丁沒有被接受了,并且幾乎被人遺忘。
GIL 讓人頭痛,我們還是想點其他辦法吧
盡管“free threading”這個補丁沒有被接受,但是它還是有啟發性意義。它證明了一個關于 Python 解釋器的基本要點:移除 GIL 是非常困難的。比起該補丁發布的時候,現在的解釋器依賴的全局狀態變得更多了,這使得移除 GIL 變得更加困難。值得一提的是,也正是因為這個原因,許多人對移除 GIL 變得更感興趣了。困難的問題通常都很有趣。
但是這可能有點被誤導了。我們假設一下:如果我們有這樣一個神奇的補丁,它其移除了 GIL ,并且沒有使單線程的 Python 代碼性能下降,我們會得到一直想要的東西:一個能并發使用所有處理器的線程 API。現在我們已經獲得了我們希望的,但這確實是件好事嗎?
基于線程的編程是困難的。當一個人覺得自己了解關于線程的一切,總會有一些新問題出現。一些非常知名的語言設計者和研究者站出來反對線程模型,因為在這方面想要得到合理的一致性真的是太難了。就像任何一個寫過多線程應用程序的人可以告訴你的一樣,不管是多線程應用的開發還是調試難度都會是單線程的應用的指數倍。程序員的思維模型往往適應順序執行模型,恰恰與并行執行模型不匹配。GIL 的出現無意中幫助了開發者免于陷入困境。在使用多線程時仍然需要同步原語,GIL 事實上幫助我們保證不同線程之間的數據一致性。
這么說起來 Python 最難的問題似乎有點問錯了問題。Python 專家推薦使用多進程代替多線程是有道理的,而不是想要給 Python 線程實現遮羞。Python 的這種實現方式促使開發者使用更安全也更直觀的方式實現并發模型,同時保留使用多線程進行開發,讓開發者在必要的時候使用。大多數人可能并不清楚什么是***的并行編程模型。但是大多數人都清楚多線程的方式并不是***的并行模型。
不要認為 GIL 是一成不變或者毫無道理的。Antoine Pitrou 在 Python 3.2 中實現了一個新的 GIL ,比較顯著地改進的 Python 解釋器。這是1992年以來,針對 GIL 最主要的一次改進。這個改變非常巨大,很難在這里解釋清楚,但是從高層次來看,舊的 GIL 通過對 Python 指令進行計數來確定何時釋放 GIL。由于 Python 指令和翻譯成的機器指令并非一一對應的關系,這使得單條 Python 指令可能包含大量工作。新的 GIL 用一個固定的超時時間來指示當前的線程釋放鎖。在當前線程持有鎖且第二個線程請求這個鎖的時候,當前線程就會在 5 ms 后被強制釋放這個鎖(這就是說,當前線程每 5 ms 就要檢查其是否需要釋放這個鎖)。在任務可以執行的情況下,這使得預測線程間的切換變得更容易。
然而,這并不是一個***的改進。對于不同類型任務執行過程中 GIL 的作用的研究,David Beazley 可能是最活躍的一個。除了對 Python 3.2 之前的 GIL 研究最深入,他還研究了這個***的 GIL 實現,并且發現了很多有趣的程序方案:在這些方案中,即使是新的 GIL 實現,表現也相當糟糕。他目前仍然通過實踐研究來推動著有關 GIL 的討論,并發布實踐結果。
不管人們對 Python 的 GIL 看法如何,它仍然是 Python 語言里最困難的技術挑戰。想要理解它的實現需要對操作系統設計、多線程編程、C 語言、解釋器設計和 CPython 解釋器的實現有著非常透徹的理解。單是這些前提就妨礙了很多開發者去更徹底地研究 GIL。然而并沒有任何跡象表明 GIL 會在不久之后遠離我們。目前,它將繼續給那些新接觸 Python 并對解決技術難題感興趣的人帶來困惑和驚喜。
以上內容是基于我目前對 Python 解釋器的研究。我打算寫一些關于解釋器其它方面的內容,但是沒有比 GIL 知名度更高的了。雖然這些技術細節來自我對 CPython 代碼庫的徹底研究,但是仍有可能存在不準確的地方。如果你發現了不準確的內容,請及時告知我,我會盡快修正。