原文地址

不同于传统的多线程并发模型使用共享内存来实现线程间通信的方式,golang 的哲学是通过 channel 进行协程(goroutine)之间的通信来实现数据共享:

Do not communicate by sharing memory; instead, share memory by communicating.

这种方式的优点是通过提供原子的通信原语,避免了竞态情形(race condition)下复杂的锁机制。

channel 可以看成一个 FIFO 队列,对 FIFO 队列的读写都是原子的操作,不需要加锁。对 channel 的操作行为结果总结如下:

操作 nil channel closed channel not-closed non-nil channel
close panic panic 成功 close
ch <- 一直阻塞 panic 阻塞或成功写入数据
<- ch 一直阻塞 读取对应类型零值 阻塞或成功读取数据

读取一个已关闭的 channel 时,总是能读取到对应类型的零值,为了和读取非空未关闭 channel 的行为区别,可以使用两个接收值:

  1. // ok is false when ch is closed
  2. v, ok := <-ch

golang 中大部分类型都是值类型(只有 slice / channel / map 是引用类型),读/写类型是值类型的 channel 时,如果元素 size 比较大时,应该使用指针代替,避免频繁的内存拷贝开销。

内部实现

如图所示,在 channel 的内部实现中(具体定义在 $GOROOT/src/runtime/chan.go 里),维护了 3 个队列:

  • 读等待协程队列 recvq,维护了阻塞在读此 channel 的协程列表
  • 写等待协程队列 sendq,维护了阻塞在写此 channel 的协程列表
  • 缓冲数据队列 buf,用环形队列实现,不带缓冲的 channel 此队列 size 则为 0

当协程尝试从未关闭的 channel 中读取数据时,内部的操作如下:

  1. 当 buf 非空时,此时 recvq 必为空,buf 弹出一个元素给读协程,读协程获得数据后继续执行,此时若 sendq 非空,则从 sendq 中弹出一个写协程转入 running 状态,待写数据入队列 buf ,此时读取操作 <- ch 未阻塞;
  2. 当 buf 为空但 sendq 非空时(不带缓冲的 channel),则从 sendq 中弹出一个写协程转入 running 状态,待写数据直接传递给读协程,读协程继续执行,此时读取操作 <- ch 未阻塞;
  3. 当 buf 为空并且 sendq 也为空时,读协程入队列 recvq 并转入 blocking 状态,当后续有其他协程往 channel 写数据时,读协程才会重新转入 running 状态,此时读取操作 <- ch 阻塞。

类似的,当协程尝试往未关闭的 channel 中写入数据时,内部的操作如下:

  1. 当队列 recvq 非空时,此时队列 buf 必为空,从 recvq 弹出一个读协程接收待写数据,此读协程此时结束阻塞并转入 running 状态,写协程继续执行,此时写入操作 ch <- 未阻塞;
  2. 当队列 recvq 为空但 buf 未满时,此时 sendq 必为空,写协程的待写数据入 buf 然后继续执行,此时写入操作 ch <- 未阻塞;
  3. 当队列 recvq 为空并且 buf 为满时,此时写协程入队列 sendq 并转入 blokcing 状态,当后续有其他协程从 channel 中读数据时,写协程才会重新转入 running 状态,此时写入操作 ch <- 阻塞。

当关闭 non-nil channel 时,内部的操作如下:

  1. 当队列 recvq 非空时,此时 buf 必为空,recvq 中的所有协程都将收到对应类型的零值然后结束阻塞状态;
  2. 当队列 sendq 非空时,此时 buf 必为满,sendq 中的所有协程都会产生 panic ,在 buf 中数据仍然会保留直到被其他协程读取。

使用场景

除了常规的用来在协程之间传递数据外,本节列出了一些特殊的使用 channel 的场景。

futures / promises

golang 虽然没有直接提供 futrue / promise 模型的操作原语,但通过 goroutine 和 channel 可以实现类似的功能:

  1. package main
  2. import (
  3. "io/ioutil"
  4. "log"
  5. "net/http"
  6. )
  7. // RequestFuture, http request promise.
  8. func RequestFuture(url string) <-chan []byte {
  9. c := make(chan []byte, 1)
  10. go func() {
  11. var body []byte
  12. defer func() {
  13. c <- body
  14. }()
  15. res, err := http.Get(url)
  16. if err != nil {
  17. return
  18. }
  19. defer res.Body.Close()
  20. body, _ = ioutil.ReadAll(res.Body)
  21. }()
  22. return c
  23. }
  24. func main() {
  25. future := RequestFuture("https://api.github.com/users/octocat/orgs")
  26. body := <-future
  27. log.Printf("reponse length: %d", len(body))
  28. }

条件变量(condition variable)

类型于 POSIX 接口中线程通知其他线程某个事件发生的条件变量,channel 的特性也可以用来当成协程之间同步的条件变量。因为 channel 只是用来通知,所以 channel 中具体的数据类型和值并不重要,这种场景一般用 strct {} 作为 channel 的类型。

一对一通知

类似 pthread_cond_signal() 的功能,用来在一个协程中通知另个某一个协程事件发生:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. ch := make(chan struct{})
  8. nums := make([]int, 100)
  9. go func() {
  10. time.Sleep(time.Second)
  11. for i := 0; i < len(nums); i++ {
  12. nums[i] = i
  13. }
  14. // send a finish signal
  15. ch <- struct{}{}
  16. }()
  17. // wait for finish signal
  18. <-ch
  19. fmt.Println(nums)
  20. }
广播通知

类似 pthread_cond_broadcast() 的功能。利用从已关闭的 channel 读取数据时总是非阻塞的特性,可以实现在一个协程中向其他多个协程广播某个事件发生的通知:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. N := 10
  8. exit := make(chan struct{})
  9. done := make(chan struct{}, N)
  10. // start N worker goroutines
  11. for i := 0; i < N; i++ {
  12. go func(n int) {
  13. for {
  14. select {
  15. // wait for exit signal
  16. case <-exit:
  17. fmt.Printf("worker goroutine #%d exit\n", n)
  18. done <- struct{}{}
  19. return
  20. case <-time.After(time.Second):
  21. fmt.Printf("worker goroutine #%d is working...\n", n)
  22. }
  23. }
  24. }(i)
  25. }
  26. time.Sleep(3 * time.Second)
  27. // broadcast exit signal
  28. close(exit)
  29. // wait for all worker goroutines exit
  30. for i := 0; i < N; i++ {
  31. <-done
  32. }
  33. fmt.Println("main goroutine exit")
  34. }

信号量

channel 的读/写相当于信号量的 P / V 操作,下面的示例程序中 channel 相当于信号量:

  1. package main
  2. import (
  3. "log"
  4. "math/rand"
  5. "time"
  6. )
  7. type Seat int
  8. type Bar chan Seat
  9. func (bar Bar) ServeConsumer(customerId int) {
  10. log.Print("-> consumer#", customerId, " enters the bar")
  11. seat := <-bar // need a seat to drink
  12. log.Print("consumer#", customerId, " drinks at seat#", seat)
  13. time.Sleep(time.Second * time.Duration(2+rand.Intn(6)))
  14. log.Print("<- consumer#", customerId, " frees seat#", seat)
  15. bar <- seat // free the seat and leave the bar
  16. }
  17. func main() {
  18. rand.Seed(time.Now().UnixNano())
  19. bar24x7 := make(Bar, 10) // the bar has 10 seats
  20. // Place seats in an bar.
  21. for seatId := 0; seatId < cap(bar24x7); seatId++ {
  22. bar24x7 <- Seat(seatId) // none of the sends will block
  23. }
  24. // a new consumer try to enter the bar for each second
  25. for customerId := 0; ; customerId++ {
  26. time.Sleep(time.Second)
  27. go bar24x7.ServeConsumer(customerId)
  28. }
  29. }

互斥量

互斥量相当于二元信号里,所以 cap 为 1 的 channel 可以当成互斥量使用:

  1. package main
  2. import "fmt"
  3. func main() {
  4. mutex := make(chan struct{}, 1) // the capacity must be one
  5. counter := 0
  6. increase := func() {
  7. mutex <- struct{}{} // lock
  8. counter++
  9. <-mutex // unlock
  10. }
  11. increase1000 := func(done chan<- struct{}) {
  12. for i := 0; i < 1000; i++ {
  13. increase()
  14. }
  15. done <- struct{}{}
  16. }
  17. done := make(chan struct{})
  18. go increase1000(done)
  19. go increase1000(done)
  20. <-done; <-done
  21. fmt.Println(counter) // 2000
  22. }

关闭 channel

关闭不再需要使用的 channel 并不是必须的。跟其他资源比如打开的文件、socket 连接不一样,这类资源使用完后不关闭后会造成句柄泄露,channel 使用完后不关闭也没有关系,channel 没有被任何协程用到后最终会被 GC 回收。关闭 channel 一般是用来通知其他协程某个任务已经完成了。golang 也没有直接提供判断 channel 是否已经关闭的接口,虽然可以用其他不太优雅的方式自己实现一个:

  1. func isClosed(ch chan int) bool {
  2. select {
  3. case <-ch:
  4. return true
  5. default:
  6. }
  7. return false
  8. }

不过实现一个这样的接口也没什么必要。因为就算通过 isClosed() 得到当前 channel 当前还未关闭,如果试图往 channel 里写数据,仍然可能会发生 panic ,因为在调用 isClosed() 后,其他协程可能已经把 channel 关闭了。

关闭 channel 时应该注意以下准则:

  • 不要在读取端关闭 channel ,因为写入端无法知道 channel 是否已经关闭,往已关闭的 channel 写数据会 panic ;
  • 有多个写入端时,不要再写入端关闭 channle ,因为其他写入端无法知道 channel 是否已经关闭,关闭已经关闭的 channel 会发生 panic ;
  • 如果只有一个写入端,可以在这个写入端放心关闭 channel 。

关闭 channel 粗暴一点的做法是随意关闭,如果产生了 panic 就用 recover 避免进程挂掉。稍好一点的方案是使用标准库的 sync 包来做关闭 channel 时的协程同步,不过使用起来也稍微复杂些。下面介绍一种优雅些的做法。

一写多读

这种场景下这个唯一的写入端可以关闭 channel 用来通知读取端所有数据都已经写入完成了。读取端只需要用 for range 把 channel 中数据遍历完就可以了,当 channel 关闭时,for range 仍然会将 channel 缓冲中的数据全部遍历完然后再退出循环:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. wg := &sync.WaitGroup{}
  8. ch := make(chan int, 100)
  9. send := func() {
  10. for i := 0; i < 100; i++ {
  11. ch <- i
  12. }
  13. // signal sending finish
  14. close(ch)
  15. }
  16. recv := func(id int) {
  17. defer wg.Done()
  18. for i := range ch {
  19. fmt.Printf("receiver #%d get %d\n", id, i)
  20. }
  21. fmt.Printf("receiver #%d exit\n", id)
  22. }
  23. wg.Add(3)
  24. go recv(0)
  25. go recv(1)
  26. go recv(2)
  27. send()
  28. wg.Wait()
  29. }

多写一读

这种场景下虽然可以用 sync.Once 来解决多个写入端重复关闭 channel 的问题,但更优雅的办法设置一个额外的 channel ,由读取端通过关闭来通知写入端任务完成不要再继续再写入数据了:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. wg := &sync.WaitGroup{}
  8. ch := make(chan int, 100)
  9. done := make(chan struct{})
  10. send := func(id int) {
  11. defer wg.Done()
  12. for i := 0; ; i++ {
  13. select {
  14. case <-done:
  15. // get exit signal
  16. fmt.Printf("sender #%d exit\n", id)
  17. return
  18. case ch <- id*1000 + i:
  19. }
  20. }
  21. }
  22. recv := func() {
  23. count := 0
  24. for i := range ch {
  25. fmt.Printf("receiver get %d\n", i)
  26. count++
  27. if count >= 1000 {
  28. // signal recving finish
  29. close(done)
  30. return
  31. }
  32. }
  33. }
  34. wg.Add(3)
  35. go send(0)
  36. go send(1)
  37. go send(2)
  38. recv()
  39. wg.Wait()
  40. }

多写多读

这种场景稍微复杂,和上面的例子一样,也需要设置一个额外 channel 用来通知多个写入端和读取端。另外需要起一个额外的协程来通过关闭这个 channel 来广播通知:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. func main() {
  8. wg := &sync.WaitGroup{}
  9. ch := make(chan int, 100)
  10. done := make(chan struct{})
  11. send := func(id int) {
  12. defer wg.Done()
  13. for i := 0; ; i++ {
  14. select {
  15. case <-done:
  16. // get exit signal
  17. fmt.Printf("sender #%d exit\n", id)
  18. return
  19. case ch <- id*1000 + i:
  20. }
  21. }
  22. }
  23. recv := func(id int) {
  24. defer wg.Done()
  25. for {
  26. select {
  27. case <-done:
  28. // get exit signal
  29. fmt.Printf("receiver #%d exit\n", id)
  30. return
  31. case i := <-ch:
  32. fmt.Printf("receiver #%d get %d\n", id, i)
  33. time.Sleep(time.Millisecond)
  34. }
  35. }
  36. }
  37. wg.Add(6)
  38. go send(0)
  39. go send(1)
  40. go send(2)
  41. go recv(0)
  42. go recv(1)
  43. go recv(2)
  44. time.Sleep(time.Second)
  45. // signal finish
  46. close(done)
  47. // wait all sender and receiver exit
  48. wg.Wait()
  49. }

总结

channle 作为 golang 最重要的特性,用起来还是比较爽的。传统的 C 里要实现类型的功能的话,一般需要用到 socket 或者 FIFO 来实现,另外还要考虑数据包的完整性与并发冲突的问题,channel 则屏蔽了这些底层细节,使用者只需要考虑读写就可以了。 channel 是引用类型,了解一下 channel 底层的机制对更好的使用 channel 还是很用必要的。虽然操作原语简单,但涉及到阻塞的问题,使用不当可能会造成死锁或者无限制的协程创建最终导致进程挂掉。

channel 除在可以用来在协程之间通信外,其阻塞和唤醒协程的特性也可以用作协程之间的同步机制,文中也用示例简单介绍了这种场景下的用法。

关闭 channel 并不是必须的,只要没有协程没用引用 channel ,最终会被 GC 清理。所以使用的时候要特别注意,不要让协程阻塞在 channel 上,这种情况很难检测到,而且会造成 channel 和阻塞在 channel 的协程占有的资源无法被 GC 清理最终导致内存泄露。

channle 方便 golang 程序使用 CSP 的编程范形,但是 golang 是一种多范形的编程语言,golang 也支持传统的通过共享内存来通信的编程方式。终极的原则是根据场景选择合适的编程范型,不要因为 channel 好用而滥用 CSP 。

参考资料

golang channel 使用总结的更多相关文章

  1. golang channel的使用以及调度原理

    golang channel的使用以及调度原理 为了并发的goroutines之间的通讯,golang使用了管道channel. 可以通过一个goroutines向channel发送数据,然后从另一个 ...

  2. golang channel关闭后,是否可以读取剩余的数据

    golang channel关闭后,其中剩余的数据,是可以继续读取的. 请看下面的测试例子. 创建一个带有缓冲的channel,向channel中发送数据,然后关闭channel,最后,从channe ...

  3. golang channel原理

    channel介绍 channel一个类型管道,通过它可以在goroutine之间发送和接收消息.它是Golang在语言层面提供的goroutine间的通信方式. 众所周知,Go依赖于称为CSP(Co ...

  4. golang channel 用法转的

    一.Golang并发基础理论 Golang在并发设计方面参考了C.A.R Hoare的CSP,即Communicating Sequential Processes并发模型理论.但就像John Gra ...

  5. golang channel初次接触

    goroutine之间的同步 goroutine是golang中在语言级别实现的轻量级线程,仅仅利用go就能立刻起一个新线程.多线程会引入线程之间的同步问题,经典的同步问题如生产者-消费者问题,在c, ...

  6. 如何优雅的关闭Golang Channel?

    Channel关闭原则 不要在消费端关闭channel,不要在有多个并行的生产者时对channel执行关闭操作. 也就是说应该只在[唯一的或者最后唯一剩下]的生产者协程中关闭channel,来通知消费 ...

  7. golang channel几点总结

    golang提倡使用通讯来共享数据,而不是通过共享数据来通讯.channel就是golang这种方式的体现. Channel 在golang中有两种channel:带缓存的和不带缓存. 带缓存的cha ...

  8. golang channel 源码剖析

    channel 在 golang 中是一个非常重要的特性,它为我们提供了一个并发模型.对比锁,通过 chan 在多个 goroutine 之间完成数据交互,可以让代码更简洁.更容易实现.更不容易出错. ...

  9. golang channel本质——共享内存

    channel是golang中很重要的概念,配合goroutine是golang能够方便实现并发编程的关键.channel其实就是传统语言的阻塞消息队列,可以用来做不同goroutine之间的消息传递 ...

  10. Golang channel 用法简介

    channel 是 golang 里相当有趣的一个功能,大部分时候 channel 都是和 goroutine 一起配合使用.本文主要介绍 channel 的一些有趣的用法. 通道(channel), ...

随机推荐

  1. python笔记8-多线程threading之封装式

    执行函数 1.先写一个执行函数,用来实现做某件事情,不同的人吃火锅用一个参数people代替. # coding=utf-8 import threading import time def chiH ...

  2. Jmeter测试普通java类说明

    概述 Apache JMeter是Apache组织开发的基于Java的压力测试工具.本文档主要描述用Jmeter工具对基于Dubbo.Zookeeper框架的Cassandra接口.区块链接口进行压力 ...

  3. linux性能系列--内存

    一.啥是内存呢? 回答:内存是计算机中重要的部件之一,它是与CPU进行沟通的桥梁.计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大. 内存(Memory)也被称为内存储器, ...

  4. python正则二

    在python中,我们可以使用re模块来使用正则表达式. 正则表达式使用\对特殊字符进行转义,因为python本身也是用\作为转义,所以在使用正则的时候会出现这样的情况,'python\\.org', ...

  5. C对64位整数类型的支持

    在使用C语言过程中可能需要接触长整数类型,其中包括固定长度数据类型的声明.输入输出函数的标志符等细节,在此记录. int64_t 与 uint64_t C的标准只规定特定数据类型需要实现的最小长度,特 ...

  6. Angular Reactive Forms -- Model-Driven Forms响应式表单

    Angular 4.x 中有两种表单: Template-Driven Forms - 模板驱动式表单 (类似于 AngularJS 1.x 中的表单 )  官方文档:https://v2.angul ...

  7. Inno Setup添加中文安装语言文件

    如果你不添加中文安装语言文件,你编译生成的安装包的语言是不会有中文. 一,打开软件安装目录下的Languages文件夹下,有如下好多文件,可是就是没有Chianese.isl. 好了,你只需要随便拷贝 ...

  8. .net中使用mysql回滚和sqlserver回滚的区别

    关于sqlserver事务和mysql事务 首先这是一种方法 public static int GetExecteQuery()        {            SqlConnection ...

  9. selenium + python自动化测试unittest框架学习(四)python导入模块及包知识点

    在写脚本的时候,发现导入某些模块,经常报错提示导入模块失败,这里来恶补下python导入模块的知识点. 1.模块导入时文件查找顺序 在脚本中,import xxx模块时的具体步骤: (1)新建一个mo ...

  10. VSCode调试C++

    在ubuntu下调试C++ 本人觉得VSCode比较好用. 步骤如下: 1. 编写.cpp,.h文件 自行完成自己的程序. 2. 编写CMakeLists.txt.下面是一个比较好用的模板. 根目录为 ...