【Golang】常见数据结构原理剖析
文章目录
- 1、golang channel底层结构实现
- channel 底层数据结构
- 关键字段解析:
- waitq 队列结构
- channel 核心操作的底层逻辑
- 缓冲 channel 与非缓冲 channel 的区别
- 总结
- 2、为什么等待队列需要时双向链表呢
- 总结
- 3、当 select 语句中某个 channel 操作就绪时,需要将其他未就绪的 channel 等待节点从队列中移除。这种情况下需要删除中间的sudog,这是为什么呢
- 4、select是怎么实现的呢,是什么原理
- select 的核心行为
- select 底层实现的关键数据结构
- select 的执行流程(核心原理)
- 关键细节:随机性与效率
- 总结
1、golang channel底层结构实现
在 Go 语言中,channel 是实现 goroutine 间通信的核心机制,其底层通过一个复杂的数据结构和同步逻辑实现。理解 channel 的底层结构,能帮助我们更深入地掌握其工作原理和性能特性。
channel 底层数据结构
channel 的底层实现由 Go runtime 中的 hchan 结构体(定义在 runtime/chan.go 中)表示,核心字段如下(简化版):
type hchan struct {qcount uint // 队列中当前元素数量dataqsiz uint // 环形队列的容量(缓冲大小)buf unsafe.Pointer // 指向环形队列的指针(缓冲数据存储区)elemsize uint16 // 每个元素的大小closed uint32 // 标记 channel 是否关闭(0:未关闭,1:已关闭)elemtype *_type // 元素类型(用于类型检查和内存分配)sendx uint // 发送操作的索引(环形队列的下一个发送位置)recvx uint // 接收操作的索引(环形队列的下一个接收位置)recvq waitq // 等待接收的 goroutine 队列sendq waitq // 等待发送的 goroutine 队列// 互斥锁,保护 hchan 的所有字段lock mutex
}
关键字段解析:
1. 缓冲相关:
- buf:指向一个环形队列的指针,用于存储缓冲数据(仅缓冲 channel 有值,非缓冲 channel 为 nil)。
- dataqsiz:环形队列的容量(创建 channel 时指定的 make(chan T, n) 中的 n)。
- qcount:当前缓冲队列中的元素数量。
- sendx/recvx:环形队列的索引,分别记录下一次发送和接收的位置(类似生产者 - 消费者模型的指针)。
2. 同步相关:
- recvq:等待接收数据的 goroutine 队列(双向链表),当 channel 无数据时,接收方 goroutine 会阻塞并加入此队列。
- sendq:等待发送数据的 goroutine 队列(双向链表),当 channel 缓冲满时,发送方 goroutine 会阻塞并加入此队列。
◦ lock:互斥锁,确保对 channel 操作的原子性(并发安全)。
3. 元信息:
- elemtype/elemsize:记录 channel 中元素的类型和大小,用于内存分配和类型校验。
- closed:标记 channel 是否关闭,避免对已关闭 channel 进行操作(发送会 panic,接收返回零值)。
waitq 队列结构
recvq 和 sendq 均为 waitq 类型,本质是一个双向链表,存储等待中的 goroutine:
type waitq struct {first *sudog // 队头last *sudog // 队尾
}// sudog 是对 goroutine 的封装,关联等待的 channel 和数据
type sudog struct {g *g // 关联的 goroutinenext *sudog // 下一个 sudogprev *sudog // 上一个 sudogelem unsafe.Pointer // 数据指针(发送时存储待发送的值,接收时存储接收的地址)// 其他字段(如是否在 select 中等待等)
}
sudog 是连接 goroutine 和 channel 的桥梁,每个阻塞在 channel 上的 goroutine 都会被包装成 sudog 放入对应的等待队列。
channel 核心操作的底层逻辑
channel 的操作(发送 <-、接收 <-、关闭 close)均围绕 hchan 结构展开,核心逻辑如下:
1. 发送操作(ch <- val)
- 加锁:先获取 hchan.lock,确保操作原子性。
- 尝试直接发送:
◦ 若 recvq 不为空(有等待接收的 goroutine),则直接将数据发送给队列头部的 goroutine(通过 sudog.elem 复制数据),并唤醒该 goroutine,当前 goroutine 继续执行。
◦ 若 recvq 为空且缓冲未满(qcount < dataqsiz),则将数据存入 buf 环形队列,更新 sendx 和 qcount,解锁后继续执行。 - 阻塞等待:
◦ 若缓冲已满(或非缓冲 channel),则将当前 goroutine 包装成 sudog,加入 sendq 队列,释放锁并阻塞(由 Go runtime 调度器挂起)。
◦ 当其他 goroutine 接收数据或关闭 channel 时,当前 goroutine 会被唤醒,重新尝试发送(或因 channel 关闭而 panic)。
2. 接收操作(val <- ch 或 val, ok <- ch)
- 加锁:同样先获取 hchan.lock。
- 尝试直接接收:
- 若 sendq 不为空(有等待发送的 goroutine):
▪ 若为缓冲 channel 且缓冲未满,先将发送队列头部的 goroutine 的数据存入 buf,再从 buf 取数据返回,唤醒发送 goroutine。
▪ 若为非缓冲 channel 或缓冲已满,直接从发送队列头部的 goroutine 接收数据,唤醒发送 goroutine,当前 goroutine 继续执行。
◦ 若 sendq 为空且缓冲有数据(qcount > 0),则从 buf 环形队列取数据,更新 recvx 和 qcount,解锁后返回。
- 若 sendq 不为空(有等待发送的 goroutine):
- 阻塞等待:
- 若缓冲为空(或非缓冲 channel),则将当前 goroutine 包装成 sudog,加入 recvq 队列,释放锁并阻塞。
- 当其他 goroutine 发送数据或关闭 channel 时,当前 goroutine 会被唤醒,接收数据(或因 channel 关闭返回零值和 false)。
3. 关闭操作(close(ch))
- 加锁:获取 hchan.lock。
- 校验:若 channel 已关闭(closed == 1),则 panic(避免重复关闭)。
• 唤醒等待队列:- 唤醒 recvq 中所有等待接收的 goroutine,它们会接收到零值和 false(表示 channel 已关闭)。
- 唤醒 sendq 中所有等待发送的 goroutine,它们会因向已关闭 channel 发送数据而 panic。
- 标记关闭:设置 closed = 1,解锁。
缓冲 channel 与非缓冲 channel 的区别
- 非缓冲 channel(make(chan T)):dataqsiz = 0,buf = nil。发送和接收操作必须同步:发送方会阻塞直到有接收方接收,接收方会阻塞直到有发送方发送(直接通过 sudog 传递数据,不经过缓冲队列)。
- 缓冲 channel(make(chan T, n)):dataqsiz = n,buf 指向一个大小为 n * elemsize 的环形队列。发送和接收可异步进行,当缓冲未满 / 未空时,操作无需阻塞;仅当缓冲满 / 空时,才会阻塞并加入等待队列。
总结
channel 底层通过 hchan 结构体实现,核心是 环形缓冲队列(用于缓冲数据)和 等待队列(用于阻塞的 goroutine),配合互斥锁保证并发安全。其设计兼顾了通信效率和同步灵活性,是 Go 并发模型的核心支柱。理解其底层结构,有助于写出更高效、更安全的并发代码。
2、为什么等待队列需要时双向链表呢
在 Go 语言 channel 的等待队列(recvq 和 sendq)中,使用双向链表(waitq 封装的 sudog 双向链表)而非单向链表,主要是为了高效地完成 节点的插入、删除和唤醒操作,这与 channel 的并发调度逻辑密切相关。具体原因如下:
1. 双向链表支持高效的首尾操作
等待队列的核心需求是:
- 快速向队列中添加新的等待 goroutine(队尾插入)。
- 快速从队列中唤醒第一个等待的 goroutine(队头删除)。
- 在某些场景下(如 select 中 channel 被关闭或唤醒),需要快速移除中间的节点。
双向链表通过 first(头指针)和 last(尾指针),可以在 O (1) 时间 内完成: - 队尾插入(last.next = 新节点,更新 last 为新节点)。
- 队头删除(first = first.next,若新头不为空则重置其 prev 为 nil)。
如果用单向链表,队尾插入需要遍历到尾部(O (n) 时间),或额外维护尾指针(但删除中间节点仍不方便),效率远低于双向链表。
2. 支持从中间节点快速删除
在某些场景下,需要从等待队列中 移除中间的 sudog 节点(例如:
- 当 select 语句中某个 channel 操作就绪时,需要将其他未就绪的 channel 等待节点从队列中移除。
- 当 goroutine 被唤醒时,需要将其从等待队列中删除(可能处于队列中间,而非头部)。
双向链表的每个节点(sudog)都有 prev 和 next 指针,删除中间节点时: - 只需修改该节点前一个节点的 next 指针,以及后一个节点的 prev 指针(O (1) 时间)。
而单向链表删除中间节点需要先遍历找到前驱节点(O (n) 时间),在高频并发的 channel 操作中,这种性能损耗是不可接受的。
3. 适配 Go runtime 的调度逻辑
Go 的调度器在唤醒 goroutine 时,需要快速定位并操作等待队列中的节点。例如:
- 当 channel 有数据写入时,需要唤醒 recvq 的第一个等待者(队头)。
- 当 channel 被关闭时,需要唤醒队列中 所有等待者(遍历链表,从 first 到 last)。
双向链表的遍历可以轻松从头部开始(first → next → … → last),也可以从尾部反向遍历(虽然 channel 场景中较少用到,但结构上支持),灵活性更高。
总结
channel 的等待队列本质是一个 FIFO(先进先出)的阻塞队列,但需要支持高频的插入(队尾)、删除(队头或中间)操作。双向链表通过 prev 和 next 指针,在这些操作中提供了 O (1) 级别的效率,完美适配了 channel 并发调度的性能需求。这也是 Go runtime 选择双向链表作为等待队列底层结构的核心原因。
接收者在尝试接收操作时候,当缓冲channel未满的时候,为什么需要先将发送队列头部的groutine数据存入到buf里面,然后再从buf里取数据返回。
在缓冲 channel 的接收操作中,当缓冲未满时,若发送队列(sendq)中有等待的 goroutine,会先将发送队列头部的 goroutine 数据存入缓冲(buf),再从缓冲中取数据返回。这种设计的核心目的是 维持缓冲队列的 FIFO 特性 并 优化并发调度效率,具体原因如下:
1. 保证缓冲队列的 FIFO 语义
缓冲 channel 的核心是一个 环形队列(FIFO),其设计初衷是让数据的发送和接收遵循 “先进先出” 的顺序。当缓冲未满但发送队列有等待的 goroutine 时,这些等待的发送者本质上是 “更早到达” 的生产者,它们的数据理应优先进入缓冲队列。
例如:
- 缓冲 channel 容量为 2,当前缓冲中有 1 个数据(A),此时发送队列 sendq 中有等待发送 B 和 C 的 goroutine。
- 当接收者尝试接收时,若直接从发送队列取 B 给接收者,缓冲中会剩下 A,后续的发送 / 接收会打破 FIFO 顺序(比如下一次接收本应取 A,却可能取到新的发送数据)。而先将 B 存入缓冲(此时缓冲变为 [A, B]),再从缓冲头部取 A 返回给接收者,既保证了缓冲队列的 FIFO 顺序,也让等待的发送者 B 顺利入队,符合 channel 设计的语义一致性。
2. 避免缓冲空间浪费,平衡生产者 - 消费者效率
缓冲 channel 的缓冲空间是一种有限资源,其价值在于允许发送和接收异步进行(无需立即阻塞)。当缓冲未满时,若直接将发送队列的 goroutine 数据传递给接收者(跳过缓冲),会导致缓冲空间被浪费,同时可能让后续的发送操作继续阻塞(明明缓冲有空间却不用)。
例如:
- 缓冲容量为 2,当前缓冲为空,发送队列有一个等待发送 X 的 goroutine,此时有一个接收者到来。
- 若直接将 X 传递给接收者,缓冲仍为空,发送队列的 X 被消耗,但缓冲的空间未被利用。若此时又有一个发送者发送 Y,本可以直接存入缓冲(无需阻塞),但如果没有上述逻辑,可能因设计疏漏导致不必要的调度开销。
通过先将发送队列的 X 存入缓冲,再从缓冲取 X 给接收者,缓冲虽短暂被占用后释放,但保证了缓冲空间的 “充分利用” 逻辑 —— 只要缓冲有空间,等待的发送者应优先填充缓冲,而非直接 “插队” 传递给接收者。
3. 简化并发调度逻辑,避免复杂的特殊判断
Go runtime 的调度逻辑需要尽可能简洁以保证效率。若针对 “缓冲未满且发送队列非空” 的场景设计特殊逻辑(比如直接将发送者数据传递给接收者),会引入额外的条件判断和分支处理,增加代码复杂度。
而采用 “先入缓冲,再出缓冲” 的统一逻辑,能让缓冲 channel 的所有操作(发送、接收)都遵循同一套 FIFO 规则:
- 发送操作:优先入缓冲,缓冲满则入发送队列。
- 接收操作:优先从缓冲取,缓冲空则从发送队列取(或入接收队列)。
这种一致性设计减少了特殊情况的处理,让 channel 的底层逻辑更易维护,也降低了并发场景下的逻辑冲突风险(比如多个接收者同时唤醒时的数据竞争)。
总结
缓冲 channel 中 “接收时先将发送队列数据入缓冲,再从缓冲取数据” 的逻辑,本质是为了 维持 FIFO 语义、充分利用缓冲空间 并 简化调度逻辑。这一设计确保了 channel 操作的一致性和可预测性,同时优化了并发场景下的资源利用效率。
3、当 select 语句中某个 channel 操作就绪时,需要将其他未就绪的 channel 等待节点从队列中移除。这种情况下需要删除中间的sudog,这是为什么呢
在 select 语句中,当某个 channel 操作就绪时,需要将其他未就绪 channel 的等待节点(sudog)从队列中移除,核心原因是 避免这些未就绪的等待节点被错误唤醒或阻塞,保证 select 的语义正确性和 goroutine 的正常调度。具体可从以下角度理解:
1. select 的核心语义:仅执行一个就绪的分支
select 语句的行为是:同时监听多个 channel 的操作(发送或接收),当其中任意一个 channel 操作就绪时(可执行而不阻塞),就执行该分支;若多个同时就绪,则随机选择一个执行;若所有分支都未就绪,则阻塞等待,直到至少一个就绪。
这意味着,当 select 中某个 channel 操作就绪并开始执行后,其他未就绪的 channel 操作必须 “放弃等待”—— 因为 select 已经确定了唯一要执行的分支,其他分支的等待状态必须被取消。
2. 未移除等待节点会导致的问题
当 select 中的某个 goroutine 为了等待多个 channel 操作而将自己的 sudog 加入多个 channel 的等待队列(recvq 或 sendq)后:
- 若其中一个 channel 就绪并执行了操作,此时其他 channel 的等待队列中仍保留着该 goroutine 的 sudog。
- 若不及时移除这些 sudog,当后续其他 channel 就绪时(例如有数据发送或被关闭),runtime 会错误地唤醒这个已经 “不需要等待” 的 goroutine,导致:
- 重复唤醒:该 goroutine 已经因 select 的某个分支被唤醒并执行,却可能被其他 channel 再次唤醒,引发逻辑混乱(例如重复处理数据)。
- 资源泄漏:sudog 关联的 goroutine 已经继续执行,但 sudog 仍留在等待队列中,会占用内存资源,甚至可能导致其他操作错误(例如关闭 channel 时唤醒不存在的等待者)。
3. 双向链表支持高效移除中间节点
select 中的 goroutine 会为每个待监听的 channel 创建一个 sudog,并将这些 sudog 分别加入对应 channel 的等待队列(可能处于队列的中间位置,而非头部)。
当 select 确定执行某个分支后,需要从其他所有 channel 的等待队列中 删除当前 goroutine 对应的 sudog。由于等待队列是双向链表,每个 sudog 有 prev 和 next 指针,因此可以在 O (1) 时间 内完成中间节点的删除(只需调整前后节点的指针),避免了遍历整个队列的性能开销。
这种高效的删除能力,是 select 能够正确取消未就绪分支等待的关键保障。
示例说明
假设一个 select 同时监听 channel ch1 和 ch2 的接收操作:
select {
case <-ch1:// 分支1
case <-ch2:// 分支2
}
- 当 goroutine 执行到该 select 时,会创建两个 sudog,分别加入 ch1.recvq 和 ch2.recvq,然后阻塞等待。
- 若此时 ch1 有数据发送(就绪),则该 goroutine 会被从 ch1.recvq 中唤醒,执行分支 1。
- 此时必须将该 goroutine 在 ch2.recvq 中的 sudog 移除 —— 否则,若后续 ch2 有数据发送,runtime 会错误地认为该 goroutine 仍在等待 ch2,从而尝试唤醒它,导致不可预期的行为。
总结
select 语句中移除未就绪 channel 的等待节点(sudog),是为了 保证仅执行一个就绪分支的语义,避免 goroutine 被错误唤醒或资源泄漏。而双向链表的结构支持高效删除中间节点,恰好满足了这一需求,是 select 与 channel 协作的底层基础。
4、select是怎么实现的呢,是什么原理
Go 语言的 select 语句是实现多路复用(同时监听多个 channel 操作)的核心机制,允许在多个 channel 的发送 / 接收操作中选择一个就绪的执行。其底层实现依赖于 Go runtime 的调度逻辑和 sudog、scase 等数据结构,核心原理可概括为:同时注册多个 channel 的等待事件,通过 runtime 检测就绪状态,选择一个执行并取消其他等待。
select 的核心行为
select 的行为规则:
- 同时检查所有 case 中的 channel 操作(发送 / 接收)。
- 若有多个 case 就绪(可执行而不阻塞),随机选择一个 执行。
- 若只有一个 case 就绪,执行该 case。
- 若所有 case 都未就绪,且有 default 分支,则执行 default。
- 若所有 case 都未就绪且无 default,则当前 goroutine 阻塞,直到某个 case 就绪。
select 底层实现的关键数据结构
select 的实现依赖以下核心结构(定义在 runtime/select.go 中):
- scase:每个 case 的元信息
select 中的每个 case(包括 default)会被封装为 scase 结构体,存储该 case 关联的 channel、操作类型(发送 / 接收)、数据指针等:
type scase struct {c *hchan // 关联的 channel(nil 表示 default)elem unsafe.Pointer // 数据指针(发送时为待发送值的地址,接收时为存储结果的地址)kind uint16 // 操作类型:caseRecv(接收)、caseSend(发送)、caseDefault(默认)// 其他辅助字段(如是否在等待队列中)
}
- sudog:关联 goroutine 与等待队列
如前所述,sudog 是 goroutine 的封装,用于将当前 goroutine 加入多个 channel 的等待队列(recvq/sendq),并记录其在 select 中的上下文。
select 的执行流程(核心原理)
select 的执行可分为 “检测就绪” 和 “阻塞等待” 两个阶段,具体流程如下:
阶段 1:快速检测就绪的 case(非阻塞)
- 遍历所有 case:runtime 首先遍历 select 中的所有 scase(跳过 default),检查每个 channel 的操作是否就绪:
◦ 对于 接收操作(case <-ch):检查 channel 是否有数据(缓冲非空)或有等待发送的 goroutine(sendq 非空)。
◦ 对于 发送操作(case ch <- val):检查 channel 是否有缓冲空间(缓冲未满)或有等待接收的 goroutine(recvq 非空)。 - 收集就绪的 case:若发现就绪的 scase,将其加入一个临时列表。
- 处理就绪的 case:
◦ 若列表非空,随机选择一个 执行(通过 fastrand 保证随机性),执行完成后退出 select。
◦ 若列表为空且存在 default 分支,则执行 default 并退出。
阶段 2:阻塞等待(无就绪 case 且无 default)
若所有 case 都未就绪且无 default,当前 goroutine 会进入阻塞状态,流程如下: - 创建 sudog 并注册到等待队列:
◦ 为当前 goroutine 创建一个 sudog(每个 scase 对应一个 sudog,但共享同一个 goroutine 指针)。
◦ 将这些 sudog 分别加入对应 channel 的等待队列(recvq 或 sendq),标记 goroutine 正在等待这些 channel。 - 阻塞当前 goroutine:
◦ 释放当前 goroutine 的 CPU 控制权,由 Go 调度器将其挂起(状态改为 Gwaiting)。 - 被唤醒后重新检测:
◦ 当某个 channel 就绪(有数据发送 / 接收,或被关闭),runtime 会唤醒等待队列中的 goroutine,并将其从所有其他 channel 的等待队列中 移除未就绪的 sudog(避免被错误唤醒)。
◦ 唤醒后,goroutine 重新进入阶段 1,再次检测所有 case,选择一个就绪的执行。
关键细节:随机性与效率
- 随机性选择:当多个 case 同时就绪时,select 会通过 fastrand 生成随机数选择一个 case,避免固定顺序导致的调度倾斜(例如某个 case 长期被优先执行)。
- 高效取消等待:当 select 中的一个 case 就绪并唤醒 goroutine 时,runtime 必须将该 goroutine 在其他 channel 等待队列中的 sudog 移除(通过双向链表的 prev/next 指针快速删除),否则可能导致 goroutine 被多次唤醒,引发逻辑错误。
- 避免 “活锁”:随机性选择确保了在高并发场景下,多个 select 同时竞争多个 channel 时,不会出现某个 case 永远无法被选中的情况。
五、示例:select 执行的底层逻辑
ch1 := make(chan int)
ch2 := make(chan int)go func() { ch1 <- 1 }()
go func() { ch2 <- 2 }()select {
case x := <-ch1:fmt.Println("ch1:", x)
case y := <-ch2:fmt.Println("ch2:", y)
}
- 执行 select 时,首先检测 ch1 和 ch2 是否就绪(此时两个 channel 都有数据发送,均就绪)。
- runtime 生成随机数,随机选择一个 case 执行(例如 ch1),输出 ch1: 1。
- 若两个 goroutine 发送数据的时机较晚,select 会先将当前 goroutine 加入 ch1.recvq 和 ch2.recvq,阻塞等待;当其中一个 channel 有数据时,唤醒 goroutine 并执行对应分支,同时移除另一个 channel 中的 sudog。
总结
select 的底层原理是通过 scase 封装每个 channel 操作,先快速检测就绪状态,若未就绪则将 goroutine 注册到多个 channel 的等待队列中阻塞等待;当某个 channel 就绪时,唤醒 goroutine 并执行对应分支,同时取消其他 channel 的等待。这一机制实现了高效的多路复用,是 Go 并发模型中连接多个 channel 的核心工具。
