Спасибо за детальный разбор! Покопался глубже в механику буферов и написал бенчмарки. Часть ваших замечаний подтвердилась, но есть нюансы.
Вы правы, что в направлении telegram→client relay буфер напрямую определяет размер read(2) — на стороне Telegram нет TLS-буферизации (telegramConn = connTraffic(obfuscation(tcp))).
В направлении client→telegram картина другая: tls.Conn.Read() читает целые TLS records во внутренний bytes.Buffer (readBuf), и relay буфер достаёт данные оттуда через memcpy. Размер relay буфера в этом направлении на число syscalls не влияет.
Написал бенчмарки, чтобы измерить конкретно. Throughput и число read-вызовов одинаковы для всех размеров буфера:
| Тест | buf 4 КБ | buf 16 КБ | Reads |
|---|---|---|---|
| client→tg (TLS, 10 МБ) | 7 460 МБ/с | 7 520 МБ/с | 322 = 322 |
| tg→client (raw, 10 МБ) | 1 946 МБ/с | 1 943 МБ/с | 1 281 = 1 281 |
| Скачивание медиа (MTU-порции ~1460Б) | 2 816 МБ/с | 2 833 МБ/с | 7 184 = 7 184 |
| Мелкие сообщения (200Б × 10К) | 392 МБ/с | 400 МБ/с | 10 001 = 10 001 |
Оговорка: бенчмарки используют net.Pipe() (синхронная передача). В реальном TCP ядро может накопить больше данных в receive buffer между вызовами read(2), и тогда маленький буфер действительно приведёт к большему числу syscalls. Плюс ваш аргумент про tcp_rmem и congestion window — если мы гребём медленнее чем ядро наполняет буфер, это может давить на окно.
Поэтому я согласен: оставляем буфер 16 КБ (MaxRecordPayloadSize). Ниже покажу, что основная экономия памяти достигается не уменьшением буфера, а другим способом.
Вы написали, что пулинг не экономит память — объекты болтаются в ожидании следующего всплеска. Это абсолютно верно для классического use-case (пул ради снижения GC и аллокаций). Но здесь задача другая.
Суть в том, как Go рантайм работает со стеками горутин. var buf [16379]byte на стеке заставляет рантайм вырасти стек горутины. Go растит стеки удвоением: 2 КБ → 4 → 8 → 16 → 32 КБ. Массив 16 КБ + стековый фрейм не влезают в 16 КБ, поэтому стек растёт до 32 768 байт. И обратно он не сжимается, пока горутина жива.
Замер подтверждает — ровно 32 КБ на горутину, стабильно:
| Подход | N=1000 горутин | N=2000 горутин |
|---|---|---|
Stack [16379]byte |
32 МБ (32 КБ/гор.) | 64 МБ |
| Pool (16 КБ буфер) | 0,4-0,8 МБ | 2,1-2,4 МБ |
96,5% снижения стековой памяти. Буфер тот же размер (16 КБ), просто живёт на куче вместо стека. 16 КБ на куче дешевле, чем 32 КБ раздутого стека.
Про то, что соединения короткоживущие и стек эффективно переиспользуется: да, при закрытии горутины её стек освобождается сразу, без GC. Но экономия проявляется именно в момент пиковой нагрузки — когда одновременно живут сотни горутин, и каждая держит 32 КБ стека. Пулированный буфер позволяет стекам оставаться маленькими (2-8 КБ), а сами буферы переиспользуются через пул.
Между всплесками idle-память пула — да, она есть (~6-14 МБ при 500 буферах по 16 КБ). Но sync.Pool освобождает её при следующем GC — это его штатное поведение.
Я понимаю философию v2 — «всё на стеке, нет нагрузки на GC». Это правильный подход в общем случае. Но конкретно relay буферы — исключение, потому что 16 КБ на стеке горутины стоят 32 КБ из-за механики удвоения стека Go.
Вы упомянули trade-off «память за CPU». Замерил, в том числе под нагрузкой — стресс-тесты с конкурентными соединениями:
| Сценарий | stack 16 КБ | pool 16 КБ | pool 4 КБ |
|---|---|---|---|
| 100 × 10 МБ | 71 826 МБ/с / 5,6 МБ | 68 413 / 4,5 МБ | 66 985 / 4,3 МБ |
| 500 × 10 МБ | 68 208 / 6,0 МБ | 63 587 / 6,4 МБ | 69 775 / 5,6 МБ |
| 1000 × 10 МБ | 68 265 / 7,5 МБ | 71 258 / 9,7 МБ | 55 186 / 6,3 МБ |
| 2000 × 1 МБ | 45 666 / 16,0 МБ | 53 451 / 9,0 МБ | 53 367 / 8,5 МБ |
| 500 × 50 МБ | 70 020 / 7,3 МБ | 71 983 / 7,0 МБ | 67 908 / 6,2 МБ |
(формат: throughput / peak memory)
Ключевое:
То есть pool не создаёт trade-off «память за CPU» — при высокой нагрузке он выигрывает по обоим параметрам.
Тут всё просто — согласен с вашей оценкой. Меньше горутин, примерно та же сложность кода.
Могу подготовить чистый PR. Бенчмарки доступны для воспроизведения: go test -bench=. -benchmem ./mtglib/internal/relay/
Заметки (не публикуется):