最令人頭疼的Python問題

Coureur發表於2017-10-13

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 程式碼庫的徹底研究,但是仍有可能存在不準確的地方。如果你發現了不準確的內容,請及時告知我,我會盡快修正。

相關文章