---
title: "Ping/Pong: соединение, которое не засыпает"
description: "Ping pong в websocket на Go: heartbeat через SetReadDeadline и PongHandler, тикер ping, формула pingPeriod и почему без дедлайнов копятся мёртвые соединения."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/06-ping-pong/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 6 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/06-ping-pong/*

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

Каждый, кто впервые оставил чат работать на ночь, утром находит странную картину: сервер уверенно числит клиентов, которых давно нет. Вкладки, закрытые кнопкой, тут не виноваты — браузер шлёт кадр 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. Ошибка, возвращённая из хендлера, прервёт чтение — так соединение закрывается даже изнутри реакции на кадр.

> **Мёртвый клиент без pong.** Сервер без дедлайна выглядит безобидно и проживает недели: тесты зелёные, один пользователь ничего не замечает. Потом приходит реальная сеть: каждый сон ноутбука, каждый туннель 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

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

> **Ping — это тоже запись.** Кадр ping пишется в соединение, а gorilla допускает одного concurrent-писателя: две одновременные записи — паника concurrent write to websocket connection. В демо выше тикер единственный пишет, всё честно. Но добавьте эхо или broadcast из другой горутины — и они столкнутся с тикером. Правильное место пульса — внутри writePump, где select обслуживает и исходящие сообщения, и тик: об этом урок про readPump и writePump.

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

**Вопрос:** Wi-Fi клиента умер в момент t = 0. На сервере стоит SetReadDeadline на 60 секунд и ping-тикер на 54 секунды. Когда ReadMessage вернёт ошибку?

- ✅ Около t = 60 секунд: pong не пришёл, дедлайн чтения истёк
- Никогда: TCP сам держит соединение открытым
- Около t = 54 секунд: сразу после неудачного ping
- Мгновенно: обрыв Wi-Fi закрывает сокет у обеих сторон

Обрыв сети не порождает ни данных, ни признака закрытия — сервер узнаёт о смерти только по дедлайну: за 60 секунд не пришло ни pong, ни данных, и ReadMessage возвращает ошибку превышения дедлайна. TCP тут бессилен: его keepalive по умолчанию ждёт часы и не всегда проходит через промежуточные шлюзы.

### Проверка: поведение браузера

**Вопрос:** Что делает браузерный клиент, когда сервер присылает кадр ping?

- Вызывает ваш onmessage с объектом ping
- ✅ Молча отвечает pong — JavaScript-код не участвует
- Переподключает соединение заново
- Игнорирует кадр: серверу ping не нужен

Управляющие кадры браузер обслуживает сам: pong уходит автоматически и до onmessage не доходит. Поэтому для веб-клиентов схема с серверным ping — стандартная: весь контроль heartbeat живёт на стороне Go, клиенту для живости не нужно ни строчки. Если же клиент не браузерный, а Go-диалер из тестов, pong придётся отвечать вручную — об этом урок двадцать первый про тесты.

### Когда истечёт дедлайн?

```go
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 с и после этого замолчал
```

**Что выведет программа?**

- ✅ Примерно в t = 105 с: pong продлил дедлайн на 60 секунд с момента ответа
- В t = 60 с: дедлайн был установлен один раз при подключении
- В t = 45 с: входящий pong сбрасывает дедлайн до нуля
- Никогда: pong не влияет на дедлайн чтения

PongHandler ставит дедлайн заново относительно текущего момента: 45 плюс 60 — около 105 секунд. Дедлайн чтения — это не общий таймер жизни соединения, а окно ожидания следующего кадра, и каждый pong открывает его заново.

### Соберите инициализацию heartbeat

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

```go
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))
})
```

Подсказка: Десятая часть дедлайна остаётся запасом на сеть; дедлайн ставит ReadDeadline, а реакцию на pong подключает PongHandler.

Правильные ответы в пропусках: `9`, `ReadDeadline`, `PongHandler`

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

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

Правильный порядок строк:

1. `const pongWait = 60 * time.Second`
2. `const pingPeriod = (pongWait * 9) / 10`
3. `conn.SetReadDeadline(time.Now().Add(pongWait))`
4. `conn.SetPongHandler(func(string) error {`
5. `return conn.SetReadDeadline(time.Now().Add(pongWait))`
6. `})`

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

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

### Карточки: heartbeat websocket

- **Ping и Pong** — Управляющие кадры WebSocket: получатель ping обязан ответить pong с тем же содержимым. Служат пульсом, по которому отличают живого клиента от мёртвого.
- **SetReadDeadline** — Граница ожидания чтения: если за отведённое время не пришло ни данных, ни pong, ReadMessage вернёт ошибку превышения дедлайна.
- **SetPongHandler** — Колбэк gorilla/websocket на входящий pong, сигнатура func(string) error. Обычно продлевает ReadDeadline на полный pongWait.
- **pingPeriod = pongWait * 9 / 10** — Формула из examples/chat: пульс каждые 54 секунды при минутном дедлайне — десятая часть остаётся запасом на сетевые задержки.
- **Почему не TCP keepalive** — Зонд ядра включается примерно через два часа простоя и настраивается системно; через NAT и прокси часто не проходит. WS-ping идёт внутри протокола, интервал выбирает приложение.
- **Мёртвый клиент** — Соединение, у которого владелец уже пропал из сети — сон ноутбука, обрыв Wi-Fi, выкидыш из NAT, — но сервер об этом не знает: TCP не сообщает о пропаже пира.

Закреплять heartbeat лучше запуском: код курса — реальный серверный Go, для него нужны локальный Go и браузер, потому что песочница не умеет TCP. Возьмите эхо-сервер из урока про [первый WebSocket-сервер](/courses/websocket/lessons/04-pervyj-server/), добавьте константы, дедлайн и тикер, а проверяйте клиентом из урока про [WebSocket в браузере](/courses/websocket/lessons/05-js-klient/): закрытая вкладка должна оборачиваться чистым закрытием мгновенно, а уведённая в сон машина — ошибкой дедлайна примерно через минуту. Дальше по курсу пульс встраивается в общий каркас: в уроке про [ReadPump и WritePump](/courses/websocket/lessons/09-readpump-writepump/) ping-тикер переезжает к единственному писателю, в уроке про [чистку мёртвых соединений](/courses/websocket/lessons/18-otvaly-i-tajmauty/) — в систему мониторинга отвалов, а серверное отключение мёртвого клиента рождает обратную задачу: клиент должен заметить разрыв и вернуться — об этом отдельный урок про [reconnect и backoff](/courses/websocket/lessons/17-reconnect/). Полный маршрут от эхо до промышленного чата расписан на странице [курса WebSocket на Go](/courses/websocket/).

## Коротко: что запомнить

- Тишина в сокете не значит, что клиент жив: обрыв 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 секунд: статус гаснет быстро, и повторное подключение клиент делает сам.
