Наивный чат из прошлого урока упёрся в две стены: параллельные 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 естественно расширяется: завтра к циклу добавится ветка для личных сообщений, послезавтра — для комнат, и все они останутся в одном месте под одним владельцем.
Хаб: структура и главный цикл
GOtype 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 — хаб, и закрывает его только он, в ветке 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 вычистит клиента. Альтернатива — блокирующая отправка — возвращает проблему медленного клиента, только теперь в теле самого хаба.
Подключение клиента к хабу
GOfunc 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 в том же цикле |
Жизненный цикл клиента в хабе#
- Апгрейд: HTTP-запрос становится websocket.Conn.
- Создание Client с каналом send на 256 сообщений.
- register <- client: хаб добавляет клиента в карту.
- Запуск writePump отдельной горутиной и readPump в текущей.
- Все исходящие идут через send: хаб кладёт, писатель достаёт и пишет в conn.
- Ошибка чтения: 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 и рассылка.
Соберите структуру Hub
Расставьте поля структуры Hub в правильном порядке: карта клиентов и три канала событий.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: hub-паттерн
Хаб — это скелет, на который дальше наращивается всё остальное, поэтому закрепить его стоит не глазами, а компилятором: локальный 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 и пошлите пять сообщений в упавший клиент — опишите, что станет с остальными.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему внутри хаба не нужен мьютекс?
Карту клиентов читает и пишет только горутина run: конкурентного доступа нет, защищать нечего. Взаимодействие с внешним миром идёт через каналы — они и есть точка синхронизации. Мьютексная версия тоже легальна, но хаб даёт очередь, единое место для логики и предсказуемое отключение медленных клиентов.
Почему буфер send именно 256?
Число из классического примера, подобранное как разумный компромисс: сглаживает всплески рассылки, не съедая память при тысячах клиентов. Это константа, а не закон: под своей нагрузкой её подбирают заново, но смысл остаётся — буфер должен переживать краткие паузы клиента и при переполнении отключать его, а не блокировать хаб.
Не станет ли один цикл run узким местом?
Цикл только перекладывает сообщения между каналами — это копирование ссылок, а не запись в сеть. Реальная работа идёт в writePump каждого клиента параллельно. Узким местом run становится при десятках тысяч клиентов и тяжёлой логике в ветках; тогда выносят фильтрацию и сериализацию, а сам хаб оставляют диспетчером.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами