DEV Community

Cover image for How Does sync.WaitGroup Work? A Look into Goroutine Synchronization via Source Code
Leapcell
Leapcell

Posted on

How Does sync.WaitGroup Work? A Look into Goroutine Synchronization via Source Code

Image description

Leapcell: The Best of Serverless Web Hosting

In - depth Analysis of sync.WaitGroup Principles and Applications

1. Overview of the Core Functions of sync.WaitGroup

1.1 Synchronization Needs in Concurrent Scenarios

In the concurrent programming model of the Go language, when a complex task needs to be broken down into multiple independent subtasks to be executed in parallel, the scheduling mechanism of goroutines may cause the main goroutine to exit early while the subtasks have not been completed. At this time, a mechanism is needed to ensure that the main goroutine waits for all subtasks to be completed before continuing to execute the subsequent logic. sync.WaitGroup is a core tool designed to solve such goroutine synchronization problems.

1.2 Basic Usage Paradigm

Definition of Core Methods

  • Add(delta int): Sets or adjusts the number of subtasks to wait for. delta can be positive or negative (a negative value means reducing the number of waits).
  • Done(): Called when a subtask is completed, which is equivalent to Add(-1).
  • Wait(): Blocks the current goroutine until all the subtasks to be waited for are completed.

Typical Code Example

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(2) // Set the number of subtasks to wait for as 2

    go func() {
        defer wg.Done() // Mark when the subtask is completed
        fmt.Println("Subtask 1 executed")
    }()

    go func() {
        defer wg.Done()
        fmt.Println("Subtask 2 executed")
    }()

    wg.Wait() // Block until all subtasks are completed
    fmt.Println("The main goroutine continues to execute")
}
Enter fullscreen mode Exit fullscreen mode

Explanation of Execution Logic

  1. The main goroutine declares that it needs to wait for 2 subtasks through Add(2).
  2. Subtasks notify completion through Done(), and internally call Add(-1) to reduce the counter.
  3. Wait() continues to block until the counter reaches zero, and the main goroutine resumes execution.

2. Source Code Implementation and Data Structure Analysis (Based on Go 1.17.10)

2.1 Memory Layout and Data Structure Design

type WaitGroup struct {
    noCopy noCopy // A marker to prevent the structure from being copied
    state1 [3]uint32 // Composite data storage area
}
Enter fullscreen mode Exit fullscreen mode

Field Analysis

  1. noCopy Field

    Through the go vet static inspection mechanism of the Go language, WaitGroup instances are prohibited from being copied to avoid state inconsistency caused by copying. This field is essentially an unused structure, only used to trigger compile - time checks.

  2. state1 Array

    It uses a compact memory layout to store three types of core data, compatible with the memory alignment requirements of 32 - bit and 64 - bit systems:

    • 64 - bit System:
      • state1[0]: Counter, records the number of remaining subtasks to be completed.
      • state1[1]: Waiter count, records the number of goroutines that have called Wait().
      • state1[2]: Semaphore, used for blocking and waking up between goroutines.
    • 32 - bit System:
      • state1[0]: Semaphore.
      • state1[1]: Counter.
      • state1[2]: Waiter count.

Memory Alignment Optimization

By combining counter and waiter into a 64 - bit integer (the high 32 bits are counter, and the low 32 bits are waiter), natural alignment is ensured on 64 - bit systems, improving the efficiency of atomic operations. On 32 - bit systems, the position of the semaphore is adjusted to ensure the address alignment of 64 - bit data blocks.

2.2 Implementation Details of Core Methods

2.2.1 state() Method: Data Extraction Logic

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
    // Determine the memory alignment method
    if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
        // 64 - bit alignment: the first two uint32s form the state, and the third is the semaphore
        return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
    } else {
        // 32 - bit alignment: the last two uint32s form the state, and the first is the semaphore
        return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Dynamically determine the distribution of data in the array through the alignment characteristics of the pointer address.
  • Use unsafe.Pointer to achieve underlying memory access and ensure cross - platform compatibility.

2.2.2 Add(delta int) Method: Counter Update Logic

func (wg *WaitGroup) Add(delta int) {
    statep, semap := wg.state()
    // Atomically update the counter (high 32 bits)
    state := atomic.AddUint64(statep, uint64(delta)<<32)
    v := int32(state >> 32) // Extract the counter
    w := uint32(state)      // Extract the waiter count

    // The counter cannot be negative
    if v < 0 {
        panic("sync: negative WaitGroup counter")
    }
    // Prohibit calling Add concurrently when Wait is executing
    if w != 0 && delta > 0 && v == int32(delta) {
        panic("sync: WaitGroup misuse: Add called concurrently with Wait")
    }
    // When the counter is zero and there are waiters, release the semaphore
    if v == 0 && w != 0 {
        *statep = 0 // Reset the state
        for ; w > 0; w-- {
            runtime_Semrelease(semap, false, 0) // Wake up the waiting goroutines
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Core Logic: Ensure the thread safety of counter updates through atomic operations. When the counter is zero and there are waiting goroutines, wake up all waiters through the semaphore release mechanism.
  • Exception Handling: Strictly check for illegal operations such as negative counters and concurrent calls to avoid program logic errors.

2.2.3 Wait() Method: Blocking and Wake - up Mechanism

func (wg *WaitGroup) Wait() {
    statep, semap := wg.state()
    for {
        state := atomic.LoadUint64(statep) // Atomically read the state
        v := int32(state >> 32)
        w := uint32(state)
        if v == 0 {
            // If the counter is 0, return directly
            return
        }
        // Safely increase the waiter count using a CAS operation
        if atomic.CompareAndSwapUint64(statep, state, state+1) {
            runtime_Semacquire(semap) // Block the current goroutine and wait for the semaphore to be released
            // Check the state consistency
            if *statep != 0 {
                panic("sync: WaitGroup is reused before previous Wait has returned")
            }
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Spin Waiting: Ensure the safe increment of the waiter count through loop CAS operations to avoid race conditions.
  • Semaphore Blocking: Call runtime_Semacquire to enter the blocking state until the Add or Done operation releases the semaphore to wake up the goroutine.

2.2.4 Done() Method: Quick Counter Decrement

func (wg *WaitGroup) Done() {
    wg.Add(-1) // Equivalent to decrementing the counter by 1
}
Enter fullscreen mode Exit fullscreen mode

3. Usage Specifications and Precautions

3.1 Key Usage Principles

  1. Order Requirements

    The Add operation must be completed before the Wait call to avoid the failure of the waiting logic caused by the uninitialized counter.

  2. Count Consistency

    The number of Done calls must be consistent with the initial count set by Add. Otherwise, the counter may not be able to reach zero, causing permanent blocking.

  3. Prohibition of Concurrent Operations

    • It is strictly forbidden to call Add concurrently during the execution of Wait, otherwise a panic will be triggered.
    • When reusing WaitGroup, ensure that the previous Wait has returned to avoid state confusion.

3.2 Typical Error Scenarios

Error Operation Consequence Example Code
Negative Counter panic wg.Add(-1) (when the initial count is 0)
Concurrent Calling of Add and Wait panic The main goroutine calls Wait while the subtask calls Add
Unpaired Calling of Done Permanent Blocking After wg.Add(1), Done is not called

4. Summary

sync.WaitGroup is a basic tool for handling goroutine synchronization in Go language concurrent programming. Its design fully reflects the engineering practice principles such as memory alignment optimization, atomic operation safety, and error checking. By deeply understanding its data structure and implementation logic, developers can use this tool more safely and efficiently and avoid common pitfalls in concurrent scenarios. In practical applications, it is necessary to strictly follow the specifications such as count matching and sequential calling to ensure the correctness and stability of the program.

Leapcell: The Best of Serverless Web Hosting

Finally, recommend a platform that is most suitable for deploying Go services: Leapcell

Image description

πŸš€ Build with Your Favorite Language

Develop effortlessly in JavaScript, Python, Go, or Rust.

🌍 Deploy Unlimited Projects for Free

Only pay for what you useβ€”no requests, no charges.

⚑ Pay - as - You - Go, No Hidden Costs

No idle fees, just seamless scalability.

Image description

πŸ“– Explore Our Documentation

πŸ”Ή Follow us on Twitter: @LeapcellHQ

Top comments (0)