The WebSocket Hub: Go's Take on the Actor Model
The WebSocket Hub: Go's Take on the Actor Model
Gortex’s WebSocket Hub is an event loop on its own goroutine; every access to the clients map goes through a channel. It’s the actor model in Go, communication instead of locks.
Prerequisite: A4 goroutines/channels.
Hub.Run: a single-goroutine loop
A4 described the actor mindset; here’s the implementation. Hub.Run() is a for { select } living permanently on its own goroutine, and all state (the clients map, messageTypes) is touched only by it:
func (h *Hub) Run() {
for {
select {
case req := <-h.register: h.registerClient(req.client); req.result <- true
case req := <-h.unregister: h.unregisterClient(req.client); close(req.done)
case op := <-h.broadcast: h.broadcastMessage(op.msg)
case req := <-h.clientCount: req.response <- len(h.clients)
case req := <-h.metricsReq: req.response <- h.snapshotMetrics()
case <-h.shutdown: h.runShutdown(); return
}
}
}
clients is a plain map[*Client]bool with no lock — because only the Run goroutine ever touches it.
Channels instead of locks
To do anything from outside, you send a request into a channel rather than touching the map directly. Want the connection count? Send a request carrying a response channel and wait:
func (h *Hub) GetConnectedClients() int {
req := clientRequest{response: make(chan int)}
select {
case h.clientCount <- req:
return <-req.response
case <-h.shutdown:
return 0
}
}
RegisterClient goes further: it blocks until the hub has actually recorded the client (its result channel is buffered to 1), so callers don’t “sleep and pray”. Registration and shutdown are mutually exclusive, so a client’s send channel is closed exactly once. Fold the synchronisation into channels and the state needs no lock.
Note the cross-goroutine counters (totalConnections, messagesSent…) still use atomic.Int64 — any goroutine reads them, so atomics are required; only the messageTypes map, touched by one goroutine, goes lock-free. Even inside the actor, the tool is chosen by shape — the mirror image of B5.
Slow consumers: drop via select-default
The actor model’s biggest risk is a slow consumer stalling the whole loop. Gortex’s answer is “full means dropped”. On broadcast, for each client:
select {
case client.send <- message:
h.messagesSent.Add(1)
default:
h.forcedDisconnects.Add(1) // couldn't enqueue (buffer full)
h.logger.Warn("client send full, closing", zap.String("client_id", client.ID))
go h.removeClient(client) // evict the slow one
}
Broadcast itself is fire-and-forget too: if the broadcast channel (buffered to 256) is full, the message is dropped and droppedBroadcasts++. The rule is that a slow consumer must never block a producer — and both drops and forced disconnects are recorded in metrics, instead of becoming invisible log-only events.
Graceful shutdown
Shutdown must let in-flight messages land without freezing anyone. shutdownOnce sync.Once guarantees close(h.shutdown) happens exactly once. runShutdown runs on the hub goroutine: first it sends a 1001 (Going Away) close frame to every client, then — the key part — it uses serveDuringGrace(500ms) to keep servicing channels during the grace window rather than a bare time.Sleep, so concurrent Register / GetMetrics calls aren’t frozen. An empty hub skips the grace and shuts down at once; only after the window does it close every client’s send channel.
The hub also has two security defaults (details in B8): a 64 KiB per-message cap against oversize-message DoS, and the Authorizer — a private message’s Target is resolved before the Authorizer runs, so the policy sees the final recipient.
Takeaways
Hub.Run()is a single-goroutine event loop; theclientsmap is touched only by it, so it needs no lock — that’s the actor model in Go.- Outside code interacts via “send a request + wait on a response channel”;
RegisterClientreports synchronously, no sleep needed. - Cross-goroutine counters still use
atomic; only the single-goroutine map goes lock-free (save only when the shape allows). - Slow consumers are “drop when full + force-disconnect”, never blocking producers; drops and disconnects both land in metrics.
- Graceful shutdown uses
sync.Onceplus a grace window that keeps servicing channels (not a sleep); an empty hub shuts down instantly.
Source: yshengliao/gortex.
Outline by Sheng, drafted with Claude · Go 1.25 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.