Go语言复合类型 - Channel详解

silverwq
2022-07-12 / 0 评论 / 343 阅读 / 正在检测是否收录...

概述

Channel 是一种特殊的引用类型,中文直译一般叫做通道,是 goroutine 执行体之间进行通信的桥梁,可以实现一个 goroutine 发生特定的值到另外一个 goroutine。

每个通道都是一个具体类型的导管,叫做通道的元素类型。使用 make 函数创建一个通道

ch := make(chan int)

和 map 一样,通道是一个使用 make 创建的数据结构的引用。所以当复制,或者作为参数传递到一个函数的时候,复制的是引用,这样调用者和被调用者都引用同一份数据结构。

通道之间可以比较吗?

  1. 同一个类型的通道之间是可以用 == 进行比较的,当二者都是同一个底层的引用的时候,结果是 true。
  2. 通道还可以和 nil 进行比较

声明

可以通过 var 关键字进行定义 channel,由于 channel 是一种引用类型,所以 var 定义的并没有在内存种实例化,var 定义后的 channel 零值为 nil,还需要初始化化,比较麻烦,因此这种定义方式不常用。

var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道
fmt.Println(ch1, ch2, ch3) // 输出<nil> <nil> <nil>

另外一种方式是通过 make 函数定义,定义之后就已经在内存中初始化了,这个方式比较常用

chInt := make(chan int)       // 无缓冲
chBool := make(chan bool, 0)  // 无缓冲
chStr := make(chan string, 2) // 容量为2的缓冲通道

以上两种声明 channel 的都是双向的,意味着可以向该 channel 可以发送数据并接收数据。"发送"和"接收"是 channel 的两个基本操作,统称为通信,可以通过箭头操作符书写

ch <- x // 发送
x <- ch // 接收
<-ch    // 接收并且丢弃结果

通道的第三个个操作是关闭。它设置一个标志位来表明现在已经发送完毕,这个通道不会再发送新的值了。

close(ch)
  1. 关闭后的发送操作会导致宕机。
  2. 在已经关闭的通道进行接收操作,将获取已经发送的值,直到通道为空。这个时候对已关闭的空通道上进行接收,会立即返回零值。
  3. 关闭一个 nil 的通道也会导致宕机
  4. 关闭以一个已经关闭的通道也会导致宕机

通道缓冲

无缓冲通道

如下用 make 创建无缓冲通道

chInt := make(chan int)       // 无缓冲
chBool := make(chan bool, 0)  // 无缓冲

对无缓冲通道的发送操作,将会阻塞(相当于挂起休眠了),直到其他 goroutine 在对应的通道上执行接收操作(发送的 goroutine 再次被唤醒),这时候传值完成。相反,如果接收操作先执行,接收方的 goroutine 将阻塞,直到另一个 goroutine 在同一个通道上发送值。

因为无缓冲通道会导致发送和接收 goroutine 同步化,所以无缓冲通道也称作同步通道。这个也是它的一个重要特征。

无缓冲通道如何导致死锁?

当在一个 goroutine 中对同一个无缓冲的通道进行收发操作,就会导致死锁,因为收发都在同一协程,就会导致互相等待,导致死锁

func main() {
 ch := make(chan string)
 ch <- "ping"
  fmt.Println(<-ch)
}

缓冲通道

缓冲通道有一个元素队列,队列长度可以在 make 的时候指定

// 队列的长度是2
ch := make(chan int, 2)

缓冲通道的发送操作,会在队列的尾部插入元素(lpush),接收操作会在对了的头部移出(rpop)。如果通道满了,发送操作就会阻塞所在的 goroutine,直到另一个 goroutine 对它进行了接收操作来腾出空间。反过来,如果通道是空的,执行接收操作的 goroutine 阻塞,直到另一个 goroutine 在通道上发送数据。

因为缓冲空间足够的时候,不会阻塞,所以缓冲通道的一个重要特点是解耦。

在同一个 goroutine 上收发缓冲通道,会导致死锁吗?

如果没有超过缓冲的容量,就不会,但是如果超过容量就会触发阻塞,从而导致死锁。通道一般是用于 goroutine 之间通信的,所以一般不要在同一个 goroutine 上进行收发操作。

// 利用channel 实现了一个队列的操作,不过有死锁的风险
func main() {
  ch := make(chan int, 2)
 ch <- 1
 ch <- 2
 // ch <- 3 如果增加这个,就会死锁
  fmt.Println(<-ch)
   fmt.Println(<-ch)
}

一般所谓的死锁,是阻塞了主进程,如果只是阻塞子协程,并不会导致死锁

func main() {
    go func() {
     ch := make(chan int, 2)
     ch <- 1 // 会一直阻塞在这里,但是并不会报死锁
        <-ch
    }()
 for {
       fmt.Println("end")
        time.Sleep(time.Second)
 }
}

需要注意的是,在内存无法提供缓冲容量的情况下,可能导致程序死锁。

如何获取通道的容量呢?

可以通过 cap 函数获取,例如 cap(ch)

如何获取通道的内的元素的个数

可以通过 len 函数获取,例如 len(ch)

缓冲通道的使用场景?

一般用于上游生产比下游消耗的速度快的场景。可以一下子生产很多东西,下游慢慢的去消费。当一个下游 goroutine 消费速度太慢的时候,可以再创建一个 goroutine 来一起消费这个通道,提高消费速度。

通道的遍历

下例中 goroutine 产生自然数传递给另外一个 goroutine,并且求平方后给主 goroutine 打印

func main() {
   naturals := make(chan int)
  squares := make(chan int)

   go func() {
     for i := 1; ; i++ {
         // 无限产生自然数到通道naturals
           naturals <- i
       }
   }()

 go func() {
     for {
           // 无限从naturals通道读取数据,并且求平方
          x := <-naturals
         squares <- (x * x)
      }
   }()

 for {
       // 求得的平方无限打印出来
      val := <-squares
        fmt.Println(val)
    }
}

上述例子是发送无限的数字,如果想要发送有限的数字怎么办呢?

如果发送方直到没有更多的数据要发送,就应该告诉接受者所在的 goroutine 不要再等待了。通过 close(naturals) 来通知接收者就好了。

通道 naturals 被关闭后,后续就不能再往该通道发送数据,否则会异常退出。并且关闭的通道 naturals 被读完之后,所有后续的接收操作都不会被阻塞,而是顺畅的获取到零值,传递给了 squares 通道

func main() {
   naturals := make(chan int)
  squares := make(chan int)

   go func() {
     for i := 1; i <= 5; i++ {
           naturals <- i
       }
       // 产生5个自然数到通道naturals
       close(naturals)
 }()

 go func() {
     for {
           // 获取5个自然数后,从关闭的通道上获取的数都是0
          x := <-naturals
         squares <- (x * x)
      }
   }()

 for {
       // 打印出1,4,9,16,25,0,0....后续都是0
      val := <-squares
        fmt.Println(val)
    }
}

那么,有没一个方式来判断该通道是否已经被关闭了呢?

可以在接收参数上加一个 ok 参数,当为 true 的时候表示接收成功,为 false 的话表示当前接收操作在一个关闭并且读完的通道上。

func main() {
  naturals := make(chan int)
  squares := make(chan int)

   go func() {
     // 产生5个自然数到通道naturals,然后关闭通道
        for i := 1; i <= 5; i++ {
           naturals <- i
       }
       close(naturals)
 }()

 go func() {
     for {
           // 当5个数接收完后,由于通道被关闭,所有ok会为false
         x, ok := <-naturals
         if !ok {
                break
           }
           squares <- (x * x)
      }
       close(squares)
  }()

 for {
       val, ok := <-squares
        if !ok {
            break
       }
       // 打印出1,4,9,16,25
       fmt.Println(val)
    }
}

有没有更简单的写法呢?

由于 for 循环里通过 ok 判断来退出循环的方式比较笨拙,所以提供了 range 循环语法,可以直接在通道上迭代。Range 更方便的从通道上接收值,并且接收完最后一个后退出循环。所以可以简写为

func main() {
 naturals := make(chan int)
  squares := make(chan int)

   go func() {
     // 产生5个自然数到通道naturals,然后关闭通道
        for i := 1; i <= 5; i++ {
           naturals <- i
       }
       close(naturals)
 }()

 go func() {
     for x := range naturals {
           squares <- (x * x)
      }
       close(squares)
  }()

 for val := range squares {
      // 打印出1,4,9,16,25
       fmt.Println(val)
    }
}

通道关闭是必须的吗?

并不是必须的,只有需要通知接收方的 goroutine 所有的数据都发送完毕的时候,才需要关闭通道。因为如果像是 range 循环,不通知的通道关闭的话,会一直等到,导致死锁,例如

func main() {
 naturals := make(chan int)
  go func() {
     for i := 1; i <= 5; i++ {
           naturals <- i
       }
   }()
 // 由于naturals 没有被关闭,所以这里会一直等待,导致死锁
  for val := range naturals {
     fmt.Println(val)
    }
}

对于未关闭的通道,垃圾回收器可以通过它是否可以被访问来决定是否回收它。

单向通道

当一个通道作为函数的参数时候,它经常会被有意的设置不能发送或不能接收。Go 的类型系统提供了单向通道类型,仅仅导出发送或者接收操作。

  1. 类型 chan<- int 是一个只能发送的通道,允许向这个通道发送,不能从这个通过接收
  2. 类型 <-chan int 是一个只能接收的通道,允许向从这个通道接收,不能向这个通过发送
// 只能发送
func counter(out chan<- int) {
    for i := 1; i <= 5; i++ {
       out <- i
    }
   close(out)
}

// 发送和接收
func squarer(out chan<- int, in <-chan int) {
    for x := range in {
     out <- (x * x)
  }
   close(out)
}

func main() {
    naturals := make(chan int)
  squares := make(chan int)

   go counter(naturals)
    go squarer(squares, naturals)

   for val := range squares {
      // 打印出1,4,9,16,25
       fmt.Println(val)
    }
}

需要注意的是:在变量声明中是不应该出现单向通道的,因为通道本来就是为了通信而生,定义一个只能接收或者只能发送数据的通道是没有意义的。

ch := make(chan<- string, 1)
ch <- "str"

这个例子中定义了一个只能用来接收数据的通道,从语法上来看没有错误,但这是一种糟糕的实践。

Select 多路复用

正常我们一行代码只能监听一个 channel,看下该 channel 是否有数据,有时候我们希望同时监听多个 channel,如果同时有数据,多个 channel 则是并发执行。在下述例子中,通过 select 的使用,使得代码阻塞在 select 那,直到每个通道都关闭了,才会退出 select,保证了 worker 中的事务可以执行完毕后才退出 main 函数

func strWorker(ch chan string) {
  time.Sleep(1 * time.Second)
 fmt.Println("do something with strWorker...")
 ch <- "str"
}

func intWorker(ch chan int) {
 time.Sleep(2 * time.Second)
 fmt.Println("do something with intWorker...")
 ch <- 1
}

func main() {
   chStr := make(chan string)
  chInt := make(chan int)

 go strWorker(chStr)
 go intWorker(chInt)

 for i := 0; i < 2; i++ {
        select {
        case <-chStr:// 这个通道一有值就会执行这
            fmt.Println("get value from strWorker")

       case <-chInt:// 这个通道一有值就会执行这
            fmt.Println("get value from intWorker")

       }
   }
}

通过 select 里的default,我们还可以实现不阻塞 channel

package main

import (
    "fmt"
    "time"
)

func readBlocWithNoBlock() {
    dataCh := make(chan int)
    go func() {
        for i := 0; i < 100; i++ {
            dataCh <- i
            time.Sleep(time.Second)
        }
    }()

    for i := 0; i < 100; i++ {
        select {
        // 这个取到值后,就走这个
        case data := <-dataCh:
            fmt.Println(data)
        // 如果上面的阻塞,就默认执行这个
        default:
        }
        fmt.Println("YES")
        time.Sleep(500 * time.Millisecond)
    }

}

func main() {
    readBlocWithNoBlock()
}

通过 channel 实现同步机制

一个经典的例子如下,main 函数中起了一个 goroutine,通过非缓冲队列的使用,能够保证在 goroutine 执行结束之前 main 函数不会提前退出。

func worker(done chan bool) {
    fmt.Println("start working...")
   done <- true
    fmt.Println("end working...")
}

func main() {
   done := make(chan bool, 1)
  go worker(done)

 <-done // 阻塞在这里,确保协程执行完成
}

如果没有 <-done 的话,worker 里的内容将不会执行,当然我们还可以通过 sync. WaitGroup 来实现同步,见 go语言的goroutine 章节

通道总结

l5i05u85.png

哪些情况会导致 channel 死锁?

  1. 在同一个协程了使用非缓冲通道
  2. Foreach 变量通道,没有关闭协程
0

评论 (0)

取消