脑抽研究生Go并发-2-Channel-基本用法、实现原理、消息交流、数据传递、信号通知、任务编排等

64 阅读6分钟

Channel

img

CSP(Communicating Sequential Process):CSP 允许使用进程组件来描述系统,它们独立运行,并且只通过消息传递的方式通信

Channel 类型是 Go 语言内置的类型,可以直接使用

Don’t communicate by sharing memory, share memory by communicating. --Go Proverbs by Rob Pike

Channel用法:

  • 数据交流:当作并发的 buffer 或者 queue,解决生产者 - 消费者问题。多个 goroutine 可以并发当作生产者(Producer)和消费者(Consumer)。
  • 数据传递:一个 goroutine 将数据交给另一个 goroutine,相当于把数据的拥有权 (引用) 托付出去。
  • 信号通知:一个 goroutine 可以将信号 (closing、closed、data ready 等) 传递给另一个或者另一组 goroutine 。
  • 任务编排:可以让一组 goroutine 按照一定的顺序并发或者串行的执行,这就是编排的功能。
  • 锁:利用 Channel 也可以实现互斥锁的机制。
chan string          // 可以发送接收string
chan<- struct{}      // 只能发送struct{}
<-chan int           // 只能从chan接收int
ch <- 2000 // 发送数据
x := <-ch // 把接收的一条数据赋值给变量x foo(<-ch) // 把接收的一个的数据作为参数传给函数 <-ch // 丢弃接收的一条数据

close:关闭 chan 关闭掉

cap:返回 chan 的容量

chan 还可以应用于 for-range 语句中:

    for v := range ch {
        fmt.Println(v)
    }

使用 Channel 容易犯的错误

img

panic

  • close 为 nil 的 chan;
  • send 已经 close 的 chan;
  • close 已经 close 的 chan。

goroutine 泄漏

unbuffered chan(初始化时不指定容量)Writer和reader,这两个事件必须同时发生。如果一个先发生,它所在的 goroutine 就会被 Go 的调度器阻塞

并发原语和Channel的选择方法

  1. 共享资源的并发访问使用传统并发原语;
  2. 复杂的任务编排和消息传递使用 Channel;
  3. 消息通知机制使用 Channel,除非只想 signal 一个 goroutine,才使用 Cond;
  4. 简单等待所有任务的完成用 WaitGroup,也有 Channel 的推崇者用 Channel,都可以;
  5. 需要和 Select 语句结合,使用 Channel;
  6. 需要和超时配合时,使用 Channel 和 Context。

使用反射操作 Channel

通过 reflect.Select 函数,可以将一组运行时的 case clause 传入,当作参数执行,无需写100个Case,可以动态创建case

func main() {
    var ch1 = make(chan int, 10)
    var ch2 = make(chan int, 10)
​
    // 创建SelectCase
    var cases = createCases(ch1, ch2)
​
    // 执行10次select
    for i := 0; i < 10; i++ {
        chosen, recv, ok := reflect.Select(cases)
        if recv.IsValid() { // recv case
            fmt.Println("recv:", cases[chosen].Dir, recv, ok)
        } else { // send case
            fmt.Println("send:", cases[chosen].Dir, ok)
        }
    }
}
​
func createCases(chs ...chan int) []reflect.SelectCase {
    var cases []reflect.SelectCase
​
​
    // 创建recv case
    for _, ch := range chs {
        cases = append(cases, reflect.SelectCase{
            Dir:  reflect.SelectRecv,
            Chan: reflect.ValueOf(ch),
        })
    }
​
    // 创建send case
    for i, ch := range chs {
        v := reflect.ValueOf(i)
        cases = append(cases, reflect.SelectCase{
            Dir:  reflect.SelectSend,
            Chan: reflect.ValueOf(ch),
            Send: v,
        })
    }
​
    return cases
}

典型的应用场景

消息交流

chan的内部实现是循环队列,所以有时会被当成线程安全的队列和 buffer 使用

例子:

  • 通过chan Job实现的worker 池
  • etcd 中的 node节点

数据传递

令牌(token)传递

type Token struct{}
​
func newWorker(id int, ch chan Token, nextCh chan Token) {
    for {
        token := <-ch         // 取得令牌
        fmt.Println((id + 1)) // id从1开始
        time.Sleep(time.Second)
        nextCh <- token
    }
}
func main() {
    chs := []chan Token{make(chan Token), make(chan Token), make(chan Token), make(chan Token)}
​
    // 创建4个worker
    for i := 0; i < 4; i++ {
        go newWorker(i, chs[i], chs[(i+1)%4])
    }
​
    //首先把令牌交给第一个worker
    chs[0] <- struct{}{}
  
    select {}
}

信号通知

使用 chan 实现程序的 graceful shutdown,在退出之前执行一些连接关闭、文件 close、缓存落盘等一些动作

func main() {
    var closing = make(chan struct{})
    var closed = make(chan struct{})
​
    go func() {
        // 模拟业务处理
        for {
            select {
            case <-closing:
                return
            default:
                // ....... 业务计算
                time.Sleep(100 * time.Millisecond)
            }
        }
    }()
​
    // 处理CTRL+C等中断信号
    termChan := make(chan os.Signal)
    signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
    <-termChan
​
    close(closing)
    // 执行退出之前的清理动作
    go doCleanup(closed)
​
    select {
    case <-closed:
    case <-time.After(time.Second):
        fmt.Println("清理超时,不等了")
    }
    fmt.Println("优雅退出")
}
​
func doCleanup(closed chan struct{}) {
    time.Sleep((time.Minute))
    close(closed)
}

互斥锁,利用 select+chan 的方式,很容易实现 TryLock、Timeout 的功能

任务编排

Or-Done 模式、扇入模式、扇出模式、Stream 和 map-reduce

Or-Done 模式

如果有多个任务,只要有任意一个任务执行完,我们就想获得这个信号

扇入模式

多个源 Channel 输入、一个目的 Channel 输出

扇出模式

扇出模式只有一个输入源 Channel,有多个目标 Channel

Stream

流式管道,提供跳过几个元素,或者是只取其中的几个元素等方法

map-reduce

第一步是映射(map),处理队列中的数据,第二步是规约(reduce),把列表中的每一个元素按照一定的处理方式处理成结果,放入到结果队列中

内存模型

img

多线程同时访问同一个变量的可见性和顺序

happens-before

👍在一个 goroutine 内部,程序的执行顺序和它们的代码指定的顺序是一样的,即使编译器或者 CPU 重排了读写顺序,从行为上来看,也和代码指定的顺序一样

😡Go 只保证 goroutine 内部重排对读写的顺序没有影响,如果要保证多个 goroutine 之间对一个共享变量的读写顺序,在 Go 语言中,可以使用并发原语为读写操作建立 happens-before 关系

  1. 在 Go 语言中,对变量进行零值的初始化就是一个写操作。
  2. 如果对超过机器 word(64bit、32bit 或者其它)大小的值进行读写,那么,就可以看作是对拆成 word 大小的几个读写无序进行。
  3. Go 并不提供直接的 CPU 屏障(CPU fence)来提示编译器或者 CPU 保证顺序性,而是使用不同架构的内存屏障指令来实现统一的并发原语。

Go 语言中保证的 happens-before 关系

init 函数

main 函数一定在导入的包的 init 函数之后执行

goroutine

启动 goroutine 的 go 语句的执行,一定 happens before 此 goroutine 内的代码执行

Channel
  • 第 1 条规则是,往 Channel 中的发送操作,happens before 从该 Channel 接收相应数据的动作完成之前,即第 n 个 send 一定 happens before 第 n 个 receive 的完成。
  • 第 2 条规则是,close 一个 Channel 的调用,肯定 happens before 从关闭的 Channel 中读取出一个零值。
  • 第 3 条规则是,对于 unbuffered 的 Channel,也就是容量是 0 的 Channel,从此 Channel 中读取数据的调用一定 happens before 往此 Channel 发送数据的调用完成。
  • 第 4 条规则是,如果 Channel 的容量是 m(m>0),那么,第 n 个 receive 一定 happens before 第 n+m 个 send 的完成。

Mutex/RWMutex

  1. 第 n 次的 m.Unlock 一定 happens before 第 n+1 m.Lock 方法的返回;
  2. 对于读写锁 RWMutex m,如果它的第 n 个 m.Lock 方法的调用已返回,那么它的第 n 个 m.Unlock 的方法调用一定 happens before 任何一个 m.RLock 方法调用的返回,只要这些 m.RLock 方法调用 happens after 第 n 次 m.Lock 的调用的返回。这就可以保证,只有释放了持有的写锁,那些等待的读请求才能请求到读锁。
  3. 对于读写锁 RWMutex m,如果它的第 n 个 m.RLock 方法的调用已返回,那么它的第 k (k<=n)个成功的 m.RUnlock 方法的返回一定 happens before 任意的 m.RUnlockLock 方法调用,只要这些 m.Lock 方法调用 happens after 第 n 次 m.RLock。

WaitGroup

Wait 方法等到计数值归零之后才返回

Once

对于 once.Do(f) 调用,f 函数的那个单次调用一定 happens before 任何 once.Do(f) 调用的返回