Go 语言进阶 | 青训营笔记

99 阅读3分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天

1. Goroutine

1.1 快速打印 Hello World

package main  
  
import "fmt"  
  
func main() {  
   for i := 0; i < 10; i++ {  
      go func() {  
         fmt.Println("hello world!")  
      }()  
   }  
}

输出:

进程 已完成,退出代码为 0

很明显,程序并没有成功输出结果就马上结束了。但是,如果有多线程 or 多进程开发经验的小伙伴很快就能发现问题

在协程还没开始执行的时候,主进程因为没有被阻塞,所以主进程很快就结束了。而主进程结束时,会将主进程下的所有子进程和子线程给杀死,所以程序并没有输出

所以我们应该在主进程还没有结束时将其阻塞,以保证协程的正确执行。最简单的方式是在 main 函数结束前,使用 time.sleep() 函数将其阻塞一段时间。

1.2 成功输出

添加 time.sleep() 后的代码

package main  
  
import (  
   "fmt"  
   "time")  
  
func main() {  
   for i := 0; i < 10; i++ {  
      go func() {  
         fmt.Println("hello world!")  
      }()  
   }  
   time.Sleep(2 * time.Second)  
}

程序输出:

hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!

进程 已完成,退出代码为 0

值得注意的是: 在 Go 语言中,使用 time.sleep() 函数指定阻塞时间时,并不能直接传递一个整数指定阻塞时间。而是使用整数乘以一段时间的方式,以指定程序的阻塞时间。

但是,有同学肯定会疑惑:我们如何能知道某个协程的执行时间是多少呢?这种阻塞方式也太不优雅了吧?

至于有没有一种方式能够让我们的主进程能够在子协程执行完成后及时地退出呢? 答案是:当然是有的;我们可以在 main() 函数中设置一个变量,这个变量的值等于需要执行的协程的数量。同时我们在每个协程结束后都对这个变量的数量做一个减一操作,以达到通知 main() 函数该协程执行完毕的目的。最后我们在 main() 中写一个循环来持续监听这个变量的值,如果这个变量归零了,就表示该函数调用的协程就已经执行完毕了。

1.3 及时地结束程序

进一步改进的代码:

package main  
  
import (  
   "fmt"  
)  
  
func main() {  
   n := 10  
   for i := 0; i < 10; i++ {  
      go func() {  
         fmt.Println("hello world!")  
         n--  
      }()  
   }  
   for n > 0 {  
   }  
}

程序输出:

hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!
hello world!

进程 已完成,退出代码为 0

当然,这也并不够优雅,因为 Go 语言对于 Goroutine 的支持是天生的,而我们希望 Go 语言能够给我们提供一种优雅的方式,让我们的调用函数能够准确知道被调用的协程的执行完毕时间。

于是我们就有了下面的一种更加优雅的写法。

1.4 优雅地结束程序

进一步改造后的代码:

package main  
  
import (  
   "fmt"  
   "sync"
)  
  
func main() {  
   wait := sync.WaitGroup{}  
   wait.Add(10)  
   for i := 0; i < 10; i++ {  
      go func() {  
         fmt.Println("hello world!")  
         wait.Done()  
      }()  
   }  
   wait.Wait()  
}

怎么样,这样是不是优雅了许多?

在上面我们使用 sync.WaitGroup{} 实例化了一个wait 变量,然后调用 add() 方法指定我们需要调用的协程的数量,在每个协程执行结束时,我们及时地调用 done() 方法对 wait 进行类似于减一的操作,以达到通知调用函数其被调用的协程执行完成的目的。最后我们在调用函数的末尾执行 wait() 函数,阻塞函数的执行完毕,保证协程的执行完成。

当然,肯定有粗心同学会忘记在调用函数末尾执行 wait() 函数 or 在协程末尾忘记执行 done() 函数,以至于产生调用函数过早地退出,或者运行时报错 fatal error: all goroutines are asleep - deadlock! 的问题。

对于以上问题,Go 语言的 defer 机制就能够很好的解决这些问题。

1.5 使用 defer 机制

改进后的代码:

package main  
  
import (  
   "fmt"  
   "sync")  
  
func main() {  
   wait := sync.WaitGroup{}  
   wait.Add(10)  
   defer wait.Wait()  
   for i := 0; i < 10; i++ {  
      go func() {  
         defer wait.Done()  
         fmt.Println(i, "hello world!")  
      }()  
   }  
}

程序输出:

2 hello world!
4 hello world!
10 hello world!
10 hello world!
10 hello world!
10 hello world!
10 hello world!
10 hello world!
10 hello world!
10 hello world!

进程 已完成,退出代码为 0

在使用 defer 后就能够及时的在函数结束时执行需要执行的操作,而不会忘记及时地在函数结束时添加该操作。

这里聪明的同学也发现了,该程序的输出是存在问题的。因为我虽然在每条输出语句前添加了 i 参数,但是实际上确实重复输出了,那么这是为什么呢? 这当然是因为在这里的每个协程都是在调用函数的闭包内的,我们虽然没有直接传递参数给被调用的协程,但是协程还是能够获取到调用函数的变量,但是这种获取并不是值传递的,而是址传递的。这意味着,每个协程的 i 变量都指向的是同一个地址空间,而在调用函数改变 i 中的值时,代表每个协程中 i 的值都发生了变化。

解决方式就是,在每次调用协程执行的时候都采用直接传参数的值传递方式,将值拷贝一份传递给协程。

1.6 值传递给协程

改进后的代码:

package main  
  
import (  
   "fmt"  
   "sync")  
  
func main() {  
   wait := sync.WaitGroup{}  
   wait.Add(10)  
   defer wait.Wait()  
   for i := 0; i < 10; i++ {  
      go func(n int) {  
         defer wait.Done()  
         fmt.Println(n, "hello world!")  
      }(i)  
   }  
}

程序输出:

1 hello world!
8 hello world!
0 hello world!
5 hello world!
4 hello world!
6 hello world!
2 hello world!
7 hello world!
9 hello world!
3 hello world!

进程 已完成,退出代码为 0