Go Goroutine

大家好,我是行舟,今天我们将一起探讨go语言中的goroutine。

Goroutine是Go语言中的协程概念,是Go语言实现并发编程的主要方式。要全面理解Goroutine,我们需要从操作系统的进程和线程开始讲起。

Goroutine的概念大家可能已经有所了解,操作系统中存在进程和线程的概念。

进程是操作系统分配资源的最小单位。创建一个进程需要为其分配独立的存储空间和CPU。进程对CPU的使用是分时间片进行的。线程是进程的子任务,是操作系统的最小调度单位。创建一个线程只需要少量内存和寄存器组。同一进程内的线程可以共享内存。

相比之下,创建和销毁进程的开销远大于线程。创建一个进程通常需要数G的虚拟内存,而创建一个线程只需几M就足够了。进程切换的开销也比线程大得多,因为进程切换涉及CPU环境的保存,而线程只需保存当前调用和少量寄存器内容。进程和线程的通信方式不同,尽管进程可以通过共享内存进行通信,但这种方式与同一进程内线程之间的通信效率相近。多进程系统更适合高密度计算的场景,而多线程系统则更适合高I/O操作的场景。尽管操作系统已经提供了进程和线程,但Go语言的开发者认为线程的创建和切换成本仍然过高,因此在语言层面上引入了一种新的封装,即Go协程。创建一个协程仅需几KB的内存,协程的上下文切换成本更低,只需要切换更少的栈信息和寄存器信息。Go语言中有一个调度模型来决定哪个操作系统线程来实际执行协程。总结来说,Go协程、线程和进程的关系如下图所示。

Go Goroutine

Go语言为Goroutine的使用提供了非常简便的方法。以下是示例1:

func helloWorld()  {   println("Hello World")} func main()  {   go helloWorld() // 启动一个协程执行helloWorld方法}

在上述示例中,go helloWorld()使用go关键字后面跟随要执行的方法,即可创建一个Goroutine并执行指定的方法。

然而,运行上述代码时我们会发现没有任何输出。这是为什么呢?这是因为:启动一个Goroutine后,它会立即执行,而我们的代码也会继续执行,不会等待Goroutine返回。因此,在这个例子中,执行go helloWorld()后,不等打印出Hello World,代码就继续往下执行了。执行到下一句没有代码时,main方法就会退出。实际上,main方法在Go语言中也是一个Goroutine,只不过它比较特殊,如果main Goroutine终止执行,整个Go语言进程也会退出,其他Goroutine也会被终止。因此,在这个例子中,Hello World还没有来得及被打印,main Goroutine就已经终止了,我们也就看不到任何输出。

以下是示例2:

func helloWorld()  {   println("Hello World")} func main()  {   go helloWorld() // 启动一个协程执行helloWorld方法   time.Sleep(100*time.Millisecond)}

我们修改了示例1中的代码,在main函数退出之前进行100ms的等待。再次运行代码,我们会看到控制台输出Hello World,并在约100ms后程序退出。

在示例2中,我们已经成功输出了Hello World字符串,但我们是通过让程序等待100ms的方式来完成字符串输出的。在实际开发过程中,我们无法确定自己编写的程序需要多久才能执行完成,更多时候需要程序在执行完成后自动执行下一个动作。我们可以借助Go语言sync包中的WaitGroup来实现这种效果。以下是示例3:

func helloWorld(wg *sync.WaitGroup)  {   println("Hello World")   defer wg.Done()} func main()  {   var wg sync.WaitGroup   wg.Add(1)   go helloWorld(&wg) // 启动一个协程执行helloWorld方法   wg.Wait() // 阻塞执行直到接收到done信号}

我们声明了wg并调用Add方法加1,当代码执行到wg.Wait()时会阻塞等待,helloWorld方法中执行wg.Done()方法后,解除wg.Wait()的阻塞,代码继续执行。

接下来看下面的例子。以下是示例4:

func main()  {   var wg sync.WaitGroup   for i:=0; i<10; i++ {      wg.Add(1)      go func() {         println(i)         defer wg.Done()      }()   }   wg.Wait()}

运行上述代码时,我们会发现控制台并不能输出0-9的所有值,而且每次调用输出的值都不确定。这是因为go关键字后面的匿名函数是在Goroutine中执行的,不会阻塞for循环的执行。那如果我们想输出0-9全部数字,该怎么办呢?以下是示例5:

func main()  {   var wg sync.WaitGroup   for i:=0; i<10; i++ {      wg.Add(1)      go func(i int) {         println(i)         defer wg.Done()      }(i)   }   wg.Wait()}

我们将i作为参数传递给匿名函数,因为Go语言中的函数传值都是值传递,所以0-9十个数字都会被打印出来。

尽管Goroutine使用起来非常简便,但在使用时我们还是需要谨慎,以免造成Goroutine泄漏。如果我们创建了一个Goroutine,但由于某些原因导致它永远不会退出,那么为此Goroutine分配的内存就永远不会释放,我们称这种情况为Goroutine泄漏。要防止Goroutine泄漏,我们在创建Goroutine时必须考虑它何时退出。以下是示例6:

func leak() {   c := make(chan int)   go func() {      val := <-c      println(val)   }()}

如上示例,我们定义了一个名为leak的方法,调用leak方法时会创建一个Goroutine。此Goroutine内部接收通道c的值,当接收到c的值后,读取并完成打印。因为没有代码为c通道传递值,所以每次调用leak方法都会生成一个永远无法结束的Goroutine。这就是非常简单的Goroutine泄漏。

接下来,我们借鉴网上的一个例子。以下是示例7:

func search(term string) (string, error) {   // 模拟一段查询逻辑   time.Sleep(200 * time.Millisecond)   return "some value", nil} // 查询结果 type result struct {     record string     err    error} // 100ms之后自动退出的一段代码。 func process(term string) error {   // 通过Context包,实现100ms自动退出逻辑   ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)   defer cancel()      // 创建一个无缓冲的通道,返回执行结果   ch := make(chan result)      go func() {      record, err := search(term)      ch <- result{record, err}   }()      select {   case res := <-ch:      println(res.record)      return res.err   case <-ctx.Done():      return errors.New("process timed out")   }} func main() {   process("some term")}

如上示例,search方法代表耗时一段时间执行查询逻辑,result是查询返回的结果。在process方法中,通过Context包实现100ms限时返回的逻辑。然后定义无缓冲通道ch,启动Goroutine执行search方法并通过channel返回结果。select阻塞代码,直到ch通道有结果时返回结果或者等到100ms执行ctx.Done()退出函数。

如上面我们构造的例子中,search方法需要200ms才能返回结果,所以process在100ms时就退出执行了,此时接收通道异常停止继续接收数据,就会造成发送方阻塞,process中启动的Goroutine则无法回收,就产生了Goroutine泄漏。我们执行main方法,发现最终Goroutine的数量是2。

解决泄漏最简单的办法就是修改ch通道为缓冲区为1的通道,我们修改ch := make(chan result,1),此时Goroutine通过将结果值放入通道完成发送操作后返回。再次执行代码,发现Goroutine的数量是1。

Go语言的调度器内容较多,建议大家阅读这篇文章:https://www.php.cn/link/87acc42a82fb7ace361a4c390d047f74

总结本文主要介绍了Go语言选择Goroutine的原因、Goroutine的基本用法和注意事项。如果大家对文章内容有任何疑问或建议,欢迎私信交流。

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享