Channels select

114 阅读15分钟

panic/block的情况

image.png

golang channel 需要手动关闭吗

image.png

csp

Go语言中的并发程序可以用两种手段来实现。

1.CSP(communicating sequential processes)

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

2.更为传统的并发模型:多线程共享内存

当一个程序启动时,其主函数即在一个单独的goroutine中运行,我们叫它main goroutine。新的goroutine会用go语句来创建。

f()    // call f(); wait for it to return
go f() // create a new goroutine that calls f(); don't wait

go后跟的函数的参数会在go语句自身执行时被求值

一个channel是一个通信机制,它可以让一个goroutine通过它给另一个goroutine发送值信息。每个channel都有一个特殊的类型,也就是channels可发送数据的类型。

ch := make(chan int)
log.Println(unsafe.Sizeof(ch))//8

src/runtime/chan.go

type hchan struct {
   qcount   uint           // total data in the queue
   dataqsiz uint           // size of the circular queue
   buf      unsafe.Pointer // points to an array of dataqsiz elements
   elemsize uint16
   closed   uint32
   elemtype *_type // element type
   sendx    uint   // send index
   recvx    uint   // receive index
   recvq    waitq  // list of recv waiters
   sendq    waitq  // list of send waiters

   // lock protects all fields in hchan, as well as several
   // fields in sudogs blocked on this channel.
   //
   // Do not change another G's status while holding this lock
   // (in particular, do not ready a G), as this can deadlock
   // with stack shrinking.
   lock mutex
}
type waitq struct {
   first *sudog
   last  *sudog
}

和map类似,channel也对应一个make创建的底层数据结构的引用。当我们复制一个channel或用于函数参数传递时,我们只是拷贝了一个channel引用,因此调用者和被调用者将引用同一个channel对象。和其它的引用类型一样,channel的零值也是nil。

两个相同类型的channel可以使用==运算符比较。如果两个channel引用的是相同的对象,那么比较的结果为真。一个channel也可以和nil进行比较。

一个channel有发送和接受两个主要操作,都是通信行为。一个发送语句将一个值从一个goroutine通过channel发送到另一个执行接收操作的goroutine

ch <- x  // a send statement
x = <-ch // a receive expression in an assignment statement
<-ch     // a receive statement; result is discarded

阻塞的goroutine 如何被唤醒

写入时,如果有等待读取的goroutine, 把recvq队列头的 g 唤醒,即把sg.g置成ready,由scheduler 调度

读取同理

chansend

...
if sg := c.recvq.dequeue(); sg != nil {
   // Found a waiting receiver. We pass the value we want to send
   // directly to the receiver, bypassing the channel buffer (if any).
   send(c, sg, ep, func() { unlock(&c.lock) }, 3)//send 会把sg.g置成ready
   return true
}

if c.qcount < c.dataqsiz {
   // Space is available in the channel buffer. Enqueue the element to send.
   qp := chanbuf(c, c.sendx)
   if raceenabled {
      racenotify(c, c.sendx, nil)
   }
   typedmemmove(c.elemtype, qp, ep)
   c.sendx++
   if c.sendx == c.dataqsiz {
      c.sendx = 0
   }
   c.qcount++
   unlock(&c.lock)
   return true
}

...
// Block on the channel
// 构造sudog 放入c.sendq
c.sendq.enqueue(mysg)

gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
KeepAlive(ep)

//等待被唤醒, 其实就是数据被接收后被唤醒

releaseSudog(mysg)//清空sudog的一些字段后放入p.sudogcache

chanrecv

...
if sg := c.sendq.dequeue(); sg != nil {
   // Found a waiting sender. If buffer is size 0, receive value
   // directly from sender. Otherwise, receive from head of queue
   // and add sender's value to the tail of the queue (both map to
   // the same buffer slot because the queue is full).
   recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
   return true, true
}


if c.qcount > 0 {
   // Receive directly from queue
   qp := chanbuf(c, c.recvx)
   if raceenabled {
      racenotify(c, c.recvx, nil)
   }
   if ep != nil {
      typedmemmove(c.elemtype, ep, qp)
   }
   typedmemclr(c.elemtype, qp)
   c.recvx++
   if c.recvx == c.dataqsiz {
      c.recvx = 0
   }
   c.qcount--
   unlock(&c.lock)
   return true, true
}

...


// no sender available: block on this channel.
// 构造sudog 放入c.recvq, 然后等待
mysg.elem = ep
...

gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)


//被唤醒后
releaseSudog(mysg)

close

Channel还支持close操作,用于关闭channel,随后对基于该channel的任何发送操作都将导致panic异常,再次发送也会panic。对一个已经被close过的channel进行接收操作依然可以接收到之前已经成功发送的数据;如果channel中已经没有数据的话将产生一个零值的数据。

close(ch)
// The close built-in function closes a channel, which must be either
// bidirectional or send-only. It should be executed only by the sender,
// never the receiver, and has the effect of shutting down the channel after
// the last sent value is received. After the last value has been received
// from a closed channel c, any receive from c will succeed without
// blocking, returning the zero value for the channel element. The form
// x, ok := <-c
// will also set ok to false for a closed channel.
func closechan(c *hchan) {

// release all readers


// release all writers (they will panic)


// Ready all Gs now

select

源码

select源码位于src\runtime\select.go,最重要的scase 数据结构为:

type scase struct {
   c    *hchan         // chan
   elem unsafe.Pointer // data element
}

轮询顺序 pollorder 是通过runtime.fastrandn 函数引入随机性;随机的轮询顺序可以避免 channel 的饥饿问题,保证公平性加锁顺序 lockorder是按照 channel 的地址排序后确定的加锁顺序,这样能够避免死锁的发生。

加锁和解锁调用的是runtime.sellock()函数和runtime.selunlock()函数。 从下面的代码逻辑中可以看到,两个函数分别是按lockorder顺序对channel加锁,以及按lockorder逆序释放锁。

接下来,是selectgo()函数的主处理逻辑,它会分三个阶段查找或等待某个channel准备就绪: 首先,根据 pollorder 的顺序查找 scases 是否有可以立即收发的 channel;其次,将当前 goroutine 加入各 case 的 channel 对应的收发队列上并等待其他 goroutine 的唤醒;最后,当前 goroutine 被唤醒之后找到满足条件的 channel 并进行处理;

如果没有可以立即处理的 channel,则进入主逻辑的下一个阶段,根据需要将当前 goroutine 加入 channel 对应的收发队列上并等待其他 goroutine 的唤醒。

然后,unlinking from gp.waiting,遍历全部的case的sudog,找到唤醒当前goroutine的case的索引并返回,后面会根据它做channel的收发操作;最后,剩下的不是唤醒当前goroutine的case,需要将当前goroutine从这些case的发送队列或接收队列出队,并释放这些case的sudog;

总结

selectgo函数的执行分为四个步骤

1.首先,随机生成一个遍历case的 轮询顺序 pollorder ,并根据 channel 地址生成加锁顺序 lockorder,随机顺序能够避免channel饥饿,保证公平性,加锁顺序能够避免死锁和重复加锁;然后 lock all the channels involved in the select

2.然后,根据 pollorder 的顺序查找 scases 是否有可以立即收发的channel,如果有则获取case索引进行处理;

3.再次,如果pollorder顺序上没有可以直接处理的case,则将当前 goroutine 加入各 case 的 channel 对应的收发队列上并等待其他 goroutine 的唤醒;

4.最后,当调度器唤醒当前 goroutine 时,会再次 遍历所有的case,从中查找需要被处理的case索引进行读写处理,同时从所有case的发送接收队列中移除掉当前goroutine。

data[:6:8] 两个冒号的理解

golang slice data[:6:8] 两个冒号的理解 data[:6:8] 每个数字前都有个冒号: slice内容为data从0到第6位,长度len为6,最大扩充项cap设置为8

创建

以最简单方式调用make函数创建的是一个无缓存的channel,但是我们也可以指定第二个整型参数,对应channel的容量。如果channel的容量大于零,那么该channel就是带缓存的channel。

ch = make(chan int)    // unbuffered channel
ch = make(chan int, 0) // unbuffered channel
ch = make(chan int, 3) // buffered channel with capacity 3

不带缓存的Channel

一个基于无缓存Channels的发送操作将导致发送者goroutine阻塞,直到另一个goroutine在相同的Channels上执行接收操作,当发送的值通过Channels成功传输之后,两个goroutine可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的Channels上执行发送操作。

基于无缓存Channels的发送和接收操作将导致两个goroutine做一次同步操作。因为这个原因,无缓存Channels有时候也被称为同步Channels。当通过一个无缓存Channels发送数据时,接收者收到数据发生在再次唤醒唤醒发送者goroutine之前(译注:happens before,这是Go语言并发内存模型的一个关键术语!)。

在讨论并发编程时,当我们说x事件在y事件之前发生(happens before),我们并不是说x事件在时间上比y时间更早;我们要表达的意思是要保证在此之前的事件都已经完成了,例如在此之前的更新某些变量的操作已经完成,你可以放心依赖这些已完成的事件了。

当我们说x事件既不是在y事件之前发生也不是在y事件之后发生,我们就说x事件和y事件是并发的。这并不是意味着x事件和y事件就一定是同时发生的,我们只是不能确定这两个事件发生的先后顺序。在下一章中我们将看到,当两个goroutine并发访问了相同的变量时,我们有必要保证某些事件的执行顺序,以避免出现某些并发问题。

串联的Channels(Pipeline)

Channels也可以用于将多个goroutine连接在一起,一个Channel的输出作为下一个Channel的输入。这种串联的Channels就是所谓的管道(pipeline)。

当一个被关闭的channel中已经发送的数据都被成功接收后,后续的接收操作将不再阻塞,它们会立即返回一个零值。

没有办法直接测试一个channel是否被关闭,但是接收操作有一个变体形式:它多接收一个结果,多接收的第二个结果是一个布尔值ok,ture表示成功从channels接收到值,false表示channels已经被关闭并且里面没有值可接收。

用range遍历channel

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

    // Counter
    go func() {
        for x := 0; x < 100; x++ {
            naturals <- x
        }
        close(naturals)
    }()

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

    // Printer (in main goroutine)
    for x := range squares {
        fmt.Println(x)
    }
}

不close的话会deadlock

其实你并不需要关闭每一个channel。只有当需要告诉接收者goroutine,所有的数据已经全部发送时才需要关闭channel。不管一个channel是否被关闭,当它没有被引用时将会被Go语言的垃圾自动回收器回收。(不要将关闭一个打开文件的操作和关闭一个channel操作混淆。对于每个打开的文件,都需要在不使用的时候调用对应的Close方法来关闭文件。)

试图重复关闭一个channel将导致panic异常,试图关闭一个nil值的channel也将导致panic异常。关闭一个channels还会触发一个广播机制,我们将在8.9节讨论。

Deadlock in Channel

Deadlock in Channel in Golang happens when a few goroutines are waiting for each other but no goroutine can proceed.

Under What Situations Does a Deadlock Occur in the Golang Channel?

  1. Read from an Empty Channel
  2. Sending Data to a Full Channel
ch := make(chan int)
go func() {
   ch <- 1// 必须写在go func 里面 ,不然这一步就会阻塞,引发deadlock
}()

fmt.Println(<-ch)

如何避免

  • 避免无限制的等待: 使用带有超时的channel
  • 使用buffered channel: buffered channel允许发送方在没有接收方准备好的情况下仍然能发送数据,这可以在某些情况下避免死锁。

带有超时的channel

使用select语句来监视多个通道的操作,结合time包中的定时器来实现超时控制

ch1 := make(chan string)
go func() {
   time.Sleep(2 * time.Second)
   ch1 <- "操作1完成!"
}()

select {
case result := <-ch1:
   fmt.Println(result)
case <-time.After(1 * time.Second):
   fmt.Println("操作1超时...")
}

单方向的Channel

类型chan<- int表示一个只发送int的channel,只能发送不能接收。相反,类型<-chan int表示一个只接收int的channel,只能接收不能发送。(箭头<-和关键字chan的相对位置表明了channel的方向。)这种限制将在编译期检测。

因为关闭操作只用于断言不再向channel发送新的数据,所以只有在发送者所在的goroutine才会调用close函数,因此对一个只接收的channel调用close将是一个编译错误。

func counter(out chan<- int) {
    for x := 0; x < 100; x++ {
        out <- x
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    for v := range in {
        out <- v * v
    }
    close(out)
}

func printer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

func main() {
    naturals := make(chan int)
    squares := make(chan int)
    go counter(naturals)
    go squarer(squares, naturals)
    printer(squares)
}

调用counter(naturals)时,naturals的类型将隐式地从chan int转换成chan<- int。调用printer(squares)也会导致相似的隐式转换,这一次是转换为<-chan int类型只接收型的channel。任何双向channel向单向channel变量的赋值操作都将导致该隐式转换。这里并没有反向转换的语法:也就是不能将一个类似chan<- int类型的单向型的channel转换为chan int类型的双向型的channel。

带缓存的Channels

带缓存的Channel内部持有一个元素队列(环形队列)。队列的最大容量是在调用make函数创建channel时通过第二个参数指定的。

image.png

向缓存Channel的发送操作就是向内部缓存队列的尾部插入元素,接收操作则是从队列的头部删除元素。如果内部缓存队列是满的,那么发送操作将阻塞直到因另一个goroutine执行接收操作而释放了新的队列空间。相反,如果channel是空的,接收操作将阻塞直到有另一个goroutine执行发送操作而向队列插入元素。

cap函数获取channel内部缓存的容量 len函数返回channel内部缓存队列中有效元素的个数

下面的例子展示了一个使用了带缓存channel的应用。它并发地向三个镜像站点发出请求,三个镜像站点分散在不同的地理位置。它们分别将收到的响应发送到带缓存channel,最后接收者只接收第一个收到的响应,也就是最快的那个响应。因此mirroredQuery函数可能在另外两个响应慢的镜像站点响应之前就返回了结果。(顺便说一下,多个goroutines并发地向同一个channel发送数据,或从同一个channel接收数据都是常见的用法。)

func mirroredQuery() string {
    responses := make(chan string, 3)
    go func() { responses <- request("asia.gopl.io") }()
    go func() { responses <- request("europe.gopl.io") }()
    go func() { responses <- request("americas.gopl.io") }()
    return <-responses // return the quickest response
}

如果我们使用了无缓存的channel,那么两个慢的goroutines将会因为没有人接收而被永远卡住。这种情况,称为goroutines泄漏,这将是一个BUG。和垃圾变量不同,泄漏的goroutines并不会被自动回收,因此确保每个不再需要的goroutine能正常退出是重要的。

并发的循环

for _, f := range filenames {
    go func() {
        thumbnail.ImageFile(f) // NOTE: incorrect!
        // ...
    }()
}

上面这个单独的变量f是被所有的匿名函数值所共享,且会被连续的循环迭代所更新的。当新的goroutine开始执行字面函数时,for循环可能已经更新了f并且开始了另一轮的迭代或者(更有可能的)已经结束了整个循环,所以当这些goroutine开始读取f的值时,它们所看到的值已经是slice的最后一个元素了。

改成

 for _, f := range filenames {
        go func(f string) {
            thumbnail.ImageFile(f) // NOTE: ignoring errors
        }(f)
    }

显式地添加这个参数,我们能够确保使用的f是当go语句执行时的“当前”那个f。

基于select的多路复用

为了能够多路复用,我们使用了select语句。

select {
case <-ch1:
    // ...
case x := <-ch2:
    // ...use x...
case ch3 <- y:
    // ...
default:
    // ...
}

上面是select语句的一般形式。和switch语句稍微有点相似,也会有几个case和最后的default选择分支。每一个case代表一个通信操作(在某个channel上进行发送或者接收)

select会等待case中有能够执行的case时去执行。当条件满足时,select才会去通信并执行case之后的语句;这时候其它通信是不会执行的。一个没有任何case的select语句写作select{},会永远地等待下去。

让我们回到我们的火箭发射程序。time.After函数会立即返回一个channel,并起一个新的goroutine在经过特定的时间后向该channel发送一个独立的值。下面的select语句会一直等待直到两个事件中的一个到达,无论是abort事件或者一个10秒经过的事件。如果10秒经过了还没有abort事件进入,那么火箭就会发射。

func main() {
    // ...create abort channel...

    fmt.Println("Commencing countdown.  Press return to abort.")
    select {
    case <-time.After(10 * time.Second):
        // Do nothing.
    case <-abort:
        fmt.Println("Launch aborted!")
        return
    }
    launch()
}

如果多个case同时就绪时,select会随机地选择一个执行,这样来保证每一个channel都有平等的被select的机会。

func main() {
    // ...create abort channel...

    fmt.Println("Commencing countdown.  Press return to abort.")
    tick := time.Tick(1 * time.Second)
    for countdown := 10; countdown > 0; countdown-- {
        fmt.Println(countdown)
        select {
        case <-tick:
            // Do nothing.
        case <-abort:
            fmt.Println("Launch aborted!")
            return
        }
    }
    launch()
}

time.Tick函数表现得好像它创建了一个在循环中调用time.Sleep的goroutine,每次被唤醒时发送一个事件。当countdown函数返回时,它会停止从tick中接收事件,但是ticker这个goroutine还依然存活,继续徒劳地尝试向channel中发送值,然而这时候已经没有其它的goroutine会从该channel中接收值了——这被称为goroutine泄露(§8.4.4)。

Tick函数挺方便,但是只有当程序整个生命周期都需要这个时间时我们使用它才比较合适。否则的话,我们应该使用下面的这种模式:

ticker := time.NewTicker(1 * time.Second)
<-ticker.C    // receive from the ticker's channel
ticker.Stop() // cause the ticker's goroutine to terminate