大家都晓得,Go是一种反对并发编程的编程语言,但并发编程也是比较复杂和容易出错的。比方本篇分享的问题:竞态条件和数据竞争的问题。

会产生竞态条件和数据竞争的场景有哪些

  • 多个 goroutine 对同一变量进行读写操作。例如,多个 goroutine 同时对一个计数器变量进行减少操作。
  • 多个 goroutine 同时对同一数组、切片或映射进行读写操作。例如,多个 goroutine 同时对一个切片进行增加或删除元素的操作。
  • 多个 goroutine 同时对同一文件进行读写操作。例如,多个 goroutine 同时向同一个文件中写入数据。
  • 多个 goroutine 同时对同一网络连接进行读写操作。例如,多个 goroutine 同时向同一个 TCP 连贯中写入数据。
  • 多个 goroutine 同时对同一通道进行读写操作。例如,多个 goroutine 同时向同一个无缓冲通道中发送数据或接收数据。
所以,咱们要明确的一点是:只有多个 goroutine 并发拜访了共享资源,就有可能呈现竞态条件和数据竞争。

避坑方法

当初,咱们曾经晓得了。在编写并发程序时,如果不审慎,没有思考分明共享资源的拜访形式和同步机制,那么就会产生竞态条件和数据竞争这些问题,那么如何防止踩坑?防止产生竞态条件和数据竞争的方法有哪些?请看上面:

  • 互斥锁:应用 sync 包中的 Mutex 或者 RWMutex,通过对共享资源加锁来保障同一时间只有一个 goroutine 拜访。
  • 读写锁:应用 sync 包中的 RWMutex,通过读写锁的机制来容许多个 goroutine 同时读取共享资源,然而只容许一个 goroutine 写入共享资源。
  • 原子操作:应用 sync/atomic 包中提供的原子操作,能够对共享变量进行原子操作,从而保障不会呈现竞态条件和数据竞争。
  • 通道:应用 Go 语言中的通道机制,能够将数据通过通道传递,从而防止间接对共享资源的拜访。
  • WaitGroup:应用 sync 包中的 WaitGroup,能够期待多个 goroutine 实现后再继续执行,从而保障多个 goroutine 之间的程序性。
  • Context:应用 context 包中的 Context,能够传递上下文信息并管制多个 goroutine 的生命周期,从而避免出现因为某个 goroutine 阻塞导致整个程序阻塞的状况。

实战场景

  1. 互斥锁

比方在一个Web服务器中,多个goroutine须要同时拜访同一个全局计数器的变量,达到记录网站访问量的目标。

在这种状况下,如果没有对拜访计数器的拜访进行同步和爱护,就会呈现竞态条件和数据竞争的问题。假如有两个goroutine A和B,它们同时读取计数器变量的值为N,而后都减少了1并把后果写回计数器,那么最终的计数器值只会减少1而不是2,这就是一个竞态条件。

为了解决这个问题,能够应用锁等机制来保障拜访计数器的同步和互斥。在Go中,能够应用互斥锁(sync.Mutex)来爱护共享资源。当一个goroutine须要访问共享资源时,它须要先获取锁,而后拜访资源并实现操作,最初开释锁。这样就能够保障每次只有一个goroutine可能访问共享资源,从而防止竞态条件和数据竞争问题。

看上面的代码:

package mainimport ( "fmt" "sync")var count intvar mutex sync.Mutexfunc main() { var wg sync.WaitGroup // 启动10个goroutine并发减少计数器的值 for i := 0; i < 10; i++ {  wg.Add(1)  go func() {   // 获取锁   mutex.Lock()   // 拜访计数器并增加值   count++   // 开释锁   mutex.Unlock()   wg.Done()  }() } // 期待所有goroutine执行结束 wg.Wait() // 输入计数器的最终值 fmt.Println(count)}

在下面的代码中,应用了互斥锁来爱护计数器变量的拜访。每个goroutine在拜访计数器变量之前先获取锁,而后进行计数器的减少操作,最初开释锁。这样就能够保障计数器变量的一致性和正确性,防止竞态条件和数据竞争问题。

具体的思路是,启动每个 goroutine 时调用 wg.Add(1) 来减少期待组的计数器。而后,在所有 goroutine 执行结束后,调用 wg.Wait() 来期待它们实现。最初,输入计数器的最终值。

请留神,这个假如的场景和这个代码示例,仅仅只是是为了演示如何应用互斥锁来爱护共享资源,理论状况可能更加简单。例如,在理论的运维开发中,如果应用锁的次数过多,可能会影响程序的性能。因而,在理论开发中,还须要依据具体情况抉择适合的同步机制来保障并发程序的正确性和性能。
  1. 读写锁

上面是一个应用 sync 包中的 RWMutex 实现读写锁的代码案例:

package mainimport ( "fmt" "sync" "time")var ( count  int rwLock sync.RWMutex)func readData() { // 读取共享数据,获取读锁 rwLock.RLock() defer rwLock.RUnlock() fmt.Println("reading data...") time.Sleep(1 * time.Second) fmt.Printf("data is %d\n", count)}func writeData(n int) { // 写入共享数据,获取写锁 rwLock.Lock() defer rwLock.Unlock() fmt.Println("writing data...") time.Sleep(1 * time.Second) count = n fmt.Printf("data is %d\n", count)}func main() { // 启动 5 个读取协程 for i := 0; i < 5; i++ {  go readData() } // 启动 2 个写入协程 for i := 0; i < 2; i++ {  go writeData(i + 1) } // 期待所有协程完结 time.Sleep(5 * time.Second)}

在这个示例中,有 5 个读取协程和 2 个写入协程,它们都会拜访一个共享的变量 count。读取协程应用 RLock() 办法获取读锁,写入协程应用 Lock() 办法获取写锁。通过读写锁的机制,多个读取协程能够同时读取共享数据,而写入协程则会期待读取协程全副完结后能力执行,从而防止了读取协程在写入协程执行过程中读取到脏数据的问题。

  1. 原子操作

上面是一个应用 sync/atomic 包中提供的原子操作实现并发平安的计数器的代码案例:

package mainimport (    "fmt"    "sync/atomic"    "time")func main() {    var counter int64    // 启动 10 个协程对计数器进行增量操作    for i := 0; i < 10; i++ {        go func() {            for j := 0; j < 100; j++ {                atomic.AddInt64(&counter, 1)            }        }()    }    // 期待所有协程完结    time.Sleep(time.Second)    // 输入计数器的值    fmt.Printf("counter: %d\n", atomic.LoadInt64(&counter))}

在这个示例中,有 10 个协程并发地对计数器进行增量操作。因为多个协程同时对计数器进行操作,如果不应用同步机制,就会呈现竞态条件和数据竞争。为了保障程序的正确性和健壮性,应用了 sync/atomic 包中提供的原子操作,通过 AddInt64() 办法对计数器进行原子加操作,保障了计数器的并发平安。最初应用 LoadInt64() 办法获取计数器的值并输入。

  1. 通道

上面是一个应用通道机制实现并发平安的计数器的代码案例:

package mainimport (    "fmt"    "sync")func main() {    var counter int    // 创立一个有缓冲的通道,容量为 10    ch := make(chan int, 10)    // 创立一个期待组,用于期待所有协程实现    var wg sync.WaitGroup    wg.Add(10)    // 启动 10 个协程对计数器进行增量操作    for i := 0; i < 10; i++ {        go func() {            for j := 0; j < 10; j++ {                // 将增量操作发送到通道中                ch <- 1            }            // 工作实现,向期待组发送信号            wg.Done()        }()    }    // 期待所有协程实现    wg.Wait()    // 从通道中接管增量操作并累加到计数器中    for i := 0; i < 100; i++ {        counter += <-ch    }    // 输入计数器的值    fmt.Printf("counter: %d\n", counter)}

在这个示例中,有 10 个协程并发地对计数器进行增量操作。为了防止间接对共享资源的拜访,应用了一个容量为 10 的有缓冲通道,将增量操作通过通道传递,而后在主协程中从通道中接管增量操作并累加到计数器中。在协程中应用了期待组期待所有协程实现工作,保障了程序的正确性和健壮性。最初输入计数器的值。

  1. WaitGroup

上面是一个应用 sync.WaitGroup 期待多个 Goroutine 实现后再继续执行的代码案例:

package mainimport (    "fmt"    "sync")func main() {    var wg sync.WaitGroup    for i := 1; i <= 3; i++ {        wg.Add(1) // 计数器加1        go func(i int) {            defer wg.Done() // 实现时计数器减1            fmt.Printf("goroutine %d is running\n", i)        }(i)    }    wg.Wait() // 期待所有 Goroutine 实现    fmt.Println("all goroutines have completed")}

在这个示例中,有 3 个 Goroutine 并发执行,应用 wg.Add(1) 将计数器加1,示意有一个 Goroutine 须要期待。在每个 Goroutine 中应用 defer wg.Done() 示意工作实现,计数器减1。最初应用 wg.Wait() 期待所有 Goroutine 实现工作,而后输入 "all goroutines have completed"。

  1. Context

上面是一个应用 context.Context 管制多个 Goroutine 的生命周期的代码案例:

package mainimport (    "context"    "fmt"    "time")func worker(ctx context.Context, id int, wg *sync.WaitGroup) {    defer wg.Done()    fmt.Printf("Worker %d started\n", id)    for {        select {        case <-ctx.Done():            fmt.Printf("Worker %d stopped\n", id)            return        default:            fmt.Printf("Worker %d is running\n", id)            time.Sleep(time.Second)        }    }}func main() {    ctx, cancel := context.WithCancel(context.Background())    var wg sync.WaitGroup    for i := 1; i <= 3; i++ {        wg.Add(1)        go worker(ctx, i, &wg)    }    time.Sleep(3 * time.Second)    cancel()    wg.Wait()    fmt.Println("All workers have stopped")}

在这个示例中,应用 context.WithCancel 创立了一个上下文,并在 main 函数中传递给多个 Goroutine。每个 Goroutine 在一个 for 循环中执行工作,如果收到了 ctx.Done() 信号就结束任务并退出循环,否则就打印出正在运行的信息并期待一段时间。在 main 函数中,通过调用 cancel() 来发送一个信号,告诉所有 Goroutine 结束任务。应用 sync.WaitGroup 期待所有 Goroutine 结束任务,而后输入 "All workers have stopped"。

本文转载于WX公众号:不背锅运维(喜爱的盆友关注咱们):https://mp.weixin.qq.com/s/lYg-GdheztmkTo9mwqHGsg