Привет!
Решил проверить, могу ли я сделать более экономичной юнит-экономику по соединениям — оперативка нынче дорогая :) и по сути главный лимитирующий фактор для количества одновременных соединений.
Что нашёл:
Замерил расход памяти на одно соединение. Основные потребители — горутинные стеки, которые раздуваются из-за stack-allocated массивов по 16 КБ и больше не сжимаются:
| Компонент |
Расход |
pump() × 2 — var buf [MaxRecordPayloadSize]byte на стеке |
~64 КБ (стеки растут до 32 КБ каждый) |
doppel.start() — buf := [MaxRecordSize]byte{} на стеке |
~32 КБ |
Clock.Start() — отдельная горутина + канал на соединение |
~2-4 КБ |
ctx.Done() watcher горутины в relay и proxy |
~4 КБ |
| Итого стеки |
~100 КБ |
Плюс TLS/doppel heap-буферы (~13 КБ) и прочее. В сумме ~120 КБ на соединение.
Что сделал:
-
sync.Pool для relay буферов + уменьшение до 4 КБ. 16 КБ буфер не нужен, потому что TLS-слой сам собирает TLS records во внутреннем readBuf — relay просто читает из него порциями. 4 КБ не увеличивает число syscalls, только больше итераций io.CopyBuffer (overhead — наносекунды на interface dispatch).
-
sync.Pool для doppel буфера. Перенос 16 КБ массива из стека в пул.
-
Inline Clock в start(). Вместо отдельной горутины с каналом — time.Timer прямо в цикле start(). Семантика та же: таймер стреляет → обработка → reset. Backpressure сохраняется, т.к. Reset только после завершения итерации.
-
context.AfterFunc вместо горутин в relay и proxy. Вместо горутин, висящих на <-ctx.Done() всё время жизни соединения — context.AfterFunc, который создаёт горутину только в момент отмены.
Результат в проде:
| Метрика |
До |
После |
| Горутин на соединение |
4-5 |
2-3 |
| Стеки горутин (суммарно) |
~100 КБ |
~12 КБ |
| RSS на соединение (замер) |
~160 КБ |
~93 КБ |
| Экономия |
|
~42% |
Замерял так: RSS процесса минус baseline (RSS при 0 соединениях), делим на число соединений. Тестировал при ~50-60 активных соединениях.
Все тесты проходят, включая -race. В проде крутится несколько часов без проблем.
У себя в форке реализовал: dolonet#1
Если идея ок, могу подготовить чистый PR в mtg.
Привет!
Решил проверить, могу ли я сделать более экономичной юнит-экономику по соединениям — оперативка нынче дорогая :) и по сути главный лимитирующий фактор для количества одновременных соединений.
Что нашёл:
Замерил расход памяти на одно соединение. Основные потребители — горутинные стеки, которые раздуваются из-за stack-allocated массивов по 16 КБ и больше не сжимаются:
pump()× 2 —var buf [MaxRecordPayloadSize]byteна стекеdoppel.start()—buf := [MaxRecordSize]byte{}на стекеClock.Start()— отдельная горутина + канал на соединениеctx.Done()watcher горутины в relay и proxyПлюс TLS/doppel heap-буферы (~13 КБ) и прочее. В сумме ~120 КБ на соединение.
Что сделал:
sync.Pool для relay буферов + уменьшение до 4 КБ. 16 КБ буфер не нужен, потому что TLS-слой сам собирает TLS records во внутреннем
readBuf— relay просто читает из него порциями. 4 КБ не увеличивает число syscalls, только больше итерацийio.CopyBuffer(overhead — наносекунды на interface dispatch).sync.Pool для doppel буфера. Перенос 16 КБ массива из стека в пул.
Inline Clock в
start(). Вместо отдельной горутины с каналом —time.Timerпрямо в циклеstart(). Семантика та же: таймер стреляет → обработка → reset. Backpressure сохраняется, т.к. Reset только после завершения итерации.context.AfterFuncвместо горутин в relay и proxy. Вместо горутин, висящих на<-ctx.Done()всё время жизни соединения —context.AfterFunc, который создаёт горутину только в момент отмены.Результат в проде:
Замерял так: RSS процесса минус baseline (RSS при 0 соединениях), делим на число соединений. Тестировал при ~50-60 активных соединениях.
Все тесты проходят, включая
-race. В проде крутится несколько часов без проблем.У себя в форке реализовал: dolonet#1
Если идея ок, могу подготовить чистый PR в mtg.