Содержание урока · 8 разделов
Каждый, кто впервые оставил чат работать на ночь, утром находит странную картину: сервер уверенно числит клиентов, которых давно нет. Вкладки, закрытые кнопкой, тут не виноваты — браузер шлёт кадр close, и такое соединение закрывается чисто. Мёртвые клиенты — другие: человек вышел из метро, ноутбук уснул, роутер перезагрузился, NAT-таблица на шлюзе провайдера выкинула запись о соединении. Во всех этих сценариях TCP-сокет не получает ни данных, ни признака закрытия: обе стороны ждут, и для ядра операционной системы канал исправно жив. ReadMessage из gorilla/websocket честно блокируется дальше — ошибки не будет никогда. Пока сервер верит призракам, они держат горутину на чтении, место в карте клиентов и продолжают получать рассылку, которую никто не прочитает. Неделя таких выходных — и чат с реальными пятьюстами пользователями хранит несколько тысяч соединений. Хуже памяти то, что бизнес-логика считает всех призраков онлайном: счётчик участников врёт, рассылки уходят в никуда, а очередь недоставленных сообщений для мёртвого клиента копится до его возвращения — если оно вообще случится.
Как устроен websocket ping pong: кадры и роли#
Протокол решает проблему на прикладном уровне. Кроме двух типов данных — текста и бинарных кадров — стандарт определяет управляющие кадры: ping и pong. Логика закреплена в спецификации: получивший ping обязан как можно скорее ответить pong, скопировав его содержимое. Отсюда договорённость, которую используют все heartbeat websocket-серверы: одна сторона шлёт ping по таймеру, вторая молча отвечает. В браузерной паре инициатор почти всегда сервер: JavaScript-клиент не умеет ни слать ping, ни ловить pong — браузер отвечает сам, до того как кадр доберётся до вашего onmessage. Серверу остаётся две задачи: отправлять ping по таймеру и следить, отвечают ли. За слежение отвечает дедлайн чтения: SetReadDeadline ставит границу, после которой ReadMessage вернёт ошибку, а SetPongHandler запускается на каждом входящем pong и продлевает дедлайн заново. У живого клиента дедлайн не наступает никогда; мёртвый молчит — и через pongWait соединение объявляется умершим.
Читающая сторона: дедлайн и реакция на pong
GOconst (
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
)
func heartbeat(conn *websocket.Conn) {
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
}pongWait задаёт, сколько сервер готов ждать признаков жизни, pingPeriod — девять десятых этого срока, чтобы ping успел съездить и вернуться. SetReadDeadline фиксирует границу ожидания чтения, а SetPongHandler подключает колбэк на входящий pong: тот снова продлевает дедлайн на полный pongWait. Ошибка, возвращённая из хендлера, прервёт чтение — так соединение закрывается даже изнутри реакции на кадр.
Сервер без дедлайна выглядит безобидно и проживает недели: тесты зелёные, один пользователь ничего не замечает. Потом приходит реальная сеть: каждый сон ноутбука, каждый туннель LTE и каждая перезагрузка роутера оставляют в чате призрака — он числится онлайн, занимает горутину и получает рассылку. Дедлайн чтения — единственный способ узнать правду, потому что TCP сам о смерти пира не сообщает никогда. Разница в цене вопроса огромная: heartbeat стоит пару строк и один таймер, а чистка последствий — отдельный проект на неделю.
TCP keepalive не спасает: зачем heartbeat go-серверу свой пульс#
Первая реакция на историю выше — а зачем heartbeat, go и так умеет следить за соединениями? Не умеет. Keepalive — это зонд транспортного уровня, который ядро шлёт после долгого простоя; в Linux по умолчанию он включается примерно через два часа, и тумблеры у него системные, а не на каждое соединение из Go-кода. Второй аргумент — промежуточные звенья: NAT-шлюзы и корпоративные прокси держат запись о соединении от минут до часов и молча выбрасывают молчащие каналы, а keepalive-пакеты через некоторые из них вообще не проходят. Прикладной ping от этого свободен: он едет внутри самого WebSocket-протокола, поэтому доходит везде, где ходят и остальные кадры, заодно освежая NAT-запись. Третий аргумент — скорость диагноза: два часа keepalive против сорока-шестидесяти секунд своего пульса. Поэтому heartbeat websocket-приложений делают сами, на уровне прикладного протокола, где интервал выбирает приложение, а не прошивка чужого шлюза.
pingPeriod: девять десятых от pongWait и выбор интервала#
Откуда берётся формула pingPeriod = pongWait * 9 / 10? Она из классического examples/chat в репозитории gorilla, и смысл у неё точный: ping отправляется каждые 54 секунды при минутном дедлайне, значит на поездку до клиента и обратно остаётся десятая часть — запас на сетевые задержки. Если сделать интервал равным pongWait, пульс будет приходить впритык к границе, и любой зигзаг сети превратится в ложное отключение. Интервалы выбирают по типу продукта: 30-60 секунд — обычный диапазон для чатов и уведомлений; для мобильных клиентов с агрессивным энергосбережением берут 30 секунд, чтобы не рвать соединение ложными срабатываниями; чаще — жжётся трафик и батарея, реже — мёртвые соединения живут в карте дольше. Каждый ping — это ещё и пробуждение радиомодуля на телефоне: слишком частый пульс расходует заряд быстрее, чем сами данные чата, поэтому пульс всегда компромисс между быстрой диагностикой и ценой на мобиле. Проверка запускается только локально: песочница сайта не поднимает TCP-сервер, поэтому код этого урока живёт в вашем go-модуле рядом с браузером.
Хендлер с полным heartbeat
GOvar upgrader = websocket.Upgrader{}
const (
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
)
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
go func() {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}()
for {
_, _, err := conn.ReadMessage()
if err != nil {
log.Println("соединение закрыто:", err)
return
}
}
}Хендлер апгрейдит соединение, ставит первый дедлайн и подключает PongHandler. Писателем здесь остаётся ровно одна горутина — тикер: он шлёт PingMessage каждые pingPeriod и уходит, как только запись ломается, а deferred conn.Close() добивает его следующей ошибкой. Читающий цикл — та же горутина хендлера: превышение дедлайна возвращает ошибку ReadMessage, и призрак освобождается. Полноценные readPump и writePump с буфером соберём в девятом уроке.
Роли сторон в heartbeat#
| Кадр или событие | Отправитель | Реакция получателя |
|---|---|---|
| ping | сервер: тикер каждые pingPeriod | браузер отвечает pong автоматически, JS-код не нужен |
| pong | браузер, без участия вашего кода | SetPongHandler продлевает SetReadDeadline на полный pongWait |
| тишина дольше pongWait | никто | ReadMessage вернёт ошибку превышения дедлайна, соединение закрывают |
| close | любая сторона при штатном выходе | ReadMessage вернёт ошибку закрытия; дедлайн не нужен |
Кадр ping пишется в соединение, а gorilla допускает одного concurrent-писателя: две одновременные записи — паника concurrent write to websocket connection. В демо выше тикер единственный пишет, всё честно. Но добавьте эхо или broadcast из другой горутины — и они столкнутся с тикером. Правильное место пульса — внутри writePump, где select обслуживает и исходящие сообщения, и тик: об этом урок про readPump и writePump.
Проверка: смерть клиента
Wi-Fi клиента умер в момент t = 0. На сервере стоит SetReadDeadline на 60 секунд и ping-тикер на 54 секунды. Когда ReadMessage вернёт ошибку?
Проверка: поведение браузера
Что делает браузерный клиент, когда сервер присылает кадр ping?
Когда истечёт дедлайн?
Прочитайте код и выберите, что он выведет:
const pongWait = 60 * time.Second
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
// клиент прислал pong в момент t = 45 с и после этого замолчалСоберите инициализацию heartbeat
Допишите константы и читающую сторону: запас в формуле, дедлайн чтения и хендлер ответа.
Соберите heartbeat по строкам
Расставьте строки инициализации heartbeat в правильном порядке: константы, первый дедлайн и продлевающий хендлер.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Диагностика: что пишет лог при разных обрывах#
Heartbeat удобно отлаживать по логам, потому что каждый сценарий оставляет свою подпись. Чистое закрытие — пользователь закрыл вкладку или нажал кнопку выхода: ошибка ReadMessage приходит мгновенно, и в логе это выглядит как обычный уход клиента. Дедлайн — сеть пропала без закрытия: минута тишины, затем ошибка превышения дедлайна; если тикер успел отправить ping в мёртвый сокет до того, как сработал дедлайн чтения, в логе может мелькнуть и его ошибка записи — это нормально, обе горутины разбирают одно соединение. Отказ записи на живом канале — клиент завис намертво, но сеть формально есть. Смоделировать обрыв честно можно двумя способами: увести ноутбук в сон, а потом разбудить, либо выключить сетевой адаптер на несколько минут. Эмуляция офлайна в инструментах разработчика браузера для устоявшегося WebSocket-соединения обычно не работает — она режет новые запросы, а не живые сокеты, поэтому полагаться на неё не стоит. Локальный запуск по-прежнему единственный честный стенд: локальный Go плюс браузер плюс два сценария обрыва — и вы видите все три подписи своими глазами.
Карточки: heartbeat websocket
Закреплять heartbeat лучше запуском: код курса — реальный серверный Go, для него нужны локальный Go и браузер, потому что песочница не умеет TCP. Возьмите эхо-сервер из урока про первый WebSocket-сервер, добавьте константы, дедлайн и тикер, а проверяйте клиентом из урока про WebSocket в браузере: закрытая вкладка должна оборачиваться чистым закрытием мгновенно, а уведённая в сон машина — ошибкой дедлайна примерно через минуту. Дальше по курсу пульс встраивается в общий каркас: в уроке про ReadPump и WritePump ping-тикер переезжает к единственному писателю, в уроке про чистку мёртвых соединений — в систему мониторинга отвалов, а серверное отключение мёртвого клиента рождает обратную задачу: клиент должен заметить разрыв и вернуться — об этом отдельный урок про reconnect и backoff. Полный маршрут от эхо до промышленного чата расписан на странице курса WebSocket на Go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тишина в сокете не значит, что клиент жив: обрыв Wi-Fi без признака закрытия выглядит как обычное ожидание.
- Ping и pong — управляющие кадры протокола: сервер шлёт ping, браузер отвечает сам, клиентский код не нужен.
- SetReadDeadline плюс SetPongHandler: каждый pong продлевает дедлайн, тишина сверх pongWait рождает ошибку и закрытие.
- pingPeriod равен девяти десятым pongWait: десятая часть — запас на сетевые задержки пульса.
- Без heartbeat мёртвые соединения копятся в карте клиентов и съедают память до перезапуска сервера.
- Ping — это запись в соединение, поэтому его тикер живёт там же, где единственный писатель, — в writePump.
- Проверка кода — только локальный Go и браузер: песочница не поднимает TCP-сервер.
Домашнее задание
Добавьте heartbeat в эхо-сервер из четвёртого урока: pongWait 45 секунд, pingPeriod по формуле девять десятых, PongHandler. Запустите локально, откройте две вкладки и проверьте два сценария: закрытие вкладки сервер видит сразу, а сон ноутбука или отключение сети — ошибкой дедлайна через 45-50 секунд. Лог каждого закрытия — обязательная часть задания.
Личные заметкиПусто
Вопросы и ответы по уроку
Браузерный клиент обязан отвечать на pong своим кодом?
Нет: JavaScript-интерфейс не отдаёт управление ping/pong-кадрами, браузер отвечает автоматически и прячет их от onmessage. Код нужен только на сервере — тикер ping и PongHandler, продлевающий дедлайн.
Зачем прикладной heartbeat, если существует TCP keepalive?
Keepalive ядра включается по умолчанию через два часа простоя и настраивается на уровне операционной системы, а через NAT и корпоративные прокси часто не проходит. WS-ping живёт внутри протокола, его интервал задаёте вы, и работает он везде, где идут остальные кадры.
Какой интервал выбрать для pongWait?
Обычный диапазон — 30-60 секунд, pingPeriod соответственно 27-54. Меньше — ложные отключения на мобильных сетях, больше — мёртвые соединения живут в карте дольше. Для чата со статусами онлайна часто берут 30 секунд: статус гаснет быстро, и повторное подключение клиент делает сам.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами