Go Channel 深度解析

229 阅读19分钟

1. 并发模型

1.1 并发与并行

并发是指逻辑上具备同时处理多个任务的能力;并行则是物理上同时执行多个任务。

并发的/并行的代码如果在单核机器上运行,它还能并行吗?不能。

我们实际上只能编写并发的代码,而不能编写并行的代码

Go发布前写并发代码时,考虑到的最底层抽象是线程。Go 发布后,在这条抽象链上,又加入了 goroutinechannel

goroutine替代了线程。现在在写代码时,大部分时候只需考虑 goroutine 和 channel。

1.2 什么是 CSP

“Communicating Sequential Processes”(通信顺序进程)。

2. Channel 简介

2.1 什么是 channel

Goroutine 用于执行并发任务,channel 用于 goroutine 之间的同步、通信

Go 通过 channel 实现 CSP 通信模型。

Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;它是线程安全的,还提供先进先出的特性,还能影响 goroutine 的阻塞和唤醒

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

不要通过共享内存来通信,而要通过通信来实现内存共享。

这就是 Go 的并发哲学,它依赖 CSP 模型,基于 channel 实现。

前半句:通过 sync 包里的一些组件进行并发编程;后半句则是说 Go 推荐使用 channel 进行并发编程。

实际上 channel 的底层就是通过 mutex 来控制并发。只是 channel 是更高一层次的并发编程原语,封装了更多的功能。

2.2 channel

Channel 字面意义是通道,类似于 Linux 中的管道。

// T是数据类型的意思
var ch chan T // 声明一个双向通道
var ch chan<- T // 声明一个只能用于发送的通道
var ch <-chan T // 声明一个只能用于接收的通道
ch = make(chan T, n) // 初始化通道,n为缓冲区大小

channel 是引用类型,被初始化前值是 nil

3. Channel 底层

从此开始进入源码,如果看不懂不用着急,下面有概述

3.1 数据结构

建议截图稍后对照

type hchan struct {
	qcount   uint  // chan 元素数量
	dataqsiz uint  // chan 底层循环数组的长度
	buf      unsafe.Pointer // 指向底层循环数组的指针,只针对有缓冲的 channel
	elemsize uint16 // chan 中元素大小
	closed   uint32 // 是否关闭
	elemtype *_type // chan 中元素类型
	// 指向底层循环数组,已 发送/接收 元素在循环数组中的索引
	sendx    uint   
	recvx    uint   
	// 等待 接收/发送 的 goroutine 队列
	recvq    waitq 
	sendq    waitq  
	lock mutex // 保证读写都是原子操作
}

waitq 是 sudog 的一个双向链表,而 sudog 是对 goroutine 的一个封装:

type waitq struct {
	first *sudog
	last  *sudog
}

例如,创建一个容量为 6 的,元素为 int 型的 channel 数据结构如下 :

chan data structure

3.2 创建

一般而言,使用 make 创建一个能收能发的通道:

// 无缓冲通道
ch1 := make(chan int)
// 有缓冲通道
ch2 := make(chan int, 10)

底层创建函数是:

func makechan(t *chantype, size int64) *hchan

具体来看下代码:

const hchanSize = unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))&(maxAlign-1))

func makechan(t *chantype, size int64) *hchan {
	elem := t.elem
	var c *hchan
	// 元素类型不含指针 或者 size 大小为 0(无缓冲类型),只进行一次内存分配
	if elem.kind&kindNoPointers != 0 || size == 0 {
		// 如果 hchan 结构体中不含指针,GC 就不会扫描 chan 中的元素
		// 只分配 "hchan 结构体大小 + 元素大小*个数" 的内存
		c = (*hchan)(mallocgc(hchanSize+uintptr(size)*elem.size, nil, true))
		// 如果是缓冲型 channel 且元素大小不等于 0(大小等于 0的元素类型:struct{})
		if size > 0 && elem.size != 0 {
			c.buf = add(unsafe.Pointer(c), hchanSize)
		} else {
			// 1. 非缓冲型的,buf 没用,直接指向 chan 起始地址处
			// 2. 缓冲型的,元素无指针且元素类型为 struct{}
			c.buf = unsafe.Pointer(c)
		}
	} else {
		// 进行两次内存分配操作
		c = new(hchan)
		c.buf = newarray(elem, int(size))
	}
        // 对c的 元素大小、类型,底层数组长度赋值...

	return c
}

浓缩完就是:

  • 如果 channel 的 元素类型不是指针是无缓冲通道,则只需分配一次内存
    • hchan结构体的大小 + 元素大小 * 容量 赋给 c
    • 再判断如果是缓冲型chan并且元素大小 != 0 (struct{} 大小为0)
      • 把 c.buf 即指向底层数组的指针挪到相应的位置
    • 否则只剩两种情况:
      • 1.非缓冲型,buf没用
      • 2.缓冲型,但元素类型为struct{}
      • 二者的buf都没用,c.buf直接指向chan起始地址
  • 否则进行两次内存分配
    • new一个chan 和 newarray chan的buf

我们用一个例子来理解创建、发送、接收的整个过程。

这个例子将贯穿 Channel底层这节

func goroutineA(a <-chan int) {
	val := <- a
	fmt.Println("G1 received data: ", val)
	return
}

func goroutineB(b <-chan int) {
	val := <- b
	fmt.Println("G2 received data: ", val)
	return
}

func main() {
	ch := make(chan int)
	go goroutineA(ch)
	go goroutineB(ch)
	ch <- 3
	time.Sleep(time.Second)
}

创建一个无缓冲的 chan,启动两个协程。向 chan 发送数据,sleep 1 秒后退出。

我们只看 chan 中的一些重要字段,从整体层面看一下 chan 的状态,一开始什么都没有: unbuffered chan

3.3 接收

接收操作有两种写法:

  • ok,反应 channel 是否关闭;
  • 不带 ok,当接收到零值时无法知道是发送者发送的值,还是 channel 关闭后返回的零值。

经过编译器的处理后,这两种写法最后对应源码里的这两个函数:

func chanrecv1(c *hchan, elem unsafe.Pointer) {
	chanrecv(c, elem, true)
}

func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
	_, received = chanrecv(c, elem, true)
	return
}

接收值会放到参数 elem 所指向的地址。如果代码忽略了接收值,elem 为 nil。

无论如何,最终转向了 chanrecv 函数:

// 如果 block == false,即非阻塞型接收,在没有数据可接收的情况下,返回 (false, false)
// 否则,如果 c 处于关闭状态,将 ep 指向的地址的值清零,返回 (true, false)
// 否则,用返回值填充 ep 指向的内存地址。返回 (true, true)
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
	if c == nil {
		// 不阻塞,直接返回 (false, false)
		if !block {
			return
		}
		// 否则,接收一个 nil 的 channel,goroutine 挂起
		gopark(nil, nil, "chan receive (nil chan)", traceEvGoStop, 2)
		// 不会执行到这里
		throw("unreachable")
	}

	// 在非阻塞模式下,快速检测到失败,快速返回
	// 当我们观察到 channel 没准备好接收:
	// 1. 非缓冲型,等待发送列队 sendq 里没有 goroutine 在等待
	// 2. 缓冲型,但 buf 里没有元素
	// closed == 0,即 channel 未关闭。
	// 在这种情况下可以直接宣布接收失败,返回 (false, false)
	if !block && (c.dataqsiz == 0 && c.sendq.first == nil ||
		c.dataqsiz > 0 && atomic.Loaduint(&c.qcount) == 0) &&
		atomic.Load(&c.closed) == 0 {
		return
	}

	// 加锁
	lock(&c.lock)

	// channel 已关闭,并且循环数组 buf 里没有元素
	// 这里可以处理非缓冲型关闭 和 缓冲型关闭但 buf 无元素的情况
	if c.closed != 0 && c.qcount == 0 {
		if raceenabled {
			raceacquire(unsafe.Pointer(c))
		}
		// 解锁
		unlock(&c.lock)
		if ep != nil {
			// 接收的值将是一个该类型的零值
			// typedmemclr 根据类型清理相应地址的内存
			typedmemclr(c.elemtype, ep)
		}
		// 从一个已关闭的 channel 接收,selected 会返回true
		return true, false
	}

	// 等待发送队列里有 goroutine 存在,说明 buf 是满的
	// 这有可能是:
	// 1. 非缓冲型的 channel
	// 2. 缓冲型的 channel,但 buf 满了
	// 针对 1,直接进行内存拷贝(从 sender goroutine -> receiver goroutine)
	// 针对 2,接收到循环数组头部的元素,并将发送者的元素放到循环数组尾部
	if sg := c.sendq.dequeue(); sg != nil {
		recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true, true
	}

	// 缓冲型,buf 里有元素,可以正常接收
	if c.qcount > 0 {
		// 直接从循环数组里找到要接收的元素
		qp := chanbuf(c, c.recvx)

		if ep != nil {
			typedmemmove(c.elemtype, ep, qp)
		}
		// 清理掉循环数组里相应位置的值
		typedmemclr(c.elemtype, qp)
		// 接收游标向前移动
		c.recvx++
		// 接收游标归零
		if c.recvx == c.dataqsiz {
			c.recvx = 0
		}
		// buf 数组里的元素个数减 1
		c.qcount--
		// 解锁
		unlock(&c.lock)
		return true, true
	}

	if !block {
		// 非阻塞接收,解锁。selected 返回 false,因为没有接收到值
		unlock(&c.lock)
		return false, false
	}

	// 接下来就是要被阻塞的情况了
	// 构造一个 sudog
	gp := getg()
	mysg := acquireSudog()
	mysg.releasetime = 0
	if t0 != 0 {
		mysg.releasetime = -1
	}

	// 待接收数据的地址保存下来...
        
	// 进入channel 的等待接收队列
	c.recvq.enqueue(mysg)
	// 将当前 goroutine 挂起
	goparkunlock(&c.lock, "chan receive", traceEvGoBlockRecv, 3)

	// 被唤醒了,接着从这里继续执行一些扫尾工作
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	closed := gp.param == nil
	gp.param = nil
	mysg.c = nil
	releaseSudog(mysg)
	return true, !closed
}

浓缩一下:

  1. 如果 chan 为nil,非阻塞模式下直接返回。阻塞模式下协程会一直阻塞下去。因为 chan 为 nil ,要想不阻塞只有关闭它,但关闭 nil 的 chan 会 panic,所以没机会被唤醒了。

  2. 接下来通过一个边缘检测快速检测(不用获取锁) 失败并返回。

    • 非阻塞模式下当我们观察到 chan 没准备好接收:
      1. 非缓冲型:等待发送列队里没有 goroutine
      2. 缓冲型: buf 里没元素
    • 观察到 closed = 0,即 chan 未关闭。
    • 该情况下可直接宣布接收失败。
  3. 加锁。如果 chan 已关闭,并且缓冲型 buf 里没有元素/非缓冲型。返回对应类型的零值received 标识是 false,告诉调用者 chan 已关闭。

  4. 如果等待发送队列里有协程存在,这有可能是:

    1. 非缓冲型 chan:直接进行内存拷贝(从sender 协程 -> receiver 协程)
    2. 缓冲型 chan,但 buf满了:接收 buf的头部元素,将发送的元素放入buf(buf是个先进先出的循环队列)
  5. 如果 buf 里还有数据,将 buf 里接收游标处的数据拷贝到接收数据的地址。清理,游标移动,qcount-1

  6. 最后一步,如果不阻塞,直接返回,阻塞情况下: 构造一个 sudog,保存各种值,放入等待接收队列。被唤醒时,说明有人传值或chan关闭了接收的数据就会保存到这个字段指向的地址。

继续之前的例子。

第 14 行创建了一个非缓冲型的 chan, 15、16 行分别创建了一个 goroutine,各自执行了一个接收操作。这两个 goroutine (后面称为 G1 和 G2 )都会被阻塞在接收操作。G1 和 G2 会挂在 chan 的 recq 队列中,形成一个双向循环链表。

在程序的 17 行之前,chan 的整体数据结构如下:

chan struct at the runtime

  • buf 指向一个长度为 0 的数组,qcount 为 0,表示 chan 中没有元素。
  •  recvq 和 sendq是 waitq 结构体,而 waitq 实际上就是一个双向链表,链表的元素是 sudog,里面包含 g 字段,g 表示一个 goroutine,所以 sudog 可以看成一个 goroutine。
  •  recvq 存储尝试读取 chan 但被阻塞的协程,sendq 则存储那些尝试写入 chan,但被阻塞的协程。

此时 recvq 里挂了两个 协程,即 G1、G2。因为没有协程发送,chan 又是无缓冲类型,所以 G1 和 G2 被阻塞。

recvq 的数据结构如下。这里直接引用文章中的一幅图,用了三维元素,画得很好:

image.png 再从整体上来看一下 chan 此时的状态:

chan state

现在 G1 和 G2 都被挂起了,等待着一个 sender 往 channel 里发送数据。

3.4 发送

ch <- 3

第 17 行向 channel 发送了一个元素 3。

发送操作最终转化为 chansend 函数:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
	// 如果 channel 是 nil
	if c == nil {
		// 不能阻塞,直接返回 false,表示未发送成功
		if !block {
			return false
		}
		// 当前 goroutine 被挂起
		gopark(nil, nil, "chan send (nil chan)", traceEvGoStop, 2)
		throw("unreachable")
	}

	// 对于不阻塞的 send,快速检测失败场景
	// 如果 channel 未关闭且 channel 没有多余的缓冲空间。这可能是:
	// 1. channel 是非缓冲型的,且等待接收队列里没有 goroutine
	// 2. channel 是缓冲型的,但循环数组已经装满了元素
	if !block && c.closed == 0 && ((c.dataqsiz == 0 && c.recvq.first == nil) ||
		(c.dataqsiz > 0 && c.qcount == c.dataqsiz)) {
		return false
	}

	lock(&c.lock)

	// 如果 channel 关闭了
	if c.closed != 0 {
		unlock(&c.lock)
		panic(plainError("send on closed channel"))
	}

	// 如果接收队列里有 goroutine,直接将要发送的数据拷贝到接收 goroutine
	if sg := c.recvq.dequeue(); sg != nil {
		send(c, sg, ep, func() { unlock(&c.lock) }, 3)
		return true
	}

	// 对于缓冲型的 channel,如果还有缓冲空间
	if c.qcount < c.dataqsiz {
		// qp 指向 buf 的 sendx 位置
		qp := chanbuf(c, c.sendx)

		// 将数据从 ep 处拷贝到 qp
		typedmemmove(c.elemtype, qp, ep)
		// 发送游标值加 1
		c.sendx++
		// 如果发送游标值等于容量值,游标值归 0
		if c.sendx == c.dataqsiz {
			c.sendx = 0
		}
		// 缓冲区的元素数量加一
		c.qcount++

		// 解锁
		unlock(&c.lock)
		return true
	}

	// 如果不需要阻塞,则直接返回错误
	if !block {
		unlock(&c.lock)
		return false
	}

	// channel 满了,发送方会被阻塞。接下来会构造一个 sudog...

	// 当前 goroutine 进入发送等待队列
	c.sendq.enqueue(mysg)

	// 当前 goroutine 被挂起
	goparkunlock(&c.lock, "chan send", traceEvGoBlockSend, 3)

	// 从这里开始被唤醒了(channel 有机会可以发送了)
	if mysg != gp.waiting {
		throw("G waiting list is corrupted")
	}
	gp.waiting = nil
	if gp.param == nil {
		if c.closed == 0 {
			throw("chansend: spurious wakeup")
		}
		// 被唤醒后,channel 关闭了。坑爹啊,panic
		panic(plainError("send on closed channel"))
	}
	gp.param = nil
	if mysg.releasetime > 0 {
		blockevent(mysg.releasetime-t0, 2)
	}
	// 去掉 mysg 上绑定的 channel
	mysg.c = nil
	releaseSudog(mysg)
	return true
}

还是浓缩一下:

  1. 如果 chan 是nil,非阻塞类型直接返回,阻塞类型 协程被永久挂起(与上面讲的接收相同)。
  2. 对于不阻塞的send,快速检测失败现场,发生以下情况之一立即返回false
    • 非缓冲型,且等待接收队列里没有协程
    • 缓冲型,但循环数组已经装满了元素
  3. 如果 chan 已经关闭,直接 panic
  4. 如果等待接收队列里有协程,说明此时 chan 是空的。这时会调用 send 函数将元素直接从发送者的栈拷贝到接收者的栈。然后,解锁、唤醒接收者,等待调度器。

一般而言,不同协程的栈是各自独有的。为了不出问题,写的过程中增加了写屏障。这样做的好处是减少了一次内存 copy:不用先拷贝到 buf,直接由发送者到接收者,效率得以提高。

  1. 如果 c.qcount < c.dataqsiz,说明是缓冲型 chan 并且 buf 没满。将待发送的元素放入循环队列。游标移动 + qcount+1,解锁并返回。

  2. 如果没有命中以上条件的,说明 chan 满了。不管是缓冲型还是非缓冲型,都要将这个 sender 阻塞。如果是非阻塞型直接解锁,返回 false。

  3. 阻塞型则构造一个 sudog,将其入队(sendq)挂起。等待合适的时机唤醒。

接着分析例子

在发送小节里我们说到 G1 和 G2 被挂起了。第 17 行,主协程向 ch 发送了一个元素 3。

根据前面源码分析我们知道,sender 发现 ch 的 recvq 里有 receiver 在等待接收,直接把元素拷贝到 队首 sudog 的 elem 地址处, send direct 并调用 goready 将 G1 唤醒,将其加入到 P 的可运行 goroutine 队列中。 G1 runnable

3.5 关闭

func closechan(c *hchan) {
	// 关闭一个 nil channel,panic
	if c == nil {
		panic(plainError("close of nil channel"))
	}

	lock(&c.lock)
	// 如果 channel 已经关闭
	if c.closed != 0 {
		unlock(&c.lock)
		// panic
		panic(plainError("close of closed channel"))
	}

	c.closed = 1
	var glist *g

	// 将 channel 所有等待接收队列的里 sudog 释放
	for {
		// 从接收队列里出队一个 sudog
		sg := c.recvq.dequeue()
		// 出队完毕,跳出循环
		if sg == nil {
			break
		}

		// 如果 elem 不为空,说明此 receiver 未忽略接收数据
		// 给它赋一个相应类型的零值
		if sg.elem != nil {
			typedmemclr(c.elemtype, sg.elem)
			sg.elem = nil
		}
		if sg.releasetime != 0 {
			sg.releasetime = cputicks()
		}
		// 取出 goroutine
		gp := sg.g
		gp.param = nil
		if raceenabled {
			raceacquireg(gp, unsafe.Pointer(c))
		}
		// 相连,形成链表
		gp.schedlink.set(glist)
		glist = gp
	}

	// 将 channel 等待发送队列里的 sudog 释放
	// 如果存在,这些 goroutine 将会 panic
	for {
		// 从发送队列里出队一个 sudog
		sg := c.sendq.dequeue()
		if sg == nil {
			break
		}

		// 发送者会 panic
		sg.elem = nil
		if sg.releasetime != 0 {
			sg.releasetime = cputicks()
		}
		gp := sg.g
		gp.param = nil
		if raceenabled {
			raceacquireg(gp, unsafe.Pointer(c))
		}
		// 形成链表
		gp.schedlink.set(glist)
		glist = gp
	}
	// 解锁
	unlock(&c.lock)

	// 遍历链表
	for glist != nil {
		// 取最后一个
		gp := glist
		// 向前走一步,下一个唤醒的 g
		glist = glist.schedlink.ptr()
		gp.schedlink = 0
		// 唤醒相应 goroutine
		goready(gp, 3)
	}
}

最后的浓缩一下:

  1. 如果 chan 是 nil 直接 panic
  2. 加锁
  3. 如果 chan 是 closed 直接 panic
  4. 遍历 recvqsendq 的所有协程,赋对应类型的0值和准备panic,将他们连到一个链表。
  5. 解锁,唤醒链表里的协程:
    • sender 执行 panic
    • receiver 返回对应的0值。

在不了解 chan 还有没有sender的情况下,不能贸然关闭 channel。

4. channel 进阶

总结一下操作 chan 的结果:

操作nil channelclosed channelnot nil, not closed channel
closepanicpanic正常关闭
读 <- ch阻塞读到对应类型的零值阻塞或正常读取数据。缓冲型 channel 为空或非缓冲型 channel 没有发送者时会阻塞
写 ch <-阻塞panic阻塞或正常写入数据。非缓冲型 channel 没有接收者或缓冲型 channel buf 满时会被阻塞

发生 panic 的情况有三种

  1. 向一个关闭的 channel 进行写操作
  2. 关闭一个 nil 的 channel
  3. 重复关闭一个 channel。

4.1 资源泄漏

Channel 可能会引发 goroutine 泄漏。

协程 操作 chan 后,处于发送或接收阻塞状态,而 chan 处于满或空的状态,一直得不到改变。同时GC也不会回收此类资源,进而导致 协程 会一直处于等待队列中。

4.2 如何优雅地关闭 channel

两个不那么优雅地关闭 channel 的方法:

  1. 使用 defer-recover 机制,放心大胆地关闭 channel 或者向 channel 发送数据。即使发生了 panic,有 defer-recover 在兜底。
  2. 使用 sync.Once 来保证只关闭一次。

那应该如何优雅地关闭 channel?

根据 sender 和 receiver 的个数,分下面几种情况:

  1. 一个 sender,一个 receiver
  2. 一个 sender, M 个 receiver
  3. N 个 sender,一个 reciver
  4. N 个 sender, M 个 receiver

对于只有一个 sender 的情况就不用说了,直接从 sender 端关闭就好。重点关注 3,4 种情况。

第 3 种情形:增加一个传递关闭信号的 chan,receiver 通过信号 chan 下达关闭数据 chan 指令。senders 监听到关闭信号后,停止发送数据。

func main() {
	rand.Seed(time.Now().UnixNano())

	const Max = 100000
	const NumSenders = 1000 // sender的个数

	dataCh := make(chan int, 100)
	stopCh := make(chan struct{}) // 信号channel

	// senders
	for i := 0; i < NumSenders; i++ {
		go func() {
			for {
				select {
				case <- stopCh:
					return
				case dataCh <- rand.Intn(Max):
				}
			}
		}()
	}

	// the receiver
	go func() {
		for value := range dataCh {
			if value == Max-1 {
				fmt.Println("send stop signal to senders.")
				close(stopCh)
				return
			}

			fmt.Println(value)
		}
	}()

}

senders 收到关闭信号后,select 分支 case <- stopCh 被选中,退出函数,不再发送数据。

上面的代码没有明确关闭 dataCh。对于一个 chan,如果没有任何协程引用它,最终会被 gc 回收。所以所谓的优雅地关闭 channel 就是不关闭 channel,让 gc 代劳。

最后一种情况有 M 个 receiver,如果还是由 receiver 关闭 stopCh 的话,会重复关闭一个 chan导致 panic。因此需要增加一个中间人,M 个 receiver 都向它发送关闭 dataCh 的请求,中间人收到第一个请求后,就会直接下达关闭 dataCh 的指令(通过关闭 stopCh,这时就不会发生重复关闭的情况,因为 stopCh 的发送方只有中间人一个)。另外,这里的 N 个 sender 也可以向中间人发送关闭 dataCh 的请求。

func main() {
	rand.Seed(time.Now().UnixNano())

	const Max = 100000
	const NumReceivers = 10
	const NumSenders = 1000

	dataCh := make(chan int, 100)
	stopCh := make(chan struct{})

	toStop := make(chan string, 1) // 中间人

	var stoppedBy string

	// moderator
	go func() {
		stoppedBy = <-toStop
		close(stopCh)
	}()

	// senders
	for i := 0; i < NumSenders; i++ {
		go func(id string) {
			for {
				value := rand.Intn(Max)
				if value == 0 { // 随机数到0 给中间人发送关闭消息
					select {
					case toStop <- "sender#" + id:
					default:
					}
					return
				}

				select {
				case <- stopCh:
					return
				case dataCh <- value:
				}
			}
		}(strconv.Itoa(i))
	}

	// receivers
	for i := 0; i < NumReceivers; i++ {
		go func(id string) {
			for {
				select {
				case <- stopCh:
					return
				case value := <-dataCh:
					if value == Max-1 {
						select {
						case toStop <- "receiver#" + id:
						default:
						}
						return
					}

					fmt.Println(value)
				}
			}
		}(strconv.Itoa(i))
	}
}

将 toStop 声明成 缓冲型的 chan。假设 toStop 是非缓冲型,那么第一个发送的关闭 dataCh 请求可能会丢失。因为无论是 sender 还是 receiver 都是通过 select 语句来发送请求,如果中间人所在的 协程没准备好,那 select 语句就不会选中,直接走 default 选项,什么也不做。这样,第一个关闭 dataCh 的请求就会丢失。

如果,我们把 toStop 的容量声明成 Num(senders) + Num(receivers),那发送 dataCh 请求的部分可以改成更简洁的形式:

...
toStop := make(chan string, NumReceivers + NumSenders)
...
			value := rand.Intn(Max)
			if value == 0 {
				toStop <- "sender#" + id
				return
			}
...
				if value == Max-1 {
					toStop <- "receiver#" + id
					return
				}
...

向 toStop 发送请求,因为 toStop 容量足够大,所以不用担心阻塞,自然也就不用 select 语句再加一个 default case 来避免阻塞。

可以看到,这里同样没有真正关闭 dataCh,原因同第 3 种情况。

5. channel 应用

5.1 任务定时

与 timer 结合,一般有两种玩法:超时控制现定期执行某个任务

有时需要执行某项操作,但又不想它耗费太长时间,上一个定时器就可以搞定:

select {
	case <-time.After(100 * time.Millisecond):
	case <-s.stopc:
		return false
}

等待 100 ms 后,如果 s.stopc 还没有读出数据或者被关闭,就直接结束。

定时执行某个任务:

func worker() {
	ticker := time.Tick(1 * time.Second)
	for {
		select {
		case <- ticker:
			// 执行定时任务
			fmt.Println("执行 1s 定时任务")
		}
	}
}

每隔 1 秒种,执行一次定时任务。

5.2 解耦生产方和消费方

服务启动时,启动 n 个 worker,作为工作协程池,这些协程工作在一个 for {} 无限循环里,从某个 channel 消费工作任务并执行:

func main() {
	taskCh := make(chan int, 100)
	go worker(taskCh)

    // 塞任务
	for i := 0; i < 10; i++ {
		taskCh <- i
	}

    // 等待 1 小时 
	select {
	case <-time.After(time.Hour):
	}
}

func worker(taskCh <-chan int) {
	const N = 5
	// 启动 5 个工作协程
	for i := 0; i < N; i++ {
		go func(id int) {
			for {
				task := <- taskCh
				fmt.Printf("finish task: %d by worker %d\n", task, id)
				time.Sleep(time.Second)
			}
		}(i)
	}
}

5 个工作协程在不断地从工作队列里取任务,生产方只管往 channel 发送任务即可,解耦生产方和消费方。

程序输出:

finish task: 1 by worker 4
finish task: 2 by worker 2
finish task: 4 by worker 3
finish task: 3 by worker 1
finish task: 0 by worker 0
finish task: 6 by worker 0
finish task: 8 by worker 3
finish task: 9 by worker 1
finish task: 7 by worker 4
finish task: 5 by worker 2

5.3 控制并发数

有时需要定时执行几百个任务。但并发数又不能太高,可以通过 chan 来控制并发数。

var limit = make(chan int, 3)

func main() {
    // …………
    for _, w := range work {
        go func() {
            limit <- 1
            w()
            <-limit
        }()
    }
    // …………
}

构建一个缓冲型的 channel,容量为 3。遍历任务列表,每个任务启动一个协程去完成。执行任务访问第三方的动作在 w() 中完成,在此之前,先要从 limit 中拿许可证才能执行,结束后要将许可证归还。这样就可以控制同时运行的 goroutine 数。

如果 w() 发生 panic,许可证可能就还不回去了,因此需要使用 defer 来保证。