CPU性能
響應時間:指的就是,我們執行一個程序,到底需要花多少時間。花的時間越少,自然性能就越好。
吞吐率:在一定的時間范圍內,到底能處理多少事情。這里的“事情”,在計算機里就是處理的數據或者執行的程序指令。
我們一般把性能,定義成響應時間的倒數,也就是: 性能 = 1/響應時間
程序運行的時間
程序運行的時間=程序運行結束的時間-程序開始運行的時間
但是,計算機可能同時運行着好多個程序,CPU實際上不停地在各個程序之間進行切換。在這些走掉的時間里面,很可能CPU切換去運行別的程序了。所以這個時間並不准。
我們使用time命令統計運行時間:
$ time seq 1000000 | wc -l
1000000
real 0m0.101s
user 0m0.031s
sys 0m0.016s
其中real就是Wall Clock Time,而程序實際花費的CPU執行時間,就是user time加上sys time。
我們下面對程序的CPU執行時間進行拆解:
程序的CPU執行時間=CPU時鍾周期數×時鍾周期時間
時鍾周期時間:如果一台電腦的主頻是2.8GHz,那么可以簡單認為,CPU在1秒時間內,可以執行的簡單指令的數量是2.8G條。在這個2.8GHz的CPU上,這個時鍾周期時間,就是1/2.8G。
對於上面的公式:CPU時鍾周期數還可以拆解成指令數×每條指令的平均時鍾周期數Cycles Per Instruction,簡稱CPI)。
程序的CPU執行時間=指令數×CPI×Clock Cycle Time
並行優化
由於通過提升CPU頻率已經達到瓶頸,所以開始推出多核CPU,通過提升“吞吐率”而不是“響應時間”,來達到目的。
但是,並不是所有問題,都可以通過並行提高性能來解決。如果想要使用這種思想,需要滿足這樣幾個條件。
- 需要進行的計算,本身可以分解成幾個可以並行的任務。
- 需要能夠分解好問題,並確保幾個人的結果能夠匯總到一起。
- 在“匯總”這個階段,是沒有辦法並行進行的,還是得順序執行,一步一步來。
所以並行計算涉及到了一個阿姆達爾定律(Amdahl’s Law)。
對於一個程序進行優化之后,處理器並行運算之后效率提升的情況。具體可以用這樣一個公式來表示:
優化后的執行時間 = 受優化影響的執行時間/加速倍數+不受影響的執行時間
比如做一段數據的計算, 本來如果整個計算單核完成需要120ns,但是我們可以將這個任務拆分成4個,最后再匯總加起來。如果每個任務單獨計算需要25ns,加起來匯總需要20ns,那么4個任務並行計算需要100/4+20=25ns。
即使我們增加更多的並行度來提供加速倍數,比如有100個CPU,整個時間也需要100/100+20=21ns。
從編譯到匯編,代碼怎么變成機器碼?
如下C語言程序例子:
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
我們給兩個變量 a、b分別賦值1、2,然后再將a、b兩個變量中的值加在一起,重新賦值給了a整個變量。
要讓這段程序在一個Linux操作系統上跑起來,我們需要把整個程序翻譯成一個匯編語言(ASM,Assembly Language)的程序,這個過程我們一般叫編譯(Compile)成匯編代碼。
針對匯編代碼,我們可以再用匯編器(Assembler)翻譯成機器碼(Machine Code)。這些機器碼由“0”和“1”組成的機器語言表示。這一條條機器碼,就是一條條的計算機指令。這樣一串串的16進制數字,就是我們CPU能夠真正認識的計算機指令。
匯編代碼其實就是“給程序員看的機器碼”,也正因為這樣,機器碼和匯編代碼是一一對應的。我們人類很容易記住add、mov這些用英文表示的指令,而8b 45 f8這樣的指令,由於很難一下子看明白是在干什么,所以會非常難以記憶。所以我們需要匯編代碼。
程序指令
指令是如何被執行的
一個CPU里面會有很多種不同功能的寄存器。我這里給你介紹三種比較特殊的。
一個是PC寄存器(Program Counter Register),也叫指令地址寄存器(Instruction Address Register)。它就是用來存放下一條需要執行的計算機指令的內存地址。
第二個是指令寄存器(Instruction Register),用來存放當前正在執行的指令。
第三個是條件碼寄存器(Status Register),用里面的一個一個標記位(Flag),存放CPU進行算術或者邏輯計算的結果。
實際上,一個程序執行的時候,CPU會根據PC寄存器里的地址,從內存里面把需要執行的指令讀取到指令寄存器里面執行,然后根據指令長度自增,開始順序讀取下一條指令。可以看到,一個程序的一條條指令,在內存里面是連續保存的,也會一條條順序加載。
程序的執行和跳轉
現在就來看一個包含if…else的簡單程序。
// test.c
#include <time.h>
#include <stdlib.h>
int main()
{
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{
a = 1;
} else {
a = 2;
}
把這個程序編譯成匯編代碼。
if (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
3f: 75 09 jne 4a <main+0x4a>
{
a = 1;
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
48: eb 07 jmp 51 <main+0x51>
}
else
{
a = 2;
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
}
可以看到,這里對於r == 0的條件判斷,被編譯成了cmp和jne這兩條指令。
對於:
cmp DWORD PTR [rbp-0x4],0x0
cmp指令比較了前后兩個操作數的值,這里的DWORD PTR代表操作的數據類型是32位的整數,而[rbp-0x4]則是一個寄存器的地址。所以,第一個操作數就是從寄存器里拿到的變量r的值。第二個操作數0x0就是我們設定的常量0的16進制表示。cmp指令的比較結果,會存入到條件碼寄存器當中去。
在這里,如果比較的結果是False,也就是0,就把零標志條件碼(對應的條件碼是ZF,Zero Flag)設置為1。
cmp指令執行完成之后,PC寄存器會自動自增,開始執行下一條jne的指令。
對於:
jne 4a <main+0x4a>
jne指令,是jump if not equal的意思,它會查看對應的零標志位。如果為0,會跳轉到后面跟着的操作數4a的位置。這個4a,對應這里匯編代碼的行號,也就是上面設置的else條件里的第一條指令。
當跳轉發生的時候,PC寄存器就不再是自增變成下一條指令的地址,而是被直接設置成這里的4a這個地址。這個時候,CPU再把4a地址里的指令加載到指令寄存器中來執行。
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
4a的指令,實際是一條mov指令,第一個操作數和前面的cmp指令一樣,是另一個32位整型的寄存器地址,以及對應的2的16進制值0x2。mov指令把2設置到對應的寄存器里去,相當於一個賦值操作。然后,PC寄存器里的值繼續自增,執行下一條mov指令。
下一條指令也是mov,第一個操作數eax,代表累加寄存器,第二個操作數0x0則是16進制的0的表示。這條指令其實沒有實際的作用,它的作用是一個占位符。
函數調用
我們先來看個例子:
// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
我們把這個程序編譯之后:
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 <main>:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 <add>
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
在add函數編譯之后,代碼先執行了一條push指令和一條mov指令;在函數執行結束的時候,又執行了一條pop和一條ret指令。
add函數的第0行,push rbp這個指令,就是在進行壓棧。這里的rbp又叫棧幀指針(Frame Pointer),是一個存放了當前棧幀位置的寄存器。push rbp就把之前調用函數的返回地址,壓到棧頂。
接着,第1行的一條命令mov rbp, rsp里,則是把rsp這個棧指針(Stack Pointer)的值復制到rbp里,而rsp始終會指向棧頂。這個命令意味着,rbp這個棧幀指針指向的返回地址,變成當前最新的棧頂,也就是add函數的返回地址了。
而在函數add執行完成之后,又會分別調用第12行的pop rbp來將當前的棧頂出棧,然后調用第13行的ret指令,將程序的控制權返回到出棧后的棧頂,也就是main函數的返回地址。
拆解程序執行
實際上,“C語言代碼-匯編代碼-機器碼” 這個過程,在我們的計算機上進行的時候是由兩部分組成的。
第一個部分由編譯(Compile)、匯編(Assemble)以及鏈接(Link)三個階段組成。在這三個階段完成之后,我們就生成了一個可執行文件。
第二部分,我們通過裝載器(Loader)把可執行文件裝載(Load)到內存中。CPU從內存中讀取指令和數據,來開始真正執行程序。
鏈接
靜態鏈接
程序的鏈接,是把對應的不同文件內的代碼段,合並到一起,成為最后的可執行文件。
在可執行文件里,我們可以看到,對應的函數名稱,像add、main等等,乃至你自己定義的全局可以訪問的變量名稱對應的地址,存儲在一個叫作符號表(Symbols Table)的位置里。符號表相當於一個地址簿,把名字和地址關聯了起來。
經過程序的鏈接之后,main函數里調用add的跳轉地址,不再是下一條指令的地址了,而是add函數的入口地址了。
鏈接器會掃描所有輸入的目標文件,然后把所有符號表里的信息收集起來,構成一個全局的符號表。然后再根據重定位表,把所有不確定要跳轉地址的代碼,根據符號表里面存儲的地址,進行一次修正。最后,把所有的目標文件的對應段進行一次合並,變成了最終的可執行代碼。
這個合並代碼段的方法,是叫靜態鏈接。
動態鏈接
在動態鏈接的過程中,我們想要“鏈接”的,不是存儲在硬盤上的目標文件代碼,而是加載到內存中的共享庫(Shared Libraries)。
要想要在程序運行的時候共享代碼,也有一定的要求,就是這些機器碼必須是“地址無關”的。換句話說就是,這段代碼,無論加載在哪個內存地址,都能夠正常執行。
動態代碼庫內部的變量和函數調用都是使用相對地址。因為整個共享庫是放在一段連續的虛擬內存地址中的,無論裝載到哪一段地址,不同指令之間的相對地址都是不變的。
裝載程序
在運行這些可執行文件的時候,我們其實是通過一個裝載器,解析ELF或者PE格式的可執行文件。裝載器會把對應的指令和數據加載到內存里面來,讓CPU去執行。
裝載器需要滿足兩個要求:
- 可執行程序加載后占用的內存空間應該是連續的。因為CPU在執行指令的時候,程序計數器是順序地一條一條指令執行下去。
- 我們需要同時加載很多個程序,並且不能讓程序自己規定在內存中加載的位置。因為我們現在的計算機通常會同時運行很多個程序,可能你想要的內存地址已經被其他加載了的程序占用了。
基於上面,我們需要在內存空間地址和整個程序指令指定的內存地址做一個映射。
把指令里用到的內存地址叫作虛擬內存地址(Virtual Memory Address),實際在內存硬件里面的空間地址,我們叫物理內存地址(Physical Memory Address)。
內存分頁
分頁是把整個物理內存空間切成一段段固定尺寸的大小。而對應的程序所需要占用的虛擬內存空間,也會同樣切成一段段固定尺寸的大小。這樣一個連續並且尺寸固定的內存空間,我們叫頁(Page)。
從虛擬內存到物理內存的映射,不再是拿整段連續的內存的物理地址,而是按照一個一個頁來的。
分頁之后避免了整個程序和硬盤進行交換而產生性能瓶頸。即使內存空間不夠,需要讓現有的、正在運行的其他程序,通過內存交換釋放出一些內存的頁出來,一次性寫入磁盤的也只有少數的一個頁或者幾個頁,不會花太多時間,讓整個機器被內存交換的過程給卡住。