How Hysteria Core Handles Concurrent Connections: QUIC, Goroutines, and Session Management
Hysteria manages concurrent connections through a layered goroutine architecture that spawns dedicated threads for each QUIC connection and individual TCP streams, while using sync.RWMutex and per-session sync.Mutex to protect shared UDP session state.
The apernet/hysteria repository implements a high-throughput proxy core built on QUIC (HTTP/3) that scales to thousands of simultaneous flows. Rather than using thread pools or complex schedulers, the codebase embraces Go’s goroutine-per-connection model with strategic synchronization primitives to ensure thread safety without sacrificing performance.
Connection-Level Concurrency: The QUIC Listener
At the entry point, serverImpl.Serve in core/server/server.go runs an infinite acceptance loop. Each time the QUIC listener accepts a new connection, the server immediately delegates handling to a new goroutine.
This isolation ensures that a single client’s QUIC session operates independently:
func (s *serverImpl) Serve() error {
for {
conn, err := s.listener.Accept(context.Background())
if err != nil { return err }
go s.handleClient(conn) // ← new goroutine per client
}
}
The handleClient method then initializes an HTTP/3 server instance with a custom StreamDispatcher, creating a dedicated execution context for all streams within that connection.
Stream-Level Parallelism for TCP Traffic
Inside handleClient, Hysteria hijacks incoming HTTP/3 streams via h3sHandler.ProxyStreamHijacker. Rather than processing streams sequentially, the dispatcher spawns a new goroutine for every TCP request:
func (h *h3sHandler) ProxyStreamHijacker(ft http3.FrameType, stream *quic.Stream, err error) (bool, error) {
if ft == protocol.FrameTypeTCPRequest && err == nil && h.authenticated {
qStream := &utils.QStream{Stream: stream}
go h.handleTCPRequest(qStream) // ← concurrent handling per stream
return true, nil
}
return false, nil
}
This design allows dozens of TCP streams to proxy simultaneously over a single QUIC connection without head-of-line blocking. The goroutine-per-stream model matches Hysteria’s goal of maximizing throughput for bursty traffic patterns.
UDP Session Architecture
UDP traffic follows a different concurrency pattern. Instead of per-packet goroutines, Hysteria uses a centralized udpSessionManager instantiated in core/server/udp.go.
The Manager Loop
When UDP is enabled, handleClient starts the manager’s Run loop in its own goroutine:
func (m *udpSessionManager) Run() error {
stopCh := make(chan struct{})
go m.idleCleanupLoop(stopCh) // ← cleanup runs in its own goroutine
defer close(stopCh)
for {
msg, err := m.io.ReceiveMessage()
if err != nil { return err }
m.feed(msg) // feed updates/creates per‑session entries
}
}
This loop receives all UDP datagrams for the connection and routes them to appropriate session entries based on SessionID.
Per-Session State and Cleanup
Each UDP session is represented by a udpSessionEntry stored in a protected map:
- Shared state protection: The map
map[uint32]*udpSessionEntryis guarded by async.RWMutex. Lookups during active traffic acquire read locks, while new session creation requires write locks. - Per-session isolation: Each entry owns its own UDP connection and runs
receiveLoopin a dedicated goroutine. - Race prevention: A per-entry
sync.Mutex(connLock) guarantees that a connection cannot be created after the session has been closed. - Resource reclamation: An
idleCleanupLoopruns every second to remove stale sessions based on configurable timeouts, preventing memory leaks from abandoned UDP flows.
Synchronization Strategy
Hysteria’s concurrency model distributes locks across layers to minimize contention:
| Layer | Primitive | Purpose |
|---|---|---|
| Connection acceptance | Goroutine per connection (handleClient) |
Isolates each client’s QUIC session |
| TCP streams | Goroutine per request (handleTCPRequest) |
Enables parallel proxying without blocking |
| UDP session map | sync.RWMutex |
Allows concurrent reads (traffic) with exclusive writes (new sessions) |
| UDP session entry | sync.Mutex (connLock) |
Prevents races during connection setup and teardown |
| Client-side | Identical patterns | core/client/client.go mirrors server concurrency logic for bidirectional consistency |
Summary
- Hysteria accepts QUIC connections in
core/server/server.goand immediately spawns a goroutine per connection viago s.handleClient(conn). - Each TCP stream within a connection runs concurrently through
go h.handleTCPRequest(qStream), preventing head-of-line blocking. - UDP traffic uses a centralized
udpSessionManagerwith a goroutine-per-session model andsync.RWMutexfor the session map to optimize read-heavy workloads. - Per-session
sync.Mutexlocks and a dedicatedidleCleanupLoopensure safe resource cleanup and prevent race conditions during session termination.
Frequently Asked Questions
How many goroutines does Hysteria create per client connection?
Hysteria creates one goroutine to handle the overall QUIC connection, plus an additional goroutine for every active TCP stream via handleTCPRequest. For UDP traffic, it spawns one goroutine per UDP session entry to manage read/write loops independently.
Why does Hysteria use sync.RWMutex specifically for UDP sessions?
The sync.RWMutex in core/server/udp.go optimizes for read-heavy access patterns. Since normal traffic requires frequent lookups in the session map while new session creation is relatively rare, read locks allow multiple goroutines to query the map simultaneously without blocking each other.
What prevents race conditions when closing UDP sessions?
Each udpSessionEntry maintains its own sync.Mutex (the connLock field) to serialize connection creation and closure operations. Additionally, the idleCleanupLoop checks atomic timestamps before removing entries, ensuring that active sessions are never terminated mid-transmission.
Does Hysteria reuse goroutines or create new ones for each stream?
Hysteria creates new goroutines for each TCP stream rather than using a fixed worker pool. This approach leverages Go’s efficient scheduler and minimizes latency by eliminating queueing overhead, which is critical for maintaining low-latency proxy performance under high concurrency.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →