Concurrency in Go (Part-3): sync package primitives

Concurrency in Go (Part-3): sync package primitives

Cond, Once and Pool primitives

Table of contents

Cond

Sometimes, goroutines need to wait for some data or some kind of signal to be available before executing further. Cond primitive provides the most efficient way to just do that.

Cond implements a condition variable, a rendezvous point for goroutines waiting for or announcing the occurrence of an event. - sync package doc.

Cond is implemented as a struct with Locker field, and implements 3 methods.

The Broadcast() method of Cond wakes up all goroutines that are waiting on the condition c. The caller may or may not hold the associated mutex c.L while calling this method.

Similarly, the Signal() method of Cond wakes up one goroutine that is waiting on a condition c, if any.

The Wait() method of Cond atomically releases the associated mutex c.L and suspends the execution of the calling goroutine. After resuming the execution, the method re-acquires the mutex before returning. Unlike in other systems, the Wait() the method cannot return unless it is awoken by a call to Broadcast() or Signal().

Let's see an example:

package main

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

var done bool

func reader(i int, c *sync.Cond, wg *sync.WaitGroup) {
    fmt.Printf("goroutine number %d started\n", i+1)
    defer wg.Done()
    c.L.Lock()
    defer c.L.Unlock()
    for !done {
        fmt.Printf("goroutine number %d waiting\n", i+1)
        c.Wait()
    }
    fmt.Printf("goroutine number %d finished\n", i+1)
}

func writer(c *sync.Cond, wg *sync.WaitGroup) {
    fmt.Println("Writer goroutine started")
    defer wg.Done()
    time.Sleep(5 * time.Second)
    c.L.Lock()
    defer c.L.Unlock()
    done = true
    c.Broadcast()
    fmt.Println("Writer goroutine is done")
}

func main() {
    var wg sync.WaitGroup
    m := &sync.Mutex{}
    c := sync.NewCond(m)
    for i := 0; i < 2; i++ {
        wg.Add(1)
        go reader(i, c, &wg)
    }
    wg.Add(1)
    go writer(c, &wg)
    wg.Wait()
}
➜ go run main.go
Writer goroutine started
goroutine number 2 started
goroutine number 2 waiting
goroutine number 1 started
goroutine number 1 waiting
Writer goroutine is done
goroutine number 1 finished
goroutine number 2 finished

Run this code in Go Playground

In the above example, we wait on a condition for !done{}. All the reader goroutines will be suspended until the writer goroutines update the variable done=true and send the waking-up signal to all suspended goroutines.

Once

The sync.Once type in Go is used to perform a certain operation exactly once in a concurrent setting, regardless of how many times it is called from different goroutines.

The sync.Once type guarantees that the operation is executed only once, even if multiple goroutines attempt to call it concurrently. This is useful when you need to initialize a shared resource that is expensive to create or when you want to ensure that a particular task is executed only once.

The sync.Once the type has a single method, Do(f func()), that takes a function f as an argument. The first call to Do() will execute the function f, and subsequent calls will do nothing. The sync.Once type internally maintains a flag that indicates whether f has been executed or not.

Let's see an example:

package main

import (
    "fmt"
    "sync"
)

func DBinit() {
    fmt.Println("Initializing database...")
}

func main() {
    var wg sync.WaitGroup
    var once sync.Once

    for i := 1; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            once.Do(DBinit)
            fmt.Println("Database initialized.")
        }()
    }
    wg.Wait()
}
➜  go run main.go
Initializing database...
Database initialized.
Database initialized.
Database initialized.
Database initialized.

Run this code in Go Playground

Pool

The sync.Pool type in Go enables caching of temporary objects that can be utilized again across multiple goroutines. Essentially, the sync.Pool maintains a finite set of objects that are assigned to the goroutine, and once a goroutine finishes utilizing an object, it is returned to the pool for reuse by another goroutine.

The sync.Pool type works by maintaining a pool of objects that can be accessed and reused by multiple goroutines. We can request new resource objects using Get() the method of sync.Pool. It first checks if there are any objects in the pool. If there are, it returns one of the objects. If not, it creates a new object and returns it. When an object is no longer needed, it can be put back into the pool for future use using Put(x interface{}) the method.

Using sync.Pool can significantly reduce the number of objects that need to be garbage collected, which can improve overall performance in Go programs. However, it is important to use sync.Pool judiciously, as it is not always beneficial and can sometimes even hurt performance if not used correctly.

Here's an example:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    var bufCount int

    bufPool := &sync.Pool{
        New: func() any {
            bufCount++
            buf := make([]byte, 1024)
            return &buf
        },
    }

    for i := 0; i < 1000000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            buf := bufPool.Get().(*[]byte)
            bufPool.Put(buf)
        }()
    }

    wg.Wait()
    fmt.Println(bufCount)
}
➜  go run main.go
9

Run this code in Go Playground

Next, a sync.Pool is created using the New field, which is a function that creates a new object when the pool is empty. In this case, the New function creates a new byte slice with a length of 1024 and returns a pointer to the slice. The function also increments the bufCount variable to keep track of the number of objects created.

Then, a loop is started that creates 1 million goroutines. Each goroutine calls the sync.Pool's Get method to retrieve a byte slice from the pool. In the end, the goroutine calls the sync.Pool's Put method to return the byte slice to the pool.

If this program were written without using sync.Pool, each goroutine would create its own byte buffer whenever it needed one. This would result in a lot of unnecessary memory allocation and garbage collection, which can be costly in terms of performance.

Thank you for reading this blog, and please give your feedback in the comment section below.

Did you find this article valuable?

Support Pratik Jagrut by becoming a sponsor. Any amount is appreciated!