视频笔记:理解 channels - Kavya Joshi
一、视频信息1、视频观看地址https://www.youtube.com/watch?v=KBZlN0izeiY 2、PPT下载地址http://download.csdn.net/download/xunzaosiyecao/10212884 3、博文https://about.sourcegraph.com/go/understanding-channels-kavya-joshi/ 二、Go 的并发特性
1、一个简单的事务处理的例子对于下面这样的非并发的程序: func main() { tasks := getTasks() // 处理每个任务 for _,task := range tasks { process(task) }
}
将其转换为 Go 的并发模式很容易,使用典型的 Task Queue 的模式: func main() {
// 创建带缓冲的 channel
ch := make(chan Task, 3)
// 运行固定数量的 workers
for i := 0; i < numWorkers; i++ {
go worker(ch)
}
// 发送任务到 workers
hellaTasks := getTasks()
for _,task := range hellaTasks {
ch <- task
}
...
}
func worker(ch chan Task) {
for {
// 接收任务
task := <-ch
process(task)
}
}
2、channels 的特性
三、解析1、构造 channel// 带缓冲的 channel
ch := make(chan Task, 3)
// 无缓冲的 channel
ch := make(chan Tass)
回顾前面提到的 channel 的特性,特别是前两个。如果忽略内置的 channel,让你设计一个具有 goroutines-safe 并且可以用来存储、传递值的东西,你会怎么做?很多人可能觉得或许可以用一个带锁的队列来做。没错,事实上,channel 内部就是一个带锁的队列。 type hchan struct {
...
buf unsafe.Pointer // 指向一个环形队列
...
sendx uint // 发送 index
recvx uint // 接收 index
...
lock mutex // 互斥量
}
buf 的具体实现很简单,就是一个环形队列的实现。sendx 和 recvx 分别用来记录发送、接收的位置。然后用一个 lock 互斥锁来确保无竞争冒险。 对于每一个 ch := make(chan Task,3) 这类操作,都会在堆中,分配一个空间,建立并初始化一个 hchan 结构变量,而 ch 则是指向这个 hchan 结构的指针。 因为 ch 本身就是个指针,所以我们才可以在 goroutine 函数调用的时候直接将 ch 传递过去,而不用再 &ch 取指针了,所以所有使用同一个 ch 的 goroutine 都指向了同一个实际的内存空间。 2、发送、接收为了方便描述,我们用 G1 表示 main() 函数的 goroutine,而 G2 表示 worker 的 goroutine。 // G1
func main() {
...
for _,task := range tasks {
ch <- task
}
...
}
// G2
func worker(ch chan Task) {
for {
task :=<-ch
process(task)
}
}
2.1 简单的发送、接收那么 G1 中的 ch <- task0 具体是怎么做的呢?
这一步很简单,接下来看 G2 的 t := <- ch 是如何读取数据的。
这一步也非常简单。但是我们从这个操作中可以看到,所有 goroutine 中共享的部分只有这个 hchan 的结构体,而所有通讯的数据都是内存复制。这遵循了 Go 并发设计中很核心的一个理念:
内存复制指的是: // typedmemmove copies a value of type t to dst from src. // Must be nosplit,see #16026. //go:nosplit
func typedmemmove(typ *_type,dst,src unsafe.Pointer) {
if typ.kind&kindNoPointers == 0 {
bulkBarrierPreWrite(uintptr(dst),uintptr(src),typ.size)
}
// There's a race here: if some other goroutine can write to
// src,it may change some pointer in src after we've
// performed the write barrier but before we perform the
// memory copy. This safe because the write performed by that
// other goroutine must also be accompanied by a write
// barrier,so at worst we've unnecessarily greyed the old
// pointer that was in src.
memmove(dst,src,typ.size)
if writeBarrier.cgo {
cgoCheckMemmove(typ,0,typ.size)
}
}
3、阻塞和恢复3.1 发送方被阻塞假设 G2 需要很长时间的处理,在此期间,G1 不断的发送任务: ch <- task1
ch <- task2
ch <- task3
但是当再一次 ch <- task4 的时候,由于 ch 的缓冲只有 3 个,所以没有地方放了,于是 G1 被 block 了,当有人从队列中取走一个 Task 的时候,G1 才会被恢复。这是我们都知道的,不过我们今天关心的不是发生了什么,而是如何做到的? 3.2 goroutine 的运行时调度首先,goroutine 不是操作系统线程,而是 用户空间线程。因此 goroutine 是由 Go runtime 来创建并管理的,而不是 OS,所以要比操作系统线程轻量级。 当然,goroutine 最终还是要运行于某个线程中的,控制 goroutine 如何运行于线程中的是 Go runtime 中的 scheduler (调度器)。 Go 的运行时调度器是 M:N 调度模型,既 N 个 goroutine,会运行于 M 个 OS 线程中。换句话说,一个 OS 线程中,可能会运行多个 goroutine。 Go 的 M:N 调度中使用了3个结构:
要想运行一个 goroutine - G,那么一个线程 M,就必须持有一个该 goroutine 的上下文 P。 3.3 goroutine 被阻塞的具体过程那么当 ch <- task4 执行的时候,channel 中已经满了,需要 pause G1。这个时候:
从上面的流程中可以看到,对于 goroutine 来说,G1 被阻塞了,新的 G 开始运行了;而对于操作系统线程 M 来说,则根本没有被阻塞。 我们知道 OS 线程要比 goroutine 要沉重的多,因此这里尽量避免 OS 线程阻塞,可以提高性能。 3.4 goroutine 恢复执行的具体过程前面理解了阻塞,那么接下来理解一下如何恢复运行。不过,在继续了解如何恢复之前,我们需要先进一步理解 hchan 这个结构。因为,当 channel 不在满的时候,调度器是如何知道该让哪个 goroutine 继续运行呢?而且 goroutine 又是如何知道该从哪取数据呢? 在 hchan 中,除了之前提到的内容外,还定义有 sendq 和 recvq 两个队列,分别表示等待发送、接收的 goroutine,及其相关信息。 type hchan struct {
...
buf unsafe.Pointer // 指向一个环形队列
...
sendq waitq // 等待发送的队列
recvq waitq // 等待接收的队列
...
lock mutex // 互斥量
}
其中 waitq 是一个链表结构的队列,每个元素是一个 sudog 的结构,其定义大致为: type sudog struct {
g *g // 正在等候的 goroutine
elem unsafe.Pointer // 指向需要接收、发送的元素
...
}
https://golang.org/src/runtime/runtime2.go?h=sudog#L270 所以在之前的阻塞 G1 的过程中,实际上:
这些都是 发生在调用调度器之前。 那么现在开始看一下如何恢复。 当 G2 调用 t := <- ch 的时候,channel 的状态是,缓冲是满的,而且还有一个 G1 在等候发送队列里,然后 G2 执行下面的操作:
3.5 如果接收方先阻塞呢?更酷的地方是接收方先阻塞的流程。 如果 G2 先执行了 t := <- ch,此时 buf 是空的,因此 G2 会被阻塞,他的流程是这样:
这些应该已经不陌生了,那么当 G1 开始发送数据的时候,流程是什么样子的呢? G1 可以将 enqueue(task),然后调用 goready(G2)。不过,我们可以更聪明一些。 我们根据 hchan 结构的状态,已经知道 task 进入 buf 后,G2 恢复运行后,会读取其值,复制到 t 中。那么 G1 可以根本不走 buf,G1 可以直接把数据给 G2。 Goroutine 通常都有自己的栈,互相之间不会访问对方的栈内数据,除了 channel。这里,由于我们已经知道了 t 的地址(通过 elem指针),而且由于 G2 不在运行,所以我们可以很安全的直接赋值。当 G2 恢复运行的时候,既不需要再次获取锁,也不需要对 buf 进行操作。从而节约了内存复制、以及锁操作的开销。 4、总结
四、其它 channel 的操作1、无缓冲 channel无缓冲的 channel 行为就和前面说的直接发送的例子一样:
2、select ##https://golang.org/src/runtime/select.go
五、为什么 Go 会这样设计?1、Simplicity更倾向于带锁的队列,而不是无锁的实现。
后者虽然性能可能会更好,但是这个优势,并不一定能够战胜随之而来的实现代码的复杂度所带来的劣势。 2、Performance
当然,任何优势都会有其代价。这里的代价是实现的复杂度,所以这里有更复杂的内存管理机制、垃圾回收以及栈收缩机制。 在这里性能的提高优势,要比复杂度的提高带来的劣势要大。 所以在 channel 实现的各种代码中,我们都可以见到这种 simplicity vs performance 的权衡后的结果。 原文地址: 推荐阅读: 深入理解Golang Channel 个人微信公众号:
(编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |