這里將會使用 Python蜡塌,把操作系統(tǒng)中的原生線程完全重寫為基于協(xié)程的版本。使現(xiàn)有的非異步程序勿负,“無論使用何種語言開發(fā)”馏艾,都能在無需修改和重新編譯的情況下直接變成協(xié)程架構下的異步程序,從而在并發(fā)處理能力上取得大幅提升奴愉。本主題將會在 Python 的擴展與嵌入琅摩,以及協(xié)程與異步編程等方面進行較為深入地探討。
不過在開始之前锭硼,思慮在三眶痰,我覺得還是要先介紹一下協(xié)程∪鹉悖現(xiàn)在 Python 增加了關鍵字 async/await,相信大家對協(xié)程已經(jīng)比較熟悉了 —— 協(xié)程实撒,是在系統(tǒng)線程中模擬出來的更小的線程修肠。不管開了多少個協(xié)程钠怯,本質上還是單線程程序淋袖。
我這里用了一張圖搅窿,五角場中環(huán)的大轉盤。我經(jīng)常拿交通上的調度來講線程和協(xié)程的不同廊镜。線程是用時間片來調度的牙肝,就像紅綠燈。時間一到,不管這條路上有沒有車惊奇,通行權都會交到另一個車道。而協(xié)程只有在碰到 I/O 這樣的阻塞的時候播赁,才會切換颂郎。所以協(xié)程的調度次數(shù)非常少,系統(tǒng)負擔小容为。所以一言不和就可以隨便起幾十萬上百萬個協(xié)程乓序,只要你的內存夠用。
雖然協(xié)程用起來和線程差不多坎背,但協(xié)程其實就是一個函數(shù)替劈。函數(shù)碰到 return 就會從棧里面跳出去,棧就回收掉了得滤。但如果跳出去以后陨献,不把棧回收掉呢懂更?我們就可以再跳回去眨业,回到這個函數(shù)里,繼續(xù)執(zhí)行沮协。這種從函數(shù)里跳出去的方法龄捡,可以叫 yield、switch 也可以叫 async/await慷暂。這樣函數(shù)就變成了協(xié)程聘殖。所以協(xié)程和函數(shù)一樣輕,創(chuàng)建協(xié)程和調用函數(shù)的開銷差不多行瑞。
傳統(tǒng)上奸腺,有兩種編程模式∧⒓“多線程編程”洋机,比較適合初學者入門,而“異步編程”的好處是高并發(fā) —— 這里大家注意我說的多線程洋魂,是容易入門绷旗,而不是易用。因為多線程之間的資源是共享的副砍,幾個線程一起訪問資源是很危險的衔肢。所以要寫出真的能用的多線程程序,也不容易豁翎。除非拿來當玩具角骤。所以很多 Unix 程序員就很抗拒線程。
再說到異步程序,不管是事件循環(huán)還是回調地獄邦尊,基本上寫起來是非常反人類的背桐。但自從有了協(xié)程,情況就很不一樣了蝉揍。協(xié)程寫起來就像線程一樣絲滑链峭,本質上又是單線程的程序,沒有臨界資源爭用的問題又沾。然后協(xié)程是和異步配合使用的弊仪,高并發(fā)和易開發(fā)的好處就都給他占了。所以我們肯定要多用杖刷。
現(xiàn)在進入主題:“標準線程的協(xié)程替換實現(xiàn)”励饵。就是使用 Python 將 POSIX Thread 重新實現(xiàn)為基于協(xié)程的版本。使現(xiàn)有的非異步程序滑燃,無論使用何種語言開發(fā)都能在無需修改和重新編譯的情況下直接變成協(xié)程架構下的異步程序役听。
如果你看了這句話不知道是什么意思,沒有關系表窘。用一句話來說禾嫉,就是:“在不改動程序的情況下,把任意程序變成異步程序”蚊丐。
—— 這是什么熙参,這是妥妥的黑科技!那么這是怎么做到的呢麦备?
就是把系統(tǒng)線程庫用協(xié)程重新實現(xiàn)一遍孽椰。再把阻塞式的 I/O 用異步重新實現(xiàn)一遍。然后再把原來的庫替掉凛篙。
這種直接修改程序運行時的方法叫 DLL Injection —— 動態(tài)庫注入黍匾。
Proxychains 就是一個運行時注入,很好的例子呛梆。
上面第三個鏈接是我開源的堡壘機套件锐涯,等一下演示 proxychains 的時候會用到。
Proxychains 可以讓程序用上代理填物。他通過 LD_PRELOAD 這個環(huán)境變量纹腌,在程序啟動時載入自己的 libproxychians 庫,然后就把用戶的 socket 替掉了滞磺。
這里來試一下用 wget 來取一個網(wǎng)頁:
在左邊我們看到這個 wget 是系統(tǒng)自帶的升薯,是沒有改過的。右邊是代理服務器』骼В現(xiàn)在我們執(zhí)行 wget涎劈,我們來看代理服務器的日志 —— wget 經(jīng)過了代理服務器。這樣我們接下來要替換 libpthread 系統(tǒng)線程庫的這個想法應該是可行的。
接下來我們要用到兩個技術蛛枚。一個是 Gevent谅海,是 Python 的協(xié)程庫,已經(jīng)比較完善了蹦浦。
主題的原因我們今天先不講胁赢。
另外我們今天主要是用到的是 Cython,這里我用 Cython 來寫 C 語言的庫白筹。
大家說不對啊,Cython 是給 Python 寫 C 擴展 extension 的啊 —— 其實 Cython 也是可以拿來做 C 語言開發(fā)的谅摄,Cython 其實可以把 python 程序或者 Cython 擴展語法的程序轉成 C 語言的代碼徒河。
我這里就把一個 Cython 程序轉成了 C 語言。
這段程序是字符串轉整型送漠。大家看左邊這個 Cython 的程序基本上就是 Python 的語法顽照,增加了靜態(tài)類型聲明。右邊是用 Cython 把他轉成 C 語言之后的樣子闽寡,是沒有 Python 依賴的純 C 代碼代兵。
我們看 Cython 程序既是 Python 又是 C ,正好是現(xiàn)在排名前兩名的語言爷狈。所以結合了 Python 和 C 的 Cython 應該是 VIP 中 P 了植影。
下面我們試試看來實現(xiàn)一個接口:pthread_create —— 線程創(chuàng)建接口。
整個程序特別簡單涎永,只有三個文件思币。
右邊是程序的主體,里面用 Cython 寫了一個 pthread_create 函數(shù)羡微。它只做兩件事谷饿,打印 PyCon China 2020 ,然后返回一個錯誤碼說線程沒有創(chuàng)建成功妈倔,然后退出博投。
大家注意這個關鍵字“public”,這樣這個函數(shù)就會被導出到全局的符號表盯蝴,給 C 語言毅哗,還有給所有語言的程序使用。
左下角是用 C 語言做一點的初始化工作捧挺,這個初始化函數(shù)會被寫入編譯參數(shù)黎做,會在庫加載時執(zhí)行。左上角是一個 Makefile 項目文件松忍≌舻睿總共是 42 行代碼。
編譯成功以后,我們得到了兩個庫宏所,現(xiàn)在我把他寫進 LD_PRELOAD酥艳。然后我們起一個程序來測試一下,這里我用的是 Python爬骤,其實隨便哪個程序都是可以的充石。
創(chuàng)建一個線程之后。我們看到 PyCon China 2020 出來了 —— 測試成功霞玄,說明系統(tǒng)原生的 libpthread 已經(jīng)替掉了骤铃。
下面我來改進一下 Makefile。我經(jīng)常說每個程序員一輩子只需要一個 Makefile坷剧,因為每個程序員在寫過一段時間程序之后都會搞出一個無所不能的 Makefile惰爬。畢竟 —— Emacs 還能煮咖啡,make 必須也可以惫企。
當然還有很多朋友是用 IDE 來管理項目的撕瞧,對我來說難度太高,這里就不講了狞尔。
現(xiàn)在左邊是一個完善后的 Makefile丛版,能夠自動發(fā)現(xiàn)目錄下的 Cython 文件,然后進行編譯偏序。
下面在 Makefile 的目錄下隨便寫一個 Cython 文件页畦,在右上角,大家看這里已經(jīng)完全是 Python 的語法了 —— Cython 在語法上和 Python 是 100% 兼容的研儒,所以我們完全可以用 Cython 把 Python 代碼編譯之后執(zhí)行寇漫。
右下是一些額外的編譯配置,如果需要的話殉摔。
下面進行編譯,make 會自動找到目錄下后綴是“pyx”的 Cython 文件逸月,編譯出一個 Python 模塊栓撞。我們來測試一下剛才編譯的“hello”函數(shù)。
因為在開發(fā)期我們會經(jīng)常增刪,改動文件醒第,有這樣一個自動化的 Makefile 會非常的方便。
下面我們回到前面的線程創(chuàng)建接口 pthread_create霞幅,這是把 gevent 協(xié)程加進去之后的完整版抵赢。
全部代碼我已經(jīng)開源了唧取,這里是項目地址铅鲤。目前包含了標準線程庫的協(xié)程化的完整實現(xiàn)。
這里是我使用協(xié)程重新實現(xiàn)的線程接口枫弟,實現(xiàn)這批接口大概需要 2000 行代碼 —— Python 向來惜字如金邢享,2000 行已經(jīng)算是大型項目了。
接下來說一下這個“大型項目”在實現(xiàn)上的一些細節(jié)淡诗,在開發(fā)上用到了哪些技術和關鍵技巧骇塘。
我們在開發(fā)協(xié)程版的線程庫時主要操作的是 gevent 的 greenlet 對象。
這里有兩個 greenlet 對象韩容,底層的 greenlet 對象是由 greenlet 這個庫提供的款违。Gevent 的 greenlet 對象繼承自 greenlet 庫。這里是這兩個 greenlet 對象的定義:
左邊是底層的 greenlet 庫群凶,他提供的 greenlet 對象是一個 Python 對象插爹,他把函數(shù)棧,以及指令寄存器都保存在對象結構體中请梢,用來維護協(xié)程上下文赠尾。
右邊是 gevent 庫里的實現(xiàn),可以看到 gevent 也是使用 Cython 開發(fā)的毅弧。Cython 會把右邊的代碼轉換成 C 代碼气嫁。就像這樣,這也是一個 struct够坐。
我們注意看這個 __pyx_base 成員寸宵,他不是一個指向左邊這個 PyGreenlet 結構的指針崖面。而是占據(jù)了一整個 PyGreenlet 結構體的完整空間。所以右邊這個 PyGeventGreenlet struct 和左邊的這個 PyGreenlet struct 的頭部結構是一致的邓馒,一個 PyGreenlet 指針也可以指向 PyGeventGreenlet 對象嘶朱。而這兩個 greenlet 對象的,共同頭部光酣,都是一個 PyObject 結構體的申明疏遏。
包括 PyDict 在內的 Python 的內建類型都是以這種方式從 PyObject 繼承下來的。
內存整整齊齊是 C 語言天大的好處救军,整齊的內存結構可以做非常非常多的事情财异,比如說面向對象編程。
實際上 C 語言不僅可以做面向對象編程唱遭,而且還特別的簡單好用戳寸。比如說訪問父類,這里就可以從 __pyx_base 中取出父類成員拷泽。
另外疫鹊,具有 繼承關系的對象,也就是結構體指針還可以互相做類型轉換司致。
接下來拆吆,在深入進行開發(fā)的時候我們還需要經(jīng)常訪問 greenlet 對象的屬性。但是許多屬性是只讀脂矫,或者是不可訪問的枣耀。這就需要把這些不可訪問的私有屬性重新變成可以訪問的公開屬性。
這里庭再,左邊是 gevent 的 greenlet 對象的定義捞奕,下面這些帶下劃線的屬性在編譯以后,是外部訪問不到的私有屬性拄轻。我們可以通過右邊的 Cython 代碼來重新申明一次對象颅围,并重新注入回 gevent,讓這些私有成員可以被重新訪問到恨搓。
這樣我們就改變了一個二進制模塊的行為谷浅。
只要能正確地寫出對象的內存排列,這個技巧就能用在各種場合奶卓,起到各種意想不到的效果一疯。說到這里,我不禁要感嘆一下 C夺姑、Python 還有 Cython 的博大精深墩邀。不愧為排名前二的語言和 VIP 中 P。
通過前面這些內容盏浙,我們已經(jīng)順利的獲得了 greenlet 對象的完整控制權眉睹。接下來荔茬,我們來說一下具體開發(fā)中遇到的其他難點。比如如何強制終止一個協(xié)程竹海,也就是實現(xiàn) pthread_exit 這個接口慕蔚。
一般來說在多線程程序中,可以通過 signal 來強行中止一個系統(tǒng)的原生線程斋配,但是在協(xié)程中并不很適用孔飒。因為協(xié)程程序其實是一個單線程程序。我們仍然需要通過操作函數(shù)棧來實現(xiàn)這個功能艰争。
熟悉協(xié)程的朋友應該能想到這樣一個方法坏瞄,切換到上級協(xié)程,然后把保存了當前函數(shù)棧的 greenlet 對象銷毀甩卓,然后協(xié)程上下文就不能再切換回來鸠匀,協(xié)程也就中止了。這樣只需要一行代碼就可以了逾柿。
這雖然很簡單缀棍,但事情往往不會向想象中那樣發(fā)展。因為即使協(xié)程對象 greenlet 被銷毀机错,greenlet 庫還是會把協(xié)程切換回來 —— 這和 greenlet 的內存管理有關爬范。
greenlet 通過將棧轉存到堆空間來管理棧內存,這部分內存會隨著 greenlet 對象一起被回收毡熏。但是用戶創(chuàng)建在堆中的內存不會被自動回收坦敌,為了留出回收這部分內存的機會侣诵,greenlet 里所有的 switch 棧切換痢法,最終都會被強制切換回來。
即使一個協(xié)程被設計成完全無法再切換回來杜顺,greenlet 也仍然會用一個 GreenletExit 異常來返回财搁,用來通知 Python 來清理對象引用。這顯然不符合 pthread_exit 接口不可重入的要求躬络。
下面這段代碼可以用來說明這個問題尖奔,在 pthread_exit 被調用后穷当,協(xié)程在 C 語言這個層面將會繼續(xù)執(zhí)行提茁。
確切的解決方法是這樣的。在切出函數(shù)之前馁菜,我們可以通過把 greenlet 對象的 stack_start 成員 hack 成空指針茴扁,來阻止 greenlet 拋出 GreenletExit 異常,使自己不能被重入汪疮。
這樣一旦切換完成就不會再有機會回來清理堆內存峭火,所以我們必須在切換前手工清理全部的對象引用毁习,來避免內存泄漏。包括最后一步 Greenlet_Switch() 操作在內卖丸,都不能增加 Python 對象的引用計數(shù)纺且。
接下來我們來解決另一個問題,循環(huán)依賴稍浆。
我們實現(xiàn)的 libpthread 依賴于 Python 運行時 —— libpython载碌。而 libpython 本身又要用到 libpthread。這就產生了循環(huán)依賴粹湃。
那么 libpython 依賴的是哪些接口呢恐仑?
可以發(fā)現(xiàn)都是些和線程鎖相關的接口。這樣我們就知道了为鳄,Python 使用線程庫是為了來實現(xiàn) GIL 全局解釋鎖裳仆。
緣分啊,既然又碰到了老朋友 GIL孤钦。那么在解決這個問題之前我們先來思考一個靈魂問題歧斟。事情是這樣的:
- 因為 GIL,我們知道 python 本質上是單線程程序偏形。
—— 協(xié)程說静袖,巧了巧了,我也是俊扭。 - 那么 python 為什么還要用到線程队橙?python 線程其實主要是用來解決 io 阻塞的。
—— 協(xié)程說萨惑,正是在下捐康。
下面問題來了,以 EVE online 為例庸蔼,Python 協(xié)程大規(guī)模使用解总,至少已經(jīng)有 17 年歷史。
那么姐仅,Python 偽線程的意義它又在哪里呢花枫?
Python 完全可以取消 GIL,用真協(xié)程來代替?zhèn)尉€程掏膏。編譯一個無 GIL 的 Python 就可以解決所有的問題劳翰。
否則我們就只能標記出 GIL,我這里分配給 GIL 的鎖 id 是 0馒疹,在碰到 GIL 操作線程鎖時不做任何操作佳簸。
因為協(xié)程是一個單線程程序,這個操作是安全的行冰。這樣我們的線程庫就在客觀上禁用了 GIL溺蕉。
還有一些其他的主題伶丐,比如在使用了 DLL Injiection 之后,覆蓋了原版接口疯特,那么如何再訪問原版的接口哗魂?
就像這樣,都比較簡單漓雅,這里就不再贅述了录别。
今天的代碼都可以在這里找到。
我今天的分享就到這里邻吞,謝謝大家组题。