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

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

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

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

ReadPump и WritePump на соединение

ReadPump writePump — пара горутин, на которой держится каждый промышленный websocket-сервер на Go: читатель один, писатель один, всё остальное общается с ними через каналы. В этом уроке соберём оба насоса целиком — дедлайны, лимит кадра, pong-обработчик и ping-тикер из классического примера gorilla, — а заодно посчитаем, сколько websocket горутин живёт в реальном сервисе и почему это дёшево.

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

GO
const (
        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 закрыт, и только потом соединение умирает физически.

writePump: ping-тикер и один писатель

GO
func (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: кто что трогает#

ОперацияreadPumpwritePumpХаб
ReadMessage, дедлайн чтения, pongвладелец——
WriteMessage, дедлайн записи, ping—владелец—
Запись в send клиента——владелец
Чтение из send—владелец—
close(send)——владелец
conn.Close()в defer на выходев defer на выходе—

Websocket горутины: сколько их на самом деле#

Посчитаем всерьёз, сколько websocket горутин несёт реальный сервис. На процесс — одна горутина хаба. На каждое соединение — две: readPump в горутине хендлера и writePump отдельной командой go. Тысяча открытых вкладок — две тысячи и одна горутина; десять тысяч — двадцать тысяч и одна. Звучит угрожающе только до первой арифметики: стартовый стек горутины — порядка двух килобайт, и даже с буфером send на двести пятьдесят шесть сообщений клиент стоит десятки килобайт; двадцать тысяч живых клиентов — сотни мегабайт, и это цена двадцати тысяч одновременных разговоров, а не накладные расходы. Переключение между горутинами дешевле переключения между потоками на порядки, и модель один-клиент-две-горутины остаётся стандартом индустрии именно потому, что Go делает её дешёвой. Бить тревогу нужно не по числу горутин, а по их содержимому: тяжёлая обработка в readPump, большие буферы send, аллокации на каждый кадр — вот где сервис раздувается. Профилирование из продвинутого курса Go — инструмент на этот случай.

serveWs: сборка пары на соединение

GO
type 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-кадра.

ticker := time.NewTicker(pingPeriod) for { select { case message, ok := <-c.send: c.conn.(time.Now().Add(writeWait)) if !ok { return } c.conn.WriteMessage(websocket.TextMessage, message) case <-: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.WriteMessage(websocket., nil) } }

Соберите цепочку настроек readPump

Расставьте строки настроек чтения в правильном порядке: лимит, дедлайн и продление через pong-обработчик.

Строки

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

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

Карточки: насосы соединения

Термин

readPump

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

Единственный читатель conn: цикл ReadMessage, дедлайн чтения, лимит кадра, pong-обработчик. На выходе — unregister и conn.Close.

1 / 6

Пара насосов — последний недостающий слой между хабом и протоколом приложения. Сверьте картинку с хабом восьмого урока: 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.

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · ReadPump и WritePump на соединение)

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

Почему два насоса, а не один цикл с select на чтение и запись?

Потому что ReadMessage и WriteMessage — блокирующие вызовы без интеграции с select: нельзя ждать кадр и готовность записи одновременно. Разделение на две горутины — стандартный ответ Go на любую пару блокирующих потоков: каждый живёт своим блокирующим вызовом, а связываются они каналами.

Нужен ли writePump, если сервер только шлёт и ничего не читает?

Читать всё равно придётся: протокол требует отвечать на close-кадры и обрабатывать pong, а без readPump соединение не узнает об уходе клиента. Если исходящих нет, send останется пустым, ping-тикер — единственным источником работы writePump; пара вырождается, но не исчезает.

Почему буфер send снова 256, как в хабе?

Это константа классического примера, а не закон: буфер должен сглаживать всплески рассылки и при этом не съедать память на тысячах клиентов. Двести пятьдесят шесть заголовков среза — килобайты на клиента. Под своей нагрузкой пересчитайте: главное сохранить поведение при переполнении — select с default в хабе, отключающий медленных.

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

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

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

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

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

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

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