Golang High-Performance Programming EP6: Tips For Asynchronous Programming

Mastering Goroutines, Channels, and Context for Efficient Concurrent Programming

分享
Golang High-Performance Programming EP6: Tips For Asynchronous Programming
source:github.com/MariaLetta/free-gophers-pack

Introduction

The primary mission of Golang is to simplify asynchronous programming. When faced with operations requiring batch processing and lengthy execution times, traditional single-threaded execution becomes cumbersome, prompting the need for asynchronous parallel processing. This article introduces some tips for asynchronous programming in Golang.

sourcegraph/conc

Let’s start by introducing a library that simplifies concurrent programming: conc. It encapsulates many useful tools, such as WaitGroup and iter.Map. While we may not necessarily use conc in production code, learning from its concepts is still beneficial.

Usage

Using go

The simplest and most common method is to use the go keyword.

func main() {   
  go func() {   
   fmt.Println("hello world1")   
  }()   
  go func() {   
   fmt.Println("hello world2")   
  }()   
 }

Or:

func main() {   
  go Announce("hello world1")   
  go Announce("hello world2")   
 }   
 func Announce(message string) {   
  fmt.Println(message)   
 }

Using anonymous functions to pass parameters:

data := "Hello, World!"   
 go func(msg string) {   
  // Use msg to perform asynchronous task logic processing 
  fmt.Println(msg)   
 }(data)

This method doesn’t require consideration of return values. If return values are needed, the following method can be used.

Implementing Timeout Control with Goroutines and Channels

ch := make(chan int, 1)   
 timer := time.NewTimer(time.Second)   
 go func() {   
     time.Sleep(2 * time.Second)   
     ch <- 1   
     close(ch)   
 }()   
 select {   
 case <-timer.C:   
     fmt.Println("timeout")   
 case result := <-ch:   
     fmt.Println(result)   
 }

Using sync.WaitGroup

sync.WaitGroup is used to wait for a collection of goroutines to finish their tasks. The Add() method increases the number of goroutines to wait for, the Done() method marks a goroutine as completed, and the Wait() method blocks until all goroutines are finished.

var wg sync.WaitGroup   
 // Start multiple goroutines   
 for i := 0; i < 5; i++ {   
    wg.Add(1)   
    go func(index int) {   
       defer wg.Done()   
       // Asynchronous task logic   
    }(i)   
 }   
 wg.Wait()

Error Handling with errgroup for Goroutine Groups

The errgroup package is useful for easily capturing errors from goroutines. It’s a utility in the Go standard library for managing a group of goroutines and handling their errors.

var eg errgroup.Group   
 for i := 0; i < 5; i++ {   
     eg.Go(func() error {   
      return errors.New("error")   
     })   
 ​ 
     eg.Go(func() error {   
      return nil   
     })   
 }   
 ​ 
 if err := eg.Wait(); err != nil {   
     // Handle error   
 }

Tips and Techniques

Using Range and Close Operations with Channels

The range can be used to iterate over the values received on a channel until the channel is closed. Use the close function to close the channel, signaling no more values will be sent.

ch := make(chan int)   
 ​ 
 go func() {   
     for i := 0; i < 5; i++ {   
         ch <- i // Send values to the channel   
     }   
     close(ch) // Close the channel   
 }()   
 ​ 
 // Use range to iterate over received values 
 for val := range ch {   
     // Process received values   
 }

Waiting for Multiple Goroutines With Select

ch1 := make(chan int)   
 ch2 := make(chan string)   
 ​ 
 go func() {   
     // Asynchronous task 1 logic   
     ch1 <- result1   
 }()   
 ​ 
 go func() {   
     // Asynchronous task 2 logic   
     ch2 <- result2   
 }()   
 ​ 
 // Wait for multiple asynchronous tasks to complete in the main goroutine 
 select {   
 case res1 := <-ch1:   
     // Process result 1   
 case res2 := <-ch2:   
     // Process result 2   
 }

Implementing Timeout Control with Select and time.After()

If you need to set a timeout for asynchronous operations, you can use the select statement in conjunction with the time.After() function.

ch := make(chan int)   
 ​ 
 go func() {   
     // Asynchronous task logic   
     time.Sleep(2 * time.Second)   
     ch <- result   
 }()   
 ​ 
 // Set a timeout 
 select {   
 case res := <-ch:   
     // Process result   
 case <-time.After(3 * time.Second):   
     // Handle timeout   
 }

Using time.Tick() and time.After() for Timed Operations

The time.Tick() function returns a channel that sends time values periodically, which is useful for executing timed operations. The time.After() function returns a channel that sends a time value after a specified duration.

tick := time.Tick(1 * time.Second) // Execute an operation every second   
 ​ 
 for {   
     select {   
     case <-tick:   
         // Perform timed operation   
     }   
 }   
 ​ 
 select {   
 case <-time.After(5 * time.Second):   
     // Execute operation after 5 seconds   
 }

Using sync.Mutex or sync.RWMutex for Concurrent Safe Access

When multiple goroutines concurrently access shared data, ensuring data access safety is essential. sync.Mutex and sync.RWMutex provide mutual exclusion and read-write locks for locking before accessing shared resources, preventing data races.

var mutex sync.Mutex   
 var data int   
 ​ 
 // Write operation protected by a mutex 
 mutex.Lock()   
 data = 123   
 mutex.Unlock()   
 ​ 
 // Read operation protected by a read lock 
 mutex.RLock()   
 value := data   
 mutex.RUnlock()   
 ​ 
 var rwMutex sync.RWMutex   
 var sharedData map[string]string   
 ​ 
 // Read operation protected by a read lock 
 func readData(key string) string {   
     rwMutex.RLock()   
     defer rwMutex.RUnlock()   
     return sharedData[key]   
 }   
 ​ 
 // Write operation protected by a write lock 
 func writeData(key, value string) {   
     rwMutex.Lock()   
     defer rwMutex.Unlock()   
     sharedData[key] = value   
 }

Note: sync.Mutex locks cannot be nested. sync.RWMutex read locks (RLock()) can be nested if there are no write locks, and multiple read locks can be acquired.

Using sync.Cond for Conditional Variable Control

sync.Cond is a conditional variable used for communication and synchronization between goroutines. It can block and wait until a specified condition is met, then wake up waiting until the condition is satisfied.

var cond = sync.NewCond(&sync.Mutex{})   
 var ready bool   
 ​ 
 go func() {   
     // Asynchronous task logic   
     ready = true   
 ​ 
     // Notify waiting goroutines that the condition is met 
     cond.Broadcast()   
 }()   
 ​ 
 // Wait for the condition to be met 
 cond.L.Lock()   
 for !ready {   
     cond.Wait()   
 }   
 cond.L.Unlock()

Managing Object Pools With sync.Pool

sync.Pool is an object pool for caching and reusing temporary objects, improving allocation and recycling efficiency.

type MyObject struct {   
     // Object structure   
 }   
 ​ 
 var objectPool = sync.Pool{   
     New: func() interface{} {   
         // Create a new object   
         return &MyObject{}   
     },   
 }   
 ​ 
 // Get an object from the pool 
 obj := objectPool.Get().(*MyObject)   
 ​ 
 // Use the object   
 ​ 
 // Put the object back into the pool 
 objectPool.Put(obj)

Ensuring One-Time Execution with sync.Once

sync.Once ensures that an operation is executed only once, regardless of how many goroutines attempt to execute it. It's commonly used for initialization or loading resources.

var once sync.Once   
 var resource *Resource   
 ​ 
 func getResource() *Resource {   
     once.Do(func() {   
         // Perform resource initialization, executed only once 
         resource = initResource()   
     })   
     return resource   
 }   
 ​ 
 // Get the resource in multiple goroutines 
 go func() {   
     res := getResource()   
     // Use the resource   
 }()   
 ​ 
 go func() {   
     res := getResource()   
     // Use the resource   
 }()

Resource Cleanup with sync.Once and context.Context

Combine sync.Once and context.Context to ensure a resource cleanup operation is executed only once across multiple goroutines, triggered on cancellation or timeout.

var once sync.Once   
 ​ 
 func cleanup() {   
     // Perform resource cleanup   
 }   
 ​ 
 func doTask(ctx context.Context) {   
     go func() {   
         select {   
         case <-ctx.Done():   
             once.Do(cleanup) // Execute resource cleanup only once 
         }   
     }()   
 ​ 
     // Asynchronous task logic 
 }

Concurrent Safe Maps with sync.Map

sync.Map is a concurrent-safe map type in the

Go standard library, enabling safe read and write operations across multiple goroutines.

var m sync.Map   
 ​ 
 // Store key-value pairs 
 m.Store("key", "value")   
 ​ 
 // Retrieve values 
 if val, ok := m.Load("key"); ok {   
     // Use the value   
 }   
 ​ 
 // Delete a key 
 m.Delete("key")

Managing Goroutines and Cancellation with context.Context

context.Context is used to pass context information between goroutines and can be used for cancellation or timeout control. Use context.WithCancel() to create a cancellable context, and context.WithTimeout() to create a context with a timeout.

ctx, cancel := context.WithCancel(context.Background())   
 ​ 
 go func() {   
     // Asynchronous task logic   
     if someCondition {   
         cancel() // Cancel the task   
     }   
 }()   
 ​ 
 // Wait for the task to complete or be canceled 
 select {   
 case <-ctx.Done():   
     // Task canceled or timed out   
 }

Setting Deadlines with context.WithDeadline() and context.WithTimeout()

context.WithDeadline() and context.WithTimeout() functions create contexts with deadlines to limit the execution time of asynchronous tasks.

func doTask(ctx context.Context) {   
     // Asynchronous task logic   
 ​ 
     select {   
     case <-time.After(5 * time.Second):   
         // Handle timeout   
     case <-ctx.Done():   
         // Handle context cancellation   
     }   
 }   
 ​ 
 func main() {   
     ctx := context.Background()   
     ctx, cancel := context.WithTimeout(ctx, 3*time.Second)   
     defer cancel()   
 ​ 
     go doTask(ctx)   
 ​ 
     // Continue with other operations   
 }

Passing Context Values with context.WithValue()

context.WithValue() allows passing key-value pairs within a context, enabling sharing and passing context-related values between goroutines.

type keyContextValue string   
 ​ 
 func doTask(ctx context.Context) {   
     if val := ctx.Value(keyContextValue("key")); val != nil {   
         // Use the context value   
     }   
 }   
 ​ 
 func main() {   
     ctx := context.WithValue(context.Background(), keyContextValue("key"), "value")   
     go doTask(ctx)   
 ​ 
     // Continue with other operations   
 }

Atomic Operations with the atomic Package

The atomic package provides functions for atomic operations, ensuring atomicity in read and write operations on shared variables in concurrent environments.

var counter int64 
 ​ 
 func increment() { 
     atomic.AddInt64(&counter, 1) 
 } 
 ​ 
 func main() { 
     var wg sync.WaitGroup 
     for i := 0; i < 100; i++ { 
         wg.Add(1) 
         go func() { 
             defer wg.Done() 
             increment() 
         }() 
     } 
     wg.Wait() 
     fmt.Println("Counter:", counter) 
 }

Summary

In this article, we introduce some commonly used keywords. Mastering these keywords will make it easy to deal with common concurrent programming. I believe you have also discovered that the go source code does not provide the commonly used Barrier function in java and c#. Although we can also implement the Barrier function using basic concurrent statements, it is not as convenient as calling the ready-made API interface. In fact, there is SingleFlight in golang.org/x and the third-party CyclicBarrier. In the next article, we will introduce these two concurrent primitives.