In-depth understanding of concurrent programming in Go language [28] [concurrency security and locks, Sync]


Concurrency Safety and Locks

Sometimes in Go code, there may be multiple goroutines operating a resource (critical section) at the same time. In this case, a race problem (data race) will occur. Analogous to examples in real life, intersections are competed by cars from all directions; toilets on trains are competed by people in the carriages.

for example:

var x int64
var wg sync.WaitGroup

func add() {
    
    
    for i := 0; i < 5000; i++ {
    
    
        x = x + 1
    }
    wg.Done()
}
func main() {
    
    
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
} 

In the above code, we have started two goroutines to accumulate the value of the variable x. When these two goroutines access and modify the x variable, there will be a data competition, which will cause the final result to be inconsistent with the expectation.

mutex

Mutex is a commonly used method to control access to shared resources. It can ensure that only one goroutine can access shared resources at the same time. The Mutex type of the sync package is used in the Go language to implement mutual exclusion locks. Use a mutex to fix the problem with the code above:

var x int64
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
    
    
    for i := 0; i < 5000; i++ {
    
    
        lock.Lock() // 加锁
        x = x + 1
        lock.Unlock() // 解锁
    }
    wg.Done()
}
func main() {
    
    
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
} 

Using a mutex can ensure that only one goroutine enters the critical section at the same time, and other goroutines are waiting for the lock; when the mutex is released, the waiting goroutine can acquire the lock and enter the critical section, and multiple goroutines wait for a lock at the same time When , the wake-up strategy is random.

read-write mutex

Mutex locks are completely mutually exclusive, but in many actual scenarios, more reads are used than writes. When we read a resource concurrently and do not involve resource modification, there is no need to lock it. In this scenario, use A read-write lock is a better choice. Read-write locks use the RWMutex type in the sync package in the Go language.

There are two types of read-write locks: read locks and write locks. When a goroutine acquires a read lock, other goroutines will continue to acquire the lock if they acquire a read lock, and wait if they acquire a write lock; when a goroutine acquires a write lock, other goroutines will either acquire a read lock or a write lock wait.

Read-write lock example:

var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)

func write() {
    
    
    // lock.Lock()   // 加互斥锁
    rwlock.Lock() // 加写锁
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
    rwlock.Unlock()                   // 解写锁
    // lock.Unlock()                     // 解互斥锁
    wg.Done()
}

func read() {
    
    
    // lock.Lock()                  // 加互斥锁
    rwlock.RLock()               // 加读锁
    time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
    rwlock.RUnlock()             // 解读锁
    // lock.Unlock()                // 解互斥锁
    wg.Done()
}

func main() {
    
    
    start := time.Now()
    for i := 0; i < 10; i++ {
    
    
        wg.Add(1)
        go write()
    }

    for i := 0; i < 1000; i++ {
    
    
        wg.Add(1)
        go read()
    }

    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

It should be noted that read-write locks are very suitable for scenarios with more reads and fewer writes. If the difference between read and write operations is not large, the advantages of read-write locks will not be fully utilized.

Sync

sync.WaitGroup

It is definitely inappropriate to use time.Sleep bluntly in the code. In Go language, sync.WaitGroup can be used to realize the synchronization of concurrent tasks. sync.WaitGroup has the following methods:

method name Function
(wg * WaitGroup) Add(delta int) counter+delta
(wg *WaitGroup) Done() counter-1
(wg *WaitGroup) Wait() Block until the counter reaches 0

sync.WaitGroup maintains a counter internally, and the value of the counter can be increased and decreased. For example, when we start N concurrent tasks, we increase the counter value by N. The counter is decremented by 1 when each task is completed by calling the Done() method. Wait for the execution of concurrent tasks by calling Wait(). When the counter value is 0, it means that all concurrent tasks have been completed.

We use sync.WaitGroup to optimize the above code:

var wg sync.WaitGroup

func hello() {
    
    
    defer wg.Done()
    fmt.Println("Hello Goroutine!")
}
func main() {
    
    
    wg.Add(1)
    go hello() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
    wg.Wait()
}

It should be noted that sync.WaitGroup is a structure, and a pointer must be passed when passing it.

sync.Once

In the previous words: this is an advanced knowledge point.

In many scenarios of programming, we need to ensure that certain operations are only performed once in high concurrency scenarios, such as loading configuration files only once, closing channels only once, etc.

The sync package in the Go language provides a solution for only one execution scenario – sync.Once.

sync.Once has only one Do method whose signature is as follows:

func (o *Once) Do(f func()) {
    
    } 

Note: If the function f to be executed needs to pass parameters, it needs to be used with a closure.

Load configuration file example

It is good practice to delay an expensive initialization operation until it is actually needed. Because pre-initializing a variable (such as completing initialization in the init function) will increase the startup time of the program, and this variable may not be used during actual execution, then this initialization operation is not necessary. Let's look at an example:

var icons map[string]image.Image

func loadIcons() {
    
    
    icons = map[string]image.Image{
    
    
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
    
    
    if icons == nil {
    
    
        loadIcons()
    }
    return icons[name]
}

When multiple goroutines call the Icon function concurrently, it is not concurrently safe. Modern compilers and CPUs may freely rearrange the order of accessing memory on the basis of ensuring that each goroutine satisfies serial consistency. The loadIcons function may be rearranged to the following result:

func loadIcons() {
    
    
    icons = make(map[string]image.Image)
    icons["left"] = loadIcon("left.png")
    icons["up"] = loadIcon("up.png")
    icons["right"] = loadIcon("right.png")
    icons["down"] = loadIcon("down.png")
} 

In this case, even if it is judged that the icons are not nil, it does not mean that the variable initialization is complete. Considering this situation, the only way we can think of is to add a mutex to ensure that icons will not be operated by other goroutines when initializing them, but doing so will cause performance problems.

The sample code transformed using sync.Once is as follows:

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    
    
    icons = map[string]image.Image{
    
    
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 是并发安全的
func Icon(name string) image.Image {
    
    
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

sync.Once actually contains a mutex and a Boolean value inside. The mutex ensures the security of the Boolean value and data, and the Boolean value is used to record whether the initialization is completed. This design can ensure that the initialization operation is concurrently safe and the initialization operation will not be executed multiple times.

sync.Map

The built-in map in Go language is not concurrency safe. Take a look at the example below:

var m = make(map[string]int)

func get(key string) int {
    
    
    return m[key]
}

func set(key string, value int) {
    
    
    m[key] = value
}

func main() {
    
    
    wg := sync.WaitGroup{
    
    }
    for i := 0; i < 20; i++ {
    
    
        wg.Add(1)
        go func(n int) {
    
    
            key := strconv.Itoa(n)
            set(key, n)
            fmt.Printf("k=:%v,v:=%v\n", key, get(key))
            wg.Done()
        }(i)
    }
    wg.Wait()
}

When the above code starts a few goroutines, there may be no problem. When the above code is executed after more concurrency, a fatal error: concurrent map writes error will be reported.

In such a scenario, it is necessary to lock the map to ensure the security of concurrency. The sync package of the Go language provides an out-of-the-box concurrent security version map-sync.Map. Out of the box means that it can be used directly without using the make function initialization like the built-in map. At the same time, sync.Map has built-in operation methods such as Store, Load, LoadOrStore, Delete, and Range.

var m = sync.Map{
    
    }

func main() {
    
    
    wg := sync.WaitGroup{
    
    }
    for i := 0; i < 20; i++ {
    
    
        wg.Add(1)
        go func(n int) {
    
    
            key := strconv.Itoa(n)
            m.Store(key, n)
            value, _ := m.Load(key)
            fmt.Printf("k=:%v,v:=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
} 

Guess you like

Origin blog.csdn.net/m0_52896752/article/details/129797883