golang 深入浅出之 goroutine 理解

目录

一、前言

二、goroutine 的理解与使用

 (一)goroutine 入门

 (二)sync 包同步 goroutine

三、并发与并行

 (一)并发与并行的区别

 (二)runtime 包对 goroutine 控制

四、思考题

五、参考文献


一、前言

       goroutine 是 Go 并行设计的核心,其本质就是协程,协程比线程小,也叫轻量级线程,它可以轻松创建上万个而不会导致系统资源的枯竭。十几个 goroutine 可能体现在底层就是五六个线程,一个线程可以有任意多个协程,但是某一个时刻只能有一个协程在运行,多个协程共享该线程分配到的计算机资源。创建 goroutine 只需在函数调用前加上 go 语句,就可以创建并发执行单元,开发人员无需了解任何执行细节,调度器会自动安排其到合适的系统线程上执行。

二、goroutine 的理解与使用

 (一)goroutine 入门

       首先如何创建一个 goroutine ,在调用的函数前加上 go 语句,该函数除了匿名函数也可以是 golang 包中自带的方法。

go func() {
    // to do something
}()
//比如:
go fmt.Println(1)  // 输出 1

        在并发的程序中,通常是将一个过程分为几块,然后让每个 goroutine 各自负责一块工作,当程序启动时,主函数在一个单独的 goroutine 中运行,我们叫他 main goroutine , 启动程序时 main goroutine 则立刻运行,其他 goroutine 会用 go 语句来创建。下面定义了 3 个函数,前两个函数加上 go 创建两个 goroutine,分别为 goroutine_1, goroutine_2, 第 3 个 func_1 为普通函数,作为对比用。

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("goroutine_1")
	}()
	go func() {
		fmt.Println("goroutine_2")
	}()
	func() {
		fmt.Println("func_1")
	}()
}

       刚开始运行的时候只有 output1 这种输出,多运行几次之后出现了 output2 的输出。

       

       两个输出结果不一样,第一个输出结果 geroutine_1 , goroutine_2都没有输出,第二个输出也只是出现 goroutine_1。造成以上问题是因为 goroutine 的运行时需要获得时间片,获取 CPU 时间片才能运行 goroutine,在运行队列中等待调用,并不会马上执行, 在 main goroutine 中的 fun_1 则立刻运行。现在再看上面的程序,一共定义了两个 goroutine ,执行完两个定义语句之后,goroutine 不会马上运行,而是等待获取时间片执行,然而这段代码还没等到两个 goroutine 被调度执行 main goroutine 就结束运行并退出,main goroutine 退出后,其他的工作 goroutine 也会自动退出,所以就看不到两个 goroutine 的输出。为了看到两个 goroutine 的执行可以暂时在程序的末尾加上休眠时间。

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("goroutine_1")
	}()
	go func() {
		fmt.Println("goroutine_2")
	}()
	func() {
		fmt.Println("func_1")
	}()
	time.Sleep(time.Second) // 程序休眠1s
}

       这时候就看到了两个 goroutine 输出结果。因为 goroutinue 的调度执行是随机的,所以每次运行的结果可能不一样,以下是两种输出的结果。fun_1因为是马上运行的,所以第一个输出。

     

       但是我们总不能每次在运行程序的时候都要休眠,毕竟我们不能自己算出所有的 goroutine 什么时候被调用执行,以及他们什么时候才能结束,所以 sync 包中的 WaitGroup 就可以用来判断 goroutine 是否运行结束的方法。

 (二)sync 包同步 goroutine

sync 包

sync.WaitGroup : 一个计数信号量,用来记录并维护 goroutine。

wg.Add(n) : 代表有 n 个正在运行的 goroutine。

wg.Done() :  其源码是 wg.Add(-1),说明有一个 goroutine 运行结束。

wg.Wait() :  如果 WaitGroup 的值大于 0 ,Wait 方法就会阻塞,直到等待队列的值到 0 并最终释放 main 函数。

package main

import (
	"fmt"
	"sync"
)

func main() {
	// WaitGroup 是一个计数信号量,被
	// 用来记录并维护 goroutine
	var wg sync.WaitGroup
	wg.Add(2)  // 共有两个 goroutine
	go func(){
		fmt.Println("goroutine 1")
		wg.Done() // goroutine 运行结束
	}()
	go func(){
		fmt.Println("goroutine 2")
		wg.Done()
	}()
	func() {
		fmt.Println("func_1")
	}()
	wg.Wait() // 阻塞等待所有 goroutine 运行完毕
	//time.Sleep(time.Second)  // 程序休眠1s
}

 

        这样就可以保证所有的 goroutine 都被运行。 以下代码可以显示了添加 WaitGroup 阻塞判断和没有阻塞判断程序所耗时间。首先是没有加 sync.WaitGroup 的代码。

package main

import (
	"fmt"
	"time"
)

func main() {
	startTime := time.Now().UnixNano()
	go func() {
		fmt.Println("goroutine_1")
	}()
	go func() {
		fmt.Println("goroutine_2")
	}()
	func() {
		fmt.Println("func_1")
	}()
	endTime := time.Now().UnixNano()
	nanoSeconds := float64((endTime - startTime))
	fmt.Println(nanoSeconds)
}

       输出结果为:

       在这 27000 纳秒内,main goroutine 程序就已经运行完毕,但是 goroutine_1, goroutine_2 没有被调用执行。然后我们将阻塞等待机制加入以上代码中,来对比两段程序的运行时间。

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	startTime := time.Now().UnixNano()
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		fmt.Println("goroutine_1")
		wg.Done()
	}()
	go func() {
		fmt.Println("goroutine_2")
		wg.Done()
	}()
	func() {
		fmt.Println("func_1")
	}()
	wg.Wait()
	endTime := time.Now().UnixNano()
	nanoSeconds := float64((endTime - startTime))
	fmt.Println(nanoSeconds)
}

       得到的输出为:

       这段程序运行了 goroutine_1, goroutine_2 ,一共花费了 65000 纳秒的时间。 很显然加了阻塞判断机制的程序为了等待 goroutine_1, goroutine_2 被调用执行,花费了更多的时间,当然这个时间并不是唯一的,goroutine 的调用时随机的,可能两个 goroutine 调用的晚,程序花费较多的时间,goroutine 调用得早,花费较少的时间,这个视系统的环境而定。

三、并发与并行

 (一)并发与并行的区别

       Golang在运行时候会在逻辑处理器上调度 goroutine 来运行。每个逻辑处理器分别绑定绑定到单个操作系统线程。golang 运行时会默认为每个可用的物理处理器分配一个处理器,所以我们在运行代码的时候有时候会用  runtime.GOMAXPROCS(N) 来设置逻辑处理器的数量,其中 N 代表 N 个逻辑处理器,这些处理器会被用于执行所有的 goroutine。

       关于并发与并行的区别,知乎上很赞的回答,可以帮助理解两者的不同。       

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。
并行的关键是你有同时处理多个任务的能力。

所以我认为它们最关键的点就是:是否是『同时』。

来源:知乎
 

       并发是当正在运行的 goroutine 发生阻塞时,比如读取io,或者打开一个文件,通常都需要花费一定的时间等待。这类调用会让线程和 goroutine 从逻辑处理器分离,该线程会阻塞,并等待系统调用返回。调度器会重新创建一个新的线程,然后再继续从本地执行队列中选择一个 goroutine 运行。一旦被阻塞的 goroutine 执行完成并返回是,对应的 goroutine 会被放回本地运行队列,而之前的线程会被保存,以备之后继续使用,调度情况如图1.

                                                                               图1 Go 调度器如何管理 goroutine

       并发(concurrency)与并行(parallelism)是不同的。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情。在很多情况下,并发比并行的效果好,因为操作系统的和硬件的总资源一般很少,但能支持系统做很多事情。这种"使用较少资源做更多事情”的哲学也是知道 Go 语言设计的哲学。图2 展示了并发与并行的区别。

                                                                               图2 并发与并行的区别

 (二)runtime 包对 goroutine 控制

runtime 包

runtime.GOMAXPROCS(N)  设置 N 个逻辑处理器给调度器使用。当 N值为 runtime.NumCPU(),代表给每个可用的核心分配一个逻辑处理器。当 N 为 1,最多同时只能有一个 goroutine 被执行,当 N 为 2 时,两个 goroutine 可以被一起执行。

runtime.Gosched() 用于让出 CPU 时间片,让出当前 goroutine 的执行权限,调度器安排其他等待的任务先执行,并在下次再次获得 CPU 时间轮片的时候,从让出该 CPU 的位置恢复执行。

runtime.Goexit() 终止当前 goroutine 执行。

       现在可以在刚才的代码上加上 go 语句,看看逻辑处理器的调度。先分配逻辑处理器,同时将两个 goroutine 方法改为输出 1 ~ 10。

// 输出 1 ~ n 的数字
func printNum(goroutine string, n int) {
	// 设置随机种子,随机种子不同,生成的随机数不同。
	rand.Seed(time.Now().Unix())
	for i := 1; i <= n; i++ {
		fmt.Printf("%s: %d\n", goroutine, i)
		// 程序随机休眠 0~1 秒,方便观察 goroutine 之间的切换
		time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
	}
	// 输出结束标志
	fmt.Printf("%s: completed\n", goroutine)
}

       输出 1~n 之间的数字,然后通过随机长度休眠模拟阻塞,阻塞时间为 [0,n) 秒,方便观察 goroutine 之间切换执行的并发效果。将以上代码整合到前面的程序中。

package main

import (
	"fmt"
	"math/rand"
	"runtime"
	"sync"
	"time"
)

// 输出 1 ~ n 的数字
func printNum(goroutine string, n int) {
	// 设置随机种子,随机种子不同,生成的随机数不同。
	rand.Seed(time.Now().Unix())
	for i := 1; i <= n; i++ {
		fmt.Printf("%s: %d\n", goroutine, i)
		// 程序随机休眠 0~1 秒,方便观察 goroutine 之间的切换
		time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
	}
	// 输出结束标志
	fmt.Printf("%s: completed\n", goroutine)
}

func main() {
	// WaitGroup 是一个计数信号量,被
	// 用来记录并维护 goroutine
	runtime.GOMAXPROCS(1)
	var wg sync.WaitGroup
	wg.Add(2)  // 共有两个 goroutine
	go func(){
		printNum("1", 10)
		wg.Done() // goroutine 运行结束
	}()
	go func(){
		printNum("2", 10)
		wg.Done()
	}()
	func() {
		fmt.Println("func_1")
	}()
	wg.Wait() // 阻塞等待所有 goroutine 运行完毕
	//time.Sleep(time.Second)  // 程序休眠1s
}

       运行程序后可以发现,当一个程序阻塞,调度器会切换到另外一个程序执行。以下为该程序的输出结果。

func_1
goroutine_2: 1
goroutine_1: 1          // 切换到 goroutine_1
goroutine_1: 2
goroutine_2: 2          // 切换到 goroutine_2
goroutine_2: 3
goroutine_2: 4
goroutine_2: 5
goroutine_2: 6
goroutine_2: 7
goroutine_2: 8
goroutine_2: 9
goroutine_1: 3          // 切换到 goroutine_1
goroutine_1: 4
goroutine_2: 10         // 切换到 goroutine_2
goroutine_2: completed
goroutine_1: 5          // 切换到 goroutine_1
goroutine_1: 6
goroutine_1: 7
goroutine_1: 8
goroutine_1: 9
goroutine_1: 10
goroutine_1: completed

Process finished with exit code 0

       上图显示先是 goroutine_2 在运行输出,输出到数字 1 的时候,调度器将正在运行的 goroutine_2 转换为 goroutine_1,之后 goroutine_1 输出了数字 1~2,再次切换到 goroutine_2。通过这样的切换调度使得两个 goroutine 完成所有的工作。每次运行结果可能会不一样。如果将上面的休眠阻塞时间去掉,一般 goroutine 就会执行完所有的工作再切换另外一个。即 goroutine_1 输出完1~n,之后再切换 goroutine_2 输出 1~ n。对于 goroutine 的执行顺序,如果没有利用同步加以控制,那么 goroutine 的执行顺序是无法预测的。如果看到相同的执行顺序是因为测试的次数太少。

四、思考题

        文章写到这里,我们对 goroutine 有了初步的了解,我在网上找了一个有趣的程序,用来加深理解。

        程序1:

package main

import (
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1)
	for i := 0; i < 10; i++ {
		go func() {
			println(i)
		}()
	}
	time.Sleep(time.Second)
}

       结果输出为:10个10,为什么结果是这个?

       程序2:

package main

import (
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1)
	for i := 0; i < 10; i++ {
		go println(i)
	}
	time.Sleep(time.Second)
}

       结果输出:乱序输出 0~9 。为什么和程序1 结果不一样?

分析  

       首先程序1:我们在 for 循环里面定义了一个匿名函数,匿名函数的功能是输出 i 的值,结果输出 10 个 10。前面讲过了,除了 goroutine 会等待系统调用执行,其他代码会立刻运行,所以 for 循环会很快就结束,同时为了看到 goroutine 被调用执行,我们让 main 函数休眠 1 秒。当程序运行到 go 语句的时候,编译器就把运行 goroutine 所需的函数和参数都保存了,程序1 中编译器保存的是{main.func_xxx, nil},第一个参数为函数,第二个参数为该函数接收的参数,此时应该注意到,该匿名函数是无参的,所以保存参数为 nil。当系统中的 goroutine 被调用执行时, 匿名函数里面的代码开始执行,此时需要用到 i 的值,然而 for 循环已经执行完毕,当前的 i 值为 10,所以后面输出的所有值都是10。

      其次是程序2:相比于程序1 唯一不同的地方就是 go 语句后面的内容,代码看起来没什么差别,但是结果却不同。经过程序1 的分析,我们知道运行到 go 语句时,编译器会保存方法和参数,程序2 中编译器保存的是{println, current_i} ,不同的是,这次编译器保存的方法为 println 且是有参数的,参数值为当前 i 的值,所以 for 循环执行了 10次,分别传入 i 的值为 0~9 ,因此当 goroutine 被调用执行的时候,输出的值为乱序的 0~9,之所以乱序,这是因为 goroutine 的调用时随机的,所以每次执行 0~9 序列都不尽相同。

五、参考文献

[1]《 Go 语言实战》

[2] 深入浅出Golang关键字"go" 

[3] 并发与并行的区别?

[4] go语言之行--golang核武器goroutine调度原理、channel详解

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章