幾個 iOS 端底層網路問題

杭城小劉 發表於 2022-03-29
iOS

典型案例

1. Socket 斷開後會收到 SIGPIPE 型別的訊號,如果不處理會 crash

同事問了我一個問題,說收到一個 crash 資訊,去 mpaas 平臺看到如下的 crash 資訊

2021-04-06-NetworkFatlError.png

看了程式碼,顯示在某某檔案的313行程式碼,程式碼如下

2021-04-06-NetworkFatlError.png

Socket 屬於網路最底層的實現,一般我們開發不需要用到,但是用到了就需要小心翼翼,比如 Hook 網路層、長連結等。檢視官方文件會說看到一些說明。

當使用 socket 進行網路連線時,如果連線中斷,在預設情況下, 程式會收到一個 SIGPIPE 訊號。如果你沒有處理這個訊號,app 會 crash。

Mach 已經通過異常機制提供了底層的陷進處理,而 BSD 則在異常機制之上構建了訊號處理機制。硬體產生的訊號被 Mach 層捕捉,然後轉換為對應的 UNIX 訊號,為了維護一個統一的機制,作業系統和使用者產生的訊號首先被轉換為 Mach 異常,然後再轉換為訊號。

Mach 異常都在 host 層被 ux_exception 轉換為相應的 unix 訊號,並通過 threadsignal 將訊號投遞到出錯的執行緒。

Mach 異常處理以及轉換為 Unix 訊號的流程

有2種解決辦法:

  • Ignore the signal globally with the following line of code.(在全域性範圍內忽略這個訊號 。缺點是所有的 SIGPIPE 訊號都將被忽略)

    signal(SIGPIPE, SIG_IGN);
  • Tell the socket not to send the signal in the first place with the following lines of code (substituting the variable containing your socket in place of sock)(告訴 socket 不要傳送訊號:SO_NOSIGPIPE)

    int value = 1;
    setsockopt(sock, SOL_SOCKET, SO_NOSIGPIPE, &value, sizeof(value));

SO_NOSIGPIPE 是一個巨集定義,跳過去看一下實現

#define SO_NOSIGPIPE  0x1022     /* APPLE: No SIGPIPE on EPIPE */

什麼意思呢?沒有 SIGPIPE 訊號在 EPIPE。那啥是 EPIPE

其中:EPIPE 是 socket send 函式可能返回的錯誤碼之一。如果傳送資料的話會在 Client 端觸發 RST(指Client端的 FIN_WAIT_2 狀態超時後連線已經銷燬的情況),導致send操作返回 EPIPE(errno 32)錯誤,並觸發 SIGPIPE 訊號(預設行為是 Terminate)。

What happens if the client ignores the error return from readline and writes more data to the server? This can happen, for example, if the client needs to perform two writes to the server before reading anything back, with the first write eliciting the RST.

The rule that applies is: When a process writes to a socket that has received an RST, the SIGPIPE signal is sent to the process. The default action of this signal is to terminate the process, so the process must catch the signal to avoid being involuntarily terminated.

If the process either catches the signal and returns from the signal handler, or ignores the signal, the write operation returns EPIPE.

UNP(unix network program) 建議應用根據需要處理 SIGPIPE訊號,至少不要用系統預設的處理方式處理這個訊號,系統預設的處理方式是退出程式,這樣你的應用就很難查處處理程式為什麼退出。對 UNP 感興趣的可以檢視:http://www.unpbook.com/unpv13...

下面是2個蘋果官方文件,描述了 socket 和 SIGPIPE 訊號,以及最佳實踐:

Avoiding Common Networking Mistakes

Using Sockets and Socket Streams

但是線上的程式碼還是存在 Crash。查了下程式碼,發現奔潰堆疊在 PingFoundation 中的 sendPingWithData。也就是雖然在 AppDelegate 中設定忽略了 SIGPIPE 訊號,但是還是會在某些函式下「重置」掉。

- (void)sendPingWithData:(NSData *)data {
    int                     err;
    NSData *                payload;
    NSData *                packet;
    ssize_t                 bytesSent;
    id<PingFoundationDelegate>  strongDelegate;
    // ...
    // Send the packet.
    if (self.socket == NULL) {
        bytesSent = -1;
        err = EBADF;
    } else if (!CFSocketIsValid(self.socket)) {
        //Returns a Boolean value that indicates whether a CFSocket object is valid and able to send or receive messages.
        bytesSent = -1;
        err = EPIPE;
    } else {
        [self ignoreSIGPIPE];
        bytesSent = sendto(
                           CFSocketGetNative(self.socket),
                           packet.bytes,
                           packet.length,
                           SO_NOSIGPIPE,
                           self.hostAddress.bytes,
                           (socklen_t) self.hostAddress.length
                           );
        err = 0;
        if (bytesSent < 0) {
            err = errno;
        }
    }
    // ...
}

- (void)ignoreSIGPIPE {
    int value = 1;
    setsockopt(CFSocketGetNative(self.socket), SOL_SOCKET, SO_NOSIGPIPE, &value, sizeof(value));
}

- (void)dealloc {
    [self stop];
}

- (void)stop {
    [self stopHostResolution];
    [self stopSocket];

    // Junk the host address on stop.  If the client calls -start again, we'll 
    // re-resolve the host name.
    self.hostAddress = NULL;
}

也就是說在呼叫 sendto() 的時候需要判斷下,呼叫 CFSocketIsValid 判斷當前通道的質量。該函式返回當前 Socket 物件是否有效且可以傳送或者接收訊息。之
前的判斷是,當 self.socket 物件不為 NULL,則直接傳送訊息。但是有種情況就是 Socket 物件不為空,但是通道不可用,這時候會 Crash。

Returns a Boolean value that indicates whether a CFSocket object is valid and able to send or receive messages.
if (self.socket == NULL) {
    bytesSent = -1;
    err = EBADF;
} else {
    [self ignoreSIGPIPE];
    bytesSent = sendto(
                        CFSocketGetNative(self.socket),
                        packet.bytes,
                        packet.length,
                        SO_NOSIGPIPE,
                        self.hostAddress.bytes,
                        (socklen_t) self.hostAddress.length
                        );
    err = 0;
    if (bytesSent < 0) {
        err = errno;
    }
}   

2. 裝置無可用空間問題

裝置無可用空間問題


最早遇到這個問題,直觀的判斷是某個介面所在的伺服器機器,出現了儲存問題(因為查了程式碼是網路回撥存在 Error 的時候會呼叫我們公司基礎),因為不是穩定必現,所以也就沒怎麼重視。直到後來發現線上有商家反饋這個問題最近經常出現。經過排查該問題該問題 Error Domain=NSPOSIXErrorDomain Code=28 "No space left on device" 是系統報出來的,開啟 Instrucments Network 皮膚後看到顯示 Session 過多。為了將問題復現,定時器去觸發“切店”邏輯,切店則會觸發首頁所需的各個網路請求,則可以復現問題。工程中查詢 NSURLSession 建立的程式碼,將問題定位到某幾個底層庫,HOOK 網路監控的能力上。一個是 APM 網路監控,確定 APMM 網路監控 Session 建立是收斂的,另一個庫是動態域名替換的庫,之前出現過線上故障。所以思考之下,暫時將這個庫釋出熱修程式碼。之前是採用“悲觀策略”,99%的概率不會出現故障,然後犧牲線上每個網路的效能,增加一道流程,而且該流程的實現還存在問題。思考之下,採用樂觀策略,假設線上大概率不會出現故障,保留2個方法。線上出現故障,馬上釋出熱修,呼叫下面的方法。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    return NO;
}

//下面程式碼保留著,以防熱修復使用
+ (BOOL)open_canInitWithRequest:(NSURLRequest *)request {
    // 代理網路請求
} 

問題臨時解決後,後續動態域名替換的庫可以參考 WeexSDK 的實現。見 WXResourceRequestHandlerDefaultImpl.m。WeexSDK 這個程式碼實現考慮到了多個網路監聽物件的問題、且考慮到了 Session 建立多個的問題,是一個合理解法。

- (void)sendRequest:(WXResourceRequest *)request withDelegate:(id<WXResourceRequestDelegate>)delegate
{
    if (!_session) {
        NSURLSessionConfiguration *urlSessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
        if ([WXAppConfiguration customizeProtocolClasses].count > 0) {
            NSArray *defaultProtocols = urlSessionConfig.protocolClasses;
            urlSessionConfig.protocolClasses = [[WXAppConfiguration customizeProtocolClasses] arrayByAddingObjectsFromArray:defaultProtocols];
        }
        _session = [NSURLSession sessionWithConfiguration:urlSessionConfig
                                                 delegate:self
                                            delegateQueue:[NSOperationQueue mainQueue]];
        _delegates = [WXThreadSafeMutableDictionary new];
    }
    
    NSURLSessionDataTask *task = [_session dataTaskWithRequest:request];
    request.taskIdentifier = task;
    [_delegates setObject:delegate forKey:task];
    [task resume];
}