# Черновик ответа в issue #412

---

Спасибо за детальный разбор! Покопался глубже в механику буферов и написал бенчмарки. Часть ваших замечаний подтвердилась, но есть нюансы.

## Про размер буферов (4 КБ vs 16 КБ)

Вы правы, что в направлении **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).** Ниже покажу, что основная экономия памяти достигается не уменьшением буфера, а другим способом.

## Про sync.Pool и стековую память

Вы написали, что пулинг не экономит память — объекты болтаются в ожидании следующего всплеска. Это абсолютно верно для классического 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.

## CPU и нагрузка

Вы упомянули 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)*

Ключевое:
- При малой нагрузке (100 conns) stack чуть быстрее — нет overhead от пула
- **При 2000 коротких соединений** (паттерн «всплески»): pool **+17% throughput** и **вдвое меньше памяти** (8,5-9 МБ vs 16 МБ)
- GC: pool 8 циклов / 933 мкс пауз vs stack 12 циклов / 1 286 мкс — пул переиспользует буферы, меньше аллокаций, GC легче
- Pool contention (2000 воркеров): 1,3 нс/op — масштабируется идеально

То есть pool не создаёт trade-off «память за CPU» — при высокой нагрузке он выигрывает по обоим параметрам.

## Inline clock + AfterFunc

Тут всё просто — согласен с вашей оценкой. Меньше горутин, примерно та же сложность кода.

## Предложение

1. **sync.Pool для relay буферов (16 КБ)** — 96% снижение стековой памяти, +17% throughput при высокой нагрузке, меньше GC-пауз
2. **Размер буфера оставить 16 КБ (MaxRecordPayloadSize)** — основная экономия от переноса со стека, а не от уменьшения размера
3. **Inline clock + context.AfterFunc** — меньше горутин на соединение

Могу подготовить чистый PR. Бенчмарки доступны для воспроизведения: `go test -bench=. -benchmem ./mtglib/internal/relay/`

---

*Заметки (не публикуется):*
- Обращение на «вы» с маленькой буквы — как автор к нам
- Адресовано каждое замечание: tcp_rmem/congestion window, syscalls, пулинг, короткие соединения, философия v2
- Отказались от 4 КБ буфера — оставляем его дефолт
- Стресс-тесты показывают что pool лучше ОБОИХ параметров под нагрузкой
- Без упоминания Claude
