1.問題描述
前幾周在做微信需求開發(fā)的時候一個功能需要拉取微信用戶頭像,使用了file_get_contents。但是發(fā)現(xiàn)拉取非常緩慢,網(wǎng)上查詢資料說使用curl即可解決,試了一下確實如此。
但是為何造成這種差異,網(wǎng)上資料解釋也五花八門,什么HTTP頭不一樣、DNS緩存造成的........之類。
2.抓包
為了更深入了解這種差異的原因,我特意編譯了一個帶debug符號的php和libcurl方便必要的時候進行源碼級別調(diào)試。這里我先用Wireshark抓包,看file_get_contents和curl在TCP流程上是否存在差異。
file_get_contents復現(xiàn)測試代碼
$url = "http://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0";
$data = file_get_contents($url);
curl測試代碼
$url = "http://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);


TCP的握手、傳輸、關閉流程這里就不概述,可以參考其它文章了解。從抓包結(jié)果可以看出,握手、傳輸流程差異不大,但是到了關閉就有了很大差異。file_get_contents最后由微信服務端關閉(80 -> 本地端口),并且在等待對方關閉后回復的FIN ACK包耗費了大量時間,可以從Time那一欄看出。而curl最后由自己主動關閉而非等待微信服務端關閉(本地端口 -> 80),所以可以看到整個流程耗時非常短,這也是造成了使用file_get_contents和curl分別拉取微信頭像造成這么大耗時差異的原因。
從file_get_contents抓包結(jié)果里面看到,微信服務端隔了一段時間才關閉并回復FIN ACK,那么是不是由于file_get_content的HTTP頭帶的有Connect:keep-alive,而造成對方維持了一段時間長連接,然后我從抓包結(jié)果里面篩出file_get_content的HTTP請求頭內(nèi)容。

可以看出,這里的設置并不是Connect:keep-alive,所以未要求服務端維持長連接,但是微信服務端在返回完所有數(shù)據(jù)后過了段時間才調(diào)用close并返回FIN ACK包進入關閉狀態(tài),整體表現(xiàn)非常像設置了Connect:keep-alive,但因為這是微信的服務端我不能進一步追蹤,所以只能猜測可能是微信自家的服務端對HTTP協(xié)議支持不完全造成的或者這是有什么其它妙用。
上面的線索斷了后,大體知道了問題所在,因為file_get_contents在等待對方服務端調(diào)用close回復的FIN ACK上耗費了大量時間,所以造成拉取緩慢。但curl因為是主動調(diào)用close所以直接進入了關閉狀態(tài)。
3.file_get_contents調(diào)試
但是,為什么curl就能正常處理這種情況?file_get_contents就會發(fā)生這種情況。所以我后面從源代碼入手跟蹤雙方在數(shù)據(jù)傳輸及連接關閉的流程上有什么差異。
file_get_contents的實現(xiàn)在php源代碼目錄ext/standard/file.c的521行,主要流程如下圖

php_stream_open_wrapper_ex找到對應的協(xié)議實現(xiàn),進行設置并打開。我們這里是http協(xié)議,內(nèi)部協(xié)議相關實現(xiàn)會找到域名對應IP地址、創(chuàng)建socket連接、創(chuàng)建HTTP請求頭并通過socket發(fā)送等常規(guī)操作。
而我們比較關心的傳輸部分則是在php_stream_copy_to_mem(PHP源碼目錄/main/streams/stream.c 1393行)這個調(diào)用里,而我們比較關心的傳輸部分的核心邏輯如下圖

可以看到整體邏輯是先分配一個php的string類型當緩沖區(qū),不斷調(diào)用php_stream_read直到?jīng)]有數(shù)據(jù)為止。當緩沖區(qū)大小不夠時,會擴容緩沖區(qū),最后返回到php應用層就是php經(jīng)常用的字符串了。
而php_stream_read封裝的最終調(diào)用核心邏輯如下圖(PHP源碼目錄/main/streams/xp_socket.c 153行)。

可以看到在調(diào)用socket的recv之前,先調(diào)用了php_sock_stream_wait_for_data(PHP源代碼/main/streams/xp_socket.c 121行)等待數(shù)據(jù),而調(diào)試跟蹤過程中也發(fā)現(xiàn)是會在這里阻塞一段時間,然后這個函數(shù)的最終調(diào)用是poll。最后看調(diào)用poll時監(jiān)控了哪些事件。

PHP_POLLREADABLE的定義如下
#define PHP_POLLREADABLE (POLLIN|POLLERR|POLLHUP)
其中POLLHUP是在關閉時觸發(fā)的事件,到此file_get_contents的數(shù)據(jù)讀取流程已經(jīng)理順了。
整體流程可以概括為不斷調(diào)用recv獲取數(shù)據(jù),直到recv返回0(連接已經(jīng)有序的關閉了)或者小于0為止,因為recv是非阻塞調(diào)用(傳入了參數(shù)MSG_DONTWAIT),所以在調(diào)用recv之前會調(diào)用poll并阻塞到有監(jiān)控的事件發(fā)生的時候在返回。
因為實現(xiàn)是依靠不斷調(diào)用recv,并靠它的返回值來判斷是否讀完了,所以在實際過程中,當不斷調(diào)用poll + recv獲取到所有http響應的數(shù)據(jù)后,因為TCP連接沒有立即關閉,而且這個時候?qū)Ψ經(jīng)]有在發(fā)送數(shù)據(jù),所以再次調(diào)用poll時會阻塞等待監(jiān)聽的事件發(fā)生,而微信服務端會隔一段時間在關閉并回復FIN ACK。所以poll在阻塞一段時間后,收到了這個回復的FIN ACK,再次調(diào)用recv,返回0,最后關閉這個連接,整個流程結(jié)束。所以file_get_contents的整個耗時都是被阻塞在等待這個對端關閉的FIN ACK回復上。
4.CURL調(diào)試
那么curl為什么沒有這個問題?所以我馬上又開始調(diào)試curl的這個流程,curl的主要流程處理是在CURL源碼目錄下/lib/multi.c 1288行的multi_runsingle方法,這是個長達800多行的if + swtich組合的判斷邏輯(第一次調(diào)到這里簡直懵逼了好嗎!?。?,這個方法通過循環(huán)不斷改變和處理連接的狀態(tài)直到完成,涉及的狀態(tài)如下圖定義(CURL目錄/lib/multihandle.h 36行)。

對應的英文注釋應該能很好解釋含義,這里我們只關注一個狀態(tài)CURLM_STATE_PERFORM,這個是之前請求的狀態(tài)已經(jīng)處理完了,可以開始讀數(shù)據(jù)了。
處理這個狀態(tài)的邏輯在CURL源碼目錄下/lib/multi.c 1857行,需要注意的是,整個這個循環(huán)邏輯都要通過修改一個done變量來指示是否已經(jīng)全部完成了,所以我們只要觀察這個done變量什么時候會修改為true即可找到CURL對讀完的處理是怎樣判斷的。

可以看到這個邏輯把done變量的內(nèi)存地址傳給了Curl_readwrite調(diào)用,那么可以肯定這個調(diào)用內(nèi)部會修改這個變量的狀態(tài),然后跟蹤到這個方法內(nèi)部(CURL源碼目錄/lib/transfer.c 1238行)看這個方法在什么條件下會把這個done變量賦值為true。

這里可以看到當連接沒有KEEP_RECV等標志時就判斷為完成,KEEP_RECV標志代表是否還可以讀取,那么我們找到這個標志什么時候被取消的,就知道CURL是如何判斷讀完了。
完成的讀取流程也是由這個方法內(nèi)部的1125行調(diào)用完成讀取的。

這個readwrite_data方法實現(xiàn)是一個循環(huán)(CURL源代碼目錄/lib/transfer.c 482行)不斷調(diào)用Curl_read(最終調(diào)用recv)獲取數(shù)據(jù)然后解析。

在調(diào)試過程中,發(fā)現(xiàn)在726行的邏輯處理中判斷了是否讀完。

可以看到這個判斷的條件是,如果k(struct SingleRequest)里的maxdownload不是-1,并且當前已讀數(shù)量 + 前面調(diào)用Curl_read讀到的數(shù)據(jù)大小如果大于=maxdownload,則在最后取消掉KEPP_RECV標識。
那么maxdownload又是在哪設置的?在隨后的調(diào)試中發(fā)現(xiàn)在該方法內(nèi)部的539行調(diào)用了解析HTTP頭的方法。

隨后調(diào)試到Curl_http_readwrite_headers方法實現(xiàn)(CURL目錄/lib/http.c 3010行)的3580行。

我們可以看到,這個maxdownload(讀取內(nèi)容上限)是來自于HTTP頭的Content-Length字段。
所以CURL之所以沒有發(fā)生file_get_contents那樣的情況,就是因為它讀完Content-Length大小后就關閉連接了。
5.總結(jié)
通過抓包調(diào)試,我們知道了首先是微信服務器在返回完HTTP響應后并不會馬上關閉,而且HTTP頭的設置并不是Connect:keep-alive而是Connect:close,所以并不要求服務端維護一段時間長連接,因為file_get_contents的實現(xiàn)是通過不斷循環(huán)調(diào)用socket的recv方法的返回值來判斷是否讀完所以導致了file_get_contents在微信服務端連接未關閉的時候會一直阻塞等待最后一個關閉回復的FIN ACK包,這就導致了file_get_contents獲取微信頭像會耗時長的原因。
而CURL則是優(yōu)先按照HTTP響應頭的Content-Length大小來讀,并不像filet_get_contents是不斷循環(huán)調(diào)用socket的recv,然后靠recv返回值來判斷是否讀完,CURL則是讀完Content-Length個字節(jié)后馬上主動關閉連接,所以就不存在等待對端連接關閉了。
by zhiyanglee | email:zhiyanglee@foxmail.com