一个C/C++协程库的思考与实现之协程栈的动态按需增长
https://github.com/DoasIsay/ToyCoroutine
如何檢測協程是否需要進行棧擴充?
我們先思考一個問題,glibc的pthread_create創建的線程是如何檢測到用戶棧的溢出而及時終止線程的?
如下代碼
g++ test.cpp -lpthread
strace ./a.out 結果如下圖
?
由strace 結果可知pthread_create先使用mmap為線程申請了的用戶空間stack,然后使用mprotect對stack的棧頂進行保護,即棧頂的4kb無讀寫權限,一旦被讀寫就說明產生了棧溢出,操作系統就會向讀寫此4kb的任務發送SIGSEGV信號,最后才是調用clone創建線程
如果越過這4kb訪問前面的內存會怎樣?如果剛好是另一個線程的stack?只要不訪問這4kb或其它線程的這4kb都不會有問題,頂多就是會破壞其它線程的棧,或自已的棧被其它線程破壞,然后進程core掉,,,
因此這篇博客《一個C/C++協程庫的思考與實現之棧溢出檢測》
https://blog.csdn.net/DoasIsay/article/details/107396105
就需要更新一下了,因為我們找到了一種更好的方法mprotect來主動檢測棧溢出
對于操作系統的任務(進程或線程)而言,任務所需的棧內存,堆內存,并不是任務啟動后或發起內存申請(brk/mmap/malloc/new)后操作系統立即為其分配物理內存,而是先為其在進程的虛擬地址空間中找到一塊空閑的空間標記其大小起止地址及訪問權限,當CPU真正訪問到任務未分配物理內存的虛擬頁內的地址時MMU會產生一個內存缺頁中斷,此時在缺頁中斷處理中操作系統才會真正的為任務分配一頁物理內存并更新進程的頁表
對于在用戶空間實現的協程而言并不能使用操作系統及CPU提供的這種按需延遲分配的機制,但操作系統向用戶提供了信號處理這種軟中斷及mprotect這種接口,但是它還是不能支撐我們在用戶空間模擬實現這種機制,如在內存越界訪問時,發出信號,由于協程棧是在堆上分配的,當棧溢出時就會發生堆內存越界訪問,此時如果對協程的棧頂即堆的起始一段內存進行mprotect,當棧溢出時就會觸發SIGSEGV信號,在信號處理函數中我們可以為當前觸發SIGSEGV信號的協程擴充棧空間
想法甚好,但是,,,
問題1
如何獲得觸發SIGSEGV信號的協程?
在多線程環境中當向一個進程發送信號后,信號被投遞到那個線程完成完全是隨機的,除了硬件錯誤與定時器觸發的信號,SIGSEGV是一個硬件錯誤?如果它不是一個硬件錯誤,有可能當前進行信號處理的線程就不是觸發SIGSEGV信號的協程所在的線程,我們獲取當前線程正在調度運行的協程是通過__thread線程局部變量current,此變量是一個指向當前線程正在執行的程協對象的指針,因此處理SIGSEGV信號的線程一定要是觸發SIGSEGV信號的協程所在的線程,才能獲取到觸發SIGSEGV信號的協程
問題2
如何區分是因協程棧溢出導致觸發SIGSEGV信號,還是野指針導致的?
如果我們能在信號處理函數中得到導致觸發SIGSEGV信號的內存地址,再與當前觸發SIGSEGV信號的協程的棧地址進行比較,如果相差不遠,那就不會是野指針導致的,但是我們無法在信號處理函數中獲取到觸發SIGSEGV信號的內存地址
經過測試,觸發SIGSEGV信號的線程會收到SIGSEGV信號,但是在信號處理函數中無法完成問題2的操作,而且就算問題2可以解決,我們在SIGSEGV的信號處理函數中為協程擴充了??臻g后,此線程也只會被內核不斷的發送SIGSEGV信號,因為信號處理函數會返回到觸發SIGSEGV信號的那條指令繼續執行,而我們無法修改這條指令所使用的地址,也就是在信號處理函數中對a變量的修改無法被fun函數再次獲取到
比如下代碼
?
因此就真的不能在用戶空間為協程實現棧的動態按需增長
一種比較樸素的實現方法,在協程的函數調用的入口加入檢測代碼就像棧溢出檢測那樣丑陋的代碼,檢測cpu當前sp寄存器的值與棧的未尾做對比,比如還剩80%的空間就進行棧的擴充,但是如下代碼會令你的檢測失效,比如棧空間是2kb,假設進入fun函數前已經使用了1kb,進入fun函數后進行檢測發現只使用了50%的??臻g,但檢測后立即在棧上申請了1kb的空間,此時代碼繼續運行就有可能產生棧溢出
void fun(){
?????? check_stack();
?????? char a[1024];
?????? xxxxxxx;
?????? xxxxxxx;
}
因此應盡量提高棧擴充的檢測條件,比如??臻g使用超過50%后就擴充,另外盡量不在棧中創建大的臨時變量
如何進行棧的擴充?
使用malloc分配新的??臻g拷貝老棧的內容到新棧,這會有個問題就是棧中的局部變量的地址還是老棧的,因此我們需要修改每一個局部變量的地址?不,不需要
如下代碼
因為在函數調用的棧幀中是通過bp基棧指針+相對地址去訪問棧上的變量的,我們僅需修改協程的函數調用鏈中每個棧幀上保存的bp,new_bp=new_stack_start+(old_bp-old_stack_start)通過老的值計算出相對偏移量然后與新棧起始地址相加計算出新值回填到棧幀上,此處就涉及到棧的回溯,其實很簡單,取出當前棧幀上保存的上一個棧幀的bp,以此類推直至協程的入口函數調用的棧幀,然后再修改協程context的bp/sp就可以了
步驟如下:
在每個函數調用中加入檢測代碼是丑陋且繁瑣的,而且我們也無法在所有的函數調用中加入檢測代碼,因為還有第三方庫的函數,因此在協程庫中用這種方式實現棧的動態擴充并不是很優雅,除非是編譯器支持,如gcc提供的分段棧,就算這樣我們也不能保證我們使用的所有依賴庫在編譯時都打開了分段棧的選項,對于協程棧的動態擴充還是別想了吧
既然操作系統提供了虛擬內存,任務申請的內存只是虛擬內存,申請了不用就不會占用物理內存,那么我們直接給足協程的棧,不就行了?只不過會導致進程占用的虛擬內存變大而已,還搞什么協程棧的動態按需增長,,,
總結
以上是生活随笔為你收集整理的一个C/C++协程库的思考与实现之协程栈的动态按需增长的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: php实现页面强制跳转,PHP实现页面跳
- 下一篇: 线段树区间gcd