【面试题】Golang 之Channel底层原理 (第三篇)

server/2024/9/24 16:29:03/

目录

1.常见channel三大坑:死锁、内存泄漏、panic

1.死锁

1.只有生产者,没有消费者,或者反过来

2 生产者和消费者出现在同一个 goroutine 中

3 buffered channel 已满,且在同一个goroutine中

2.内存泄露

1 如何实现 goroutine 泄漏呢?

2 生产者阻塞导致泄漏

3 消费者阻塞导致泄漏

4 如何预防内存泄漏

3.panic

1 向已经 close 掉的 channel 继续发送数据

2 多次 close 同一个 channel

3 如何优雅地 close channel

1 需要检查 channel 是否关闭吗?

2 需要 close 吗?为什么?

3 谁来关?

2.channel底层原理

3.channel为什么线程安全?

4.Go channel如何控制goroutine并发执行顺序?

5.Go channel共享内存有什么优劣势?

6.Go语言中Channel缓冲有什么特点?

1. 容量固定

2. 非阻塞发送与接收

3. 阻塞发送与接收

4. 异步通信与解耦

5. 性能提升

6. 注意事项

7.channel 中的ring buffer实现

8.channel有无缓冲的区别

一、缓冲区大小

二、通信机制

三、适用场景

四、性能影响


1.常见channel三大坑:死锁、内存泄漏、panic

在使用 channel 进行 goroutine 之间的通信时,有时候场面会变得十分复杂,以至于写出难以觉察、难以定位的偶现 bug,而且上线的时候往往跑得好好的,直到某一天深夜收到服务挂了、OOM 了之类的告警…… 来梳理一下使用 channel 中常见的三大坑:panic、死锁、内存泄漏,做到防患于未然。

1.死锁

go 语言新手在编译时很容易碰到这个死锁的问题:

fatal error: all goroutines are asleep - deadlock!

这个就是喜闻乐见的「死锁」了…… 在操作系统中,学过「死锁」就是两个线程互相等待,耗在那里,最后程序不得不终止。

go 语言中的「死锁」也是类似的,两个 goroutine 互相等待,导致程序耗在那里,无法继续跑下去。看了很多死锁的案例后,channel 导致的死锁可以归纳为以下几类案例(先讨论 unbuffered channel 的情况)

1.只有生产者,没有消费者,或者反过来

channel 的生产者和消费者必须成对出现,如果缺乏一个,就会造成死锁,例如:

// 只有生产者,没有消费者
func f1() {ch := make(chan int)ch <- 1
}
​
// 只有消费者,没有生产者
func f2() {ch := make(chan int)<-ch
}

2 生产者和消费者出现在同一个 goroutine 中

除了需要成对出现,还需要出现在不同的 goroutine 中,例如:

// 同一个 goroutine 中同时出现生产者和消费者
func f3() {ch := make(chan int)ch <- 1  // 由于消费者还没执行到,这里会一直阻塞住<-ch
}
​

对于 buffered channel 则是下面这种情况

3 buffered channel 已满,且在同一个goroutine中

buffered channel 会将收到的元素先存在 hchan 结构体的 ringbuffer 中,继而才会发生阻塞。而当发生阻塞时,如果阻塞了主 goroutine ,则也会出现死锁

所以实际使用中,推荐尽量使用 buffered channel ,使用起来会更安全,在下文的「内存泄漏」相关内容也会提及。

2.内存泄露

内存泄漏一般都是通过 OOM(Out of Memory) 告警或者发布过程中对内存的观察发现的,服务内存往往都是缓慢上升,直到被系统 OOM 掉清空内存再周而复始。

在 go 语言中,错误地使用 channel 会导致 goroutine 泄漏,进而导致内存泄漏。

1 如何实现 goroutine 泄漏呢?

不会修 bug,还不会写 bug 吗?让 goroutine 泄漏的核心就是:

生产者/消费者 所在的 goroutine 已经退出,而其对应的 消费者/生产者 所在的 goroutine 会永远阻塞住,直到进程退出

2 生产者阻塞导致泄漏

一般会用 channel 来做一些超时控制,例如下面这个例子:

func leak1() {ch := make(chan int)// g1go func() {time.Sleep(2 * time.Second) // 模拟 io 操作ch <- 100                   // 模拟返回结果}()
​// g2// 阻塞住,直到超时或返回select {case <-time.After(500 * time.Millisecond):fmt.Println("timeout! exit...")case result := <-ch:fmt.Printf("result: %d\n", result)}
}
​

这里用 goroutine g1 来模拟 io 操作,主 goroutine g2 来模拟客户端的处理逻辑,

(1)假设客户端超时为 500ms,而实际请求耗时为 2s,则 select 会走到 timeout 的逻辑,这时 g2 退出,channel ch 没有消费者,会一直在等待状态,输出如下:

Goroutine num: 1 timeout! exit... Goroutine num: 2

如果这是在 server 代码中,这个请求处理完后,g1 就会挂起、发生泄漏了,就等着 OOM 吧 。

(2)假设客户端超时调整为 5000ms,实际请求耗时 2s,则 select 会进入获取 result 的分支,输出如下:

Goroutine num: 1 result: 100 Goroutine num: 1

3 消费者阻塞导致泄漏

如果生产者不继续生产,消费者所在的 goroutine 也会阻塞住,不会退出,例如:

func leak2() {ch := make(chan int)
​// 消费者 g1go func() {for result := range ch {fmt.Printf("result: %d\n", result)}}()
​// 生产者 g2ch <- 1ch <- 2time.Sleep(time.Second)  // 模拟耗时fmt.Println("main goroutine g2 done...")
}
​

这种情况下,只需要增加 close(ch) 的操作即可,for-range 操作在收到 close 的信号后会退出、goroutine 不再阻塞,能够被回收。

4 如何预防内存泄漏

预防 goroutine 泄漏的核心就是:创建 goroutine 时就要想清楚它什么时候被回收。

具体到执行层面,包括:

当 goroutine 退出时,需要考虑它使用的 channel 有没有可能阻塞对应的生产者、消费者的 goroutine; 尽量使用 buffered channel使用 buffered channel 能减少阻塞发生、即使疏忽了一些极端情况,也能降低 goroutine 泄漏的概率;

3.panic

panic 就更刺激了,一般是测试的时候没发现,上线之后偶现,程序挂掉,服务出现一个超时毛刺后触发告警。channel 导致的 panic 一般是以下几个原因:

1 向已经 close 掉的 channel 继续发送数据

先举一个简单的栗子:

func p1() {ch := make(chan int, 1)close(ch)ch <- 1
}
// panic: send on closed channel
​

在实际开发过程中,处理多个 goroutine 之间协作时,可能存在一个 goroutine 已经 close 掉 channel 了,另外一个不知道,也去 close 一下,就会 panic 掉,例如:

func p1() {ch := make(chan int, 1)done := make(chan struct{}, 1)go func() {<- time.After(2*time.Second)println("close2")close(ch)close(done)}()go func() {<- time.After(1*time.Second)println("close1")ch <- 1close(ch)}()
​<-done
}
​

万恶之源就是在 go 语言里,是无法知道一个 channel 是否已经被 close 掉的,所以在尝试做 close 操作的时候,就应该做好会 panic 的准备……

2 多次 close 同一个 channel

同上,在尝试往 channel 里发送数据时,就应该考虑

这个 channel 已经关了吗? 这个 channel 什么时候、在哪个 goroutine 里关呢? 谁来关呢?还是干脆不关?

3 如何优雅地 close channel

1 需要检查 channel 是否关闭吗?

刚遇到上面说的 panic 问题时,也试过去找一个内置的 closed 函数来检查关闭状态,结果发现,并没有这样一个函数……

那么,如果有这样的函数,真能彻底解决 panic 的问题么?答案是不能。因为 channel 是在一个并发的环境下去做收发操作,就算当前执行 closed(ch) 得到的结果是 false,还是不能直接去关,例如代码:

if !closed(ch) { // 返回 false // 在这中间出了幺蛾子! close(ch) // 还是 panic 了…… }

遵循 less is more 的原则,这个 closed 函数是要不得了

2 需要 close 吗?为什么?

结论:除非必须关闭 chan,否则不要主动关闭。关闭 chan 最优雅的方式,就是不要关闭 chan~

当一个 chan 没有 sender 和 receiver 时,即不再被使用时,GC 会在一段时间后标记、清理掉这个 chan。那么什么时候必须关闭 chan 呢?

比较常见的是将 close 作为一种通知机制,尤其是生产者与消费者之间是 1:M 的关系时,通过 close 告诉下游:我收工了,你们别读了。

3 谁来关?

chan 关闭的原则:

Don’t close a channel from the receiver side 不要在消费者端关闭 chan Don’t close a channel if the channel has multiple concurrent senders 有多个并发写的生产者时也别关 只要遵循这两条原则,就能避免两种 panic 的场景,即:向 closed chan 发送数据,或者是 close 一个 closed chan。

按照生产者和消费者的关系可以拆解成以下几类情况:

一写一读:生产者关闭即可 一写多读:生产者关闭即可,关闭时下游全部消费者都能收到通知 多写一读:多个生产者之间需要引入一个协调 channel 来处理信号 多写多读:与 3 类似,核心思路是引入一个中间层以及使用 try-send 的套路来处理非阻塞的写入.

代码示例:

func main() {rand.Seed(time.Now().UnixNano())log.SetFlags(0)const Max = 100000const NumReceivers = 10const NumSenders = 1000wgReceivers := sync.WaitGroup{}wgReceivers.Add(NumReceivers)dataCh := make(chan int)stopCh := make(chan struct{})// stopCh 是额外引入的一个信号 channel.// 它的生产者是下面的 toStop channel,// 消费者是上面 dataCh 的生产者和消费者toStop := make(chan string, 1)// toStop 是拿来关闭 stopCh 用的,由 dataCh 的生产者和消费者写入// 由下面的匿名中介函数(moderator)消费// 要注意,这个一定要是 buffered channel (否则没法用 try-send 来处理了)var stoppedBy string// moderatorgo func() {stoppedBy = <-toStopclose(stopCh)}()// sendersfor i := 0; i < NumSenders; i++ {go func(id string) {for {value := rand.Intn(Max)if value == 0 {// try-send 操作// 如果 toStop 满了,就会走 default 分支啥也不干,也不会阻塞select {case toStop <- "sender#" + id:default:}return}// try-receive 操作,尽快退出// 如果没有这一步,下面的 select 操作可能造成 panicselect {case <- stopCh:returndefault:}// 如果尝试从 stopCh 取数据的同时,也尝试向 dataCh// 写数据,则会命中 select 的伪随机逻辑,可能会写入数据select {case <- stopCh:returncase dataCh <- value:}}}(strconv.Itoa(i))}// receiversfor i := 0; i < NumReceivers; i++ {go func(id string) {defer wgReceivers.Done()for {// 同上select {case <- stopCh:returndefault:}// 尝试读数据select {case <- stopCh:returncase value := <-dataCh:if value == Max-1 {select {case toStop <- "receiver#" + id:default:}return}log.Println(value)}}}(strconv.Itoa(i))}wgReceivers.Wait()log.Println("stopped by", stoppedBy)
}
​

2.channel底层原理

1) 概念 Go 中channel 是一个先进先出(FIFO)的队列,负责协程之间的通信(Go语言提倡不要通过共享内存来通信,而要通过通信的方式实现共享内存),其中CSP并发模型就是通过goroutine 和 channel来实现的。

2) 使用场景

停止信号监听、定时任务、生产方与消费方解耦、控制并发数

3) 底层数据结构 channel 的整体结构

简单说明:

buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表 sendx和recvx用于记录buf这个循环链表中的发送或者接收的index lock是个互斥锁。 recvq和sendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表 源码位于/runtime/chan.go中。结构体为hchan。

type hchan struct {closed uint32        // 标识关闭状态:表示当前通道是否处于关闭状态。创建通道后,该字段设置为0,即通道打开; 通过调用close将其设置为1,通道关闭。qcount uint          // 当前队列列中剩余元素个数dataqsiz uint        // 环形队列长度,即可以存放的元素个数即缓冲区的大小,即make(chan T,N),N.buf unsafe.Pointer   // 环形队列列指针,ring buffer 环形队列elemsize uint16      // 每个元素的⼤⼩elemtype *_type      // 元素类型:用于数据传递过程中的赋值;sendx uint           // 队列下标,指示元素写⼊入时存放到队列列中的位置 xrecvx uint           // 队列下标,指示元素从队列列的该位置读出  recvq waitq          // 等待读消息的goroutine队列sendq  waitq         // 等待写消息的goroutine队列lock mutex           // 互斥锁,chan不允许并发读写
} type waitq struct {first *sudoglast  *sudog
}

从数据结构可以看出channel由队列、类型信息、goroutine等待队列组成。

4) 实现方式 创建channel 有两种,一种是带缓冲的channel,一种是不带缓冲的channel

// 带缓冲 ch := make(chan Task, 6) // 不带缓冲 ch := make(chan int)

下图展示了可缓存6个元素的channel底层的数据模型如下图:

func makechan(t *chantype, size int) *hchan { elem := t.elem }

说明: dataqsiz:指向队列的长度为6,即可缓存6个元素 buf:指向队列的内存,队列中还剩余两个元素 qcount:当前队列中剩余的元素个数 sendx:指后续写入元素的位置 recvx:指从该位置读取数据

3.channel为什么线程安全?

Go channel是线程安全的,因为它内部实现了同步机制。

当一个goroutine向channel中写入数据时,如果channel已满,则该goroutine会被阻塞,直到有其他goroutine从channel中取出数据为止;

反之,当一个goroutine从channel中取出数据时,如果channel为空,则该goroutine会被阻塞,直到有其他goroutine向channel中写入数据为止。

这种同步机制可以保证在多个goroutine同时操作同一个channel时,数据的读写是安全的,不会出现数据竞争等问题。因此,Go channel在并发编程中被广泛使用,它是一种高效、简单而又安全的并发通信机制。

4.Go channel如何控制goroutine并发执行顺序?

Go channel可以用来控制goroutine的并发执行顺序,通过channel的特性可以实现同步和异步的调用方式,从而控制goroutine的执行顺序。

同步调用:使用无缓冲的channel,当goroutine A向channel发送数据时会阻塞,直到有goroutine B从channel接收数据,这样就能保证goroutine A在goroutine B执行完之后再执行。 异步调用:使用有缓冲的channel,可以让多个goroutine同时向channel发送数据,然后再由其他goroutine从channel接收数据。这样可以实现并发的执行顺序。 另外,可以使用select语句来控制多个channel的并发执行顺序,以及使用sync包中的WaitGroup来等待所有goroutine执行完毕再继续执行下一步操作。

5.Go channel共享内存有什么优劣势?

Go语言中的Channel是一种用于在不同Goroutine之间进行通信和同步的机制,它可以看作是一种共享内存的方式。Channel的优势在于:

线程安全:Go语言中的Channel是线程安全的,在并发编程中可以有效避免竞态条件和锁问题。 同步性:使用Channel可以实现两个Goroutine之间的同步,一个Goroutine在读取Channel中的数据时,会一直等待直到有写入操作,这种同步性对于一些并发编程任务非常有用。 协作性:使用Channel可以协调多个Goroutine的执行,通过传递消息来控制它们执行的顺序和方式。 但是,Channel也有一些劣势:

限制性:Channel只能用于同一个进程内部的Goroutine之间通信,无法用于多个不同进程之间的通信。 主动性:Channel的读取和写入都是被动的,即读取方必须等待写入方写入数据。 内存消耗:Channel会消耗一定的内存,如果不及时关闭Channel,可能会造成内存泄漏。 因此,在实际应用中,我们需要根据具体场景来选择使用Channel还是其他的共享内存方式。

在使用channel有这么几点要注意

确保所有数据发送完后再关闭channel,由发送方来关闭 不要重复关闭channel 不要向为nil的channel里面发送值 不要向为nil的channel里面接收值 接收数据时,可以通过返回值判断是否ok n , ok := <- c 这样防止channel被关闭后返回了零值,对业务造成影响

6.Go语言中Channel缓冲有什么特点?

Go语言中的Channel缓冲具有以下几个显著特点:

1. 容量固定

  • 定义容量:缓冲Channel在创建时需要指定一个容量,这个容量表示Channel可以存储的元素数量。例如,ch := make(chan int, 3)创建了一个容量为3的整型缓冲Channel。
  • 固定性:一旦Channel的容量被设定,它就是固定的,不能更改。

2. 非阻塞发送与接收

  • 非阻塞发送:当向缓冲Channel发送元素时,如果缓冲区未满(还有剩余容量),发送操作会立即完成,并将元素存储在缓冲区中。这使得发送操作不会阻塞,即使没有接收方也可以继续发送元素。
  • 非阻塞接收:当从缓冲Channel接收元素时,如果缓冲区非空(有元素可用),接收操作会立即完成,并将缓冲区中的元素传递给接收方。这使得接收操作不会阻塞,即使没有发送方也可以继续接收元素。

3. 阻塞发送与接收

  • 阻塞发送:当缓冲Channel的缓冲区已满时,继续向通道发送元素会导致发送操作阻塞,直到有接收方从通道中接收元素,腾出缓冲区空间。
  • 阻塞接收:当缓冲Channel的缓冲区为空时,从通道接收元素会导致接收操作阻塞,直到有发送方向通道发送元素。

4. 异步通信与解耦

  • 异步通信:使用缓冲Channel可以实现异步通信,发送和接收操作可以在不同的时间进行,只要缓冲区有足够的空间或者有元素可用。
  • 时间解耦:缓冲Channel解耦了发送方和接收方的时间,使得它们不需要同时准备好即可进行通信。

5. 性能提升

  • 避免阻塞:缓冲Channel可以减少因等待对方准备好而导致的阻塞,从而提高程序的执行效率。
  • 高效数据传输:在并发场景下,缓冲Channel可以存储一定量的数据,从而避免发送方因等待接收方而浪费资源,提高了数据传输的效率。

6. 注意事项

  • 同步发送与接收:虽然缓冲Channel可以实现异步通信,但在发送和接收数据时仍需遵循同步原则,即发送方在发送完数据后应等待接收方接收完毕,以避免数据竞争等问题。
  • 避免死锁:在使用缓冲Channel时,需要确保发送和接收的操作是匹配的,否则可能会导致死锁或其他并发问题。

综上所述,Go语言中的Channel缓冲通过其容量固定、非阻塞/阻塞发送与接收、异步通信与解耦等特点,为并发编程提供了强大的支持。

7.channel 中的ring buffer实现

channel 中使用了 ring buffer(环形缓冲区) 来缓存写入的数据。ring buffer 有很多好处,而且非常适合用来实现 FIFO 式的固定长度队列。 在 channel 中,ring buffer 的实现如下:

hchan 中有两个与 buffer 相关的变量:recvx 和 sendx。其中 sendx 表示 buffer 中可写的 index,recvx 表示 buffer 中可读的 index。 从 recvx 到 sendx 之间的元素,表示已正常存放入 buffer 中的数据。 我们可以直接使用 buf[recvx]来读取到队列的第一个元素,使用 buf[sendx] = x 来将元素放到队尾。

8.channel有无缓冲的区别

Go语言中的Channel有无缓冲的区别主要体现在以下几个方面:

一、缓冲区大小

  • 无缓冲Channel:其缓冲区大小为0,即不能存储任何数据。这意味着数据必须即时从发送方传输到接收方,不能有任何延迟。
  • 有缓冲Channel:其缓冲区大小可以设定(通常大于0),用于存储待传输的数据。这意味着发送方可以在缓冲区未满的情况下继续发送数据,而接收方也可以在缓冲区非空的情况下继续接收数据。

二、通信机制

  • 无缓冲Channel:要求发送和接收操作同步进行。即发送方发送数据的操作必须与接收方接收数据的操作同时发生,否则两者都会进入阻塞状态。这种机制保证了数据的即时传输,但可能会限制程序的并发性能。
  • 有缓冲Channel:允许发送和接收操作在一定程度上解耦。发送方可以在缓冲区未满时继续发送数据,而无需等待接收方立即接收;同样,接收方也可以在缓冲区非空时继续接收数据,而无需等待发送方发送新数据。这种机制提高了程序的并发性能,但也可能导致数据在缓冲区中滞留过久。

三、适用场景

  • 无缓冲Channel:适用于对实时性要求极高、需要即时响应的场景。例如,实时通信、实时数据处理等场景,这些场景要求数据必须立即传输和处理,不能有任何延迟。
  • 有缓冲Channel:适用于对实时性要求相对较低、但需要提高并发性能的场景。例如,大规模数据传输、需要保持数据同步的系统等场景,这些场景允许数据在缓冲区中暂时滞留,以换取更高的并发性能和吞吐量。

四、性能影响

  • 无缓冲Channel:由于要求发送和接收操作同步进行,因此可能会限制程序的并发性能。但是,由于数据即时传输,因此可以减少因数据滞留而导致的延迟。
  • 有缓冲Channel:通过允许发送和接收操作解耦,可以提高程序的并发性能。但是,如果缓冲区设置不当(如过大或过小),可能会导致资源浪费或数据滞留过久的问题。因此,在设置缓冲区大小时需要根据具体场景进行权衡。

综上所述,Go语言中的Channel有无缓冲的区别主要体现在缓冲区大小、通信机制、适用场景以及性能影响等方面。开发者在选择使用哪种类型的Channel时需要根据具体的应用需求和系统约束进行权衡和选择。


http://www.ppmy.cn/server/62225.html

相关文章

DP讨论——适配器模式

学而时习之&#xff0c;温故而知新。 敌人出招&#xff08;使用场景&#xff09; 说是自己的程序对接第三方的库&#xff0c;但是自己的代码的接口设计完毕了&#xff0c;如何对接上&#xff1f; 你出招 适配器模式就是为此而生的——我觉得应该是该解决方法被命名为了适配…

房屋出租管理系统小程序需求分析及功能介绍

房屋租赁管理系统适用于写字楼、办公楼、厂区、园区、商城、公寓等商办商业不动产的租赁管理及租赁营销&#xff1b;提供资产管理&#xff0c;合同管理&#xff0c;租赁管理&#xff0c; 物业管理&#xff0c;门禁管理等一体化的运营管理平台&#xff0c;提高项目方管理运营效率…

从 Icelake 到 Iceberg Rust

本文作者丁皓是Databend 研发工程师&#xff0c;也是 ASF Member&#xff0c; Apache OpenDAL PMC Chair &#xff0c;主要研究领域包括存储、自动化与开源。 太长不看 Icelake 已经停止更新&#xff0c;请改用 iceberg-rust。 Iceberg-rust 是一个由社区驱动的项目&#xff0…

[ruby on rails]部署时候产生ActiveRecord::PreparedStatementCacheExpired错误的原因及解决方法

一、问题&#xff1a; 有时在 Postgres 上部署 Rails 应用程序时&#xff0c;可能会看到 ActiveRecord::PreparedStatementCacheExpired 错误。仅当在部署中运行迁移时才会发生这种情况。发生这种情况是因为 Rails 利用 Postgres 的缓存准备语句(PreparedStatementCache)功能来…

51单片机5(GPIO简介)

一、序言&#xff1a;不论学习什么单片机&#xff0c;最简单的外设莫过于I口的高低电平的操作&#xff0c;接下来&#xff0c;我们将给大家介绍一下如何在创建好的工程模板上面&#xff0c;通过控制51单片机的GPIO来使我们的开发板上的LED来点亮。 二、51单片机GPIO介绍&#…

Nginx入门到精通七(Nginx原理)

下面内容整理自bilibili-尚硅谷-Nginx青铜到王者视频教程 Nginx相关文章 Nginx入门到精通一&#xff08;基本概念介绍&#xff09;-CSDN博客 Nginx入门到精通二&#xff08;安装配置&#xff09;-CSDN博客 Nginx入门到精通三&#xff08;Nginx实例1&#xff1a;反向代理&a…

GPS北斗标准时钟同步服务器结构是什么?安徽京准

GPS北斗标准时钟同步服务器结构是什么&#xff1f;安徽京准 GPS北斗标准时钟同步服务器结构是什么&#xff1f;安徽京准 电厂时钟同步系统组成及配置 随着计算机和网络通信技术的飞速发展&#xff0c;火电厂热工自动化系统数字化、网络化的时代已经到来。一方面它为控制和信息系…

如何用python写接口

如何用python写接口&#xff1f;具体步骤如下&#xff1a;  1、实例化server 2、装饰器下面的函数变为一个接口 3、启动服务 开发工具和流程&#xff1a; python库&#xff1a;flask 》实例化server&#xff1a;server flask.Flask(__name__) 》server.route(/index,met…