Go语言快速之谜 | 豆包 Mars Code AI 刷题

34 阅读6分钟

Go语言快速之谜

走入并发编程


执行原理

  • 并发模型: Go 语言内置了强大的并发支持,通过轻量级的协程(goroutine)和通道(channel)机制,使得编写并发程序变得非常容易和高效。协程比传统的线程更加轻量级,创建和销毁的开销很小,可以轻松创建成千上万个协程来并发执行任务,而不会像线程那样给系统带来过大的资源负担。同时,通道提供了一种安全、高效的方式来在协程之间进行通信和同步,避免了共享内存带来的并发问题(如数据竞争等),使得并发程序的编写更加简洁和可靠。这种高效的并发模型使得 Go 语言能够充分利用多核处理器的优势,提高程序的执行效率,尤其是在处理大量并发任务(如网络请求处理、分布式系统中的任务调度等)时表现得尤为出色。
  • 并行支持: Go 语言能够很好地利用现代计算机的多核处理器,实现真正的并行计算。当有多个协程需要执行时,Go 运行时系统会自动将它们分配到不同的处理器核心上并行执行,从而加速程序的运行。通过合理地设计和组织代码,将任务分解为多个可以并行执行的子任务,Go 语言可以最大限度地发挥多核处理器的性能,提高程序的整体吞吐量。

怎么使用goroutine

  • 让我们从一段简单的代码开始了解,示例
    • 注意行14里的go关键字,我们用它启动了后面的隐式函数。然而不像一般情况下的for循环执行完内部的循环节后再进入下一轮循环,程序启动了goroutine后将立刻继续执行下面的代码,以利用并发执行极大地缩小总的运行时间
    • 行18 用于让程序"睡着"一秒钟,以等待所有goroutine执行完毕
  • 结果如图,运行结果
    • 可以看到输出是无序的,这是由于调度器完全按心情决定把宝贵的CPU时间片分给哪个goroutine,谁先分到,谁先做完,自然谁就输出

可怕的数据竞争

  • 上一节提到,多个goroutine并发执行时不会确保顺序,这可能会导致一些问题,如下,不使用锁
    • 我们在五个分列的goroutine中执行2000次**+1**操作,理论上会得到10000.但真的是这样吗?让我们try一try,错误?
    • 我们可以看到结果并不是10000什么鬼?
    • 再来一次,:sweat:结果
    • 可以看到结果并不是10000,这就是问题所在——并发运行的goroutine发生了数据竞争
  • 在 Go 语言中,x += 1 这一操作并不是原子性的,它实际上包含了三个步骤:读取 x 的当前值、将读取的值加 1、把加 1 后的结果再写回 x。当多个 goroutine 并发执行 addWithoutLock 函数时,就可能出现这样的情况:假设当前 x 的值为 10,goroutine A 执行到了读取 x 的值这一步(此时读到的值是 10),还没来得及进行加 1 和写回操作,goroutine B 也执行到了读取 x 的值这一步(同样读到 10),然后 goroutine A 继续执行,完成加 1 并写回 11,接着 goroutine B 也完成加 1 操作(它基于之前读到的 10 进行加 1),写回 11,这样就相当于少加了一次,本该得到 12,但实际只得到了 11,这就是数据竞争导致的错误结果。在复杂的生产环境下,数据竞争常常能导致难以排查且危害重大的后果,因此保证并发安全对于我们而言非常重要

确保并发安全

  • 为了避免产生数据竞争,我们可以使用一些方法,这里我们使用Mutex,WaitGroupChannel分别举例:
    1. Mutex(互斥锁)
    • Mutex是sync包下的一个类型,用于声明一个互斥锁,加锁

      • 调用lock.Lock()会在获取变量x之前给它上锁,阻止其他协程对x的获取,再在使用完成后释放x的权限
    • 看看修改后的输出,对比结果

    • Mutex锁对并发编程中的“平行”协程非常有效,可以阻止共用一些共享数据的协程之间发生数据竞争

    1. WaitGroup
    • 多个协程间除了平行还可能存在“分层”结构,如果处理不当也会发生数据竞争,如果上层协程需要等待下层协程的工作完成才能执行,互斥锁便无法很好的解决,这时就可以用到WaitGroup。下面给出一个示例,WG
      • Add方法用于给计时器wg增加一个值,Done方法用于减少计时器的值,Wait则会阻塞进程直到计时器归零,使用这种办法能很好的协调内外层协程的进行顺序

      • 从输出也可以看到,函数确实按我们需要的顺序在五个分协程执行完毕后执行了主协程

    1. Channel
    • Channel是golang内置的一种类型,用于生成不同goroutine之间数据传递的通道。他的形式包括两种:无缓冲channel有缓冲channelchannel在接收和发送数据时是阻塞的,即如果另一端(或缓冲区)没有发送或接收数据则等待。通过这个特性便可以实现goroutine的分层执行。下给示例,通道

    • 这里同时实践了两种通道,让我们逐步分析

      • 使用make关键字创建channel类型,此处lowRoutine是一个无缓冲通道,topRoutine是一个缓冲区为3的有缓冲通道。对通道的操作包括发送接收关闭
      • 行18 此处的**<-表示发送i到通道lowRoutine中(lowRoutine接收i**)
      • 虽然两层goroutine是并发的,但对于topRoutine,它在包含了lowRoutine的循环内,则lowRoutine还没有接收时循环进入阻塞,保证了只有lowRoutine接收到数据后topRoutine才开始接收,也就保证了goroutine的先后顺序
    • 运行一下,结果

    • 确实得到了按序输出的结果


一些小细节:thinking:

  • 有没有发现有一个一开始的函数中使用过,却在后面慢慢走出我们世界的方法?
time.Sleep(time.Second)
  • 这个方法阻塞了当时的主routine1秒钟,为其他routine争取了宝贵的运行时间,但这个方法并不优雅,因为我们事实上不能确定routine应该运行的时间。
  • 你可能会注意到,后面我们实现同一个作用的方法就是WaitGroup,它动态地确定了routine们运行结束的时间,从而优雅地完成了阻塞主函数的任务,这在实际的工程中更加实用高效。对于可能出现错误的工程,如果好好的看了上一篇文章,你会记得有一个名叫Context的类型,合理地运用ctx,把握设置**cancel()**的时机,你也能轻松应对这种状况。祝好运

今日bu推荐:可怕的杭电人