Community程式設計與開發github.com

george-andrikopoulos/domain-low-latency

針對低延遲環境的工程準則。涵蓋:第二輪審查時攻擊設計;測量必須符合其報告的運行模式;無限制佇列不存在超載行為;資料結構必須保持其證明時所假設的並發模式;已分配不代表已駐留;在正確的抽象層次滿足需求;熱路徑上不得分配記憶體;無協調遺漏;熱快取行上無偽共享;競爭路徑上無重試迴圈;發佈視窗內無停頓;有界路徑上無系統呼叫;……

domain-low-latency 是什麼?

這是一個軟體工程技能,專門用於設計和實作低延遲系統。它定義了一套嚴格的規則,指導開發者如何避免在關鍵路徑上出現延遲瓶頸,例如確保記憶體分配不會發生在熱路徑上、佇列必須有明確的上限行為、測量結果必須反映真實的生產環境運行模式等。此技能主要針對 Rust 和 Java 開發者,因為這兩個語言常被用於高效能、低延遲的場景(如金融交易、即時系統)。技能基於 JMH(Java Microbenchmarking Harness)、Little's Law、Dmitry Vyukov 的無鎖佇列研究等經典文獻,提供可操作的檢查點,幫助開發者避免常見的性能陷阱,並確保系統行為可預測、可測量。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/george-andrikopoulos/relearn/tree/HEAD/claude-pack/skills/domain-low-latency

Installed? Explore more 程式設計與開發 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

領域:低延遲規則

測量結果必須聲明是熱運行還是冷運行,並與生產環境匹配 [R:測量結果必須符合其報告的運行模式]

為 Rust 和 Java 受眾撰寫。

源自 JMH 及其範例(Aleksey Shipilev),其存在本身就是一個主張:在 JVM 上手動編寫的微基準測試預設就是錯誤的。

聲明測量是在哪種模式下運行的,並檢查該模式是否與生產環境一致。 此處存在兩種錯誤,但只有其中一種名聲在外。

在生產環境為熱運行時測量冷運行。 第一次執行時,程式碼由直譯器執行或從冷的指令快取開始,分支預測器未經訓練,頁表未填充,且在託管運行時環境中,沒有經過編譯的程式碼。其性能數字可能比穩定狀態差一到兩個數量級,並且它描述的是預熱過程而非實際工作。在 JVM 上,升級是分階段的——直譯器、C1、C2,並對長時間運行的循環進行棧上替換——這需要數千次迭代,這就是為什麼手動編寫的計時循環預設是錯誤的,以及為什麼需要一個測試框架。

在生產環境為冷運行時測量熱運行,這是另一半不受關注的問題,因為它看起來像是一絲不苟。每分鐘運行一次的路徑永遠不會變熱。市場開盤的突發流量進入的是冷程式碼。錯誤處理程式在一天中第一次運行時恰好是最糟糕的時刻。對於這些情況中的每一種,熱運行數字都是使用者永遠看不到的數字,而那個被仔細丟棄的預熱過程就是生產行為。在那種情況下,丟棄它並非是衛生習慣;而是在刪除測量結果。

所以,這條規則是一個範圍匹配,而非一個程序:說明測量的是哪種模式,說明生產環境處於哪種模式,並證明兩者相同。如果生產環境確實是兩種模式並存——穩定的數據流伴有冷啟動的爆發——那就應該有兩個測量結果和兩個報告數字,而不是一個被平均到毫無意義的數字。

這條規則本質上是 [R:測量主張本身,而非其子集] 應用於時間而非集合:預熱樣本是一個子集,丟棄它們是一個範圍決策,而一個由反射性做出的範圍決策,會製造出一個錯誤的數字,卻又帶著被測量過的權威。

故障模式檢查:在關鍵時刻,生產路徑是熱的還是冷的?而我又測量了哪一種? 如果基準測試丟棄了前 N 次迭代,請指出生產環境中與這些迭代相對應的是什麼。如果沒有任何東西對應,那麼被丟棄的部分就是答案。

兩個鄰近概念,故意分開說明。[R:無協調遺漏] 指的是儀器未能在重要事件發生時採集樣本;而這裡指的是儀器在錯誤的模式下採集了樣本。[R:驗證抽象是否被編譯器優化掉] 則涵蓋了優化器移除了基準測試正在計時的工作的情況——一個對什麼都沒做的熱運行測量。

無限制佇列將超載轉化為延遲而非拒絕 [R:無限制佇列不存在超載行為]

為 Rust 和 Java 受眾撰寫。

源自利特爾定律(J. D. C. Little,1961):L = λW,因此對於一個從不拒絕的佇列,一旦到達率超過服務率,等待時間將無限制增長。

決定佇列在消費速度落後於生產速度時該做什麼。增長並非一個決定。

佇列深度就是延遲——這是利特爾定律,而非經驗法則:假設到達率為 λ,平均佇列長度為 L,則平均等待時間為 L/λ,因此無限制的 L 意味著無限制的等待時間。一個沒有容量限制的佇列因此就沒有延遲上限,而在其上游任何地方聲明的延遲要求都將無法得到保證。

這種失敗有一個特徵形狀,使得診斷來得很晚。生產者永遠不會阻塞,所以上游沒有任何事情報告問題;吞吐量看起來是正確的,因為每個項目最終都被處理了;唯一的症狀是延遲單調上升。等到它變得可見時,積壓的任務正在做沒人想要的工作——每個被消費的項目都已經過時了,所以容量被用於提供那些時機已過的請求的答案,這就是為什麼當負載恢復正常時系統無法恢復。它必須先排空積壓,且排空速度與之前落後時的速度相同。

因此,為佇列設定上限,並選擇在上限處的行為。有四種合法的行為,選擇是一個領域決策,而不是技術決策:

  • 阻塞生產者。 真正的背壓——最慢的消費者為所有人設定速率,並且壓力會傳遞給實際能夠減輕它的人。在生產者不得被停止的情況下是錯誤的,這正是 [R:發佈視窗內無停頓] 所討論的模式。
  • 丟棄最舊的。 對於市場數據、感測器讀數、遙測數據來說是正確的:過時的報價具有負面價值,而最新的報價才是唯一有價值的。
  • 丟棄最新的。 在積壓是一個公平佇列且後來者未被承諾任何東西的情況下是正確的。
  • 拒絕,並明確告知。 對於訂單輸入以及任何呼叫者必須知道其結果的情況來說是正確的。一個被靜默丟棄的訂單比一個被拒絕的訂單更糟糕,其糟糕程度相當於呼叫者對該呼叫所投入的全部信任。

不做選擇實際上是選擇了第五種沒有人會故意選擇的行為:吸收一切,同樣地降低每個項目的延遲,最終因耗盡記憶體而失敗——或者在託管運行時環境中,由積壓本身導致的垃圾回收停頓會進一步減慢消費者的速度,這是在生產環境中最接近錯誤符號的反饋迴路。

無限制結構仍然是在上限真正位於別處時的正確選擇——一個生產者已在上游被限流的佇列,或者一個消費者被證明比任何可能的到達率都快的情況。請在程式碼中,緊鄰該佇列,說明是哪種情況。一個有明確理由的無限制佇列是一個決策;而一個沒有理由的無限制佇列則是披著決策外衣的決策缺失。

故障模式檢查:如果消費在十秒內落後於生產,這個佇列會做什麼,誰會發現? 如果答案是「它會增長」和「沒人會發現」,那麼這裡就沒有超載行為,只有一個等待發生的超載症狀。

資料結構僅在其證明所假設的並發模式下才是正確的 [R:資料結構必須保持其證明時所假設的並發模式]

為 Rust 和 Java 受眾撰寫。

源自 Dmitry Vyukov,侵入式 MPSC 基於節點的佇列,1024cores——其中模式在演算法名稱中明確說明,但程式碼中卻沒有。

從資料結構的正確性論證中讀取其假設的並發模式,然後在程式碼中指出是什麼機制防止了違反該模式。「文檔上說是單消費者」並非一個機制。

MPSC 代表多生產者、單一消費者,而這個「單一」是一個前置條件而非描述。Vyukov 的 mpscq_pop 讀取 self->tail,從那裡開始走訪,然後寫回——一個普通的載入和一個普通的儲存,沒有同步,因為假設只有一個執行緒在執行它們。兩個消費者在此處交錯執行,可能導致兩者返回同一個節點,或者其中一個完全丟失一個節點。這兩種結果都不會引發任何異常。沒有斷言、沒有鎖競爭、沒有錯誤路徑:佇列返回看似合理的值,程式用一個重複或消失的項目繼續運行。

這種靜默正是問題的全部,而且它會隨著負載的增加而加劇。兩個消費者運行一百次操作可能永遠不會在關鍵的視窗內交錯;測試套件通過,預發佈環境的長時間測試通過,而交錯卻隨著生產流量到來。一個只在無法重現的條件下出現的缺陷,從外部看來,與其他地方的缺陷無法區分。

因此,在語言允許的情況下,將模式放入型別系統中。在構造時只發放一個消費者控制代碼;使其既不可被 Clone 也不可被 Sync;在 pop 方法上使用 &mut self,以便借用檢查器強制排他性。那麼,「兩個消費者」就不再是一個需要發現的錯誤,而是一個根本無法編譯的程式——[R:使非法狀態不可表示],並且在模式隨資料結構生命週期變化的情況下使用 [R:協定類型狀態模式]。這是整個領域中該紀律最具槓桿效應的實例,因為另一種控制手段只是頭文件註解中的一句話,而它所防止的失敗卻是無法歸因的。

在語言無法承擔的情況下——Java 沒有這樣的控制代碼——控制手段必須是真正會運行的東西:在首次使用時記錄所屬執行緒,並在後續每次調用時(在 debug 建構中)進行斷言檢查,或者將所有消費路由到一個單一的物件,該物件的構造過程是唯一聲明該不變量的地方。註解不是一種控制手段([R:保證需要一個讀者])。

這個類別比佇列更廣泛,其他成員也以同樣的方式失效。一個有兩個生產者的 SPSC 環形緩衝區。一個被文檔記錄為不可重入,卻從信號處理程式或回調中進入的資料結構。一個從兩個執行緒中訪問的 HashMap,因為持有它的字段在三次重構後變成了共享的。一個在發生變異時持有的迭代器。在每種情況下,模式都是正確性論證的一個假設,而工件本身沒有任何東西記錄它。

故障模式檢查:這個資料結構的正確性論證假設了什麼模式,如果我現在違反它,會發生什麼失敗? 如果誠實的答案是「名稱上這麼說」或「我們很小心」,那麼這個資料結構是未受保護的,下一個接觸它的人不會知道。

從未被觸碰過的記憶體還不是真正的記憶體 [R:已分配不代表已駐留]

為 Rust 和 Java 受眾撰寫。

源自 Ulrich Drepper,《每個程式設計師都應該了解的記憶體知識》(2007),關於按需分頁和首次觸碰的成本。

預先分配不等於準備就緒。一次成功的分配買到的是位址空間;頁面在首次觸碰時才會到達,而這個觸碰才是真正的成本。

malloc 和 mmap 返回的是虛擬位址。實體頁面是懶惰供應的:對每個頁面的首次寫入會陷入核心,核心找到一個頁框,將其清零——因為前一個租戶的數據不得洩露——然後將其映射。因此,一個在啟動時分配並在熱路徑上首次寫入的 1 GB 緩衝區,包含大約 25 萬次頁面錯誤,每次錯誤處理一個頁面,分佈在第一次遍歷它的過程中。該路徑上所有關於無分配的斷言都得到滿足,但它們每個都在測量錯誤的東西:沒有東西調用過分配器。

駐留狀態一旦實現也並非永久。頁面可能在記憶體壓力下被回收或被交換出去,因此一個長時間運行的行程,如果在啟動時觸碰了它的緩衝區然後讓它們空閒著,可能會在一條已經一週沒有頁面錯誤的路徑上再次發生錯誤。這就是為什麼補救措施有兩部分,以及為什麼只做第一部分是一個常見且代價高昂的錯誤。

因此,對於任何有截止期限的路徑:在啟動時,在任何實際工作到達之前,寫入該路徑會觸碰到的每個頁面——這個觸碰必須是寫入,因為對未觸碰頁面的讀取可能由共享的零頁提供服務,這無法證明任何事情。然後鎖定它:使用 mlockall(MCL_CURRENT | MCL_FUTURE),並且在機器上禁用交換。在 JVM 上,-XX:+AlwaysPreTouch 為堆記憶體完成了第一部分,值得為此付出更慢的啟動時間,因為成本被轉移到了有界限的路徑之外。

大頁面在兩個方向上都改變了算術,應該得到自己的決策,而不是使用預設值。更少、更大的頁面意味著更少的錯誤和更少的 TLB 壓力;每次錯誤的代價更大,並且透明大頁面帶來了一條記憶體碎片整理路徑,這可能在最糟糕的時刻使執行緒停頓數毫秒。在啟動時保留的顯式大頁面可以避免這種情況;將 THP 設為 always 並祈禱,是這個領域不得不反覆指出的陷阱形狀。

由將擁有記憶體的執行緒來觸碰,而不是執行設定的執行緒。 在多插槽機器上,首次寫入也決定了放置位置:頁面分配在觸發錯誤的執行緒所在的 NUMA 節點上,因此滿足此規則最直觀的方式——在初始化執行緒中有一個循環遍歷每個緩衝區——會將每個頁面放在該執行緒的節點上,然後一個固定在另一個插槽上的工作者將通過互連線路讀取所有這些記憶體,其延遲大約是本機延遲的兩倍。這條路徑現在沒有錯誤了,但完全變成了遠端訪問:用更快的第一條訊息換來了更慢的穩定狀態。首先固定擁有記憶體的執行緒,然後讓它們每個都觸碰自己將要使用的記憶體。

故障模式檢查:這條路徑將觸碰的每個頁面,是否都已被將要讀取它的執行緒寫入過?以及有什麼阻止了它被回收? 三個問題,因為只回答第一個問題會留下一個在空閒之前很快、但空閒之後變慢的系統;只回答前兩個問題則會留下一個在單一插槽上很快的系統。

[R:熱路徑上不得分配記憶體] 是此規則的兄弟,兩者互不包含——那一條關於進入分配器,而這一條關於分配器已經返回的記憶體。 [R:在正確的抽象層次滿足需求] 說明了為什麼補救措施全都是記憶體策略、鎖或啟動參數:沒有任何容器或程式庫的選擇能觸及這個層次。 [R:發佈視窗內無停頓] 是它影響最嚴重的地方,因為在一次寫入的兩次儲存之間發生的頁面錯誤會將視窗保持打開狀態,持續其整個持續時間。

在需求所處的抽象層次滿足它 [R:在正確的抽象層次滿足需求]

為 Rust 和 Java 受眾撰寫。

在做出任何選擇之前,先確定一個需求存在於哪個抽象層次,然後在那個層次滿足它。

一個關於機器的需求——不要干擾鄰近的隔離核心,不要在發送路徑上觸發頁面錯誤,不要獲取一個即時執行緒會競爭的鎖——不能通過選擇一個更快的程式庫、一個名聲更好的傳輸協議或一個非同步 API 來滿足。這些是程式庫層次的決策。它們可能是正確的,甚至可能有所幫助,但它們並不保證一個系統層次的約束,而將它們視為保證,會將一個開放問題轉變為一個封閉問題,背後卻空無一物。

失敗的並非是錯誤的選擇;而是錯誤的記帳條目。一個被標記為已滿足的約束就不再被審查。一個開放的約束仍然是一個開放的約束。

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

george-andrikopoulos/domain-java

Engineering discipline for working in java. Covers: a speculated path deoptimises when the input changes; a view is not a copy; a wrapper type is not free here; close what you open; design for inheritance or forbid it; equality is one contract; exceptions name what failed; identity is not equality for boxes; no reference to internals escapes; null is not a value; publish safely or not at all; seal the alternatives; serializable is a second constructor

george-andrikopoulos/domain-rust

Engineering discipline for working in rust. Covers: design types first; borrow in signatures; module visibility is deliberate; must use on consequential returns; newtype liberally; parse wide then range check; seal closed trait sets; typestate builder for required fields; typestate for protocols; verify the abstraction compiled away; async all the way; errors name what failed; justify every clone; no let underscore on a local; parse dont validate; private fields only; xplat fixtures; no...

george-andrikopoulos/global

Engineering discipline that applies to every project and language. Covers: guarantee needs a reader; verdict survives the channel; check the claim you inherit; doc currency; measure the claim not a subset; no silent spend; prefer by construction; repair the lying artefact; signal needs a consequence; verify through production path; detector excludes own definitions; an ambiguous write target is a question; anchor outside the checked partys reach; delegate a fan out; diff against the known...

相關技能