Содержание урока · 8 разделов
Восьмой урок оставил два обещания: у клиента появится канал send, который заберёт все записи в соединение, а продакшен-версией цикла чтения станет readPump с дедлайнами. Время собрать пару целиком. Напомним исходную проблему: библиотека gorilla разрешает писать в один conn только из одной горутины — два одновременных WriteMessage перемешивают кадры и рвут соединение протокольной ошибкой. А источников записей у живого чата минимум три: рассылка от хаба, личные сообщения, ping-кадры heartbeat. С чтением симметричная картина: ReadMessage тоже безопасно вызывать только из одной горутины, и для молчаливых клиентов чтению нужны дедлайны, иначе мёртвые соединения копятся в карте вечно. Решение — разделить владение: чтения целиком забирает readPump, записи целиком — writePump, а между ними личный канал send с буфером. Тот же принцип единственного владельца, что двигал хаб из восьмого урока, только теперь он применяется к каждому соединению отдельно.
ReadPump writePump: пара горутин на каждое соединение#
Имена прижились из классического chat example gorilla и теперь встречаются почти в каждом websocket-проекте: readPump — метод клиента, который в вечном цикле читает кадры, writePump — горутина, которая всё пишет. Распределение обязанностей жёсткое и стоит выучить дословно. ReadPump владеет ReadMessage, дедлайном чтения, лимитом размера кадра и pong-обработчиком; на выходе он сигнализирует хабу через unregister и закрывает conn. WritePump владеет всеми WriteMessage — сообщениями, ping и close-кадром; он читает канал send, ставит дедлайн записи перед каждой отправкой и умирает, когда канал закрывается. Никто из них не трогает чужую половину — ни одним вызовом. Хаб же вообще не касается conn: он пишет только в каналы — register, unregister, send клиентов. Такая дисциплина кажется формальностью ровно до первого severed соединения в проде: после неё класс гонок записей просто исчезает, и race detector на нагрузке молчит. В этом уроке мы пройдём оба насоса построчно, а потом посчитаем, во сколько обходится пара горутин на каждый из десяти тысяч клиентов.
readPump: дедлайны, лимит и pong-обработчик
GOconst (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10 // 54 секунды
)
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(512)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
break
}
c.hub.broadcast <- message
}
}Четыре узла. SetReadLimit ставит потолок кадра: больше пятисот двенадцати байт — соединение рвётся протокольной ошибкой, защита от гигабайтных сообщений. SetReadDeadline даёт клиенту минуту тишины: если за это время не пришло ни кадра, ни pong, чтение оборвётся ошибкой. SetPongHandler продлевает дедлайн на каждом подтверждении — живой клиент, отвечающий на ping, соединение не теряет. Defer: сначала unregister — хаб вычеркнет клиента и закроет send, — затем conn.Close(), на всякий случай.
Анатомия readPump: почему обработчик pong безопасен#
Разберём тонкости, которые в этом коде спрятаны. Pong-обработчик вызывает SetReadDeadline — то есть пишет в состояние conn изнутри чтения, и это законно: gorilla вызывает обработчики pong в той же горутине, которая выполняет ReadMessage, — никакого конкурентного доступа нет, продлевать дедлайн чтения может только читатель. Broadcast внутри цикла — передача в небуферизованный канал хаба: чтение блокируется, пока хаб не примет сообщение, и это нормально, потому что цикл run всегда крутится. Заметьте, что readPump никогда не пишет в conn: если хендлеру понадобилось ответить — например, подтверждить команду, — ответ уезжает в send, а не в WriteMessage. Порядок в defer тоже не случайность: unregister стоит первым, потому что именно хаб — владелец close(send); если закрыть conn до вычёркивания, writePump узнает об уходе через ошибку записи, и клиент может мгновение числиться в карте мёртвым. Текущий порядок гарантирует: карта чиста, send закрыт, и только потом соединение умирает физически.
Первый соблазн в этом коде — ответить прямо на месте: пришло сообщение, и рука тянется к conn.WriteMessage. Так рождается второй писатель, и с writePump они начинают рвать кадры друг другу на полпути. Правило без исключений: readPump только читает и кладёт в каналы; любой ответ уходит в send. Это касается и pong-обработчика — он продлевает дедлайн, но не отправляет ничего.
writePump: ping-тикер и один писатель
GOfunc (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
c.conn.WriteMessage(websocket.TextMessage, message)
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}Select на два источника: сообщения из send и тики ping-таймера раз в пятьдесят четыре секунды. Перед каждой записью — SetWriteDeadline: зависший TCP-пир не удержит горутину дольше десяти секунд. Закрытый send приходит в ветку с ok равным false: писатель прощается CloseMessage и выходит — дальше сработает defer с conn.Close. Ошибка пинга тоже конец: писать в мёртвое соединение смысла нет.
Анатомия writePump: select вместо for range#
Почему select, а не банальный for range по send? Потому что писателю нужно просыпаться не только для сообщений: ping-тикер должен стрелять по расписанию независимо от трафика, иначе молчаливый клиент с пустым send не получит ни одного ping и дедлайн чтения его убьёт зря. Select развязывает два будильника: пришло сообщение — пишем, тикнул таймер — пингуем, оба сразу — select выберет случайно, и это не страшно, обе операции законны в любом порядке. CloseMessage в ветке ok false — вежливое прощание: сервер инициирует close-рукопожатие с кодом 1000, чтобы клиент получил осознанный сигнал, а не обрыв. SetWriteDeadline перед каждой записью — страховка от особого класса зомби: соединение, у которого сеть наполовину умерла, принимает байты и никуда их не девает; без дедлайна горутина писателя застрянет в такой записи навсегда. Классический пример gorilla здесь ещё и сливает скопившиеся сообщения в один кадр через NextWriter — тот же select, но с батчингом; к нему вернёмся в уроке двадцатом.
Владение conn: кто что трогает#
| Операция | readPump | writePump | Хаб |
|---|---|---|---|
| ReadMessage, дедлайн чтения, pong | владелец | — | — |
| WriteMessage, дедлайн записи, ping | — | владелец | — |
| Запись в send клиента | — | — | владелец |
| Чтение из send | — | владелец | — |
| close(send) | — | — | владелец |
| conn.Close() | в defer на выходе | в defer на выходе | — |
Оба насоса закрывают conn в defer, и при обычном сценарии это происходит дважды. Второй Close вернёт ошибку и ничего не сломает — сокет уже закрыт. Страховка важнее аккуратности: насосы умирают в разных порядках и по разным причинам, и каждый должен быть уверен, что соединение не переживает его горутину.
Websocket горутины: сколько их на самом деле#
Посчитаем всерьёз, сколько websocket горутин несёт реальный сервис. На процесс — одна горутина хаба. На каждое соединение — две: readPump в горутине хендлера и writePump отдельной командой go. Тысяча открытых вкладок — две тысячи и одна горутина; десять тысяч — двадцать тысяч и одна. Звучит угрожающе только до первой арифметики: стартовый стек горутины — порядка двух килобайт, и даже с буфером send на двести пятьдесят шесть сообщений клиент стоит десятки килобайт; двадцать тысяч живых клиентов — сотни мегабайт, и это цена двадцати тысяч одновременных разговоров, а не накладные расходы. Переключение между горутинами дешевле переключения между потоками на порядки, и модель один-клиент-две-горутины остаётся стандартом индустрии именно потому, что Go делает её дешёвой. Бить тревогу нужно не по числу горутин, а по их содержимому: тяжёлая обработка в readPump, большие буферы send, аллокации на каждый кадр — вот где сервис раздувается. Профилирование из продвинутого курса Go — инструмент на этот случай.
serveWs: сборка пары на соединение
GOtype Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
}
hub.register <- client
go client.writePump()
client.readPump()
}Код тот же, что в восьмом уроке, — теперь видно, зачем он такой. Register уходит до запуска насосов: иначе writePump мог бы получить сообщение для клиента, которого хаб ещё не знает. WritePump стартует горутиной, readPump занимает текущую — хендлер HTTP превращается в читателя и живёт, пока живо соединение. Выход readPump означает конец соединения, и горутина хендлера уходит вместе с ним.
Проверка: молчаливый клиент
Клиент перестал отвечать на ping, но TCP-соединение формально живо. Что произойдёт через минуту?
Проверка: коэффициент девять десятых
Почему pingPeriod равен девяти десятым pongWait, а не самому pongWait?
Что вернёт ReadMessage
Прочитайте код и выберите, что он выведет:
const pongWait = 60 * time.Second
// клиент не отвечает на ping, TCP жив
// прошло 61 секунда с последнего кадра
_, _, err := conn.ReadMessage()Соберите скелет writePump
Заполните три пропуска: дедлайн записи, ветка тикера и тип ping-кадра.
Соберите цепочку настроек readPump
Расставьте строки настроек чтения в правильном порядке: лимит, дедлайн и продление через pong-обработчик.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: насосы соединения
Пара насосов — последний недостающий слой между хабом и протоколом приложения. Сверьте картинку с хабом восьмого урока: run раздаёт события, writePump каждого клиента тянет их из send и пишет в conn, readPump кормит хаб входящими — круг замкнулся, и в нём нет ни одной незащищённой записи. Дедлайны и пинги здесь появились сжато; полная механика heartbeat с графиками таймингов — в шестом уроке. Следующий шаг — смысловая надстройка: в десятом уроке по каналам поедут структуры-конверты вместо сырых байтов, и клиент научится различать чат и системные события. Фундамент под всем этим — каналы, select и завершение горутин из продвинутого курса Go; если ветка ok false в select кажется магией, вернитесь туда и освежите. Запуск по-прежнему локальный: пара насосов проверяется двумя вкладками браузера и логом сервера — песочница TCP не поднимет.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пара горутин на соединение: readPump единственный читает conn, writePump единственный пишет, хаб касается только каналов.
- readPump: SetReadLimit, SetReadDeadline, pong-обработчик, продлевающий дедлайн; на выходе unregister и conn.Close.
- writePump: select из send и ping-тикера, SetWriteDeadline перед каждой записью, CloseMessage и выход при закрытом send.
- Ответы на ping шлёт writePump кадрами PingMessage каждые 54 секунды при дедлайне 60 — запас девять десятых.
- Не писать из readPump: любой ответ уходит в send — второй писатель рвёт кадры.
- Двойное conn.Close() из двух defer безопасно: второй вызов вернёт ошибку и ничего не сломает.
- Десять тысяч клиентов — двадцать тысяч горутин и мегабайты стеков: дёшево до тех пор, пока насосы не тащат тяжёлую логику.
Домашнее задание
Соберите пару насосов поверх хаба из восьмого урока и запустите с двумя вкладками. Затем сломайте связь честно: поднимите pongWait до пяти секунд и заглушите сеть в devtools пунктом Offline — соединение не закроется, но и на ping вкладка отвечать перестанет. Наблюдайте в логах цепочку: истечение дедлайна, unregister, закрытие send. После этого верните константы и объясните вслух, почему pingPeriod обязан быть меньше pongWait.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему два насоса, а не один цикл с select на чтение и запись?
Потому что ReadMessage и WriteMessage — блокирующие вызовы без интеграции с select: нельзя ждать кадр и готовность записи одновременно. Разделение на две горутины — стандартный ответ Go на любую пару блокирующих потоков: каждый живёт своим блокирующим вызовом, а связываются они каналами.
Нужен ли writePump, если сервер только шлёт и ничего не читает?
Читать всё равно придётся: протокол требует отвечать на close-кадры и обрабатывать pong, а без readPump соединение не узнает об уходе клиента. Если исходящих нет, send останется пустым, ping-тикер — единственным источником работы writePump; пара вырождается, но не исчезает.
Почему буфер send снова 256, как в хабе?
Это константа классического примера, а не закон: буфер должен сглаживать всплески рассылки и при этом не съедать память на тысячах клиентов. Двести пятьдесят шесть заголовков среза — килобайты на клиента. Под своей нагрузкой пересчитайте: главное сохранить поведение при переполнении — select с default в хабе, отключающий медленных.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами