记c++坑:7.记一次由智能指针导致的内存泄漏问题解决

6 篇文章 0 订阅
3 篇文章 0 订阅

项目背景

    我们的主要产品是一个针对个人用户的c/sb/s混合架构的应用,为了对我们产品的一些新功能调试,压力测试,以及对线上服务的监控,我使用c++开发了一个机器人程序。这个程序中90%的设计和代码由我完成,除了其中的一个基于udp通信的库,使用了enet,由于这个库在之前不可追溯的几任维护者手中,进行了源码修改且缺少文档,以至于现在也没有一个简单的方式将这个库升级到最新版本,所以我请了相对比较熟悉的同事帮我将其移植过来供我使用。闲言少叙,进入正题!
    要模拟大量的客户端和服务器进行通信,使用多线程并行是个绕不过的选择,机器人的设计思路是针对每一个机器人实例(对象),都有两个线程为其服务,一个用于网络io,一个用于逻辑处理。而同一个网络线程和逻辑线程指定处理多个机器人实例。同一个机器人的io队列需要被其相关的两个线程读写,使用了无锁队列。这样就保证了大量实例运行时不会有线程锁竞争浪费资源,同时也合理限制了线程的数量。
    但是由于使用的c++标准是c++11,不支持协程,所以在代码中使用了完全的状态驱动来将每一个模拟行为(程序中称为API)分解为多个异步操作,在每个API执行前、执行中、执行后(成功或出错)记录状态,并根据状态触发接下来的逻辑。类似于自己实现了协程,以保证在同一个io或逻辑线程中处理一批机器人业务的时候不会由于某个机器人的等待而导致所有实例阻塞。
    一个典型的API例子是Sleep,代码实例如下:

namespace api {
	void Sleep(Robot* robot, api::OpIterator op, int64_t durationmsec)
	{
		OP_START(); // 标记 operate 开始状态
		robot->RegTimer(robot->GetSeq()
					  , std::chrono::milliseconds(durationmsec)
					  , MakeTimerFuncShared([durationmsec, robot, op]()
		{
			OP_SUCCESS(); // 标记 operate 成功状态
		}));
	}
}

    可以看到,整个API调用在一开始记录了一个状态并设置了一个定时器之后函数就结束退出了,只有等到未来的一个时间点定时器触发之后记录一个操作完成的状态,才标志着这个API真正完成。这样即便是在一个逻辑线程中跑10000个机器人,每个机器人都执行Sleep(1000),也只需要1s就能结束而不是10000s
    又或者EnterRoom,其代码实现中有出现网络io处理还有异常状态处理等。

namespace api {
	void EnterRoom(Robot* robot, api::OpIterator op, int64 room_guid)
	{
		OP_START(); // 标记 operate 开始状态
		Req_EnterRoom* req = g_MsgMgr.GetMsgInst(emReq_EnterRoom);
		if (!req)
		{
			OP_FATAL(); // 标记 operate 错误(不可恢复)状态
			return;
		}
		req->set_room_guid(room_guid);
		uint32_t msg_seq = robot->GetSeq();
		if (!robot->AsyncSend(Msg::channelChat, emReq_EnterRoom, msg_seq, *req) && !robot->IsChatConnecting())
		{
			OP_FATAL(); // 标记 operate 错误(不可恢复)状态
			return;
		}
		rpc::RegRpcCallbackWait(robot
							  , msg_seq
							  , std::chrono::seconds(g_Cfg.chat_rpc_timeout)
							  , MakeTimerFuncShared([msg_seq, robot]()
		{
			rpc::IgnoreTimeoutedMsg(robot, msg_seq);
			OP_EXCEPT(); // 超时 将 标记 operate 异常状态
		})
							  , MakeMsgCbFuncShared([msg_seq, robot](MsgPtr msg) -> ErrNo
		{
			robot->UnRegTimer(msg_seq);
			HANDLE_MSG_AND_CHK_RET(Resp_EnterRoom, OP_EXCEPT()); // 标准应答数据检测,异常数据 将 标记 operate 异常状态
			OP_SUCCESS(); // 标记 operate 成功状态
			return ERR_NO_ERRNO;
		}));
	}
}

    顺便说一下,逻辑线程有一个主循环,不停的从io recv队列取出收到的网络数据并调用处理函数同时触发到期的定时器。并且处理状态的模块会根据机器人实例的当前状态和API属性确定该继续的操作。

问题产生

    从某个版本开始,希望加入API重试机制。也就是说,原来调用某个API过程中如果出现了错误就会直接认为出错,比如作为监控程序,那这个时候就会发出警告了。但是希望能够在调用异常时重试多次,只有在重试失败达到预定上限才发警告,这样可以过滤掉绝大多数网络波动等引起的可以忽略的问题。
    但是当时已支持的API数量已经比较多了,算下来有50+,如果每个API都改动则牵涉面太广,耗费时间太多,所以经过一些考虑后,引入了一个InvokeApi函数,大致代码如下:

namespace api_help {
	template<typename Api, typename... Args>
	void InvokeApi(CRobot* robot, api::OpIterator op, const Api&& api, Args&&... args)
	{
		auto tried = std::make_shared<int>(0);
		auto func_retry = std::make_shared<api_inner::SimpleFunc>(nullptr);
		auto apir = std::make_shared<api::SimpleFunc>(std::bind(std::forward<Api>(api)
												    , robot, op, 
												    , func_retry
												    , std::forward<Args>(args)...));
		*func_retry= [robot, op, tried, apir]() {
			if (g_Cfg.op_retry_tms > *tried)
			{
				++(*tried);
				LogInfo("Retry(%d/%d) [robot:%04d] [ %s ]", *tried, g_Cfg.op_retry_tms, robot->get_id(), op);
				(*apir)();
			}
			else
			{
				OP_EXCEPT();
			}
		};
		(*apir)();
	}
}

    然后,对现有的需要重试机制的APISleep是永远是成功的,不需要重试)进行改造, 例如EnterRoom:

namespace api_inner {
	void EnterRoom(Robot* robot, api::OpIterator op, SimpleFuncShared func_retry, int64 room_guid)
	{
		Req_EnterRoom* req = g_MsgMgr.GetMsgInst(emReq_EnterRoom);
		if (!req)
		{
			OP_FATAL();
			return;
		}
		req->set_room_guid(room_guid);
		uint32_t msg_seq = robot->GetSeq();
		if (!robot->AsyncSend(Msg::channelChat, emReq_EnterRoom, msg_seq, *req) && !robot->IsChatConnecting())
		{
			OP_FATAL();
			return;
		}
		rpc::RegRpcCallbackWait(robot
							  , msg_seq
							  , std::chrono::seconds(g_Cfg.chat_rpc_timeout)
							  , MakeTimerFuncShared([msg_seq, robot, func_retry]()
		{
			rpc::IgnoreTimeoutedMsg(robot, msg_seq);
			(*func_retry)(); // 异常 retry
		})
							  , MakeMsgCbFuncShared([msg_seq, robot, func_retry](MsgPtr msg) -> ErrNo
		{
			robot->UnRegTimer(msg_seq);
			HANDLE_MSG_AND_CHK_RET(Resp_EnterRoom, (*func_retry)()); // 标准应答数据检测,异常 retry
			OP_SUCCESS();
			return ERR_NO_ERRNO;
		}));
	}
}
namespace api {
	void EnterRoom(Robot* robot, api::OpIterator op, int64 room_guid)
	{
		OP_START(); // 标记 operate 开始状态
		api_help::InvokeApi(robot, op, api_inner::EnterRoom, room_guid);
	}
}

    这样修改,针对原来的所有API,逻辑基本不用变动,修改也基本只需要模式匹配结合替换,半小时可以搞定。
    所有代码改好了之候,编译运行,一路没有报警错误,也没有coredump,简直完美。内心自然是相当舒畅,满以为问题已经迎刃而解了。可谁知…
    在后来的某一次大规模压测中,机器人程序跑了几个小时之后,内存被占满,然后bad alloc,程序Duang~挂了。。

问题排查

    根据运行时内存飙升可以断定是产生了内存泄漏,那么,是什么原因呢?重看git提交记录,警觉地注意到了这次提交的InvokeApi里面的3shared_ptr。细看代码,发现apir的捕获列表里捕获了func_retryfunc_retry的捕获列表又里捕获了apir!emmmm?等等,这不就是典型的智能指针互相引用导致的不能释放嘛!哪个傻(和谐)居然写出了这样的代码!!!不对,一想到这代码都是自己在维护,又觉得,emmmm,这也只不过是个小小的笔误而已嘛,修复它,so easy !
    为了证实自己的猜想,使用内存泄漏检测工具检测了一下,果然问题出在这里了(这里顺便吐槽下linux下查内存泄漏的工具真的是都不顺手,还是windowsvld牛批!)。
    怎么改呢,与shared_ptrcp的是一个叫做weak_ptr的家伙,这两家伙合到一块儿专门解决智能指针互相引用的问题。但是思考良久,emmmm,始终没有找到合适的下手方式将其中某一个shared_ptr捕获改成weak_ptr
    如果将apir捕获的func_retry改掉,那么因为没有强引用,如果api中有异步处理,如上面的EnterRoom那样,在一个API还没有正式调用完成时func_retry就会被析构掉(EnterRoom31行,InvokeApi24行);如果将func_retry捕获的apir改掉呢?看起来也是不行的,原因同上。
    emmmm,赶脚这个事情变得比较棘手了,经典cp居然都没有办法解决这个问题,看来是要动大手术了哇。

问题分析

    整理一下逻辑,代码使用了函数式编程的思想来实现各种异步逻辑。每个API内部都有可能有多个异步等待的操作,而这些操作在需要等待的时候其实都是以发起异步操作的函数调用终止并注册一个等待异步操作完成后继续调用的新函数的方式来实现的。而现在需要保证的就是,在整个API多个异步操作执行过程(一连串的函数注册与触发)当中,如果出现了异常(重试达到上限)、不可恢复的错误、或者是整个操作成功后,也即是在整个API执行结束之后,需要释放InvokeApi中申请的内存。
    这么一分析,看起来是没办法指望使用语言的特性来解决这个内存泄漏的问题了,智能指针的RAII也不是万能解药,只能自己赤膊上阵啦!

问题解决

    找到了症结所在,分析清楚了问题本质,接下来修改就是水到渠成的事情了。修改后的代码如下:
    InvokeApi:

namespace api_help {
	template<typename Api, typename... Args>
	void InvokeApi(CRobot* robot, api::OpIterator op, const Api&& api, Args&&... args)
	{
		auto tried = std::make_shared<int>(0);
		auto funcs = std::make_shared<api_inner::SimpleFunc>(nullptr);
		auto funce = std::make_shared<api_inner::SimpleFunc>(nullptr);
		auto funcf = std::make_shared<api_inner::SimpleFunc>(nullptr);
		auto apir = new api_inner::SimpleFunc{ std::bind(std::forward<Api>(api)
													   , robot, op, funcs, funce, funcf
													   , std::forward<Args>(args)...) };
		*funcs = [robot, op, apir]() {
			OP_SUCCESS();
			delete apir;
		};
		*funce = [robot, op, tried, apir]() {
			if (g_Cfg.operation_retry_tms > *tried)
			{
				++(*tried);
				LogInfo("Retry(%d/%d) [robot:%04d] [ %s ]", *tried, g_Cfg.op_retry_tms, robot->get_id(), op);
				(*apir)();
			}
			else
			{
				OP_EXCEPT();
				delete apir;
			}
		};
		*funcf = [robot, op, apir]() {
			OP_FATAL();
			delete apir;
		};
		(*apir)();
	}
}

    EnterRoom:

namespace api_inner {
	void EnterRoom(Robot* robot, api::OpIterator op, SimpleFuncShared funcs, SimpleFuncShared funce, SimpleFuncShared funcf, int64 room_guid)
	{
		Req_EnterRoom* req = g_MsgMgr.GetMsgInst(emReq_EnterRoom);
		if (!req)
		{
			(*funcf)(); // 调用错误流程函数
			return;
		}
		req->set_room_guid(room_guid);
		uint32_t msg_seq = robot->GetSeq();
		if (!robot->AsyncSend(Msg::channelChat, emReq_EnterRoom, msg_seq, *req) && !robot->isChatConnecting())
		{
			(*funcf)(); // 调用错误流程函数
			return;
		}
		rpc::RegRpcCallbackWait(robot
							  , msg_seq
							  , std::chrono::seconds(g_Cfg.chat_rpc_timeout)
							  , MakeTimerFuncShared([msg_seq, robot, funcs, funce]()
		{
			rpc::IgnoreTimeoutedMsg(robot, msg_seq);
			(*funce)(); // 调用异常流程函数
		})
							  , MakeMsgCbFuncShared([msg_seq, robot, funcs, funce](MsgPtr msg) -> ErrNo
		{
			robot->UnRegTimer(msg_seq);
			HANDLE_MSG_AND_CHK_RET(Resp_EnterRoom, (*funce)()); // 标准应答数据检测,失败调用异常流程函数
			(*funcs)(); // 调用成功流程函数
			return ERR_NO_ERRNO;
		}));
	}
}
namespace api {
	void EnterRoom(Robot* robot, api::OpIterator op, int64 room_guid)
	{
		OP_START(); // 标记 operate 开始状态
		api_help::RetryOperation(robot, op, api_inner::EnterRoom, room_guid);
	}
}

    所以啊,现代c++虽然足够牛批,但还是有解决不了的问题,作为一个c++程序员,还是要秉持一个原则:“谁污染,谁治理;谁开发,谁保护!”不变。智能指针解决不了的问题,那咱就 — “谁new,谁delete!”

题外话

    接上面对linux c++查内存泄漏的吐槽。看起来目前最好用的就是valgrind了,可是用在我的项目中实在是一言难尽,性能会被拖的很慢,本来全负荷跑10分钟就可以收集到比较合理的信息了,使用上valgrind后需要跑好几个小时,并且卡得我主动等待线程退出再主线程终止的优雅关闭策略也不起作用。无奈自己撸了一个,虽然问题多多(因为毕竟也只是拿来查查问题而已),但我觉得用着顺手多了。代码记录在这里,以备后需。

// file: dbg_new.h
#ifndef _DBG_NEW_AED45C1F_AFB4_4DA9_A424_C7053F4F1D6C_H__
#define _DBG_NEW_AED45C1F_AFB4_4DA9_A424_C7053F4F1D6C_H__

// #define DBG_NEW

#ifdef DBG_NEW

#include <cstddef>

void* operator new(size_t size);
void *operator new[](size_t size);
void operator delete(void *ptr) noexcept;
void operator delete[](void *ptr) noexcept;

#endif

#endif // _DBG_NEW_AED45C1F_AFB4_4DA9_A424_C7053F4F1D6C_H__

// file: dbg_new.cpp
#include <dbg_new.h>

#ifdef DBG_NEW
#include <unistd.h>
#include <stdlib.h>
#include <execinfo.h>
#include <iostream>
#include <fstream>
#include <map>
#include <mutex>
#include <vector>

namespace std
{
	template <>
	struct allocator<void*> {
		typedef void* value_type;
		allocator() = default;
		template <class U> constexpr allocator(const allocator<U>&) noexcept {}
		value_type* allocate(std::size_t n) {
			if (n > std::size_t(-1) / sizeof(value_type)) throw std::bad_alloc();
			if (auto p = static_cast<value_type*>(std::malloc(n * sizeof(value_type)))) return p;
			throw std::bad_alloc();
		}
		void deallocate(value_type* p, std::size_t) noexcept { std::free(p); }
	};
	template <>
	struct allocator<std::_Rb_tree_node<std::pair<void* const, std::pair<unsigned long, std::vector<void*>>>>> {
		typedef std::_Rb_tree_node<std::pair<void* const, std::pair<unsigned long, std::vector<void*>>>> value_type;
		allocator() = default;
		template <class U> constexpr allocator(const allocator<U>&) noexcept {}
		value_type* allocate(std::size_t n) {
			if (n > std::size_t(-1) / sizeof(value_type)) throw std::bad_alloc();
			if (auto p = static_cast<value_type*>(std::malloc(n * sizeof(value_type)))) return p;
			throw std::bad_alloc();
		}
		void deallocate(value_type* p, std::size_t) noexcept { std::free(p); }
	};
	template <>
	struct allocator<std::pair<void* const, std::pair<unsigned long, std::vector<void*, std::allocator<void*>>>>> {
		typedef std::pair<void* const, std::pair<unsigned long, std::vector<void*, std::allocator<void*>>>> value_type;
		allocator() = default;
		template <class U> constexpr allocator(const allocator<U>&) noexcept {}
		value_type* allocate(std::size_t n) {
			if (n > std::size_t(-1) / sizeof(value_type)) throw std::bad_alloc();
			if (auto p = static_cast<value_type*>(std::malloc(n * sizeof(value_type)))) return p;
			throw std::bad_alloc();
		}
		void deallocate(value_type* p, std::size_t) noexcept { std::free(p); }
	};
	template <>
	struct allocator<std::pair<size_t, std::vector<void*>>> {
		typedef std::pair<size_t, std::vector<void*>> value_type;
		allocator() = default;
		template <class U> constexpr allocator(const allocator<U>&) noexcept {}
		value_type* allocate(std::size_t n) {
			if (n > std::size_t(-1) / sizeof(value_type)) throw std::bad_alloc();
			if (auto p = static_cast<value_type*>(std::malloc(n * sizeof(value_type)))) return p;
			throw std::bad_alloc();
		}
		void deallocate(value_type* p, std::size_t) noexcept { std::free(p); }
	};
};

class NewMgr
{
private:
	NewMgr() : m_logfile{}
	{
		snprintf(const_cast<char*>(m_logfile), 64, "executefile.dbg_new.%d.log", getpid());
	}
	~NewMgr()
	{
		std::ofstream logfile;
		logfile.open(m_logfile, std::ios::out);
		if (!logfile)
		{
			return;
		}
		logfile << "=[debug new]==================================================================================================" << std::endl;
		for (auto x : m_news)
		{
			logfile << " <" << x.first << "> " << x.second.first << "bytes" << std::endl;
			char **strings = backtrace_symbols(&(x.second.second[0]), x.second.second.size());
			for (size_t i = 0; i < x.second.second.size(); ++i)
			{
				logfile << "  " << strings[i] << std::endl;
			}
			free(strings);
			logfile << " -------------------------------------------------------------------------------------------------------------" << std::endl;
		}
		logfile.close();
	}
private:
	NewMgr(const NewMgr& that) = delete;
	NewMgr& operator=(const NewMgr& that) = delete;
	NewMgr(NewMgr&& that) = delete;
	NewMgr& operator=(NewMgr&& that) = delete;
public:
	static NewMgr& GetInst()
	{
		static NewMgr inst;
		return inst;
	}
public:
	void RecordNew(void* ptr, size_t s)
	{
		void *array[10];
		size_t size = backtrace(array, 10);
		{
			auto v = (size > 1) ? std::vector<void*>(array + 1, array + size) : std::vector<void*>{};
			std::lock_guard<std::recursive_mutex> l(m_lock);
			m_news[ptr] = std::make_pair(s, v);
		}
	}
	void UnRecordNew(void* ptr)
	{
		std::lock_guard<std::recursive_mutex> l(m_lock);
		m_news.erase(ptr);
	}

private:
	const char m_logfile[64];
	std::map<void*, std::pair<size_t, std::vector<void*>>> m_news;
	std::recursive_mutex m_lock;
};

#define g_NewMgr NewMgr::GetInst()

void* operator new(size_t size)
{
	void* p = malloc(size);
	g_NewMgr.RecordNew(p, size);
	return p;
}
void *operator new[](size_t size)
{
	void* p = malloc(size);
	g_NewMgr.RecordNew(p, size);
	return p;
}
void operator delete(void *ptr)
{
	g_NewMgr.UnRecordNew(ptr);
	return free(ptr);
}
void operator delete[](void *ptr)
{
	g_NewMgr.UnRecordNew(ptr);
	return free(ptr);
}
#endif

    其中使用了linuxbacktrace库,不过还存在一些问题,比如如果需要检测的代码中出现了那一堆allocator特化模板类型的使用就统计不进去了,所以更正确的做法是这个类里面不要使用stl容器,自己使用c风格来重新实现所需容器,这样也也可保证其自身析构过程中的new操作不会污染统计结果;其次一个问题是,虽然g_NewMgr是个静态变量,但是其析构之后任然有可能new或者之前new的之后才delete,这部分代码的调用顺序是很难保证的,所以并不能保证统计全面。
    还有,上面的代码并不能精确到文件行号,如果有此需求,还需要结合addr2line命令,将生成的log文件翻译一下。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值