写点什么

实例解析网络编程中的另类内存泄漏

  • 2022 年 4 月 07 日
  • 本文字数:7344 字

    阅读完需:约 24 分钟

本文分享自华为云社区《【网络编程开发系列】一种网络编程中的另类内存泄漏》,作者:架构师李肯。

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=5set enable_interval_time=15
:: Config your loop times: enable->disable->enable->disable...set loop_time=10000
:: Config your network adapter listSET adapter_num=1SET 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 operationset /A cnt=%loop_index% + 1set /A result=cnt%%2if %result% equ 0 (set operation=enabledset interval_time=%enable_interval_time%) else (set operation=disableset 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:AdapterStartif %adapter_index% EQU %adapter_num% goto :AdapterStopset 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 statecall: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 ...
pausegoto:eof
:: function definition:adapter_switchset cmd=netsh interface set interface %1 %2echo %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.comping_Commandping IP address:www.baidu.comping: create socket failed
复制代码

正常的 ping log 长这样:

# ping www.baidu.comping_Commandping IP address:www.baidu.com60 bytes from 14.215.177.39 icmp_seq=0 ttl=53 time=40 ticks60 bytes from 14.215.177.39 icmp_seq=1 ttl=53 time=118 ticks60 bytes from 14.215.177.39 icmp_seq=2 ttl=53 time=68 ticks60 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 intalloc_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 函数的实现如下:

intlwip_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 voidfree_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 参考链接


点击关注,第一时间了解华为云新鲜技术~

发布于: 刚刚阅读数: 2
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
实例解析网络编程中的另类内存泄漏_TCP_华为云开发者社区_InfoQ写作平台