】關(guān)于線程堆棧的那些事——棧溢出、堆內(nèi)存分配算法)
??在這個懷疑的年代我們依然需要信仰。個人主頁 YYYing.??C編程系列專欄C編程系列系列上期內(nèi)容【C進階系列 (二)】關(guān)于線程堆棧的那些事——函數(shù)調(diào)用過程系列下期內(nèi)容暫無目錄棧溢出一、 從“物理邊界”出發(fā)棧溢出的本質(zhì)是什么1. 宏觀目標為什么必須有個“天花板”2. 微觀定義兩種截然不同的“踩線”方式二、 白板式推演 —— 親手導演一場“延遲爆炸”第一幕編譯器的“傲慢”靜態(tài)分配第二幕操作系統(tǒng)的“困惑”如果沒立刻崩第三幕魔鬼的“延遲引爆”三、 發(fā)散聯(lián)想 —— 站在“編譯器保鏢”和“內(nèi)核警察”視角聯(lián)想 1編譯器保鏢 —— Stack Canary棧哨兵聯(lián)想 2內(nèi)核警察 —— 守護頁為什么失效了非常重要的細節(jié)聯(lián)想 3如何主動“續(xù)命” —— 使用 sigaltstack備用電網(wǎng)四、 可視化 —— 棧溢出的完整觸發(fā)鏈五、 面試怎么答堆內(nèi)存分配算法一、 從“城市規(guī)劃局”出發(fā)棧內(nèi)存分配的宏觀目標1. 宏觀目標用最少的物理內(nèi)存撐起最大的虛擬幻想2. 微觀定義誰來執(zhí)行這個“畫餅”操作二、 白板式推演 —— 從 pthread_create 到物理內(nèi)存的“瞬時誕生”第一幕虛擬地址的“圈地運動”創(chuàng)建線程時第二幕物理內(nèi)存的“缺頁填充”第一次壓棧時第三幕分配算法的“增長限制”什么時候不讓蓋房了三、 發(fā)散聯(lián)想 —— 站在“內(nèi)核開發(fā)者”和“性能優(yōu)化”視角聯(lián)想 1主線程棧 vs 子線程棧分配算法完全不同聯(lián)想 2glibc 的“棧緩存”Stack Cache—— 線程池加速的秘密聯(lián)想 3透明大頁Transparent Hugepage的副作用四、 可視化 —— 虛擬地址與物理內(nèi)存的“延遲映射”時間線五、 面試怎么答結(jié)語---??封面自取??---棧溢出一、 從“物理邊界”出發(fā)棧溢出的本質(zhì)是什么1. 宏觀目標為什么必須有個“天花板”線程棧不是無限向下生長的。操作系統(tǒng)在分配那8MB虛擬內(nèi)存時只給了你8MB的合法通行證。棧溢出的本質(zhì)極其簡單粗暴RSP棧頂指針帶著CPU走出了這8MB的“合法領(lǐng)地”闖入了隔壁的禁飛區(qū)未映射內(nèi)存或守護頁。我們的發(fā)散聯(lián)想懸崖與蹦極線程棧是一個向下延伸的懸崖。8MB是懸崖的高度RSP是蹦極者的位置。正常函數(shù)調(diào)用蹦極者往下跳壓棧函數(shù)返回彈回去彈棧。守護頁Guard Page是懸崖底部4KB厚的“電網(wǎng)”寫著“高壓危險禁止觸碰”。棧溢出蹦極者RSP跳得太深腳訪問地址碰到了電網(wǎng)。內(nèi)核保安Page Fault Handler一看直接拉響警報SIGSEGV程序當場斃命。2. 微觀定義兩種截然不同的“踩線”方式實際上棧溢出在硬件層面觸發(fā)有兩種完全不同的劇本觸發(fā)方式硬件動作結(jié)果典型場景精確踩雷RSP 指向或訪問了守護頁的地址。MMU直接報缺頁異常內(nèi)核判定非法訪問。立即崩潰gdb能精準停在觸發(fā)溢出的那行代碼附近。無限遞歸每次都壓一點點棧慢慢磨到守護頁。越界空降更常見定義超大局部數(shù)組如char buf[16384]數(shù)組遠遠大于8MB剩余空間直接跳過守護頁踩到棧下方的堆內(nèi)存或未映射區(qū)域。災難性延遲崩潰??赡軟]立刻崩但寫壞了別的數(shù)據(jù)等程序跑飛后才崩潰。在深層次函數(shù)里定義了幾KB的棧上大緩沖區(qū)。二、 白板式推演 —— 親手導演一場“延遲爆炸”我們寫一段看起來人畜無害的代碼然后推演它如何毀掉整個世界void evil_func() { char big_buf[4096 * 3]; // 12KB 棧上數(shù)組 memset(big_buf, A, sizeof(big_buf)); // 瘋狂寫A }假設(shè)此時線程棧只剩 8KB 空間了RSP 離守護頁只有 8KB。第一幕編譯器的“傲慢”靜態(tài)分配編譯器在編譯時根本不管棧還剩多少直接在evil_func的入口生成sub rsp, 12288 ; 一次性把RSP往下挪12KB微操瞬間RSP 從“還剩8KB的位置”一下子跨過守護頁4KB直接插進了棧下方屬于堆的未知領(lǐng)地甚至是未映射內(nèi)存。第二幕操作系統(tǒng)的“困惑”如果沒立刻崩如果RSP指向的那個地址恰好已經(jīng)被映射了比如堆內(nèi)存那么這次sub rsp不會觸發(fā)任何異常程序繼續(xù)運行。接下來memset瘋狂往big_buf里寫‘A’。這些‘A’實際上寫入了棧上合法的剩余空間少量。守護頁4KB這里會立刻崩潰——但如果跨過去了呢。堆內(nèi)存或其他線程的棧如果RSP跳太遠完全越過了守護頁寫在了別人的地盤上。第三幕魔鬼的“延遲引爆”假設(shè)memset沒有立刻崩潰它成功地把堆里的某個重要數(shù)據(jù)結(jié)構(gòu)比如虛函數(shù)表指針給涂成了‘A’。真正的崩潰發(fā)生在 10 毫秒后程序調(diào)用某個對象的虛函數(shù)CPU去查虛表發(fā)現(xiàn)虛表地址變成了0x41414141全是A嘗試跳轉(zhuǎn)Segmentation Fault在完全無關(guān)的代碼行上引爆我的思考調(diào)試地獄的本質(zhì)這就是棧溢出被稱為“調(diào)試噩夢”的原因。崩潰地點 ≠ 犯錯地點。你看著gdb停在第100行心里罵娘但真正的兇手在第10行的那個超大局部數(shù)組上。守護頁只能防“循序漸進的溢”防不住“一步登天的越界大數(shù)組”。三、 發(fā)散聯(lián)想 —— 站在“編譯器保鏢”和“內(nèi)核警察”視角聯(lián)想 1編譯器保鏢 —— Stack Canary棧哨兵既然大數(shù)組越界喜歡篡改返回地址壓棧順序中返回地址在局部變量上方編譯器加入了“棧保護”機制-fstack-protector-strong。微觀布局在RBP和局部變量之間插旗運行邏輯就像電影里珠寶底座的細線函數(shù)入口從線程本地存儲取一個隨機數(shù)塞進C位置。函數(shù)體往big_buf里寫數(shù)據(jù)如果越界它會從低地址往高地址覆蓋第一個遭殃的就是 Canary。函數(shù)出口ret之前編譯器插入檢查代碼對比 Canary 是否被篡改。如果被改不執(zhí)行ret直接跳轉(zhuǎn)到__stack_chk_fail函數(shù)主動讓程序崩潰并打印“Stack smashing detected”。工程價值它把“延遲爆炸”變成了“當場自爆”讓錯誤地點越界寫和崩潰地點__stack_chk_fail通過堆棧信息關(guān)聯(lián)起來。這是線上C服務必須開啟的保命選項。聯(lián)想 2內(nèi)核警察 —— 守護頁為什么失效了非常重要的細節(jié)剛才sub rsp, 12288跨過守護頁時為什么沒崩因為sub rsp只是改變寄存器值并沒有真正去讀/寫那4KB守護頁的地址只有當memset執(zhí)行時CPU去寫那些地址才觸發(fā)頁錯誤。如果memset從高地址往低地址寫守護頁恰好被繞過12KB跨過4KB那就完美躲過了安檢。所以守護頁只能防“訪問”不能防“指針跳躍”。聯(lián)想 3如何主動“續(xù)命” —— 使用sigaltstack備用電網(wǎng)高級C后端會注冊一個備用信號棧Alternate Signal Stack。當主線程棧溢出導致 SIGSEGV 時內(nèi)核會檢查如果崩潰地址確實在棧上內(nèi)核會臨時切換到備用棧再執(zhí)行用戶注冊的信號處理函數(shù)。這意味著你可以在程序崩潰的最后一刻在備用棧上打印出完整的調(diào)用棧backtrace然后把錯誤日志寫入文件再退出。這是線上“遺言”機制的標準實現(xiàn)。四、 可視化 —— 棧溢出的完整觸發(fā)鏈五、 面試怎么答面試官心理分析你答“遞歸太深”只能得30分。高分答案必須涵蓋“守護頁的防護盲區(qū)”、“延遲崩潰的成因”和“Canary機制的底層原理”。給你的回答面試官您好關(guān)于棧溢出我把它分為“觸發(fā)機制”、“延遲效應”和“工程防御”三個層次第一觸發(fā)機制硬件視角線程棧底部有一個4KB的守護頁Guard Page被內(nèi)核標記為無權(quán)限。當RSP指向該區(qū)域或CPU訪問該區(qū)域時MMU觸發(fā)缺頁異常內(nèi)核發(fā)送SIGSEGV終止進程。這是最經(jīng)典的“溫和遞歸”導致棧溢出的路徑。第二延遲崩潰反常識陷阱但真實工程中更可怕的是大局部數(shù)組越界。編譯器在函數(shù)入口通過sub rsp, N一次性挪動RSP如果N大于剩余??臻g比如還剩8KB卻分配12KBRSP會直接跨過守護頁指向下方的堆或未映射內(nèi)存。如果恰好指向已映射的堆內(nèi)存程序不會立刻崩潰memset會悄悄把堆里的返回地址或虛表指針涂改掉。真正的崩潰發(fā)生在函數(shù)返回或調(diào)用虛函數(shù)時錯誤地點和崩潰地點完全脫節(jié)——這是棧溢出難以調(diào)試的根本原因。第三工程防御體系C實戰(zhàn)為了對抗這種延遲破壞我嚴格執(zhí)行三層防線編譯期Canary必須開啟-fstack-protector-strong。編譯器在返回地址和局部變量之間插入一個隨機哨兵Canary函數(shù)返回前校驗。一旦檢測到被篡改立刻調(diào)用__stack_chk_fail主動崩潰把“延遲爆炸”強制轉(zhuǎn)為“當場自爆”定位錯誤源頭。代碼規(guī)范團隊規(guī)定超過 1KB 的局部緩沖區(qū)必須用堆分配std::vector或unique_ptr從根源避免RSP大幅跳躍。監(jiān)控兜底線上服務注冊sigaltstack備用信號棧即使主棧崩了也能在備用棧上安全執(zhí)行信號處理器打印完整 backtrace 后優(yōu)雅退出留下遺言日志。總結(jié)一句話棧溢出不僅是內(nèi)存問題更是時序問題延遲爆炸。堆內(nèi)存分配算法一、 從“城市規(guī)劃局”出發(fā)棧內(nèi)存分配的宏觀目標1. 宏觀目標用最少的物理內(nèi)存撐起最大的虛擬幻想寫C后端你開1000個線程每個線程默認8MB棧。按常理需要8GB物理內(nèi)存服務器早爆了。但現(xiàn)實是你的服務器只有16GB內(nèi)存開了2000個線程居然還活著核心本質(zhì)就一句話線程棧分配是“虛擬內(nèi)存Virtual Memory預留”“物理內(nèi)存Physical Memory按需分配Demand Paging”。操作系統(tǒng)內(nèi)核在創(chuàng)建線程時只是在虛擬地址空間里畫了8MB的圈預留地址但一分錢物理內(nèi)存都不給。只有當你真正往棧里寫數(shù)據(jù)壓棧時內(nèi)核才“摳摳搜搜”地按4KB一頁給你補上物理內(nèi)存。我們的發(fā)散聯(lián)想開發(fā)商與住戶虛擬內(nèi)存開發(fā)商在沙盤上給你畫了一套200平的大房子8MB棧房產(chǎn)證上寫得很清楚但房子還沒蓋。物理內(nèi)存真正的鋼筋水泥。開發(fā)商內(nèi)核的策略是“按需蓋房”——你住進客廳壓棧他只給你蓋客廳4KB你走到臥室繼續(xù)壓棧他再蓋臥室4KB。目的如果這套房子你只用來放個鞋柜遞歸很淺開發(fā)商一輩子就只給你蓋了那幾平米幾十KB物理內(nèi)存省下了海量建材。2. 微觀定義誰來執(zhí)行這個“畫餅”操作這個“分配算法”不歸new或malloc管它由內(nèi)核內(nèi)存管理器buddy system伙伴系統(tǒng)glibc的NPTLNative POSIX Thread Library協(xié)同完成。二、 白板式推演 —— 從pthread_create到物理內(nèi)存的“瞬時誕生”第一幕虛擬地址的“圈地運動”創(chuàng)建線程時當你調(diào)用std::thread或pthread_createglibc在用戶態(tài)通過系統(tǒng)調(diào)用mmap向內(nèi)核申請一塊匿名內(nèi)存// 偽代碼glibc 內(nèi)部實現(xiàn) mmap(NULL, 8MB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);MAP_ANONYMOUS表示這不是文件映射是純內(nèi)存。內(nèi)核動作只修改進程的頁表Page Table在虛擬地址空間中把0x7f..000到0x7f..800這段地址標記為“保留Reserved”并記錄下這塊區(qū)域會用于棧。此時此刻的物理內(nèi)存0 字節(jié)物理內(nèi)存條上沒有任何變化。順便提一嘴守護頁Guard Page的分配緊接著glibc調(diào)用mprotect把棧底低地址端的 4KB 標記為PROT_NONE。這同樣只是改頁表屬性不占用物理內(nèi)存。第二幕物理內(nèi)存的“缺頁填充”第一次壓棧時程序開始運行調(diào)用了第一個函數(shù)。CPU 執(zhí)行push rbp。CPU嘗試寫地址RSP 指向了棧頂比如0x7f..7FF。MMU內(nèi)存管理單元查頁表發(fā)現(xiàn)這個虛擬地址雖然有記錄保留但沒有對應物理內(nèi)存頁框Page Frame而且頁表里還缺個標志。觸發(fā)缺頁中斷Page FaultCPU 暫停陷入內(nèi)核態(tài)調(diào)用內(nèi)核的中斷處理函數(shù)do_anonymous_page。內(nèi)核分配物理頁內(nèi)核從物理內(nèi)存的伙伴系統(tǒng)中取出一塊空閑的 4KB 物理內(nèi)存頁把地址填入頁表建立虛擬地址到物理地址的映射。CPU 重試指令剛才失敗的push指令現(xiàn)在成功寫入物理內(nèi)存。注意這個過程對程序員完全透明但你如果在內(nèi)核態(tài)用perf監(jiān)控會發(fā)現(xiàn)大量的page-fault事件。第三幕分配算法的“增長限制”什么時候不讓蓋房了內(nèi)核每次響應缺頁中斷時都會偷偷做一個檢查“當前 RSP 離棧底的偏移量有沒有超過ulimit -s設(shè)定的 8MB”如果沒超過愉快分配物理頁。如果超過了或者踩到了守護頁內(nèi)核直接給進程發(fā)送SIGSEGV拒絕再分配物理頁。思考核心本質(zhì)所謂的“棧內(nèi)存分配算法”本質(zhì)上就是內(nèi)核的缺頁中斷處理流程虛擬地址越界檢查。它不像堆內(nèi)存需要鏈表管理、合并碎片棧的內(nèi)存管理極度簡單粗暴往低地址生長缺一頁補一頁補到邊界就殺人崩潰。三、 發(fā)散聯(lián)想 —— 站在“內(nèi)核開發(fā)者”和“性能優(yōu)化”視角聯(lián)想 1主線程棧 vs 子線程棧分配算法完全不同這是一個極易被忽略的面試陷阱子線程棧我們上面講的由mmap在堆和棧之間的空隙分配靈活可回收。主線程啟動main的那個棧由操作系統(tǒng)加載器exec在進程啟動時固定分配。它不能用mmap擴容地址在編譯鏈接時就確定了。如果主線程棧溢出連sigaltstack都很難救因為它是進程的“根”。聯(lián)想 2glibc 的“棧緩存”Stack Cache—— 線程池加速的秘密頻繁創(chuàng)建銷毀線程std::thread代價很高不僅因為創(chuàng)建上下文還因為mmap/munmap涉及內(nèi)核態(tài)切換。glibc 的優(yōu)化NPTL 實現(xiàn)當一個線程退出pthread_exitglibc不會立刻munmap歸還那 8MB 虛擬地址而是把它放入一個棧緩存Stack Cache鏈表里。下次創(chuàng)建新線程時直接從緩存里拿現(xiàn)成的虛擬地址復用省掉了mmap系統(tǒng)調(diào)用。只有當緩存里的棧數(shù)量超過閾值比如 40 個或者內(nèi)存緊張時glibc才真正釋放給內(nèi)核。工程意義這就是為什么你用線程池std::thread_pool或boost::asio性能遠優(yōu)于頻繁new thread——底層連棧的虛擬地址分配都省了聯(lián)想 3透明大頁Transparent Hugepage的副作用現(xiàn)代 Linux 支持2MB 的 Hugepage大頁。如果內(nèi)核把棧的缺頁分配從 4KB 升級為 2MB好處是 TLB頁表緩存命中率提高壞處是物理內(nèi)存浪費嚴重。你只想在棧里存 4KB 數(shù)據(jù)內(nèi)核卻強行塞了 2MB 物理內(nèi)存。這在容器環(huán)境下容易導致OOM內(nèi)存耗盡。所以高性能后端有時會顯式關(guān)閉棧區(qū)的透明大頁echo never /sys/kernel/mm/transparent_hugepage/enabled。四、 可視化 —— 虛擬地址與物理內(nèi)存的“延遲映射”時間線我用一張時序圖完整展示從創(chuàng)建線程到崩潰虛擬和物理內(nèi)存的變化關(guān)鍵在整個生命周期中虛擬內(nèi)存始終占著 8MB 的“位子”虛擬地址空間被消耗但物理內(nèi)存只按實際使用量線性增長。五、 面試怎么答面試官心理分析問“棧內(nèi)存分配算法”低級答案是“用malloc分配的”大錯特錯。高級回答必須精準地區(qū)分“虛擬地址保留”和“物理內(nèi)存按需分頁”并能主動點出“缺頁中斷”的性能開銷和“棧緩存”的優(yōu)化。給你的回答面試官您好關(guān)于線程棧的內(nèi)存分配它與堆內(nèi)存malloc有本質(zhì)不同它采用的是操作系統(tǒng)內(nèi)核級的“虛擬預留 物理按需”雙軌策略。我從三個層次回答第一分配時機與算法宏觀策略線程創(chuàng)建時pthread_createglibc通過mmap(MAP_ANONYMOUS)系統(tǒng)調(diào)用僅在進程的虛擬地址空間中預留 8MB 連續(xù)地址并調(diào)用mprotect在低地址端設(shè)置 4KB 守護頁。此時物理內(nèi)存分配為 0。只有當線程開始執(zhí)行CPU 壓棧觸發(fā)缺頁中斷時內(nèi)核的內(nèi)存管理子系統(tǒng)伙伴系統(tǒng)才從物理內(nèi)存取出一頁通常 4KB建立映射。整個分配過程是惰性Lazy的直到觸及ulimit -s邊界或守護頁才停止分配并崩潰。第二性能開銷底層代價這種按需分配有個隱性成本——缺頁中斷Page Fault。每次訪問新的棧頁CPU 都要陷入內(nèi)核態(tài)這會增加延遲。因此在延遲敏感的場景如高頻交易下我可能會使用mlockall或預先觸碰棧頁Touch Pages來強制提前分配把缺頁開銷從熱路徑前置到啟動階段。第三工程中的回收與復用關(guān)鍵優(yōu)化線程退出時glibc的 NPTL 實現(xiàn)并不會立刻munmap歸還虛擬地址而是將其放入棧緩存Stack Cache。新線程創(chuàng)建時優(yōu)先從緩存獲取已分配好的虛擬地址這避免了反復陷入內(nèi)核態(tài)進行mmap/munmap。這也正是為什么在實際開發(fā)中使用線程池比頻繁new std::thread高效得多——后者不僅在調(diào)度上有開銷在內(nèi)存分配上同樣有系統(tǒng)調(diào)用壓力??偨Y(jié)一句話線程棧分配的本質(zhì)是操作系統(tǒng)頁表管理的藝術(shù)——用虛擬空間換物理內(nèi)存的彈性用缺頁中斷換啟動時的低占用。理解這層就能在遇到性能瓶頸時精準判斷是缺頁抖動還是棧緩存未命中。”結(jié)語從“二進制對齊”到“匯編指令”從“延遲爆炸”到“虛擬內(nèi)存畫餅”我們用了三站帶你學習了C線程堆棧底層的重中之重?,F(xiàn)在你腦海中的程序運行不再是“黑盒”而是一幅由CPU、編譯器、操作系統(tǒng)三方精密協(xié)作的立體藍圖。我是YYYing后面還有更精彩的內(nèi)容希望各位能多多關(guān)注支持一下主包。無限進步我們下次再見---??封面自取??---