---
title: "Hub-паттерн: register, unregister, broadcast"
description: "Hub pattern websocket на Go: каналы register, unregister и broadcast, один цикл select и буфер send на 256. Разбор классического gorilla chat example по шагам."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/08-hub-pattern/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Hub-паттерн: register, unregister, broadcast

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

Hub pattern websocket — это когда состояние чата принадлежит одной горутине: каналы register, unregister и broadcast сводят все события в единый цикл select, и гонки исчезают по построению. Именно так устроен классический gorilla chat example — пример, который пережил десятилетия и остаётся скелетом почти каждого чата на Go. Соберём хаб по шагам: структуру клиента с каналом send, ветки регистрации и отключения, рассылку с политикой переполнения.

Наивный чат из прошлого урока упёрся в две стены: параллельные broadcast сталкиваются записями в один conn, а медленный клиент тормозит рассылку за общим замком. Обе стены убираются одним архитектурным ходом — забрать состояние у горутин и отдать его единственному владельцу. Представьте центрального диспетчера: клиенты больше ничего не знают друг о друге и не пишут друг в друга; каждый шлёт события диспетчеру, а тот один решает, кому и что доставить. В Go роль диспетчера играет структура с несколькими каналами и вечный цикл select внутри одной горутины. Событий ровно три: клиент подключился, клиент отключился, пришло сообщение для всех. Отсюда и названия каналов — register, unregister, broadcast, — которые вы теперь будете узнавать в каждом websocket-проекте на Go. Тот же каркас обслуживает и другие real-time-задачи: тикеры котировок шлют в broadcast новый курс, системы presence — события входа и выхода, кооп-редакторы — правки документа; меняется содержимое сообщений, а не устройство диспетчера. Структура клиента меняется тоже: кроме conn у него появляется канал send — его личная очередь исходящих сообщений. Писать в соединение будет один-единственный потребитель этого канала, и вся проблема двух писателей растворяется: больше нет нескольких отправителей.

### Hub pattern websocket: каналы вместо замка

Структура хаба скромна: карта клиентов и три канала. Карта объявлена без мьютекса не из халатности — к ней нет конкурентного доступа, потому что читает и пишет её только горутина run. Каналы служат почтой между миром клиентов и этим единственным владельцем: любые структуры данных, которые раньше приходилось защищать замками, здесь заменяются передачей сообщения в канал. Это тот же приём, что вы знаете из курса по конкурентности: не делить память, а передавать владение. Пока содержимым каналов были сырые байты, но это не ограничение: завтра в broadcast поедут структуры конверта из десятого урока, и типы каналов поменяются вместе с протоколом, не трогая сам цикл. Register приносит нового клиента, unregister — уходящего, broadcast — байты для всех. Порядок веток в select не задаёт приоритетов: select выбирает готовый канал случайно, и это нормально, потому что ни одно событие не срочнее другого. Вечный цикл вокруг select — обычная практика: хаб живёт, пока жив процесс. В отличие от карты с мьютексом hub pattern websocket естественно расширяется: завтра к циклу добавится ветка для личных сообщений, послезавтра — для комнат, и все они останутся в одном месте под одним владельцем.

### Хаб: структура и главный цикл

```go
type Client struct {
        conn *websocket.Conn
        send chan []byte
}

type Hub struct {
        clients    map[*Client]bool
        broadcast  chan []byte
        register   chan *Client
        unregister chan *Client
}

func (h *Hub) run() {
        for {
                select {
                case client := <-h.register:
                        h.clients[client] = true

                case client := <-h.unregister:
                        if _, ok := h.clients[client]; ok {
                                delete(h.clients, client)
                                close(client.send)
                        }

                case message := <-h.broadcast:
                        for client := range h.clients {
                                select {
                                case client.send <- message:
                                default:
                                        close(client.send)
                                }
                        }
                }
        }
}
```

Три канала — и три ветки select в вечном цикле. Регистрация — метка в карте; отключение — delete и close(send): закрытый канал станет сигналом смерти для писателя этого клиента. Рассылка кладёт сообщение в send каждого, но через select с default: если буфер полон, клиент не успевает читать — его send закрывается, а хаб не блокируется. Именно select/default отличает живой хаб от версии, которая встанет на одном медленном клиенте.

> **close(send) закрывается ровно один раз.** Владелец канала send — хаб, и закрывает его только он, в ветке unregister. Второй close того же канала — паника close of closed channel. Отсюда правило: никто кроме хаба не трогает close, остальные сигналят об уходе через unregister. Классический баг из статей десятилетней давности — defer close(client.send) в readPump в паре с unregister в хабе: два претендента на закрытие, и рано или поздно процесс падает.

### Буфер send на 256 и select/default

Почему буфер канала send именно 256 сообщений? Число из классики, и смысл его не магический, а практический: сгладить всплеск. Хаб рассылает пачками, писатель каждого клиента тянет со своей скоростью; без буфера каждое сообщение требовало бы синхронной встречи отправителя и получателя. Буфер на двести пятьдесят шесть даёт клиенту запас на короткие паузы — подгрузился DOM, подвисла сборка мусора, отстала сеть. Память за запас платится по честному тарифу: канал хранит 256 заголовков среза, по 24 байта каждый — около шести килобайт на клиента ещё до первого сообщения, а на десять тысяч подключений это уже десятки мегабайт. Важнее размера поведение при переполнении: select с default делает попытку положить сообщение и не ждёт, если не влезает. Ветка default интерпретируется жёстко: клиент не успевает вычитывать свою очередь — значит, он фактически мёртв или слишком медленный, и держать его в чате нет смысла. Закрытие send в этой ветке запускает цепочку разбора соединения: писатель увидит закрытый канал и уйдёт, readPump получит ошибку, unregister вычистит клиента. Альтернатива — блокирующая отправка — возвращает проблему медленного клиента, только теперь в теле самого хаба.

### Подключение клиента к хабу

```go
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
                return
        }

        client := &Client{
                conn: conn,
                send: make(chan []byte, 256),
        }
        hub.register <- client

        go client.writePump()
        client.readPump()
}
```

Хендлер апгрейдит соединение, создаёт клиента с буферизованным send, регистрирует его в хабе и запускает пару насосов: писатель — в отдельной горутине, читатель — в текущей. ReadPump здесь упрощён, полный вариант с дедлайнами и лимитом разберём в следующем уроке. Метод run хаба стартует один раз в main перед ListenAndServe — иначе регистрация повиснет без приёмника.

### Gorilla chat example по шагам

Всё, что мы собрали, — это скелет chat example из репозитория gorilla, и полезно понимать, что в оригинале добавлено к нему. Там Client живёт в пакете chat вместе с Hub, канал send буферизован теми же 256, а writePump содержит хитрый трюк: он пишет сообщения через NextWriter, держит открытый буфер и умудряется слить несколько скопившихся сообщений в один кадр — оптимизация, до которой мы дойдём в уроке про батчинг. В оригинале также есть дедлайны и ping-тикер в writePump — вся heartbeat-механика шестого урока. Почему этот пример стал учебником? Он минимальный и при этом промышленный: в нём нет ни одной гонки, ни одного лишнего мьютекса, а поведение при переполнении и отключении определено явно. Скопировать его — дело пяти минут, поэтому ценнее идти по нему медленно и задавать вопросы: кто владеет close, кто пишет в conn, что случится, если буфер переполнить. Эти три вопроса — контрольные и для вашего собственного кода.

### Когда хаб не нужен

Честности ради: hub pattern websocket — не догма, а решение под задачу. Для пары клиентов и личного уведомления он избыточен: эхо-сервер с одним читателем и одним писателем в одной горутине проще и без всяких каналов. Не нужен хаб и там, где нет общего состояния: уведомления конкретному пользователю по его личному соединению можно отправлять прямо из обработчика события, если они не пересекаются с рассылкой. Но как только появляется хотя бы список клиентов, общих подписчиков или групп, дисциплина единого владельца окупается в первый же день — иначе вы будете изобретать её сами, но с худшими названиями. Мой рабочий порог простой: появился второй источник событий для одного соединения — время для хаба.

### Мьютекс-чат против хаба

| Критерий | Карта с мьютексом (урок 07) | Хаб с каналами |
| --- | --- | --- |
| Кто пишет в conn | любая горутина рассылки — гонка двух писателей | ровно один писатель на клиента, из send |
| Владелец состояния | карта под RWMutex, доступ у всех | карта внутри run(), доступа извне нет |
| Медленный клиент | тормозит рассылку за общим замком | переполненный send — отключение, остальные не страдают |
| Расширение | новые типы событий — новые замки и условия | новая ветка select в том же цикле |

### Жизненный цикл клиента в хабе

1. Апгрейд: HTTP-запрос становится websocket.Conn.
2. Создание Client с каналом send на 256 сообщений.
3. register <- client: хаб добавляет клиента в карту.
4. Запуск writePump отдельной горутиной и readPump в текущей.
5. Все исходящие идут через send: хаб кладёт, писатель достаёт и пишет в conn.
6. Ошибка чтения: unregister <- client, хаб удаляет клиента и закрывает send.

### Проверка: небуферизованный broadcast

**Вопрос:** Канал hub.broadcast небуферизованный, хаб занят обработкой register. Что сделает readPump, выполнив h.broadcast <- message?

- Вернётся немедленно: сообщение поместится в очередь канала
- ✅ Заблокируется, пока select в run не дойдёт до ветки broadcast
- Потеряет сообщение без всяких последствий
- Перезапустит метод run

Передача в небуферизованный канал ждёт приёмника. Хаб примет сообщение на ближайшей итерации select — задержка микросекунды, и это осознанный выбор классики. Если же паузы недопустимы, канал broadcast делают буферизованным, но тогда приходится думать о переполнении уже его.

### Проверка: переполнение send

**Вопрос:** Что делает select с веткой default в broadcast-рассылке хаба?

- Ждёт, пока буфер send клиента освободится
- ✅ Пытается положить сообщение, а при полном буфере закрывает send — клиент отключается
- Пишет сообщение прямо в conn в обход канала
- Откладывает сообщение до следующего broadcast

select/default — неблокирующая попытка отправки. Полный буфер означает, что клиент не вычитывает очередь: его отключают, вместо того чтобы тормозить весь хаб. Вернуться он сможет reconnect-логикой клиента, а остальные продолжат получать рассылку без паузы. Тот же трюк встречается и вне хаба — везде, где очередь может застрять на одном получателе.

### Кто получит сообщение?

```go
// клиенты A и B подключены и зарегистрированы
// клиент A отправил кадр с текстом "hi"
// readPump клиента A: h.broadcast <- message
```

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

- Только B: отправителю своё сообщение не возвращается
- ✅ A и B: хаб рассылает всем в карте, включая отправителя
- Никто: хаб занят и пропустит сообщение
- Только A: хаб шлёт эхо отправителю

В классическом примере рассылка идёт по всей карте клиентов, и отправитель получает своё сообщение тоже — никакого особого случая для него нет. Клиентская сторона обычно не рисует чужие копии своего текста или фильтрует по автору; серверная простота здесь важнее удобства.

### Соберите ветки хаба

Впишите имена каналов и операцию закрытия: регистрация, отключение с закрытием send и рассылка.

```go
case client := <-h.___:
        h.clients[client] = true
case client := <-h.___:
        if _, ok := h.clients[client]; ok {
                delete(h.clients, client)
                ___(client.send)
        }
case message := <-h.___:
        for client := range h.clients {
                select {
                case client.send <- message:
                default:
                        ___(client.send)
                }
        }
```

Подсказка: Регистрация и отключение — это register и unregister, сообщение для всех идёт в broadcast; закрытие канала выполняет close.

Правильные ответы в пропусках: `register`, `unregister`, `close`, `broadcast`, `close`

### Соберите структуру Hub

Расставьте поля структуры Hub в правильном порядке: карта клиентов и три канала событий.

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

1. `type Hub struct {`
2. `clients    map[*Client]bool`
3. `broadcast  chan []byte`
4. `register   chan *Client`
5. `unregister chan *Client`
6. `}`

### Карточки: hub-паттерн

- **Hub** — Структура с картой клиентов и каналами register, unregister, broadcast. Единственная горутина run() владеет состоянием и рассылкой.
- **Канал register** — Вход новых клиентов: хаб отмечает Client в карте. Отправитель — хендлер serveWs сразу после апгрейда.
- **Канал unregister** — Уход клиентов: хаб удаляет Client из карты и закрывает его send. Закрытие канала — сигнал смерти для writePump.
- **Канал broadcast** — Сообщения для всех: readPump кладёт, хаб разносит по send каждого клиента через select с default.
- **send на 256** — Личная очередь исходящих клиента. Буфер сглаживает всплески; переполнение означает, что клиент не успевает, — его отключают.
- **close of closed channel** — Паника при повторном закрытии канала. В хабе close(send) зовёт только ветка unregister — владелец ровно один.

Хаб — это скелет, на который дальше наращивается всё остальное, поэтому закрепить его стоит не глазами, а компилятором: локальный Go, один файл, go mod init, go get github.com/gorilla/websocket — и два клиента из урока про [WebSocket в браузере](/courses/websocket/lessons/05-js-klient/) в соседних вкладках. В следующем уроке достроим pumps: [ReadPump и WritePump](/courses/websocket/lessons/09-readpump-writepump/) с дедлайнами, лимитом и ping-тикером — тогда соединения научатся умирать красиво. После этого хаб естественно обрастает смыслом: [JSON-протокол](/courses/websocket/lessons/10-json-soobscheniya/) превратит байты в события, а итоговый проект курса соберёт всё в чат с комнатами — его план лежит на странице [курса WebSocket](/courses/websocket/). Если сравнивать с [наивным чатом из седьмого урока](/courses/websocket/lessons/07-chat-rassylka/), хаб добавляет одну горутину и три канала, а убирает целый класс гонок.

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

- Хаб — единый владелец состояния: карта клиентов трогается только горутиной run().
- Три канала: register для входа, unregister для ухода, broadcast для сообщений всем.
- Каждый клиент носит канал send с буфером 256 — личную очередь исходящих сообщений.
- close(send) зовёт только хаб в ветке unregister: второй close — паника close of closed channel.
- select/default в рассылке отключает переполненного клиента, не блокируя хаб.
- Классический gorilla chat example — тот же скелет плюс дедлайны, ping-тикер и батчинг кадров.
- Расширение хаба — новая ветка select: комнаты и личные сообщения встраиваются без ломки основы.

## Домашнее задание

Соберите хаб урока целиком и запустите локально с двумя вкладками браузера. Затем сломайте его осознанно: добавьте defer close(client.send) в readPump, подключите и отключите клиента несколько раз — дождитесь close of closed channel в логе. Уберите строку обратно и объясните себе, почему владелец close должен быть один. Бонус: сделайте буфер send размером 2 и пошлите пять сообщений в упавший клиент — опишите, что станет с остальными.

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

### Почему внутри хаба не нужен мьютекс?

Карту клиентов читает и пишет только горутина run: конкурентного доступа нет, защищать нечего. Взаимодействие с внешним миром идёт через каналы — они и есть точка синхронизации. Мьютексная версия тоже легальна, но хаб даёт очередь, единое место для логики и предсказуемое отключение медленных клиентов.

### Почему буфер send именно 256?

Число из классического примера, подобранное как разумный компромисс: сглаживает всплески рассылки, не съедая память при тысячах клиентов. Это константа, а не закон: под своей нагрузкой её подбирают заново, но смысл остаётся — буфер должен переживать краткие паузы клиента и при переполнении отключать его, а не блокировать хаб.

### Не станет ли один цикл run узким местом?

Цикл только перекладывает сообщения между каналами — это копирование ссылок, а не запись в сеть. Реальная работа идёт в writePump каждого клиента параллельно. Узким местом run становится при десятках тысяч клиентов и тяжёлой логике в ветках; тогда выносят фильтрацию и сериализацию, а сам хаб оставляют диспетчером.
