作者:HDK (百度)
前言
在任何語言中,函數都是最基本的組成單元。對於php的函數,它具有哪些特點?函數調用是怎麼實現的?php函數的效能如何,有什麼使用建議?本文將從原理出發進行分析結合實際的效能測試嘗試對這些問題進行回答,在瞭解實現的同時更好的編寫php程式。同時也會對一些常見的php函數進行介紹。
php函數的分類
在php中,橫向劃分的話,函數分為兩大類: user function(內建函數) 和internal function(內建函數)。前者就是使用者在程式中自訂的一些函數和方法,後者則是php本身提供的各類庫函數(比如sprintf、array_push等)。使用者也可以通過擴充的方法來編寫庫函數,這個將在後面介紹。對於user function,又可以細分為function(函數)和method(類方法),本文中將就這三種函數分別進行分析和測試。
php函數的實現
一個php函數最終是如何執行,這個流程是怎麼樣的呢?
要回答這個問題,我們先來看看php代碼的執行所經過的流程。
從圖1可以看到,php實現了一個典型的動態語言執行過程:拿到一段代碼後,經過詞法解析、文法解析等階段後,來源程式會被翻譯成一個個指令(opcodes),然後ZEND虛擬機器順次執行這些指令完成操作。Php本身是用c實現的,因此最終調用的也都是c的函數,實際上,我們可以把php看做是一個c開發的軟體。
通過上面描述不難看出,php中函數的執行也是被翻譯成了opcodes來調用,每次函數調用實際上是執行了一條或多條指令。
對於每一個函數,zend都通過以下的資料結構來描述
typedef union _zend_function { zend_uchar type; /* MUST be the first element of this struct! */ struct { zend_uchar type; /* never used */ char *function_name; zend_class_entry *scope; zend_uint fn_flags; union _zend_function *prototype; zend_uint num_args; zend_uint required_num_args; zend_arg_info *arg_info; zend_bool pass_rest_by_reference; unsigned char return_reference; } common; zend_op_array op_array; zend_internal_function internal_function;} zend_function;typedef struct _zend_function_state { HashTable *function_symbol_table; zend_function *function; void *reserved[ZEND_MAX_RESERVED_RESOURCES];} zend_function_state;
其中type標明了函數的類型:使用者函數、內建函數、重載函數。Common中包含函數的基本資料,包括函數名,參數資訊,函數標誌(普通函數、靜態方法、抽象方法)等內容。另外,對於使用者函數,還有一個函數符號表,記錄了內部變數等,這個將在後面詳述。 Zend維護了一個全域function_table,這是一個大的hahs表。函數調用的時候會首先根據函數名從表中找到對應的zend_function。當進行函數調用時候,虛擬機器會根據type的不同決定調用方法, 不同類型的函數,其執行原理是不相同的 。
內建函數
內建函數,其本質上就是真正的c函數,每一個內建函數,php在最終編譯後都會展開成為一個名叫zif_xxxx的function,比如我們常見的sprintf,對應到底層就是zif_sprintf。Zend在執行的時候,如果發現是內建函數,則只是簡單的做一個轉寄操作。
Zend提供了一系列的api供調用,包括參數擷取、數組操作、記憶體配置等。內建函數的參數擷取,通過zend_parse_parameters方法來實現,對於數組、字串等參數,zend實現的是淺拷貝,因此這個效率是很高的。可以這樣說,對於php內建函數,其效率和相應c函數幾乎相同,唯一多了一次轉寄調用。
內建函數在php中都是通過so的方式進行動態載入,使用者也可以根據需要自己編寫相應的so,也就是我們常說的擴充。ZEND提供了一系列的api供擴充使用
使用者函數
和內建函數相比,使用者通過php實現的自訂函數具有完全不同的執行過程和實現原理。如前文所述,我們知道php代碼是被翻譯成為了一條條opcode來執行的,使用者函數也不例外,實際中每個函數對應到一組opcode,這組指令被儲存在zend_function中。於是,使用者函數的調用最終就是對應到一組opcodes的執行。
- 局部變數的儲存及遞迴的實現
我們知道,函數遞迴是通過堆棧來完成的。在php中,也是利用類似的方法來實現。Zend為每個php函數分配了一個活動符號表(active_sym_table),記錄當前函數中所有局部變數的狀態。所有的符號表通過堆棧的形式來維護,每當有函數調用的時候,分配一個新的符號表併入棧。當調用結束後當前符號表出棧。由此實現了狀態的儲存和遞迴。
對於棧的維護,zend在這裡做了最佳化。預先分配一個長度為N的靜態數組來類比堆棧,這種通過靜態數組來類比動態資料結構的手法在我們自己的程式中也經常有使用,這種方式避免了每次調用帶來的記憶體配置、銷毀。ZEND只是在函數調用結束時將當前棧頂的符號表資料clean掉即可。
因為靜態數組長度為N,一旦函數調用層次超過N,程式不會出現棧溢出,這種情況下zend就會進行符號表的分配、銷毀,因此會導致效能下降很多。在zend裡面,N目前取值是32。因此,我們編寫php程式的時候,函數調用層次最好不要超過32。當然,如果是web應用,本身可以函數調用層次的深度。
- 參數的傳遞
和內建函數調用zend_parse_params來擷取參數不同,使用者函數中參數的擷取是通過指令來完成的。函數有幾個參數就對應幾條指令。具體到實現上就是普通的變數賦值。
通過上面的分析可以看出,和內建函數相比,由於是自己維護堆棧表,而且每條指令的執行也是一個c函數,使用者函數的效能相對會差很多,後面會有具體的對比分析。因此,如果一個功能有對應php內建函數實現的盡量不要自己重新寫函數去實現。
類方法
類方法其執行原理和使用者函數是相同的,也是翻譯成opcodes順次調用。類的實現,zend用一個資料結構zend_class_entry來實現,裡面儲存了類相關的一些基本資料。這個entry是在php編譯的時候就已經處理完成。
在zend_function的common中,有一個成員叫做scope,其指向的就是當前方法對應類的zend_class_entry。關於php中物件導向的實現,這裡就不在做更詳細的介紹,今後將專門寫一篇文章來詳述php中物件導向的實現原理。就函數這一塊來說,method實現原理和function完全相同,理論上其效能也差不多,後面我們將做詳細的效能對比。
效能對比函數名長度對效能的影響
- 測試方法
對名字長度為1、2、4、8、16的函數進行比較,測試比較它們每秒可執行次數,確定函數名長度對效能的影響
- 測試結果如
- 結果分析
從圖上可以看出,函數名的長度對效能還是會有一定的影響。一個長度為1的函數和長度為16的 空函數調用 ,其效能差了1倍。分析一下源碼不難找到原因,如前面敘述所說,函數調用的時候zend會先在一個全域的funtion_table中通過函數名查詢相關資訊,function_table是一個雜湊表。必然的,名字越長查詢所需要的時間就越多。因此,在實際編寫程式的時候,對多次調用的函數,名字建議不要太長
雖然函數名長度對效能有一定影響,但具體有多大呢?這個問題應該還是需要結合實際情況來考慮,如果一個函數本身比較複雜的話,那麼對整體的效能影響並不大。
一個建議是對於那些會調用很多次,本身功能又比較簡單的函數,可以適當取一些言簡意賅的名字。
函數個數對效能的影響
- 測試方法
在以下三種環境下進行函數調用測試,分析結果:1.程式僅包含1個函數 2.程式包含100個函數 3.程式包含1000個函數。
測試這三種情況下每秒所能調用的函數次數
- 測試結果如
- 結果分析
從測試結果可以看出,這三種情況下效能幾乎相同,函數個數增加時效能下降微乎其微,可以忽略。
從實現原理分析,幾種實現下唯一的區別在於函數擷取的部分。如前文所述,所有的函數都放在一個hash表中,在不同個數下尋找效率都應該還是接近於O(1),所以效能差距不大。
不同類型函數調用消耗
- 測試方法
選取使用者函數、類方法、靜態方法、內建函數各一種,函數本身不做任何事情,直接返回,主要測試空函數調用的消耗。測試結果為每秒可執行次數
測試中為去除其他影響,所有函數名字長度相同
- 測試結果如
- 結果分析
通過測試結果可以看到,對於使用者自己編寫的php函數,不管是哪種類型,其效率是差不多的,均在280w/s左右。如我們預期,即使是空調,內建函數其效率也要高很多,達到780w/s,是前者是3倍。可見,內建函數調用的開銷還是遠低於使用者函數。從前面原理分析可知主要差距在於使用者函數調用時初始化符號表、接收參數等操作。
內建函數和使用者函數效能對比
- 測試方法
內建函數和使用者函數的效能對比,這裡我們選取幾個常用的函數,然後用php實現相同功能的函數進行一下效能對比。
測試中,我們選取字串、數學、數組中各一個典型進行對比,這幾個函數分別是字串截取(substr)、10進位轉2進位(decbin)、求最小值(min)和返回數組中的所以key(array_keys)。
- 測試結果如
- 結果分析
從測試結果可以看出,如我們預期,內建函數在總體效能上遠高於普通使用者函數。尤其對於涉及到字串類操作的函數,差距達到了1個數量級。因此,函數使用的一個原則就是如果某功能有相應的內建函數,盡量使用它而不是自己編寫php函數。
對於一些涉及到大量字串操作的功能,為提高效能,可以考慮用擴充來實現。比如常見的富文本過濾等。
和C函數效能對比
- 測試方法
我們選取字串操作和算術運算各3種函數進行比對,php用擴充實現。三種函數是簡單的一次演算法運算、字串比較和多次的演算法運算。
除了本身的兩類函數外,還會測試將函數空調開銷去掉後的效能,一方面比對一下兩種函數(c和php內建)本身的效能差異,另外就是側面印證空調函數的消耗
測試點為執行10w次操作的時間消耗
- 測試結果如
- 結果分析
內建函數和C函數的開銷在去掉php函數空調用的影響後差距較小,隨著函數功能越來越複雜,雙方效能趨近於相同。這個從之前的函數實現分析中也容易得到論證,畢竟內建函數就是C實現的。
函數功能越複雜,c和php的效能差距越小
相對c來說,php函數調用的開銷大很多,對於簡單函數來說效能還是有一定影響。因此php中函數不宜嵌套封裝太深。
偽函數及其效能
在php中,有這樣一些函數,它們在使用上是標準的函數用法,但底層實現卻和真正函數調用完全不同,這些函數不屬於前文提到的三種function中的任何一類,其實質是一條單獨的opcode,這裡估且叫做偽函數或者指令函數。
如上所說,偽函數使用起來和標準的函數並無二致,看起來具有相同的特徵。但是他們最終執行的時候是被zend反映成了一條對應的指令(opcode)來調用,因此其實現更接近於if、for、算術運算等操作。
- php中的偽函數
isset
empty
unset
eval
通過上面的介紹可以看出,偽函數由於被直接翻譯成指令來執行,和普通函數相比少了一次函數調用所帶來的開銷,因此效能會更好一些。我們通過如下測試來做一個對比。 Array_key_exists和isset兩者都可以判斷數組中某個key是否存在,看一下他們的效能
從圖上可以看出,和array_key_exists相比,isset效能要高出很多,基本是前者的4倍左右,而即使是和空函數調用相比,其效能也要高出1倍左右。由此也側面印證再次說明了php函數調用的開銷還是比較大的。
常用php函數實現及介紹count
count是我們經常用到的一個函數,其功能是返回一個數組的長度。
count這個函數,其複雜度是多少呢?
一種常見的說法是count函數會遍曆整個數組然後求出元素個數,因此複雜度是O(n)。那實際情況是不是這樣呢?
我們回到count的實現來看一下,通過源碼可以發現,對於數組的count操作,函數最終的路徑是zif_count-> php_count_recursive-> zend_hash_num_elements,而zend_hash_num_elements的行為是 return ht->nNumOfElements,可見,這是一個O(1)而不是O(n)的操作。實際上,數組在php底層就是一個hash_table,對於hash表,zend中專門有一個元素nNumOfElements記錄了當前元素的個數,因此對於一般的count實際上直接就返回了這個值。由此,我們得出結論:count是O(1)的複雜度,和具體數組的大小無關。
非數群組類型的變數,count的行為時怎樣?
對於未設定變數返回0,而像int、double、string等則會返回1
strlen
Strlen用於返回一個字串的長度。那麼,他的實現原理是如何的呢?
我們都知道在c中strlen是一個o(n)的函數,會順序遍曆字串直到遇到/0,然後出長度。Php中是否也這樣呢?答案是否定的,php裡字串是用一個複合結構來描述,包括指向具體資料的指標和字串長度(和c++中string類似),因此strlen就直接返回字串長度了,是常數層級的操作。
另外,對於非字串類型的變數調用strlen,它會首先將變數強制轉換為字串再求長度,這點需要注意。
isset和array_key_exists
這兩個函數最常見的用法都是判斷一個key是否在數組中存在。但是前者還可以用於判斷一個變數是否被設定過。
如前文所述,isset並非真正的函數,因此它的效率會比後者高很多。推薦用它代替array_key_exists。
array_push和array[]
兩者都是往數組尾部追加一個元素。不同的是前者可以一次push多個。他們最大的區別在於一個是函數一個是語言結構,因此後者效率要更高。因此如果只是普通的追加元素,建議使用array []。
rand和mt_rand
兩者都是提供產生隨機數的功能,前者使用libc標準的rand。後者用了 Mersenne Twister 中已知的特性作為隨機數發生器,它可以產生隨機數值的平均速度比 libc 提供的 rand() 快四倍。因此如果對效能要求較高,可以考慮用mt_rand代替前者。
我們都知道,rand產生的是偽隨機數,在C中需要用srand顯示指定種子。但是在php中,rand會自己幫你預設調用一次srand,一般情況下不需要自己再顯示的調用。
需要注意的是,如果特殊情況下需要調用srand時,一定要配套調用。就是說srand對於rand,mt_srand對應srand,切不可混合使用,否則是無效的。
sort和usort
兩者都是用於排序,不同的是前者可以指定排序策略,類似我們C裡面的qsort和C++的sort。
在排序上兩者都是採用標準的快排來實現,對於有排序需求的,如非特殊情況調用php提供的這些方法就可以了,不用自己重新實現一遍,效率會低很多。原因見前文對於使用者函數和內建函數的分析比對。
urlencode和rawurlencode
這兩個都是用於url編碼, 字串中除了 -_. 之外的所有非字母數字字元都將被替換成百分比符號(%)後跟兩位十六進位數。兩者唯一的區別在於對於空格,urlencode會編碼為+,而rawurlencode會編碼為%20。
一般情況下除了搜尋引擎,我們的策略都是空格編碼為%20。因此採用後者的居多。
注意的是encode和decode系列一定要配套使用。
strcmp系列函數
這一系列的函數包括strcmp、strncmp、strcasecmp、strncasecmp,實現功能和C函數相同。但也有不同,由於php的字串是允許/0出現,因此在判斷的時候底層使用的是memcmp系列而非strcmp,理論上來說更快。
另外由於php直接能擷取到字串長度,因此會首先這方面的檢查,很多情況下效率就會高很多了。
is_int和is_numeric
這兩個函數功能相似又不完全相同,使用的時候一定需要注意他們的區別。
Is_int:判斷一個變數類型是否是整數型,php變數中專門有一個欄位表徵類型,因此直接判斷這個類型即可,是一個絕對O(1)的操作
Is_numeric:判斷一個變數是否是整數或數字字串,也就是說除了整數型變數會返回true之外,對於字串變數,如果形如”1234”,”1e4”等也會被判為true。這個時候會遍曆字串進行判斷。
總結及建議
通過對函數實現的原理分析和效能測試,我們總結出以下一些結論
1. Php的函數調用開銷相對較大。
2. 函數相關資訊儲存在一個大的hash_table中,每次調用時通過函數名在hash表中尋找,因此函數名長度對效能也有一定影響。
3. 函數返回引用沒有實際意義
4. 內建php函數效能比使用者函數高很多,尤其對於字串類操作。
5. 類方法、普通函數、靜態方法效率幾乎相同,沒有太大差異
6. 除去空函數調用的影響,內建函數和同樣功能的C函數效能基本差不多。
7. 所有的參數傳遞都是採用引用計數的淺拷貝,代價很小。
8. 函數個數對效能影響幾乎可以忽略
因此,對於php函數的使用,有如下一些建議
1. 一個功能可以用內建函數完成,盡量使用它而不是自己編寫php函數。
2. 如果某個功能對效能要求很高,可以考慮用擴充來實現。
3. Php函數調用開銷較大,因此不要過分封裝。有些功能,如果需要調用的次數很多本身又只用1、2行代碼就行實現的,建議就不要封裝調用了。
4. 不要過分迷戀各種設計模式,如上一條描述,過分的封裝會帶來效能的下降。需要考慮兩者的權衡。Php有自己的特點,切不可東施效顰,過分效仿java的模式。
5. 函數不宜嵌套過深,遞迴使用要謹慎。
6. 偽函數效能很高,同等功能實現下優先考慮。比如用isset代替array_key_exists
7. 函數返回引用沒有太大意義,也起不到實際作用,建議不予考慮。
8. 類成員方法效率不比普通函數低,因此不用擔心效能損耗。建議多考慮靜態方法,可讀性及安全性都更好。
9. 如不是特殊需要,參數傳遞都建議使用傳值而不是傳引用。當然,如果參數是很大的數組且需要修改時可以考慮引用傳遞。