Go并发编程实战课(Note.7:Channel)

2021/11/15 1:10:45

本文主要是介绍Go并发编程实战课(Note.7:Channel),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

文章目录

    • 13.chan:另辟蹊径,解决并发问题
      • channel的发展
      • Channel的应用场景
      • Channel的基本用法
      • 1.发送数据
      • 2.接收数据
      • 3.其他操作
      • chan的实现原理
        • chan的数据结构
        • 初始化
        • send
        • recv
        • close
        • 使用chan容易犯的错
    • 14.透过代码看典型的应用模式
      • 使用反射操作channel
      • 典型的应用场景
        • 1.消息交流
        • 2.数据传递
        • 3.信号通知
        • 4.锁
        • 5.任务编排
          • 1.Or-Done模式
          • 2.扇入模式
          • 3.扇出模式
          • 4.Stream
          • 5.map-reduce
    • 15.内存模型:GO如何保证并发读写的顺序
      • 重排和可见性的问题
      • happens-before
      • Go语言中保证的happens-before关系
        • init函数
        • goroutine
        • Channel
        • Mutex/RWMutex
        • WaitGroup
        • Once
        • atomic

13.chan:另辟蹊径,解决并发问题

channel的发展

CSP:Communicating Sequential Process,通信顺序进程。允许使用进程组件来描述进程,他们独立运行并且只通过消息传递的方式通信。

Channel的应用场景

Don’t communicate by sharing memory,share memory by communicating.
不用通过共享内存的方式通信,而是通过通信共享内存。
channel的应用场景分为五种类型,可以有目的地去学习channel的基本原理。
1:数据交流:当作并发的buffer或者queue。解决生产者-消费者问题。
2:数据传递:一个goroutine将数据交给另一个goroutine,相当于把数据的拥有权托付出去。
3:信号通知:一个goroutine可以将信号(closing、closed、data ready等)传递给另一个或者另一组goroutine。
4:任务编排:可以让一组goroutine按照一定的顺序并发或者串行的执行,这就是编排的功能。
5:锁:利用channel也可以实现互斥锁的逻辑。

Channel的基本用法

chan string //可以接收发送string
chan <- struct{} //可以发送struct
<-chan int //只能从chan接收int 

1.发送数据

ch <- 200

2.接收数据

x := <-ch

3.其他操作

​ close:关闭,cap:计算容量,len:计算长度
​ 发送和接收,都可以作为select语句的case clause
​ chan还可以用在for range语句中

chan的实现原理

chan的数据结构

runtime.hchan
qcount uint //chan中已经接收但还没有被取走的元素的个数。len()可以返回这个字段的值
dataqsiz //队列的大小。chan中使用一个循环队列来存放元素。循环队列很适合这种生产者-消费者的场景
buf //循环队列的buffer
eletype //chan中元素的类型,chan一旦声明,它的元素类型是固定的
elesize //chan中元素的大小,普通类型或者指针类型,所以元素大小是固定的
sendx //处理发送数据的指针在buf中的位置。一旦接收到新的数据,指针就会加上elesize,移向下一个位置。buf的总大小是elesize的整数倍,而且buf是一个循环列表
recvx //处理接收请求时的指针在buf中的位置。一旦取出数据,此指针会移动到下一个位置
recvq //chan是多生产者多消费者的模式,如果消费者因为没有数据可读而被阻塞了,就会被加入到recvq队列中
sendq //如果生产者因为buf满了而阻塞,会被加入到sendq队列中

初始化

使用makechan创建hchan对象。

send

1.如果chan为nil的话,就把调用者goroutine阻塞休眠,调用者就永远被阻塞住了。
2.如果chan没有被close,并且chan满了,直接返回
3.如果chan已经close,再往里面发送数据的话触发panic
4.如果等待队列中有等待的recevier,那么这段代码就把它从队列中弹出,然后直接把数据交给它,而不需要放入到buf中,速度可以更快一些
5.当前没有receiver,需要把数据放入buf中,放入之后就返回成功了
6.如果buf满了,发送者的goroutine就会发送者的等待队列(sendq)中

recv

1.chan为nil的情况,和send一样,从nil chan中接收数据时,调用者会一直阻塞
2.第二部分暂时不分析
3.如果chan已经被close了,并且队列中没有缓存的元素,那么返回true、false
4.处理buf满了的情况,如果是unbuffer的chan,直接将sender的数据复制给recevier;否则就从队列头部读取一个值,并把这个sender的值加入到队列尾部。
5.处理没有等待的sender的情况。和send共用一把锁以防并发。如果buf有元素就取一个元素给recevier
6.处理buf中没有元素的情况。如果没有元素,那么当前的recevier就会被阻塞,直到它从sender中接收到数据,或者chan被close才返回。

close

1.如果chan为nil,close会panic
2.如果chan已经closed,再次close也会panic
3.如果chan不为nil,chan也没有closed,就把等待队列中的sender和recevier从队列中全部移除并唤醒。

使用chan容易犯的错

使用chan最常见的错误是panic和goroutine泄露。

panic的情况有3种:
1.close为nil的chan
2.send已经close的chan
3.close已经close的chan

go的并发原语选择方法
1.共享资源的并发访问使用传统并发原语
2.复杂的任务编排和消息传递使用channel
3.消息通知机制使用chan,除非只想signal一个goroutine,才使用cond
4.简单等待所有任务的完成使用WaitGroup,也可以使用Channel,都可以
5.需要和select结合,使用Channel
6.需要和超时配合时,使用Channel和context

14.透过代码看典型的应用模式

补充一个知识点:通过反射的方式执行select语句,在处理很多case clause,尤其是不定长的case clause时非常有用

使用反射操作channel

select语句可以同时处理chan的send和recv,send和recv都可以作为case clause。
如果需要处理一个slice of chan,应该如何处理?通过使用reflect.Select函数。

典型的应用场景

1.消息交流

​ 把它当成一个线程安全的队列和buffer使用。
​ 例子1:worker池的例子。例子2:etcd的node实现

2.数据传递

​ 击鼓传花的游戏。可以定义一个令牌在多个goroutine中传递。

3.信号通知

​ chan类型有这样一个特点。chan如果为空,那么receiver接收数据的时候就会阻塞等待,直到chan被关闭或者有的数据到来。利用这个机制可以实现wait/notify的设计模式。传统并发原语cond也可以实现,只是比较复杂,容易出错。另外可以做一些类似优雅关闭的实现。

4.锁

在chan的内部,就有一把互斥锁保护着它的所有字段。chan的发送和接收之间也存在着happens-before的关系。
happens-before,是指时间发生的先后顺序关系。
实现方式至少有两种:
1.初始化一个capacity为1的channel,放入元素。谁取到这个元素就代表获取到了锁。
2.初始化一个capacity为1的channel,空槽代表锁,谁成功发送元素到channel代表获取到了锁。

5.任务编排

使用channel可以实现WaitGroup的功能,这里不做展开。介绍几种更复杂的场景。

1.Or-Done模式
2.扇入模式
3.扇出模式
4.Stream
5.map-reduce

15.内存模型:GO如何保证并发读写的顺序

Go官方文档中专门介绍了Go的内存模型,它并不是指Go对象的内存分配、内存回收和内存管理的规范,它描述的是并发环境下多goroutine读取相同变量的时候,变量的可见性条件。
也就是指,在什么条件下多goroutine在读取一个变量的值时,能够看到其他goroutine对这个变量进行的写的结果。

由于CPU指令重排和多级cache的存在,保证多核访问同一个变量这件事变的非常复杂。
编程语言需要一个规范,来明确多线程同时访问同一个变量的可见性和顺序,
这个规范被叫做内存模型。

定义内存模型的目的:
1.在程序员开发程序时,面对同一个数据被多个goroutine访问时,可以做一些串行化访问的控制,比如使用channel或者sync包和sync/atomic包中的并发原语。
2.允许编译器和硬件对程序做一些优化。

重排和可见性的问题

由于代码重排,代码并不一定会按照你写的顺序执行。

var a,b int 
func f(){
	a = 1
	b = 2
}
func g(){
	print(b)
	print(a)
}
func main(){
	go f()
	g()
}
//可能出现的结果,b=2的时候,a还是为0的。

程序在运行的时候,两个操作的顺序可能不会得到保证,那么就需要happens-before。
happens-before用来描述两个时间的顺序关系,如果某些操作能提供happens-before关系,那么我们就可以100%保证它们之间的顺序。

happens-before

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

对单个goroutine来说,它有一个特殊的happens-before关系:
在单个goroutine内部,happens-before的关系和代码编写的顺序是一致的。

在Go语言中,可以使用并发原语为读写操作建立happens-before关系,这样就可以保证顺序了。
1.在Go语言中,对变量进行零值的初始化就是一个写操作
2.如果对超过机器word大小的值进行读写,可以看作是对拆成word大小的几个读写无序进行
3.Go并不提供直接的CPU屏障来提示编译器或者CPU保证顺序性,而是使用不同架构的内存屏障指令来实现统一的并发原语。

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

除了单个goroutine内部提供的happens-before保证,Go语言中还提供了一些其他的happens-before关系的保证。

init函数

应用程序的初始化是在单一的goroutine执行的。如果包p导入了包q,那么q的init函数的执行一定happens-before p的任何初始化代码。main函数一定在导入的包的init函数之后执行。
同一个包下的多个文件,会按照文件名的排列顺序进行初始化。

goroutine

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

var a string
func f(){
	print(a)
}
func hello(){
	a = "hello world"
	go f()
}
//输出hello world

Channel

Channel是goroutine同步交流的主要方法。往一个Channel中发送一条数据,通常对应着另一个goroutine从这个Channel中接收一条数据。

Channel happens-before关系保证有4条规则
1.第n个send一定happens before第n个receive的完成
2.close一个Channel的调用,一定happens before从关闭的Channel中读取一个零值。
3.对于容量为0的Channel,从此Channel中读取数据的调用一定happens before往此Channel发送数据的调用完成。
4.如果Channel的容量是m,那么第n个receive一定happens before第n+m个send的完成。

Mutex/RWMutex

3条happens before关系的保证
1.第n次的m.Unlock一定happens before第n+1 m.Lock方法的返回
2.只有释放了持有的写锁,等待的读请求才能请求到读锁
3.如果第n个m.RLock方法的调用已返回,那么它的第k(k<=n)个成功的m.RUnlock方法的返回一定happens before任意的m.Rulock方法调用。

WaitGroup

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

Once

函数f一定会在Do方法返回之前执行

atomic

可以保证使用atomic的Load/Store的变量之间的顺序性,但是过于复杂,现阶段不建议使用atomic保证顺序性



这篇关于Go并发编程实战课(Note.7:Channel)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程