Featured image of post Overview of Changes in Go’s Concurrency Library in 2023

Overview of Changes in Go’s Concurrency Library in 2023

In 2023, there have been some changes to Go’s concurrency library, and this article provides an overview of these changes. Minor details such as typos and documentation changes will not be covered.

 

In 2023, there have been some changes to Go’s concurrency library, and this article provides an overview of these changes. Minor details such as typos and documentation changes will not be covered.

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

sync.Map

In Go 1.21.0, three functions related to Once were added to the sync package to facilitate the usage of Once:

1
2
3
func OnceFunc(f func()) func()
func OnceValue[T any](f func() T) func() T
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)

The functionality of these three functions is as follows:

  • OnceFunc: Returns a function g that, when called multiple times, will only execute f once. If f panics during execution, subsequent calls to g will not execute f, but each call will still panic.
  • OnceValue: Returns a function g that, when called multiple times, will only execute f once. The return type of g is T, which is an additional return value compared to the previous function. The panic behavior is the same as OnceFunc.
  • OnceValues: Returns a function g that, when called multiple times, will only execute f once. The return type of g is (T1, T2), which is an additional return value compared to the previous function. The panic behavior is the same as OnceFunc.

In theory, you can add more functions and return more values. However, since Go does not have a tuple type, the return values of function g cannot be simplified to a tuple type. In any case, Go 1.21.0 only added these three functions.

What are the benefits of these functions? Previously, when using sync.Once, such as initializing a thread pool, we needed to define a variable for the thread pool and call sync.Once.Do every time we accessed the thread pool variable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func TestOnce(t *testing.T) {
   var pool any
   var once sync.Once
   var initFn = func() {
     // initialize pool
     pool = 1
   }
   for i := 0; i < 10; i++ {
     once.Do(initFn)
     t.Log(pool)
   }
 }

With OnceValue, the code can be simplified:

1
2
3
4
5
6
7
8
9
func TestOnceValue(t *testing.T) {
   var initPool = func() any {
     return 1
   }
   var poolGenerator = sync.OnceValue(initPool)
   for i := 0; i < 10; i++ {
     t.Log(poolGenerator())
   }
 }

The code is slightly simplified, and you only need to call the returned function g to obtain the singleton.

In summary, these three functions are just encapsulations of sync.Once to make it more convenient to use.

Understanding copyChecker

We know that sync.Cond has two fields, noCopy and checker. noCopy can be statically checked using the go vet tool, but checker is checked at runtime:

1
2
3
4
5
6
7
type Cond struct {
   noCopy noCopy
   // L is held while observing or changing the condition
   L Locker
   notify  notifyList
   checker copyChecker
 }

Previously, the conditions for copyChecker were as follows, although it is just three simple lines, it is not easy to understand:

1
2
3
4
5
6
7
func (c *copyChecker) check() {
   if uintptr(*c) != uintptr(unsafe.Pointer(c)) &&
     !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) &&
     uintptr(*c) != uintptr(unsafe.Pointer(c)) {
     panic("sync.Cond is copied")
   }
 }

Now, with added comments, the meaning of these three lines is explained:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (c *copyChecker) check() {
 
   // Check if c has been copied in three steps:
 
   // 1. The first comparison is the fast-path. If c has been initialized and not copied, this will return immediately. Otherwise, c is either not initialized, or has been copied.
 
   // 2. Ensure c is initialized. If the CAS succeeds, we're done. If it fails, c was either initialized concurrently and we simply lost the race, or c has been copied.
 
   // 3. Do step 1 again. Now that c is definitely initialized, if this fails, c was copied.
   if uintptr(*c) != uintptr(unsafe.Pointer(c)) &&
     !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) &&
     uintptr(*c) != uintptr(unsafe.Pointer(c)) {
     panic("sync.Cond is copied")
   }
 }

The main logic is as follows:

  • The first step is a fast check, directly comparing the pointer of c and the pointer of c itself. If they are not equal, it means that c has been copied. This is the fastest check path.
  • The second step ensures that c is initialized. It initializes c using CAS (CompareAndSwap). If the CAS succeeds, we’re done. If it fails, it means that c was either initialized concurrently and we simply lost the race, or c has been copied.
  • The third step repeats the first step’s check. Since we know that c is definitely initialized at this point, if the check fails, it means that c was copied.

The entire logic uses CAS combined with two pointer checks to ensure the correctness of the judgment.

In summary, the first step is a performance optimization. The second step uses CAS to ensure initialization. The third step rechecks to ensure the judgment.

Optimization in sync.Map

Previously, the implementation of the Range function in sync.Map was as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func (m *Map) Range(f func(key, value any) bool) {
     ...
     if read.amended {
       read = readOnly{m: m.dirty}
       m.read.Store(&read)
       m.dirty = nil
       m.misses = 0
   }
     ...
 }

There was a line of code: m.read.Store(&read), which caused read to escape to the heap. To avoid the escape of read, a small trick was employed by introducing a new variable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (m *Map) Range(f func(key, value any) bool) {
     ...
   if read.amended {
     read = readOnly{m: m.dirty}
     copyRead := read
     m.read.Store(&Read)
     m.dirty = nil
     m.misses = 0
   }
     ...
 }

Issue #62404 analyzed this problem.

Replacing done in sync.Once implementation with atomic.Uint32

Previously, the implementation of sync.Once was as follows:

1
2
3
4
type Once struct {
	done uint32
	m    Mutex
}

The done field was of type uint32 to indicate whether Once has been executed. The reason for using uint32 instead of bool is that uint32 can be used with atomic operations from the atomic package, while bool cannot.

Now, the implementation of sync.Once is as follows:

1
2
3
4
type Once struct {
	done atomic.Uint32
	m    Mutex
}

Since Go 1.19, the standard library has provided atomic wrappers for basic types, and a large amount of code in the Go standard library has been replaced with atomic.XXX types.

In my opinion, this modification may result in a performance decrease compared to the previous implementation in certain cases. I will write an article specifically to explore this.

Besides sync.Once, there are other types that have been replaced with atomic.XXX types in their usage. Is it necessary to replace them?

Optimization in Initial Implementation of sync.OnceFunc

The initial implementation of sync.OnceFunc was as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func OnceFunc(f func()) func() {
 var (
  once  Once
  valid bool
  p     any
 )

 g := func() {
  defer func() {
   p = recover()
   if !valid {
    panic(p)
   }
  }()
  f()
  valid = true
 }

 return func() {
  once.Do(g)
  if !valid {
   panic(p)
  }
 }
}

If you look closely at this code, you will notice that the function f passed to OnceFunc/OnceValue/OnceValues remains alive even after it has been executed once, as long as the returned function g is not garbage collected. This is unnecessary because f only needs to be executed once and can be garbage collected afterwards. Therefore, an optimization can be made to set f to nil after it is executed, allowing it to be garbage collected.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func OnceFunc(f func()) func() {
 var (
  once  Once
  valid bool
  p     any
 )

 // Construct the inner closure just once to reduce costs on the fast path.
 g := func() {
  defer func() {
   p = recover()
   if !valid {
    // Re-panic immediately so on the first call the user gets a
    // complete stack trace into f.
    panic(p)
   }
  }()
  f()
  f = nil      // Do not keep f alive after invoking it.
  valid = true // Set only if f does not panic.
 }

 return func() {
  once.Do(g)
  if !valid {
   panic(p)
  }
 }
}

Context

As we know, in Go 1.20, a new method WithCancelCause was added ( func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc)), which allows us to pass the cancellation cause to the Context generated by WithCancelCause. This allows us to retrieve the cancellation cause using the context.Cause method.

1
2
3
4
ctx, cancel := context.WithCancelCause(parent)
cancel(myError)
ctx.Err() // returns context.Canceled
context.Cause(ctx) // returns myError

Of course, this implementation is only halfway done, as timeout-related Context also needs this functionality. Therefore, in Go 1.21.0, two additional functions were added:

1
2
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc)
func WithTimeoutCause(parent Context, timeout time.Duration, cause error) (Context, CancelFunc)

These two functions, unlike WithCancelCause, directly pass the cause as a parameter instead of using the returned cancel function.

Go 1.21.0 also introduced a function AfterFunc, which is similar to time.AfterFunc, but it returns a Context that is automatically canceled after the timeout. The implementation of this function is as follows:

1
func AfterFunc(ctx Context, f func()) (stop func() bool)

The specified Context triggers the invocation of f immediately when done (either due to timeout or cancellation). The returned stop function is used to stop the invocation of f. If stop is called and returns true, f will not be invoked.

This is a helper function, but it may be difficult to understand, and it is unlikely to be widely used.

Other minor performance optimizations, such as replacing type emptyCtx int with type emptyCtx struct{}, are not mentioned here.

An additional function func WithoutCancel(parent Context) Context was added, which creates a Context that is not affected when the parent is canceled.

golang.org/x/sync No Significant Changes in sync

errgroup now supports setting the cause using withCancelCause. singleflight added an Unwrap method to panicError.

Built with Hugo
Theme Stack designed by Jimmy