Python的快速并行版本:PyParallel
PyParallel是Trent Nelson發起的一個研究項目,其目標是以提供高性能異步支持的方式將Windows I/O完成端口(IOCP)的強大功能移到Python中。
Python的異步支持多少有點問題。它是圍繞Unix/Linux的異步、非阻塞I/O理念設計的。線程會持續輪詢進入的數據,然后相應進行分 發。盡管Linux針對該模式進行了調優,但在Windows機器上,這種處理方式是性能的災難。將數據從輪詢線程復制到真正處理任務的線程,非常昂貴。
PyParallel帶來的就是使用了原生IOCP的真正的異步。在IOCP模型下,每個核有一個線程。每個線程負責處理完成I/O請求(比如,從網卡復制數據)和執行請求關聯的應用層回調。
只有這一點尚不足以橫向擴展Python;還需要解決GIL(Global Interpreter Lock,全局解釋器鎖)帶來的問題。否則我們仍然被限制于每次執行一個線程。使用細粒度的鎖替換GIL,結果會更糟糕;像PyPy中的軟件事務內存往往 最終會導致1個線程繼續推進,N-1個線程持續重試的問題。所以我們需要別的解決方案。
對PyParallel團隊來說,這個解決方案就是不允許自由創建線程。換言之,應用不能隨意創建新線程。相反,并行操作被綁定到異步回調機制和并行上下文(parallel context)的概念。
在深入并行上下文之前,我們先反過來看一下。當并行上下文不運行的時候,主線程會運行;反之亦然。主線程就是你進行正常的Python開發所考慮的東西。主線程持有GIL,對全局命名空間具有完全的訪問權限。
相反,并行上下文對全局命名空間只能進行只讀訪問。這意味著,開發者需要注意某個事物是主線程對象還是并行上下文對象。處理過套間線程模型(apartment threading models)的COM程序員對其中的痛苦是再清楚不過了。
對于非I/O任務,主線程使用async.submit_work函數對任務進行排隊,然后使用async.run 函數切換到并行上下文。這會掛起主線程,并激活并行解釋器。多個并行上下文可以同時運行,由Windows操作系統處理線程池的管理。
與GIL并行
有一點非常重要,需要注意一下,這里并沒有創建多個進程。盡管多進程技術在Python開發中很常用,但PyParallel將所有東西都放在了一個進程中,以減少跨進程通信的代價。這通常是不允許的,因為CPython解釋器不是線程安全的,這包括:
-
全局靜態數據會頻繁用到
</li> -
引用計數不是原子的
</li> -
對象沒有用鎖保護
</li> -
垃圾收集不是線程安全的
</li> -
拘留字符串(Interned string)的創建不是線程安全的
</li> -
bucket內存分配器不是線程安全的
</li> -
arena內存分配器不是線程安全的
</li> </ul>Greg Stein曾嘗試通過向Python 1.4中加入細粒度的鎖來解決該問題,但是在單線程代碼中,他的項目導致速度下降40%,所以被拒絕了。因此Trent Nelson決定采用不同的方案。在主線程中,GIL和原來一樣運作。但是當在并行上下文中運行的時候,會使用線程安全的替換方案代替核心函數來運行。
Trent的方案的代價是0.01%,比Greg的方案好得多。至于PyPy的軟件事務內存,對單線程模型而言,其代價大概是200~500%。
該設計的一個有趣的地方是,在并行上下文中運行的代碼,當要從全局命名空間中的對象中讀取數據時,不需要獲得鎖。不過它只有讀的能力。
PyParallel沒有垃圾收集器
為了避免處理內存分配、存取和垃圾收集相關的鎖,PyParallel使用了一種無共享模式。每個并行上下文都有自己的堆,沒有垃圾收集器。就是這樣,沒有與并行上下文關聯的垃圾收集器。因此實際上是這樣:
-
內存分配使用一個簡單的塊分配器完成。每次內存分配只是調整一下指針。
</li> -
根據需要分配4K或2MB大小的新頁面,這由并行上下文的大頁面設置控制。
</li> -
不使用引用計數。
</li> -
當并行上下文結束時,與它關聯的所有頁面同時釋放。
</li> </ul>這種設計避免了線程安全的垃圾收集器或線程安全的引用計數的代價。另外,它支持前面提到的塊分配器,這可能是最快的內存分配方式了。
PyParallel團隊認為這種設計可以成功,因為并行上下文意在支持生命周期較短、范圍較為有限的應用。一個很好的例子是并行排序算法或Web頁面請求處理程序。
為使這種設計正常工作,在并行上下文中創建的對象不能逃逸到主線程中。這是通過只讀訪問全局命名空間這一限制來保證的。
引用計數與主線程對象
這時我們有兩類對象:主線程對象和并行上下文對象。主線程對象會使用引用計數進行管理,因為它們在某一時刻需要回收。但并行上下文對象沒有使用引用計數。但如果兩類對象有相互作用,那該如何處理呢?
因為并行上下文對象不能修改主線程對象,所以它就不能改變主線程對象的引用計數。但是又因為,當并行上下文運行時,主線程的垃圾收集器無法運行,所以這就不是問題了。當主線程的垃圾收集啟動時,所有的并行上下文對象都已經銷毀,所以沒有從它們指回到主線程對象的東西。
這一切的最終結果是,在并行上下文中執行的代碼通常比在主線程中執行的代碼快。
并行上下文與異步I/O
當考慮異步I/O調用時,上面討論的內存模型就有問題了。這些調用會使并行上下文存活的時間比系統設計的存活時間長得多。像Web頁面請求處理程序這樣的情況,調用的數目是沒有限制的。
為處理這一問題,Trent加入了快照(snapshot)的概念。當一個異步回調開始時,就為并行上下文的內存保存一個快照。在回調的最后,所有 改變都會被恢復,新分配的內存也會釋放。這比較適合無狀態應用,比如Web頁面請求處理程序,但是對需要保持數據的應用就不合適了。
快照最多可以嵌套64層深,但是Trent沒有詳細描述其處理細節。
平衡同步與異步I/O
異步I/O不是免費的午餐。如果想獲得最大的吞吐量,同時保持最低的延遲,同步I/O實際上更快。但只有并發請求數比可用的核數少時,這才成立。
因為開發者不一定會隨時了解負載情況,所以指望他決策可能是不合理的。因此PyParallel提供了一個套接字(socket)庫,可以在運行時 根據活動的客戶端數做出決策。只要活動客戶端的數目比核數少,就執行同步代碼。如果客戶端數超過了核數,該庫會自動切換到異步模式。不管哪種方式,這里的 修改對應用都是透明的。
異步HTTP服務器
作為概念驗證的一部分,PyParallel還提供了一個異步HTTP服務器,它基于stdlib 中的SimpleHttpServer。它的一個主要特性是支持Win32函數TransmitFile,允許數據直接從文件緩存發送到套接字。
未來計劃
未來,Trent希望繼續改進內存模型,準備通過引入一組新的互鎖(interlocked)數據類型和使用上下文管理器控制內存分配協議來實現。
與Numba的集成也正在進行之中。想法是異步啟動Numba,當Numba完成時,將CPython交換出去,換為本機生成的代碼。
另一個計劃中的變化是支持可插拔的PxSocket_IOLoop端點。這就允許不同的協議以流水線方式鏈到一起。在可能的情況下,他想使用管道代替套接字,因為這可以減少在多個步驟之間所要復制的必要數據的量。
更多信息,可以查看Trent Nelson的演講:PyParallel - How We Removed the GIL and Exploited All Cores (Without Needing to Remove the GIL at all)。
來自 InfoQ
-