在 Go 语言并发编程中,“不要通过共享内存来通信,而应该通过通信来共享内存”是其核心理念之一。这句话体现了 Go 语言并发模型的设计哲学,即通过通信机制(如 channel)来实现数据共享,而不是直接操作共享内存。
一、chan的优势
- 避免竞态条件:直接操作共享内存容易引发竞态条件,即多个线程同时访问和修改同一内存区域,导致数据不一致。而通过 channel 通信,数据的传递是原子性的,避免了这种问题。
- 简化并发逻辑:使用通信机制可以将程序解耦为多个独立的模块,每个模块负责特定任务,通过 channel 传递消息。这种方式使得并发逻辑更加清晰,易于理解和维护。
- 提高代码安全性:Go 语言的 channel 设计确保了数据在并发环境下的安全访问,减少了因共享内存导致的错误。
二、基础概念
- Goroutine:Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁成本低。多个 Goroutine 可以并发运行。
- Channel:Channel 是 Go 语言实现通信的核心机制,用于在 Goroutine 之间传递数据。它支持同步和缓冲两种模式。通过 channel,Goroutine 可以安全地共享数据,而无需直接操作共享内存。
以下是一个简单的示例,展示如何通过 channel 在 Goroutine 之间共享数据:
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int) // 创建一个无缓冲区的 channel
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ch <- 10 // 发送数据
}()
wg.Add(1)
go func() {
defer wg.Done()
data := <-ch // 接收数据
fmt.Println("Received:", data)
}()
wg.Wait()
}
在这个例子中,两个 Goroutine 通过 channel 安全地交换数据。特殊场景下的共享内存, 尽管 Go 语言倡导通过通信共享内存,但在某些特殊场景下,仍然可以通过 sync 包或 unsafe 包直接操作共享内存,但需要谨慎使用。
三、源码分析
首先 chan 的底层数据结构定义在 runtime/chan.go 中:
type hchan struct {
qcount uint // 表示chan 里面的元素数量
dataqsiz uint // chan 底层循环数组的长度
buf unsafe.Pointer // 指向底层循环数组的指针, 对于有缓冲的通道
elemsize uint16 // 表示chan 里面的元素大小
closed uint32 // 判断 chan 是否被关闭
timer *timer // timer feeding this chan
elemtype *_type // chan 中的元素种类
sendx uint // 已经发送元素在循环数组中的索引
recvx uint // 已经接受元素在循环数组中的索引
recvq waitq // 等待接收的goroutine 队列
sendq waitq // 等待发送的goroutine 队列
// 因为 chan 被多个go 程共享, 所以需要加锁保护
lock mutex
}
我画了一张图片表示这个chan 结构
首先我们先看一下 waitq 的基本结构, 这个结构是一个双向链表, 其中sudog 这个类型其实是对goroutine 这个数据结构的封装。
type waitq struct {
first *sudog
last *sudog
}
3.1 创建 chan
创建chan 的函数是 makechan, 因为chan 有两种类型, 第一种是缓冲类型, 第二种是非缓冲类型, 所以它的创建条件相对来说也是存在两种的。
var c *hchan
switch {
case mem == 0:
c = (*hchan)(mallocgc(hchanSize, nil, true))
c.buf = c.raceaddr()
case !elem.Pointers():
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
case1: 表示 mem==0
, 也就创建一个非缓冲区通道, 或者是元素大小为0,这种情况通常指的是 struct{}{}
这种数据类型,
- 直接分配大小为hchanSize的内存仅hchan结构体。
- c.buf指向c.raceaddr()(通常是unsafe.Pointer(&c.buf)), 用于竞态检测工具的同步点,无实际数据存储。
case2: 表示 elem.ptrdata == 0
表示当前元素不包含对应的指针
- 一次性分配hchanSize + mem的内存(结构体+缓冲区连续空间)。
- c.buf指向结构体末尾
add(unsafe.Pointer( c ), hchanSize)
。
case3: 元素包含指针, 同时分为两次进行分配, 第一次分配对应的hchan, 第二次分配对应的缓冲区 mallocgc(mem, elem, true)
首先我们看一下接收的过程, 因为接收其实是带ok 和 不带 ok 的形式, ok 表示当前的 chan 时候被关闭
func chanrecv1(c *hchan, elem unsafe.Pointer) {
chanrecv(c, elem, true)
}
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
_, received = chanrecv(c, elem, true)
return
}
3.2 外界接收数据
然后我们分析一下对应的 chanrecv 这个函数, 这个接收操作其实也是比较复杂的, 首先它有三个参数:
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool)
- c: 要接收数据的 channel。
- ep: 指向接收值存放地址的指针。若为 nil,表示调用者忽略接收值。
- block: 布尔值,表示是否为阻塞模式。
当通道接收操作是 select 的多个 case 之一时,selected 表示该 case 是否被选中执行:
selected == true
:该通道的接收操作是 select 最终选择的就绪操作。selected == false
:该通道的接收操作未被选中(例如其他 case 已就绪,或 default 分支存在且无就绪操作)
if !block && empty(c) { //如果是非阻塞而且当前的通道通道为空
if atomic.Load(&c.closed) == 0 { //首先判断一下当前的通道时候存在数据
// 如果没有返回对应的false
return false, false
}
}
如果当前的chan 已经被关闭了, 我们这个时候还需要判断一下当前的 chan 是否为空, 因为在检查的时候可能有数据被发送进来
if empty(c) {
if raceenabled {
raceacquire(c.raceaddr())
}
// 因为通道为null,所以需要清除对应的ep
if ep != nil {
typedmemclr(c.elemtype, ep)
}
// 因为会返回对应的0 值
return true, false
}
如果使用代码表示上面的过程就是下面这段代码:
func chanTest1() {
ch := make(chan int, 2)
close(ch)
select {
case value := <-ch:
fmt.Printf("chan has a value %d\n", value)
default:
fmt.Println("chan is null")
}
}
如果被block了, 当 chan 已经被关闭, 并且循环数组buf 里面没有元素:
if c.closed == 1 {
if c.qcount == 0 {
if raceenabled {
raceacquire(c.raceaddr())
}
unlock(&c.lock)
// 返回对应类型的 零 值
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
}
下面一步是判断等待发送队列中存在对应的goroutine, 说明对应的 buf 是满的, 在这种情况下我们需要对chan 进行判断, 首先判断当前的chan 是缓冲通道还是非缓冲通道:
- 非缓冲通道: 直接进行内存复制, 从sender->groutine 复制到 recver->groutine
- 缓冲通道: 接收操作会从缓冲区头部取出一个值,并将发送者的值放入缓冲区尾部,然后返回 true, true。
if sg := c.sendq.dequeue(); sg != nil {
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
说完上面的内容, 就是通道双方互相接收的过程:
if c.qcount > 0 {
qp := chanbuf(c, c.recvx)
if raceenabled {
racenotify(c, c.recvx, nil)
}
// 如果是 val := <- chan
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
c.recvx++
c.qcount--
}
接下来就是对应的处理被阻塞的情况
//1. 获取当前 goroutine
gp := getg()
//2. acquireSudog() 从池中获取一个 sudog 结构体
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
//3.设置相关的字段
//4. 将 sudog 加入接收队列
c.recvq.enqueue(mysg)
if c.timer != nil {
blockTimerChan(c)
}
//5. gopark 是 Go 运行时用于阻塞当前 goroutine 的函数。
gp.parkingOnChan.Store(true)
gopark(
chanparkcommit,
unsafe.Pointer(&c.lock),
waitReasonChanReceive,
traceBlockChanRecv, 2)
之后就是等待对应的goroutine 被唤醒了:
// 检查当前 sudog(mysg)是否与 goroutine 的 waiting 字段一致。
// 如果不一致,说明等待队列被破坏,抛出异常(throw)。
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
// 如果通道 c 有计时器(timer),调用 unblockTimerChan 解除计时器的阻塞状态。
// 这是为了确保计时器在 goroutine 被唤醒后能够正常工作。
if c.timer != nil {
unblockTimerChan(c)
}
// 将 goroutine 的 waiting 字段设置为 nil,表示当前 goroutine 不再等待。
// 将 gp.activeStackChans 设置为 false,表示当前 goroutine 不再阻塞在通道上
gp.waiting = nil
gp.activeStackChans = false
if mysg.releasetime > 0 {
blockevent(mysg.releasetime-t0, 2)
}
success := mysg.success
// 将 goroutine 的 param 字段设置为 nil,清除与 sudog 的关联。
// 将 mysg.c 设置为 nil,解除 sudog 与通道的关联。
gp.param = nil
mysg.c = nil
// 释放对应的block
releaseSudog(mysg)
return true, success
3.3 关闭chan
关于chansend 函数我们在这里就不进行分析了, 因为分析了chanrecv 函数之后, 再去看chansend函数之后就变得没有任何的压力了。
下面分析的函数是 closechan 这个函数, close chan 的接口其实非常简单只需要传递一个chan 参数:
close chan 参数首先会去检查对应的chan 是否被关闭, 如果被关闭会直接爆出panic 的错误
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
第二步是释放掉所有在等待队列中的发送者和接受者
// 将chan 中所有等待接收队列里 sudog 释放
for {
// 从接收队列中出队一个sudog
sg := c.recvq.dequeue()
if sg == nil {
break
}
// 相当于ep 指针, 直接赋值成为零值
if sg.elem != nil {
typedmemclr(c.elemtype, sg.elem)
sg.elem = nil
}
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
// 取出对应的goroutine
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
//形成一个链表
glist.push(gp)
}
最终会遍历整个glist, 然后释放
for !glist.empty() {
gp := glist.pop() //取出最后一个
gp.schedlink = 0
goready(gp, 3) //唤醒
}