Don’t communicate by sharing memory; share memory by communicating.

Goroutine

goroutine 与 thread

在 Go 中,应用程序并发处理的部分被称作 goroutines,它可以进行更有效的并发运算。协程由 Go 运行时 (runtime) 创建,和操作系统线程之间并无一对一的关系(user-kernel thread 映射模型):协程是根据一个或多个线程的可用性,映射(多路复用)在他们之上的。

协程调度器在用户态上对 goroutines 进行管理,采用 M:N 调度模型(M 个 Goroutine 映射到 N 个线程),可以用GOMAXPROCS(逻辑处理器数量)来控制 N 的数量。相比之下,线程是由操作系统内核进行管理和调度的,被操作系统调度器分配到不同的处理器核心上运行。由于没有内核的介入,协程的创建与切换的开销降低很多。具体来说,Go 采用协作式调度(通过  Gosched()  或 channel阻塞主动让出 CPU ),而线程则是由 OS 进行抢占式调度(RR, FCFS, …)。

gorountine调度

摘选知乎的一个例子,生动解释了 Goroutine、coroutine 和 thread 之间的联系:

Thread:想象线程就像是公司的员工。每个员工都有自己的任务和责任,但他们共享公司的资源(例如办公室、打印机等)。员工(线程)的上下班(开始和结束线程)以及工作调度(线程切换)由公司管理层(操作系统)控制。如果公司要新增一个员工或者安排员工之间的工作,这需要管理层的直接参与,也会涉及到较多的人力和物力资源(也就是说,线程的创建和上下文切换成本相对较高)。

Coroutine:现在想象协程就像是在家工作的自由职业者。他们使用自己的电脑和办公设备(拥有自己的堆栈和局部变量),并且自己决定什么时候工作、什么时候休息(编程者控制)。他们可以随时暂停工作去喝杯咖啡或是散步(yield 或等待),然后再回来继续工作。所有这些活动的安排都不需要外部管理层的参与(用户级的调度),并且几乎不需要额外的资源(低成本的任务切换)。

Goroutine:Goroutine 就像是使用特殊工作方法的自由职业者团队。他们不仅可以自己安排工作时间(用户态调度),还使用一种特殊的通信方式 —— 他们不会直接交谈(共享内存),而是通过写信(传递消息)来沟通(channel 机制)。这种工作方式使他们的合作更加高效和有序(并发编程更容易实现和管理)。

GoLang GPM 模型 - Go 语言中文网 - Golang 中文社区介绍了 Go runtime GPM 调度模型,其中很清晰的解释了线程模型 👍

select

在 Go 里,select语句主要用于在多个通道操作间进行选择。它和switch语句类似,不过switch用于选择不同的条件分支,而select用于选择不同的通道操作。select会阻塞,直到其中一个通道操作准备好,若有多个操作同时就绪,会随机选择一个执行。要是存在default分支,在没有通道操作准备好时,会执行default分支,从而避免阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 示例:实现超时控制
func main() {
ch1 := make(chan string)
ch2 := make(chan string)

go func() {
time.Sleep(2 * time.Second)
ch1 <- "data from channel 1"
}()

go func() {
time.Sleep(1 * time.Second)
ch2 <- "data from channel 2"
}()

select {
case msg1 := <-ch1:
fmt.Println("Received", msg1)
case msg2 := <-ch2:
fmt.Println("Received", msg2)
case <-time.After(3 * time.Second):
fmt.Println("Timeout")
}
}

select机制是 Go 语言并发编程中的一个重要特性,它为处理多个通道的并发操作提供了一种简洁、高效且灵活的方式,使得 Go 语言在处理复杂的并发场景时能够更加优雅和易于维护。

  • 实现并发操作的多路复用:在并发编程中,经常需要同时处理多个通道的操作。select允许程序在多个通道之间进行选择,当其中任何一个通道准备好进行读取或写入操作时,就可以执行相应的分支逻辑。这使得程序能够高效地处理多个并发任务,避免了逐个检查通道状态的繁琐操作,提高了代码的简洁性和可读性。
  • 处理异步事件:在异步编程模型中,各个操作可能在不同的时间点完成,select可以用于监听多个异步操作的完成信号。例如,在网络编程中,可能同时有多个网络连接在进行数据传输,通过select可以随时响应哪个连接有数据可读或可写,从而实现对多个网络连接的高效管理。

Channel

无缓冲同步与有缓冲异步的数据传递

一般我们通过ch := make(chan type)创建的是无缓冲且同步的通道,只有当接收方准备好后发送方才会发送数据,因此通道的发送 / 接收操作在对方准备好之前是阻塞的。

1
2
3
4
5
6
7
8
9
10
// 阻塞示例
func print(ch chan int) {
fmt.Print(<-ch)
}

func main() {
ch := make(chan int)
ch <- 2 // main线程在此被阻塞,产生deadlock,抛出panic
go print(ch) // 不会执行这条语句
}

一个无缓冲通道只能包含 1 个元素,通过ch := make(chan type, buf)可以设置通道缓存大小。在缓冲区满载之前,给一个带缓冲的通道发送数据是不会阻塞的,而从通道读取数据也不会阻塞,直到缓冲区空了。

如果容量大于 0,通道就是异步的了:缓冲满载(发送)或变空(接收)之前通信不会阻塞,元素会按照发送的顺序被接收,此时上面的示例可以运行。如果容量是 0 或者未设置,通信仅在收发双方准备好的情况下才可以成功。

底层数据传递原理

  • 数据复制:当一个  goroutine  向通道发送数据时,数据会被复制到通道内部。在无缓冲通道中,这个复制操作是在接收者准备好接收数据时才会发生。
  • 同步机制:通道使用了互斥锁(mutex)和条件变量(condition variable)来实现同步。当发送者尝试发送数据时,它会先获取通道的锁mutex_lock(),检查是否有接收者准备好接收数据。如果没有,发送者会释放锁mutex_unlock()并进入阻塞状态;当接收者准备好接收数据时,它会获取锁,通知发送者可以发送数据signal(),然后进行数据的复制和接收操作。

关闭通道与测试阻塞

通道可以被显式的关闭close(chan),只有在当需要告诉接收者不会再提供新的值的时候,才需要关闭通道,因此只有发送者才会有关闭通道的需要。给已经关闭的通道发送或者再次关闭都会导致运行时 panic。

1
2
3
4
5
6
func send() chan int {
ch := make(chan int)
defer close(ch) // 将通道标记为无法通过发送操作 `<-` 接受更多的值
// do something
return ch
}

相应的,接收方需要一种方法检测通道有没有被阻塞(或被关闭)。

1
2
3
4
5
6
func receive(ch chan int) {
if val, stat := <- ch; stat {
// 通道未关闭
process()
}
}

参考资料:
[1] https://learnku.com/docs/the-way-to-go/141-concurrency-parallel-and-co-process/3685
[2] Goroutine 和线程比较 - 知乎
[3] GoLang GPM 模型 - Go 语言中文网 - Golang 中文社区
[4] https://fafucoder.github.io/2021/11/08/golang-goroutine/