標籤:

介紹一下call_in_stack(1)

上文我們已經通過介紹c/c++傳統的程序控制流程 從順序執行,條件執行,循環執行說起(0) - 知乎專欄來說明協程的必要性,包括順序執行,條件執行,循環執行,函數調用,函數返回,異常拋出,並且"初始化"了一下有棧協程的概念。不過離真正實現一個理想的,高效的有棧協程庫(我要造輪子,一定是要比市面上的boost::context, libco這些要強很多的性能和易用性才有興趣去做)還遠的很。

本文就來」花開兩朵,同表一支「,介紹另外一個平行的」call in stack「的概念,讓介紹」上下文「和」堆棧「之間的關係時,不那麼突兀。

---------------------------------------------------------------------------------------------------------------------

我們已經知道了,A函數調用B函數,對於堆棧內存的劃分使用準則,默認就是用一個堆棧指針值把一塊連續的內存分為2半:堆棧的一邊是屬於A函數和A函數的調用者的,堆棧的另一邊是屬於B函數和B函數調用的函數的。分界線附近則可能存放一些A函數和B函數交互用的數據,如A函數傳給B函數的參數,B函數返回A函數的跳轉地址等。B函數不能去直接去訪問分界線另一邊的內存(除非A把屬於自己的內存地址用參數的形式傳給了B);A函數也不能訪問分界線另一邊的內存。B函數如果去調用C函數,那麼則需要把堆棧指針(也就是分界線)朝同一個方向繼續移動,划出一條新的分界線,新老兩條分界線之間的內存就算是屬於B的。

上面這套規則,讓每個函數都先天就有一個半開內存區間可使用(堆棧指針本身像函數調用樹的一顆深度指示符),無需額外調用介面申請內存。不過內存的使用不能晚於函數返回之後(B函數返回之後,A如果對分界線屬於B那邊的內存進行讀操作其實是未定義行為);此外如果使用量不是常量,太大了也會很危險(C++17又把局部變數變長數組給砍了)。

那如果我們不使用一個編譯器不斷移動的分界線來標定可使用內存區間,而是A函數手工指定一個內存半開區間給B使用,可不可以呢?

回答:完全可以。

好處是:

1.A和B所使用的堆棧內存不再需要是連續空間了。這對於我們未來進行上下文切換之類的,非常有用:A和B不僅從代碼結構上解耦了(調用/返回關係暫時還沒有解耦:未來協程的切換里,會讓A調用B,但是可能是某個D函數中返回到A。這讓調用返回關係也解耦掉),還從內存空間上解耦了。A函數和B函數將可以模擬的扮演獨立的生產者,產出的數據就放自己那段堆棧上。

2.A可以根據傳給B的參數,大致知道B需要消耗多少堆棧內存,所以可以粗略的按需分配堆棧內存:例如快速排序函數,最壞情況消耗堆棧是正比於數組長度的。

和GCC的split stack相比,勝在開銷可控,內存可自主管理,不會依賴頁防寫中斷等優點,就不多說了。

實現上的困難:

1.雖然我們可以使用切換堆棧指針的方式,來讓函數B使用我們另外分配的內存p。然而我們沒法阻止編譯器傳遞參數時,無條件的往當前分界線附近拷貝參數(如果在傳遞參數之前就修改堆棧指針,那麼A可能無法正確的定址自己的拷貝源)。。所以,考慮到很多時候參數又混合使用寄存器來進行傳遞的,我們讓A函數轉而調用B的代理函數B+,B+會再

1.1保存舊堆棧指針,

1.2把參數淺拷貝到我們內存p的合適位置(使用寄存器傳遞的參數我們就不用拷貝啦),

1.3再切換堆棧指針,

1.4調用B,

1.5 B返回之後再切換堆棧指針回來,

1.6 復原舊堆棧指針,正常返回。

2. 注意1.2中有一個粗體的「拷貝」:執行深拷貝不是不可以,但是會讓B+的實現上會遇到很多障礙(x64 systemV ABI上需要一個特殊的is_pod來決定拷貝到B堆棧上的是指針還是值,C++03無法實現這種is_pod, C++11的is_pod又和x64 ABI要求的is_pod不一致....)。最終我選擇了禁止使用class/union類型做參數和返回值。畢竟,我覺得程序員這點素質應該是有的。。

3. windows x64 visual studio上不允許內嵌彙編,蛋疼。。那我暫時先不支持windows了。

4. 非靜態成員或成員函數指針為參數的編譯器行為可移植性有點差,也缺乏充分的測試,暫時標記為不支持。

------------------------------------------------------

綜上,就得到了這樣的設計結果(請不吝觀賞 yuanzhubi/call_in_stack)

我們把:有符號或者無符號的整數類型char, wchar_t, short, int, long, long long;枚舉類型; 浮點數類型float, double, long double; 任何指針(除了非靜態類成員函數或者非靜態類成員變數,的指針和引用,成員指針這些實現太沒有可移植性了,)或者引用類型或者右值引用(可以通過定義ENABLE_RIGHT_VALUE_REFERENCE宏來啟用)類型;任何前述類型的const或者volatile修飾類型, 統稱為基本類型。

call_in_stack 可以在一個新的棧調用一個函數f, 只要能滿足以下條件:

  1. f 被聲明為只擁有基本類型參數,不管他擁有定長或者變長參數列表。如果f擁有變長參數列表,那麼在列表前面至少有一個「具名」參數(如同printf的第一個參數)!

  2. f 被調用時只有不超過10個參數,不管他擁有定長或者變長參數列表。

  3. f 的返回類型需要是基本類型或者void。

------------------------------------------------------

char* new_buf = new char[12*1024];

int x = 0; char buf[]="My god!";

call_in_stack(new_buf, 12*1024, &printf, "%d%d%p%f%d%sn", 1, x, buf, 3.0, ++x, "Hello world!");

使用起來真的也很簡單。而且這個內存是new來的還是怎麼來的,完全沒有任何限制。什麼堆棧對齊,call_in_stack這個函數全都幫你搞定了:printf 就跑到new_buf上面去執行了。

介面也很簡單,函數和參數列表前放上內存指針和大小就行了。

//如果你編譯不通過,歡迎私信聯繫我,我會幫你解決完為止!

//20170513補充:根據知友owen提供的案例,發現使用GCC 4.8.5 來運行call_in_stack存在編譯器怠工的情況,測試用例test.cpp 中的main函數編譯不到1/10就標定main函數編譯完成,生成的彙編代碼只有main函數不到1/10的流程。。。也不排除可能是編譯環境內存不足或者/tmp分區大小不足造成的g++怠工。 如果你發現你的編譯結果運行時發生core dump 或者提醒invalid instruction, 可以gdb a.out 然後disassemble main, 如果發現反彙編結果連1頁都沒放滿(正常的屏幕大小應該會有5頁左右),那一定就是這個問題。此問題已經設法繞過,在最新的版本中進行了修復,所有fork的同學請積極merge最新版。

-----------------------------------------------------------

call_in_stack簡單介紹完了,但是他並不是一個有棧協程庫中的必要組件。

不過請允許我提前劇透點下一回合的信息:如果在使用有棧協程庫編程時使用call_in_stack, 你將會收穫

1、低內存佔用(可以使用小於4K的堆棧內存(絕不增長,絕不分段,絕不共享,絕不拷貝)創建協程),

2、嶄新高效而非侵入式的協程編程模式(協程的主線函數會像個系統中斷常式去執行。沒錯,linux中斷常式就一般只允許4K或者8K或者16K的棧(查查IRQ_STACK_SIZE),還有一個call_on_stack(x86)或者call_with_stack(arm)函數給你切換堆棧執行處理常式:call_in_stack這個函數名字就是偷師的linux內核呢)。

3、你可以把call_in_stack配合使用於已有的市面上的大部分有棧協程庫(這也是call_in_stack最早的設計目標,也是在設計過程中才逐漸發現已有輪子設計的弊病)還同時享有1和2的好處(使用call_in_stack後你會發現協程消耗的堆棧其實可以非常少:因為市面上使用協程更多是為了讓上下文切換和控制流更簡單,需要持久化存儲在堆棧上的狀態其實非常少,大部分開銷都可以用call_in_stack避開:讓所有不會發生協程切換的函數都call_in_stack_safe到同一塊內存上去執行吧(call_in_stack_safe會檢查是否發生嵌套使用同一塊內存))!!

4、下一回合我會快速的完成一個有棧協程原型,並開源之。

(真是抱歉,一夜間這麼多人來關注這個專欄,我可能要修改計劃,讓整個系列寫的更大眾化一些)


推薦閱讀:

協程初步筆記
使用coroutine實現狀態機(2)
Kotlin雜談(五) - Coroutines(三): 基本語法
Unity 協程運行時的監控和優化

TAG:C | 协程 | 堆栈 |