Concurrency Synchronization Techniques in Go’s sync Package

Leapcell: The Best of Serverless Web Hosting Detailed Explanation of the sync Standard Library Package in Go Language In the concurrent programming of the Go language, the sync standard library package provides a series of types for implementing concurrent synchronization. These types can meet different memory ordering requirements. Compared with channels, using them in specific scenarios is not only more efficient but also makes the code implementation more concise and clear. The following will introduce in detail several commonly used types in the sync package and their usage methods. 1. sync.WaitGroup Type (Wait Group) The sync.WaitGroup is used to achieve synchronization between goroutines, allowing one or more goroutines to wait for several other goroutines to complete their tasks. Each sync.WaitGroup value internally maintains a count, and the initial default value of this count is zero. 1.1 Method Introduction The sync.WaitGroup type contains three core methods: Add(delta int): Used to change the count maintained by the WaitGroup. When a positive integer delta is passed in, the count increases by the corresponding value; when a negative number is passed in, the count decreases by the corresponding value. Done(): It is an equivalent shortcut for Add(-1), and is usually used to decrement the count by 1 when a goroutine task is completed. Wait(): When a goroutine calls this method, if the count is zero, this operation is a no-op (no operation); if the count is a positive integer, the current goroutine will enter a blocked state and will not re-enter the running state until the count becomes zero, that is, the Wait() method returns. It should be noted that wg.Add(delta), wg.Done() and wg.Wait() are abbreviations of (&wg).Add(delta), (&wg).Done() and (&wg).Wait() respectively. If the call to Add(delta) or Done() causes the count to become negative, the program will panic. 1.2 Usage Example package main import ( "fmt" "math/rand" "sync" "time" ) func main() { rand.Seed(time.Now().UnixNano()) // Required before Go 1.20 const N = 5 var values [N]int32 var wg sync.WaitGroup wg.Add(N) for i := 0; i

Apr 14, 2025 - 17:43
 0
Concurrency Synchronization Techniques in Go’s sync Package

Image description

Leapcell: The Best of Serverless Web Hosting

Detailed Explanation of the sync Standard Library Package in Go Language

In the concurrent programming of the Go language, the sync standard library package provides a series of types for implementing concurrent synchronization. These types can meet different memory ordering requirements. Compared with channels, using them in specific scenarios is not only more efficient but also makes the code implementation more concise and clear. The following will introduce in detail several commonly used types in the sync package and their usage methods.

1. sync.WaitGroup Type (Wait Group)

The sync.WaitGroup is used to achieve synchronization between goroutines, allowing one or more goroutines to wait for several other goroutines to complete their tasks. Each sync.WaitGroup value internally maintains a count, and the initial default value of this count is zero.

1.1 Method Introduction

The sync.WaitGroup type contains three core methods:

  • Add(delta int): Used to change the count maintained by the WaitGroup. When a positive integer delta is passed in, the count increases by the corresponding value; when a negative number is passed in, the count decreases by the corresponding value.
  • Done(): It is an equivalent shortcut for Add(-1), and is usually used to decrement the count by 1 when a goroutine task is completed.
  • Wait(): When a goroutine calls this method, if the count is zero, this operation is a no-op (no operation); if the count is a positive integer, the current goroutine will enter a blocked state and will not re-enter the running state until the count becomes zero, that is, the Wait() method returns.

It should be noted that wg.Add(delta), wg.Done() and wg.Wait() are abbreviations of (&wg).Add(delta), (&wg).Done() and (&wg).Wait() respectively. If the call to Add(delta) or Done() causes the count to become negative, the program will panic.

1.2 Usage Example

package main

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

func main() {
    rand.Seed(time.Now().UnixNano()) // Required before Go 1.20

    const N = 5
    var values [N]int32

    var wg sync.WaitGroup
    wg.Add(N)
    for i := 0; i < N; i++ {
        i := i
        go func() {
            values[i] = 50 + rand.Int31n(50)
            fmt.Println("Done:", i)
            wg.Done() // <=> wg.Add(-1)
        }()
    }

    wg.Wait()
    // All elements are guaranteed to be initialized.
    fmt.Println("values:", values)
}

In the above example, the main goroutine sets the count of the wait group to 5 through wg.Add(N), and then starts 5 goroutines. Each goroutine calls wg.Done() to decrement the count by 1 after completing the task. The main goroutine calls wg.Wait() to block until all 5 goroutines have completed their tasks and the count becomes 0, and then continues to execute the subsequent code to print out the values of each element.

In addition, the call to the Add method can also be split into multiple times, as shown below:

...
var wg sync.WaitGroup
for i := 0; i < N; i++ {
    wg.Add(1) // Will be executed 5 times
    i := i
    go func() {
        values[i] = 50 + rand.Int31n(50)
        wg.Done()
    }()
}
...

The Wait method of a *sync.WaitGroup value can be called in multiple goroutines. When the count maintained by the corresponding sync.WaitGroup value drops to 0, these goroutines will all receive the notification and end the blocked state.

func main() {
    rand.Seed(time.Now().UnixNano()) // Required before Go 1.20

    const N = 5
    var values [N]int32

    var wgA, wgB sync.WaitGroup
    wgA.Add(N)
    wgB.Add(1)

    for i := 0; i < N; i++ {
        i := i
        go func() {
            wgB.Wait() // Wait for the broadcast notification
            log.Printf("values[%v]=%v \n", i, values[i])
            wgA.Done()
        }()
    }

    // The following loop is guaranteed to execute before any of the above
    // wg.Wait calls end.
    for i := 0; i < N; i++ {
        values[i] = 50 + rand.Int31n(50)
    }
    wgB.Done() // Send a broadcast notification
    wgA.Wait()
}

The WaitGroup can be reused after the Wait method returns. However, it should be noted that when the base number maintained by the WaitGroup value is zero, the call to the Add method with a positive integer argument cannot be run concurrently with the call to the Wait method, otherwise a data race problem may occur.

2. sync.Once Type

The sync.Once type is used to ensure that a piece of code is only executed once in a concurrent program. Each *sync.Once value has a Do(f func()) method, which accepts a parameter of type func().

2.1 Method Characteristics

For an addressable sync.Once value o, the call to the o.Do() (i.e., the abbreviation of (&o).Do()) method can be executed multiple times concurrently in multiple goroutines, and the arguments of these method calls should (but are not mandatory) be the same function value. Among these calls, only one of the argument functions (values) will be called, and the called argument function is guaranteed to exit before any o.Do() method call returns, that is, the code inside the called argument function will be executed before any o.Do() method returns the call.

2.2 Usage Example

package main

import (
    "log"
    "sync"
)

func main() {
    log.SetFlags(0)

    x := 0
    doSomething := func() {
        x++
        log.Println("Hello")
    }

    var wg sync.WaitGroup
    var once sync.Once
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            once.Do(doSomething)
            log.Println("world!")
        }()
    }

    wg.Wait()
    log.Println("x =", x) // x = 1
}

In the above example, although 5 goroutines all call once.Do(doSomething), the doSomething function will only be executed once. Therefore, "Hello" will only be output once, while "world!" will be output 5 times, and "Hello" will definitely be output before all 5 "world!" outputs.

3. sync.Mutex (Mutex Lock) and sync.RWMutex (Read-Write Lock) Types

Both the *sync.Mutex and *sync.RWMutex types implement the sync.Locker interface type. Therefore, these two types both contain Lock() and Unlock() methods, which are used to protect data and prevent it from being read and modified simultaneously by multiple users.

3.1 sync.Mutex (Mutex Lock)

  • Basic Characteristics: The zero value of a Mutex is an unlocked mutex. For an addressable Mutex value m, it can only be successfully locked by calling the m.Lock() method when it is in the unlocked state. Once the m value is locked, a new lock attempt will cause the current goroutine to enter a blocked state until it is unlocked by calling the m.Unlock() method. m.Lock() and m.Unlock() are abbreviations of (&m).Lock() and (&m).Unlock() respectively.
  • Usage Example
package main

import (
    "fmt"
    "runtime"
    "sync"
)

type Counter struct {
    m sync.Mutex
    n uint64
}

func (c *Counter) Value() uint64 {
    c.m.Lock()
    defer c.m.Unlock()
    return c.n
}

func (c *Counter) Increase(delta uint64) {
    c.m.Lock()
    c.n += delta
    c.m.Unlock()
}

func main() {
    var c Counter
    for i := 0; i < 100; i++ {
        go func() {
            for k := 0; k < 100; k++ {
                c.Increase(1)
            }
        }()
    }

    // This loop is for demonstration purposes only.
    for c.Value() < 10000 {
        runtime.Gosched()
    }
    fmt.Println(c.Value()) // 10000
}

In the above example, the Counter struct uses the Mutex field m to ensure that the field n will not be accessed and modified simultaneously by multiple goroutines, ensuring the consistency and correctness of the data.

3.2 sync.RWMutex (Read-Write Mutex Lock)

  • Basic Characteristics: The sync.RWMutex internally contains two locks: a write lock and a read lock. In addition to the Lock() and Unlock() methods, the *sync.RWMutex type also has RLock() and RUnlock() methods, which are used to support multiple readers to read data concurrently, but prevent the data from being used simultaneously by a writer and other data accessors (including readers and writers). The read lock of rwm maintains a count. When the rwm.RLock() call is successful, the count increases by 1; when the rwm.RUnlock() call is successful, the count decreases by 1; a count of zero indicates that the read lock is in the unlocked state, and a non-zero count indicates that the read lock is in the locked state. rwm.Lock(), rwm.Unlock(), rwm.RLock() and rwm.RUnlock() are abbreviations of (&rwm).Lock(), (&rwm).Unlock(), (&rwm).RLock() and (&rwm).RUnlock() respectively.
  • Locking Rules
    • The write lock of rwm can only be successfully locked when both the write lock and the read lock are in the unlocked state, that is, the write lock can only be successfully locked by at most one data writer at any time, and the write lock and the read lock cannot be locked simultaneously.
    • When the write lock of rwm is in the locked state, any new write lock or read lock operation will cause the current goroutine to enter a blocked state until the write lock is unlocked.
    • When the read lock of rwm is in the locked state, a new write lock operation will cause the current goroutine to enter a blocked state; and a new read lock operation will be successful under certain conditions (occurring before any blocked write lock operation), that is, the read lock can be held by multiple data readers simultaneously. When the count maintained by the read lock is cleared to zero, the read lock returns to the unlocked state.
    • In order to prevent the data writer from starving, when the read lock is in the locked state and there are blocked write lock operations, subsequent read lock operations will be blocked; in order to prevent the data reader from starving, when the write lock is in the locked state, after the write lock is unlocked, the previously blocked read lock operations will definitely succeed.
  • Usage Example
package main

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

func main() {
    var m sync.RWMutex
    go func() {
        m.RLock()
        fmt.Print("a")
        time.Sleep(time.Second)
        m.RUnlock()
    }()
    go func() {
        time.Sleep(time.Second * 1 / 4)
        m.Lock()
        fmt.Print("b")
        time.Sleep(time.Second)
        m.Unlock()
    }()
    go func() {
        time.Sleep(time.Second * 2 / 4)
        m.Lock()
        fmt.Print("c")
        m.Unlock()
    }()
    go func () {
        time.Sleep(time.Second * 3 / 4)
        m.RLock()
        fmt.Print("d")
        m.RUnlock()
    }()
    time.Sleep(time.Second * 3)
    fmt.Println()
}

The above program is most likely to output abdc, which is used to explain and verify the locking rules of the read-write lock. It should be noted that the use of the time.Sleep call in the program for synchronization between goroutines should not be used in production code.

In practical applications, if read operations are frequent and write operations are few, the Mutex can be replaced with an RWMutex to improve execution efficiency. For example, replace the Mutex in the above Counter example with an RWMutex:

...
type Counter struct {
    //m sync.Mutex
    m sync.RWMutex
    n uint64
}

func (c *Counter) Value() uint64 {
    //c.m.Lock()
    //defer c.m.Unlock()
    c.m.RLock()
    defer c.m.RUnlock()
    return c.n
}
...

In addition, sync.Mutex and sync.RWMutex values can also be used to implement notifications, although this is not the most elegant implementation in Go. For example:

package main

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

func main() {
    var m sync.Mutex
    m.Lock()
    go func() {
        time.Sleep(time.Second)
        fmt.Println("Hi")
        m.Unlock() // Send a notification
    }()
    m.Lock() // Wait for the notification
    fmt.Println("Bye")
}

In this example, a simple notification between goroutines is implemented through the Mutex to ensure that "Hi" is printed before "Bye". For the memory ordering guarantees related to sync.Mutex and sync.RWMutex values, you can refer to the relevant documents on memory ordering guarantees in Go.

The types in the sync standard library package play a crucial role in the concurrent programming of the Go language. Developers need to reasonably select and correctly use these synchronization types according to specific business scenarios and requirements, so as to write efficient, reliable and thread-safe concurrent programs. At the same time, when writing concurrent code, it is also necessary to have an in-depth understanding of various concepts and potential problems in concurrent programming, such as data races, deadlocks, etc., and ensure the correctness and stability of the program in a concurrent environment through sufficient testing and verification.

Leapcell: The Best of Serverless Web Hosting

Finally, I would like to recommend a platform that is most suitable for deploying Go services: Leapcell

Image description