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

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

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

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

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

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

Содержание урока · 9 разделов

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

Буфер 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?

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

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

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

Прочитайте код и выберите, что он выведет:

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

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

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

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

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

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

Строки

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

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

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

Термин

Hub

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

Структура с картой клиентов и каналами register, unregister, broadcast. Единственная горутина run() владеет состоянием и рассылкой.

1 / 6

Хаб — это скелет, на который дальше наращивается всё остальное, поэтому закрепить его стоит не глазами, а компилятором: локальный Go, один файл, go mod init, go get github.com/gorilla/websocket — и два клиента из урока про WebSocket в браузере в соседних вкладках. В следующем уроке достроим pumps: ReadPump и WritePump с дедлайнами, лимитом и ping-тикером — тогда соединения научатся умирать красиво. После этого хаб естественно обрастает смыслом: JSON-протокол превратит байты в события, а итоговый проект курса соберёт всё в чат с комнатами — его план лежит на странице курса WebSocket. Если сравнивать с наивным чатом из седьмого урока, хаб добавляет одну горутину и три канала, а убирает целый класс гонок.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Хаб — единый владелец состояния: карта клиентов трогается только горутиной 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 и пошлите пять сообщений в упавший клиент — опишите, что станет с остальными.

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · Hub-паттерн: register, unregister, broadcast)

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

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

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

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

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

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

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

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

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

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

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

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

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

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