說明:本文來自作者 鄒毅 在 GitChat 上分享「 PHP 內存泄漏分析定位」
目錄
-
場景一 程序操作數據過大
-
場景二 程序操作大數據時產生拷貝
-
場景三 配置不合理系統資源耗盡
-
場景四 無用的數據未及時釋放
-
深入了解
-
php內存管理
-
php-fpm內存泄露問題
-
常駐進程內存泄露問題
前言
本文開始撰寫時我負責的項目需要用 php 開發一個通過 Socket 與服務端建立長連接后持續實時上報數據的常駐進程程序,在程序業務功能開發聯調完畢后實際運行發送大量數據后發現內存增長非常迅速,在很短的時間內達到了 php 默認可用內存上限 128M ,並報錯:
Fatal error: Allowed memory size of X bytes exhausted (tried to allocate Y bytes)
我第一反應是內存泄露了,但是不知道在哪。第二反應是無用的變量應該用完就 unset 掉,修改完畢后問題依舊。經過了幾番周折終於解決了問題。就決定好好把類似情況整理一下,遂有此文,與諸君共勉。
觀察 PHP 程序內存使用情況
php 提提供了兩個方法來獲取當前程序的內存使用情況。
memory_get_usage(),這個函數的作用是獲取目前PHP腳本所用的內存大小。
memory_get_peak_usage(),這個函數的作用返回當前腳本到目前位置所占用的內存峰值,這樣就可能獲取到目前的腳本的內存需求情況。
int memory_get_usage ([ bool $real_usage = false ] )
int memory_get_peak_usage ([ bool $real_usage = false ] )
函數默認得到的是調用emalloc()占用的內存,如果設置參數為TRUE,則得到的是實際程序向系統申請的內存。因為 PHP 有自己的內存管理機制,所以有時候盡管內部已經釋放了內存但並沒有還給系統。
linux 系統文件 /proc/{$pid}/status 會記錄某個進程的運行狀態,里面的 VmRSS 字段記錄了該進程使用的常駐物理內存(Residence),這個就是該進程實際占用的物理內存了,用這個數據比較靠譜,在程序里面提取這個值也很容易
場景一:程序操作數據過大
情景還原:一次性讀取超過php可用內存上限的數據導致內存耗盡
<?php ini_set('memory_limit', '128M');
$string = str_pad('1', 128 * 1024 * 1024);//str_pad函數的作用把字符串填充為新的長度
Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 134217729 bytes) in /Users/zouyi/php-oom/bigfile.php on line 3
這是告訴我們程序運行時試圖分配新內存時由於達到了PHP允許分配的內存上限而拋出致命錯誤,無法繼續執行了,在 java 開發中一般稱之為 OOM ( Out Of Memory ) 。
PHP 配置內存上限是在 php.ini 中設置 memory_limit,PHP 5.2 以前這個默認值是 8M,PHP 5.2 的默認值是16M,在這之后的版本默認值都是128M。
問題現象:特定數據處理時可復現,做任何 IO 操作都有可能遇到此類問題,比如:一次 mysql 查詢返回大量數據、一次把大文件讀取進程序等。
解決方法:
-
能用錢解決的問題都不是問題,如果程序要讀大文件的機會不是很多,且上限可預期,那么通過 ini_set('memory_limit', '1G'); 來設置一個更大的值或者 memory_limit=-1。內存管夠的話讓程序一直跑也可以。
-
如果程序需要考慮在小內存機器上也能正常使用,那就需要優化程序了。如下,代碼復雜了很多。
<?php //php7 以下版本通過 composer 引入 paragonie/random_compat ,為了方便來生成一個隨機名稱的臨時文件 require "vendor/autoload.php"; ini_set('memory_limit', '128M'); //生成臨時文件存放大字符串 $fileName = 'tmp'.bin2hex(random_bytes(5)).'.txt';
touch($fileName); for ( $i = 0; $i < 128; $i++ ) { $string = str_pad('1', 1 * 1024 * 1024); file_put_contents($fileName, $string, FILE_APPEND); } $handle = fopen($fileName, "r"); for ( $i = 0; $i <= filesize($fileName) / 1 * 1024 * 1024; $i++ ) { //do something $string = fread($handle, 1 * 1024 * 1024); } fclose($handle); unlink($fileName);
場景二:程序操作大數據時產生拷貝
情景還原:執行過程中對大變量進行了復制,導致內存不夠用。
<?php ini_set("memory_limit",'1M'); $string = str_pad('1', 1* 750 *1024); $string2 = $string; $string2 .= '1';
Fatal error: Allowed memory size of 1048576 bytes exhausted (tried to allocate 768001 bytes) in /Users/zouyi/php-oom/unset.php on line 8 Call Stack: 0.0004 235440 1. {main}() /Users/zouyi/php-oom/unset.php:0 zend_mm_heap corrupted
問題現象:局部代碼執行過程中占用內存翻倍。
問題分析:php 是寫時復制(Copy On Write),也就是說,當新變量被賦值時內存不發生變化,直到新變量的內容被操作時才會產生復制。
解決方法:及早釋放無用變量,或者以引用的形式操作原始數據。
<?php ini_set("memory_limit",'1M'); $string = str_pad('1', 1* 750 *1024); $string2 = $string; unset($string); $string2 .= '1';
<?php ini_set("memory_limit",'1M'); $string = str_pad('1', 1* 750 *1024); $string2 = &$string; $string2 .= '1'; unset($string2, $string);
場景三:配置不合理系統資源耗盡
情景還原:因配置不合理導致內存不夠用,2G 內存機器上設置最大可以啟動 100 個 php-fpm 子進程,但實際啟動了 50 個 php-fpm 子進程后無法再啟動更多進程
問題現象:線上業務請求量小的時候不出現問題,請求量一旦很大后部分請求就會執行失敗
問題分析:
一般為了安全方面考慮, php 限制表單請求的最大可提交的數量及大小等參數,post_max_size、max_file_uploads、upload_max_filesize、max_input_vars、max_input_nesting_level。
假設帶寬足夠,用戶頻繁的提交post_max_size = 8M數據到服務端,nginx 轉發給 php-fpm 處理,那么每個 php-fpm 子進程除了自身占用的內存外,即使什么都不做也有可能多占用 8M 內存。
解決方法:合理設置 post_max_size、max_file_uploads、upload_max_filesize、max_input_vars、max_input_nesting_level 等參數並調優 php-fpm 相關參數。
php.ini
$ php -i |grep memory memory_limit => 1024M => 1024M //php腳本執行最大可使用內存 $php -i |grep max max_execution_time => 0 => 0 //最大執行時間,腳本默認為0不限制,web請求默認30s max_file_uploads => 20 => 20 //一個表單里最大上傳文件數量max_input_nesting_level => 64 => 64 //一個表單里數據最大數組深度層數 max_input_time => -1 => -1 //php從接收請求開始處理數據后的超時時間 max_input_vars => 1000 => 1000 //一個表單(包括get、post、cookie的所有數據)最多提交1000個字段 post_max_size => 8M => 8M //一次post請求最多提交8M數據upload_max_filesize => 2M => 2M //一個可上傳的文件最大不超過2M
如果上傳設置不合理那么出現大量內存被占用的情況也不奇怪,比如有些內網場景下需要 post 超大字符串 post_max_size=200M,那么當從表單提交了 200M 數據到服務端, php 就會分配 200M 內存給這條數據,直到請求處理完畢釋放內存。
php-fpm.conf
pm = dynamic //僅dynamic模式下以下參數生效 pm.max_children = 10 //最大子進程數 pm.start_servers = 3 //啟動時啟動子進程數 pm.min_spare_servers = 2 //最小空閑進程數,不夠了啟動更多進程
pm.max_spare_servers = 5 //最大空閑進程數,超過了結束一些進程
pm.max_requests = 500 //最大請求數,注意這個參數是一個php-fpm如果處理了500個請求后會自己重啟一下,可以避免一些三方擴展的內存泄露問題
一個 php-fpm 進程按 30MB 內存算,50 個 php-fpm 進程就需要 1500MB 內存,這里需要簡單估算一下在負載最重的情況下所有 php-fpm 進程都啟動后是否會把系統內存耗盡。
ulimit
$ulimit -a -t: cpu time (seconds) unlimited -f: file size (blocks) unlimited -d: data seg size (kbytes) unlimited -s: stack size (kbytes) 8192 -c: core file size (blocks) 0 -v: address space (kbytes) unlimited -l: locked-in-memory size (kbytes) unlimited -u: processes 1024 -n: file descriptors 1024
這是我本地 mac os 的配置,文件描述符的設置是比較小的,一般生產環境配置要大得多。
場景四:無用的數據未及時釋放
情景還原:這種問題從程序邏輯上不是問題,但是無用的數據大量占用內存導致資源不夠用,應該有針對性的做代碼優化。
Laravel開發中用於監聽數據庫操作時有如下代碼:
DB::listen(function ($query) { // $query->sql // $query->bindings // $query->time });
啟用數據庫監聽后,每當有 SQL 執行時會 new 一個 QueryExecuted 對象並傳入匿名函數以便后續操作,對於執行完畢就結束進程釋放資源的 php 程序來說沒有什么問題,而如果是一個常駐進程的程序,程序每執行一條 SQL 內存中就會增加一個 QueryExecuted 對象,程序不結束內存就會始終增長。
問題現象:程序運行期間內存逐漸增長,程序結束后內存正常釋放。
問題分析:此類問題不易察覺,定位困難,尤其是有些框架封裝好的方法,要明確其適用場景。
解決方法:本例中要通過DB::listen方法獲取所有執行的 SQL 語句記錄並寫入日志,但此方法存在內存泄露問題,在開發環境下無所謂,在生產環境下則應停用,改用其他途徑獲取執行的 SQL 語句並寫日志。
深入了解
1. 名詞解釋
-
內存泄漏(Memory Leak):是程序在管理內存分配過程中未能正確的釋放不再使用的內存導致資源被大量占用的一種問題。在面向對象編程時,造成內存泄露的原因常常是對象在內存中存儲但是運行中的代碼卻無法訪問他。由於產生類似問題的情況很多,所以只能從源碼上入手分析定位並解決。
-
垃圾回收(Garbage Collection,簡稱GC):是一種自動內存管理的形式,GC程序檢查並處理程序中那些已經分配出去但卻不再被對象使用的內存。最早的GC是1959年前后John McCarthy發明的,用來簡化在Lisp中手動控制內存管理。PHP的內核中已自帶內存管理的功能,一般應用場景下,不易出現內存泄露。
-
追蹤法(Tracing):從某個根對象開始追蹤,檢查哪些對象可訪問,那么其他的(不可訪問)就是垃圾。
-
引用計數法(reference count):每個對象都一個數字用來標示被引用的次數。引用次數為0的可以回收。當對一個對象的引用創建時他的引用計數就會增加,引用銷毀時計數減少。引用計數法可以保證對象一旦不被引用時第一時間銷毀。但是引用計數有一些缺陷:1.循環引用,2.引用計數需要申請更多內存,3.對速度有影響,4.需要保證原子性,5.不是實時的
2. php 內存管理
在 PHP 5.2 以前, PHP 使用引用計數(Reference count)來做資源管理, 當一個 zval 的引用計數為 0 的時候, 它就會被釋放.。
雖然存在循環引用(Cycle reference), 但這樣的設計對於開發 Web 腳本來說, 沒什么問題, 因為 Web 腳本的特點和它追求的目標就是執行時間短, 不會長期運行。
對於循環引用造成的資源泄露, 會在請求結束時釋放掉. 也就是說, 請求結束時釋放資源, 是一種部補救措施( backup ).
然而, 隨着 PHP 被越來越多的人使用, 就有很多人在一些后台腳本使用 PHP , 這些腳本的特點是長期運行, 如果存在循環引用, 導致引用計數無法及時釋放不用的資源, 則這個腳本最終會內存耗盡退出.
所以在 PHP 5.3 以后, 我們引入了 GC .
—— 摘自鳥哥博客文章《請手動釋放你的資源》
在 PHP 5.3 以后引入了同步周期回收算法(Concurrent Cycle Collection)來處理內存泄露問題,代價是對性能有一定影響,不過一般 web 腳本應用程序影響很小。
PHP 的垃圾回收機制是默認打開的,php.ini 可以設置 zend.enable_gc=0 來關閉。也能通過分別調用 gc_enable() 和 gc_disable() 函數來打開和關閉垃圾回收機制。
雖然垃圾回收讓 php 開發者在內存管理上無需擔心了,但也有極端的反例: php 界著名的包管理工具 composer 曾因加入一行 gc_disable();性能得到極大提升。
引用計數基本知識( http://php.net/manual/zh/features.gc.refcounting-basics.php )
回收周期(Collecting Cycles)( http://docs.php.net/manual/zh/features.gc.collecting-cycles.php )
上面兩個鏈接是php官方手冊中的內存管理、GC相關知識講解,圖文並茂,這里不再贅述。
3. php-fpm 內存泄露問題
在一台常見的 nginx + php-fpm 的服務器上:
-
nginx 服務器 fork 出 n 個子進程(worker), php-fpm 管理器 fork 出 n 個子進程。
-
當有用戶請求, nginx 的一個 worker 接收請求,並將請求拋到 socket 中。
-
php-fpm 空閑的子進程監聽到 socket 中有請求,接收並處理請求。
一個 php-fpm 的生命周期大致是這樣的:
模塊初始化(MINIT)-> 請求初始化(RINIT)-> 請求處理 -> 請求結束(RSHUTDOWN) -> 請求初始化(RINIT)-> 請求處理 -> 請求結束(RSHUTDOWN)……. 請求初始化(RINIT)-> 請求處理 -> 請求結束(RSHUTDOWN)-> 模塊關閉(MSHUTDOWN)。
在請求初始化(RINIT)-> 請求處理 -> 請求結束(RSHUTDOWN)這個“請求處理”過程是:
php 讀取相應的 php 文件,對其進行詞法分析,生成 opcode , zend 虛擬機執行 opcode 。
php 在每次請求結束后自動釋放內存,有效避免了常見場景下內存泄露的問題,然而實際環境中因某些擴展的內存管理沒有做好或者 php 代碼中出現循環引用導致未能正常釋放不用的資源。
在 php-fpm 配置文件中,將pm.max_requests這個參數設置小一點。這個參數的含義是:一個 php-fpm 子進程最多處理pm.max_requests個用戶請求后,就會被銷毀。當一個 php-fpm 進程被銷毀后,它所占用的所有內存都會被回收。
4. 常駐進程內存泄露問題
Valgrind 包括如下一些工具:
-
Memcheck。這是 valgrind 應用最廣泛的工具,一個重量級的內存檢查器,能夠發現開發中絕大多數內存錯誤使用情況,比如:使用未初始化的內存,使用已經釋放了的內存,內存訪問越界等。
-
Callgrind。它主要用來檢查程序中函數調用過程中出現的問題。
-
Cachegrind。它主要用來檢查程序中緩存使用出現的問題。
-
Helgrind。它主要用來檢查多線程程序中出現的競爭問題。
-
Massif。它主要用來檢查程序中堆棧使用中出現的問題。
-
Extension。可以利用core提供的功能,自己編寫特定的內存調試工具。
Memcheck 對調試 C/C++ 程序的內存泄露很有幫助,它的機制是在系統 alloc/free 等函數調用上加計數。 php 程序的內存泄露,是由於一些循環引用,或者 gc 的邏輯錯誤, valgrind 無法探測,因此需要在檢測時需要關閉 php 自帶的內存管理。
$ export USE_ZEND_ALLOC=0 # 設置環境變量關閉內存管理 $ valgrind --tool=memcheck --num-callers=30 --log-file=php.log /Users/zouyi/Downloads/php-5.6.31/sapi/cli/php leak.php
通過命令行執行 valgrind 分析可能有內存泄露的文件
==12075== Memcheck, a memory error detector ==12075== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al. ==12075== Using Valgrind-3.13.0 and LibVEX; rerun with -h for copyright info ==12075== Command: /Users/zouyi/Downloads/php-5.6.31/sapi/cli/php leak.php ==12075== Parent PID: 42043 ==12075== ==12075== Syscall param msg->desc.port.name points to uninitialised byte(s) ==12075== at 0x10121F34A: mach_msg_trap (in /usr/lib/system/libsystem_kernel.dylib) ==12075== by 0x10121E796: mach_msg (in /usr/lib/system/libsystem_kernel.dylib) ==12075== by 0x101218485: task_set_special_port (in /usr/lib/system/libsystem_kernel.dylib) ==12075== by 0x1013B410E: _os_trace_create_debug_control_port (in /usr/lib/system/libsystem_trace.dylib) ==12075== by 0x1013B4458: _libtrace_init (in /usr/lib/system/libsystem_trace.dylib) ==12075== by 0x100DF09DF: libSystem_initializer (in /usr/lib/libSystem.B.dylib) ==12075== by 0x100C37A1A: ImageLoaderMachO::doModInitFunctions(ImageLoader::LinkContext const&) (in /usr/lib/dyld) ==12075== by 0x100C37C1D: ImageLoaderMachO::doInitialization(ImageLoader::LinkContext const&) (in /usr/lib/dyld) ==12075== by 0x100C334A9: ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) (in /usr/lib/dyld) ==12075== by 0x100C33440: ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) (in /usr/lib/dyld) ==12075== by 0x100C32523: ImageLoader::processInitializers(ImageLoader::LinkContext const&, unsigned int, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) (in /usr/lib/dyld) ==12075== by 0x100C325B8: ImageLoader::runInitializers(ImageLoader::LinkContext const&, ImageLoader::InitializerTimingList&) (in /usr/lib/dyld) ==12075== by 0x100C24433: dyld::initializeMainExecutable() (in /usr/lib/dyld) ==12075== by 0x100C288C5: dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*) (in /usr/lib/dyld) ==12075== by 0x100C23248: dyldbootstrap::start(macho_header const*, int, char const**, long, macho_header const*, unsigned long*) (in /usr/lib/dyld) ==12075== by 0x100C23035: _dyld_start (in /usr/lib/dyld) ==12075== by 0x1: ??? ==12075== by 0x1054AC862: ??? ==12075== by 0x1054AC891: ??? ==12075== Address 0x1054aa98c is on thread 1's stack ==12075== in frame #2, created by task_set_special_port (???:) ==12075== --12075-- UNKNOWN mach_msg unhandled MACH_SEND_TRAILER option --12075-- UNKNOWN mach_msg unhandled MACH_SEND_TRAILER option (repeated 2 times) --12075-- UNKNOWN mach_msg unhandled MACH_SEND_TRAILER option (repeated 4 times) ==12075== ==12075== HEAP SUMMARY: ==12075== in use at exit: 125,805 bytes in 185 blocks ==12075== total heap usage: 14,686 allocs, 14,501 frees, 3,261,322 bytes allocated ==12075== ==12075== LEAK SUMMARY: ==12075== definitely lost: 3 bytes in 1 blocks ==12075== indirectly lost: 0 bytes in 0 blocks ==12075== possibly lost: 72 bytes in 3 blocks ==12075== still reachable: 107,582 bytes in 23 blocks ==12075== suppressed: 18,148 bytes in 158 blocks ==12075== Rerun with --leak-check=full to see details of leaked memory ==12075== ==12075== For counts of detected and suppressed errors, rerun with: -v ==12075== Use --track-origins=yes to see where uninitialised values come from ==12075== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 1 from 1)
definitely lost: 肯定內存泄露 indirectly lost: 非直接內存泄露 possibly lost: 可能發生內存泄露 still reachable: 仍然可訪問的內存 suppressed: 外部造成的內存泄露
Callgrind 配合 php 擴展 xdebug 輸出的 profile 分析日志文件可以分析程序運行期間各個函數調用時占用的內存、 CPU 占用情況。
總結
遇到了內存泄露時先觀察是程序本身內存不足還是外部資源導致,然后搞清楚程序運行中用到了哪些資源:寫入磁盤日志、連接數據庫 SQL 查詢、發送 Curl 請求、 Socket 通信等, I/O 操作必然會用到內存,如果這些地方都沒有發生明顯的內存泄露,檢查哪里處理大量數據沒有及時釋放資源,如果是 php 5.3 以下版本還需考慮循環引用的問題。
多了解一些 Linux 下的分析輔助工具,解決問題時可以事半功倍。
最后宣傳一下穿雲團隊今年最新開源的應用透明鏈路追蹤工具 Molten:https://github.com/chuan-yun/Molten。
安裝好 php 擴展后就能幫你實時收集程序的 curl,pdo,mysqli,redis,mongodb,memcached 等請求的數據,可以很方便的與 zipkin 集成。
參考資料
-
http://php.net/manual/zh/features.gc.php
-
http://www.php-internals.com/book/?p=chapt06/06-07-memory-leaks
-
http://www.programering.com/a/MDN5UjMwATk.html
-
https://stackoverflow.com/questions/20458136/using-valgrind-to-debug-a-php-cli-segmentation-fault
-
http://www.laruence.com/2013/08/14/2899.html
-
https://mengkang.net/873.html
把字符串填充為新的長度