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
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
}
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 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
==================
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.")
}
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!
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)