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 statesync.Mutex / RWMutex. Simple and fast.
  • Distributing work or transferring ownership → channels. Ideal for worker pools and pipelines.
  • Coordinating multiple events and cancellationselect + 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.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기