使用 channel 导致的死锁和内存泄漏问题

使用 channel 导致的死锁和内存泄漏问题

死锁出现的条件

死锁有三个必要条件他们分别是循环等待、资源共享、非抢占式,在 go 并发中出现通道死锁只有两种情况:

  1. 数据要发送,但是没有人接收;
  2. 数据要接收,但是没有人发送;

发送单个值的死锁

在下面这种情况下,就会出现死锁的情况:

a := make(chan int)
a <- 1   //将数据写入channel
z := <-a //从channel中读取数据

原因在于:

  • 有且只有一个协程时,无缓冲的通道
  • 先发送会阻塞在发送,先接收会阻塞在接收处。
  • 发送操作在接收者准备好之前是阻塞的,接收操作在发送之前是阻塞的。

解决办法就是改为缓冲通道,或者使用协程配对

协程配对,先发送还是先接收无所谓只要配对就好

chanInt := make(chan int)
go func() {
    chanInt <- 1
}()

res := <-chanInt

缓冲通道

chanInt := make(chan int,1)
chanInt <- 2
res := <-chanInt
  • 缓冲通道内部的消息数量用 len() 函数可以测试出来
  • 缓冲通道的容量可以用 cap() 测试出来
  • 在满足 cap>len 时候,因为没有满,发送不会阻塞
  • len>0 时,因为不为空,所以接收不会阻塞

使用缓冲通道可以让生产者和消费者减少阻塞的可能性,对异步操作更友好,不用等待对方准备,但是容量不应设置过大,不然会占用较多内存。

多个值发送产生的死锁

配对可以让死锁消失,但如果发送多个值,这时候由于无法配对了,又会产生死锁:

func multipleDeathLock() {
 chanInt := make(chan int)
 defer close(chanInt)
 go func() {
  res := <-chanInt
  fmt.Println(res)
 }()
 chanInt <- 1
 chanInt <- 1
}

循环接收值解决死锁

循环来不断接收值,接受一个处理一个,可以解决上述的死锁问题,如下:

func multipleLoop() {
 chanInt := make(chan int)
 defer close(chanInt)
 go func() {
  for {
   //不使用ok会goroutine泄漏
   //res := <-chanInt
   res,ok := <-chanInt
   if !ok {
                 break
            }
   fmt.Println(res)
  }
 }()
 chanInt <- 1
 chanInt <- 1
}
  • 给通道的接收加上二个值,ok 代表通道是否正常,如果是关闭则为false
  • 如果不使用 ok 判断,会输出 1 1 0 0 0 这样的数列,因为关闭是需要时间的,而循环接收关闭的通道拿到的是 0,另外还会导致goroutine泄漏(后面讲到)

先接收还是先发送

在上面的例子中,使用的是先开一个协程来接收,再进行发送的过程。如果反过来,在协程中进行发送:

func multipleDeathLock2() {
 chanInt := make(chan int)
 defer close(chanInt)
 go func() {
  chanInt <- 1
  chanInt <- 2
 }()
 for {
  res, ok := <-chanInt
  if !ok {
   break
  }
  fmt.Println(res)
 }
}

此时运行程序会导致死锁:

1
2
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.multipleDeathLock2()
  • 出现上面的结果是因为 for 循环一直在获取通道中的值,但是在读取完 1 2 后,通道中没有新的值传入,而且此时不会执行 defer close(chanInt),这样接收者就阻塞了。
  • 为什么先接收再发送可以,因为发送提前结束后会触发函数的 defer 自动关闭通道
  • 所以应该总是先接收后发送,并由发送端来关闭

goroutine 泄漏

goroutine 终止的场景有三个:

  • 当一个 goroutine 完成了它的工作
  • 由于发生了没有处理的错误
  • 有其他的协程告诉它终止

当三个条件都没有满足时,goroutine 就会一直运行下去。

func goroutineLeak() {
 chanInt := make(chan int)
 defer close(chanInt)
 go func() {
  for {
   res := <-chanInt
   //res,ok := <-chanInt
   //if !ok {
            //     break
            //}
   fmt.Println(res)
  }
 }()
 chanInt <- 1
 chanInt <- 1
}

上面程序运行后,后面会一直打印 0 :

1
1
0
0
0
...
  • 上面的 goroutineLeak() 函数结束后触发 defer close(chanInt) 关闭了通道
  • 但是匿名函数中 goroutine 并没有关闭,而是一直在循环取值,并且取到的是关闭后的通道值(这里是 int 的默认值 0)
  • goroutine 会永远运行下去,如果以后再次使用又会出现新的泄漏!导致内存、cpu占用越来越多。

假如不关闭且外部没有写入值,那接收处就会永远阻塞在那里,连输出都不会有:

func goroutineLeakNoClosed() {
 chanInt := make(chan int)
 go func() {
  for {
   res := <-chanInt
   fmt.Println(res)
  }
 }()
}
  • 无任何输出的阻塞
  • 换成写入也是一样的
  • 如果是有缓冲的通道,换成已满的通道写没有读;或者换成向空的通道读没有写也是同样的情况
  • 除了阻塞,goroutine进入死循环也是泄露的原因

要发现死锁,可以通过 pprof 工具。

参考文章

面试高频:Go语言死锁与goroutine泄露问题谈论