記c++坑:7.記一次由智能指針導致的內存泄漏問題解決

項目背景

    我們的主要產品是一個針對個人用戶的c/sb/s混合架構的應用,爲了對我們產品的一些新功能調試,壓力測試,以及對線上服務的監控,我使用c++開發了一個機器人程序。這個程序中90%的設計和代碼由我完成,除了其中的一個基於udp通信的庫,使用了enet,然而這個庫在之前不可追溯的幾任維護者手中,將其中enet代碼進行了修改,居然在裏面摻雜進了tcp通信的功能!這完全違背了設計模式的單一職責和開閉原則。以至於現在也沒有一個簡單的方式將這個庫升級到最新版本,而且還不敢貿然使用其所提供的的tcp功能,整個庫就像一個黑盒,所以我只能請相對比較熟悉的同事幫我將其移植過來供我使用。然而到目前爲止這部分移植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);
				log::Info("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);
				log::Info("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文件翻譯一下。

發佈了33 篇原創文章 · 獲贊 6 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章