How Fabrica-Util's Closure Primitive Manages Thread-Safe Callbacks in Game Servers
Fabrica-Util's Closure primitive is a synchronization wrapper that guarantees callbacks execute exactly once across concurrent goroutines while preventing data races.
In high-concurrency game server architectures like those built with the go-pantheon ecosystem, managing callbacks safely is critical to avoiding data races and duplicate side effects. The Closure type defined in xsync/closure.go provides a zero-allocation, mutex-protected mechanism for executing arbitrary functions with guaranteed single-fire semantics.
What Is the Closure Primitive?
The Closure primitive is a tiny synchronization wrapper that encapsulates a callback function—either func() error or func(context.Context) error for context-aware variants—and ensures it runs safely even when triggered simultaneously from multiple goroutines. Unlike a naïve function pointer, the Closure struct maintains an internal sync.Mutex, storage for the returned error, and boolean flags to track execution state.
This design isolates concurrency concerns to a single, well-tested component. Rather than re-implementing locking logic in every subsystem, game server components delegate callback safety to Closure, which resides at github.com/go-pantheon/fabrica-util/xsync/closure.go.
Why Game Servers Need Thread-Safe Callbacks
Game servers handling thousands of concurrent connections face specific challenges where unprotected callbacks cause catastrophic failures:
- Event handling: Player login notifications, match-making updates, and world-tick events must fire exactly once to prevent duplicate database writes or conflicting state updates.
- Lifecycle hooks: Connection teardown, resource cleanup, and graceful shutdown require idempotent execution to avoid double-closing sockets or leaking goroutines.
- Asynchronous responses: Background operations like AI calculations must respect cancellation signals when the server initiates shutdown.
Without synchronization, these callbacks risk data races, panic-induced crashes, and duplicated work when network retransmissions or multiple subsystems attempt to trigger the same event simultaneously.
How Closure Guarantees Safe Execution
The implementation in xsync/closure.go solves these problems through several coordinated mechanisms:
Mutex-protected execution: The Do() method acquires an internal sync.Mutex before invoking the wrapped callback. This ensures that state-mutating operations—such as updating a player's session metadata—never run concurrently, eliminating race conditions.
Idempotent single-fire semantics: After the first successful invocation, Closure stores the returned error and a completion flag. Subsequent calls to Do() return the cached result without re-invoking the underlying function. This prevents duplicated side effects when events are published multiple times due to network retransmissions or redundant triggers.
Context awareness: The ClosureCtx variant accepts func(context.Context) error signatures and provides DoCtx(ctx), allowing callbacks to abort early on timeout or cancellation. This enables graceful termination of long-running logic when the server receives a shutdown signal.
Error propagation: The wrapper stores the first error returned by the callback and returns it on every subsequent Do() call. Callers can inspect this result to determine whether to retry operations, log failures, or disconnect clients.
Zero-allocation overhead: The struct consists only of a function field, mutex, error slot, and boolean flags—minimal memory footprint critical for massively multiplayer servers managing millions of connections.
Practical Implementation Examples
Basic Usage Pattern
Create a Closure using the constructor and invoke it safely from multiple goroutines:
package main
import (
"fmt"
"time"
"github.com/go-pantheon/fabrica-util/xsync"
)
func updatePlayerScore() error {
fmt.Println("updating score...")
// Database or cache update logic here
return nil
}
func main() {
cl := xsync.NewClosure(updatePlayerScore)
// Fire from five concurrent goroutines—only the first executes
for i := 0; i < 5; i++ {
go func(id int) {
if err := cl.Do(); err != nil {
fmt.Printf("goroutine %d: %v\n", id, err)
}
}(i)
}
time.Sleep(100 * time.Millisecond)
}
Context-Aware Execution
For operations requiring cancellation support, use NewClosureCtx with timeout constraints:
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
ctxClosure := xsync.NewClosureCtx(func(ctx context.Context) error {
select {
case <-time.After(30 * time.Millisecond):
fmt.Println("work completed")
return nil
case <-ctx.Done():
return ctx.Err()
}
})
if err := ctxClosure.DoCtx(ctx); err != nil {
fmt.Println("operation aborted:", err)
}
Real-World Game Server Pattern
In a gateway component (similar to the Janus architecture), ensure connections close exactly once:
type ConnHandler struct {
closeOnce xsync.Closure
}
func NewConnHandler(conn net.Conn) *ConnHandler {
h := &ConnHandler{}
h.closeOnce = xsync.NewClosure(func() error {
return conn.Close()
})
return h
}
// Safe to call from read loop, write loop, or shutdown signal
func (h *ConnHandler) Close() error {
return h.closeOnce.Do()
}
Integration with Higher-Level Utilities
The Closure primitive serves as the foundational building block for fabrica-util's advanced synchronization utilities:
Delayer(xsync/delayer.go): Time-based task scheduling relies onClosureto ensure delayed callbacks execute safely when their timer fires.Future(xsync/future.go): Asynchronous result handling usesClosureto wrap completion callbacks, preventing double-completion when multiple goroutines resolve the same future.Routines(xsync/routines.go): Goroutine lifecycle management utilities employ the same mutex patterns pioneered inClosurefor coordinated shutdown.
By centralizing callback safety in xsync/closure.go, these higher-level components avoid duplicating concurrency logic while maintaining consistent behavior across the Roma, Janus, Lares, and Senate server architectures.
Summary
- Fabrica-Util's Closure provides mutex-protected, single-fire callback execution in
xsync/closure.go. Do()guarantees the wrapped function runs exactly once regardless of how many goroutines invoke it simultaneously.- Context-aware variants (
ClosureCtx) support cancellation and timeout propagation for long-running operations. - Zero-allocation design minimizes per-connection overhead for high-scale game servers.
- Error caching allows callers to inspect initial failures while preventing side-effect duplication.
- Higher-level utilities like
DelayerandFuturebuild uponClosurefor complex scheduling and async patterns.
Frequently Asked Questions
How does Closure prevent callbacks from running multiple times?
Closure maintains an internal boolean flag that flips to true after the first successful Do() invocation. The method acquires a mutex, checks this flag, and returns the cached result if execution already occurred. This ensures the underlying func() error or func(context.Context) error executes exactly once even when triggered concurrently from thousands of goroutines.
What is the difference between NewClosure and NewClosureCtx?
NewClosure wraps a standard func() error and provides the Do() method, suitable for fire-and-forget operations. NewClosureCtx wraps func(context.Context) error and provides DoCtx(ctx), enabling the callback to respect cancellation signals, timeouts, and deadlines—critical for graceful server shutdowns and preventing resource leaks in long-running computations.
Can Closure handle panics in the wrapped function?
The current implementation in xsync/closure.go does not automatically recover panics. The wrapped callback should implement its own panic recovery if needed. However, the mutex is released via deferred unlock, so even if the wrapped function panics, the lock will not remain held, preventing deadlocks in the calling goroutines.
How does Closure fit into the broader fabrica-util synchronization toolkit?
Closure is the primitive upon which Delayer, Future, and Routines are built. For example, Delayer uses Closure to ensure delayed tasks execute safely when their scheduled time arrives, while Future uses it to guarantee completion callbacks fire exactly once. This layering allows game server developers to use high-level abstractions while trusting that the underlying synchronization is handled consistently and efficiently.
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 →