3 Architecture
Virgil edited this page 2026-02-19 16:55:59 +00:00

Architecture

The go-ws package implements a hub-and-spoke WebSocket architecture. A single Hub goroutine owns all mutable state and coordinates client registration, channel subscriptions, and message delivery.

Back to Home

Goroutine Model

                    ┌───────────────────────┐
                    │     Hub.Run(ctx)       │
                    │   (single goroutine)   │
                    │                        │
  register ────────►│  clients map           │
  unregister ──────►│  channels map          │
  broadcast ───────►│  select loop           │
  ctx.Done() ──────►│                        │
                    └───────────────────────┘
                         │           │
                    ┌────┘           └────┐
                    ▼                     ▼
             ┌────────────┐        ┌────────────┐
             │  Client A   │        │  Client B   │
             │ readPump()  │        │ readPump()  │
             │ writePump() │        │ writePump() │
             └────────────┘        └────────────┘

Each WebSocket connection spawns exactly two goroutines:

Goroutine Responsibility
readPump Reads incoming messages from the client, dispatches subscribe/unsubscribe/ping
writePump Drains the client's send channel and writes to the WebSocket; sends periodic pings

The Hub itself runs in a single goroutine that processes all state mutations via channel-based message passing. This eliminates the need for fine-grained locking in the hot path.

Connection Lifecycle

1. HTTP request arrives at Hub.Handler()
2. gorilla/websocket upgrades the connection
3. Client struct created (send channel, subscriptions map)
4. Client sent to hub.register channel
5. Hub.Run() adds client to clients map
6. readPump() and writePump() goroutines launched
7. Client sends/receives messages...
8. Connection closes (error, timeout, or explicit close)
9. readPump() sends client to hub.unregister channel
10. Hub.Run() removes client from clients map and all channels
11. Empty channels are garbage-collected

Upgrade Configuration

The WebSocket upgrader uses the following defaults:

Setting Value
Read buffer 1024 bytes
Write buffer 1024 bytes
Origin check All origins allowed (suitable for local/development use)
Read limit 65536 bytes per message
Read deadline 60 seconds (reset on pong)
Write deadline 10 seconds
Ping interval 30 seconds (server-to-client)

Write Batching

The writePump batches queued messages into a single WebSocket write frame when multiple messages are buffered. This reduces syscall overhead under high throughput:

// After writing the first message, drain any queued messages
n := len(c.send)
for i := 0; i < n; i++ {
    w.Write([]byte{'\n'})
    w.Write(<-c.send)
}

Concurrency Model

The Hub uses a layered concurrency approach:

Layer Mechanism Purpose
Hub event loop Go channels (register, unregister, broadcast) Serialise state mutations
Hub public methods sync.RWMutex on Hub Safe reads from outside the event loop (e.g. Stats(), ClientCount())
Channel operations sync.RWMutex on Hub Protect the channels map during Subscribe/Unsubscribe/SendToChannel
Client subscriptions sync.RWMutex on Client Protect the per-client subscriptions map

Back-Pressure

Each client has a buffered send channel (capacity 256). If a client cannot keep up:

  1. The broadcast or SendToChannel call attempts a non-blocking send
  2. If the buffer is full, the client is scheduled for unregistration
  3. The Hub removes the slow client on the next event loop iteration

This prevents one slow consumer from blocking delivery to other clients.

Graceful Shutdown

When the context passed to Hub.Run() is cancelled:

  1. The select loop receives ctx.Done()
  2. All client send channels are closed
  3. Each writePump detects the closed channel and sends a WebSocket close frame
  4. All client entries are removed from the Hub
  5. Hub.Run() returns
ctx, cancel := context.WithCancel(context.Background())
go hub.Run(ctx)

// Later, to shut down cleanly:
cancel()

See Also