例項解析網路程式設計中的另類記憶體洩漏

華為雲開發者社群發表於2022-04-07
摘要:最近在排查一個網路通訊的壓測問題,最後發現跟“記憶體洩漏”扯上了關係,但這跟常規理解的記憶體洩漏有那麼一點點不同,本文將帶你瞭解問題的始與末。

本文分享自華為雲社群《【網路程式設計開發系列】一種網路程式設計中的另類記憶體洩漏》,作者:架構師李肯。

1 寫在前面

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

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

2 問題描述

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

例項解析網路程式設計中的另類記憶體洩漏

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

3 場景復現

3.1 搭建壓測環境

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

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

有了這個思路後,我想著我手上正好有一個 隨身移動Wi-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指令碼的編碼問題,否則會出現識別不到正確的網路介面卡名稱。

例項解析網路程式設計中的另類記憶體洩漏例項解析網路程式設計中的另類記憶體洩漏

3.2 壓測問題說明

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

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

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

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

4 問題分析

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

4.1 簡單分析

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

例項解析網路程式設計中的另類記憶體洩漏

由於在斷網重連的操作中,可能對應的時間點下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的申請和釋放:

例項解析網路程式設計中的另類記憶體洩漏例項解析網路程式設計中的另類記憶體洩漏

為了比較好描述原廠所做的優化,我把其新增的程式碼做了少量修改,大致就加了幾個巨集定義,這幾個巨集定義看其註釋應該是為了處理多工下新建、關閉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函式的邏輯:

例項解析網路程式設計中的另類記憶體洩漏

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

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

例項解析網路程式設計中的另類記憶體洩漏

所有的疑惑均開啟,為了一定是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 參考連結

 

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章