Go 并发编程 -goroutine 初体验

说到 Go 语言,被人讨论最多的就是 Go 很擅长做高并发,并且不需要依赖外部的库,语言本身就支持高并发。

Go 中实现这一能力的秘密是 goroutine,也经常被称之为协程,goroutine 是 Go 对协程的实现。在这篇文章中,会介绍协程的基本概念,以及 goroutine 的基本使用。

1.什么是协程

协程(Coroutine),又被称之为微线程,这个概念出现的时间很早,在 1963 年就有相关的文献发表,但协程真正被用起来的时间很短。

对于操作系统来说,线程是最小的调度单位,但对于一些高并发的环境,线程处理起来就比较吃力,一方面操作系统能够分配的线程数量有限,另外线程之间的切换相对来说也比较大。

所以对于 Java 这类以线程为调度单位的语言,一般会依靠外部的类库来做到高并发,比如 Java 的Netty 就是一个开始高并发应用必不可少的库。

协程和线程非常类似,只是比线程更加轻量级,具体表现在协程之间的切换不需要涉及系统调用,也不需要互斥锁或者信号量等同步手段,甚至都不需要操作系统的支持。

协程与线程的行为基本一致,但是协程是在语言层面实现的,而线程是操作系统实现的。

2. Go 语言的协程

在 Go 语言中,支持两种并发编程的模式,一种就是以 goroutine 和 channel 为主,这种方式称之为 CSP 模式,这种方式的核心是在 goroutine 之间传递值来来实现并发。

还有一种方式是传统的共享内存式的模式,通过一些同步机制,比如锁之类的机制来实现并发。

Go 程序通过 main 函数来启动,main 函数启动的时候也会启动一个 goroutine,称之为主 goroutine。然后在主 goroutine 中通过 go 关键字创建新的 goroutine。go 语句是立马返回的,不会阻塞当前的 goroutine。

一个 Go 程序中可以创建的 goroutine 数量可以比线程数量多很多,这也是 Go 程序可以做到高并发的原因,goroutine 的实现原理,我们后续的文章再详细聊,下面来看看看 goroutine 的使用。

3. goroutine 的基本使用

goroutine 的使用很简单,只需要在调用的函数前面添加 go 关键字,就会创建一个新的 goroutine:

1
2
3
4
5
6
7
8
func goroutine1()  {
fmt.Println("Hello goroutine")
}

func main() {
go goroutine1()
fmt.Println("Hello main")
}

但运行上面的代码之后,输出的结果为:

1
Hello main

预想中的 Hello goroutine 并没有出现,因为 main 方法执行完成之后,main 方法 所在的 goroutine 就销毁了,其他的 goroutine 都没有机会执行完。

可以通过设置一个休眠时间来阻止主 goroutine 执行完成。

1
2
3
4
5
6
7
8
9
func goroutine1()  {
fmt.Println("Hello goroutine")
}

func main() {
go goroutine1()
time.Sleep(1 * time.Second)
fmt.Println("Hello main")
}

这样,输出结果就和我们预想的一样了:

1
2
Hello goroutine
Hello main

但是这种方法也存在一些问题,这个休眠时间不太好设置,设置的过长,会浪费时间,设置的过短, goroutine 还没运行完成,所以最好的方式是让 goroutine 自己来决定。我们再改动一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func goroutine2(isDone chan bool) {
fmt.Println("child goroutine begin...")
time.Sleep(2 * time.Second)
fmt.Println("child goroutine end...")
isDone <- true
}

func main() {
isDone := make(chan bool)
go goroutine2(isDone)
<-isDone
close(isDone)
fmt.Println("main goroutine end..")
}

在上面的代码中,我们使用了 chan 类型,这个类型我们后续会详细讲解,暂时只需要知道创建一个 chan 类型的变量,传入到一个子 goroutine 之后,它就会阻塞当前的 goroutine,直到子 goroutine 执行完成。这种方式比上面设置休眠时间的方式要优雅很多,也不会产生一些意料之外的结果。

结果输出为:

1
2
3
child goroutine begin...
child goroutine end...
main goroutine end..

但这种方式还是不完美,现在只启动了一个 goroutine,如果要启动多个 goroutine,这种方式就不管用了。当然,肯定还是有解决办法的,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func goroutine3(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("child goroutine %d begin...\n", id)
time.Sleep(time.Second)
fmt.Printf("child goroutine %d end...\n", id)
}

func main() {
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
wg.Add(1)
go goroutine3(i, &wg)
}
wg.Wait()
}

这个代码看起来要复杂不少,其中 sync 包中包括了 Go 语言并发编程的所有工具,我们用到的 WaitGroup 就是其中的一个工具。

首先创建一个 WaitGroup 类型的变量 wg,每创建一个 goroutine,就向 wg 中加 1,每个 goroutine 执行完成之后,就调用 wg.Done,这样 wg 就会减 1,wg.Wait() 会阻塞当前 goroutine,直到 wg 中的值清零。

如果熟悉其他语言同步机制的人就会想到,这不就是信号量么,是的,这就是使用信号量来实现的。这个 WaitGroup 与 Java 语言中的 CountDownLatch 功能是一样的。

输出的结果也很漂亮:

1
2
3
4
5
6
7
8
9
10
child goroutine 4 begin...
child goroutine 0 begin...
child goroutine 3 begin...
child goroutine 2 begin...
child goroutine 1 begin...
child goroutine 1 end...
child goroutine 2 end...
child goroutine 3 end...
child goroutine 4 end...
child goroutine 0 end...

到这里,我们了解了 goroutine 的基本使用,但很多情况下,goroutine 不是独立运行的,而经常需要与其他的 goroutine 通信,在下一篇文章中,我们将详细的聊一聊 goroutine 之间的通信方式。

4. 小结

在这篇文章中,我们了解了协程的概念,并且知道了 goroutine 是 Go 语言对协程的实现。也知道了如何通过启动一个新的 goroutine 并发的去做一些事情,同时也知道了如何让 main goroutine 来等待其他 goroutine 工作完成再退出的几种方法。

文 / Rayjun

© 2020 Rayjun    PowerBy Hexo    京ICP备16051220号-1