記憶體優化規劃書(交接用)
本文件是給接手 agent 的工作規劃。每一項都附上檔案位置、目前的證據、建議作法與驗收標準。 接手時請依優先序逐項開獨立 PR,不要把多個項目混在一個 PR 內。
2026-09-07 實測補充
PR #4 已補 Windows/Linux 驗證與七輪 WSL2 A/B,見效能報告。4,096 筆 DNS cache 的 RSS 中位數 −2.5%,但前後範圍重疊;1,000 條 TCP 未省 RAM。大型 pool Get/Put 與完整 DNS 訊息快取命中有明確時間回退,後者配置量由 252 B/5 allocs 增至 516 B/10 allocs。自然 GC 下的 TCP heap profile 尚不足以完成 P0-1 歸因,以下工作不可標成已驗收。
0. 現況與已完成事項
| 項目 | 狀態 | 位置 |
|---|---|---|
| 16 KiB–128 KiB 配置器 slab 改為有上限的 channel pool,連線爆量後不再無限保留 | 已完成,PR #4 | common/pool/alloc.go、common/pool/alloc_cap*.go |
DNS cache 純 A/AAAA 回應改存 []netip.Addr,只有 CNAME/SOA/附加區段才存完整 *dns.Msg | 已完成,PR #4 | dns/msg_cache.go、dns/resolver.go、dns/util.go |
| UDP NAT 表上限(8192/低記憶體 2048) | 既有 | component/nat/table.go |
bytes.Buffer 超過 128 KiB 不回全域 pool | 既有 | common/pool/buffer.go |
低記憶體 build tag with_low_memory 縮小 relay/UDP buffer | 既有 | common/pool/buffer_low_memory.go |
docs/reference/performance.md 的 2026-09-05 整程序 A/B 已指出目前最大的缺口:
- 空載 working set 約 19 MiB;持有 1,000 條 TCP 時為 113–115 MiB,釋放後 60 秒仍為 122 MiB。
- 亦即每條閒置 TCP 連線約佔 85–95 KiB,而且釋放後不回落。
本輪在 common/net 用 1,000 對 net.Pipe 走 N.Relay 的臨時探針(探針檔已移除,未提交)量到:
goroutines=2002
HeapInuse: 640 -> 68832 KiB (+68192 KiB, 68.2 KiB/conn)
StackInuse: 544 -> 8544 KiB (8.0 KiB/conn)net.Pipe 不是 ExtendedConn 也不是 syscall.Conn,所以這個探針只量到 copyConn 的備援路徑:兩個方向各持有一塊 32 KiB 的 pool.RelayBufferSize buffer,並在整條連線閒置期間不放手。實際 tunnel 路徑走的是 sing 的 bufio.Copy,各分支的閒置成本不同,詳見 P0-1。歸因並削減每條連線的常駐成本是最高優先級。
1. 量測方法(每個項目改動前後都要跑)
- 整程序情境:沿用
docs/reference/performance.md的最小設定(Rule 模式 +MATCH,DIRECT、DNS/TUN 關閉、loopback SOCKS),情境至少包含:空載、持有 100/1,000 條 TCP、釋放後 60 秒、1,000 個 UDP flow 各 queue 若干封包。每情境七輪、新程序、前後交錯。Linux 取/proc/<pid>/status的VmRSS/VmHWM,Windows 取 working set 與 private bytes;兩者不可互換比較。 - Heap 歸因:用
hub/route的/debug/pprof/heap(需external-controller開 debug)在「持有 1,000 條 TCP」時抓inuse_space,以go tool pprof -top確認 top 3 符號。這是驗收「錢花在哪」的依據,不是用來取代整程序數字。 - Microbenchmark:
go test -run '^$' -bench ... -benchmem -count=7 -cpu=1,每個 case 用go test -c建 test binary、新程序執行,見docs/reference/performance.md的「如何重跑」。 - 禁止事項:不設定
GOGC/GOMEMLIMIT、不呼叫debug.FreeOSMemory()或runtime.GC()來美化數字(/debug/gc端點除外,它是既有的手動工具)。不能拿 B/op 的下降直接宣稱 RSS 下降。
2. 工作項目
P0-0 決策項:閒置後 heap 不回落是 Go runtime 行為,需要使用者裁決
- 證據:
docs/reference/performance.md的「1,000 條 TCP 釋放後 60 秒」working set 仍 122 MiB,高於持有 1,000 條時的 115 MiB。連線全部關閉後配置速率趨近 0,Go GC 不會被觸發,sync.Pool(Aster 與 sing 兩份)與已死物件所在的 span 都不會被回收,scavenger 也就沒有可歸還 OS 的 free span。 - 現行約束:專案文件明訂「不使用 GC 調參」。在此約束下,這個情境無法靠任何 pool 或資料結構改動改善,只能靠降低峰值(P0-1、P0-2)間接降低釋放後的殘留。
- 請使用者選擇其一並記錄在本文件:
- (A) 維持約束,接受釋放後不回落,文件如實標註。
- (B) 允許一個保守、可關閉的閒置回收器:例如連線數歸零且持續 N 分鐘沒有新連線、且
HeapInuse低於 RSS 的一半時,呼叫一次debug.FreeOSMemory();預設關閉或只在with_low_memory開啟。這仍屬 GC 干預,需明確授權;hub/route/server.go已有手動/debug/gc端點可作為行為參考。
- 未取得裁決前,接手者一律採 (A)。
P0-1 每條 TCP 連線的常駐成本:先歸因,再削減閒置時持有的 relay buffer
- 檔案:
common/net/sing.go(copyConn、Relay)、context/conn.go(inbound 一律包成N.BufferedConn)、constant/adapters.go(C.Conn內嵌N.ExtendedConn)、tunnel/statistic/tracker.go、common/pool/buffer_*.go;相依:github.com/metacubex/sing/common/bufio/copy.go、copy_direct*.go、common/buf/buffer_standard.go(singBufferSize = 32 KiB)。 - 實際路徑分析(接手者請先讀懂再動手):
- tunnel 內 inbound 是
*N.BufferedConn(ExtendedConn+CachedReader+ReaderPossiblyReplaceable),outbound 是C.Conn(ExtendedConn),所以copyConn的if sourceExtended || ...幾乎必定成立,走 singbufio.Copy;pool.Get(pool.RelayBufferSize)的備援迴圈只有在兩端都是裸net.Conn時才會用到(例如測試、少數 listener 直接 relay)。 - sing
bufio.Copy分三支:(a) 兩端解包後都是syscall.Conn(DIRECT TCP↔TCP)→copyDirect在 Linux 用 splice,不持有使用者空間 buffer;(b) 來源是syscall.Conn但目的不是(DIRECT 回程寫進 TLS 等)→syscallReadWaiter,用RawConn.Read等可讀後才NewBuffer(),閒置時 heap 接近 0,posix 與 Windows 都有實作;(c) 來源不是syscall.Conn(TLS、VMess、Trojan、Hysteria、AnyTLS 等所有代理 outbound 的下行方向;以及 inbound 若是 TLS listener)→CopyExtendedWithPool,每圈options.NewBuffer()後在ReadBuffer阻塞期間持有一塊 32 KiB sing buffer(低記憶體 tag 為 16 KiB)。 - 因此「代理節點下行方向」這個最主要的使用情境,每條閒置連線至少持有 32 KiB sing buffer,再加上
crypto/tls自身每條連線的讀寫 record buffer(約 16–34 KiB)。1,000 條走 TLS 代理的閒置連線就是 32–66 MiB。這與docs/reference/performance.md的 SOCKS→DIRECT 情境不同;該情境的 85–95 KiB/conn 來源尚未歸因(見下方第一個任務)。 - sing 的
bufpool 與 Aster 的common/pool一樣是無上限sync.Pool;PR #4 只替 Aster 自己的 16 KiB+ 級距加了上限,sing 那份沒有。
- tunnel 內 inbound 是
- 任務:
- 歸因:在 Linux 建兩個 1,000 條閒置連線情境並抓
inuse_spaceheap profile:(i) SOCKS→DIRECT loopback echo(對應現有文件數字),(ii) SOCKS→loopback TLS 代理(trojan 或 vmess+tls 服務端)。把 top 5 符號與每條連線的 bytes 寫進 PR,確認 (i) 的 85–95 KiB 究竟是 goroutine stack、BufferedConn的 bufio、tracker、sync.Pool 殘留還是 heap 碎片。沒有這步不得宣稱任何項目「省了多少」。 - 備援路徑(確定會有收益、改動範圍小):把
copyConn的固定 32 KiB 改成自適應:起始 4–8 KiB,連續 N 次readN == len(buffer)升一級(上限RelayBufferSize),連續 M 次readN < len(buffer)/4降一級並歸還。common/net/bufconn_unsafe.go已有類似的 bufio 放大邏輯可參考。 - sing (c) 分支(主要收益,改動較大):Aster 已擁有自己的 copy 迴圈,可把
CopyExtendedWithPool的邏輯搬進common/net,改為同樣的自適應大小,並在ReadBuffer阻塞前只持有小塊。必須維持N.NewReadWaitOptions的 front/rear headroom(VMess、Shadowsocks 等協定要靠它零複製加 header),所以小塊也要保留 headroom;ReadBuffer對「容量比 MTU 小的 buffer」是否安全需逐協定確認(sing-shadowsocks、sing-vmess、AnyTLS、Hysteria2 的ReadBuffer實作)。 - 保留現有的
readCounters/writeCounters、ReportHandshakeFailure、closeWrite、ReadCached語意;splice 與 readWaiter 分支不要動。
- 歸因:在 Linux 建兩個 1,000 條閒置連線情境並抓
- 驗收標準:
- 歸因報告(任務 1)附在 PR 內,兩個情境都有 heap top 5 與 per-conn bytes。
net.Pipe探針(1,000 對閒置 relay)heap 由 68 KiB/conn 降到 ≤ 16 KiB/conn(任務 2)。- 新增「1,000 條閒置 TLS 代理連線」整程序情境;任務 3 完成後 working set 中位數下降且七輪範圍不重疊。
- 既有
Relay32KiBComparison(common/net/relay_comparison_test.go)與 tunnel relay benchmark 吞吐回退不超過 3%,維持 0 allocs/op 的 case 仍為 0。 - 新增測試:小流量連線不升級、持續滿讀連線在數次 read 內升級、降級後 buffer 正確歸還、headroom 協定(至少 VMess 與 Shadowsocks)在小 buffer 下 round-trip 正確。
- 風險:升級門檻太保守會讓下載初期吞吐下降,須以持續 32 KiB 寫入的 benchmark 佐證升級在少數幾圈內完成;任務 3 若某協定的
ReadBuffer假設 buffer 容量 ≥ MTU,會直接讀壞資料,必須有 round-trip 測試。
P0-2 每個排隊中的 UDP 封包都釘住一塊 16 KiB slab
- 檔案:
common/net/packet/packet.go(waitReadFrom、enhanceUDPConn.WaitReadFrom)、common/net/packet/packet_posix.go、listener/tproxy/udp.go、listener/tproxy/packet.go、listener/tunnel/udp.go、tunnel/tunnel.go(queueCapacity=64、senderCapacity=128)、tunnel/connection.go(packetSender.ch)。 - 問題:讀取 UDP 時固定
pool.Get(pool.UDPBufferSize)(16 KiB,低記憶體 8 KiB),封包實際大小常見只有 60–1,400 bytes。封包進入packetSender.ch(每個 NAT entry 128 格)與 worker queue(每 worker 64 格)排隊期間,整塊 16 KiB 都被保留。理論上限是 NAT 8192 entries × 128 × 16 KiB,遠超任何實機 RAM;實務上一個目的端卡住的 UDP flow 就能釘住 2 MiB。 - 建議作法:
- 讀完後若
readN <= 某門檻(例如 2 KiB),pool.Get(readN)複製一份、立刻歸還 16 KiB 讀取 buffer;put改釘小塊。複製幾百 bytes 的成本遠低於釘住 16 KiB。門檻與是否複製要用BenchmarkPacketMetadata/handlePacket類 benchmark 確認 ns/op 與 allocs 不惡化。 - 另行評估全域「在途 UDP bytes 上限」(例如 4 MiB/低記憶體 1 MiB),超限時在
Send直接Drop(),並在udpNATAdmissionDrops之外增加計數器暴露到/memory或 log。
- 讀完後若
- 驗收標準:
- 新增 benchmark/test:1,000 個 flow、每 flow 排隊 32 個 200-byte 封包時,heap inuse 由約 500 MiB 級降到 ≤ 20 MiB 級(實際數字在實作時量測寫入 PR)。
listener/sing的handlePacket與tunnel的 UDP benchmark ns/op 變動在 ±5% 內。
- 風險:
sing路徑(listener/sing/sing.go的WaitReadPacket)用的是 sing 的buf.Bufferpool,不在此改動範圍;改動前先確認哪些 listener 真的走common/net/packet。
P1-3 QUIC 接收視窗預設值未隨 with_low_memory 縮小
- 檔案:
transport/tuic/common/congestion.go(DefaultStreamReceiveWindow = 15 MiB、DefaultConnectionReceiveWindow = 64 MiB)、adapter/outbound/tuic.go、adapter/outbound/hysteria.go、adapter/outbound/shadowquic.go、listener/tuic/server.go、listener/shadowquic/server.go、adapter/outbound/hysteria2.go。 - 問題:這些預設值決定 quic-go 每條連線最多可緩衝多少未讀資料。單一 TUIC/Hysteria 連線在高 RTT、接收端消費慢時最多可佔 64 MiB,且完全不受
with_low_memory影響。Hysteria2 在未設定時交給 sing-quic 的預設,行為需查證。 - 建議作法:
- 先量測:在 loopback 上對 TUIC 與 Hysteria 做「接收端刻意慢讀」情境,抓 heap 確認 quic-go 緩衝真的成長到接近視窗。
- 若確認,為
with_low_memory提供較小預設(例如 stream 2 MiB/connection 8 MiB),一般 build 維持不變;設定檔已提供recv-window/recv-window-conn覆寫。 - 在
docs/reference/performance.md與docs/tutorials說明低記憶體 build 的預設變更與吞吐取捨。
- 驗收標準:低記憶體 build 的慢讀情境 heap 上限明顯下降;一般 build 的吞吐 benchmark 無變化(因為未改動)。
- 風險:縮小視窗會直接限制高 RTT 下的單流吞吐,所以只做在
with_low_memory,且必須寫進文件。
P1-4 連線面板 WebSocket 每秒完整序列化所有連線
- 檔案:
hub/route/connections.go(getConnections、sendSnapshot)、tunnel/statistic/manager.go(Snapshot)、tunnel/statistic/tracker.go(TrackerInfo)。 - 問題:每個 WS 客戶端每
interval(預設 1 秒)呼叫Manager.Snapshot(),connectionsslice 從 nil 開始append成長,再用json.NewEncoder序列化每條連線的完整Metadata(20 多個欄位,含SrcGeoIP/DstGeoIPslice 與多個字串)。10,000 條連線時每秒產生數 MB 垃圾,推高 GC 頻率與 heap 峰值;bytes.Buffer會長到最大快照大小並在 WS 存活期間保留。 - 建議作法:
Snapshot()用m.connections.Size()預先配置 slice 容量(一行改動)。- 為
TrackerInfo實作MarshalJSON或改用json.Encoder直接寫入,避免中間Snapshot結構;評估jsoniter/encoding/json/v2是否已在依賴中。 - 可選:多個 WS 客戶端共用同一份每秒快照(單一 ticker 產生、廣播給所有訂閱者)。
- 驗收標準:新增
BenchmarkConnectionsSnapshotJSON(1,000/10,000 條假 tracker),B/op 與 allocs/op 下降 ≥ 50%;輸出 JSON 與現行完全相容(用 golden test 比對)。
P2-5 LruCache 每筆額外開銷與 stale 模式只靠容量淘汰
- 檔案:
common/lru/lrucache.go、使用者:dns/resolver.go(lru.WithStale(true))、dns/enhancer.go(4096 筆 fake-IP 反查)、component/fakeip/memory.go、component/sniffer/dispatcher.go。 - 問題:每筆項目 =
entry{key, value, expires}+list.Element(4 個指標)+ map slot,約 80–100 bytes 額外開銷,且 Go map 刪除後不縮小。DNS 走lru演算法時WithStale(true)讓maybeDeleteOldest完全不做事,過期項目只在達到CacheMaxSize時才淘汰;這是刻意設計(stale-while-revalidate),但意味 cache 幾乎永遠滿載。 - 建議作法:先量測 4,096/65,536 筆時的實際 bytes/entry;若開銷 > 30%,評估以索引型 intrusive list(
[]entry+int32prev/next)取代generic-list-go。不要改動 stale 語意或降低容量上限(見第 3 節約束)。 - 驗收標準:
common/lrubenchmark 的 bytes/entry 下降且Get/Setns/op 不變差;所有既有 lru 測試通過。
P2-6 memconservative 每載入一個 GeoIP/GeoSite 清單就強制 runtime.GC()
- 檔案:
component/geodata/memconservative/memc.go(defer runtime.GC()×2)、component/geodata/utils.go。 - 問題:設定檔有 N 條
GEOSITE/GEOIP規則就在啟動時觸發 N 次 full GC。這是 CPU/啟動時間問題而不是常駐記憶體問題,但與專案「不做 GC 調參」的立場衝突,且loadGeoSiteMatcherListSF已把原始 list 設為不保留,強制 GC 的原始理由可能已不存在。 - 建議作法:量測 50 條 geosite 規則的啟動時間與
VmHWM,比較保留/移除runtime.GC()兩版;只有在峰值不惡化時才移除。若峰值惡化,改為「整批規則載入完成後 GC 一次」而非每筆一次。 - 驗收標準:啟動時間下降、
VmHWM不上升,兩者皆需七輪中位數。
P2-7 TrackerInfo.Metadata 保留整個 Metadata 直到連線結束
- 檔案:
tunnel/statistic/tracker.go、constant/metadata.go。 - 問題:每條連線的
TrackerInfo持有*C.Metadata,其中RawSrcAddr/RawDstAddr net.Addr、SrcGeoIP/DstGeoIP []string、Process/ProcessPath等在 dial 完成後只剩面板顯示用途。單筆不大(約 300–400 bytes + 字串),但乘以連線數後是固定成本;同時Metadata是 sync.Pool 物件,被 tracker 長期引用會讓 pool 無法回收。 - 建議作法:確認
Metadatapool 與 tracker 引用關係(是否 tracker 持有的就不歸還 pool),評估在NewTCPTracker時只複製面板需要的欄位到精簡結構。這項收益較小,排在 P0/P1 之後。 - 驗收標準:
BenchmarkTCPTrackerLifecycleB/op 不上升;/connectionsJSON 欄位不變。
P3-8 log observable 的 Emit 在鎖內阻塞
- 檔案:
common/observable/observable.go(process在mux內呼叫sub.Emit)、common/observable/subscriber.go(buffer <- item無default)、log/log.go(logCh無緩衝)。 - 問題:任何一個訂閱者 200 格 buffer 滿了,
Emit就阻塞並持鎖,連帶Subscribe/UnSubscribe與所有呼叫log.*的 goroutine 停住。hub/route/server.go的getLogs已用另一個 goroutine 快速搬到會丟棄的 channel 緩解,但observable本身仍是阻塞語意。這是健壯性議題,不是記憶體議題。 - 建議作法:
Emit改為非阻塞(滿了就丟並累計 dropped 計數)。需確認沒有測試依賴阻塞語意。 - 驗收標準:新增測試「慢訂閱者不阻塞
log.Infoln」;既有log/observable測試通過。
P3-9 Provider 載入時的瞬時峰值
- 檔案:
component/resource/fetcher.go(os.ReadFile→parser→vehicle.Write)、rules/provider/mrs_reader.go、component/geodata/standard/standard.go。 - 問題:載入時同時存在原始 bytes、解碼後結構與寫回檔案的 buffer;
standardloader 更是把整個geosite.dat讀進來並proto.Unmarshal成完整 list。memconservative是預設,standard需使用者自選。這是峰值而非常駐,僅列入觀察。 - 建議作法:只量測,不改動;在
docs/reference/performance.md記錄「100k 條 MRS/GeoSite 載入時VmHWM與穩態差距」,供使用者判斷standard與memconservative的取捨。
3. 約束(接手者不得違反)
- 不使用
GOGC/GOMEMLIMIT/debug.SetMemoryLimit/定時FreeOSMemory讓數字好看。 - 不降低 queue/cache/NAT 上限、不縮短 TTL、不移除功能來換記憶體;
with_low_memory可以有更小的預設,一般 build 的預設要有數據支持才能改。 - 保留 stale DNS、fake-IP 反查、面板 JSON 欄位等對外語意。
- 每個項目獨立 PR,PR 描述必須附:改動前後整程序數字(含範圍)、heap top 3、相關 benchmark 前後值、
go test ./...、go vet、gofmt、golangci-lint結果。 - 文件數字只能寫實際量到的,不可由 B/op 外推 RSS,也不可拿桌機結果冒充 OpenWrt 結果(見
docs/reference/performance.md既有寫法)。
4. 建議執行順序與相依
P0-0 使用者裁決(不阻塞其他項目,未裁決前採 A)
P0-1.1 heap 歸因(DIRECT 與 TLS 代理兩情境)──> 決定 P0-1.2/P0-1.3 與 P2-7 是否值得做
P0-1.2 備援路徑自適應 buffer ──┐
P0-1.3 sing (c) 分支自適應 ──┼──> 重跑整程序 A/B,更新 docs/reference/performance.md
P0-2 UDP slab 右尺寸化 ──┤
P1-4 面板快照 ──┘
P1-3 QUIC 視窗(獨立,可平行)
P2-5 / P2-6 / P2-7(各自獨立,收益需先量測再決定是否做)
P3-8 / P3-9(健壯性與觀察,不影響前面項目)P0-1 完成後,「持有 1,000 條 TCP」與「釋放後 60 秒」兩個情境的數字會改變,P1-4 與 P2-7 的收益要在 P0-1 之後重新量測,避免把 P0-1 的收益算到別的項目上。
5. 快速檢查清單(每個 PR 完成前)
- [ ]
go build ./...與go build -tags with_low_memory ./...通過 - [ ]
go test ./...;改動 package 加跑-race - [ ]
gofmt -l .為空;go vet ./...;golangci-lint run - [ ] 相關 benchmark 前後
-count=7 -cpu=1並附 benchstat - [ ] 整程序情境七輪中位數與範圍寫進 PR 與
docs/reference/performance.md - [ ] 臨時探針、debug log、
runtime.GC()呼叫已移除
