DEV Community

Usk
Usk

Posted on • Edited on

Concurrency and Goroutines in Go: A Deep Dive into sync.Mutex

Previous post. This time, I explored sync.Mutex to deepen my understanding of how mutex work in Go.


Overview

Using sync.Mutex prevents data races that can cause problems, such as updates not being applied correctly or unexpected behaviors. Specifically, by placing Lock and Unlock calls around accesses to shared resources, you can avoid inconsistencies between multiple goroutines.

You can also use the -race option to detect data races in your program. Applying this option during development or testing helps catch bugs at an early stage.


About sync.Mutex

https://pkg.go.dev/sync#Mutex

Starting from Go 1.18, TryLock was added, so now the mutex has three methods:

  • func (m *Mutex) Lock()
  • func (m *Mutex) TryLock() bool
  • func (m *Mutex) Unlock()

Typically, Lock and Unlock are sufficient. TryLock immediately returns false if it fails to acquire the lock, but as stated in Go’s official documentation, its use case is rare. If your design is appropriate, just Lock and Unlock will handle most scenarios.


Example Using Mutex

Below is a sample demonstrating a “docker pull-like progress display” while testing for potential data races. This time, you can specify via command-line argument whether to use a mutex or not.

When the mutex is used, the shared variable is correctly incremented the specified number of times. However, if you disable the mutex, the process might finish with a count below the intended number due to goroutine conflicts (increasing the first command-line argument often makes this easier to observe).

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
    "sync"
    "time"
)

func main() {
    // set concurrency count
    if len(os.Args) < 3 {
        fmt.Println("Usage: go run main.go [number] [useMutex]")
        return
    }
    maxLoopCount, err := strconv.Atoi(os.Args[1])
    if err != nil || maxLoopCount <= 0 {
        fmt.Println("Invalid number. Please provide a positive integer.")
        return
    }

    useMutex, err := strconv.ParseBool(os.Args[2])
    if err != nil {
        fmt.Println("Invalid string. Please provide a true or false.")
        return
    }

    // A mutex to prevent conflicts during progress updates.
    var mu sync.Mutex

    // WaitGroup for waiting until each goroutine has completed
    var wg sync.WaitGroup

    loopCount := 0
    for i := 0; i < maxLoopCount; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            time.Sleep(time.Duration(rand.Intn(400)+100) * time.Millisecond)
            if useMutex {
                mu.Lock()
                loopCount++
                mu.Unlock()
            } else {
                loopCount++
            }
        }()
    }

    // A channel to signal that all goroutines have finished
    done := make(chan struct{})
    go func() {
        wg.Wait()
        done <- struct{}{}
    }()

    // Periodically draw the progress status on the screen
    ticker := time.NewTicker(10 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            // Clear the screen and redraw
            printProgress(maxLoopCount, loopCount, &mu)
        case <-done:
            // Display the final state and exit
            printProgress(maxLoopCount, loopCount, &mu)
            return
        }
    }
}

// printProgress is a function that displays the progress of each indicator
// Prevent concurrent access to progress data using a mutex
func printProgress(maxLoopCount int, loopCount int, mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()

    // Clear the screen using ANSI escape sequences
    // \033[H : Move the cursor to the home position
    // \033[2J : Clear the screen
    fmt.Print("\033[H\033[2J")
    fmt.Printf("max loop count: %d\n", maxLoopCount)
    fmt.Printf("current loop count: %d\n", loopCount)

    // Set the total width of the progress bar to 50 characters
    width := 50
    progress := int((float64(loopCount) / float64(maxLoopCount)) * 100)

    // Number of "*" characters based on progress percentage
    stars := progress * width / 100
    spaces := width - stars
    fmt.Printf("%d.[%s%s] %d%%\n", 1, repeat("*", stars), repeat(" ", spaces), progress)
}

// repeat: Returns concatenated string by repeating string "s" "count" times
func repeat(s string, count int) string {
    result := ""
    for i := 0; i < count; i++ {
        result += s
    }
    return result
}
Enter fullscreen mode Exit fullscreen mode

Program Demonstration

As shown, whether or not you use the mutex can significantly affect the result. If multiple goroutines write to shared data without synchronization, data races are highly likely to occur.


The Race Option

Data Race Detector

Data races are among the most common and hardest to debug types of bugs in concurrent systems. A data race occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write.

To help diagnose such bugs, Go includes a built-in data race detector. To use it, add the -race flag to the go command:

For instance, if you run go run -race main.go 100000 false on the sample program above, you might see an output like the following if a race condition exists:

==================
WARNING: DATA RACE
Read at 0x00c000126048 by goroutine 417:
  main.main.func1()
      /path/to/main.go:47 +0xe8

Previous write at 0x00c000126048 by goroutine 23:
  main.main.func1()
      /path/to/main.go:47 +0xf8

Goroutine 417 (running) created at:
  main.main()
      /path/to/main.go:39 +0x440

Goroutine 23 (finished) created at:
  main.main()
      /path/to/main.go:39 +0x440
==================
Enter fullscreen mode Exit fullscreen mode

You can also use the -race option with go test or go build. If your code uses many goroutines, keeping the race detector on during development or testing can help find data races early.


About TryLock

As noted earlier, sync.Mutex in Go 1.18 and later includes the TryLock method. If the lock cannot be acquired, TryLock returns false immediately rather than blocking, unlike Lock. However, the official documentation mentions that use cases for this feature are rare. As shown in the example below, misusing TryLock can lead to unnecessarily complex logic, so caution is advised.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex

    fmt.Println("Start: Lock - Unlock")
    mu.Lock()
    mu.Unlock()
    fmt.Println("End: Lock - Unlock")
    fmt.Println("---")

    fmt.Println("Start: TryLock - Unlock")
    b := mu.TryLock()
    fmt.Printf("TryLock() result is %v\n", b)
    mu.Unlock()
    fmt.Println("End: TryLock - Unlock")
    fmt.Println("---")

    fmt.Println("Start: Lock - TryLock - Unlock")
    mu.Lock()
    b = mu.TryLock()
    fmt.Printf("TryLock() result is %v\n", b)
    mu.Unlock()
    fmt.Println("End: Lock - TryLock - Unlock")
    fmt.Println("---")

    fmt.Println("Start: TryLock - TryLock - Unlock")
    b = mu.TryLock()
    fmt.Printf("TryLock()1 result is %v\n", b)
    b = mu.TryLock()
    fmt.Printf("TryLock()2 result is %v\n", b)
    mu.Unlock()
    fmt.Println("End: TryLock - TryLock - Unlock")
    fmt.Println("---")

    fmt.Println("Start: Lock - Lock - Unlock")
    mu.Lock()
    mu.Lock() // This causes a deadlock
    mu.Unlock()
    fmt.Println("End: Lock - Lock - Unlock")

    fmt.Println("Done.")
}
Enter fullscreen mode Exit fullscreen mode

Running this program gives:

$ go run trylock.go
Start: Lock - Unlock
End: Lock - Unlock
---
Start: TryLock - Unlock
TryLock() result is true
End: TryLock - Unlock
---
Start: Lock - TryLock - Unlock
TryLock() result is false
End: Lock - TryLock - Unlock
---
Start: TryLock - TryLock - Unlock
TryLock()1 result is true
TryLock()2 result is false
End: TryLock - TryLock - Unlock
---
Start: Lock - Lock - Unlock
fatal error: all goroutines are asleep - deadlock!
Enter fullscreen mode Exit fullscreen mode

While calling Lock again inside a locked section causes a deadlock, TryLock does not block and returns false instead, thus avoiding a deadlock. You should carefully consider where this behavior is actually needed.


Conclusion

  • Use mutexes to avoid data races

    • Data races are more likely if multiple goroutines simultaneously access shared data without synchronization.
    • Race conditions can also occur in other scenarios, such as sharing data via channels.
  • Use the -race option to detect data races

    • During development and testing, it helps discover issues early.
  • TryLock is rarely needed; Lock/Unlock is generally sufficient

    • In most cases, proper design can be handled solely with Lock/Unlock.

That covers the basics of using sync.Mutex and detecting race conditions. By leveraging these techniques, you can increase the safety of your concurrent programs.

Top comments (0)