When should you use a mutex over a channel?

Viewed 10786

For the past few weeks I've been wrestling with one (not-so) simple question:

When is it best to use a sync.Mutex and, conversely, when is it best use a chan?

It seems that for a lot of problems either strategy is interchangeable with the other - and that's just the problem!

Take this video found in the Golang documentation. Below, I've taken the liberty to dictate the code in the playground and also translate it to a sync.Mutex equivalent.

Is there a certain problem - encountered in the real world - that warrants the use of one other?

Notes:

  • I am a huge fan of this use of chan and struggle to think of a more elegant implementation using sync.Mutex.
  • It's worth noting that the chan implementation does more work in the same time (reaches 12)*

Playgrounds:

Ping/pong with chan:

package main

import (
    "fmt"
    "time"
)

type Ball struct { hits int }

func main() {
    table := make(chan *Ball)
    go player("ping", table)
    go player("pong", table)

    table <- new(Ball)
    time.Sleep(1 * time.Second)
    <-table
}

func player(name string, table chan *Ball) {
    for {
        ball := <-table
        ball.hits++
        fmt.Println(name, ball.hits)
        time.Sleep(100 * time.Millisecond)
        table <- ball
    }
}

Ping/pong with sync.Mutex:

package main

import (
    "fmt"
    "time"
    "sync"
)

type Ball struct { hits int }

var m =  sync.Mutex{}

func main() {
    ball := new(Ball)
    go player("ping", ball)
    go player("pong", ball)

    time.Sleep(1 * time.Second)
}

func player(name string, ball *Ball) {
    for {
        m.Lock()
        ball.hits++
        fmt.Println(name, ball.hits)
        time.Sleep(100 * time.Millisecond)
        m.Unlock()

    }
}
2 Answers

In Go, channels are fantastic, and you can use them to communicate between goroutines. However, you may want to use the sync.Mutex in some circumstances for convenience. These circumstances are like the following:

  • Guarding an internal state
  • Cache problems
  • For better performance

Here are three examples and explanations

  1. A simple counter

enter image description here

  1. Ping Pong Game

enter image description here

  1. The Simplest Cache

enter image description here

In most cases prefer channels, in some sync.Mutex, (and pay attention to sync/atomic and sync.Once too):
Use channels if you need to:

  • limit concurrent tasks number (e.g. number of running goroutines) using buffered channel capacity (and length)
  • transferring ownership of an object (one and only one goroutine writes to it while no other goroutines reading it)
  • coordination, synchronization, sending signals, and data.

Use primitives if:

  • guarding internal state of a struct (with e.g. sync.Mutex)
  • performance critical (algorithm and use case dependent, not a general rule)

Note: Concurrent safe coding needs more care and slow careful timing analysis - I've seen subtle concurrent bugs in production codes, which is not easy to detect - even with the Go race detector.


Micro-benchmark

To make it clear, imagine we need a one second counter, so in the following examples we count for a second then print the counter value to see how fast it counts:

No |         Count |       Method
------------------------------------------------------
 1 |     17_729_027 | Using sync.RWMutex for increment   
 2 |     12_180_741 | Using channel for increment    
 3 |    106_743_095 | Using channel for timer 
 4 |    104_178_671 | Using time.AfterFunc and channel sync

Note: go version go1.13.5 linux/amd64


Codes:

1 - Using sync.RWMutex for increment:

package main

import (
    "sync"
    "time"
)

func main() {
    var i rwm
    go func() {
        for {
            i.inc() // free running counter
        }
    }()
    time.Sleep(1 * time.Second)
    println(i.read()) // sampling the counter
}

type rwm struct {
    sync.RWMutex
    i int
}

func (l *rwm) inc() {
    l.Lock()
    defer l.Unlock()
    l.i++
}
func (l *rwm) read() int {
    l.RLock()
    defer l.RUnlock()
    return l.i
}

2 - Using channel for increment:

package main

import (
    "time"
)

func main() {
    ch := make(chan int, 1)
    ch <- 1
    timeout := time.NewTimer(1 * time.Second)
loop:
    for {
        select {
        case <-timeout.C:
            timeout.Stop()
            break loop
        default:
            ch <- 1 + <-ch
        }
    }

    println(<-ch)
}

3 - Using channel for timer:

package main

import "time"

func main() {
    ch := make(chan int)
    go func() {
        timeout := time.NewTimer(1 * time.Second)
        defer timeout.Stop()
        i := 1
        for {
            select {
            case <-timeout.C:
                ch <- i
                return
            default:
                i++
            }
        }
    }()

    println(<-ch)
}

4 - Using time.AfterFunc and channel sync:

package main

import (
    "fmt"
    "time"
)

func main() {
    d := 1 * time.Second
    i := uint64(0)
    ch := make(chan struct{})

    time.AfterFunc(d, func() {
        close(ch)
    })

loop:
    for {
        select {
        case <-ch:
            break loop
        default:
            i++
        }
    }

    fmt.Println(i) // 104_178_671
}

Related