Эхо-сервер из четвёртого урока честен, но одинок: каждый хендлер владеет своим соединением и не знает соседей. Клиент А пишет — сервер отвечает клиенту А, и на этом круг замкнут. Для чата нужно одно маленькое, но принципиальное изменение: сообщение от любого клиента должно дойти до всех остальных. Это значит, что соединения перестают быть частной собственностью хендлера и становятся общим состоянием сервера: нужен реестр, в котором видно, кто сейчас подключён, функции вступления и выхода, и рассылка по реестру. Такое состояние живёт дольше любого HTTP-запроса: клиенты приходят и уходят в случайные моменты, а реестр существует, пока жив процесс. Одновременно меняется и модель исполнения: в эхо каждая горутина работала только со своим conn, теперь несколько горутин будут читать и менять общую структуру — а значит, появляется вопрос конкурентного доступа, который в Go без дисциплины заканчивается паникой. Обратим внимание и на масштаб: пять клиентов — это пять горутин-читателей, пятьдесят — пятьдесят, и все они со временем дотронутся до одной и той же карты. Соберём всё это в две структуры: реестр с мьютексом и хендлер соединения.
Чат websocket go: карта соединений и мьютекс#
Реестр клиентов удобнее всего держать в карте: map[websocket.Conn]bool даёт удаление за константное время и естественную уникальность — одно соединение не попадёт в список дважды. Срез тоже возможен, но удаление из середины превращается в поиск и копирование, а для операции, которая случается на каждом уходе клиента, это лишняя работа. Значение в карте — булево — нужно только для того, чтобы обойтись без указателей на пустую структуру; некоторые пишут map[conn]struct{}, разницы в поведении нет. Карта — общая для всех хендлеров, а каждый клиент живёт в своей горутине: две одновременные записи в Go-карту фатальны, процесс падает с concurrent map writes без возможности recover. Поэтому вокруг реестра сразу появляется sync.RWMutex: чтение списка под RLock, добавление и удаление под полным Lock. Здесь важно не перепутать, что именно защищает замок: он охраняет целостность карты, а не записи в соединения — об этой ловушке отдельная врезка ниже. Ключевые слова здесь простые, но от них зависит вся архитектура реального чата websocket go-проектов.
Реестр: join, leave и первая broadcast
GOtype Chat struct {
mu sync.RWMutex
clients map[*websocket.Conn]bool
}
func (c *Chat) join(conn *websocket.Conn) {
c.mu.Lock()
defer c.mu.Unlock()
c.clients[conn] = true
}
func (c *Chat) leave(conn *websocket.Conn) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.clients, conn)
}
func (c *Chat) broadcast(msg []byte) {
c.mu.RLock()
defer c.mu.RUnlock()
for conn := range c.clients {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Println("write:", err)
}
}
}join и leave меняют карту, поэтому берут полный Lock; broadcast только читает список, поэтому хватает RLock — и несколько рассылок могут идти параллельно. Ошибка записи в лог не убивает рассылку: одно мёртвое соединение не должно мешать остальным, удалить его лучше в читающем цикле, который и так умрёт на следующем ReadMessage.
gorilla допускает максимум одну горутину, пишущую в conn одновременно. Broadcast под RLock это требование нарушает: два клиента написали в один момент — две горутины рассылки пошли по карте параллельно и обе могут писать в один и тот же conn. Результат — паника concurrent write to websocket connection, и роняет она весь процесс, а не один хендлер. Поймать её легко локально: две вкладки, два сообщения одновременно. Лечения три: писать под полным Lock, сериализовать рассылку одним владельцем или дать каждому соединению своего писателя с каналом — третий путь ведёт к hub-паттерну и writePump.
Вторая половина чата — жизненный цикл соединения, и он повторяет эхо-сервер почти один в один. Хендлер апгрейдит запрос, вступает в чат, ставит deferred выход и садится в цикл чтения. Каждая ошибка ReadMessage — конец соединения: клиент закрыл вкладку, сеть умерла или пришёл битый кадр; нюансы можно не различать, выход один. После выхода срабатывает leave, и реестр уменьшается на единицу — удаляет соединение та самая горутина, которая его читала, поэтому карту при удалении снова приходится брать под Lock. Наивный вопрос новичка: а можно ли не удалять, ведь соединение всё равно мёртвое? Нельзя: мёртвый conn в карте продолжает получать рассылку, превращает каждую WriteMessage в ошибку в логе и держит файловые дескрипторы. Кстати, отсюда же видно, почему heartbeat из шестого урока так важен: без дедлайна чтения мёртвый клиент выглядит живым, и leave для него не наступает никогда.
Чат и смерть клиента: кто узнает первым#
В этой версии есть тонкость, на которую стоит смотреть открытыми глазами: смерть клиента обнаруживается не мгновенно, а на ближайшей записи. ReadMessage мёртвого соединения висит до дедлайна, если вы его поставили, или вечно, если нет, — а значит, leave может не наступать минуты. Рассылка, наоборот, узнаёт о смерти сразу, как только дойдёт до мёртвого conn: WriteMessage вернёт ошибку, мы её залогируем — и пойдём дальше по списку, потому что прерывать рассылку из-за одного клиента было бы ошибкой. Хуже всего выглядит разумный на первый взгляд ход — удалять соединение из карты прямо внутри broadcast: мы стоим под RLock, а leave требует полного Lock, и попытка апгрейда замка на ходу рождает новые гонки. Практический вывод: в этой версии рассылка терпит мёртвых, читатель — вычищает их; каждая горутина делает свою работу и не лезет в чужую. Хаб из следующего урока разрулит и это: там смерть клиента станет обычным событием в канале unregister, а не исключительной ситуацией в логах.
Хендлер соединения: вступил, читал, вышел
GOfunc (s *Server) wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return // upgrader сам ответил клиенту ошибкой
}
s.chat.join(conn)
defer s.chat.leave(conn)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return // закрытие, сеть или битый кадр — выход один
}
s.chat.broadcast(msg)
}
}Порядок строк важен: join сразу после апгрейда, defer leave сразу после join — тогда ни один путь выхода не оставит клиента в реестре. Читающий цикл — тот же, что в эхо, но ошибка чтения теперь не просто завершает хендлер, а запускает разборку соединения в чате. Отправку сообщений наружу выполняет broadcast, и в этой версии она живёт в горутине читателя.
Broadcast сообщения: цена наивной рассылки#
Даже если закрыть глаза на панику двух писателей, у схемы остаётся вторая беда — скорость. WriteMessage блокируется, когда TCP-буфер получателя полон: сеть медленная, клиент уехал в метро, приложение в фоне — ядро держит байты у себя и не даёт писать дальше. В версии под RLock это значит, что одна рассылка на плохом клиенте тормозит все параллельные рассылки: они выстроились за RWMutex. В версии под полным Lock хуже — замеряет весь чат: каждый новый broadcast ждёт, пока медленная запись не окончится. Блокирующая broadcast сообщения — антипаттерн, и решение у него одно: развязать читателей и писателей через канал с буфером, чтобы каждое соединение обслуживалось в своём темпе. В маленьком офисе этого не заметить: двадцать клиентов на быстрой сети пишут мгновенно. Проблема вылезает на публичном интернете, где у каждого второго клиента связь хуже тестовой, — и вылезает именно в пик нагрузки, когда медленных клиентов больше всего. Заодно всплывает и третья беда: байты без смысла. Сервер не знает, чат это или системное уведомление, — протокол появится в десятом уроке, а комнаты для групп — в одиннадцатом.
Что не переживает продакшен в наивном чате#
- Два писателя: broadcast из разных горутин роняет процесс с concurrent write to websocket connection.
- Медленный клиент: блокирующая WriteMessage тормозит рассылку для всех, кто стоит за тем же замком.
- Нет буфера: сообщение либо пишется прямо сейчас, либо теряется — задерживать нечего и некуда.
- Сырые байты: сервер не отличает чат от системного события, протокол сообщений появится позже.
- Один общий чат: все клиенты сидят в одной комнате, групп и личных сообщений нет.
- Нет heartbeat: обрыв Wi-Fi оставляет призрака в карте до первого неудачного broadcast — а с дедлайном из шестого урока он ушёл бы сразу.
Проверка: зачем мьютекс
Что именно защищает sync.RWMutex в этой версии чата?
Проверка: concurrent write
Две горутины одновременно вызвали WriteMessage в одно соединение gorilla/websocket. Что произойдёт?
Две рассылки одновременно
Прочитайте код и выберите, что он выведет:
func (c *Chat) broadcast(msg []byte) {
c.mu.RLock()
defer c.mu.RUnlock()
for conn := range c.clients {
conn.WriteMessage(websocket.TextMessage, msg)
}
}
// клиенты A и B в чате; A шлёт "hi", в тот же момент B шлёт "yo"Соберите реестр чата
Допишите join и broadcast: замок на изменение карты, чтение списка и запись текстового кадра.
Соберите хендлер соединения
Расставьте тело wsHandler в правильном порядке: апгрейд, вступление в чат, гарантия выхода и цикл рассылки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: наивный чат
Проверять чат живьём стоит по старой схеме курса: локальный Go и браузер, потому что песочница не поднимает TCP-сервер. Возьмите клиент из урока про WebSocket в браузере, откройте две вкладки и напишите с обеих: сообщения должны появляться в обоих окнах. Тот же клиент подойдёт для ловли паники — одновременно жмите отправку в двух вкладках, и с вероятностью, растущей с числом попыток, вы увидите concurrent write своими глазами. Дальше по курсу всё встанет на места: в уроке про hub-паттерн рассылку заберёт себе один центральный цикл с каналами, в уроке про протокол сообщений байты превратятся в события, а комнаты из одиннадцатого урока разделят общий чат на группы. Эта наивная версия останется в истории как честный пример того, как просто выглядит чат websocket go-стека до первой настоящей нагрузки. И не спешите её выбрасывать: как тестовый стенд для клиентской стороны она удобна — простая логика, предсказуемые ответы, а вся сложность в будущем переедет на сервер.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Чат отличается от эхо общим состоянием: реестр соединений живёт дольше любого хендлера.
- map[*websocket.Conn]bool с sync.RWMutex — минимальный реестр: чтение под RLock, изменения под Lock.
- gorilla допускает одного писателя: две параллельные broadcast под RLock дают панику concurrent write to websocket connection.
- Мьютекс защищает карту, а не записи в соединения — это разные границы ответственности.
- Блокирующая WriteMessage на медленном клиенте тормозит всех: развязка — канал с буфером и писатель на соединение.
- Соединение удаляет горутина-читатель: ошибка ReadMessage запускает deferred leave.
- Наивный чат — учебная ступень: дальше идут хаб, pumps и JSON-протокол.
Домашнее задание
Соберите чат урока локально и добейтесь паники двух писателей: две вкладки браузера, одновременная отправка, лог concurrent write to websocket connection. Затем почините рассылку первым способом — замените RLock на полный Lock в broadcast — и проверьте, что паника ушла. Оцените честно: какой сценарий станет узким местом при сотне клиентов, и запишите ответ — сравним с хабом из следующего урока.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему для реестра карта, а не срез?
Удаление из карты стоит O(1) и не оставляет дубликатов, а срез при каждом уходе клиента требует поиска и сдвига элементов. Для операции, которая случается на каждом отключении, карта честнее; struct{} вместо bool — вопрос вкуса, поведение то же.
Почему в broadcast RLock, если это приводит к панике?
RLock правильно описывает намерение — читать карту, а не менять её. Ловушка в том, что чтение списка сопровождается записью в соединения, которую замок не охватывает. Правильное лечение — не ужесточать замок, а убрать конкурентные записи: один писатель на соединение, и мьютекс остаётся только на карте.
Чем эта версия отличается от хаба?
В наивном чате рассылку делает горутина читателя каждого клиента, и они мешают друг другу. В хабе состояние принадлежит одному циклу select, исходящие идут через каналы с буфером, и у каждого соединения ровно один писатель. Мьютексная версия легальна при полном Lock, но не масштабируется по медленным клиентам, и смерть соединения в ней обнаруживается с запозданием.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами