Beyond Traditional State Machines
Most state machines are synchronous: you call a method, the state transitions, and you get a result. But what if your state machine needs to react to multiple concurrent events? What if state transitions should happen asynchronously based on timeouts, external signals, or user input?
Enter the select-based state machine: a pattern that combines Go’s channels and select statements to create responsive, event-driven state machines that can handle multiple inputs simultaneously.
The Traditional Synchronous Approach
Here’s a typical synchronous state machine:
type State int
const (
Idle State = iota
Processing
Complete
)
type Machine struct {
state State
}
func (m *Machine) Process() {
switch m.state {
case Idle:
m.state = Processing
// Do work...
m.state = Complete
case Processing:
// Already processing
case Complete:
// Done
}
}
// Problem: Blocking operations stall the entire machine
// Problem: Can't react to multiple events simultaneously
// Problem: Timeouts and cancellation are difficult
Event-Driven State Machines with Select
Let’s build a connection manager that handles multiple concurrent events:
package main
import (
"context"
"fmt"
"log"
"time"
)
// ConnectionState represents connection states
type ConnectionState int
const (
Disconnected ConnectionState = iota
Connecting
Connected
Reconnecting
ShuttingDown
)
func (cs ConnectionState) String() string {
return [...]string{
"Disconnected",
"Connecting",
"Connected",
"Reconnecting",
"ShuttingDown",
}[cs]
}
// Event types
type Event interface {
String() string
}
type ConnectEvent struct{}
func (ConnectEvent) String() string { return "ConnectEvent" }
type DisconnectEvent struct{}
func (DisconnectEvent) String() string { return "DisconnectEvent" }
type ConnectionSuccessEvent struct{}
func (ConnectionSuccessEvent) String() string { return "ConnectionSuccessEvent" }
type ConnectionFailedEvent struct {
Err error
}
func (e ConnectionFailedEvent) String() string {
return fmt.Sprintf("ConnectionFailedEvent: %v", e.Err)
}
type DataReceivedEvent struct {
Data string
}
func (e DataReceivedEvent) String() string {
return fmt.Sprintf("DataReceivedEvent: %s", e.Data)
}
type ShutdownEvent struct{}
func (ShutdownEvent) String() string { return "ShutdownEvent" }
// ConnectionManager manages connection state with select-based event loop
type ConnectionManager struct {
state ConnectionState
events chan Event
data chan string
shutdown chan struct{}
reconnectTimer *time.Timer
ctx context.Context
cancel context.CancelFunc
}
func NewConnectionManager() *ConnectionManager {
ctx, cancel := context.WithCancel(context.Background())
return &ConnectionManager{
state: Disconnected,
events: make(chan Event, 10),
data: make(chan string, 10),
shutdown: make(chan struct{}),
ctx: ctx,
cancel: cancel,
}
}
func (cm *ConnectionManager) Start() {
log.Printf("Connection manager starting in state: %s", cm.state)
for {
select {
case event := <-cm.events:
cm.handleEvent(event)
case data := <-cm.data:
cm.handleData(data)
case <-cm.reconnectTimer.C:
if cm.state == Reconnecting {
log.Println("Reconnect timer fired")
cm.attemptConnect()
}
case <-cm.shutdown:
log.Println("Shutdown signal received")
cm.handleShutdown()
return
case <-cm.ctx.Done():
log.Println("Context cancelled")
return
}
}
}
func (cm *ConnectionManager) handleEvent(event Event) {
log.Printf("[%s] Received event: %s", cm.state, event)
switch cm.state {
case Disconnected:
cm.handleDisconnectedState(event)
case Connecting:
cm.handleConnectingState(event)
case Connected:
cm.handleConnectedState(event)
case Reconnecting:
cm.handleReconnectingState(event)
case ShuttingDown:
cm.handleShuttingDownState(event)
}
}
func (cm *ConnectionManager) handleDisconnectedState(event Event) {
switch e := event.(type) {
case ConnectEvent:
cm.transition(Connecting)
cm.attemptConnect()
case ShutdownEvent:
cm.transition(ShuttingDown)
close(cm.shutdown)
default:
log.Printf("[%s] Ignoring event: %s", cm.state, e)
}
}
func (cm *ConnectionManager) handleConnectingState(event Event) {
switch e := event.(type) {
case ConnectionSuccessEvent:
cm.transition(Connected)
case ConnectionFailedEvent:
log.Printf("Connection failed: %v, will retry", e.Err)
cm.transition(Reconnecting)
cm.scheduleReconnect(2 * time.Second)
case DisconnectEvent:
cm.transition(Disconnected)
case ShutdownEvent:
cm.transition(ShuttingDown)
close(cm.shutdown)
default:
log.Printf("[%s] Ignoring event: %s", cm.state, e)
}
}
func (cm *ConnectionManager) handleConnectedState(event Event) {
switch e := event.(type) {
case DisconnectEvent:
cm.transition(Disconnected)
case ConnectionFailedEvent:
log.Printf("Connection lost: %v, will reconnect", e.Err)
cm.transition(Reconnecting)
cm.scheduleReconnect(2 * time.Second)
case ShutdownEvent:
cm.transition(ShuttingDown)
close(cm.shutdown)
default:
log.Printf("[%s] Ignoring event: %s", cm.state, e)
}
}
func (cm *ConnectionManager) handleReconnectingState(event Event) {
switch e := event.(type) {
case ConnectionSuccessEvent:
if cm.reconnectTimer != nil {
cm.reconnectTimer.Stop()
}
cm.transition(Connected)
case ConnectionFailedEvent:
log.Printf("Reconnect failed: %v, will retry", e.Err)
cm.scheduleReconnect(5 * time.Second)
case DisconnectEvent:
if cm.reconnectTimer != nil {
cm.reconnectTimer.Stop()
}
cm.transition(Disconnected)
case ShutdownEvent:
if cm.reconnectTimer != nil {
cm.reconnectTimer.Stop()
}
cm.transition(ShuttingDown)
close(cm.shutdown)
default:
log.Printf("[%s] Ignoring event: %s", cm.state, e)
}
}
func (cm *ConnectionManager) handleShuttingDownState(event Event) {
log.Printf("[%s] Ignoring event during shutdown: %s", cm.state, event)
}
func (cm *ConnectionManager) handleData(data string) {
if cm.state == Connected {
log.Printf("[%s] Processing data: %s", cm.state, data)
} else {
log.Printf("[%s] Dropping data (not connected): %s", cm.state, data)
}
}
func (cm *ConnectionManager) handleShutdown() {
log.Println("Shutting down connection manager")
if cm.reconnectTimer != nil {
cm.reconnectTimer.Stop()
}
cm.cancel()
}
func (cm *ConnectionManager) transition(newState ConnectionState) {
log.Printf("State transition: %s -> %s", cm.state, newState)
cm.state = newState
}
func (cm *ConnectionManager) attemptConnect() {
log.Println("Attempting to connect...")
// Simulate async connection attempt
go func() {
time.Sleep(time.Duration(500+rand.Intn(1000)) * time.Millisecond)
// Simulate random success/failure
if rand.Float32() > 0.3 {
cm.events <- ConnectionSuccessEvent{}
} else {
cm.events <- ConnectionFailedEvent{Err: fmt.Errorf("connection refused")}
}
}()
}
func (cm *ConnectionManager) scheduleReconnect(delay time.Duration) {
log.Printf("Scheduling reconnect in %v", delay)
if cm.reconnectTimer != nil {
cm.reconnectTimer.Stop()
}
cm.reconnectTimer = time.AfterFunc(delay, func() {
cm.attemptConnect()
})
}
// Public API methods
func (cm *ConnectionManager) Connect() {
cm.events <- ConnectEvent{}
}
func (cm *ConnectionManager) Disconnect() {
cm.events <- DisconnectEvent{}
}
func (cm *ConnectionManager) SendData(data string) {
cm.data <- data
}
func (cm *ConnectionManager) Shutdown() {
cm.events <- ShutdownEvent{}
}
func main() {
fmt.Println("=== Event-Driven State Machine Demo ===\n")
manager := NewConnectionManager()
// Start the state machine in a goroutine
go manager.Start()
// Simulate various events
time.Sleep(500 * time.Millisecond)
manager.Connect()
time.Sleep(2 * time.Second)
manager.SendData("Hello, World!")
time.Sleep(1 * time.Second)
manager.Disconnect()
time.Sleep(500 * time.Millisecond)
manager.Connect()
time.Sleep(3 * time.Second)
manager.SendData("Test message")
time.Sleep(2 * time.Second)
manager.Shutdown()
time.Sleep(1 * time.Second)
fmt.Println("\n=== Demo Complete ===")
}
Advanced Pattern: Multi-Input State Machine
Here’s a game input handler that processes multiple input sources:
package main
import (
"context"
"fmt"
"time"
)
type InputType int
const (
KeyPress InputType = iota
MouseClick
GamepadButton
)
type Input struct {
Type InputType
Value string
}
type GameState int
const (
MainMenu GameState = iota
Playing
Paused
GameOver
)
func (gs GameState) String() string {
return [...]string{"MainMenu", "Playing", "Paused", "GameOver"}[gs]
}
type GameStateMachine struct {
state GameState
keyboardInput chan Input
mouseInput chan Input
gamepadInput chan Input
gameEvents chan string
tickRate *time.Ticker
ctx context.Context
cancel context.CancelFunc
score int
}
func NewGameStateMachine() *GameStateMachine {
ctx, cancel := context.WithCancel(context.Background())
return &GameStateMachine{
state: MainMenu,
keyboardInput: make(chan Input, 5),
mouseInput: make(chan Input, 5),
gamepadInput: make(chan Input, 5),
gameEvents: make(chan string, 5),
tickRate: time.NewTicker(100 * time.Millisecond),
ctx: ctx,
cancel: cancel,
}
}
func (gsm *GameStateMachine) Run() {
fmt.Printf("Game started in state: %s\n", gsm.state)
for {
select {
case input := <-gsm.keyboardInput:
gsm.handleKeyboard(input)
case input := <-gsm.mouseInput:
gsm.handleMouse(input)
case input := <-gsm.gamepadInput:
gsm.handleGamepad(input)
case event := <-gsm.gameEvents:
gsm.handleGameEvent(event)
case <-gsm.tickRate.C:
gsm.tick()
case <-gsm.ctx.Done():
fmt.Println("Game shutting down")
return
}
}
}
func (gsm *GameStateMachine) handleKeyboard(input Input) {
fmt.Printf("[%s] Keyboard: %s\n", gsm.state, input.Value)
switch gsm.state {
case MainMenu:
if input.Value == "ENTER" {
gsm.transition(Playing)
} else if input.Value == "ESC" {
gsm.shutdown()
}
case Playing:
if input.Value == "ESC" {
gsm.transition(Paused)
} else if input.Value == "SPACE" {
gsm.score += 10
fmt.Printf("Score: %d\n", gsm.score)
}
case Paused:
if input.Value == "ESC" {
gsm.transition(Playing)
} else if input.Value == "Q" {
gsm.transition(MainMenu)
gsm.score = 0
}
case GameOver:
if input.Value == "ENTER" {
gsm.transition(MainMenu)
gsm.score = 0
}
}
}
func (gsm *GameStateMachine) handleMouse(input Input) {
fmt.Printf("[%s] Mouse: %s\n", gsm.state, input.Value)
if gsm.state == Playing {
gsm.score += 5
fmt.Printf("Score: %d\n", gsm.score)
}
}
func (gsm *GameStateMachine) handleGamepad(input Input) {
fmt.Printf("[%s] Gamepad: %s\n", gsm.state, input.Value)
switch gsm.state {
case Playing:
if input.Value == "START" {
gsm.transition(Paused)
}
case Paused:
if input.Value == "START" {
gsm.transition(Playing)
}
}
}
func (gsm *GameStateMachine) handleGameEvent(event string) {
fmt.Printf("[%s] Game event: %s\n", gsm.state, event)
switch event {
case "PLAYER_DIED":
if gsm.state == Playing {
gsm.transition(GameOver)
}
case "LEVEL_COMPLETE":
if gsm.state == Playing {
gsm.score += 100
fmt.Printf("Level complete! Score: %d\n", gsm.score)
}
}
}
func (gsm *GameStateMachine) tick() {
if gsm.state == Playing {
// Game logic tick
// fmt.Printf("[%s] Tick (Score: %d)\n", gsm.state, gsm.score)
}
}
func (gsm *GameStateMachine) transition(newState GameState) {
fmt.Printf("\n>>> State transition: %s -> %s <<<\n\n", gsm.state, newState)
gsm.state = newState
}
func (gsm *GameStateMachine) shutdown() {
fmt.Println("Initiating shutdown...")
gsm.tickRate.Stop()
gsm.cancel()
}
// Public API
func (gsm *GameStateMachine) SendKeyboard(key string) {
gsm.keyboardInput <- Input{Type: KeyPress, Value: key}
}
func (gsm *GameStateMachine) SendMouse(button string) {
gsm.mouseInput <- Input{Type: MouseClick, Value: button}
}
func (gsm *GameStateMachine) SendGamepad(button string) {
gsm.gamepadInput <- Input{Type: GamepadButton, Value: button}
}
func (gsm *GameStateMachine) SendGameEvent(event string) {
gsm.gameEvents <- event
}
func ExampleGameStateMachine() {
game := NewGameStateMachine()
go game.Run()
// Simulate game session
time.Sleep(500 * time.Millisecond)
fmt.Println("=== Starting Game ===")
game.SendKeyboard("ENTER")
time.Sleep(300 * time.Millisecond)
game.SendKeyboard("SPACE")
game.SendMouse("LEFT_CLICK")
time.Sleep(300 * time.Millisecond)
game.SendKeyboard("ESC") // Pause
time.Sleep(300 * time.Millisecond)
game.SendGamepad("START") // Resume
time.Sleep(300 * time.Millisecond)
game.SendGameEvent("LEVEL_COMPLETE")
time.Sleep(300 * time.Millisecond)
game.SendGameEvent("PLAYER_DIED")
time.Sleep(300 * time.Millisecond)
game.SendKeyboard("ENTER") // Back to menu
time.Sleep(300 * time.Millisecond)
game.SendKeyboard("ESC") // Quit
time.Sleep(500 * time.Millisecond)
fmt.Println("=== Game Session Complete ===")
}
Pattern: Timeout-Based State Transitions
Handle timeouts elegantly with select:
type TimeoutStateMachine struct {
state string
timeout *time.Timer
events chan string
}
func (tsm *TimeoutStateMachine) Run() {
for {
select {
case event := <-tsm.events:
tsm.handleEvent(event)
// Reset timeout on activity
tsm.timeout.Reset(30 * time.Second)
case <-tsm.timeout.C:
fmt.Println("Timeout! Transitioning to idle")
tsm.state = "idle"
}
}
}
Benefits of Select-Based State Machines
- Concurrent Event Handling: Process multiple input channels simultaneously
- Non-Blocking: State machine remains responsive
- Timeout Support: Built-in timeout handling with time.After
- Cancellation: Easy integration with context for graceful shutdown
- Priority: Control event priority through select case ordering
When to Use This Pattern
Select-based state machines are ideal when:
- You need to react to multiple concurrent event sources
- Timeouts and deadlines are important
- You want non-blocking state transitions
- Building network protocols or connection managers
- Creating responsive game input systems
Comparison: Traditional vs Select-Based
| Aspect | Traditional | Select-Based |
|---|---|---|
| Event handling | Sequential | Concurrent |
| Blocking | Can block | Non-blocking |
| Timeouts | Complex | Built-in |
| Cancellation | Manual | Context-aware |
| Multiple inputs | Difficult | Natural |
Thank you
Select-based state machines harness Go’s concurrency primitives to create responsive, event-driven systems. By combining channels, select statements, and goroutines, you can build state machines that handle complex concurrent scenarios with elegance.
Please drop an email at [email protected] if you would like to share any feedback or suggestions. Peace!