Go Concurrency in Practice: Channel or Mutex?
Learn Go and you'll hear the maxim: "Don't communicate by sharing memory; share memory by communicating." It's a fine line, but misread it in practice as use channels for everything and your code gets more complex, not less. The truth is this: mutexes are right for guarding shared state, and channels are right for handing out work.
Guarding Shared State — Mutex Wins
A bot has multiple goroutines reading and writing the same values. A health-check goroutine refreshes the RPC node ranking while the trading goroutine reads it. For simple data shared across goroutines like this, wrapping it in a mutex is the simplest and fastest option.
type Bot struct {
rpcMu sync.RWMutex
rpcNodes []RPCNode // ranking table shared by several goroutines
}
// Write: exclusive lock
func (b *Bot) setNodes(n []RPCNode) {
b.rpcMu.Lock()
b.rpcNodes = n
b.rpcMu.Unlock()
}
// Read: shared lock (many readers may read concurrently)
func (b *Bot) bestNode() (RPCNode, bool) {
b.rpcMu.RLock()
defer b.rpcMu.RUnlock()
if len(b.rpcNodes) == 0 {
return RPCNode{}, false
}
return b.rpcNodes[0], true
}
RWMutex fits bot state especially well when reads are frequent and writes are rare. Many readers proceed together, and only writes briefly take exclusive access. Emulating that with channels would mean a dedicated goroutine owning the value plus request/response channels—just use a mutex.
Distributing Work — Channel Wins
Conversely, when several workers need to split quoting across hundreds of candidate paths, a channel is the answer. It serves as both the job queue and a safe hand-off between workers.
func (b *Bot) scanCycles(ctx context.Context, ids []uint64) []Opportunity {
jobs := make(chan uint64)
out := make(chan Opportunity)
// Spin up N workers
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for id := range jobs { // pull jobs until the channel closes
if op, ok := b.quoteCycle(ctx, id); ok {
out <- op
}
}
}()
}
// Feed jobs → close the channel to signal "done" to the workers
go func() {
for _, id := range ids {
jobs <- id
}
close(jobs)
wg.Wait() // wait for every worker to finish, then
close(out) // close the results channel
}()
var res []Opportunity
for op := range out { // collect until results close
res = append(res, op)
}
return res
}
The beauty of this pattern is that worker count is your concurrency dial. Tight RPC limits? Four workers. Room to spare? Sixteen. Shutdown is clean too, since range over the jobs channel detects the close and exits on its own.
select — Juggling Multiple Channels and Cancellation
A bot needs to "wait for work, but bail out immediately if a cancellation arrives." select handles this elegantly, picking whichever channel becomes ready first.
select {
case op := <-out:
handle(op)
case <-ticker.C:
b.refreshHealth(ctx) // periodic health check
case <-ctx.Done():
return ctx.Err() // cancellation → exit immediately
}
Get into the habit of putting a <-ctx.Done() case in every long-running loop and you can stop the bot cleanly at any moment.
Three Rules for Avoiding Deadlock
- Never wait on a channel while holding a lock. Block on a channel send with a mutex held and you tangle with every other goroutine waiting for that lock. Keep critical sections short and do channel waits outside the lock.
- Pair senders and receivers on unbuffered channels. Send to a channel nobody is receiving from and you stop forever. Use unbuffered channels only where the consumer is guaranteed, as in a worker pool.
- Closing is the sender's job. The sending side closes a channel. If the receiver closes it, another sender panics writing to a closed channel.
Summary — How to Choose
- Guarding shared state →
sync.Mutex/RWMutex. Simple and fast. - Distributing work or transferring ownership → channels. Ideal for worker pools and pipelines.
- Coordinating multiple events and cancellation →
select+context.
These two aren't competitors; they're different tools in the same box. Neither "always channels" nor "always mutexes." Is the thing you're protecting state, or is the thing you're moving work? That question decides the answer. And when you suspect contention, run it with go run -race—it catches data races you'd never see by eye.
댓글
댓글 쓰기