摘要:最近在排查一個網路通訊的壓測問題,最後發現跟“記憶體洩漏”扯上了關係,但這跟常規理解的記憶體洩漏有那麼一點點不同,本文將帶你瞭解問題的始與末。
本文分享自華為雲社群《【網路程式設計開發系列】一種網路程式設計中的另類記憶體洩漏》,作者:架構師李肯。
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 參考連結