印象系列-理解程式的存在

Zerui發表於2018-07-16

前言

大多數開發者並不對程式有過多細緻瞭解,至少在很多層面上,普通開發者沒必要理會這些細節,作業系統存在的意義就是消除開發者在這方面遭遇的恐慌,使得能夠快速編寫出可以執行的程式碼。那麼理解程式的意義正是希望在作業系統層面有新的認識,在處理多程式和併發時能從根本上判斷問題。

本文將從最簡單的作業系統模型去分析程式的存在,在作業系統中,任何程式碼的執行都必須依附於某個程式,可以說,程式就是程式碼在作業系統執行的基本單元。因此,下面將作業系統各個核心模組做個描繪,然後瞭解程式在整個系統中存在的形式。

程式碼的最終形式

無論是何種語言,高階的如php、java、python還是低階的c最終都會轉化成統一規範的二進位制流,在作業系統的控制下二進位制流流通於各個硬體,經由CPU實現計算處理。

這裡,我們以c語言為例,講述程式碼轉化的過程。當你建立如下程式碼的c檔案code.c:

int sum=0;
int sum(int x,inty){
    int t=x+y;
    sum+=t;
    return t;
}複製程式碼

作業系統接下來通過編譯器轉化成如下形式的彙編程式碼code.s:

pushl %ebp   
movl %esp,%ebp
movl 12(%ebp),%eax
addl 8(%ebp),%eax
addl %eax,sum
popl %ebp
ret複製程式碼

彙編程式碼無限接近程式碼檔案在作業系統最終存在的形式,如果不理解上面每一行的意義並不要緊,我們現在只需要知道每一行代表著一個指令,而指令是處理器(CPU)執行的基本單位。儘管彙編程式碼已經無限接近機器硬體了,但計算機硬體只能識別二進位制,所以彙編程式碼還會經過一次轉換,通過彙編器將彙編程式碼轉化成二進位制形式(下文左邊):

55                               pushl %ebp
89 e5                            movl %esp,%eb    
8b 45 0c                         movl 12(%ebp),%eax
03 45 08                         addl 8(%ebp),%eax
01 05 00 00 00 00                addl %eax,sum
5d                               popl %ebp
c3                               ret複製程式碼

為了便於文字編寫,上文左邊二進位制程式碼我們通過16進位制來表示,每行的右邊是對應的原彙編程式碼說明,通過上面的轉化,我們編寫的c程式碼最終變成了二進位制流!

接下來CPU將載入上面每一行二進位制流,按順序執行每個指令,完成整個過程。

CPU如何運算程式碼

計算機從上個世紀發展至今,效能實現了巨大的飛躍,但計算機的處理模型一直沒有改變,就是我們熟知的"馮諾依曼結構計算機"。這個結構的計算機要求:計算機的數制採用二進位制,計算機應該按照程式順序執行

時至至今,我們所用的計算機依然是馮諾依曼計算機(額...你或許聽說過量子計算機,但你應該沒體驗過)。CPU保持了高速的運算能力,這裡的運算能力體現在對指令的執行頻次上,一個完整邏輯的程式碼最終都將轉化成按順序排放的指令序列,CPU時時刻刻地對待處理指令按序執行。

一個單核的CPU在每個時鐘週期內完成一次運算,我們常說的主頻就是指CPU核心工作的時脈頻率,即每秒鐘能產生多少次時鐘中斷。我們需要了解的是,時鐘中斷是計算機硬體運作的一種方式,每次時鐘的中斷相當於一次對硬體的觸發(你或許可以想象為對函式的一次呼叫了)。一個主頻為1GHz的CPU意味著每一秒鐘CPU可實現1000000000次觸發,如果每條指令都可以在一次觸發中完成執行,那麼CPU的運算能力我們可以簡單理解為每秒可執行1000000000條指令。

那麼,CPU在運算過程中和指令的關係是怎樣的?

通過上文可知,程式碼編譯後的最終形式是二進位制流,並且會儲存在儲存器中供載入。在指令執行過程中,CPU需要知道每次執行的下一條指令地址,並且執行後的相關狀態需要實時儲存,在CPU執行頻率如此快的情況下,要求必須有足夠快的速度實現資料儲存,而暫存器就是這麼一個離CPU最近的儲存硬體,它速度足夠快,滿足CPU的高速儲存需求,接下來我們結合上文示例的彙編程式碼來講述暫存器和CPU之間的關係。

暫存器代表的是CPU可直接訪問的高速儲存器,在Y86處理器中,有8個暫存器(在彙編程式中以%符號開頭來表示,如%eax,%ecx....代表不同暫存器),每個暫存器可儲存四個位元組

上一節在講述程式碼編譯的過程中,我們以一個sum函式做了示例,該函式實現了兩個引數x、y的相加,其中我們看到如下一條指令:

movl 12(%ebp),%eax複製程式碼

該指令在原c程式碼中相當於獲取引數x的值,具體功能為:將暫存器%ebp儲存的值加上12,然後將得到的值作為記憶體地址去記憶體中獲取對應值,並儲存到%eax暫存器中。可以理解為,%ebp儲存著記憶體某個地址,而引數x的值被放置在該記憶體地址偏移12(這裡的單位是位元組)的地方,找到該值後放置到%eax暫存器中,儲存狀態如下圖所示:

印象系列-理解程式的存在

隨後開始執行指令:

addl 8(%ebp),%eax複製程式碼

其中addl指令會命令CPU執行加的運算操作,具體功能為:將%ebp儲存的值加上8,然後得到的結果作為記憶體地址從記憶體空間取出對應值,將該值和%eax儲存的資料進行相加操作,最後將結果儲存到%eax暫存器中。可以理解為,引數y一開始放置在%ebp所儲存記憶體地址偏移量為8的地方,addl命令首先從該地址獲取y值,接下來從%eax獲取x值,最後執行相加操作,由此實現了sum函式中對引數x和y的計算。儲存狀態如下圖所示:

印象系列-理解程式的存在

通過上面兩條指令,我們大致瞭解了sum函式的核心計算過程,CPU在這個過程中扮演了主要角色,暫存器則配合CPU完成了一些儲存狀態的相關操作(放置引數變數的地址,儲存相關結果值...)。

值得注意的是,這個過程還有一個專門暫存器用於存放待執行指令的地址,每執行一條指令時都會更新PC暫存器,用於指向下一條指令的地址,作業系統通過維護好PC暫存器儲存的內容很好地控制了CPU的運算過程。

作業系統中的排程

從CPU的執行過程來看,在單核的條件下,CPU在每個時鐘週期最多隻能處理一條指令,如果CPU一直在不間斷處理某個程式碼邏輯,那麼其它的應用程式將無法得到執行的機會。因此,必須引入一種機制合理分配計算資源,讓不同的程式碼邏輯能夠及時得到處理,排程的概念在這裡出現了,作業系統通過排程的機制,在每秒鐘處理頻次如此高的情況下,每一秒鐘的單位時間CPU可以處理很多不同程式碼邏輯的指令,實現了“併發”的操作。

那麼,排程的物件是什麼,這個過程如何去理解呢?

上文中的示例程式碼,完成了兩個數值的相加操作,假設現在還有另一個程式碼邏輯也在執行過程中,完成的是兩個數值的相減操作,我們希望這兩個過程互不相關互不干擾。

另一個程式碼邏輯如下:

int sub(int x,inty){
    int t=x-y;
    return t;
}複製程式碼

編譯後的形式為:

55                               pushl %ebp
89 e5                            movl %esp,%ebp   
8b 45 0c                         movl 20(%ebp),%eax
61 45 08                         subl 16(%ebp),%eax
01 05 00 00 00 00                addl %eax,sum
5d                               popl %ebp
c3                               ret複製程式碼

從彙編程式碼可以知道,該函式的x、y引數在執行過程中放置在%ebp暫存器所儲存地址對應偏移位元組的位置處,最終的執行結果也儲存在%eax暫存器,只不過%ebp在兩個執行過程中會存放不同的值用於區分不同程式碼空間的記憶體地址。暫存器是被所有執行過程共享的,按照我們的設想,如果CPU在運算過程中直接來回撥用兩個邏輯的指令,那麼期間暫存器儲存的值將會受到另一個程式的干擾,那麼將無法得到正確的結果!

我們來模擬CPU運算過程可能遭遇的一種狀態,當PC暫存器定位到sum函式如下指令時:

movl 12(%ebp),%eax複製程式碼

完成了x值在%eax暫存器的儲存操作,此時作業系統通過排程將PC指定到sub函式的如下指令:

movl 20(%ebp),%eax複製程式碼

如果在此過程不對%ebp進行更新操作,那麼sub函式的x值將會按照sum函式執行的儲存狀態來繼續處理,這顯然是不對的,會造成取值的錯誤。

同樣的情況,當CPU執行完sum函式的下一條指令時:

addl 8(%ebp),%eax複製程式碼

此時,%eax儲存著x、y相加的結果,接下來作業系統通過排程將PC指定到sub函式的如下指令:

movl 20(%ebp),%eax複製程式碼

可以預見的是,%eax值被覆蓋了,那麼當CPU重新排程到sum函式的後續指令時,將發生各種可能的錯誤。

所以,在排程過程中,必須設計一套模型,使得各個程式碼邏輯在執行過程中,關於儲存空間的利用不會互相干擾,並且CPU能完整地執行完各個邏輯。

上下文的概念和程式的存在

在前面示例的案例中,如果要讓兩個函式同時執行,通過CPU高頻的排程可以“無感知”地分配計算資源進行處理,但在兩個不同程式碼邏輯中來回計算,需要考慮一些儲存空間的衝突問題,避免資料受到彼此干擾,這裡需要提及一個重要概念:上下文環境

個人剛開始接觸程式設計時,偶爾會在書本或相關文件中見到“上下文”的字眼,當時並沒有太在意這個概念,也沒真正理解過。何謂上下文?百科是這樣概括的:

上下文,即語境、語意,是語言學科(語言學、社會語言學、篇章分析、語用學、符號學等)的概念。

但在剛剛分析的問題中,我個人可以用蹩腳的話語來說明上下文環境:某個執行中程式碼邏輯的前後關係和儲存狀態,前後關係說明了在這個環境下需保證邏輯的正確性,儲存狀態說明了相關資料內容在前後執行過程中需保持一致的狀態,不可異常變動。

假設CPU在執行sum函式過程中,還未執行指令movl 12(%ebp),%eax的情況下直接執行addl 8(%ebp),%eax那麼這個前後關係就被破壞了。如果在執行sum函式的指令addl 8(%ebp),%eax後開始排程sub函式的movl 20(%ebp),%eax指令,那麼%eax數值被干擾,當CPU重新排程執行sum函式後續指令時,儲存狀態的一致性被破壞了。

因此,維護好上下文環境就是將每個獨立的程式碼邏輯當做一個完整而封閉的執行單元來區別處理,程式的概念就是在這樣的需求下被設計了出來,可以說,程式作為不同程式執行的基本單元,維護了相應的上下文環境,在CPU高速排程過程中保證了不同程式的正確執行!

關於程式的概念,linux作業系統中是這樣理解的:

程式是一個可執行檔案,而程式是一個執行中的程式例項。利用分時技術,在Linux作業系統上可以同時執行多個程式。分時技術的基本原理是把CPU的執行時間劃分成一個個規定長度的時間片,讓每個程式在一個時間內執行。當程式的時間片用完時系統就利用排程程式切換到另一個程式去執行。因此實際上對於具有單個CPU的機器來說某一時刻只能執行一個程式。但由於每個程式執行的時間片很短,所以表面看起來好像所有程式都在同時執行著。

當前包括Linux等作業系統經過數十年發展,程式在作業系統內部的表示已經變得非常複雜,包括執行緒、協程等概念也被創造出來,但所有的程式最終都依附於程式。本文意在對程式完成初步印象,因此我們將用最簡單的方式來結合前面案例構建一個程式結構。

在上面的排程案例中,我們遭遇了上下文環境不一致的問題,一個是程式碼邏輯方面、一個是儲存狀態方面,我們分別從這兩個方面進行分析。

程式碼邏輯

CPU在排程過程中,從sum函式切換到sub函式執行時,首先需要知道接下來該執行sub函式哪個位置的指令,從而切換到sub函式執行前可以更新PC暫存器的值,讓CPU沿著上一次執行的位置按序處理後續指令。這裡,需要有一個儲存空間用於儲存各個程式碼邏輯待需執行的指令位置,每當CPU排程到該程式時,從該空間提取出指令位置資訊,恢復到PC暫存器,整個過程可以實現完整的處理。

儲存狀態

當執行完sum函式的 movl 12(%ebp),%eax 指令後,%eax儲存著sum函式的x變數值,用於後續指令的呼叫,但當CPU接下來排程到sub函式的 movl 20(%ebp),%eax 指令執行後,%eax的值將受到干擾,因此每次排程都必須對當前上下文環境的暫存器值進行儲存,即在準備排程到sub函式前將%eax的值儲存到sum函式專有的記憶體空間,在後續重新執行sum函式時,再從該記憶體空間恢復%eax值,這樣保證了sum函式後續的指令 addl 8(%ebp),%eax 正常處理!這些過程由作業系統的程式機制自動處理,對程式設計師而言都是透明的。

所以,每個程式必須要求有一個獨立的記憶體空間,這個記憶體空間的第一作用就是維護當前程式碼邏輯的上下文環境資訊!

linux作業系統是怎麼實現程式管理的?我們來窺探下linux本人在30年前開發linux核心初版時的思路:linux核心在記憶體空間為每個程式開闢一個獨立而固定的記憶體空間來存放程式結構,程式結構儲存了不同程式當前的上下文環境資訊。結合前文的分析,我們瞭解到至少在該空間儲存了程式待執行指令的地址(用於恢復PC暫存器),當排程發生準備切換到另一程式時,作業系統會將各個暫存器值儲存到程式空間對應位置中,當排程重新切換到該程式時再從程式空間恢復到各個暫存器,程式切換就是在這麼一個過程中反反覆覆。

如果你理解了這個基本過程,那麼應該明白程式的排程其實是有成本的,作業系統每次對程式的排程,都需要對很多儲存狀態進行處理,目前作業系統很多程式狀態都儲存在記憶體中,這就要求每次排程都會對記憶體進行了一定的IO操作,這對於每秒鐘億萬次運算的CPU來說不得不等待IO的過程,可能會造成一定的延遲。

相關問題

有個技術編寫一個從遠端伺服器拉取資料的指令碼,在四核CPU的伺服器上操作。當時一下子開啟了幾十個程式同時往遠端拉取,發現程式開的越多,整個伺服器從遠端拉取資料的速度反而越慢,最終還沒有開4個程式的塊,這是為什麼呢?

為什麼說執行緒比較輕量?這個輕量的輕如何從底層去理解?

golang語言作為21世紀的C語言,以高效能、高併發著稱,其中golang有一個叫“協程”的概念,通過協程一個應用程式可以在消耗極少記憶體和其它計算資源的條件下實現大量併發處理,這種技術到底是怎麼實現的?

後語

在本人的理解中,程式存在的意義就是為了管理不同的程式,維護著不同程式的上下文環境,這些都是在排程的場景中被設計出來的。無論作業系統如何龐大複雜,程式排程的核心概念都離不開此。本文在很多關鍵細節上並沒有做非常細緻而嚴謹的說明,突出的是程式在排程中扮演的角色,這個角色如果深究還有非常多的特性和細節,可以自行查詢相關資料瞭解。


參考:《深入理解計算機系統》、《linux核心完全註釋》、《linux核心架構》

   





相關文章