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

目录

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)
    // g1
    go 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)
​
    // 消费者 g1
    go func() {
        for result := range ch {
            fmt.Printf("result: %d\n", result)
        }
    }()
​
    // 生产者 g2
    ch <- 1
    ch <- 2
    time.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 <- 1
        close(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 = 100000
    const NumReceivers = 10
    const NumSenders = 1000
    
    wgReceivers := 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
    
    // moderator
    go func() {
        stoppedBy = <-toStop
        close(stopCh)
    }()
    
    // senders
    for 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 操作可能造成 panic
                select {
                    case <- stopCh:
                    return
                    default:
                    }
                
                // 如果尝试从 stopCh 取数据的同时,也尝试向 dataCh
                // 写数据,则会命中 select 的伪随机逻辑,可能会写入数据
                select {
                    case <- stopCh:
                    return
                    case dataCh <- value:
                    }
            }
        }(strconv.Itoa(i))
    }
    
    // receivers
    for i := 0; i < NumReceivers; i++ {
        go func(id string) {
            defer wgReceivers.Done()
            
            for {
                // 同上
                select {
                    case <- stopCh:
                    return
                    default:
                    }
                
                // 尝试读数据
                select {
                    case <- stopCh:
                    return
                    case 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           // 队列下标,指示元素写⼊入时存放到队列列中的位置 x
  recvx uint           // 队列下标,指示元素从队列列的该位置读出  
  
  recvq waitq          // 等待读消息的goroutine队列
  sendq  waitq         // 等待写消息的goroutine队列
  
  lock mutex           // 互斥锁,chan不允许并发读写
} 
 
type waitq struct {
    first *sudog
    last  *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时需要根据具体的应用需求和系统约束进行权衡和选择。

相关推荐

  1. 面试Golang Channel底层原理 ()

    2024-07-13 12:02:05       22 阅读
  2. 面试Golang互斥锁与读写锁()

    2024-07-13 12:02:05       22 阅读
  3. 面试】MySQL(

    2024-07-13 12:02:05       18 阅读
  4. 面试Golang垃圾回收机制(

    2024-07-13 12:02:05       22 阅读
  5. 面试Golang 锁的相关问题()

    2024-07-13 12:02:05       18 阅读
  6. 面试Golang (第一)

    2024-07-13 12:02:05       23 阅读
  7. 2024 前端高频面试 浏览器原理

    2024-07-13 12:02:05       54 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-13 12:02:05       67 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-13 12:02:05       72 阅读
  3. 在Django里面运行非项目文件

    2024-07-13 12:02:05       58 阅读
  4. Python语言-面向对象

    2024-07-13 12:02:05       69 阅读

热门阅读

  1. 数据结构

    2024-07-13 12:02:05       26 阅读
  2. MySQL 面试题

    2024-07-13 12:02:05       25 阅读
  3. 【笔记】在虚拟机中输入 yum makecache报错

    2024-07-13 12:02:05       26 阅读
  4. 如何使用键盘优雅地使用浏览器

    2024-07-13 12:02:05       29 阅读
  5. C++ 开源库

    2024-07-13 12:02:05       28 阅读
  6. Spark SQL----CREATE FUNCTION

    2024-07-13 12:02:05       22 阅读
  7. 有免代码开发平台,还需要学习软件工程吗?

    2024-07-13 12:02:05       22 阅读
  8. 建立共享linux第三方软件仓库

    2024-07-13 12:02:05       19 阅读
  9. mysql自动kill卡事务进程

    2024-07-13 12:02:05       22 阅读
  10. 计算机网络高频面试题

    2024-07-13 12:02:05       26 阅读
  11. 如何将已有的docker服务迁移至Kubernetes集群中

    2024-07-13 12:02:05       22 阅读
  12. 【Go系列】 函数与方法

    2024-07-13 12:02:05       21 阅读
  13. Mysql索引、执行计划、体系架构

    2024-07-13 12:02:05       26 阅读