引用)
前言「循環(huán)引用」在 C 語境里有兩個常見所指一是智能指針之間的所有權(quán)成環(huán)導(dǎo)致引用計數(shù)永遠降不到零二是頭文件互相包含導(dǎo)致類型定義順序或編譯依賴出問題。兩者都叫循環(huán)引用但機制和解法完全不同。本文以第一類為主因為它是使用std::shared_ptr時最容易造成內(nèi)存泄漏的坑最后一節(jié)再簡要說明頭文件循環(huán)包含的處理方式。先澄清一個誤解循環(huán)引用并不等于「程序一定崩潰」。它通常表現(xiàn)為內(nèi)存緩慢增長、對象析構(gòu)函數(shù)不執(zhí)行、文件句柄或網(wǎng)絡(luò)連接遲遲不釋放。程序可能長時間看起來正常直到資源耗盡。另一個誤解是「用了智能指針就不會泄漏」——std::shared_ptr解決的是「誰負(fù)責(zé)釋放」解決不了「所有權(quán)成環(huán)」環(huán)上的對象互相持有強引用引用計數(shù)始終至少為 1。本文講清shared_ptr的計數(shù)模型說明std::weak_ptr如何打破環(huán)給出父子關(guān)系、觀察者模式等實際場景的寫法并補充檢測思路和頭文件循環(huán)包含的處理。一、引用計數(shù)為什么會被環(huán)卡住std::shared_ptr由兩部分組成指向?qū)ο蟮闹羔樢约爸赶蚩刂茐Kcontrol block的指針??刂茐K里維護強引用計數(shù)strong count與弱引用計數(shù)weak count??截愐粋€shared_ptr會讓強計數(shù)加一銷毀一個會讓它減一強計數(shù)歸零時銷毀被管理對象弱計數(shù)也歸零時釋放控制塊本身。關(guān)鍵點在于銷毀被管理對象的條件是強計數(shù)歸零而強計數(shù)只統(tǒng)計shared_ptr的個數(shù)不分析對象之間的引用關(guān)系??聪旅孢@個雙向鏈表節(jié)點// 適用于 C17#include memory#include string#include iostreamstruct Node {std::string name;std::shared_ptrNode next;std::shared_ptrNode prev;~Node() { std::cout destroy name \n; }};int main() {auto a std::make_sharedNode();auto b std::make_sharedNode();a-name a;b-name b;a-next b;b-prev a; // 形成環(huán)a 持有 bb 持有 areturn 0;}main返回時局部變量a和b被銷毀強計數(shù)各減一但a的計數(shù)仍為 1被b-prev持有b的計數(shù)也仍為 1被a-next持有。兩個對象都達不到零析構(gòu)函數(shù)不會執(zhí)行兩塊內(nèi)存泄漏。注意這里~Node的輸出來自代碼邏輯實際運行時是否打印取決于是否成環(huán)按標(biāo)準(zhǔn)語義成環(huán)時不會打印。這個例子說明引用計數(shù)是一種局部規(guī)則它只回答「還有多少個強引用」不回答「這些引用是否構(gòu)成可達的環(huán)」。垃圾回收器會做可達性分析引用計數(shù)不會。二、用weak_ptr打破環(huán)std::weak_ptr是對shared_ptr管理對象的非擁有non-owning引用。它不增加強計數(shù)只增加弱計數(shù)因此不會延長對象的生命周期。使用時通過lock()提升為shared_ptr對象還活著就返回有效指針已經(jīng)銷毀就返回空指針。也可以用expired()判斷是否失效。把雙向鏈表中的一條邊改成weak_ptr環(huán)就被打破// 適用于 C17#include memory#include string#include iostreamstruct Node {std::string name;std::shared_ptrNode next; // 擁有下一個節(jié)點std::weak_ptrNode prev; // 觀察上一個節(jié)點不擁有~Node() { std::cout destroy name \n; }};int main() {auto a std::make_sharedNode();auto b std::make_sharedNode();a-name a;b-name b;a-next b;b-prev a;if (auto p b-prev.lock()) {std::cout prev of b is p-name \n;}return 0;}現(xiàn)在a只被局部變量和b-prev弱引用指向b只被a-next和局部變量指向。main結(jié)束時局部變量銷毀a的強計數(shù)歸零a析構(gòu)a-next隨之銷毀b的強計數(shù)也歸零b析構(gòu)。整條鏈正常釋放。選擇哪條邊用弱引用取決于所有權(quán)語義單向鏈表用next擁有、prev觀察是常見約定樹結(jié)構(gòu)中父節(jié)點擁有子節(jié)點子節(jié)點只觀察父節(jié)點圖結(jié)構(gòu)則要給每條邊明確方向避免所有邊都是強引用。一個實用原則是讓所有權(quán)形成有向無環(huán)圖DAG環(huán)上的「回邊」用weak_ptr。特性shared_ptrweak_ptr是否影響對象生命周期是增加強計數(shù)否只增加弱計數(shù)能否直接解引用能operator*、operator-不能必須先lock()典型用途表達所有權(quán)表達觀察、打破環(huán)、緩存失效后的行為對象在計數(shù)歸零時銷毀lock()返回空shared_ptr構(gòu)造方式make_shared、從其他shared_ptr拷貝從shared_ptr或weak_ptr構(gòu)造weak_ptr的lock()在并發(fā)場景下是安全的它原子地檢查對象是否存活并增加強計數(shù)因此不會出現(xiàn)「檢查時還活著、使用時已被銷毀」的競態(tài)。但要注意lock()成功只保證在你持有返回的shared_ptr期間對象存活釋放這個shared_ptr后對象可能立即銷毀。需要多次訪問時把lock()的結(jié)果存下來復(fù)用而不是每次訪問都重新lock()。weak_ptr也有代價它需要額外的原子操作且控制塊必須等到弱計數(shù)也歸零才釋放。因此不要為了「看起來安全」把所有shared_ptr都換成weak_ptr它表達的是「我不擁有它」濫用會讓所有權(quán)關(guān)系變得含糊。三、典型場景與排查思路父子關(guān)系是循環(huán)引用最高頻的來源。父對象持有子對象的容器子對象又保存指向父對象的指針如果兩邊都用shared_ptr每個子對象都會把父對象釘住。正確寫法是父持有子、子弱引用父// 適用于 C17#include cstddef#include memory#include string#include utility#include vectorclass Parent;class Child {public:explicit Child(std::string name, std::weak_ptrParent parent): name_(std::move(name)), parent_(std::move(parent)) {}const std::string name() const noexcept { return name_; }std::shared_ptrParent parent() const {return parent_.lock();}private:std::string name_;std::weak_ptrParent parent_;};class Parent : public std::enable_shared_from_thisParent {public:void addChild(const std::string name) {children_.push_back(std::make_sharedChild(name, shared_from_this()));}std::size_t childCount() const noexcept { return children_.size(); }private:std::vectorstd::shared_ptrChild children_;};這里Parent繼承std::enable_shared_from_thisParent從而能在成員函數(shù)里通過shared_from_this()取得指向自身的shared_ptr再以weak_ptr形式交給子對象。使用shared_from_this()有一個前提對象必須已經(jīng)由某個shared_ptr管理。對棧上對象或由裸指針管理的對象調(diào)用它C17 起會拋出std::bad_weak_ptr更早的標(biāo)準(zhǔn)里行為未定義。因此Parent應(yīng)當(dāng)通過std::make_sharedParent()創(chuàng)建。觀察者模式也常踩這個坑。被觀察者保存一列觀察者觀察者又持有被觀察者的shared_ptr以便回調(diào)于是雙方互強引用。解決辦法同樣是單向所有權(quán)被觀察者持有觀察者的weak_ptr通知時逐個lock()失敗的說明觀察者已銷毀順手清理。這樣觀察者的生命周期由外部決定被觀察者不會阻止其析構(gòu)。異步回調(diào)是另一個隱蔽來源。比如定時器持有shared_ptrSelf的 lambda// 適用于 C17示意生命周期不是完整定時器實現(xiàn)auto self shared_from_this();registerCallback([self]() {self-onTimer();});如果回調(diào)注冊后永遠不觸發(fā)、也不被注銷self就永遠活著如果對象內(nèi)部又持有這個回調(diào)句柄就形成環(huán)。正確做法是捕獲weak_ptr回調(diào)觸發(fā)時lock()失敗就直接返回。這樣對象銷毀后回調(diào)自然失效不會延長生命周期。循環(huán)引用造成的泄漏不會報錯只能靠工具和代碼審查發(fā)現(xiàn)。常用手段有三類。第一在關(guān)鍵類型析構(gòu)函數(shù)里打日志或設(shè)置斷點觀察對象是否按預(yù)期銷毀。這是最直接的辦法適合定位「哪個對象沒釋放」。第二用use_count()觀察計數(shù)。它適合調(diào)試但要注意多線程環(huán)境下返回值只是瞬時快照不能作為同步依據(jù)某些實現(xiàn)包括標(biāo)準(zhǔn)庫的優(yōu)化可能不精確標(biāo)準(zhǔn)只保證它返回當(dāng)前的強引用數(shù)近似值。第三使用 AddressSanitizer 的泄漏檢測LeakSanitizer或 Valgrind 的泄漏檢查。它們能在程序退出時報告未釋放的堆塊配合調(diào)試符號可以定位分配棧。具體命令行與平臺支持以工具官方文檔為準(zhǔn)。代碼審查時可以問三個問題這個shared_ptr成員真的需要擁有對方嗎兩個對象之間是否存在雙向的強引用回調(diào)捕獲的是shared_ptr還是weak_ptr大部分環(huán)都能在這三個問題里暴露。四、另一種循環(huán)引用頭文件互相包含當(dāng)頭文件 A 包含 B、B 又包含 A 時包含衛(wèi)士include guard或#pragma once只能防止同一文件被重復(fù)展開不能解決類型不完整的問題。典型癥狀是「unknown type name」或在某個頭文件里看到另一個類只聲明未定義。處理原則是能前置聲明就不包含頭文件。如果 A 的類只用到B*、B或std::shared_ptrB這類不需要完整類型的成員可以在 A 中寫class B;前置聲明把#include b.h移到實現(xiàn)文件。只有在需要按值成員、繼承、訪問成員或調(diào)用內(nèi)聯(lián)函數(shù)時才必須包含完整定義。// 適用于 C17// a.h#pragma once#include memoryclass B; // 前置聲明足以聲明 shared_ptrB 成員class A {public:void setB(std::shared_ptrB b);void useB();private:std::shared_ptrB b_;};std::shared_ptrB允許B不完整因為刪除器信息在控制塊里但std::unique_ptrB的析構(gòu)需要完整類型若在頭文件中定義析構(gòu)函數(shù)就必須看到B的定義通常把析構(gòu)函數(shù)聲明在頭文件、定義在實現(xiàn)文件pimpl 手法。這些規(guī)則與智能指針的循環(huán)引用是兩回事但都源于「依賴關(guān)系成環(huán)」設(shè)計時減少雙向依賴兩類問題都會少很多。常見坑點? 父子對象互相用shared_ptr持有。 ? 父持有子的shared_ptr子持有父的weak_ptr用lock()臨時提升。? 用weak_ptr后直接-訪問成員。 ? 先lock()得到shared_ptr并判空再訪問weak_ptr沒有解引用運算符。? 每次訪問都調(diào)用lock()用完立刻釋放中間對象被銷毀。 ? 把lock()的結(jié)果保存為局部shared_ptr覆蓋整個使用區(qū)間。? 對未被shared_ptr管理的對象調(diào)用shared_from_this()。 ? 確保對象由make_shared或shared_ptr構(gòu)造C17 起否則拋std::bad_weak_ptr。? 異步回調(diào)捕獲shared_ptr自身且回調(diào)永不注銷。 ? 捕獲weak_ptr觸發(fā)時lock()判空避免回調(diào)延長對象生命周期。? 用同一個裸指針構(gòu)造兩個獨立的shared_ptr。 ? 用make_shared或從一個已有的shared_ptr拷貝重復(fù)構(gòu)造會產(chǎn)生兩個控制塊導(dǎo)致重復(fù)釋放。? 以為use_count()在多線程下是精確的同步依據(jù)。 ? 只把它當(dāng)調(diào)試參考同步用互斥量或原子操作。? 頭文件互相包含靠 include guard 硬扛。 ? 用前置聲明加實現(xiàn)文件包含減少編譯期依賴必要時用 pimpl 隱藏實現(xiàn)??偨Y(jié)主題關(guān)鍵結(jié)論成環(huán)原因強引用計數(shù)只看數(shù)量不做可達性分析破環(huán)工具std::weak_ptr不增加強計數(shù)lock()提升所有權(quán)設(shè)計讓強引用構(gòu)成有向無環(huán)圖回邊用弱引用父子關(guān)系父持子強引用子持父弱引用回調(diào)捕獲weak_ptr避免異步任務(wù)釘住對象頭文件循環(huán)前置聲明、實現(xiàn)文件包含、pimpl循環(huán)引用的根源不是智能指針本身而是所有權(quán)關(guān)系沒有設(shè)計成單向。寫代碼時先問「誰擁有誰、誰只觀察誰」再決定用shared_ptr還是weak_ptr比事后用工具找泄漏更省事。記住weak_ptr的語義是「我引用它但不為它的生命周期負(fù)責(zé)」——這句話既是約束也是設(shè)計文檔。