本文是《go調度器源代碼情景分析》系列 第一章 預備知識的第3小節。
什么是棧
棧是一種“后進先出”的數據結構,它相當於一個容器,當需要往容器里面添加元素時只能放在最上面的一個元素之上,需要取出元素時也只能從最上面開始取,通常我們稱添加元素為入棧(push),取出元素為出棧(pop)。
不知道讀者是否有快餐店吃飯的經歷,快餐店一般都有一摞干凈的盤子讓顧客取用,這就好比一個棧,我們取盤子時通常都是拿走最上面一個(pop),當盤子被取走剩得不多時,服務員又會拿一些干凈的盤子放在原有盤子的上面(push),取盤子和放盤子這一端用棧的術語來說叫棧頂,另一端叫棧底。
下面用圖演示一下棧的push和pop。
進程在內存中的布局
嚴格說來這里講的是進程在虛擬地址空間中的布局,但這並不影響我們的討論,所以這里我們就不做區分,籠統的稱之為進程在內存中的布局。
操作系統把磁盤上的可執行文件加載到內存運行之前,會做很多工作,其中很重要的一件事情就是把可執行文件中的代碼,數據放在內存中合適的位置,並分配和初始化程序運行過程中所必須的堆棧,所有准備工作完成后操作系統才會調度程序起來運行。來看一下程序運行時在內存中的布局圖:
進程在內存中的布局主要分為4個區域:代碼區,數據區,堆和棧。在詳細討論棧之前,先來簡單介紹一下其它區域。
-
代碼區,包括能被CPU執行的機器代碼(指令)和只讀數據比如字符串常量,程序一旦加載完成代碼區的大小就不會再變化了。
-
數據區,包括程序的全局變量和靜態變量(c語言有靜態變量,而go沒有),與代碼區一樣,程序加載完畢后數據區的大小也不會發生改變。
-
堆,程序運行時動態分配的內存都位於堆中,這部分內存由內存分配器負責管理。該區域的大小會隨着程序的運行而變化,即當我們向堆請求分配內存但分配器發現堆中的內存不足時,它會向操作系統內核申請向高地址方向擴展堆的大小,而當我們釋放內存把它歸還給堆時如果內存分配器發現剩余空閑內存太多則又會向操作系統請求向低地址方向收縮堆的大小。從這個內存申請和釋放流程可以看出,我們從堆上分配的內存用完之后必須歸還給堆,否則內存分配器可能會反復向操作系統申請擴展堆的大小從而導致堆內存越用越多,最后出現內存不足,這就是所謂的內存泄漏。值的一提的是傳統的c/c++代碼就必須小心處理內存的分配和釋放,而在go語言中,有垃圾回收器幫助我們,所以程序員只管申請內存,而不用管內存的釋放,這大大降低了程序員的心智負擔,這不光是提高了程序員的生產力,更重要的是還會減少很多bug的產生。
函數調用棧
函數調用棧簡稱棧,在程序運行過程中,不管是函數的執行還是函數調用,棧都起着非常重要的作用,它主要被用來:
-
保存函數的局部變量;
-
向被調用函數傳遞參數;
-
返回函數的返回值;
-
保存函數的返回地址。返回地址是指從被調用函數返回后調用者應該繼續執行的指令地址,在匯編指令一節介紹call指令時我們將會對返回地址做更加詳細的說明。
每個函數在執行過程中都需要使用一塊棧內存用來保存上述這些值,我們稱這塊棧內存為某函數的棧幀(stack frame)。當發生函數調用時,因為調用者還沒有執行完,其棧內存中保存的數據還有用,所以被調用函數不能覆蓋調用者的棧幀,只能把被調用函數的棧幀“push”到棧上,等被調函數執行完成后再把其棧幀從棧上“pop”出去,這樣,棧的大小就會隨函數調用層級的增加而生長,隨函數的返回而縮小,也就是說函數調用層級越深,消耗的棧空間就越大。棧的生長和收縮都是自動的,由編譯器插入的代碼自動完成,因此位於棧內存中的函數局部變量所使用的內存隨函數的調用而分配,隨函數的返回而自動釋放,所以程序員不管是使用有垃圾回收還是沒有垃圾回收的高級編程語言都不需要自己釋放局部變量所使用的內存,這一點與堆上分配的內存截然不同。
另外,AMD64 Linux平台下,棧是從高地址向低地址方向生長的,為什么棧會采用這種看起來比較反常的生長方向呢,具體原因無從考究,不過根據前面那張進程的內存布局圖可以猜測,當初這么設計的計算機科學家是希望盡量利用內存地址空間,才采用了堆和棧相向生長的方式,因為程序運行之前無法確定堆和棧誰會消耗更多的內存,如果棧也跟堆一樣向高地址方向生長的話,棧底的位置不好確定,離堆太近則堆內存可能不夠用,離堆太遠棧又可能不夠用,於是乎就采用了現在這種相向生長的方式。
AMD64 CPU提供了2個與棧相關的寄存器:
-
rsp寄存器,始終指向函數調用棧棧頂
-
rbp寄存器,一般用來指向函數棧幀的起始位置
下面用兩個圖例來說明一下函數調用棧以及rsp/rbp與棧之間的關系。
假設現在有如下函數調用鏈且正在執行函數C():
A()->B()->C()
則函數ABC的棧幀以及rsp/rbp的狀態大致如下圖所示(注意,棧從高地址向低地址方向生長):
對於上圖,有幾點需要說明一下:
-
調用函數時,參數和返回值都是存放在調用者的棧幀之中,而不是在被調函數之中;
-
目前正在執行C函數,且函數調用鏈為A()->B()->C(),所以以棧幀為單位來看的話,C函數的棧幀目前位於棧頂;
-
CPU硬件寄存器rsp指向整個棧的棧頂,當然它也指向C函數的棧幀的棧頂,而rbp寄存器指向的是C函數棧幀的起始位置;
-
雖然圖中ABC三個函數的棧幀看起來都差不多大,但事實上在真實的程序中,每個函數的棧幀大小可能都不同,因為不同的函數局部變量的個數以及所占內存的大小都不盡相同;
-
有些編譯器比如gcc會把參數和返回值放在寄存器中而不是棧中,go語言中函數的參數和返回值都是放在棧上的;
隨着程序的運行,如果C、B兩個函數都執行完成並返回到了A函數繼續執行,則棧狀態如下圖:
因為C、B兩個函數都已經執行完成並返回到了A函數之中,所以C、B兩個函數的棧幀就已經被POP出棧了,也就是說它們所消耗的棧內存被自動回收了。因為現在正在執行A函數,所以寄存器rbp和rsp指向的是A函數的棧中的相應位置。如果A函數又繼續調用了D函數的話,則棧又變成下面這個樣子:
可以看到,現在D函數的棧幀其實使用的是之前調用B、C兩個函數所使用的棧內存,這沒有問題,因為B和C函數已經執行完了,現在D函數重用了這塊內存,這也是為什么在C語言中絕對不要返回函數局部變量的地址,因為同一個地址的棧內存會被重用,這就會造成意外的bug,而go語言中沒有這個限制,因為go語言的編譯器比較智能,當它發現程序返回了某個局部變量的地址,編譯器會把這個變量放到堆上去,而不會放在棧上。同樣,這里我們還是需要注意rbp和rsp這兩個寄存器現在指向了D函數的棧幀。從上面的分析我們可以看出,寄存器rbp和rsp始終指向正在執行的函數的棧幀。
最后,我們再來看一個遞歸函數的例子,假設有如下go語言代碼片段:
funcf(nint) { ifn<=0{ //遞歸結束條件 n <= 0 return } ...... f(n-1) //遞歸調用f函數自己 ...... }
函數f是一個遞歸函數,f函數會一直遞歸的調用自己直到參數 n 小於等於0為止,如果我們在其它某個函數里調用了f(10),而且現在正在執行f(8)的話,則其棧狀態如下圖所示:
從上圖可以看出,即使是同一個函數,每次調用都會產生一個不同的棧幀,因此對於遞歸函數,每遞歸一次都會消耗一定的棧內存,如果遞歸層數太多就有導致棧溢出的風險,這也是為什么我們在實際的開發過程中應該盡量避免使用遞歸函數的原因之一,另外一個原因是遞歸函數執行效率比較低,因為它要反復調用函數,而調用函數有較大的性能開銷。
本節我們簡要的介紹了棧的基本概念及它在程序運行過程中的重要作用,但遺留了一些細節問題,比如每個函數的棧幀是怎么分配的,局部變量和參數又是如何保存在棧中的,又是誰把返回地址放在了棧上等等,這些內容我們會在函數調用過程一節加以詳細介紹。這里為什么不把細節跟概念放在一起討論呢,主要是因為我們首先要對棧有個大致的了解,才能更好的理解下一節即將講述的有關匯編語言相關的知識,而沒有匯編語言作為基礎,我們又不能很好的理解棧的這些細節問題,所以我們決定把基本概念和用途與細節分開介紹。