Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 6 из 25 ~18 мин +20 XP

Ping/Pong: соединение, которое не засыпает

Соединение WebSocket выглядит живым, даже когда его владелец давно уехал в метро: чтобы отличать спящих клиентов от мёртвых, существует websocket ping pong — кардиомонитор real-time-канала. Сервер стучится кадром ping, клиент молча отвечает pong, и пока ответы приходят, дедлайн чтения продлевается. Здесь соберём heartbeat-механику gorilla/websocket целиком: SetReadDeadline, SetPongHandler, тикер ping и цену отсутствия всего этого — память, съеденную призраками соединений.

Содержание урока · 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

GO
const (
        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. Ошибка, возвращённая из хендлера, прервёт чтение — так соединение закрывается даже изнутри реакции на кадр.

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

GO
var 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 вернёт ошибку закрытия; дедлайн не нужен

Проверка: смерть клиента

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

Допишите константы и читающую сторону: запас в формуле, дедлайн чтения и хендлер ответа.

const ( pongWait = 60 * time.Second pingPeriod = (pongWait * ) / 10 ) conn.Set(time.Now().Add(pongWait)) conn.Set(func(string) error { return conn.SetReadDeadline(time.Now().Add(pongWait)) })

Соберите heartbeat по строкам

Расставьте строки инициализации heartbeat в правильном порядке: константы, первый дедлайн и продлевающий хендлер.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Диагностика: что пишет лог при разных обрывах#

Heartbeat удобно отлаживать по логам, потому что каждый сценарий оставляет свою подпись. Чистое закрытие — пользователь закрыл вкладку или нажал кнопку выхода: ошибка ReadMessage приходит мгновенно, и в логе это выглядит как обычный уход клиента. Дедлайн — сеть пропала без закрытия: минута тишины, затем ошибка превышения дедлайна; если тикер успел отправить ping в мёртвый сокет до того, как сработал дедлайн чтения, в логе может мелькнуть и его ошибка записи — это нормально, обе горутины разбирают одно соединение. Отказ записи на живом канале — клиент завис намертво, но сеть формально есть. Смоделировать обрыв честно можно двумя способами: увести ноутбук в сон, а потом разбудить, либо выключить сетевой адаптер на несколько минут. Эмуляция офлайна в инструментах разработчика браузера для устоявшегося WebSocket-соединения обычно не работает — она режет новые запросы, а не живые сокеты, поэтому полагаться на неё не стоит. Локальный запуск по-прежнему единственный честный стенд: локальный Go плюс браузер плюс два сценария обрыва — и вы видите все три подписи своими глазами.

Карточки: heartbeat websocket

Термин

Ping и Pong

нажмите, чтобы перевернуть
Определение

Управляющие кадры WebSocket: получатель ping обязан ответить pong с тем же содержимым. Служат пульсом, по которому отличают живого клиента от мёртвого.

1 / 6

Закреплять 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 секунд. Лог каждого закрытия — обязательная часть задания.

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · Ping/Pong: соединение, которое не засыпает)

Вопросы и ответы по уроку

Браузерный клиент обязан отвечать на pong своим кодом?

Нет: JavaScript-интерфейс не отдаёт управление ping/pong-кадрами, браузер отвечает автоматически и прячет их от onmessage. Код нужен только на сервере — тикер ping и PongHandler, продлевающий дедлайн.

Зачем прикладной heartbeat, если существует TCP keepalive?

Keepalive ядра включается по умолчанию через два часа простоя и настраивается на уровне операционной системы, а через NAT и корпоративные прокси часто не проходит. WS-ping живёт внутри протокола, его интервал задаёте вы, и работает он везде, где идут остальные кадры.

Какой интервал выбрать для pongWait?

Обычный диапазон — 30-60 секунд, pingPeriod соответственно 27-54. Меньше — ложные отключения на мобильных сетях, больше — мёртвые соединения живут в карте дольше. Для чата со статусами онлайна часто берут 30 секунд: статус гаснет быстро, и повторное подключение клиент делает сам.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

RabbitMQ25 уроков~11 чпрактика25 уроков уже доступны

RabbitMQ в Go: очереди сообщений на amqp091-go

Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.

  • Подключаться к RabbitMQ из Go и управлять connection и channel
  • Объявлять очереди, обменники и привязки с нужными свойствами
  • Публиковать и потреблять сообщения с ручными ack
  • Настраивать prefetch и справедливое распределение между воркерами