go - channel初探

Go并发哲学


Do not communicate by sharing memory; instead, share memory by communicating.
不要通过共享内存来实现通信,而是通过通信来实现共享内存。

-- 它依赖 CSP 模型,基于 channel 实现。

简介


        channel 可以看成一个 FIFO 队列,对 FIFO 队列的读写都是原子的操作,不需要加锁。    

        channel可分为 无缓冲channel 和 有缓冲channel 。
        无缓冲channel 无法缓冲元素,对它的操作一定顺序是“发送-> 接收 -> 发送 -> 接收 -> ……”,如果连续向一个  无缓冲channel 发送 2 个元素,并且没有接收的话,第二次一定会被阻塞;对于 有缓冲channel 的操作,则要“宽松”一些,毕竟是带了“缓冲”光环。

       channel 是一个引用类型,所以在它被初始化之前,它的值是 nil。channel 使用 make 函数进行初始化。可以向它传递一个 int 值,代表 channel 缓冲区的大小(容量),构造出来的是一个缓冲型的 channel;不传或传 0 的,构造的就是一个无缓冲channel。

        channel是 Go 里的第一对象。通过 channel,Go 实现了通过通信来实现内存共享。


无缓冲channel


特点

若只有读端没有写端,那么读端阻塞;反之亦然。

无缓冲 channel,发送者会阻塞直到接收者接收了发送的值,所以是“同步”的。

声明无缓冲channel的方式:不设置缓冲大小或者设置为 0。

// 举例
c := make(chan string)
// <==>
c := make(chan string, 0)

有缓冲channel


特点

发送方在缓冲区满的情况下阻塞,接收方在缓冲区空的情况下阻塞,所以是“异步”的。
只要缓冲区有未使用空间用于发送数据,或还包含可以接收的数据,那么其通信就会无阻塞地进行。

// 举例
c := make(chan string, 2)

未初始化的channel


在一个值为 nil 的 channel 上发送和接收数据将永久阻塞

func main() {
    var ch chan int // 未初始化,值为 nil
    for i := 0; i < 3; i++ {
        go func(i int) {
            ch <- i
        }(i)
    }

    fmt.Println("Result: ", <-ch)
}


// 运行结果
fatal error: all goroutines are asleep - deadlock! 
goroutine 1 [chan receive (nil chan)]

channel关闭相关


        关闭不再需要使用的 channel 并不是必须的。跟其他资源比如打开的文件、socket 连接不一样,这类资源使用完后不关闭后会造成句柄泄露,channel 使用完后不关闭也没有关系,channel 没有被任何协程用到后最终会被 GC 回收。关闭 channel 一般是用来通知其他协程某个任务已经完成了。

channel关闭注意事项:
(1)同一个channel不能close多次,否则会导致panic。

(2)可以通过 v, ok := <- ch 方式中的ok值来判断该channel是否已经关闭。

(3)不能向已经关闭的channel中继续写入数据,否则会导致panic。

(4)从一个已经关闭的channel中读取数据时,依据有缓冲/无缓冲可以分两种情况讨论:

       ①无缓冲的channel:不会panic,但读到的将是"零值"。如int型channel将读到0,bool型channel将读到false。

       ②有缓冲channel:不会panic,若关闭channel时缓冲区里还有数据,则可以继续读取出剩余的数据,直到数据读完后将读出"零值"。


由上,总结channel操作行为结果如下:

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

如何优雅地关闭channel:

原则:

        永远不要在读取端关闭 channel ,因为写入端无法知道 channel 是否已经关闭,往已关闭的 channel 写数据会 panic。 

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

///
func SafeSend(ch chan T, value T) (closed bool) {
    defer func() {
        if recover() != nil {
            closed = true
        }
    }()
    ch <- value
    return false 
}

///
func SafeClose(ch chan T) (justClosed bool) {
    defer func() {
        if recover() != nil {
            justClosed = false
        }
    }()
    close(ch)
    return true
}
/// 还可以通过sync.Once来实现安全关闭channel
type MyChannel struct {
    C chan T
    once sync.Once
}

func NewMyChannel() *MyChannel {
    return &MyChannel{C: make(chan T)}
}

func (mc *MyChannel) SafeClose() {
    mc.once.Do(func(){
        close(mc.C)
    })
}
/// 判断channel是否关闭
///   不过实现一个这样的接口也没什么必要。
///   因为就算通过 isClosed() 得到当前 channel 当前还未关闭,
///   如果试图往 channel 里写数据,仍然可能会发生 panic ,
///   因为在调用 isClosed() 后,其他协程可能已经把 channel 关闭了。
func isClosed(ch chan int) bool {
	select {
	case <-ch:
		return true
	default:
	}
	return false
}

拓展:下面介绍不同场景下关闭channel的优雅些的做法

场景一:一写多读

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

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	wg := &sync.WaitGroup{}
	ch := make(chan int, 100)
	send := func() {
		for i := 0; i < 100; i++ {
			ch <- i
		}
		// signal sending finish
		close(ch)
	}
	recv := func(id int) {
		defer wg.Done()
		for i := range ch {
			fmt.Printf("receiver #%d get %d\n", id, i)
		}
		fmt.Printf("receiver #%d exit\n", id)
	}
	wg.Add(3)
	go recv(0)
	go recv(1)
	go recv(2)
	send()
	wg.Wait()
}

场景二:多写一读

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

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	wg := &sync.WaitGroup{}
	ch := make(chan int, 100)
	done := make(chan struct{})
	send := func(id int) {
		defer wg.Done()
		for i := 0; ; i++ {
			select {
			case <-done:
				// get exit signal
				fmt.Printf("sender #%d exit\n", id)
				return
			case ch <- id*1000 + i:
			}
		}
	}
	recv := func() {
		count := 0
		for i := range ch {
			fmt.Printf("receiver get %d\n", i)
			count++
			if count >= 1000 {
				// signal recving finish
				close(done)
				return
			}
		}
	}
	wg.Add(3)
	go send(0)
	go send(1)
	go send(2)
	recv()
	wg.Wait()
}

场景三:多写多读

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

package main
 
import (
	"fmt"
	"sync"
	"time"
)
 
func main() {
	wg := &sync.WaitGroup{}
	ch := make(chan int, 100)
	done := make(chan struct{})
	send := func(id int) {
		defer wg.Done()
		for i := 0; ; i++ {
			select {
			case <-done:
				// get exit signal
				fmt.Printf("sender #%d exit\n", id)
				return
			case ch <- id*1000 + i:
			}
		}
	}
	recv := func(id int) {
		defer wg.Done()
		for {
			select {
			case <-done:
				// get exit signal
				fmt.Printf("receiver #%d exit\n", id)
				return
			case i := <-ch:
				fmt.Printf("receiver #%d get %d\n", id, i)
				time.Sleep(time.Millisecond)
			}
		}
	}
	wg.Add(6)
	go send(0)
	go send(1)
	go send(2)
	go recv(0)
	go recv(1)
	go recv(2)
	time.Sleep(time.Second)
	// signal finish
	close(done)
	// wait all sender and receiver exit
	wg.Wait()
}

底层实现


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

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

go - channel初探_第1张图片

hchan中持有两个链表,接收者链表recvq和发送者链表sendq,它们的类型是waitq。链表中的元素为sudog结构体类型,它包含了发送者或接收者的协程相关的信息。通过这些信息,Go 可以在发送者不存在时阻塞住接收者,反之亦然。

go - channel初探_第2张图片

以三种不同场景为例分析:

(1)当协程尝试从未关闭的 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 阻塞。

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

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

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

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

问答


(1)channel为什么可以做到线程安全?

        channel底层实现时用到了互斥锁和缓冲数据队列,在元素进行出队/入队时都通过锁机制保障了操作的原子性,避免了复杂的竞态情形。

拓展


// 创建读写channel
c := make(chan int)

// 创建只读channel
c := make(<-chan int)

// 创建只写channel
c := make(chan<- int)

总结


        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 管道 通道 信道 使用总结_whatday的博客-CSDN博客

https://www.jianshu.com/p/d24dfbb33781

[译] Go语言的有缓冲channel和无缓冲channel - 知乎

你可能感兴趣的:(GO,channel,go)