GO语言进阶下| 豆包MarsCode AI刷题

74 阅读8分钟

1.3 Channel(通道) 特性

image-20241116225725997

这张图片讲解了 Go 语言中的 Channel(通道) 特性,特别是无缓冲通道与有缓冲通道的区别。

1. Channel 基本概念

在 Go 语言中,Channel 是用于 Goroutine 之间进行通信的工具。可以使用 make() 函数创建 Channel,语法如下:

 make(chan 元素类型, [缓冲大小])
  • 无缓冲通道make(chan int),没有缓冲区,发送和接收必须同步进行。
  • 有缓冲通道make(chan int, 2),有缓冲区,可以存储一定数量的元素,发送和接收可以异步进行。

2. 无缓冲通道(左图)

  • 无缓冲通道无法存储数据,发送方和接收方必须 同步
  • Goroutine 1 向 Channel 发送数据时,必须等待 Goroutine 2 准备好接收数据,数据才能传递。
  • 在这种情况下,发送和接收操作是阻塞的。如果没有对应的接收方,发送操作会一直阻塞,直到有 Goroutine 准备接收数据为止。
  • 适合用于 Goroutine 之间的同步操作。

图示解释:

  • 左侧图中,Goroutine 1 和 Goroutine 2 之间通过一个无缓冲的 Channel 进行通信,发送方和接收方必须同时准备好。
  • 图中展示了一个箭头,表示数据从 Goroutine 1 直接传递给 Goroutine 2。

3. 有缓冲通道(右图)

  • 有缓冲通道可以存储一定数量的数据,发送方不需要等待接收方立即接收。
  • 在缓冲区未满时,发送操作不会阻塞;只有当缓冲区满了,发送操作才会阻塞。
  • 同样,接收方只有在缓冲区为空时才会阻塞,其他情况下可以直接从缓冲区取数据。
  • 适合用于需要异步通信的场景,提高 Goroutine 之间的解耦和效率。

图示解释:

  • 右侧图中,Goroutine 1 和 Goroutine 2 之间通过一个有缓冲的 Channel 进行通信。
  • Channel 中有一个缓冲区,包含两个存储格子。发送方可以存储数据,而不必等待接收方立即接收。
  • 图中显示了数据进入缓冲区,然后再从缓冲区传递给接收方 Goroutine 2。

总结:

  • 无缓冲通道:发送和接收必须同时发生,适合用于 Goroutine 同步。
  • 有缓冲通道:允许发送方和接收方异步工作,提高并发性能,适合异步通信。

这两种通道在并发编程中各有优势,可以根据具体需求选择合适的类型。

1.4并发安全LOCK

这段代码展示了 Go 语言中 并发安全问题 以及使用 sync.Mutex 来解决数据竞争的情况。

1. 代码概述

  • x 是全局变量,所有 Goroutine 都会对其进行修改。

  • lock 是一个 sync.Mutex 类型的互斥锁,用于保护对共享变量 x 的并发访问。

  • 代码包含两个函数:

    • addWithLock() 使用互斥锁来保护对 x 的修改。
    • addWithoutLock() 不使用锁,直接对 x 进行修改。
  • Add() 函数中,分别启动 5 个 Goroutine 来调用这两个函数,测试加锁与不加锁情况下的执行结果。

2. 代码解析

全局变量定义

 var (
     x    int64
     lock sync.Mutex
 )
  • x 是一个全局整数变量,用于存储计算结果。
  • lock 是一个互斥锁,用于保证对 x 的并发操作是安全的。

addWithLock() 函数

 func addWithLock() {
     for i := 0; i < 2000; i++ {
         lock.Lock()
         x += 1
         lock.Unlock()
     }
 }
  • lock.Lock() 加锁,防止其他 Goroutine 同时修改 x
  • x += 1 是对共享变量 x 的修改操作。
  • lock.Unlock() 解锁,允许其他 Goroutine 继续操作。
  • 使用互斥锁确保了 x 的修改是线程安全的。

addWithoutLock() 函数

 func addWithoutLock() {
     for i := 0; i < 2000; i++ {
         x += 1
     }
 }
  • 这个函数没有加锁,直接修改 x
  • 在并发环境下,这种情况下会出现 数据竞争,导致 x 的结果不可预测。

Add() 主函数

 func Add() {
     x = 0
     for i := 0; i < 5; i++ {
         go addWithoutLock()
     }
     time.Sleep(time.Second)
     println("WithoutLock:", x)
 ​
     x = 0
     for i := 0; i < 5; i++ {
         go addWithLock()
     }
     time.Sleep(time.Second)
     println("WithLock:", x)
 }
  • 第一部分

    • 重置 x = 0
    • 启动 5 个 Goroutine,每个 Goroutine 调用 addWithoutLock(),并对 x 进行修改。
    • 使用 time.Sleep(time.Second) 等待 Goroutine 完成(这不是最佳的同步方式,但在示例中用于简单处理并发)。
    • 打印出结果 WithoutLock: x
  • 第二部分

    • 重置 x = 0
    • 启动 5 个 Goroutine,每个 Goroutine 调用 addWithLock(),并对 x 进行修改。
    • 使用 time.Sleep(time.Second) 等待 Goroutine 完成。
    • 打印出结果 WithLock: x

3. 运行结果分析

 WithoutLock: 8382
 WithLock: 10000
  • WithoutLock

    • 理论上应该执行 5 * 2000 = 10000 次加法操作。
    • 但是由于没有加锁,多个 Goroutine 同时修改 x 时会产生 数据竞争,导致结果错误。
    • 在本例中输出 8382,说明出现了数据丢失的情况。
  • WithLock

    • 使用了互斥锁,保证了每次对 x 的修改都是线程安全的。
    • 输出结果为 10000,符合预期。

4. 并发安全问题解释

  • 在并发环境下,多个 Goroutine 同时对共享变量进行操作时,如果没有合适的同步机制(如锁、原子操作等),就会出现 数据竞争 问题。
  • 数据竞争会导致程序的执行结果不可预测,出现数据丢失、错误计算等情况。
  • 通过 sync.Mutex 互斥锁,确保了在任何时刻只有一个 Goroutine 能访问和修改共享变量,从而避免了数据竞争。

5. sync.Mutex 使用总结

  • Lock():获取锁。如果锁已经被其他 Goroutine 持有,当前 Goroutine 会阻塞,直到锁被释放。
  • Unlock():释放锁。只有持有锁的 Goroutine 才能释放锁,否则会导致程序 panic。

6. 注意事项

  • 死锁:如果一个 Goroutine 获得锁后没有调用 Unlock() 释放锁,会导致其他 Goroutine 永远阻塞,出现死锁问题。
  • 性能问题:频繁使用锁会降低并发性能,特别是在锁竞争激烈的情况下。

总结

  • 使用锁可以解决并发安全问题,但也会带来性能开销。
  • 在实际应用中,需要根据具体场景选择合适的并发控制策略,如锁、原子操作、Channel 等。
  • 这段代码很好地展示了数据竞争的危害,以及使用互斥锁来保护共享资源的重要性。

1.5WaitGroup

image-20241117122155144

这段代码展示了 Go 语言中 Goroutine 的并发执行,并指出了常见的并发陷阱 —— 循环变量捕获问题

代码解析

hello() 函数

 func hello(i int) {
     println("hello goroutine:", i)
 }
  • 这是一个简单的函数,接受一个整数参数 i 并打印 hello goroutine: i

HelloGoRoutine() 函数

 func HelloGoRoutine() {
     for i := 0; i < 5; i++ {
         go func(j int) {
             hello(j)
         }(i)
     }
     time.Sleep(time.Second)
 }
  • 使用 for 循环启动了 5 个 Goroutine。
  • go 关键字启动了匿名函数,每次循环传递当前的 i 值作为参数 j 给匿名函数。
  • time.Sleep(time.Second) 用于等待所有 Goroutine 执行完毕(不推荐这种方式,通常使用 sync.WaitGroup 更合适)。

输出解释

 hello goroutine: 4
 hello goroutine: 1
 hello goroutine: 0
 hello goroutine: 2
 hello goroutine: 3

为什么输出顺序不一致?

  • Goroutine 是并发执行的,启动 Goroutine 后并不会等待其执行完毕,主 Goroutine 会继续向下执行。
  • 因为 Goroutine 的调度顺序是不确定的,所以 hello goroutine: i 的打印顺序也是不确定的。
  • 在不同的运行环境和 CPU 调度下,打印的顺序可能会有所不同。

为什么没有出现循环变量捕获问题?

  • 在这段代码中,匿名函数使用了参数 (j int),并将 i 的值在启动 Goroutine 时通过参数传递给 j
  • 这样可以避免循环变量捕获问题。因为 j 是函数参数,它的值在函数调用时已经固定,不会受到循环中 i 值变化的影响。

循环变量捕获问题的解释

如果我们改成如下代码,会出现循环变量捕获问题:

 for i := 0; i < 5; i++ {
     go func() {
         hello(i)  // 直接使用循环变量 i
     }()
 }
  • 在这种情况下,匿名函数直接使用了循环变量 i,而不是通过参数传递。
  • 当 Goroutine 实际执行时,循环可能已经结束,i 的值变为 5
  • 结果可能会打印出相同的值 hello goroutine: 5,因为所有 Goroutine 都捕获了同一个循环变量 i

正确的 Goroutine 启动方式

通过将循环变量作为参数传递给匿名函数,可以避免这个问题:

 for i := 0; i < 5; i++ {
     go func(j int) {
         hello(j)
     }(i)  // 将 i 作为参数传递
 }

time.Sleep() 的问题

  • time.Sleep(time.Second) 是用来等待 Goroutine 执行完毕的,但这种方式并不可靠。
  • 在高并发场景下,可能会出现 Goroutine 尚未执行完,主 Goroutine 已经结束的情况。
  • 推荐使用 sync.WaitGroup 来等待所有 Goroutine 完成。

使用 sync.WaitGroup 的改进版

 func HelloGoRoutine() {
     var wg sync.WaitGroup
     for i := 0; i < 5; i++ {
         wg.Add(1)
         go func(j int) {
             defer wg.Done()
             hello(j)
         }(i)
     }
     wg.Wait()
 }
  • 使用 sync.WaitGroup 来跟踪 Goroutine 的完成状态。
  • wg.Add(1) 为每个启动的 Goroutine 增加计数。
  • defer wg.Done() 在 Goroutine 完成时减少计数。
  • wg.Wait() 阻塞主 Goroutine,直到所有 Goroutine 完成。

改进后的输出示例

 hello goroutine: 0
 hello goroutine: 1
 hello goroutine: 2
 hello goroutine: 3
 hello goroutine: 4
  • 由于使用了 WaitGroup,输出结果更加稳定,所有 Goroutine 都会执行完毕,且不会出现循环变量捕获问题。

总结

  • 在 Go 中启动 Goroutine 时,避免直接捕获循环变量,最好通过参数传递。
  • 使用 sync.WaitGroup 可以更安全、可靠地等待所有 Goroutine 执行完毕。
  • Go 语言中的并发编程需要特别注意数据竞争和变量捕获等常见问题,以确保程序的正确性和稳定性。