DEV Community

Mohamad Harith
Mohamad Harith

Posted on • Updated on

The Difference between sync.Mutex and sync.RWMutex in Golang Explained in Simple Terms

#go

Image description

What is Mutex?

Mutex (mutual exclusion) is an object used to synchronise concurrent threads accessing a shared resource to prevent race conditions, which is a situation that happens when multiple concurrent threads access a shared resource and try to modify it, leading to an incorrect data or an unexpected behaviour.

Race Condition Example in Go

The below program demonstrates a race condition where we start with a shared resource variable of type integer initialised to zero, and we increment it in one loop and decrement it in another, both happening at the same time concurrently using goroutines. We use sync.WaitGroup to wait for all the goroutines to finish before terminating the program. At the end of the execution, we expect resource to be back to 0, but it does not. This is known as race condition as multiple goroutines tries to access and modify the resource at the same time which leads to incorrect data.

package main

import (
    "fmt"
    "sync"
)

var resource int32 = 0

func Increment(wg *sync.WaitGroup) {
    resource = resource + 1
    wg.Done()
}

func Decrement(wg *sync.WaitGroup) {
    resource = resource - 1
    wg.Done()
}

func main() {
    const ITERATIONS = 500
    wg := &sync.WaitGroup{}

    for i := 0; i < ITERATIONS; i++ {
        wg.Add(1)
        go Increment(wg)
    }

    for i := 0; i < ITERATIONS; i++ {
        wg.Add(1)
        go Decrement(wg)
    }

    wg.Wait()

    fmt.Println(resource) // does not print 0
}
Enter fullscreen mode Exit fullscreen mode

Using Golang's sync.Mutex

To fix the race condition in the code above, we can use a mutex. A mutex is like a lock that we could acquire before allowing the program control to the critical section of the code to the shared resource. This allows only one goroutine to access the resource at a time, ensuring no conflicts occur. We lock it when one goroutine is using it, and unlock it when it's done, so the next one can use it. This ensures that the operations on resource happen one at a time, preventing conflicts and giving the expected result. Golang provides mutex in the sync package. As such, we can use sync.Mutex to implement a mutex as follows:

package main

import (
    "fmt"
    "sync"
)

var resource int32 = 0

func Increment(wg *sync.WaitGroup, mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()

    resource = resource + 1
    wg.Done()
}

func Decrement(wg *sync.WaitGroup, mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()

    resource = resource - 1
    wg.Done()
}

func main() {
    const ITERATIONS = 500
    wg := &sync.WaitGroup{}
    mu := &sync.Mutex{}

    for i := 0; i < ITERATIONS; i++ {
        wg.Add(1)
        go Increment(wg, mu)
    }

    for i := 0; i < ITERATIONS; i++ {
        wg.Add(1)
        go Decrement(wg, mu)
    }

    wg.Wait()

    fmt.Println(resource) // prints 0
}
Enter fullscreen mode Exit fullscreen mode

By implementing a mutex as shown above, we are able to prevent the race condition that we saw earlier and the value of the resource variable does indeed become zero at the end of the execution.

The Shortcomings of sync.Mutex

sync.Mutex provides an exclusive lock, also known as write lock where only one thread can access the shared resource at a time before we modify the resource. However, what if we would like to have read lock where we can have multiple threads reading a shared resource and while it is being read, we prevent any other threads from modifying the resource? This is where sync.RWMutex comes into picture.

sync.RWMutex is similar to sync.Mutex where it provides write lock, i.e., sync.RWMutex.Lock. In addition to that, sync.RWMutex also provides read lock, i.e., sync.RWMutex.RLock. Let's see these locks in action below:

package main

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

var resource int32 = 0

func Read(rwm *sync.RWMutex, wg *sync.WaitGroup) {

    rwm.RLock()
    fmt.Println("read lock acquired")

    time.Sleep(time.Second * 3)

    fmt.Println("read lock released")
    rwm.RUnlock()
    wg.Done()
}

func Write(rwm *sync.RWMutex, wg *sync.WaitGroup) {

    rwm.Lock()
    fmt.Println("write lock acquired")

    resource = resource + 1
    time.Sleep(time.Second * 3)

    fmt.Println("write lock released")
    rwm.Unlock()
    wg.Done()

}

func main() {
    rwm := &sync.RWMutex{}
    wg := &sync.WaitGroup{}

    for i := 0; i < 5; i++ {
        wg.Add(2)
        go Write(rwm, wg)
        go Read(rwm, wg)
    }

    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

Sample Output:

read lock acquired
read lock acquired
read lock released
read lock released
write lock acquired
write lock released
read lock acquired
read lock acquired
read lock acquired
read lock released
read lock released
read lock released
write lock acquired
write lock released
write lock acquired
write lock released
write lock acquired
write lock released
write lock acquired
write lock released
Enter fullscreen mode Exit fullscreen mode

In the output above, we can observe that we were able to acquire multiple read locks (sync.RWMutex.RLock) at the same time, in contrary to the write locks (sync.RWMutex.Lock) which we were only able to acquire one lock at a time. As such, the following are true for sync.RWMutex:

  1. sync.RWMutex.Lock(write lock) can only be acquired if the write lock has not already been acquired AND sync.RWMutex.RLock (read lock) has not been acquired.

  2. sync.RWMutex.RLock (read lock) can only be acquired if sync.RWMutex.Lock (write lock) has not been acquired.

  3. Both sync.RWMutex.Lock (write lock) and sync.RWMutex.RLock (read lock) are blocking. As such, sync.RWMutex.Lock (write lock) waits for all the sync.RWMutex.RLock (read lock) or a sync.RWMutex.Lock (write lock) that were acquired earlier to be released before being able to acquire a lock. On the other hand, sync.RWMutex.RLock (read lock) waits for a sync.RWMutex.Lock (write lock) that was acquired earlier to be released before being able to acquire a lock.

Conclusion

In conclusion, both sync.Mutex and sync.RWMutex are Golang primitives that provide mutex. sync.Mutex only provides a read lock mutex. On the other hand, sync.RWMutex provides both read lock and write lock mutex. The decision as to which one to use depends on the use case. If the application mostly does reads and occasional writes, then sync.RWMutex would be suitable. Contrarily, if the application mostly does writes and occasional reads, then sync.Mutex would be suitable.

Test your Understanding

The following programs demonstrate how the order of acquiring read or write locks results in successful lock acquirement. Some of these programs may panic due to deadlock. Which of the following programs may or may not panic?

1.

package main

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

func main() {

    m := &sync.Mutex{}

    m.Lock()

    m.Lock()
}
Enter fullscreen mode Exit fullscreen mode

2.

package main

import (
    "sync"
)

func main() {

    m := &sync.Mutex{}

    m.Lock()
    m.Unlock()

    m.Lock()
}
Enter fullscreen mode Exit fullscreen mode

3.

package main

import (
    "sync"
)

func main() {

    m := &sync.RWMutex{}

    m.Lock()
    m.Unlock()

    m.RLock()

    m.Lock()
}
Enter fullscreen mode Exit fullscreen mode

4.

package main

import (
    "sync"
)

func main() {

    m := &sync.RWMutex{}

    m.Lock()
    m.Unlock()

    m.RLock()
    m.RUnlock()

    m.Lock()
}
Enter fullscreen mode Exit fullscreen mode

5.

package main

import (
    "sync"
)

func main() {

    m := &sync.RWMutex{}

    m.RLock()
    m.RLock()
    m.RLock()

    m.RUnlock()

    m.Lock()
}
Enter fullscreen mode Exit fullscreen mode

6.

package main

import (
    "sync"
)

func main() {

    m := &sync.RWMutex{}

    m.RLock()
    m.RLock()
    m.RLock()

    m.RUnlock()
    m.RUnlock()
    m.RUnlock()

    m.Lock()
}
Enter fullscreen mode Exit fullscreen mode

Reference

  1. https://programmer.ink/think/go-mutex-sync.mutex-and-read-write-lock-sync.rwmutex.html

Top comments (0)