PEP703是未來去除GIL的計劃,當然現在提案還在繼續修改,但大致方向確定了。
對於實現細節我沒啥興趣多說,挑幾個我比較在意的點講講。
沒了GIL之後會出現兩個以上的執行緒同時操作同一個Python物件的情況,首先要解決的是參照計數的計算不能出岔子,否則整個記憶體管理就無從談起了。
多執行緒間的參照計數有很多現成方案了,比如c++的shared_ptr
但原子操作是有代價的,雖然比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
現在至少是不需要系統呼叫了。
這東西看著簡單,然而細節問題非常多,整個增強提案快有三分之一的篇幅在將這東西怎麼實現的。有興趣可以研讀PEP703,大多數人我覺得了解到這個程度就差不多了。
先簡單說下3.12將帶來的「永生代物件」。如字面意思,有些物件從建立之後就永遠不會被回收,也永遠不會被改變(None, True/False, 小整數),對於這些物件來說參照計數的操作是沒什麼必要的,所以乾脆就不去更新參照計數了。減少這些不必要的參照計數維護操作之後能提升一點效能,也能保證這些物件的在去除GIL之後更安全。
延遲參照計數又是什麼呢?有一些物件的生命週期比其他物件長的多,但不如永生代物件那樣會始終存在,後面可能會被回收也可能會被修改;同時相比一般的物件大多數的存取都發生在本地執行緒,這類物件會更頻繁地被跨執行緒存取。這類物件上更新參照計數在多數情況下會需要用原子操作更新跨執行緒計數器,使用原先的參照計數策略在效能上會很不划算,所以出現了延遲參照計數來緩解這一問題。
這種物件通常是function,class,module等。python很靈活,可以執行時建立或修改這些物件,仔細想想是不是很符合上面的描述。
對於這類物件,python直譯器會考慮跳過一些參照計數的更新,然後把跳過更新的數量放線上程原生的計數器裡,等到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擴充套件的遷移,我覺得這個估計有點過於樂觀了。而且現在誰也沒法預言三五年以後會怎麼樣。