---
title: "ReadPump и WritePump на соединение"
description: "ReadPump writePump на Go: пара websocket горутин на соединение, дедлайны, pong-обработчик, ping-тикер и close(send) как сигнал смерти. Код по шагам."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/09-readpump-writepump/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

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

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

Восьмой урок оставил два обещания: у клиента появится канал 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 закрыт, и только потом соединение умирает физически.

> **Не пишите из readPump.** Первый соблазн в этом коде — ответить прямо на месте: пришло сообщение, и рука тянется к conn.WriteMessage. Так рождается второй писатель, и с writePump они начинают рвать кадры друг другу на полпути. Правило без исключений: readPump только читает и кладёт в каналы; любой ответ уходит в send. Это касается и pong-обработчика — он продлевает дедлайн, но не отправляет ничего.

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

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

> **Двойное conn.Close() — не баг.** Оба насоса закрывают conn в defer, и при обычном сценарии это происходит дважды. Второй Close вернёт ошибку и ничего не сломает — сокет уже закрыт. Страховка важнее аккуратности: насосы умирают в разных порядках и по разным причинам, и каждый должен быть уверен, что соединение не переживает его горутину.

### 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-соединение формально живо. Что произойдёт через минуту?

- Ничего: соединение будет висеть, пока клиент не вернётся
- ✅ Истечёт ReadDeadline, ReadMessage вернёт ошибку, сработает unregister и close(send)
- writePump закроет соединение из-за ошибки пинга
- Хаб удалит клиента по таймеру без участия readPump

Механика такая: ping-тикер шлёт пинги, клиент молчит, pong-обработчик не продлевает дедлайн — через pongWait чтение оборвётся ошибкой. Дальше по цепочке: unregister, хаб вычёркивает клиента и закрывает send, writePump видит закрытый канал и уходит. Именно так мёртвые Wi-Fi-клиенты не копятся в карте вечно.

### Проверка: коэффициент девять десятых

**Вопрос:** Почему pingPeriod равен девяти десятым pongWait, а не самому pongWait?

- Для красоты констант в классическом примере
- ✅ Запас на один потерянный ping: сеть может съесть кадр, и соединение не разорвётся
- Чтобы ping и pong не совпали по времени
- Так требует протокол WebSocket

Ping летит каждые пятьдесят четыре секунды при дедлайне в шестьдесят: даже если один ping потеряется, до истечения дедлайна успеет уйти и дойти следующий. Равные интервалы означали бы, что единственный пропавший пинг рвёт живое соединение. Коэффициент из классики, не из стандарта — можно и свой, главное — с запасом.

### Что вернёт ReadMessage

```go
const pongWait = 60 * time.Second

// клиент не отвечает на ping, TCP жив
// прошло 61 секунда с последнего кадра
_, _, err := conn.ReadMessage()
```

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

- err nil: соединение живо, просто тихо
- ✅ Ошибка чтения: дедлайн истёк, соединение порвано
- Паника горутины чтения
- ReadMessage продолжит ждать, сколько потребуется

SetReadDeadline превращает молчание в фатал: по истечении дедлайна чтение возвращает ошибку, и весь конвейер смерти запускается — unregister, close(send), прощание writePump. Без дедлайна ReadMessage честно ждал бы вечно, и мёртвые клиенты копились бы в карте до рестарта процесса.

### Соберите скелет writePump

Заполните три пропуска: дедлайн записи, ветка тикера и тип ping-кадра.

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

Подсказка: Перед каждой записью ставится дедлайн записи; тики таймера приходят из ticker.C; пинг уходит кадром PingMessage без нагрузки.

Правильные ответы в пропусках: `SetWriteDeadline`, `ticker.C`, `PingMessage`

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

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

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

1. `c.conn.SetReadLimit(512)`
2. `c.conn.SetReadDeadline(time.Now().Add(pongWait))`
3. `c.conn.SetPongHandler(func(string) error {`
4. `        c.conn.SetReadDeadline(time.Now().Add(pongWait))`
5. `        return nil`
6. `}))`

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

- **readPump** — Единственный читатель conn: цикл ReadMessage, дедлайн чтения, лимит кадра, pong-обработчик. На выходе — unregister и conn.Close.
- **writePump** — Единственный писатель conn: select из send и ping-тикера, SetWriteDeadline перед каждой записью, CloseMessage при закрытом send.
- **pongWait и pingPeriod** — Классическая пара: 60 секунд тишины и ping каждые 54. Запас девять десятых переживает один потерянный ping.
- **SetPongHandler** — Продлевает ReadDeadline на каждом подтверждении. Безопасно менять дедлайн из него, потому что gorilla вызывает обработчик в горутине readPump.
- **close(send)** — Сигнал смерти писателя: select получит ok со значением false, writePump отправит CloseMessage и выйдет. Закрывает канал только хаб.
- **SetReadLimit** — Потолок размера кадра: превышение рвёт соединение протокольной ошибкой. Защита от DoS гигантскими сообщениями — урок четырнадцатый.

Пара насосов — последний недостающий слой между хабом и протоколом приложения. Сверьте картинку с [хабом восьмого урока](/courses/websocket/lessons/08-hub-pattern/): run раздаёт события, writePump каждого клиента тянет их из send и пишет в conn, readPump кормит хаб входящими — круг замкнулся, и в нём нет ни одной незащищённой записи. Дедлайны и пинги здесь появились сжато; полная механика heartbeat с графиками таймингов — в [шестом уроке](/courses/websocket/lessons/06-ping-pong/). Следующий шаг — смысловая надстройка: в [десятом уроке](/courses/websocket/lessons/10-json-soobscheniya/) по каналам поедут структуры-конверты вместо сырых байтов, и клиент научится различать чат и системные события. Фундамент под всем этим — каналы, select и завершение горутин из [продвинутого курса Go](/courses/go-advanced/); если ветка ok false в select кажется магией, вернитесь туда и освежите. Запуск по-прежнему локальный: пара насосов проверяется двумя вкладками браузера и логом сервера — песочница TCP не поднимет.

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

- Пара горутин на соединение: 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 в хабе, отключающий медленных.
