我們能從PEP 703中學到什麼

来源:https://www.cnblogs.com/apocelipes/archive/2023/09/05/17679741.html
-Advertisement-
Play Games

PEP703是未來去除GIL的計劃,當然現在提案還在繼續修改,但大致方向確定了。 對於實現細節我沒啥興趣多說,挑幾個我比較在意的點講講。 ## 儘量少依賴原子操作的引用計數 沒了GIL之後會出現兩個以上的線程同時操作同一個Python對象的情況,首先要解決的是引用計數的計算不能出岔子,否則整個記憶體管 ...


PEP703是未來去除GIL的計劃,當然現在提案還在繼續修改,但大致方向確定了。

對於實現細節我沒啥興趣多說,挑幾個我比較在意的點講講。

儘量少依賴原子操作的引用計數

沒了GIL之後會出現兩個以上的線程同時操作同一個Python對象的情況,首先要解決的是引用計數的計算不能出岔子,否則整個記憶體管理就無從談起了。

多線程間的引用計數有很多現成方案了,比如c++的shared_ptr,還有rust的Arc。這些方案都使用原子操作來維護引用計數並保證線程安全。

但原子操作是有代價的,雖然比mutex要小,但依舊會產生不少的性能倒退,這也是為什麼c++里一般不推薦多用shared_ptr<T>的原因之一。

更重要的一點是,python是大量使用引用計數來管理記憶體的,原子操作帶來的性能影響會被放大到不能接受的地步。

但想要保證線程安全又不得不做一些同步措施,所以python選擇了這個方案:Biased Reference Counting

暫時沒想到好的譯名,字面意思就是不精確的引用計數。

大致思路是這樣的:通過統計分析,大多數引用計數的修改只會發生在擁有引用計數對象的單個線程里(對於python來說通常是創建出對象的那個線程),跨線程共用並操作計數的情況沒有那麼多。所以可以對引用計數的操作分為兩類,一類是擁有計數的那個線程(為了方便後面叫本地線程)的訪問,這種訪問不需要加鎖也不需要原子操作;另一種是跨線程的訪問,這種會單獨分配一個計數器給本地線程之外的線程訪問,訪問採用原子操作。最後真正的引用計數是本地線程的計數加上跨線程訪問使用的計數。

這樣做的好處是減少了大量的不必要的原子操作,按原論文描述相比直接使用原子操作,上述的方法可以提升7%到20%的性能。

壞處也是顯而易見的,某個時間點獲得的引用計數的值不一定准確,這導致需要做很多補正措施,而且python為了避免計數器數值溢出的問題需要一個本地線程計數器和跨線程計數器,導致需要占用更多記憶體。

新的對象頭暫定是這樣子:

struct _object {
  _PyObject_HEAD_EXTRA
  uintptr_t ob_tid;         // 本地線程的線程標識符 (4-8 bytes)
  uint16_t __padding;       // 記憶體填充,以後可能會變成其他欄位也可能消失,不用在意 (2 bytes)
  PyMutex ob_mutex;         // 每個對象的輕量級互斥鎖,後面細說 (1 byte)
  uint8_t ob_gc_bits;       // GC fields (1 byte)
  uint32_t ob_ref_local;    // 本地線程計數器 (4 bytes)
  Py_ssize_t ob_ref_shared; // 跨線程共用計數器 (4-8 bytes)
  PyTypeObject *ob_type;
};

另外跨線程共用計數器還有2bit用了表示引用計數的狀態,以便python正確處理引用計數。

對於目前的引用計數處理也需要改造:

// low two bits of "ob_ref_shared" are used for flags
#define _Py_SHARED_SHIFT 2

void Py_INCREF(PyObject *op)
{
  uint32_t new_local = op->ob_ref_local + 1;
  if (new_local == 0)
    // 3.12的永生對象,它們不參與引用計數,並會一直存在伴隨整個程式的運行
    // 看3.12源碼的話會發現檢查是不是永生對象的方法不太一樣,反正這裡是偽代碼,別太在意
    return;
  if (op->ob_tid == _Py_ThreadId())
    op->ob_ref_local = new_local;
  else
    atomic_add(&op->ob_ref_shared, 1 << _Py_SHARED_SHIFT);
}

需要檢查的條件比原來多了很多,勢必會對性能產生一定的負面影響。

另一個潛在的性能影響是如何獲取線程的id,在linux上會使用gettid這個系統調用,如果這麼做的話性能是會嚴重下降的,所以得用些hack:

static inline uintptr_t
_Py_ThreadId(void)
{
    // copied from mimalloc-internal.h
    uintptr_t tid;
#if defined(_MSC_VER) && defined(_M_X64)
    tid = __readgsqword(48);
#elif defined(_MSC_VER) && defined(_M_IX86)
    tid = __readfsdword(24);
#elif defined(_MSC_VER) && defined(_M_ARM64)
    tid = __getReg(18);
#elif defined(__i386__)
    __asm__("movl %%gs:0, %0" : "=r" (tid));  // 32-bit always uses GS
#elif defined(__MACH__) && defined(__x86_64__)
    __asm__("movq %%gs:0, %0" : "=r" (tid));  // x86_64 macOSX uses GS
#elif defined(__x86_64__)
    __asm__("movq %%fs:0, %0" : "=r" (tid));  // x86_64 Linux, BSD uses FS
#elif defined(__arm__)
    __asm__ ("mrc p15, 0, %0, c13, c0, 3\nbic %0, %0, #3" : "=r" (tid));
#elif defined(__aarch64__) && defined(__APPLE__)
    __asm__ ("mrs %0, tpidrro_el0" : "=r" (tid));
#elif defined(__aarch64__)
    __asm__ ("mrs %0, tpidr_el0" : "=r" (tid));
#else
  # error "define _Py_ThreadId for this platform"
#endif
  return tid;
}

https://github.com/colesbury/nogil/blob/f7e45d6bfbbd48c8d5cf851c116b73b85add9fc6/Include/object.h#L428-L455

現在至少是不需要系統調用了。

這東西看著簡單,然而細節問題非常多,整個增強提案快有三分之一的篇幅在將這東西怎麼實現的。有興趣可以研讀PEP703,大多數人我覺得瞭解到這個程度就差不多了。

延遲的引用計數

先簡單說下3.12將帶來的“永生代對象”。如字面意思,有些對象從創建之後就永遠不會被回收,也永遠不會被改變(None, True/False, 小整數),對於這些對象來說引用計數的操作是沒什麼必要的,所以乾脆就不去更新引用計數了。減少這些不必要的引用計數維護操作之後能提升一點性能,也能保證這些對象的在去除GIL之後更安全。

延遲引用計數又是什麼呢?有一些對象的生命周期比其他對象長的多,但不如永生代對象那樣會始終存在,後面可能會被回收也可能會被修改;同時相比一般的對象大多數的訪問都發生在本地線程,這類對象會更頻繁地被跨線程訪問。這類對象上更新引用計數在多數情況下會需要用原子操作更新跨線程計數器,使用原先的引用計數策略在性能上會很不划算,所以出現了延遲引用計數來緩解這一問題。

這種對象通常是function,class,module等。python很靈活,可以運行時創建或修改這些對象,仔細想想是不是很符合上面的描述。

對於這類對象,python解釋器會考慮跳過一些引用計數的更新,然後把跳過更新的數量放線上程本地的計數器里,等到GC運行的時候,會檢查對象本身的引用計數和各個線程里緩存的跳過操作的數量,再加上可達性分析來確定這個對象是不是需要被回收。

好處是減少了引用計數的更新,大部分時間只需要更新線程本地的數據因此沒有數據衝突也不需要原子操作;壞處是實現比較複雜,判斷對象是否需要回收需要gc參與進來。

gc不再會分代

去除GIL後gc可能不會在分代,gc的策略會變成按記憶體壓力或者定時觸發。

真正支持多線程並行運行之後,gc需要STW,即暫停除gc線程之外的所有線程運行直到gc運行結束。以前有GIL的時候實際上也差不多,gc開始運行之後會鎖住GIL,之後只有gc能運行其他所有操作都會阻塞住。

分代垃圾回收的核心理念是大部分的對象在年輕代的時候就會被回收,因此分出年輕代中年代老年代之後可以減少不必要的gc操作。

這個理論很對,而且對python也適用。但不巧的是python里大多數年輕代對象在引用計數變成0之後就立即釋放了,根本不需要垃圾回收器參與。雪上加霜的是python的年輕代回收策略是進行了N次對象創建後運行一次年輕代gc,中年代回收策略是N次年輕代回收後會掃描一般中年代的對象,因為引用計數的存在很多時候這種gc掃描是在空轉。

在真正實現並行之後STW帶來的影響是不容忽略的,頻繁的gc空轉會浪費資源和性能。所以分代回收策略不再合適。

另一個原因是目前分代的對象被存在雙鏈表裡,而python的gc演算法對這些鏈表的操作比較平凡,想要實現一個等價的多線程併發安全、足夠高效並儘量相容現有api的演算法會非常困難,所以乾脆放棄分代回收演算法了。

雖然gc幾乎要完全重構,但針對gc的性能優化策略還是沒怎麼變的:不要無節制創建對象,做好資源復用。

對象鎖

有GIL存在的時候,python可以保證同一時間只有一個線程在操作python對象,雖然這根本避免不了“數據競爭”問題(當前線程的某個操作可以中途被打斷的話即使有GIL也不可能保證數據不會被其他線程修改導致數據損壞),但可以保護python自己運行所依賴的各種數據不會被損壞,因此即使你的數據損壞了python本身也能繼續安全地運行下去。

想象一下這樣的代碼:

listOne.extend(listTwo)

extend並不是原子操作,且整個流程不止調用一個Python C API,因此從參數傳遞到添加完listTwo所有元素前都有可能會暫停當前線程的執行讓其他線程得到機會運行,假如這個時候有個線程2會改變listTwo或者往listOne里添加/刪除了某些元素,這句表達式的運行結果就會和你所預期的大相徑庭,GIL並不能防止數據競爭這樣的問題。

沒了GIL後這些就不一樣了,現在不僅會有race condition,還會有多個線程同時修改python對象導致運行時需要的各種元數據損壞,這輕則導致數據錯亂記憶體泄漏,重則會讓進程直接崩潰。

有人可能會想這些不是很自然的規矩麽,c++,java,golang里哪個不是這樣的?然而python之前並不是,也不存在這類問題。為了相容,python也不可能大幅修改已有的語言行為。

一個更現實的問題是,很多時候上面這樣的問題只在python代碼裡加鎖是解決不了的,解決不了python的穩定性就會大打折扣,誰敢用一個不知道什麼時候就崩潰了的程式呢?

目前提出的解決辦法是在每個python對象裡加個輕量級的鎖:

struct _object {
  _PyObject_HEAD_EXTRA
  ...
  PyMutex ob_mutex;         // 每個對象的輕量級互斥鎖 (1 byte)
  ...
  PyTypeObject *ob_type;
};

每個線程操作這個對象的時候都要去獲取鎖,這樣保證同一時間只會有一個線程在訪問python對象。

多個線程訪問同一個對象的時候會阻塞在對象的鎖上,但如果訪問的是不同的對象,就能真正實現並行運行了。

這麼乾好處是沒了GIL也能儘量保證對象數據的安全,壞處是占用記憶體,且實現複雜非常容易犯錯(為了提升性能,還整了不少特定條件下不需要鎖的fast path,更複雜了),而且再輕量也是鎖,會降低性能。

還有一點,對象鎖粒度比GIL細得多,GIL尚且不能保證數據的併發安全,新的對象鎖就更不能了,老老實實用mutex就行:

from threading import Thread, Lock

mutex = Lock()

def processData(data):
    with mutex:
        print('Do some stuff with data')

性能代價

香農計劃還在如火如荼進行中,增強提案本身也在修改演進,所以最後記憶體占用和運行性能要為這些改動付出多少代價還是個未知數。

目前來看記憶體占用的問題其實不是很突出,但引用計數的原子操作以及更新操作更多的條件判斷、延遲引用計數和不分代後gc每次回要掃描更多對象、對象上的鎖等會帶來客觀的性能損耗。

按照PEP703給的數據,每個核心上的性能損耗超過5%但不到9%,多線程時損耗會稍大一點。

但由於去除GIL之後python可以真正地利用多核心進行並行計算,所以單個核心損耗了5%最後依靠並行的優勢依舊能大幅提升性能。

一個簡單的數學題:假設以前單核單線程在單位時間能處理100w個數據,現在每個核心有10%性能損耗,在此基礎上線程間調度和同步又會帶來10%的性能下降,那麼利用雙核兩線程後單位時間能處理多少數據:100w x 90% x 90% x 2 = 162w。以這樣極端的情況計算仍然能獲得60%以上的性能提升。

另外提案里還提到703和多解釋器並不衝突(703是建立在進程里只有一個解釋器的基礎上的),也可以期待兩個方案共存後的化學反應。

總結

想寫這篇文章的主要原因是記錄下python社區在性能上的取捨,尤其讓我覺得該多說兩句的就是引用計數上的取捨和gc演算法的選擇,充分體現了軟體開發中的“權衡”。

整個提案看下來我就一個想法:當初要是沒選擇用引用計數來管理記憶體,也許今天去除GIL的時候就用不著費這麼大勁兒了,而且為了相容老代碼不得不做了大量的妥協。

目前整個方案在不斷修改,社區有討論到第一個能拿來測試non-GIL代碼的版本最快也得3.17了,考慮到改動的規模和難度以及各種庫和c擴展的遷移,我覺得這個估計有點過於樂觀了。而且現在誰也沒法預言三五年以後會怎麼樣。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • ## 1、標準原子類型 標準原子類型的定義位於頭文件``內。原子操作的關鍵用途是取代需要互斥的同步方式,但假設原子操作本身也在內部使用了互斥,就很可能無法達到期望的性能提升。有三種方法來判斷一個原子類型是否屬於無鎖數據結構: - 所有標準原子類型(`std::atomic_flag`除外,因為它必須 ...
  • ![file](https://img2023.cnblogs.com/other/268922/202309/268922-20230905194637565-1884920101.png) 上圖的意思: 百戰百勝,屢試不爽。 # 故事 ![file](https://img2023.cnblog ...
  • # Hook Method 鉤子方法(Hook Method)之所以被稱為“鉤子”,是因為它在演算法或流程中提供了一個“鉤子”,允許子類在特定的點上“鉤入”自己的實現邏輯,從而影響演算法的行為或流程的執行。 它類似於一個掛鉤、錨點,所以叫Hook method, 它允許子類插入自定義的代碼來改變或擴展算 ...
  • 原文在[這裡](https://go.dev/blog/rebuild)。 > 由 Russ Cox 發佈於 2023年8月28日 開源軟體的一個關鍵優勢是任何人都可以閱讀源代碼並檢查其功能。然而,大多數軟體,甚至是開源軟體,都以編譯後的二進位形式下載,這種形式更難以檢查。如果攻擊者想對開源項目進行 ...
  • 類型介面 Go不是面向對象的語言,在go里通過不同的結構體實現同一組公共介面這種組合的形式實現多態,類似C++的類和虛函數 定義類型介面(Interface Definition): 使用 type 關鍵字定義介面,指定介面的方法簽名。 方法簽名由方法的名稱、參數列表和返回值組成,但不包含方法體。 ...
  • ## 教程簡介 Spring框架是一個開放源代碼的J2EE應用程式框架,由Rod Johnson發起,是針對bean的生命周期進行管理的輕量級容器(lightweight container)。 Spring解決了開發者在J2EE開發中遇到的許多常見的問題,提供了功能強大IOC、AOP及Web MV ...
  • # Python文件的基礎操作(2) - 二進位模式下讀寫操作 - 練習 - 控制文件內指針(游標)的移動 - 文件的修改 - 函數的介紹 ## 練習 ```python # 小練習: 編寫拷貝工具 1. 讓用戶輸入被拷貝的文件路徑(源文件路徑) 2. 輸入即將要拷貝的文件路徑(目標文件路徑) 3. ...
  • `@Autowired` 是 Spring Framework 中用於自動註入依賴的註解,通常情況下可以正常工作,但有一些情況下可能無法獲取到 bean 對象: 1. **Bean未定義或未掃描到**:如果要註入的 bean 沒有在 Spring 上下文中定義或者沒有被正確掃描到,`@Autowir ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...