戰(zhàn):內(nèi)存優(yōu)化、狀態(tài)拆分與工廠緩存設(shè)計(jì))
前陣子接手一個(gè)C渲染項(xiàng)目粒子系統(tǒng)一開內(nèi)存蹭蹭往上飆八G的機(jī)器直接OOM。排查到最后罪魁禍?zhǔn)资且话偃f(wàn)個(gè)長(zhǎng)得幾乎一模一樣的粒子對(duì)象每個(gè)人都揣著一份紋理句柄和著色器參數(shù)。那一刻我意識(shí)到早該上享元模式Flyweight Pattern。這玩意兒不是教科書里那種可有可無(wú)的“形式主義抽象”它是實(shí)打?qū)嵞芫葍?nèi)存和性能的工程手段尤其C這種極度在意內(nèi)存布局和對(duì)象生命周期的語(yǔ)言享元模式用好了收益立竿見(jiàn)影。這篇文章我不打算重復(fù)教科書里那些概念定義我想講的是為什么你需要它、怎么判斷哪些字段該共享、哪些必須獨(dú)立、工廠和緩存池怎么設(shè)計(jì)才穩(wěn)以及我實(shí)際踩過(guò)的坑。內(nèi)容適合已經(jīng)掌握C基礎(chǔ)語(yǔ)法、寫過(guò)一些中大型對(duì)象模型但還在琢磨怎么優(yōu)化多對(duì)象內(nèi)存開銷的開發(fā)者??赐昴阒辽倌茏约菏謱懸粋€(gè)線程安全、支持懶加載的享元工廠并且知道什么時(shí)候別碰這個(gè)模式。1. 什么場(chǎng)景下才值得動(dòng)用享元模式1.1 先做一次內(nèi)存賬本很多人一聽到設(shè)計(jì)模式就想著“套上去”但享元模式不是銀彈它解決的是非常具體的痛點(diǎn)大量同構(gòu)對(duì)象各自持有重復(fù)的高成本數(shù)據(jù)。我通常在動(dòng)手前先算一筆賬把每個(gè)對(duì)象拆成數(shù)據(jù)字段估算單對(duì)象體積再乘上預(yù)估實(shí)例數(shù)量。舉個(gè)例子客戶端渲染引擎里常見(jiàn)的實(shí)體類struct Entity { std::string name; // 平均64字節(jié)含堆內(nèi)存 TextureHandle tex; // 16字節(jié) ShaderParams shader; // 128字節(jié) float matrix[16]; // 64字節(jié) Vector3 position; // 12字節(jié) float scale, rotation; // 8字節(jié) };這里前三塊加起來(lái)的體積非常大但它們的值在一大群實(shí)體里往往是相同的——比如一千個(gè)金幣模型用的是同一張貼圖、同一套材質(zhì)參數(shù)。如果每個(gè)實(shí)體都復(fù)制一份貼圖句柄和材質(zhì)參數(shù)浪費(fèi)就是指數(shù)級(jí)的。用sizeof算一下假設(shè)單實(shí)體256字節(jié)一百萬(wàn)個(gè)實(shí)體就是256MB其中可共享的部分可能占了150MB以上。這就是享元模式出場(chǎng)的前提對(duì)象數(shù)量夠大、可共享數(shù)據(jù)占比夠高、成本差異夠明顯。1.2 為什么C尤其適合這一套C和Java、Python這類語(yǔ)言不一樣它沒(méi)有自動(dòng)的“對(duì)象頭輕量化”或“字符串駐留”兜底措施所有內(nèi)存都由你顯式掌控。這意味著共享的語(yǔ)義必須由程序員親手實(shí)現(xiàn)但反過(guò)來(lái)也意味著你能做得非常極致配合const成員、string_view、pmr::memory_resource這些工具共享對(duì)象可以被設(shè)計(jì)成只讀、連續(xù)、零拷貝性能上限極高。我個(gè)人體會(huì)最深的一點(diǎn)C的“值語(yǔ)義”天然是享元模式的敵人但也是它的放大器。敵人在于C程序員默認(rèn)會(huì)給每個(gè)對(duì)象都分配獨(dú)立的數(shù)據(jù)塊復(fù)制起來(lái)心安理得放大器在于如果你能用shared_ptrconst Flyweight這種句柄去替代對(duì)象里的原始數(shù)據(jù)字段那么所有讀寫共享數(shù)據(jù)的地方都能命中同一塊內(nèi)存緩存友好度和分配次數(shù)都大幅改善。2. 先拆狀態(tài)把共享和不共享的部分分干凈2.1 內(nèi)在狀態(tài)與外在狀態(tài)的判斷清單享元模式的一切都建立在狀態(tài)拆分上。這個(gè)拆分一旦做錯(cuò)后面代碼寫得再漂亮也白搭。我的經(jīng)驗(yàn)是拿到一個(gè)類先別急著寫工廠先把所有成員變量列出來(lái)逐個(gè)問(wèn)三個(gè)問(wèn)題這個(gè)字段描述的是“這個(gè)對(duì)象是什么”還是“這個(gè)對(duì)象現(xiàn)在在哪”如果把兩個(gè)實(shí)例的該字段互換它們還能正常工作嗎這個(gè)字段在對(duì)象生命周期里會(huì)發(fā)生變化嗎前兩個(gè)問(wèn)題用于區(qū)分內(nèi)外第三個(gè)問(wèn)題決定你能不能安全地共享。內(nèi)在狀態(tài)是對(duì)象的本質(zhì)屬性一旦確定就不再改變外在狀態(tài)是對(duì)象在具體場(chǎng)景中的上下文會(huì)頻繁變化。舉一個(gè)大家最容易理解的例子一棵樹的渲染。樹的頂點(diǎn)網(wǎng)格、貼圖、法線數(shù)據(jù)是“樹之所以是樹”的本質(zhì)屬于內(nèi)在狀態(tài)——你可以讓一千棵松樹共享同一份網(wǎng)格和貼圖。但每棵樹的坐標(biāo)、朝向、縮放、風(fēng)吹動(dòng)的彎曲程度都不一樣這些屬于外在狀態(tài)。如果把“位置”錯(cuò)誤地扔進(jìn)共享對(duì)象里那么所有站在那棵樹位置上的樹都會(huì)被拖過(guò)去亂成一鍋粥。2.2 可變性的鐵律內(nèi)在狀態(tài)必須是const這里有一條我踩過(guò)坑之后才徹底貫徹的鐵律所有內(nèi)在狀態(tài)設(shè)計(jì)成const成員在構(gòu)造函數(shù)里一次性初始化之后只允許讀取不允許修改。為什么這么嚴(yán)格因?yàn)楣蚕韺?duì)象被多方同時(shí)引用任何一方對(duì)內(nèi)部字段的修改都會(huì)立刻傳染給所有使用者。這種“幽靈式的全局狀態(tài)改動(dòng)”極難排查比普通數(shù)據(jù)競(jìng)爭(zhēng)還陰險(xiǎn)——用鎖都不好使因?yàn)槟氵B該在哪個(gè)鎖上防范都說(shuō)不清。class TreeModel { public: TreeModel(Mesh mesh, Texture tex) : mesh_(std::move(mesh)), tex_(std::move(tex)) {} const Mesh mesh() const { return mesh_; } const Texture texture() const { return tex_; } private: const Mesh mesh_; // 注意 const const Texture tex_; // 注意 const };實(shí)戰(zhàn)中我還會(huì)把可變的狀態(tài)從共享對(duì)象里徹底剔除只留下純粹的“數(shù)據(jù)資產(chǎn)”。如果你發(fā)現(xiàn)某個(gè)字段在設(shè)計(jì)階段就很想給它加 setter那大概率它屬于外在狀態(tài)把setter放在調(diào)用者那邊去。2.3 生活化類比同一個(gè)模板不同的人你可以把享元對(duì)象理解成“印章”印章本身的形狀、刻字、材質(zhì)是共享的不管是誰(shuí)拿它在哪個(gè)位置蓋印出來(lái)的內(nèi)容本質(zhì)都來(lái)自同一個(gè)物理印章。但蓋出來(lái)的印跡落在哪張紙上、什么顏色、按了多少下每張紙都不一樣。這個(gè)“印章”就是內(nèi)在狀態(tài)“印跡的位置”就是外在狀態(tài)。想清楚這個(gè)類比你就會(huì)發(fā)現(xiàn)生活中大量可以套用享元模式的場(chǎng)景樂(lè)高積木的模具、代碼里公共的配置項(xiàng)、渲染引擎里的字體字形本質(zhì)都是同一件事。3. 從工廠到緩存池共享實(shí)例的管理策略3.1 為什么不能直接new一個(gè)享元對(duì)象確定了哪些數(shù)據(jù)要共享接下來(lái)就要回答這些共享對(duì)象從哪來(lái)、怎么保證同一個(gè)數(shù)據(jù)源只存在一份實(shí)例最直接的答案是用享元工廠統(tǒng)一管理。讓外部代碼直接new TreeModel(...)是最危險(xiǎn)的設(shè)計(jì)因?yàn)闆](méi)人能保證兩個(gè)調(diào)用者不會(huì)各自創(chuàng)建一份相同的網(wǎng)格數(shù)據(jù)一來(lái)內(nèi)存沒(méi)省下來(lái)二來(lái)后續(xù)的緩存、引用計(jì)數(shù)、生命周期管理全都無(wú)從談起。所以我會(huì)定義一個(gè)FlyweightFactory它內(nèi)部的職責(zé)很簡(jiǎn)單接收“描述性鍵值”比如文件路徑、特徵值組合在緩存里查找是否已有相同鍵的共享對(duì)象命中則返回已有對(duì)象未命中則創(chuàng)建并存入緩存3.2 緩存用shared_ptr還是weak_ptr這是個(gè)策略問(wèn)題緩存的數(shù)據(jù)結(jié)構(gòu)我首推std::unordered_map鍵是能唯一描述內(nèi)在狀態(tài)的Key。至于值類型shared_ptr和weak_ptr各有各的適用場(chǎng)景。如果你用shared_ptr作為緩存值那么緩存里永遠(yuǎn)存著所有創(chuàng)建過(guò)的共享對(duì)象對(duì)象生命周期和進(jìn)程一樣長(zhǎng)。這在資源數(shù)量有限、訪問(wèn)頻繁的場(chǎng)景下沒(méi)問(wèn)題——紋理管理器就該這樣貼圖一旦加載留在內(nèi)存里隨時(shí)等復(fù)用。壞處是如果你的Key維度很多比如粒子的顏色組合有幾千種那么這些對(duì)象全會(huì)常駐內(nèi)存反而失控。我后來(lái)的處理思路是分場(chǎng)景對(duì)待長(zhǎng)生命周期、強(qiáng)復(fù)用紋理、字體、網(wǎng)格緩存直接持有shared_ptr用引用計(jì)數(shù)管理“還有人用”沒(méi)有換入換出策略。短生命周期、創(chuàng)建頻次低零部件、臨時(shí)特效配置緩存持有weak_ptr業(yè)務(wù)方持有shared_ptr。當(dāng)沒(méi)有外部使用者時(shí)緩存里的weak_ptr自動(dòng)失效下次需要時(shí)再重建。weak_ptr版本的關(guān)鍵點(diǎn)在于查找時(shí)要判斷是否expired并且要能處理“緩存中有對(duì)象但已失效”的中間態(tài)否則會(huì)出現(xiàn)明明找不到可用對(duì)象卻直接返回空指針的情況。下面是我在項(xiàng)目里用過(guò)的完整工廠骨架包含了共享鎖 二次檢查class TreeModelFactory { public: std::shared_ptrconst TreeModel get(const std::string assetPath) { { std::shared_lock lock(mutex_); auto it cache_.find(assetPath); if (it ! cache_.end()) { if (auto obj it-second.lock()) { return obj; // 緩存命中且還活著 } } } std::unique_lock lock(mutex_); // 二次檢查等待鎖期間可能已被其他線程寫入 auto it cache_.find(assetPath); if (it ! cache_.end()) { if (auto obj it-second.lock()) { return obj; } } auto model std::make_sharedconst TreeModel( loadMesh(assetPath), loadTexture(assetPath)); cache_[assetPath] model; return model; } private: mutable std::shared_mutex mutex_; std::unordered_mapstd::string, std::weak_ptrconst TreeModel cache_; };這里有兩個(gè)細(xì)節(jié)值得說(shuō)。第一我用std::shared_mutex而不是簡(jiǎn)單的std::mutex因?yàn)閷?duì)緩存的讀操作遠(yuǎn)多于寫操作共享鎖能保護(hù)并發(fā)的讀第二注意那個(gè)“二次檢查”分支線程A創(chuàng)建對(duì)象期間線程B可能已經(jīng)持鎖搶建了相同對(duì)象如果不檢查就再次make_shared就會(huì)產(chǎn)生兩個(gè)同鍵對(duì)象享元的“唯一性”就被破壞了。3.3 讓構(gòu)造函數(shù)私有化擋住不守規(guī)矩的調(diào)用者一個(gè)非常實(shí)用的小技巧讓享元類的構(gòu)造函數(shù)設(shè)為私有或僅工廠可見(jiàn)確保只有工廠能創(chuàng)建對(duì)象。C里我會(huì)用friend class TreeModelFactory或者提供一個(gè)私有的標(biāo)記結(jié)構(gòu)體構(gòu)造標(biāo)簽來(lái)做到這一點(diǎn)。這是“語(yǔ)法層面強(qiáng)制約定”的做法雖然麻煩一點(diǎn)但能預(yù)防后面接手代碼的同事直接make_sharedTreeModel繞過(guò)緩存。除了工廠函數(shù)本身我還會(huì)把加載IO的部分隔離出來(lái)比如上例的loadMesh和loadTexture。這樣做的理由是一次文件IO可能耗時(shí)幾十毫秒如果多個(gè)線程同時(shí)請(qǐng)求同一個(gè)資源這個(gè)開銷會(huì)被放大很多倍而把IO放在鎖外做可以大幅提升并發(fā)能力——雖然會(huì)有重復(fù)加載的窗口但比起持鎖做磁盤IO這點(diǎn)浪費(fèi)完全值得。4. 實(shí)戰(zhàn)案例三類最適合C的享元落地場(chǎng)景4.1 資源管理器紋理與網(wǎng)格的共享最經(jīng)典、最好理解的一類應(yīng)用就是圖形和游戲項(xiàng)目里的資源管理器。紋理、網(wǎng)格、材質(zhì)、著色器這些都是高成本、高復(fù)用、低變化的資產(chǎn)天生就是享元的獵物。我在一個(gè)2D橫版游戲項(xiàng)目里做過(guò)統(tǒng)計(jì)游戲里有100多種怪獸配置每種怪獸模型唯一但所有怪獸共享一張精靈圖集sprite atlas共享比例大概是30個(gè)貼圖文件對(duì)應(yīng)5000個(gè)活動(dòng)對(duì)象。改造前的寫法是每個(gè)對(duì)象加載一份貼圖數(shù)據(jù)5000個(gè)對(duì)象會(huì)有數(shù)千次IO和大量?jī)?nèi)存復(fù)制老機(jī)器直接卡死。改造后實(shí)體對(duì)象只持有一個(gè)“紋理句柄編號(hào)”真正的貼圖數(shù)據(jù)只保留30份。內(nèi)存節(jié)省非常直觀貼圖部分從數(shù)千份變成30份實(shí)體對(duì)象體積從原來(lái)的一兩百字節(jié)降到三四十字節(jié)。前端渲染性能的提升更是沒(méi)法只看內(nèi)存數(shù)字——共享貼圖意味著同一個(gè)批次里的CPU緩存命中率提高渲染時(shí)CPU密集型的那部分流程快了很多。4.2 文本渲染字形數(shù)據(jù)的駐留復(fù)用如果你做過(guò)文本渲染一定知道字形glyph數(shù)據(jù)有多貴。每個(gè)漢字字形輪廓可能有幾百個(gè)頂點(diǎn)加上hinting信息和紋理坐標(biāo)單個(gè)字形很容易超過(guò)2KB。一個(gè)界面里有幾百個(gè)漢字如果每個(gè)文本對(duì)象都復(fù)制一份它的字形數(shù)據(jù)那這個(gè)界面的內(nèi)存開銷會(huì)非??鋸?。用享元模式的話我們會(huì)把“字符編碼 字體 字號(hào)”組合成一個(gè)Key緩存對(duì)應(yīng)的字形輪廓數(shù)據(jù)。每個(gè)文本塊對(duì)象只保存一串“字符索引”到字形對(duì)象的引用而不是復(fù)制數(shù)據(jù)本身。對(duì)用戶來(lái)說(shuō)輸入再多的“你好世界”內(nèi)存里也永遠(yuǎn)只有一份“你”的字形。如果再疊加字符串駐留技巧把相同的文本內(nèi)容本身也緩存起來(lái)還能省掉一大塊字符串重復(fù)存儲(chǔ)。字體系統(tǒng)比較特殊的一點(diǎn)是字形對(duì)象本身不需要讓用戶感知到生命周期——字體文件加載后基本常駐內(nèi)存所以這種場(chǎng)景更適合用shared_ptr做強(qiáng)緩存不用擔(dān)心內(nèi)存泄漏之類的問(wèn)題因?yàn)樽煮w資源本身就有上限。4.3 連接配置與對(duì)象池的組合處理既想共享又想復(fù)用的難題第三個(gè)場(chǎng)景我給出的方案比較進(jìn)階因?yàn)樗严碓J胶屯耆煌某鼗夹g(shù)結(jié)合起來(lái)了。假設(shè)你在寫一個(gè)數(shù)據(jù)庫(kù)或消息隊(duì)列客戶端大量線程需要頻繁建立連接。這里的難點(diǎn)在于連接對(duì)象本身絕不是“不可變”的——它有讀緩沖區(qū)的狀態(tài)、有當(dāng)前的協(xié)議狀態(tài)、有網(wǎng)絡(luò)錯(cuò)誤標(biāo)志這些被池化復(fù)用時(shí)必須被清空和重置。所以連接對(duì)象不適合直接做成享元。但連接參數(shù)不一樣IP、端口、用戶名、數(shù)據(jù)庫(kù)名、超時(shí)時(shí)間、SSL配置這些是“描述性”的完全不變。我的做法是把連接參數(shù)做成享元對(duì)象常駐內(nèi)存然后用一個(gè)對(duì)象池保存實(shí)際的連接句柄。每次新請(qǐng)求到來(lái)時(shí)從池子里取出一個(gè)空閑連接順手從享元參數(shù)對(duì)象里讀取配置初始化它。這樣既避開了連接對(duì)象的可變狀態(tài)問(wèn)題又兼得享元對(duì)高成本參數(shù)的復(fù)用。用一句大白話總結(jié)享元管“配方”對(duì)象池管“器皿”。場(chǎng)景共享鍵Key共享的內(nèi)容典型收益紋理管理文件路徑解碼參數(shù)解碼后的像素?cái)?shù)據(jù)、GPU句柄內(nèi)存引用數(shù)量從“對(duì)象數(shù)”降到“資源數(shù)”字形駐留字體字符字號(hào)輪廓頂點(diǎn)、坐標(biāo)、紋理UV文檔渲染內(nèi)存降低一個(gè)數(shù)量級(jí)連接配置IP端口用戶名參數(shù)初始化連接所需的全部配置每個(gè)連接省掉重復(fù)配置構(gòu)造開銷消息/日志過(guò)濾等級(jí)正則規(guī)則編譯后的正則表達(dá)式避免每次使用時(shí)重復(fù)編譯正則4.4 一個(gè)容易忽略的收益對(duì)象構(gòu)造開銷的節(jié)省很多文章講享元模式都在強(qiáng)調(diào)內(nèi)存但實(shí)際上對(duì)象的構(gòu)造開銷和緩存性能同樣重要。創(chuàng)建對(duì)象不只是分配內(nèi)存還包括調(diào)構(gòu)造函數(shù)、初始化虛表、可能觸發(fā)IO或網(wǎng)絡(luò)請(qǐng)求。如果工廠緩存命中的比例高這些開銷會(huì)全部省掉。尤其是在高頻場(chǎng)景——每一幀創(chuàng)建和銷毀大量短生命周期粒子時(shí)享元 對(duì)象池組合能把“創(chuàng)建”簡(jiǎn)化為一次哈希查找這一點(diǎn)在性能分析里往往是實(shí)打?qū)嵤∠聛?lái)的幾個(gè)百分點(diǎn)。5. 高頻坑與排查實(shí)錄5.1 共享狀態(tài)被意外修改一個(gè)經(jīng)典事故我印象最深的一次線上事故發(fā)生在一次重構(gòu)后某個(gè)渲染對(duì)象的color字段本來(lái)是外在狀態(tài)結(jié)果重構(gòu)時(shí)被擺進(jìn)了享元對(duì)象的內(nèi)部數(shù)據(jù)區(qū)。當(dāng)時(shí)代碼里有個(gè)調(diào)色板功能剛開始只有少數(shù)用戶反映“改了A角色的顏色B角色也跟著變了”后來(lái)反饋越來(lái)越多才定位到所有角色其實(shí)共享了同一個(gè)著色器的內(nèi)部可變參數(shù)。這個(gè)問(wèn)題的根源就是內(nèi)部狀態(tài)不是const。從那以后我在設(shè)計(jì)享元類時(shí)強(qiáng)制把內(nèi)部分字段都標(biāo)const并且在 code review 里專門強(qiáng)調(diào)凡是需要 setter 的成員一律移到外部狀態(tài)中。假如你的編譯環(huán)境里 C 標(biāo)準(zhǔn)版本較老暫時(shí)沒(méi)法用簡(jiǎn)潔的const成員初始化套件也可以考慮把可變狀態(tài)用指針或句柄間接暴露并在訪問(wèn)時(shí)都經(jīng)過(guò)嚴(yán)格的互斥保護(hù)。5.2 緩存泄漏shared_ptr無(wú)罪有罪的是引用圖第二種高頻坑是資源管理器運(yùn)行一段時(shí)間后內(nèi)存只漲不降。排查下來(lái)發(fā)現(xiàn)緩存里存了shared_ptr而業(yè)務(wù)方拿到對(duì)象后又往某個(gè)全局容器里塞了一份強(qiáng)引用兩頭的強(qiáng)引用交纏在一起形成引用環(huán)。此時(shí)即便業(yè)務(wù)方已經(jīng)不再使用緩存和全局容器互相“續(xù)命”對(duì)象永遠(yuǎn)釋放不了。解法其實(shí)不難業(yè)務(wù)方的容器盡量使用弱引用語(yǔ)義或者讓緩存直接使用weak_ptr配合常駐所有者管理生命周期。復(fù)雜項(xiàng)目里我建議直接用weak_ptr做緩存值因?yàn)橐坏﹥?nèi)存異常上漲至少能通過(guò)弱引用語(yǔ)義迅速確認(rèn)問(wèn)題不在緩存?zhèn)取?.3 并發(fā)重復(fù)創(chuàng)建雙重檢查鎖定沒(méi)有想象中簡(jiǎn)單我見(jiàn)過(guò)不少代碼把雙重檢查鎖定寫錯(cuò)了最常見(jiàn)的問(wèn)題是在兩個(gè)鎖之間沒(méi)有重新查找緩存就重復(fù)make_shared。這會(huì)導(dǎo)致兩個(gè)線程各創(chuàng)建一個(gè)共享對(duì)象實(shí)例而這兩個(gè)實(shí)例雖然“鍵”相同內(nèi)存卻翻倍。你嘗不到享元模式的紅利還會(huì)在緩存命中率高于預(yù)期時(shí)栽跟頭。正確姿勢(shì)我在前面代碼里已經(jīng)演示了關(guān)鍵在于讀路徑加共享鎖寫路徑加獨(dú)占鎖加獨(dú)占鎖之后必須意識(shí)到可能有其他線程在等待鎖期間已經(jīng)完成寫入因此一定要二次查找。另外如果項(xiàng)目是C11之前的舊標(biāo)準(zhǔn)還沒(méi)有shared_mutex可以用經(jīng)典的讀寫鎖實(shí)現(xiàn)或加一個(gè)std::atomic_flag做快速路徑的標(biāo)記檢查。5.4 哈希表的無(wú)謂分配小心鍵類型帶來(lái)的附加開銷第三個(gè)不太起眼但實(shí)測(cè)很痛的問(wèn)題是鍵的設(shè)計(jì)。如果你的鍵是std::string但業(yè)務(wù)方常常拿string_view或const char*來(lái)查找那么在find()時(shí)會(huì)隱式構(gòu)造臨時(shí)std::string這就引入了一次堆分配。C20里可以用透明哈希解決struct StringHash { using is_transparent void; // 啟用透明查找 size_t operator()(std::string_view key) const noexcept { return std::hashstd::string_view{}(key); } };使用std::unordered_mapstd::string, value, StringHash, std::equal_to之后find可以直接接收const char*或string_view避免創(chuàng)建臨時(shí)字符串。這個(gè)優(yōu)化在每秒執(zhí)行上萬(wàn)次查找的服務(wù)器代碼里效果極其明顯。5.5 過(guò)度共享明明不能共享的東西強(qiáng)行共享最后一個(gè)我見(jiàn)得很多的坑是新人為了用模式而用模式。有些對(duì)象看似同構(gòu)但它的“內(nèi)部狀態(tài)”其實(shí)帶有上下文依賴比如依賴調(diào)用者的渲染管線狀態(tài)或者依賴攝像機(jī)的觀察角。強(qiáng)行把這種對(duì)象塞進(jìn)共享池你會(huì)發(fā)現(xiàn)所有實(shí)例的顯示結(jié)果互相干擾圖形亂得像GIF故障。判斷標(biāo)準(zhǔn)我在第2節(jié)已經(jīng)給了這里補(bǔ)充一個(gè)經(jīng)驗(yàn)法則如果共享前后你能找到哪怕一個(gè)場(chǎng)景讓兩個(gè)實(shí)例的行為產(chǎn)生預(yù)期外的差異那它就不適合共享。寧可錯(cuò)失一點(diǎn)優(yōu)化也不要做有害的抽象。5.6 問(wèn)題速查表癥狀根因排查方向內(nèi)存不斷上漲緩存強(qiáng)引用 業(yè)務(wù)方強(qiáng)引用成環(huán)檢查全局容器是否持強(qiáng)引用改為弱引用多個(gè)實(shí)例數(shù)據(jù)意外重復(fù)鎖內(nèi)未二次檢查加獨(dú)占鎖后重新find一個(gè)對(duì)象改了全部跟著變內(nèi)部狀態(tài)非const拆外內(nèi)外狀態(tài)內(nèi)部字段全部const查找耗時(shí)異常高鍵隱式轉(zhuǎn)換觸發(fā)臨時(shí)string啟用透明哈?;蛴米苑庋b鍵行為結(jié)果相互污染過(guò)度共享了帶上下文的狀態(tài)回歸狀態(tài)劃分把上下文字段移到外部6. 邊界與組合享元模式和對(duì)象池、單例怎么配合6.1 一次講清三個(gè)模式的邊界很多人搞混享元模式、對(duì)象池和單例我在這邊說(shuō)一個(gè)很糙但很有效的區(qū)分方式。單例模式解決的是“全局唯一入口”的問(wèn)題強(qiáng)調(diào)的是身份的唯一性比如日志器、配置中心。享元模式解決的是“重復(fù)內(nèi)容去重”的問(wèn)題強(qiáng)調(diào)的是內(nèi)容的可復(fù)用性比如紋理、字形。對(duì)象池解決的是“高頻創(chuàng)建銷毀”的問(wèn)題強(qiáng)調(diào)的是生命周期復(fù)用比如網(wǎng)絡(luò)連接、線程池。它們不僅不沖突還經(jīng)常需要配合使用。享元對(duì)象本身如果創(chuàng)建成本很高那它里面再套一層對(duì)象池也是常見(jiàn)設(shè)計(jì)。比如一個(gè)粒子系統(tǒng)里的共享材質(zhì)對(duì)象可以一次性Pool一整批然后讓多個(gè)粒子實(shí)例通過(guò)享元句柄引用同一批材質(zhì)。6.2 組合起來(lái)的實(shí)戰(zhàn)架構(gòu)移動(dòng)端小游戲的資源系統(tǒng)我這里給出一套實(shí)戰(zhàn)組合設(shè)計(jì)。移動(dòng)端小游戲?qū)?nèi)存極其敏感資源系統(tǒng)一定得復(fù)用再?gòu)?fù)用。底層用對(duì)象池管理Mesh和Texture的原始數(shù)據(jù)塊。中層用享元工廠管理“邏輯資源”鍵是文件路徑 參數(shù)值是一個(gè)輕量句柄這個(gè)句柄內(nèi)部指向?qū)ο蟪乩锏臄?shù)據(jù)塊。每個(gè)游戲?qū)嶓w持有的是若干個(gè)“句柄”而不是直接把數(shù)據(jù)copy進(jìn)來(lái)。這套架構(gòu)的好處是業(yè)務(wù)代碼完全感知不到數(shù)據(jù)和配置的去重過(guò)程。渲染引擎拿到句柄后能看到共享數(shù)據(jù)只加載一次而對(duì)象的生命周期由對(duì)象池統(tǒng)一回收。實(shí)際上許多商業(yè)引擎的材質(zhì)系統(tǒng)、UI系統(tǒng)和特效系統(tǒng)都是這么設(shè)計(jì)的只不過(guò)底層細(xì)節(jié)各不相同。6.3 配合現(xiàn)代C的極致玩法內(nèi)存池與緩存友好性如果你追求極致性能可以把享元對(duì)象放到一個(gè)連續(xù)的內(nèi)存塊里然后每個(gè)業(yè)務(wù)對(duì)象只保存一個(gè)索引而不是指針。這個(gè)打法比shared_ptr的緩存更省內(nèi)存因?yàn)橹羔槺旧硗ǔJ?字節(jié)換成4字節(jié)的uint32_t索引能省一半引用開銷而且數(shù)組數(shù)據(jù)天然就是CPU緩存友好的連續(xù)內(nèi)存——遍歷100萬(wàn)個(gè)粒子時(shí)共享的那份數(shù)據(jù)大概率常駐L2緩存。配合pmr::monotonic_buffer_resource或者自己寫個(gè)簡(jiǎn)單的arena分配器可以為共享對(duì)象分配一塊大的連續(xù)內(nèi)存所有make_shared都從這塊內(nèi)存取。這樣對(duì)象的創(chuàng)建幾乎零開銷內(nèi)存回收也可以一次性整塊釋放省去逐對(duì)象析構(gòu)的調(diào)零開銷。這已經(jīng)屬于C里比較進(jìn)階的內(nèi)存技巧但和享元模式組合起來(lái)效果非常驚人。6.4 什么時(shí)候堅(jiān)決別用享元模式最后必須潑一盆冷水。如果對(duì)象的數(shù)量本來(lái)就不大幾千個(gè)以內(nèi)或者可共享數(shù)據(jù)占比很低或者對(duì)象的“內(nèi)在狀態(tài)”幾乎每個(gè)實(shí)例都不同那么引入工廠和哈希表反而會(huì)帶來(lái)額外的查找開銷和代碼復(fù)雜度。我等價(jià)換算一下一次哈希查找大概耗幾十納秒而復(fù)制一份小體積的std::string可能只要幾個(gè)納秒。別為了省那點(diǎn)內(nèi)存賠上運(yùn)行時(shí)性能和維護(hù)成本。我個(gè)人在實(shí)際項(xiàng)目里的判斷標(biāo)準(zhǔn)非常簡(jiǎn)單先估算對(duì)象數(shù)量的上限和可共享數(shù)據(jù)的大小如果節(jié)省出來(lái)的內(nèi)存超過(guò)總工程內(nèi)存的20%才值得引入享元模式否則普通的多實(shí)例模型反而更簡(jiǎn)單可靠。這個(gè)閾值不是絕對(duì)的但能擋掉至少一半為用而用的過(guò)度設(shè)計(jì)。再分享一個(gè)擴(kuò)展方向如果你正在寫一個(gè)編譯器或解析器可以嘗試把享元和字符串駐留結(jié)合這樣能讓AST中的類型名、變量名共用同一份存儲(chǔ)配合自定義arena分配器整個(gè)編譯器的內(nèi)存占用能降低30%以上。我的建議是別把這個(gè)模式當(dāng)成面試題答案背下來(lái)而是把它當(dāng)成一把“內(nèi)存放大鏡”拿它去照你項(xiàng)目里那些看起來(lái)體積巨大、重復(fù)度又高的對(duì)象照出來(lái)一個(gè)就復(fù)用一層的價(jià)值。