目录
- [channel 的概念?](#channel 的概念?)
- [channel 有哪些状态?](#channel 有哪些状态?)
- [如何判断 channel 已关闭?](#如何判断 channel 已关闭?)
- [channel 的底层实现原理?](#channel 的底层实现原理?)
- [channel 发送数据和接收数据的过程?](#channel 发送数据和接收数据的过程?)
- [channel 是否线程安全?](#channel 是否线程安全?)
- [channel 如何实现线程安全?](#channel 如何实现线程安全?)
- [channel 的应用场景?](#channel 的应用场景?)
- [defer 的概述?](#defer 的概述?)
- [defer 的使用场景?](#defer 的使用场景?)
- [defer 的底层原理?](#defer 的底层原理?)
- [defer 函数和 return 的执行顺序?](#defer 函数和 return 的执行顺序?)
- [WaitGroup 使用的注意事项?](#WaitGroup 使用的注意事项?)
channel 的概念?
channel 又称为管道,用于数据传递和数据共享,本质上是先进先出的队列,使用 goroutine + channel 进行数据通信非常高效。同时,channel 是线程安全的,多个 goroutine 可以同时修改一个 channel,不需要加锁。
channel 有哪些状态?
- nil:未初始化的状态,只进行了赋值,或手动赋值为 nil;
- active:正常的 channel,可读可写;
- closed:已关闭,channel 的值不是 nil。关闭状态的 channel 仍可以读,但不能写 。
注意,上图当中的空 channel 指的是状态为 nil 的 channel,而不是活跃状态下没有数据的 channel。
如何判断 channel 已关闭?
go
if v, ok := <- ch; !ok {
fmt.Println("channel is already closed")
}
channel 的底层实现原理?
channel 有几个重要的字段:
- buf 指向底层的循环数组,只有设置为有缓存的 channel 才会有 buf(在 make 一个 channel 的时候,指定 make 的第二个参数);
- sendx 和 recvx 分别指向底层循环数组的发送和接收元素位置的索引;
- sendq 和 recvq 分别表示发送数据的被阻塞的 goroutine 和读取数据的 goroutine,二者都是双向链表结构;
- sendq 和 recvq 是等待队列类型;
- sudog 是对 goroutine 的封装;
go
type hchan struct {
qcount uint // channel中的元素个数
dataqsiz uint // channel中循环队列的长度
buf unsafe.Pointer // channel缓冲区数据指针
elemsize uint16 // buffer中每个元素的大小
closed uint32 // channel是否已经关闭,0未关闭
elemtype *_type // channel中的元素的类型
sendx uint // channel发送操作处理到的位置
recvx uint // channel接收操作处理到的位置
recvq waitq // 等待接收的sudog(sudog为封装了goroutine和数据的结构)队列由于缓冲区空间不足而阻塞的goroutine列表
sendq waitq // 等待发送的sudog队列,由于缓冲区空间不足而阻塞的goroutine列表
lock mutex // 一个轻量级锁
}
channel 发送数据和接收数据的过程?
channel 发送数据的过程:
- 检查 recvq 是否为空,如果不为空,则从 recvq 头部取一个 goroutine,将数据发送过去,并唤醒对应的 goroutine;
- 如果 recvq 为空,则将数据放入到 buffer 中;
- 如果 buffer 已满,则将要发送的数据和当前 goroutine 打包成 sudog 对象放入到 sendq 中。并将当前 goroutine 设置为 waiting 状态。
channel 接收数据的过程:
- 检查 sendq 是否为空,如果不为空,且没有缓冲区,则从 sendq 头部取一个 goroutine,将数据取出来,并唤醒对应的 goroutine,结束读取的过程【sendq 中发送数据的 goroutine 由于没有缓冲区,处于 waiting(即阻塞)状态,sendq 也是一个队列,如果此时有 goroutine 从 sendq 对头的 goroutine 取数据,那么取走数据之后,sendq 对头的 goroutine 将出列,并结束 waiting 状态】;
- 如果 sendq 不为空,且有缓冲区,则说明缓冲区已满,此时从缓冲区首部读出数据,把 sendq 头部的 goroutine 数据写入到缓冲区尾部,并将 goroutine 唤醒,结束读取过程;
- 如果 sendq 为空,缓冲区有数据,则直接从缓冲区取数据;
- 如果 sendq 为空,且缓冲区没有数据或没有缓冲区,则当前的 goroutine 加入到 recvq,并进入 waiting 状态,等待输入数据到 channel 的 goroutine 将其唤醒。
注意事项:
- sendq 和 recvq 这些队列都是单个 channel 内部的成员,因此其中保存的 goroutine 都是要操作当前 channel 的 goroutine;
- 对于没有缓冲区的 channel,当 goroutine A 向 channel 写入数据时,比如保证有另一个 goroutine B 读取,如果没有 B 读取,那么 A 将进入 sendq,状态变为 waiting,即阻塞地等待另一个 goroutine 读取数据;
- 同理,对于没有缓冲区的 channel,goroutine B 直接从中读取数据将直接使 goroutine 进入 waiting 状态,直到有 goroutine A 向 channel 输入数据;
- 理清楚缓冲区大小为 1 和 没有缓冲区的 channel 之间的区别:没有缓冲区的 channel 要求 goroutine 在读取时,必须有一个 goroutine 在阻塞地写入,否则读取的 goroutine 阻塞等待数据写入;反之,goroutine 写入没有缓冲区的 channel 时,必须有一个 goroutine 在阻塞地等待接收数据,否则写入的 goroutine 阻塞。对于缓冲区大小为 1 的 channel,写入的 goroutine 可以在 buffer 为空时直接写入,此时 buffer 满了,其它 goroutine 写入时将被阻塞;如果 buffer 为空,有 goroutine 从 buffer 读取数据也将被阻塞,而如果 buffer 满了,即其中有一个数据,那么读取的 goroutine 可以直接将数据读走。
channel 是否线程安全?
channel 是线程安全的。
不同 goroutine 通过 channel 进行通信,本身的使用场景就是多线程,为了保证数据的一致性,必须实现线程安全。
channel 如何实现线程安全?
channel 的底层实现中,hchan 结构体使用 mutex 锁确保数据读写的按权。在对 hchan 中 buf 的数据进行入队和出队的操作时,必须先获取互斥锁,才能操作 channel 中的数据。
channel 的应用场景?
任务定时
go
select {
case <- time.After(time.Second)
}
time.After
返回一个 channel(<- chan time.Time
),在指定的时间间隔后 ,该 channel 会收到一个时间值。time.After
常用于超时控制或延迟操作,特点是只能触发一次。
一个更高阶的例子如下:
go
select {
case <-time.After(2 * time.Second):
fmt.Println("2秒后执行")
case <-someOtherChannel:
fmt.Println("其他通道先收到消息")
}
在该例中,如果 someOtherChannel
在 2 秒内没有收到消息,time.After
会在 2 秒后触发,执行相应的操作。
定时任务
go
select {
case <- time.Tick(time.Second)
}
time.Tick
返回一个 channel(<- chan time.Time
),每隔指定的时间间隔 ,这个 channel 会收到一个时间值。time.Tick
的用途是定时任务或周期性操作 。time.Tick
的特点是会持续触发,每隔指定的时间间隔发送一次时间值到通道。
一个更高阶的例子如下:
go
ticker := time.Tick(1 * time.Second)
for {
select {
case <-ticker:
fmt.Println("每秒执行一次")
case <-someOtherChannel:
fmt.Println("其他通道收到消息")
return
}
}
在该例中,time.Tick
会每个 1 秒发送一次时间值到通道,直到 someOtherChannel
收到消息为止。
注意事项 :
在 Golang 当中,select
是单次执行的,如果没有 for 循环,那么 select 在单次执行之后会退出,继续执行 select 语句块之后的代码。
解耦生产者和消费者
基于 channel 可以将生产者和消费者解耦,生产者只需要向 channel 发送数据,而消费者只管从 channel 中读取数据。
以 Zinx 框架当中的 Connection 类型的读写分离模型为例,Connection 在 Start 之后,会分别通过 StartReader 和 StartWriter 两个 goroutine 分别开启从 TCP 连接中读取数据和向连接中发送数据的 goroutine。StartReader 在读取数据并进行业务处理之后,得到了业务数据,这个时候要回写到 conn 当中。非解耦的做法是直接在 StartReader 当中将数据写回到 conn 当中,而读写分离的逻辑是通过 channel 将业务处理的结果发送到 StartWriter 这个 goroutine,在这个 goroutine 中将数据回写到 conn 当中。
控制并发数
以爬虫为例,如果需要爬取 1w 条数据,需要并发爬取以提升效率,但并发量不能过大,可以通过 channel 来控制并发规模,比如同时支持 5 个并发任务:
go
ch := make(chan int, 5)
for _, url := range urls {
go func {
ch <- 1
worker(url)
<- ch
}
}
select 的用途?
select 可以理解为在语言层面实现了和 I/O 多路复用类似的功能:监听多个描述符的读/写事件,一旦某个描述符就绪(一般是读或写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。
golang 的 select 机制如下:监听多个 channel,每个 case 是一个事件,可以是读事件也可以是写事件,随机选择一个执行。可以设置 default,其作用是在监听的多个事件都阻塞时,执行 default 逻辑:
go
select {
case <-ch1:
// 如果从 ch1 信道成功接收数据,则执行该分支代码
case ch2 <- 1:
// 如果成功向 ch2 信道成功发送数据,则执行该分支代码
default:
// 如果上面都没有成功,则进入 default 分支处理流程
}
注意事项:
- select 语句只能用于 channel 的读写操作;
- select 中的 case 条件(非阻塞)是并发执行的,select 会选择先操作成功的那个 case 去执行,如果多个 case 同时成立,则随机选择一个执行,因此无法保证顺序;
- 对于 case,如果存在 channel 为 nil 的情况,则该分支将被忽略,可以理解为从 select 中删除了这个 case;
- 可以设置一个任务定时的 case,比如使用
time.After
,它通常会替代 default,即:如果在指定时间内没有任务执行,那么就执行time.After
这个 case 对应的语句块,否则执行相应的成立的 case; - 空的
select{}
会引起 deadlock; - 对于 for loop 当中的
select{}
,可能会引起 CPU 占用过高的问题。
defer 的概述?
defer 是 golang 提供的一种用于注册延迟调用的机制:defer 能够让函数或语句在当前函数执行完毕之后(包括 return 正常结束和 panic 导致的异常退出)进行调用。
defer 的特性:
- 延迟调用:defer 在 main return 前调用,且 defer 必须置于函数内部;
- LIFO:Last In First Out,后进先出,压栈式执行;
- 作用域:如果一个 defer 处于某个匿名函数当中,那么会先调用这个匿名函数中的 defer。
defer 的使用场景?
defer 通常出现在一些成对操作中,比如创建和关闭连接、加锁和解锁、打开文件与关闭文件等。总得来说,defer 在一些资源回收的场景中很有用。
并发处理
go
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 程序逻辑
}()
}
wg.Wait()
锁
go
mu.RLock()
defer mu.RUnlock()
资源释放
go
// new 一个客户端 client;
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
// 释放该 client ,也就是说该 client 的声明周期就只在该函数中;
defer cli.Close()
panic-recover
go
defer func() {
if v := recover(); v != nil {
_ = fmt.Errorf("PANIC=%v", v)
}
}()
defer 的底层原理?
defer 的结构中主要包括:siz 属性,用于标识返回值的内存和大小;heap 属性,用于标识该结构在栈上分配还是在堆上分配;sp 是栈指针、pc 是程序计数器、fn 是传入的函数地址、link 是 defer 链表。
go
type _defer struct {
siz int32 // 参数和返回值的内存大小
started bool
heap bool // 区分该结构是在栈上分配的,还是对上分配的
sp uintptr // sp 计数器值,栈指针;
pc uintptr // pc 计数器值,程序计数器;
fn *funcval // defer 传入的函数地址,也就是延后执行的函数;
_panic *_panic // panic that is running defer
link *_defer // 链表
}
link 将 defer 串成一个链表,表头是挂载在 goroutine 的 _defer 属性。defer 结构只是一个头结构,后面跟着延迟函数的参数和返回值空间,内存在defer关键字执行的时候填充。
defer 函数和 return 的执行顺序?
执行顺序如下:
首先,return
语句执行:
return
先计算返回值(如果有返回值的话),并将返回值存储到函数的返回变量中;- 如何返回值是命名返回值(named return value),
return
会将值赋给命名变量;
之后,defer
函数链执行:
return
完成返回值计算后,defer
开始执行;defer
可以访问和修改命名的返回值;
最后,函数真正地返回:
defer
函数链执行完毕后,函数才真正地返回给调用者。
WaitGroup 使用的注意事项?
补充:什么是 WaitGroup?
在 Golang 当中,sync.WaitGroup
是一个用于等待一组 goroutine 完成执行的同步工具。它非常适合在需要等待多个并发任务完成后再继续执行的场景。
WaitGroup 的作用:
- goroutine 计数:通过计数器跟踪正在执行的 goroutine 数量;
- 阻塞等待:主 goroutine 可以调用
Wait
方法阻塞,直到所有被跟踪的 goroutine 完成执行; - 动态增减:可以在运行时动态增减 goroutine 的计数;
WaitGroup 的核心方法 :
(1)Add(delta int)
- 功能:增加或减少 WaitGroup 的计数器;
- 参数 delta 可正(增加计数)可负(减少计数);
- 通常在启动新的 goroutine 之前调用
Add(1)
;
(2)Done()
- 功能:
WaitGroup
的计数器减一; - 等价于
Add(-1)
; - 常在 goroutine 完成时调用
Done()
;
(3)Wait()
- 功能:阻塞当前 goroutine,直到
WaitGroup
的计数器变为 0; - 通常在 main goroutine 调用,阻塞地等待所有子 goroutine 完成。
注意事项
(1)计数器的初始值:
计数器的初始值必须为 0,如果是负数将触发 panic;
(2)Add
和 Done
的调用顺序:
- Add 必须在启动 goroutine 之前调用;
- Done 必须在 goroutine 完成后调用,通常使用 defer 确保调用;
(3)WaitGroup
值传递:
WaitGroup
是值类型,传递时应使用指针(如 &wg
),否则会导致计数器无法正确更新。
(4)避免竞争条件:
如果多个 goroutine 同时修改 WaitGroup
的计数器,可能会导致竞争条件。可以使用 sync.Mutex
或 sync/atomic
包来保护计数器。
使用示例
go
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 增加计数器
go worker(i, &wg)
}
wg.Wait() // 阻塞,直到计数器为 0
fmt.Println("所有 goroutine 完成")
}
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // goroutine 完成时减少计数器
fmt.Printf("Worker %d 开始工作\n", id)
time.Sleep(time.Second * time.Duration(id)) // 模拟工作耗时
fmt.Printf("Worker %d 完成工作\n", id)
}
与 channel 对比
- WaitGroup:适合简单的等待场景,代码更简洁。
- channel:适合需要 goroutine 之间通信的场景,功能更强大但代码更复杂。