file_get_content

linuxMr發表於2019-05-15

1.問題描述

前幾周在做微信需求開發的時候一個功能需要拉取微信使用者頭像,使用了file_get_contents。但是發現拉取非常緩慢,網上查詢資料說使用curl即可解決,試了一下確實如此。

但是為何造成這種差異,網上資料解釋也五花八門,什麼HTTP頭不一樣、DNS快取造成的........之類。

2.抓包

為了更深入瞭解這種差異的原因,我特意編譯了一個帶debug符號的php和libcurl方便必要的時候進行原始碼級別除錯。這裡我先用Wireshark抓包,看file_get_contents和curl在TCP流程上是否存在差異。

file_get_contents復現測試程式碼

$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);

file_get_contents TCP抓包結果

curl TCP抓包結果

TCP的握手、傳輸、關閉流程這裡就不概述,可以參考其它文章瞭解。從抓包結果可以看出,握手、傳輸流程差異不大,但是到了關閉就有了很大差異。file_get_contents最後由微信服務端關閉(80 -> 本地埠),並且在等待對方關閉後回覆的FIN ACK包耗費了大量時間,可以從Time那一欄看出。而curl最後由自己主動關閉而非等待微信服務端關閉(本地埠 -> 80),所以可以看到整個流程耗時非常短,這也是造成了使用file_get_contents和curl分別拉取微信頭像造成這麼大耗時差異的原因。

從file_get_contents抓包結果裡面看到,微信服務端隔了一段時間才關閉並回復FIN ACK,那麼是不是由於file_get_content的HTTP頭帶的有Connect:keep-alive,而造成對方維持了一段時間長連線,然後我從抓包結果裡面篩出file_get_content的HTTP請求頭內容。

file_get_contents請求頭

可以看出,這裡的設定並不是Connect:keep-alive,所以未要求服務端維持長連線,但是微信服務端在返回完所有資料後過了段時間才呼叫close並返回FIN ACK包進入關閉狀態,整體表現非常像設定了Connect:keep-alive,但因為這是微信的服務端我不能進一步追蹤,所以只能猜測可能是微信自家的服務端對HTTP協議支援不完全造成的或者這是有什麼其它妙用。

上面的線索斷了後,大體知道了問題所在,因為file_get_contents在等待對方服務端呼叫close回覆的FIN ACK上耗費了大量時間,所以造成拉取緩慢。但curl因為是主動呼叫close所以直接進入了關閉狀態。

3.file_get_contents除錯

但是,為什麼curl就能正常處理這種情況?file_get_contents就會發生這種情況。所以我後面從原始碼入手跟蹤雙方在資料傳輸及連線關閉的流程上有什麼差異。

file_get_contents的實現在php原始碼目錄ext/standard/file.c的521行,主要流程如下圖

\

file_get_content主要流程

php_stream_open_wrapper_ex找到對應的協議實現,進行設定並開啟。我們這裡是http協議,內部協議相關實現會找到域名對應IP地址、建立socket連線、建立HTTP請求頭並通過socket傳送等常規操作。

而我們比較關心的傳輸部分則是在php_stream_copy_to_mem(PHP原始碼目錄/main/streams/stream.c 1393行)這個呼叫裡,而我們比較關心的傳輸部分的核心邏輯如下圖

\

從服務端讀取資料流程

可以看到整體邏輯是先分配一個php的string型別當緩衝區,不斷呼叫php_stream_read直到沒有資料為止。當緩衝區大小不夠時,會擴容緩衝區,最後返回到php應用層就是php經常用的字串了。

而php_stream_read封裝的最終呼叫核心邏輯如下圖(PHP原始碼目錄/main/streams/xp_socket.c 153行)。

可以看到在呼叫socket的recv之前,先呼叫了php_sock_stream_wait_for_data(PHP原始碼/main/streams/xp_socket.c 121行)等待資料,而除錯跟蹤過程中也發現是會在這裡阻塞一段時間,然後這個函式的最終呼叫是poll。最後看呼叫poll時監控了哪些事件。

\

PHP_POLLREADABLE的定義如下

#define PHP_POLLREADABLE    (POLLIN|POLLERR|POLLHUP)

其中POLLHUP是在關閉時觸發的事件,到此file_get_contents的資料讀取流程已經理順了。

整體流程可以概括為不斷呼叫recv獲取資料,直到recv返回0(連線已經有序的關閉了)或者小於0為止,因為recv是非阻塞呼叫(傳入了引數MSG_DONTWAIT),所以在呼叫recv之前會呼叫poll並阻塞到有監控的事件發生的時候在返回。

因為實現是依靠不斷呼叫recv,並靠它的返回值來判斷是否讀完了,所以在實際過程中,當不斷呼叫poll + recv獲取到所有http響應的資料後,因為TCP連線沒有立即關閉,而且這個時候對方沒有在傳送資料,所以再次呼叫poll時會阻塞等待監聽的事件發生,而微信服務端會隔一段時間在關閉並回復FIN ACK。所以poll在阻塞一段時間後,收到了這個回覆的FIN ACK,再次呼叫recv,返回0,最後關閉這個連線,整個流程結束。所以file_get_contents的整個耗時都是被阻塞在等待這個對端關閉的FIN ACK回覆上。

4.CURL除錯

那麼curl為什麼沒有這個問題?所以我馬上又開始除錯curl的這個流程,curl的主要流程處理是在CURL原始碼目錄下/lib/multi.c 1288行的multi_runsingle方法,這是個長達800多行的if + swtich組合的判斷邏輯(第一次調到這裡簡直懵逼了好嗎!!),這個方法通過迴圈不斷改變和處理連線的狀態直到完成,涉及的狀態如下圖定義(CURL目錄/lib/multihandle.h 36行)。

\

CURL連線狀態

\

對應的英文註釋應該能很好解釋含義,這裡我們只關注一個狀態CURLM_STATE_PERFORM,這個是之前請求的狀態已經處理完了,可以開始讀資料了。

處理這個狀態的邏輯在CURL原始碼目錄下/lib/multi.c 1857行,需要注意的是,整個這個迴圈邏輯都要通過修改一個done變數來指示是否已經全部完成了,所以我們只要觀察這個done變數什麼時候會修改為true即可找到CURL對讀完的處理是怎樣判斷的。

可以看到這個邏輯把done變數的記憶體地址傳給了Curl_readwrite呼叫,那麼可以肯定這個呼叫內部會修改這個變數的狀態,然後跟蹤到這個方法內部(CURL原始碼目錄/lib/transfer.c 1238行)看這個方法在什麼條件下會把這個done變數賦值為true。

\

\

這裡可以看到當連線沒有KEEP_RECV等標誌時就判斷為完成,KEEP_RECV標誌代表是否還可以讀取,那麼我們找到這個標誌什麼時候被取消的,就知道CURL是如何判斷讀完了。

完成的讀取流程也是由這個方法內部的1125行呼叫完成讀取的。

\

這個readwrite_data方法實現是一個迴圈(CURL原始碼目錄/lib/transfer.c 482行)不斷呼叫Curl_read(最終呼叫recv)獲取資料然後解析。

\

在除錯過程中,發現在726行的邏輯處理中判斷了是否讀完。

\

可以看到這個判斷的條件是,如果k(struct SingleRequest)裡的maxdownload不是-1,並且當前已讀數量 + 前面呼叫Curl_read讀到的資料大小如果大於=maxdownload,則在最後取消掉KEPP_RECV標識。

那麼maxdownload又是在哪設定的?在隨後的除錯中發現在該方法內部的539行呼叫瞭解析HTTP頭的方法。

\

隨後除錯到Curl_http_readwrite_headers方法實現(CURL目錄/lib/http.c 3010行)的3580行。

\

\

我們可以看到,這個maxdownload(讀取內容上限)是來自於HTTP頭的Content-Length欄位。

所以CURL之所以沒有發生file_get_contents那樣的情況,就是因為它讀完Content-Length大小後就關閉連線了。

5.總結

通過抓包除錯,我們知道了首先是微信伺服器在返回完HTTP響應後並不會馬上關閉,而且HTTP頭的設定並不是Connect:keep-alive而是Connect:close,所以並不要求服務端維護一段時間長連線,因為file_get_contents的實現是通過不斷迴圈呼叫socket的recv方法的返回值來判斷是否讀完所以導致了file_get_contents在微信服務端連線未關閉的時候會一直阻塞等待最後一個關閉回覆的FIN ACK包,這就導致了file_get_contents獲取微信頭像會耗時長的原因。

而CURL則是優先按照HTTP響應頭的Content-Length大小來讀,並不像filet_get_contents是不斷迴圈呼叫socket的recv,然後靠recv返回值來判斷是否讀完,CURL則是讀完Content-Length個位元組後馬上主動關閉連線,所以就不存在等待對端連線關閉了。

\
\
作者:lambdacalculus\
連結:https://www.jianshu.com/p/42e0c4304b60\
來源:簡書\
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。