【網路程式設計開發系列】一種網路程式設計中的另類記憶體洩漏

語言: CN / TW / HK

開啟掘金成長之旅!這是我參與「掘金日新計劃 · 12 月更文挑戰」的第 11 天,點選檢視活動詳情


作者簡介

架構師李肯全網同名,一個專注於嵌入式IoT領域的架構師。有著近10年的嵌入式一線開發經驗,深耕IoT領域多年,熟知IoT領域的業務發展,深度掌握IoT領域的相關技術棧,包括但不限於主流RTOS核心的實現及其移植、硬體驅動移植開發、網路通訊協議開發、編譯構建原理及其實現、底層彙編及編譯原理、編譯優化及程式碼重構、主流IoT雲平臺的對接、嵌入式IoT系統的架構設計等等。擁有多項IoT領域的發明專利,熱衷於技術分享,有多年撰寫技術部落格的經驗積累,連續多月獲得RT-Thread官方技術社群原創技術博文優秀獎,榮獲CSDN部落格專家CSDN物聯網領域優質創作者2021年度CSDN&RT-Thread技術社群之星2022年RT-Thread全球技術大會講師RT-Thread官方嵌入式開源社群認證專家RT-Thread 2021年度論壇之星TOP4華為云云享專家(嵌入式物聯網架構設計師)等榮譽。堅信【知識改變命運,技術改變世界】!


1 寫在前面

最近我在排查一個網路通訊的壓測問題,最後發現跟 “記憶體洩漏” 扯上了關係,但這跟常規理解的記憶體洩漏有那麼一點點不同,本文將帶你瞭解問題的始與末。

面對這樣的記憶體洩漏問題,本文也提供了一些常規的分析方法和解決思路,僅供大家參考,歡迎大家指正問題。

2 問題描述

我們直接看下測試提供的issue描述:

image-20220227235914352

簡單來說,就是裝置再執行【斷網掉線-》重新聯網線上】若干次之後,發現無法再次成功聯網,且一直無法成功,直到裝置重啟後,恢復正常。

3 場景復現

3.1 搭建壓測環境

由於測試部有專門的測試環境,但是我又不想整他們那一套,麻煩著,還得整一個測試手機。

他們的測試方法是使用手機熱點做AP,然後裝置連線這個AP,之後在手機跑指令碼動態開關Wi-Fi熱點,達到讓裝置掉網再恢復網路的測試目的。

有了這個思路後,我想著我手上正好有一個 360Wi-Fi此處無廣告費),不就恰好可以實現無線熱點嗎?只要能實現在PC上動態切換這個360Wi-Fi熱點開關,不就可以實現一樣的測試目的嗎?

具備以上物理條件之後,我開始找尋找這樣的指令碼。

要說在Linux下,寫個這樣的指令碼,真不是啥難事,不過,要是在Windows下寫個BAT指令碼,還真找找才知道。

費了一會勁,在網上找到了一個還算不錯的BAT指令碼,經過我修改後,長以下這樣,主要的功能就是定時開關網路介面卡。

@echo off  ​  :: Config your interval time (seconds)  set disable_interval_time=5  set enable_interval_time=15  ​  :: Config your loop times: enable->disable->enable->disable...  set loop_time=10000  ​  :: Config your network adapter list  SET adapter_num=1  SET adapter[0].name=WLAN  ::SET adapter[0].name=屑薪鈺犘も晲協  ::SET adapter[1].name=屑薪鈺犘も晲協 2  ​  :::::::::::::::::::::::::::::::::::::::::::::::::::::::  ​  echo Loop to switch network adapter state with interval time %interval_time% seconds  ​  set loop_index=0  ​  :LoopStart  ​  if %loop_index% EQU %loop_time% goto :LoopStop  ​  :: Set enable or disable operation  set /A cnt=%loop_index% + 1  set /A result=cnt%%2  if %result% equ 0 (  set operation=enabled  set interval_time=%enable_interval_time%  ) else (  set operation=disable  set interval_time=%disable_interval_time%  )  echo [%date:~0,10% %time:~0,2%:%time:~3,2%:%time:~6,2%] loop time ... %cnt% ... %operation%  ​  set adapter_index=0  :AdapterStart  if %adapter_index% EQU %adapter_num% goto :AdapterStop  set adapter_cur.name=0  ​  for /F "usebackq delims==. tokens=1-3" %%I in (`set adapter[%adapter_index%]`) do (   set adapter_cur.%%J=%%K  )  ​  :: swtich adapter state  call:adapter_switch "%adapter_cur.name%" %operation%  ​  set /A adapter_index=%adapter_index% + 1  ​  goto AdapterStart  ​  :AdapterStop  ​  set /A loop_index=%loop_index% + 1  ​  echo [%date:~0,10% %time:~0,2%:%time:~3,2%:%time:~6,2%] sleep some time (%interval_time% seconds) ...  ping -n %interval_time% 127.0.0.1 > nul  ​  goto LoopStart  ​  :LoopStop  ​  echo End of loop ...  ​  pause  goto:eof  ​  :: function definition  :adapter_switch  set cmd=netsh interface set interface %1 %2  echo %cmd%  %cmd%  goto:eof

注意:這個地方填的是發射AP熱點的網路介面卡,比如如下的。如果是中文的名稱,還必須注意BAT指令碼的編碼問題,否則會出現識別不到正確的網路介面卡名稱。

image-20220228220658303

image-20220228220637441

3.2 壓測問題說明

同時,為了精準定位掉網恢復的問題,我在網路掉線重連的地方增加了三個變數,分別記錄總的重連次數、重連成功的次數、重連失敗的次數。

另一方面,如issue描述所說,這是一個固定次數強相關的問題,也可能跟執行時長聯絡緊密的一個問題,且重啟之後一切恢復正常,這一系列的特徵,都把問題導向一個很常見的問題:記憶體洩漏

於是,在壓測前,我在每次重連之後(不管成功與否)重新列印了系統的記憶體情況(總剩餘記憶體,歷史最低剩餘記憶體),以便於判斷問題節點的記憶體情況。

通過調整壓測指令碼中的disable_interval_time和enable_interval_time引數,在比較短的時間內就復現了問題,的確如果issue描述那樣,在30多次之後,無法重連成功,且重啟即可恢復。

4 問題分析

大部分的問題,只要有復現路勁,都還比較好查,只不過需要花點時間,專研下。

4.1 簡單分析

首先肯定是我們懷疑最大可能的記憶體洩漏資訊,初步一看:

image-20220228222007710

由於在斷網重連的操作中,可能對應的時間點下Wi-Fi熱點還處於關閉狀態,所以肯定是會重連失敗的,當出現Wi-Fi熱點的時候是可以成功的,所以我們會看到free空閒的記憶體在一個範圍內波動,並沒有看到它有穩定下降的趨勢。

倒是和這個evmin(最低空閒記憶體)值,在出現問題之後,它出現了一個固定值,並一直持續下去,從這一點上懷疑,這個記憶體肯定是有問題的,只不過我在第一次分析這個情況的時候並沒有下這個結論,現在回過頭來看這是一個警惕訊號。

我當時推測的點(想要驗證的點)是,出現問題的時候,是不是因為記憶體洩漏導致系統空閒記憶體不足了,進而無法完成新的連線熱點,連線網路等耗記憶體操作。

所以,通過上面的記憶體表,我基本篤定了我的結論:沒有明顯的記憶體洩漏跡象,並不是因記憶體不足而重連不上

問題分析到這裡,肯定不能停下來,但是原廠的SDK,比如連熱點那塊的邏輯,對我們來說是個黑盒子,只能從原廠那裡諮詢看能不能取得什麼有效的資訊。

一圈問下來,拿到的有效資訊基本是0,所以自己的問題還得靠自己!

4.2 尋找突破口

在上面的問題場景中,我們已排除掉了記憶體不足的可能性,那麼接下來我們重點應分析三個方面:

  • 裝置最後有沒有成功連上Wi-Fi熱點?能夠正常分配子網的IP地址?
  • 裝置成功連上Wi-Fi熱點後,對外的網路是否正常?
  • 裝置對外網路正常,為何不能成功回連伺服器?

這三個問題是一個遞進關係,一環扣一環!

我們先看第一個問題,很明顯,當復現問題的時候,我們可以從PC的Wi-Fi熱點那裡看到所連過來的裝置,且看到了分配的子網IP地址。

接下來看第二個問題,這個問題測試也很簡單,因為我們的命令列中集成了ping命令,輸入ping命令一看,居然發現了一個重要資訊:

# ping www.baidu.com  ping_Command  ping IP address:www.baidu.com  ping: create socket failed

正常的ping log長這樣:

# ping www.baidu.com  ping_Command  ping IP address:www.baidu.com  60 bytes from 14.215.177.39 icmp_seq=0 ttl=53 time=40 ticks  60 bytes from 14.215.177.39 icmp_seq=1 ttl=53 time=118 ticks  60 bytes from 14.215.177.39 icmp_seq=2 ttl=53 time=68 ticks  60 bytes from 14.215.177.39 icmp_seq=3 ttl=53 time=56 ticks

WC!ping: create socket failed 這還建立socket失敗了!!!?

我第一時間懷疑是不是lwip元件出問題了?

第二個懷疑:難道socket控制代碼不夠了?因此建立記憶體大部分的操作就是在申請socket記憶體資源,並沒有進行其他什麼高階操作。

這麼一想,第二個可能性就非常大,結合前面的總總跡象,是個需要重點排查的物件。

4.3 知識點補缺

在準確定位問題之前,我們先幫相關的知識點補充完整,方便後續的知識鋪開講解。

4.3.1 lwip的socket控制代碼

  • socket具備的建立

    socket函式呼叫的路勁如下:

    socket -> lwip_socket -> alloc_socket

    alloc_socket函式的實現:

    /**   * Allocate a new socket for a given netconn.   *   * @param newconn the netconn for which to allocate a socket   * @param accepted 1 if socket has been created by accept(),   *                 0 if socket has been created by socket()   * @return the index of the new socket; -1 on error   */  static int  alloc_socket(struct netconn *newconn, int accepted)  {    int i;    SYS_ARCH_DECL_PROTECT(lev);  ​    /* allocate a new socket identifier */    for (i = 0; i < NUM_SOCKETS; ++i) {      /* Protect socket array */      SYS_ARCH_PROTECT(lev);      if (!sockets[i].conn && (sockets[i].select_waiting == 0)) {        sockets[i].conn       = newconn;        /* The socket is not yet known to anyone, so no need to protect           after having marked it as used. */        SYS_ARCH_UNPROTECT(lev);        sockets[i].lastdata   = NULL;        sockets[i].lastoffset = 0;        sockets[i].rcvevent   = 0;        /* TCP sendbuf is empty, but the socket is not yet writable until connected         * (unless it has been created by accept()). */        sockets[i].sendevent  = (NETCONNTYPE_GROUP(newconn->type) == NETCONN_TCP ? (accepted != 0) : 1);        sockets[i].errevent   = 0;        sockets[i].err        = 0;    SOC_INIT_SYNC(&sockets[i]);        return i + LWIP_SOCKET_OFFSET;     }      SYS_ARCH_UNPROTECT(lev);   }    return -1;  }

    大家注意到,上述函式中的for迴圈有一個巨集 NUM_SOCKETS,這個巨集的具體數值是可適配的,不同的平臺可根據自己的實際使用情況和記憶體情況,選擇一個合適的數值。

    我們看下這個NUM_SOCKETS巨集定義的實現:

    巨集定義替換  #define NUM_SOCKETS MEMP_NUM_NETCONN  ​  在lwipopts.h中找到了其最終的替換  /**   * MEMP_NUM_NETCONN: the number of struct netconns.   * (only needed if you use the sequential API, like api_lib.c)   *   * This number corresponds to the maximum number of active sockets at any   * given point in time. This number must be sum of max. TCP sockets, max. TCP   * sockets used for listening, and max. number of UDP sockets   */  #define MEMP_NUM_NETCONN (MAX_SOCKETS_TCP + \   MAX_LISTENING_SOCKETS_TCP + MAX_SOCKETS_UDP)

    看著這,有點繞,究竟這個值是多少啊?

  • socket控制代碼的銷燬

    具備的銷燬,我們都知道使用close介面,它的函式呼叫路徑如下:

    close -> lwip_close -> free_socket

lwip_close函式的實現如下:

int  lwip_close(int s)  {    struct lwip_sock *sock;    int is_tcp = 0;    err_t err;  ​    LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_close(%d)\n", s));  ​    sock = get_socket(s);    if (!sock) {      return -1;   }    SOCK_DEINIT_SYNC(1, sock);  ​    if (sock->conn != NULL) {      is_tcp = NETCONNTYPE_GROUP(netconn_type(sock->conn)) == NETCONN_TCP;   } else {      LWIP_ASSERT("sock->lastdata == NULL", sock->lastdata == NULL);   }  ​  #if LWIP_IGMP    /* drop all possibly joined IGMP memberships */    lwip_socket_drop_registered_memberships(s);  #endif /* LWIP_IGMP */  ​    err = netconn_delete(sock->conn);    if (err != ERR_OK) {      sock_set_errno(sock, err_to_errno(err));      return -1;   }  ​    free_socket(sock, is_tcp);    set_errno(0);    return 0;  }

這裡呼叫到了free_socket:

/** Free a socket. The socket's netconn must have been   * delete before!   *   * @param sock the socket to free   * @param is_tcp != 0 for TCP sockets, used to free lastdata   */  static void  free_socket(struct lwip_sock *sock, int is_tcp)  {    void *lastdata;  ​    lastdata         = sock->lastdata;    sock->lastdata   = NULL;    sock->lastoffset = 0;    sock->err        = 0;  ​    /* Protect socket array */    SYS_ARCH_SET(sock->conn, NULL);    /* don't use 'sock' after this line, as another task might have allocated it */  ​    if (lastdata != NULL) {      if (is_tcp) {        pbuf_free((struct pbuf *)lastdata);     } else {        netbuf_delete((struct netbuf *)lastdata);     }   }  }

這個SYS_ARCH_SET(sock->conn, NULL);就會釋放對應的socket控制代碼,從而保證socket控制代碼可迴圈使用。

4.3.2 TCP網路程式設計中的close和shutdown

為何在這裡會討論這個知識點,那是因為這個知識點是解決整個問題的關鍵。

具體他們的區別與聯絡是怎麼樣的,我這裡不做過多闡述,感興趣的可以自行去學習

這裡就直接把結論擺出來:

  • close把描述符的引用計數減1,僅在該計數變為0時關閉套接字。shutdown可以不管引用計數就激發TCP的正常連線終止序列。
  • close終止讀和寫兩個方向的資料傳送。TCP是全雙工的,有時候需要告知對方已經完成了資料傳送,即使對方仍有資料要傳送給我們。
  • shutdown與socket描述符沒有關係,即使呼叫shutdown(fd, SHUT_RDWR)也不會關閉fd,最終還需close(fd)。

4.4 深入分析

瞭解了lwip元件中對socket控制代碼的建立和關閉,我們再回到復現問題的本身。

從最細微的log我們知道問題出在無法分配新的socket具備,我們再看下那個分配socket的邏輯中,有一個判斷條件:

if (!sockets[i].conn && (sockets[i].select_waiting == 0)) {        //分配新的控制代碼編號        sockets[i].conn       = newconn;        。。。  }

通過增加log,我們知道select_waiting的值是為0的,那麼問題就出在conn不為NULL上面了。

在lwip_close中是有對.conn進行賦值NULL的,於是就猜想難道 lwip_close沒呼叫?進行導致控制代碼沒完全釋放?

回答這個問題,又需要回到我們的軟體架構上了,在實現架構了,我們不同的晶片平臺使用了不同版本的lwip元件,而上層跑的MQTT協議是公用的,也就是如果是上層邏輯中沒有正確處理close邏輯,那麼這個問題應該在所有的平臺都會出現,但為何唯獨只有這個平臺才出問題呢。

答案只有一個,問題可能出在lwip實現這一層。

由於lwip是原廠去適配,我第一時間找了原生的lwip-2.0.2版本做了下對比,主要想知道原廠適配的時候,做了哪些優化和調整。

結果一對比,果然發現了問題。

我們就以出問題的sockets.c為例,我們重點關注socket的申請和釋放:

image-20220301001352782

image-20220301001444091

為了比較好描述原廠所做的優化,我把其新增的程式碼做了少量修改,大致就加了幾個巨集定義,這幾個巨集定義看其註釋應該是為了處理多工下新建、關閉socket的同步問題。

```

define SOC_INIT_SYNC(sock) do { something ... } while(0)

define SOC_DEINIT_SYNC(sock) do { SOCK_CHECK_NOT_CLOSING(sock); something ... } while(0)

#define SOCK_CHECK_NOT_CLOSING(sock) do { \ if ((sock)->closing) { \ SOCK_DEBUG(1, "SOCK_CHECK_NOT_CLOSING:[%d]\n", (sock)->closing); \ return -1; \ } \ } while (0) ```

只是跟了一下它的邏輯,上層呼叫lwip_close的時候會呼叫到SOC_DEINIT_SYNC,同時它會呼叫到SOCK_CHECK_NOT_CLOSING,從而結束整一個socket釋放的全流程。

但是偏偏我們做的MQTT上層在呼叫TCP鏈路結束通話的時候,是這麼玩的:

``` / * Gracefully close the connection / void mbedtls_net_free( mbedtls_net_context *ctx ) { if( ctx->fd == -1 ) return;

shutdown( ctx->fd, 2 );
close( ctx->fd );

ctx->fd = -1;

} ```

優雅地關閉TCP鏈路,這時候你應該要想起4.3.2章節的知識點。

這樣呼叫對那幾個巨集會有影響?

答案是肯定的。

原來的,原廠適配時lwip_shutdown也同樣呼叫了SOC_DEINIT_SYNC,這就導致瞭如果上層關閉鏈路既呼叫shutdown又呼叫close的話,它的邏輯就會出問題,會引發close的流程走不完整。

為了能夠簡化這個問題,我大概寫了一下它的邏輯:

1)shutdown函式調過來的時候,開始啟動關閉流程SOC_DEINIT_SYNC,進入到那幾個巨集裡面,會有一步:(sock)->closing = 1;然後正常返回0;

2)等到close函式調過來的時候,再次進入關閉流程SOC_DEINIT_SYNC,結果一判斷(sock)->closing已經是1了,然後報錯返回-1;這樣close的返回就不正常了;

3)再看lwip_close函式的邏輯:

image-20220301002943192

於是就出現了之前的問題,socket控制代碼的index一直在上升,應該舊的scoket控制代碼一直被佔用,知道控制代碼數被耗盡。

最大控制代碼數NUM_SOCKETS究竟是多少,可以參考之前我的文章將如何看預編譯的程式碼,我們可以清晰地看到他的值就是38

image-20220531140012176

所有的疑惑均開啟,為了一定是30多次之後才出問題,這裡給出了答案!

這裡我大膽地猜想了一下,應該原廠在適配這段同步操作邏輯的時候,壓根就沒考慮上層還可以先shutdown再close,所以引發了這個問題。

5 問題修復

上面的分析中,已經初步定位了問題程式碼,接下來就是要進行問題修復了。

問題根源出在先調shutdown再調close,由於是一個上層程式碼,其他平臺也是共用的,且其他平臺使用並沒有問題,所以肯定不能把上層優雅關閉TCP鏈路的操作給去掉,只能底層的lwip元件自行優化解決。所謂是:誰惹的禍,誰來擦屁股!

解決問題的關鍵是,要保證調完shutdown之後,close那次操作需要走一個完整流程,這樣才能把佔用的socket控制代碼給釋放掉。

所以在執行shutdown和close的時候,SOC_DEINIT_SYNC需要帶個引數告知是不是close操作,如果不是close那麼就走一個簡易流程,這樣就能保證close流程是完整的。

當上層只調用close,也能確保close的流程是完整的。

但是,入股上層先呼叫close,再調shutdown,這樣流程就不通了。

當然,上層也不能這麼玩,具體參考4.3.2的知識點。

6 問題驗證

問題修復之後,需要進行同樣的流程複測,以確保這個問題確實被修復了。

問題驗證也很簡單,修改sockets.c中的NUM_SOCKETS,改成一個很小的值,比如3或5,加快問題復現的速度,同時把alloc_socket中獲取的控制代碼id打出來,觀察它有沒有上升,正常的測試中,在沒有其他網路通訊鏈路的情況下,它應該穩定值為0。

很快就可以驗證,不會再復現這個問題了。

接下來,需要將NUM_SOCKETS的值還原成原理的值,真實測試原本復現的場景,確保真的只有這個地方引發了這個問題,而其他程式碼並沒有干擾到。

幸運的是,還原之後的測試也通過了,這就證明了這個問題完全修復了,且沒有帶來副作用,是一次成功的bug修復。

7 經驗總結

  • 記憶體洩漏的花樣很多,但一定要注意其本質特點;
  • socket控制代碼洩漏,也是記憶體洩漏的一種;
  • 每一種優化都有它特定的場景,脫離了這個特定場景,你需要重新考慮這個優化的普適性;
  • 增強對關鍵log資訊的敏感度,有利於在茫茫問題中找到排查的方向燈;
  • 準確理解TCP程式設計介面中的close函式和shutdown函式,能對解決掉網問題有所幫助;
  • 上線前的壓力測試,必不可少。

8 參考連結

9 更多分享

歡迎關注我的github倉庫01workstation,日常分享一些開發筆記和專案實戰,歡迎指正問題。

同時也非常歡迎關注我的CSDN主頁和專欄:

【CSDN主頁:架構師李肯】

【RT-Thread主頁:架構師李肯】

【C/C++語言程式設計專欄】

【GCC專欄】

【資訊保安專欄】

【RT-Thread開發筆記】

【freeRTOS開發筆記】

【BLE藍芽開發筆記】

【ARM開發筆記】

【RISC-V開發筆記】

有問題的話,可以跟我討論,知無不答,謝謝大家。