Overview
goroutines 可以看作是一个轻量级线程,它具有单独的独立执行能力,并能与其他 goroutines 并行执行。它是一个与其他 goroutines 同时执行的函数或方法。它完全由 GO 运行时管理。Golang 是一种并发语言。每个 goroutine 都是独立执行的。正是 goroutine 帮助实现了 Golang 的并发性。
Start a go routine
Golang 使用一个特殊的关键字 "go "来启动 goroutine。要启动 goroutine,只需在函数或方法调用前添加 go 关键字。现在该函数或方法将在 goroutine 中执行。请注意,决定是否是 goroutine 的不是函数或方法。如果我们用 go 关键字调用该方法或函数,那么该函数或方法就会在 goroutine 中执行。
让我们来了解一下正常运行函数和作为 goroutine 运行函数之间的区别。
- Normal Running a function
statment1
start()
statement2
在上述情况下,函数正常运行。
-
首先,将执行 statment1
-
然后调用 start() 函数
-
一旦 start() 函数结束,将执行 statement2
-
Running a function as a goroutine
statment1
go start()
statement2
在上述情况下,以 goroutine 运行函数时
- 首先,将执行 statment1
- 然后调用函数 start() 作为 goroutine,该函数将异步执行。
- statement2 将立即执行,它不会等待 start() 函数完成。start 函数将作为Goroutine并发执行,而程序的其余部分继续执行。
所以基本上,当调用一个函数作为Goroutine时,调用将立即返回,执行将从下一行继续,而Goroutine将在后台并发执行。还要注意,来自Goroutine的任何返回值都将被忽略。
让我们看一个程序来理解上述观点
func main() {
go start()
fmt.Println("Started")
time.Sleep(time.Second * 1)
fmt.Println("Finished")
}
func start() {
fmt.Println("In Goroutine")
}
Output
Started
In Goroutine
Finished
在上面的程序中,我们在函数调用之前使用‘go’关键字来启动一个 Goroutine
go start()
上面一行将启动一个 goroutine,运行 start() 函数。程序首先打印 "Started",请注意,打印 "Started" 的那一行是在启动 goroutine 之后。
这说明了上面提到的一点,即启动 goroutine 后,调用将从下一行继续。然后我们设置了一个超时,设置超时的目的是为了让 goroutine 在主 goroutine 运行结束之前能够被调度。现在,goroutine 执行并打印出:
In Goroutine
然后打印
Finished
取消超时后会发生什么?让我们来看一个程序。
package main
import (
"fmt"
)
func main() {
go start()
fmt.Println("Started")
fmt.Println("Finished")
}
func start() {
fmt.Println("In Goroutine")
}
Started
Finished
这意味着 goroutine 从未执行。这是因为主 goroutine 或主程序在 goroutine 被调度之前就已退出。这就引出了关于主程序的讨论
Main goroutine
main 包中的 main 函数是 main goroutine,所有的 goroutine 都是从 main goroutine 启动的。然后,这些 goroutine 可以启动多个其他 goroutine,以此类推。
主 goroutine 就代表主程序,一旦它退出,就意味着程序已经退出(所有其他等待调度的 goroutine 也全部退出)。
goroutine 没有父 goroutine 或子 goroutine 的概念。当你启动一个 goroutine 时,它只是与所有其他正在运行的 goroutine 一起执行(并发的)。
每个 goroutine 只有在其函数返回时才会退出。唯一的例外是,所有的 goroutine 都会在主 goroutine(运行函数 main 的 goroutine)退出时退出。
让我们看一个程序来证明 goroutines 没有父 goroutine 和子 goroutine。
package main
import (
"fmt"
"time"
)
func main() {
go start()
fmt.Println("Started")
time.Sleep(1 * time.Second)
fmt.Println("Finished")
}
func start() {
go start2()
fmt.Println("In Groutine")
}
func start2() {
fmt.Println("In Groutine2")
}
Output
Started
In Goroutine
In Goroutine2
Finished
在上述程序中,第一个 goroutine 启动第二个 goroutine。第一个 goroutine 会打印 "In Goroutine",然后退出(如果有父 goroutine、子 goroutine 的概念,那么父 goroutine 执行结束,它的所有子 goroutine 也需要立即退出,因此 start2 应该无法执行)。然而第二个 goroutine 启动并打印 "In Goroutine2"。这表明,goroutine 没有父 goroutine 或子 goroutine,它们作为独立的执行程序存在。
另外,请注意超时只是为了说明问题,绝对不能在生产环境中使用
Creating Multiple Goroutines
让我们看看下面这个启动多个 goroutines 的程序。该示例还将演示 goroutines 是并发执行的
func main() {
fmt.Println("Started")
for i := 0; i < 10; i++ {
go execute(i)
}
time.Sleep(time.Second * 2)
fmt.Println("Finished")
}
func execute(id int) {
fmt.Printf("id: %d\n", id)
}
Output
Started
id: 4
id: 9
id: 1
id: 0
id: 8
id: 2
id: 6
id: 3
id: 7
id: 5
Finished
程序将在一个循环中产生 10 个 goroutines。由于 goroutines 会同时运行,而且无法确定哪个先运行,因此每次运行程序都会产生不同的输出结果。
Scheduling of the goroutines
go 程序启动后,go 运行时将启动与当前进程可用逻辑 CPU 数量相当的操作系统线程。每个虚拟内核有一个逻辑 CPU,虚拟内核是指
virtual_cores = x * number_of_physical_cores
其中 x = 每个内核的硬件线程数
虚拟内核数量 = 物理内核数量 * 每个内核所拥有的硬件线程数
runtime.Numcpus 函数可用于获取 GO 程序可用的逻辑处理器数量。
func main() {
fmt.Println(runtime.NumCPU())
}
在我的机器上,它打印了16。我的机器有8个物理核心,每个核心有2个硬件线程。因此,2*8=16。
go 程序将启动与可用逻辑 CPU 数量或 runtime.NumCPU() 输出值相等的操作系统线程。这些线程将由操作系统管理,而将这些线程调度到 CPU 内核是操作系统负责的(在内核态完成)。
go 运行时有自己的调度器,它将在 go 运行时中复用操作系统级线程上的 goroutine。所以基本上每个 goroutine 都运行在一个被分配给逻辑 CPU 的操作系统线程上。
Local run queue
在 go 运行时内,每个操作系统线程都有一个与之相关的队列,它被称为本地运行队列。它包含将在该线程上下文中执行的所有 goroutine。
go 运行时将对属于特定本地运行队列的 goroutine 进行调度和上下文切换,并将其分配给拥有该本地运行队列的相应操作系统级线程。
Global Run Queue
全局运行队列中包含了还没有移动到操作系统线程本地运行队列中的所有 goroutine。
Go 调度器会将该队列中的 goroutine 指派给任何操作系统线程的本地运行队列(按照一定的调度规则,比如 hand-off 和 task-stolen)
下图描述了调度程序的工作原理。
- 操作系统级别线程需要分配给逻辑 cpu 后才能运行
- goroutine 需要分配到操作系统级别线程所拥有的本地运行队列后才能被调度运行
Golang scheduler is a Cooperative Scheduler
go 调度器是一个协作调度器(非抢占式)。而抢占式调度程序在时间片用完后必须让出 cpu 给其他程序(自己把自己休眠,等待被唤醒,也被看作一种抢占,因为无法独占整个程序的生命周期)。
在协作调度程序中,线程在执行完毕后必须显式地让出执行,而下一个线程必须等待上一个线程执行完毕释放 cpu 使用权后才能获取 cpu 使用权。
虽然 golang 是协作式调度器,但它有一些特定的检查点,可以让当前正在执行的goroutine 让给其他 goroutine 来执行,从而实现了伪抢占式调度,之所以称为伪抢占:是因为我们说的抢占式调度是由操作系统来完成的,而 golang 中则是由工作在用户态的运行时模拟完成的。
运行时会在函数调用时调用调度程序,以决定是否需要调度新的程序。因此,基本上当一个goroutine 进行任何函数调用时,调度程序都会被调用,并可能发生上下文切换,这意味着一个新的 goroutine 可能会被调度。现有的 goroutine 也有可能继续执行,调度程序也有机会在以下事件中进行上下文切换
- Functions Call
- Garbage Collection
- Network Calls
- Channel operations
- On using keyword
- Blocking on primitives such as mutex etc
这里要提到的是,调度程序会在上述事件期间运行,但这并不意味着会发生上下文切换,这只是调度程序获得了机会;至于是否进行上下文切换,则取决于调度程序。
Advantages of goroutines over threads(面试重点⭐️⭐️⭐️⭐️⭐️)
- 内存空间占用方面:goroutine 用户态栈起始大小只有 8kb,但是其用户栈空间大小可根据运行时的需求进行动态扩缩。而操作系统线程的大小超过 1 mb。因此,goroutines 的分配成本极低。因此,可以同时启动大量的 goroutines。goroutine 的收缩和增长由 go 运行时内部管理。由于 goroutine 很便宜,所以你可以启动成千上万个 go 例程,而只能启动几千个线程。
什么?栈大小还能动态扩缩?
在 Golang 中,传统意义上的栈已经被 runtime 全部占用了,goroutine 的用户栈其实是在堆空间上分配的逻辑栈空间(抽象出来的);它仍然保留的栈的特性,同时也继承了堆空间动态分配的特性,因此 goroutine 的用户栈可以根据 runtime 的需求动态扩缩。
但是这并不是没有代价,由于 goroutine 用户栈空间位于堆空间上,再加上 golang 的定时内存整理优化策略,可能上一秒位于某个地址的变量在下一秒被整体搬到了另一块内存地址空间上。如果还使用之前的指针访问这个变量就可能导致数据访问错误甚至发生空指针 panic 异常。
知道了这个你就能更好理解为什么 Golang 中的 Map 是非并发安全的?为什么 Golang 中的 Map 不支持对 Key 的寻址操作;学习 Golang 需要有动态思维:Golang 的 Map 是一个"动态数据结构",为了减少 map 扩容时带来的性能损耗,Golang 使用了和 Redis 类似的渐进性扩容策略,并不是一次性将所有 map 数据 copy 到一个更大的 map 中,而是将 copy 操作分散到每个 map 操作中;(每次 Put 操作时,顺便将一个桶的数据迁移到新的桶中,注意这个迁移操作!这表示你前一刻访问的数据在下一刻可能就被迁移到新的地址,所以你既不能对 Key 寻址也不能对 Value 寻址)
- 调度者、切换速度、切换开销:goroutine 调度由 go 运行时负责。如上所述,go 运行时会在内部启动与逻辑 CPU 数量相当的操作系统线程。然后,它会将 goroutines 重新调度到每个操作系统线程上。因此,goroutines 的调度是由 go 运行时完成的,因此速度更快。而线程的调度还是由操作系统运行时完成的,因此 goroutines 的上下文切换时间要比线程的上下文切换时间快得多。
因此,数千个 goroutines 可以复用一个或多个操作系统线程。但是如果在 JAVA 中启动 1000 个线程,就会消耗大量资源,而且这 1000 个线程还需要由操作系统管理。即在内核态完成上下文切换,无论是上下文切换速度还是上下文切换开销都比较大。此外,不像 goroutine 起始用户栈大小只有 8kb,java 中每个线程的大小都将超过 1 MB。
- Goroutine 通过内置的并发原语通道进行通信,该通道是专门为了处理竞争条件而构建的(与 channel 相对的一种通信技术叫做内存共享)。因此,Goroutine 之间通过 channel 的通信是并发安全的,channel 底层结构中内置了互斥锁,它在底层已经为开发者封装好了互斥逻辑,这样开发者就无需显示使用互斥锁进行并发访问控制了,大大减少了开发者的心智负担。因此,goroutines 之间共享的数据结构(临界区资源)不必被锁定。线程编程使用锁来访问共享变量。这可能导致难以检测的死锁和竞态条件。goroutines 使用通道进行通信,而整个同步由 go 运行时管理,这样就避免了死锁和竞态条件。
请记住下面这句 "mantra" 口头禅 :
- 英文原文
Do not communicate by sharing memory; instead, share memory by communicating.
- 译文
不要通过共享内存来通信,而应该通过通信来共享内存
这句话体现了 Golang 并发通信模型的核心思想:CSP(Communicating sequential processes),感兴趣可以看看下面贴出的那篇论文。
CSP 的模型由并发执行的实体(线程或者进程)所组成,实体之间通过发送消息进行通信, 这里发送消息时使用的就是通道(channel)。也就是我们常说的 『 Don't communicate by sharing memory; share memory by communicating 』。
Hoare 78] Hoare, C. A. R. (1978). Communicating sequential processes. Communications of the ACM, 21(8), 666–677.
Anonymous Goroutines
也可以使用Goroutine调用Golang中的匿名函数。
下面是在程序中调用匿名函数的格式:
go func(){
//body
}(args..)
不过,使用 goroutine 调用匿名函数和使用 goroutine 调用普通函数的行为没有区别
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("In Goroutine")
}()
fmt.Println("Started")
time.Sleep(1 * time.Second)
fmt.Println("Finished")
}
Output
Started
In Goroutine
Finished
Conclusion
这篇英文原文感觉有点老了,比如调度那块,Golang 现在使用的是 GMP 模型,上面那个看起来还是 GM 模型(激烈的锁冲突),进阶可以去看 Aceld 大佬的 GMP 文章。