zl程序教程

您现在的位置是:首页 >  后端

当前栏目

Go Goroutine

Go Goroutine
2023-06-13 09:11:27 时间

Hi,我是行舟,今天和大家一起学习Go语言的Goroutine。

Goroutine又叫Go语言的协程。Goroutine是Go语言用来实现并发的直接方法。要想完全理解Goroutine必须从操作系统的进程和线程开始说起。

什么是Goroutine

大家都知道操作系统中有进程和线程。

进程是操纵系统分配资源的最小单位。在操作系统中创建一个进程要为它分配独立的存储空间和CPU。进程对CPU的占用并不是持续的,而是分时间片使用。线程是隶属于某个进程的子任务,是操作系统最小的调度单位 。创建一个线程需要占用少量内存和寄存器组。同一个进程中的线程可以共享内存。

进程和线程相比主要有以下区别,这些不同点也决定了多进程和多线程编程分别适用不同的应用场景。

  1. 进程创建和销毁的开销要远远大于线程。创建一个进程通常需要上G的虚拟内存,而创建一个线程只需要几M就够了。
  2. 进程切换开销也远大于线程。进程切换涉及CPU环境的保存开销较大,线程只需保存当前调用栈的内容和少量的寄存器内容。
  3. 进程之间的通信方式和线程之间的通信方式也不一样。需要注意的是进程之间可以通过共享内存的方式通讯,这种通讯方式和同一个进程中多个线程之间的通讯效率上差别不大。
  4. 多进程系统更可靠,能重复利用多个CPU及其内核的优势,适合高密度计算的场景。多线程更适合高I/O的场景。

虽然操作系统已经有了进程和线程,但是Go语言的开发者们认为创建线程和线程切换的成本仍然太高还有更多的优化空间。于是在语言层面又做了一层封装叫Go协程。创建一个协程只需要占用几KB的内存,协程的上下文切换成本更低只需要切换更少量的堆栈信息和更少的寄存器信息。Go语言中存在一个调度模型来决定协程让操作系统的哪一个线程去实际执行。总结起来Go协程、线程、进程的关系如下图。

基本用法

Go语言提供了非常方便的Goroutine使用方法。例1:

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

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

但是我们执行上面的代码会发现没有任何内容输出。这是为什么呢?因为:我们启动一个Goroutine之后,Goroutine会立刻执行。同时我们的代码也会继续往后执行不会等待Goroutine返回。所以上面的例子中执go helloWorld()之后不等打印Hello World就继续往下执行了。执行到下一句没有代码了,我们的 main方法就会退出。其实main方法在Golang中也是一个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.Wati()时会阻塞等待,helloWorld方法中执行wg.Done()方法之后,解除wg.Wait()的阻塞,代码继续执行。

接着看下面的例子。 例4:

func main()  {
   var wg sync.WaitGroup
   for i:=0; i<10; i++{
      wg.Add(1)
      go func() {
         defer wg.Done()
         fmt.Println("i=",i) // 输出i的值
      }()
   }
   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) {
         defer wg.Done()
         fmt.Println("i=",i) // 输出i的值
      }(i)
   }
   wg.Wait()
}

把i当作参数,传给匿名函数,因为Go语言中函数传值都是值传递,所以0-9十个数字就都被打印出来了。

因为Goroutine是并发执行的所以不能保证顺序是0-9,只能保证所有数字都被打印。

Goroutine泄漏

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

func leak() {
   c := make(chan int)
   go func() {
      val := <-c
      fmt.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}
   }()
   
   // 阻塞,等到search结果返回值或者超时结束
   select {
      case <-ctx.Done():
       return errors.New("search canceled")
      case result := <-ch:
       if result.err != nil {
          return result.err
       }
       fmt.Println("Received:", result.record)
       return nil
   }
}
func main()  {
   defer func() {
      fmt.Println("Goroutine数量: ", runtime.NumGoroutine())
   }()
   process("abc")
}

如上示例,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://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/

总结

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

我把Go语言基础知识相关文章的Demo都放到了下面这个仓库中,方便大家互相交流学习。https://github.com/jialj/golang-teach