函數調用太多了會有性能問題嗎?

函數開銷困惑

在現代的開發工作中,相信絕大部分的同學手頭的項目都不是從第零行代碼開始搭建的。各個語言都有自己流行的代碼框架,如PHP的有Laravel、CodeIgniter、ThinkPHP等等。大家都是在自己的框架的基礎上添加自己的業務代碼邏輯,開啟開發工作。還記得我們團隊有位開發同學當時問過我一個問題,我們用xx框架這麼重,一個用戶請求過來即使什麼也不幹,都已經進行了那麼多次的函數調用了,適合用來做接口開發嗎?

我當時給她的回答是,沒問題放心吧,函數調用的開銷很小的,不必擔心。但回答完她的問題之後,我回頭一想,我只知道函數調用的開銷很小,但是具體是多大,我心裡並吃不準,這就在我心裡又種下了草。後來終於抽空進行了一次實踐研究,把草拔掉了。

C語言測試

1) 準備測試代碼

測試代碼很簡單,這就是一個for循環的函數調用。

#include <stdio.h> 
int func(int p){
return 1;
}
int main()
{
int i;
for(i=0; i<100000000; i++){
func(2);
}
return 0;
}
/<stdio.h>

2) 函數調用耗時測試

我們用time命令來進行耗時測試

# gcc main.c -o main 
# time ./main
real 0m0.335s

user 0m0.334s
sys 0m0.000s
#perf stat ./main
......
1,100,989,673 instructions # 1.37 insns per cycle
......

不過上面的實驗中有個多餘的開銷,那就是for循環。我們單獨計算一下這個for的開銷,把func()調用那行註釋掉,單獨保留1億次的for循環,再重新編譯執行一遍。結果是

time ./main 
real 0m0.293s
user 0m0.292s
sys 0m0.000s
perf stat ./main
......
301,252,997 instructions # 0.43 insns per cycle
......

通過上面兩步測試的數據,(0.335-0.293)/100000000=0.4ns。我們可以得出結論1:每個c函數調用耗時大約是0.4ns左右。

3) 函數調用CPU指令數分析

我們用perf命令可以統計到程序運行的底層CPU指令個數。1億次的函數調用統計結果如下:

# perf stat ./main 
......
1,100,989,673 instructions # 1.37 insns per cycle
......

去掉for循環後,單獨1億次的for循環統計如下:

# perf stat ./main 
......
301,252,997 instructions # 0.43 insns per cycle
......

通過這兩個數據,(1,100,989,673-301,252,997)/100000000=8個。所以我們得出結論2:每個c函數需要的CPU指令數是8個!

4) 函數調用CPU指令剖析

如果有同學和我一樣好奇結論2中的每個c函數的CPU指令到底幹了些啥,請和我一起來,否則請開啟3倍速快進。還是上述的實驗代碼,我們通過gdb的disassemble來查看一下其內部彙編執行過程,編譯之。

gcc -g main.c -o main

再用gdb命令調試:

gdb ./main
start
disassemble
mov $0x2,%edi

看到函數到了main函數處,並打印出了main函數的彙編代碼

......
=> 0x0000000000400486 : mov $0x2,%edi
0x000000000040048b : callq 0x400474 <func>
......

/<func>

這是進入函數調用的兩個CPU指令,每個指令大概含義如下:

  • 指令1:mov $0x2,%edi是為了調用函數做準備,把參數放到寄存器中。
  • 指令2:callq表示cpu開始執行func函數的代碼段。

接下來讓我們進入到func函數內部看一下:

break func
run

這時函數停在了func函數的入口處, 繼續使用gdb的disassemble命令查看彙編指令:

(gdb) disassemble
Dump of assembler code for function func:
0x0000000000400474 : push %rbp
0x0000000000400475 : mov %rsp,%rbp
0x0000000000400478 : mov %edi,-0x4(%rbp)
=> 0x000000000040047b : mov $0x1,%eax
0x0000000000400480 : leaveq
0x0000000000400481 : retq
End of assembler dump.

這6個指令是對應在函數內部執行,以及函數返回的操作。加上前面2個,這樣在結論2中的每個函數8個CPU指令就都水落石出了。

  • 指令3:push %rbp bp寄存器的值壓入調用棧,即將main函數棧幀的棧底地址入棧(對應一次壓棧操作,內存IO)
  • 指令4:mov %rsp,%rbp被調函數的棧幀棧底地址放入bp寄存器,建立func函數的棧幀(一次寄存器操作)。
  • 指令5:mov %edi,-0x4(%rbp)是從寄存器的地址-4的內存中取出,即獲取輸入參數(內存IO)
  • 指令6:mov $0x1,%eax對應return 0,即是將返回參數寫到寄存器中(內存讀IO)

再接下來的兩個執行令是進行調用棧的退棧,以便於返回到main函數繼續執行。是指令3和指令4的逆操作。

  • 指令7:leave q等價於mov %rbp, %rsp,寄存器操作
  • 指令8:retq 等價於pop %rbp(內存IO)

總結:8次CPU指令中大部分都是寄存器的操作,即使有“內存IO”,也是在棧上進行。而棧操作密集,符合局部性原理,早就被L1緩存住了,其實都是L1的IO,所以耗時很低。前面實驗結果表明1次函數調用的開銷是0.4ns, 耗時竟然小於1次真正物理內存IO的耗時(40ns左右),

5) 介紹指令並行

不知道大家有沒有人注意到,前面兩次perf stat的結果中分別有如下兩個提示

  • 0.43 insns per cycle
  • 1.37 insns per cycle

這是說現代的CPU可以通過流水線的方式對CPU指令進行並行處理,當指令符合並行規則的時候,每個CPU週期內執行的指令數可能會大於1。這就是CPU指令並行的功勞。 所以增加函數調用後耗時並沒有增加太多,除了函數調用本身開銷不大的原因以外,還有一個原因就是函數調用讓CPU的流水線並行技術得以施展,每秒處理的CPU指令數更多了。

PHP語言測試

很多同學又會問題,你用的是C語言進行測試,性能當然高了。

  • “我用的可是PHP,這可是腳本語言”
  • “我用的可是Java,中間可還有一層虛擬機”
  • “我用的可是...”

好了,不抬槓,我們繼續試一試不就完了麼。就用php來繼續實驗一把。

function func(){ 
return true;
}
for($i=0;$i<10000000;$i++){
func();
}

實驗結果:

  • php7: 1000W次耗時0.667s,減去0.140s的for循環耗時,平均每次函數調用耗時52ns
  • php53:1000W次耗時2.1s,減去0.5s的for循環耗時,平均每次耗時160ns

結論

php的函數調用確實比c的要慢很多,從不到1ns升高到了50ns左右。因為php又用c虛擬了一層指令集,這層指令集還需要變成CPU的指令集後才可以真正運行。但是要知道的是ns這個時間單位太小了,假如你用的框架特別變態,一個用戶請求來了直接就搞了1000次的函數調用,那麼消耗在函數調用上的時間會是50ns*1000=50us。這和代碼框架化後給團隊項目帶來的便利性來對比的話,這點時間開銷,我覺得仍然是可以忽略的。


分享到:


相關文章: