go通道channel 死锁的场景和原因浅析

1,556 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

通道简介

go的通道channel是用于协程之间数据通信的一种方式,因为协程goroutine在运行的时候不保证顺序,且是并发(非并行)执行,数据的传递要么通过共享内存(比如指针)传递,要么就是通过一个额外的channel来传递。
其实推荐使用channel来传递数据主要是:

1.避免协程竞争和数据冲突问题,大家都去抢那个共享内存的指针是有冲突的,同时修改又有并发问题,还需要加锁
2.更高级抽象,降低开发难度,管道通信是一种通信方式,只需要监听即可,无需多次轮训来耗费资源
3.模块之间更容易解耦,增加扩展性和可维护性,通道有时候更像是生产者消费者模式,产生数据的协程和接受数据的协程无需知晓对方的存在,只专注自己的事就好

通道长啥样

type hchan struct {
     qcount   uint           
     dataqsiz uint         
     buf      unsafe.Pointer 
     elemsize uint16
     closed   uint32
     elemtype *_type 
     sendx    uint   
     recvx    uint   
     recvq    waitq  
     sendq    waitq  
     lock mutex
    }
  • qcount  channel 中的元素个数
  • dataqsiz channel 中循环缓存队列的长度
  • buf channel中缓存区的指针
  • elemsize channel收发(元素)的大小
  • elemtype channel收发(元素)的类型
  • closed 通道关闭的flag,一旦关闭将不能接受新的消息
  • sendx sendq,发送队列的指针和发送队列
  • recvx recvq ,接收队列的指针和接收队列
  • lock 锁,保护整个结构体

通道channel主要是设置了一个环状的缓冲区,其实就是个环形链表,好处就是降低gc的开销,缓冲区的大小是创建channel的时候传入的。当没有设置缓冲区的时候,缓冲区为0,这时候称该通道为无缓冲通道,反之就是有缓冲的通道。
关于两者的区别主要是在阻塞的过程上有差别。

  1. 对于无缓冲区的channel
    • 发送数据方在发送的时候,如果没有接收者,那么发送方的协程groutine阻塞在通道的sendq里面。
    • 接收数据方会一直等待数据到来,如果数据一直没有到来,那么接收方的协程groutine就会阻塞在channel的recvq里面。
  2. 对于有缓冲区的channel
    • 在缓冲区没有满的时候,发送方只管发送,当缓冲区存满的时候,发送方的协程groutine就会阻塞,如果这时候有协程过来取数据,那么优先给他缓存里的数据,然后再把sendq里面的协程的数据copy到缓存,并把该协程从阻塞中释放出来,也就是唤醒。
    • 当缓冲区没有任何数据,也就是管道里面没有数据的时候,接收方就因为取不到数据而阻塞,进入到recvq里面等待数据到来。当数据到的时候,发送方的协程直接把数据cpoy给接收方的协程,不需额外的经过一次缓冲区。

死锁场景

1. 没有缓冲区的时候,单协程内通道同时写和读

func main() {
   // 创建一个通道
   ch := make(chan int)
   ch <- 1 // 因为接收者在下面,所以阻塞在这里,死锁
   num := <-ch
   fmt.Println(num)
}

image.png 如果不想报死锁,可以加一个缓冲区

ch := make(chan int, 1)

又或者把发送方和接收方各放入一个新的协程

func main() {
   ch := make(chan int)
   // 发送方
   go func() {
      ch <- 1
   }()
   // 接收方
   go func() {
      num := <-ch
      fmt.Println(num)
   }()
   // 休眠一下,让主协程等子协程处理完,避免提早退出
   time.Sleep(1 * time.Second)
}



2.无缓冲区的时候,通道输入数据早于接收方的协程开启
其实和第一种死锁类似,都是没有缓冲的时候,channel的发送方没有接收者,导致阻塞在发送那一行,后面的go协程都无法启动

func main() {
   ch := make(chan int)
   ch <- 1 //此处阻塞在发送队列sendq里面,导致协程无法继续向下走,开启子协程
   go func() {
      num := <-ch
      fmt.Println(num)
   }()
   time.Sleep(1 * time.Second)
}

解决方案和第一个死锁的情况一样,设置缓冲区、发送方也放入一个子协程,或者把ch<-1 搬运到go func 代码块后面执行

3.从一个没有数据的channel里拿数据引起的死锁

func main() {
   c := make(chan int)
   num := <-c //channel里没有数据,直接死锁
   fmt.Println(num)
}

可以使用select{}来规避一下这个死锁

func main() {
   c := make(chan int)
   select {
   case num := <-c:
      fmt.Println(num)
   default:
      fmt.Println("没有数据")
   }
}



4.循环等待引起的死锁
这个不是go groutine独有的问题,这个就是循环依赖,A协程依赖B协程的数据,B也依赖A的,这种情况很常见。

func main() {
   c1 := make(chan int)
   c2 := make(chan int)
   go func() {
      select {
          // 当c1有数据的时候,才会给c2发送数据
          case num := <-c1:
             fmt.Println(num)
             c2 <- 2
      }
   }()
   select {
       // 当c2有数据的时候,才会给c1发送数据
       case num := <-c2:
          fmt.Println(num)
          c1 <- 1
   }
}

这种情况是日常开发,传统语言中也经常看到的死锁,这个就是编程的问题,要修改逻辑才行

5.有缓冲区,收发数据在同一协程,但是缓冲区已满
当缓冲区满了之后,发送数据的协程就被阻塞在当前sendq里面了,此时的情况和第一个死锁的情况差不多,都是收发数据都在同一个协程,而发数据被阻塞后,整个协程就deadlock了

func main() {
   c := make(chan int, 2)
   for i := 1; i < 5; i++ {
      c <- i
   }
   num := <-c
   fmt.Println("num=", num)
}



6.有缓冲区,缓冲区没数据\取光了,继续从channel取数据
这个情况其实和上面第三个死锁一模一样,不过是多了个缓冲区,这种一模一样的情况还要单独拿出来说,是因为在日常开发中很普遍,经常有多个协程都去拿同一个通道的数据,数据都取完了,部分协程没有拿到数据,结果阻塞在那里,变成僵尸协程。

func main() {
   c := make(chan int, 2)
   for i := 1; i < 3; i++ {
      c <- i
   }
   for i := 0; i < 4; i++ {
      go func(i int) {
         // 当通道的数据被取完的时候,子协程其实是死锁状态的
         // 但是因为主协程main退出了,所以运行的时候没有报deadlock错误,也就是这个死锁在当前例子中是无感知的
         num := <-c 
         fmt.Println(num)
      }(i)
   }
   time.Sleep(2 * time.Second)
}

注意!! 子协程在取完数据后是死锁的,因为永远都不会有新的数据产生了,如果是生产中,很可能这个goroutine一直阻塞在这里不会被回收。所以要用select来处理这种事,避免取不到数据导致的死锁。

func main() {
   c := make(chan int, 2)
   for i := 1; i < 3; i++ {
      c <- i
   }
   for i := 0; i < 4; i++ {
      go func(i int) {
         select {
         case num := <-c:
            fmt.Println(num)
         default:
            fmt.Println("没有数据了")
         }

      }(i)
   }
   time.Sleep(2 * time.Second)
}

运行结果:

image.png