iOS網路效能監控

soyo發表於2018-12-26

現在的Native App平臺化趨勢越來越明顯,網路層架構也越來越複雜。一個App基本都有多個不同的網路模組。 從簡單的業務資料的HTTP/HTTPS(基於NSURLConnection或者NSURLSession),到WebView的WebCore網路層,到基於TCP長連線的推送模組,到各種第三方元件比如統計、日誌上報各自的網路層,或者很多app採用基於TCP的私有協議等等,網路層越來越複雜,對Native開發者來說越來越像一個黑盒模組。 Native開發者只能著眼於業務開發,對網路層的異常、效能等等問題一無所知。

初識iOS網路層API

讓我們剝開網路相關的SDK,一層一層地看每一層做了些什麼。

AFNetworking

AFNetworking是對NSURLConnection/NSURLSession的封裝。增加了如下邏輯

  • 封裝成NSOperation的形式,提供了resume/cancel等等處理。
  • 增加了NSData的檔案處理,上傳/下載
  • 方便處理JSON/XML資料
  • 方便處理HTTPS
  • 有Reachablity的API

NSURLSession/NSURLConnection

NSURLSession/NSURLConnection 都是基於CFNetwork的。

NSURLConnection - CFURLConnection的封裝。提供了create,start,cancel,send(同步或者非同步),設定回撥,設定runloop等函式。

NSURLSession/NSURLSessionTask - NSCFURLSession/NSCFURLSessionTask等等的封裝。

NSURLXXX這一層主要處理了:

  • 把CFNetwork的blockhandler封裝成delegate的方式
  • 處理NSURLProtocol相關的代理
  • 處理NSURLCache的快取相關,是對CFURLCache的封裝
  • 封裝sendAsync/和sendSync方法。
  • 把CFURLResponse的statusCode轉化成String

CFNetwork

CFNetwork 展示瞭如何把位元組流封裝成HTTP協議的請求收發。

[圖片上傳失敗...(image-4f1c4b-1535019216015)]

  • CFURLRequest由使用者建立,裡面包括URL/header/body這些請求的資訊。然後CFURLRequest會被轉換成CFHTTPMessage的格式。
  • CFHTTPMessage裡主要是HTTP協議的定義和轉換,把每一個請求request轉換成標準的HTTP格式的文字。
  • CFURLConnection 裡主要是處理請求任務,包括pthread執行緒、CFRunloop,請求佇列的管理等等。所以提供了start、cancel等等操作的api。也有操作CFReadStream等API
  • CFHost:負責DNS,在有CFHostStartInfoResolution等函式,基於dns_async_startgetaddrinfo_async_start方法。在iOS8/9基於getaddrinfo。主要是同步呼叫和非同步呼叫的區別。
  • CFURLCache/CFURLCredential/CFHTTPCookie:處理快取/證照/cookie相關的邏輯,都有對應的NS類。

主要的資料交換呼叫基於CFStream的API。

CFStream

藉助CFSocketStream,封裝BSD Socket,和SecurityTransport(SSL呼叫)。

由於BSD Socket都是同步呼叫。所以CFStream這一層主要是Runloop邏輯,鎖,dowhile等待等等。 類似BSD socket一樣是資料流輸入/輸出的API。

CFStream 建立時要傳入一堆callback,包括open/close,read/write等等。比如CFSocketStream,封裝了BSD Socket的作為callback傳入CFStream

CFSocketStream 也包括了DNS、SSL連線、Connect握手等等邏輯。

BSD Socket

有多組API,包括connect/shutdown,send/recv,read/write,recvfrom/sendto,recvmsg/sendmsg. 作為客戶端一般不使用accept/bind.

send/recvread/write的區別在於多了一個flags引數。當flag為0時,send等同於write。

對於傳送訊息。send只可用於基於連線的套接字,sendtosendmsg既可用於無連線的socket,也可用於基於連線的socket。除了socket設定為非阻塞模式,呼叫將會阻塞直到資料被髮送完。

DNS方法: getaddrinfo 是對 gethostbyname/gethostbyaddr 的替代,支援了ipv6,返回一個地址struct連結串列。在iOS8/9中使用。 getaddrinfo_async_start 在iOS10中使用,支援了非同步。

監控什麼

iOS-Monitor-Platform 這篇文章提出了一些監控指標(然而他提供的方法並不能監控到)。

  • TCP 建立連線時間
  • DNS 時間
  • SSL 時間
  • 首包時間
  • 響應時間
  • HTTP 錯誤率
  • 網路錯誤率
  • 流量

APM廠聽雲提供的一些監控指標:

  • TOP5 響應時間最慢主機
  • TOP5吞吐率最高主機
  • TOP5 DNS時間最慢地域
  • TCP建連最慢主機
  • 連線次數最多主機

HTTP抓包工具Charles提供的監控指標:

  • Request Start Time
  • Request End Time
  • Response Start Time
  • Response End Time
  • Duration
  • DNS
  • Connect
  • SSL Handshake
  • Latency

當然如果可行的話,想每一個細節都監控到。但是很多資料都有實現成本,本文用最低的成本力求收集儘可能多的指標。

具體實現

HTTP 的監控

HTTP的監控最佳的實踐當然就是利用NSURLSession的NSURLSessionTaskMetrics。

[圖片上傳失敗...(image-7b6c8-1535019216015)]

想探究NSURLSessionTaskMetrics的實現,如果反編譯CFNetwork的原始碼,可以看到-[NSURLSessionTaskMetrics _initWithPerformanceTiming] 這個方法,說明是來自一個叫TimingPerformance的類。 TimingPerformance的初始化方法程式碼如下,可以看到這裡定義了所有NSURLSessionTaskMetrics時間節點需要的key,幾乎完全一致。然後初始化時利用CFAbsoluteTimeGetCurrent函式來記錄初始化的時間。

int __ZN17PerformanceTimingC2Ev() {
    rbx = rdi;
    CFObject::CFObject();
    ...
    *(rbx + 0x20) = @"_kCFNTimingDataRedirectStart";
    *(rbx + 0x30) = @"_kCFNTimingDataRedirectEnd";
    *(rbx + 0x40) = @"_kCFNTimingDataFetchStart";
    *(rbx + 0x50) = @"_kCFNTimingDataDomainLookupStart";
    *(rbx + 0x60) = @"_kCFNTimingDataDomainLookupEnd";
    *(rbx + 0x70) = @"_kCFNTimingDataConnectStart";
    *(rbx + 0x80) = @"_kCFNTimingDataConnectEnd";
    *(rbx + 0x90) = @"_kCFNTimingDataSecureConnectionStart";
    *(rbx + 0xa8) = @"_kCFNTimingDataRequestStart";
    *(rbx + 0xb8) = @"_kCFNTimingDataRequestEnd";
    *(rbx + 0xc8) = @"_kCFNTimingDataResponseStart";
    *(rbx + 0xd8) = @"_kCFNTimingDataResponseEnd";
    *(rbx + 0xe8) = @"_kCFNTimingDataRedirectCountW3C";
    *(rbx + 0xf8) = @"_kCFNTimingDataRedirectCount";
    *(rbx + 0x108) = @"_kCFNTimingDataTaskResumed";
    *(rbx + 0x118) = @"_kCFNTimingDataConnectCreate";
    *(rbx + 0x128) = @"_kCFNTimingDataTCPConnected";
    *(rbx + 0x138) = @"_kCFNTimingDataFirstWrite";
    *(rbx + 0x148) = @"_kCFNTimingDataFirstRead";
    *(rbx + 0x158) = @"_kCFNTimingDataConnectionInit";
    *(rbx + 0x168) = @"_kCFNTimingDataConnected";
    ....
    *(rbx + 0x1f0) = @"_kCFNTimingDataTimingDataInit";
    ...
    CFAbsoluteTimeGetCurrent();
    ....
    return rax;
}
複製程式碼

這個類是怎麼使用的呢,可以看到[NSCFURLSessionTask resume]這個方法裡:

void -[__NSCFURLSessionTask resume](void * self, void * _cmd) {
    rbx = self;
            ...
            __setRecordForKeyInternalPerformanceTiming(@"streamTask-resume");
            r15 = rbx->_performanceTiming;
            if (r15 != 0x0) {
                    PerformanceTiming::Class();
                    xmm0 = intrinsic_movsd(xmm0, *(r15 + 0x110));
                    xmm0 = intrinsic_ucomisd(xmm0, 0x0);
                    if ((xmm0 == 0x0) && (!CPU_FLAGS & P)) {
                            CFAbsoluteTimeGetCurrent();
                            *(r15 + 0x110) = intrinsic_movsd(*(r15 + 0x110), xmm0);
                    }
            }
            __setRecordForKeyInternalPerformanceTiming(@"start-task-resume-to-loader-start-load");
              ...
    return;
}
複製程式碼

可以看到這裡rbx暫存器儲存的就是NSCFURLSessionTask物件,這個物件有一個成員變數就是_performanceTiming,放在r15這個暫存器裡。上面的程式碼可以看到(0x108)對應的就是_kCFNTimingDataTaskResumed這個key,而這裡xmm0暫存器是個浮點數儲存的暫存器,儲存的是(r15 + 0x110),對應應該是,然後判斷xmm0是否為空,如果是空的話,就呼叫CFAbsoluteTimeGetCurrent函式獲取當前CPU時間,然後再賦給(r15 + 0x110),對應的應該就是_kCFNTimingDataTaskResumed這個key對應的value。

至於__setRecordForKeyInternalPerformanceTiming 這個函式,可以看到它的key並不存在於PerformanceTiming物件初始化的時候,它應該是InternalPerformanceTiming,這是個不同的類,可能是PerformanceTiming的子類。他的key是不同的,判斷是這個庫內部使用的,並沒有作為NSURLSessionTaskMetrics傳遞出去。

發現__ZN17PerformanceTiming32fillW3NavigationTimingAWDMetricsEP27PerformanceTimingAWDMetrics,__ZN17PerformanceTiming30fillStreamTaskTimingAWDMetricsEP26StreamTaskTimingAWDMetrics這兩個函式,說明PerformanceTimingW3NavigationTiming,以及StreamTaskTiming這幾個東西的AWDMetrics是可以互相轉化的。W3NavigationTiming很容易想到是用於WebView的Timing的API。

NSURLSessionTaskMetrics的優點是蘋果幫我們實現了,但是有很嚴重的缺點是隻能適用於iOS10以後的NSURLSession。NSURLConnection是用不了的。iOS10以下也是用不了的。 (見後文重大發現)

其它方案的分析

對於iOS10以下的NSURLSession以及NSURLConnection,想要打點統計時間點挺困難的,主要困難點在不同SDK的API呼叫不同,比如iOS8和9的DNS,可以hook到getaddrinfo函式,iOS10有時可以hook到getaddrinfo_async_start函式,但是對於iOS11,我嘗試了各種跟DNS相關的函式,完全hook不到。反編譯CFNetwok出來的跟DNS相關的函式,也都沒有被NSURLSession/NSURLConnection呼叫。 SSL的情況也非常類似,目前只知道iOS8/9會通過SecurityTransport的SSLHandshake/SSLRead/SSLWrite等函式,但是iOS10以上就完全懵逼。這些嘗試只能宣告失敗,告一段落了。

有的文章認為是iOS10之後系統遮蔽了某些BSD Socket函式的hook,比如connect/read/write 等等。 據我觀察並不是這樣,BSD socket還是能夠hook到,只是大部分情況下不呼叫這些API了,少數情況還是有使用的。 如果真的被遮蔽了,應該是完全hook不到的。

有些文章寫了說監控HTTP,可以採用NSURLProtocol攔截請求的方式(比如聽雲)。我都是持懷疑態度的,因為監控效能,如果沒有DNS/SSL相關的監控就失去了大部分意義,而監控request/response的就不需要用hook這種方式了(完全可以在自己封裝的網路層部分實現)。 而針對於NSURL相關API的hook是統計不到DNS/SSL的,因為它們不在這一層實現。

也有些文章說hook CFStream的方式(比如網易APM)。 但是如果看到CFStream的實現就知道CFStream是對BSD Socket的封裝,Open/Close/read/Write 如果看CFSocketStream的原始碼,這些API都還是BSD Socket實現的,就是說hook CFStream 和 BSD socket沒有很大的區別。 還是沒有hook到DNS/SSL的點上。

WebView 的監控

WebView的監控是相對簡單的,主要是Timing API。

[圖片上傳失敗...(image-56e48e-1535019216015)]

好處是相容性很好,目前UIWebView和WKWebView都支援,iOS9以上都支援。因為是瀏覽器的API。

WebCore裡,跟這個timing相關的API主要是PerformanceTiming類:

class PerformanceTiming : public RefCounted<PerformanceTiming>, public DOMWindowProperty {
public:
    static Ref<PerformanceTiming> create(Frame* frame) { return adoptRef(*new PerformanceTiming(frame)); }

    unsigned long long navigationStart() const;
    unsigned long long unloadEventStart() const;
    unsigned long long unloadEventEnd() const;
    unsigned long long redirectStart() const;
    unsigned long long redirectEnd() const;
    unsigned long long fetchStart() const;
    //...省略部分函式
    unsigned long long domContentLoadedEventStart() const;
    unsigned long long domContentLoadedEventEnd() const;
    unsigned long long domComplete() const;
    unsigned long long loadEventStart() const;
    unsigned long long loadEventEnd() const;

private:
    explicit PerformanceTiming(Frame*);
    const DocumentTiming* documentTiming() const;
    DocumentLoader* documentLoader() const;
    LoadTiming* loadTiming() const;
};

} // namespace WebCore
複製程式碼

標頭檔案裡有一堆getter函式的定義,同時初始化方法只有一個,入參是單一的Frame物件,說明一個Frame物件就能夠提供到這些所有的引數。

unsigned long long PerformanceTiming::requestStart() const
{
    DocumentLoader* loader = documentLoader();
    if (!loader)
        return connectEnd();

    const NetworkLoadMetrics& timing = loader->response().deprecatedNetworkLoadMetrics();
    ASSERT(timing.requestStart >= 0_ms);
    return resourceLoadTimeRelativeToFetchStart(timing.requestStart);
}
unsigned long long PerformanceTiming::domInteractive() const
{
   const DocumentTiming* timing = documentTiming();
   if (!timing)
       return 0;
   return monotonicTimeToIntegerMilliseconds(timing->domInteractive);
}
unsigned long long PerformanceTiming::loadEventStart() const
{
    LoadTiming* timing = loadTiming();
    if (!timing)
        return 0;
    return monotonicTimeToIntegerMilliseconds(timing->loadEventStart());
}
複製程式碼

再看cpp檔案就知道,PerformanceTiming是對Frame類中已經統計好的引數的一個封裝,內部並沒有邏輯。資料其實就是來自於NetworkLoadMetricsDocumentTimingLoadTiming 三部分。也很容易理解就是分別對應網路請求相關的效能統計、對應DOM載入相關的和WebView載入相關的效能統計。

有一個細節就是NetworkLoadMetrics裡有0ms的判斷,保證NetworkLoadMetrics返回的相關資料大於0。而DocumentTimingLoadTiming返回的資料為空時就是0。實際上使用這一系列資料時確實會出現一部分引數為0的情況,而且跟呼叫PerformanceTiming的介面有關。

WebCore的類的架構如下圖。 [圖片上傳失敗...(image-449013-1535019216015)] 那WebView裡,網路是怎麼一層層呼叫的呢? 追蹤WKWebView的loadRequest:方法,呼叫棧應該是這樣的:

- (WKNavigation *)loadRequest:(NSURLRequest *)request
void WebPage::loadRequest(const LoadParameters& loadParameters)
void UserInputBridge::loadRequest(FrameLoadRequest&& request, InputSource)
void FrameLoader::load(FrameLoadRequest&& request)
void FrameLoader::load(DocumentLoader* newDocumentLoader)
void FrameLoader::loadWithDocumentLoader(DocumentLoader* loader, FrameLoadType type, FormState* formState, AllowNavigationToInvalidURL allowNavigationToInvalidURL)
void FrameLoader::continueLoadAfterNavigationPolicy(const ResourceRequest& request, FormState* formState, bool shouldContinue, AllowNavigationToInvalidURL allowNavigationToInvalidURL)
void DocumentLoader::startLoadingMainResource()
void ResourceLoader::start()
void ResourceHandle::createNSURLConnection(id delegate, bool shouldUseCredentialStorage, bool shouldContentSniff, SchedulingBehavior, NSDictionary *connectionProperties);
複製程式碼

ResourceLoader是資源載入,而真正操作網路請求的類在ResourceHandle。看程式碼就發現WebCore的網路層在iOS上也是基於NSURLConnection的。可以通過AOP的方式hook到某個位置,然後使用NSURLConnection的API進行操作。

有一處比較有意思,WebCore中實現了一個NSURLSession,叫WebCoreNSURLSession。這個類似乎只在MediaPlayer裡面使用。相同的是他們也有類似的API,比如dataTaskWithRequest:等等,但是內部實現不一樣,WebCoreNSURLSession也是基於ResourceLoader的子類。而NSURLSession是基於CFNetwork。

使用Timing系列的API也有需要注意的細節。WebCore核心在iOS上和在Mac的Safari上是不一樣的。iOS10以後的WKWebView才實現.toJSON()。如果是UIWebView,或者是iOS10以下的WKWebView,需要先執行一段js指令碼,方便我們把js物件轉換為json。

NSString *funcStr = @"function flatten(obj) {"
        "var ret = {}; "
        "for (var i in obj) { "
        "ret[i] = obj[i];"
        "}"
        "return ret;}";
[webView stringByEvaluatingJavaScriptFromString:funcStr];
複製程式碼

TCP的監控

一般App的網路層長連線,會基於TCP實現自定義的協議或者使用Websocket。有的app會基於BSD Socket封裝(比如微信的mars)。有的會先利用一些開源的框架比如 CocoaAsyncSocket 或者 SocketRocket,然後再進行封裝。

CocoaAsyncSocket

CocoaAsyncSocket是基於BSD Socket,CFStream,SecurityTransport的封裝,封裝成TCP/UDP協議。這幾個API的共同之處在於都是資料流讀寫的形式。BSD Socket主要是同步阻塞,而CFStream是非同步的。

既然是資料流讀寫,所以CocoaAsyncSocket肯定是包括資料流的處理和轉換了。主要是緩衝區,ReadBuffer/WriteBuffer,判斷讀取的結尾CRLF, 讀取的長度length和讀取的超時機制等等。 CocoaAsyncSocket也封裝了DNS、ipv4和ipv6、SSL等等邏輯。

SocketRocket

是基於NSStream 的封裝,不同於CocoaAsyncSocket的傳輸層協議, 支援HTTP/WebSocket的應用層協議,定義了header的欄位等等。 由於是基於資料流讀寫的,所以也包括readBuffer/WriteBuffer等資料處理邏輯。 也包括Runloop,執行緒等非同步處理邏輯和阻塞同步邏輯。 還實現了PingPong這樣的,跟服務端配合的保活邏輯。

所以TCP的監控可以hook BSD Socket 的API,包括 connect/disconnect/read/write等等呼叫,如果是同步呼叫,所以可以在執行函式前後埋點計算時間。 也需要hook DNS方面的API,比如 gethostbyname/getaddrinfo等同步呼叫的以及getaddrinfo_async_start等等非同步呼叫的API。 也可以hook SSL 方面的API,比如 SSLHandshake/SSLRead/SSLWrite,實現對SSL連線的監測。

劇情反轉,重大發現 (這一段是後來加的)

HTTP的效能監控因為NSURLSessionTaskMetrics的相容性問題似乎已經窮途末路了,但是在寫第二部分WebView的時候突然有了一個巨大的發現,這個發現來自於在看WebCore的原始碼的時候發現了一些神奇的東西。

#if !HAVE(TIMINGDATAOPTIONS)
void setCollectsTimingData()
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [NSURLConnection _setCollectsTimingData:YES];
        ...
    });
}
#endif
複製程式碼

這是說NSURLConnection本身有一套TimingData的收集API,只是沒有暴露給開發者而已,但是WebCore裡一直在用...蘋果你為啥這麼小氣?! 然後就很輕易地在runtime header裡找到了NSURLConnection的_setCollectsTimingData: API,還有_timingData的API。 這貨iOS8以後都是支援的,iOS8之前也許也支援了。

那麼NSURLSession呢,是不是也類似?果然。在iOS9之前,也只需要設定_setCollectsTimingData:就好了。 搜了一下google和github,我應該是第一個發現這個私有API的人...

所以很神奇地,很輕易地,就實現了NSURLConnection和NSURLSession全套的支援....

總結

我們幾乎可以用很少的程式碼實現HTTP/WebView/TCP跨框架的大部分網路效能資料收集。如果把相容性整理成一張表的話可以看到我們幾乎支援了大部分的場景。

iOS SDK NSURLConnetion NSURLSession UIWebView WKWebView TCP
8.4 YES YES via TCP via TCP YES
9.3 YES YES YES YES YES
10.3 YES YES YES YES YES
11.3 YES YES YES YES YES

NetworkTracker 是我封裝的一部分程式碼。並將監控結果簡單地畫了個圖表。還是比較直觀的。

Group.png

相關文章