我們能從PEP 703中學到什麼

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擴展的遷移,我覺得這個估計有點過於樂觀了。而且現在誰也沒法預言三五年以後會怎麼樣。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章