社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
title: Golang语言笔记: 理解 Channel 特性
date: 2018-04-20 20:00:00
tags: Golang
本文是对 Gopher 2017 中一个非常好的 Talk�: [Understanding Channel](GopherCon 2017: Kavya Joshi - Understanding Channels) 的学习笔记,希望能够通过对 channel 的关键特性的理解,进一步掌握其用法细节以及 Golang 语言设计哲学的管窥蠡测。
文章内容包括:
[TOC]
channel
是可以让一个 goroutine 发送特定值到另一个 gouroutine 的通信机制。
element type
元素类型。make
创建的,对数据结构的引用,当把 channel 作为参数使用时,实际上是传引用调用nil
原生的 channel 是没有缓存的(unbuffered channel),可以用于 goroutine 之间实现同步。
ch := make(chan int) // ch hase type `chan int`
ch <- x // a send statement
x = <-ch // a receive expression in an assignment statement
<-ch // a receive statement; result is discarded
close(ch)
关闭后不能再写入,可以读取直到 channel 中再没有数据,并返回元素类型的零值。
buffered channel
的创建ch := make(chan int) // unbuffered channel
ch := make(chan int, 0) // unbuffered channel
ch := make(chan int, 3) // buffered channel with capacity 3
buffered channel 可以用于非常方便的实现生产者-消费者模型,实现异步操作。
gopl/ch3/netcat3
func main() {
conn, err := net.Dial("tcp", "localhost:8008")
if err != nil {
log.Fatal(err)
}
// communication over an buffered channel causes the sending and
// receiving goroutines to synchronize
done := make(chan struct{})
go func() {
io.Copy(os.Stdout, conn) // NOTE; ignoring errors
log.Println("Done")
done <- struct{}{} // signal the main goroutine
}()
mustCopy(conn, os.Stdin)
conn.Close()
<-done
}
首先从 channel 是怎么被创建的开始:
在heap
上分配一个hchan
类型的对象,并将其初始化,然后返回一个指向这个hchan
对象的指针。
heap
上而不是stack
上hchan
类型理解了 channel 的数据结构实现,现在转到 channel 的两个最基本方法: sends
和 receivces
,看一下以上的特性是如何体现在 sends
和 receives
中的:
假设发送方先启动,执行ch <- task0
:
lock
,加锁;Task
类型的对象task0
执行入队操作;需要特别指出的是,这里的入队
enqueue
操作实际上是一次memcopy
行为,将整个task0
复制一份到buf
,也就是FIFO缓冲队列中。
如此为 channel 带来了 goroutine-safe
的特性。
在这样的模型里,sender goroutine -> channel -> receiver goroutine
之间,hchan
是唯一的共享内存,而这个唯一的共享内存又通过mutex
来确保goroutine-safe
,所有在队列中的内容都只是副本。
这便是著名的 golang 并发原则的体现:
不要通过共享内存来通信,而是通过通信来共享内存。
发送方 goroutine 会阻塞,暂停,并在收到receive
后才恢复。
goroutine 是一种用户态线程, 由 Go runtime 创建并管理,而不是操作系统,比起操作系统线程来说,goroutine更加轻量。
Go runtime scheduler 负责将 goroutine 调度到操作系统线程上。
runtime scheduler 怎么将 goroutine 调度到操作系统线程上?
当阻塞发生时,一次 goroutine 上下文切换的全过程:
gopark
调用;gopark
调用,会将现在正在运行的 goroutine G1
从running
置为waiting
;G1
和承载它的操作系统线程M
之间的联系解除;runnable
goroutine G
,并将其和M
绑定,开始执行G
只是阻塞了 goroutine,没有阻塞操作系统线程。
然而,被阻塞的 goroutine 怎么恢复过来?
阻塞发生时,调用 runtime sheduler 执行gopark
之前,G1 会创建一个sudog
,并将它存放在hchan
的sendq
中。sudog
中便记录了即将被阻塞的 goroutine G1
,以及它要发送的数据元素task4
等等。
接收方将通过这个sudog
来恢复 G1
接收方 G2 接收数据, 并发出一个receivce
,将 G1 置为 runnable
:
task1
出队(接收 task1);sendq
中的sudog
出栈,获取到G1
和task4
,然后将task4
入队。在这里让 G2 而非 G1 执行元素入队操作的用意在于,这样 G1 恢复运行后无需再获取一次锁,将元素入队,然后再释放一次锁,即可以减少一次获取释放锁的过程;runnable
。G2 向 runtime scheduler 发起一次goready
调用,scheduler 将 G1 置为runnable
,并将其入队到 runqueue,然后返回 G2。同样的, 接收方 G2 会被阻塞,G2 会创建sudoq
,存放在recvq
,基本过程和发送方阻塞一样。
不同的是,发送方 G1如何恢复接收方 G2,这是一个非常神奇的实现。
理论上可以将 task 入队,然后恢复 G2, 但恢复 G2后,G2会做什么呢?
G2会将队列中的 task 复制出来,放到自己的 memory 中,基于这个思路,G1在这个时候,直接将 task 写到 G2的 stack memory 中!
这是违反常规的操作,理论上 goroutine 之间的 stack 是相互独立的,只有在运行时可以执行这样的操作。
这么做纯粹是出于性能优化的考虑,原来的步骤是:
优化后,相当于减少了 G2 获取锁并且执行 memcopy 的性能消耗。
sudog queues
实现gopark
,goready
)channel 设计背后的思想可以理解为 simplicity 和 performance 之间权衡抉择,具体如下:
queue with a lock prefered to lock-free implementation:
比起完全 lock-free 的实现,使用锁的队列实现更简单,容易实现
The performance improvement does not materiablize from the air, it comees with code complexity increase.
性能的提升(lock-free)不是凭空实现的,它来自代码复杂性的增长。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!