Go на 100 тысячах соединений: что упирается первым
Сервис на Go держит нагрузку до определённого предела, а потом latency растёт вертикально, и в логах — accept: too many open files. Разбираем, что упирается первым при 100k одновременных соединений и как это мерить.

Схема типичная: WebSocket-шлюз на Go 1.22, каждый клиент держит открытое соединение, всё на одной машине с 8 vCPU и 16 ГБ RAM. До 30–40 тысяч соединений p99 латентности сотни микросекунд, память ровная. На 60 тысячах в логах начинает мелькать accept4: too many open files, RSS перепрыгивает за 8 ГБ, GC-паузы из 200 мкс становятся миллисекундными. Цель — 100 тысяч живых соединений на одном процессе. Разбираем, во что упирается система по мере роста и в каком порядке ломается.
Речь именно про большое число одновременных соединений (C10K, разросшийся до C100K), а не про высокий RPS на коротких запросах — там узкие места другие. Оптимизацию сериализации и работу с базой здесь не трогаем, только сетевой слой и рантайм.
Что упирается первым: лимиты ядра
До любых разговоров про горутины и GC приложение упрётся в лимиты операционной системы. Это самый частый ложный вывод: «Go не тянет», хотя не тянет дефолтная конфигурация Linux.
Файловые дескрипторы
Каждое TCP-соединение — это открытый файловый дескриптор. Дефолтный ulimit -n в большинстве дистрибутивов — 1024. Первое, что вы увидите на паре тысяч соединений, — too many open files. Поднимать нужно и мягкий, и жёсткий лимит:
# проверить текущий лимит процесса
cat /proc/$(pgrep myserver)/limits | grep 'open files'
# в systemd-юните
[Service]
LimitNOFILE=1048576
После рестарта юнита проверьте, что лимит применился именно к процессу, а не только к сессии. ulimit -n в вашей SSH-сессии и лимит демона под systemd — это разные вещи.
Эфемерные порты и conntrack
Порты на стороне сервера, который принимает соединения, не заканчиваются: сервер слушает один порт, соединения различаются по паре (src_ip, src_port). Проблема с эфемерными портами возникает у исходящих соединений — если ваш шлюз проксирует к бэкенду, диапазон net.ipv4.ip_local_port_range и время TIME_WAIT станут потолком.
Отдельно — таблица conntrack, если на пути есть netfilter. При 100 тысячах соединений дефолтный nf_conntrack_max переполнится, и ядро начнёт молча дропать пакеты с записью nf_conntrack: table full в dmesg. Либо поднимайте лимит, либо исключайте трафик из conntrack правилом NOTRACK.
fs.file-max— глобальный лимит дескрипторов на всю систему;net.core.somaxconn— длина очереди accept, дефолтные 128 мало для всплесков подключений;net.ipv4.tcp_max_syn_backlog— очередь полуоткрытых соединений;net.core.rmem_max/net.core.wmem_max— потолок буферов сокета.
Точные значения зависят от профиля трафика — сверяйте по документации ядра Documentation/networking/ip-sysctl.txt для вашей версии, а не копируйте цифры из случайного гайда.
Прежде чем винить рантайг Go, посмотрите в dmesg и /proc/<pid>/limits. В девяти случаях из десяти на первых десятках тысяч соединений упирается не приложение, а ядро с дефолтной конфигурацией.
Память: горутина на соединение
Идиоматичный Go-сервер запускает по горутине (а часто по две — на чтение и на запись) на соединение. Это удобно и на 100 тысячах соединений выливается в 100–200 тысяч горутин. Сами по себе горутины дешёвые: стартовый стек — 2 КБ1, и он растёт по мере необходимости. Но платите вы не за стек горутины, а за буферы.
Где реально уходит память
На каждое соединение bufio.Reader и bufio.Writer по умолчанию берут по 4 КБ. Два буфера на соединение — уже 8 КБ, на 100 тысячах это 800 МБ только на буферах ввода-вывода, и это до полезной нагрузки. Плюс буферы сокета в ядре, которые вы задали через SO_RCVBUF/SO_SNDBUF.
Прикидка порядка величин: 100k соединений × (8 КБ прикладных буферов + ~16 КБ ядерных буферов сокета) ≈ 2,4 ГБ ещё до данных приложения. Отсюда правило: если соединения в основном простаивают, уменьшайте буферы.
// Go 1.22: буфер под конкретный профиль, а не дефолтные 4 КБ
const readBufSize = 1024 // соединения простаивают, читаем мелкими пакетами
reader := bufio.NewReaderSize(conn, readBufSize)
// сброс ядерных буферов сокета для idle-соединений
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.SetReadBuffer(4096)
_ = tcpConn.SetWriteBuffer(4096)
}
Не занижайте вслепую: маленький буфер увеличивает число системных вызовов read/write. Для idle-соединений это выигрыш, для активного стриминга — проигрыш. Мерьте на своём трафике.
GC под большим heap
Сборщик Go конкурентный и держит паузы в сотни микросекунд даже на больших кучах. Но частота сборок определяется параметром GOGC (по умолчанию 100 — сборка при удвоении heap относительно живого объёма). При 100 тысячах соединений и большом числе долгоживущих объектов вы получаете большой live set, и каждая сборка сканирует его целиком.
Два рычага, оба появились давно и стабильны в 1.22:
GOGC— реже собирать ценой большего RSS. Поднимите до 200–300, если памяти хватает и вас душат паузы;GOMEMLIMIT(с Go 1.19) — мягкий потолок памяти. GC начинает работать агрессивнее по мере приближения к лимиту, что защищает от OOM-килла лучше, чем одинGOGC.
# оставляем запас под ядерные буферы и всплески
GOMEMLIMIT=12GiB GOGC=200 ./myserver
Проверять эффект — только по runtime/metrics или pprof, а не по ощущениям. Ключевые счётчики: /gc/pauses:seconds (распределение пауз) и /memory/classes/heap/objects:bytes (живой объём).
Модель: горутина на соединение против event loop
Классический вопрос — не отказаться ли от горутины-на-соединение в пользу epoll-петли, как это делают библиотеки вроде gnet. Разберём цену.
| Критерий | Горутина на соединение | Event loop (epoll напрямую) |
|---|---|---|
| Память на idle-соединение | стек + буферы, десятки КБ | только буферы, единицы КБ |
| Код обработки | линейный, блокирующий стиль | колбэки, ручное состояние |
| Стоимость context switch | планировщик Go, дёшево | нет переключений горутин |
| Интеграция с net/http, стандартной библиотекой | полная | частичная или своя |
| Сложность отладки | стектрейсы читаемы | состояние размазано по колбэкам |
Когда что выбирать
- Соединения активны, обмен идёт постоянно — оставайтесь на горутине-на-соединение. Планировщик Go справляется, а читаемость кода дороже экономии памяти.
- Сотни тысяч idle-соединений, редкие сообщения (пуши, presence) — event-loop-подход экономит память в разы, потому что нет заблокированной горутины на чтение под каждый сокет.
- Не уверены — сначала выжмите горутинную модель настройкой буферов и GC. Переписывание на epoll — большой риск ради экономии, которая может не понадобиться.
Что мерить и как понять, что упёрлись
Без замеров вы будете крутить GOGC вслепую. Минимальный набор наблюдения на одной машине:
- Число открытых дескрипторов процесса:
ls /proc/<pid>/fd | wc -l— сравнить сLimitNOFILE. - Число горутин и heap — через
net/http/pprof, эндпоинты/debug/pprof/goroutineи/debug/pprof/heap. - GC-паузы — из
GODEBUG=gctrace=1в stderr или черезruntime/metrics. - dmesg на предмет
nf_conntrack: table fullиTCP: out of memory. - Дропы в очереди accept: столбец в
ss -lnt(Recv-Q у слушающего сокета растёт — очередь переполняется).
Нагрузку генерируйте инструментом, который умеет держать соединения открытыми (например, отдельный клиент, поднимающий N постоянных сокетов), а не бенчмарком коротких запросов — иначе измерите не то.
Что делать дальше
Порядок работы, а не свалка советов: сначала поднимите лимиты ядра и убедитесь по /proc/<pid>/limits, что они применились к процессу. Затем снимите профиль heap на реальном трафике и посмотрите, сколько уходит на буферы — это обычно самый жирный кусок. Только после этого крутите GOGC и GOMEMLIMIT, сверяя эффект по gctrace. Переход на event loop оставьте на случай, когда исчерпаете настройку и упрётесь именно в память idle-соединений.
Дальше смотрите в сторону горизонтального масштабирования: 100 тысяч на процесс — потолок, за которым дешевле поднять вторую машину и балансировщик, чем выжимать последние проценты из одной ноды.
1 Стек горутины — область памяти под её вызовы. Стартует с 2 КБ и растёт сегментами по мере углубления вызовов; неиспользуемый стек рантайм со временем возвращает.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Go runtime: A Guide to the Go Garbage Collector, Linux IP sysctl documentation
Частые вопросы
У меня Go 1.20, а не 1.22 — приёмы сработают?
Я поднял LimitNOFILE, но всё равно ловлю too many open files
Стоит ли сразу переписывать на event loop ради экономии памяти?
Не сработало: RSS всё равно растёт до OOM
Сколько памяти закладывать на 100 тысяч соединений?
Планировщик Go справится со 100 тысячами горутин?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.