菜鸟笔记
提升您的技术认知

go中高并发下的通信方式:channel管道的底层原理-ag真人游戏

  1. 如何声明管道?

我们首先可以使用make(chan 管道中存放的数据类型,缓冲区大小)来进行声明,分为两大类,一类是带缓冲区的管道,一类是不带缓冲区的管道(不带缓冲区的管道在声明的时候省略缓冲区大小的参数即可),具体声明方法如下代码所示:

make(chan int)   // 无缓冲区
make(chan bool, 0) // 不带缓冲区
make(chan string, 2) // 带缓冲区(2个)
  1. 基本用法

管道其实就是一种通信方式,本质上用来发送和接受数据,那么关于它的主要用法当然也就是这两种,具体使用如下代码所示:

ch <- x   // 发送数据x
x =<- ch  // 将管道中接受的数据赋值给x
<-ch      // 接受管道中的数据并丢弃 

需要注意的是如果是没有缓冲区的管道,是无法向管道中塞数据的,除非此时有一个正在运行的协程等待接受这个数据,才能向无缓冲区的管道中放数据。(有缓冲区的管道就像是一条马路上有一个仓库,这个仓库可以用来放需要传送的东西,当没有这个仓库且此时马路对面没有接收者的时候,这条马路(这个管道)不允许发送货物(数据))。

首先我们要记住一句话:不要通过共享内存的方式进行通信,二是应该通过通信的方式共享内存。我们来一段代码举例一下什么是使用共享内存的方式来通信,如下:

func watch(p *int) {
  
	for true {
  
		if *p == 1 {
  
			fmt.println(a...:"hello")
			break
		}
	}
}
func main() {
  
	i := 0
	go watch(&i)
	time.sleep(time.second)
	
	i = 1
	time.sleep(time.second)
}
// output:
// hello

以上代码就是通过共享内存来通信的,主函数向watch发送了变量的地址,watch通过不断地查看该地址的变量是否是1然后输出并跳出循环。

那么如果使用管道来进行通信,代码应该是什么样子的呢?

func watch(c chan int) {
  ​    
	if <-c == 1 {
          
		fmt.println("hello")    
	}
}
​func main() {
      
	c := make(chan int)
	go watch(c)​    
	time.sleep(time.second)​    
	c <- 1​
	time.sleep(time.second)
}
// output:
// hello

我们发现我们使用了一个不带缓冲区的管道,且因为管道是一种通信的模型,当没有数据的时候,管道是阻塞状态的,所以会发现在以上代码中我们没有使用死循环。显然使用管道的方式避免了watch函数多次循环,节约了系统资源。

我们大致了解了什么是通过共享内存来进行通信,什么是通过通信方式来共享之后,大致给出为什么使用通信来共享内存的原因:

  • 能够避免协程竞争和数据冲突的问题(毕竟共享内存使得多个协程将会读取同一块数据,使用共享内存通信必然要加锁,而管道在某种意义上可以认为是无锁的)
  • 更高级的抽象,降低开发难度,增加程序可读性(就像我们举例中的,通信的方式避免了轮询查看)
  • 模块之间更容易解耦,增强扩展性和可维护性(共享内存显然不适合分布式环境,因为共享内存要求至少在同一个硬盘上)

chanel作为一个通信管道,我们在上文中也基本了解了它的功能,他需要发送数据和接受数据,特别是如果这是一个带缓冲区的管道的话,它首先需要一个用于存放数据的缓冲区,除此以外,当channel缓冲区满的时候,如果某个发送方还像其发送数据,他就需要一个队列来存放该发送协程,接收方同理。故而,整体而言,一个channel需要三大块:中间缓冲区、发送等待队列和接受等待队列。

以上只是channel一个大致的形式,在go的底层,channel实质上是一个名为hchan的结构体,其中的具体内容如下:


前几个数据实际上是一个环形的缓存队列,这种环形缓存可以大幅度降低回收内存的开销。我们将其拿出来理解一下,如下图所示。

成员变量 含义
qcount 环形队列中存放数据的数量
dataqsiz 环形队列的容量
buf 指向环形队列
elemsize 每个数据的大小
elemtype 数据的类型

除此以外剩余的几个结构体,我们找到其相关源码如下,实际上waitq类型(该类型中的elem是用于接收数据的变量的指针)的两个变量recvq和sendq都是链表,功能上是一个存放协程的队列,sendx和recvx指的是目前工作到发送/接受链表的第几个,是一个游标。

还有一个关键成员mutex,这个互斥锁并不是用于排队发送/接受数据,是用于保护hchan结构体中的所有成员,所有协程想要操作hchan中的都需要加锁。所以channel本质上并不是无锁的,就其本身的构成来说。但是由于channel的锁只是在外部协程向hchan中塞数据或者取数据拿一下才会用到,所以即使使用channel也可以实现高并发的通信,在这种意义上我们也可以说channel是无锁的。

除此以外,不知道读者发现了没有,其中还有一个成员closed,他是channel的一个状态值,0的时候表示开启,1的时候表示关闭。

以上就是channel中的全部成员了,我们看一张总结的图如下:

文章一开始我们使用channel演示传输和发送数据,在这部分我们了解一下channel如何发送数据,在代码使用的时候我们直接使用了c<-关键字来发送数据,它在go中其实是一个语法糖,在编译阶段,编译器就会把c<-转化为runtime.chansend1(),如果去看go中的源码,你就会发现这个chansend1其实回去调用chansend方法。

我们可以将channel发送的情形分为以下几种:

  • 直接发送
  • 放入缓存
  • 休眠等待

我们接下来分点来讨论一下这几种情形。

  1. 直接发送

在发送数据前,此时有协程在休眠等待介绍。其实就是此时管道中的等待接受数据的协程还没有检测到数据的到来,此时该协程就会休眠等待。此时结构体中的环形缓存没有数据,此时就会将这个等待数据的协程放入管道对应的hchan结构体中的接收队列中去,如果此时有数据到来就从管道结构体的接收队列中取出一个等待接收的协程,直接将其拷贝给协程的接收变量(不用将数据放入环形缓存区中),唤醒该协程。

  1. 放入缓存

此时没有休眠等待的协程,管道结构体中的环形缓存中还有缓存空间,如果此时有数据接收就直接将数据放入缓存。实现的时候首先获取环形队列中可存入的缓存地址,然后将数据存入该地址,同时维护相关的索引。这也是为什么说channel是无锁的原因,只要缓存区还有空间,就不会存在阻塞。

  1. 休眠等待

此时没有协程休眠等待,且环形队列的缓存已经满了,此时发送协程进入发送队列,休眠等待。在实现上而言,发送协程将自己包装为一个sudog,然后放入发送队列中去,最后休眠并将hchan中的互斥锁进行解锁。

和channal发送数据中很相似,用于接收数据的<-c其实也是一个语法糖,在编译阶段i<-c会转化为runtime.chanrecv1(),除此以外,还有一个ok参数可以用来接收管道中的数据,可以用于判断到底有没有从管道中接收到数据,这种i,ok -< c在编译阶段将会转换为runtime.chanrecv2()。最终两者都会调用chanrecv()

我们可以将channel接收数据的情形分为以下四种:

  • 有等待的协程,从协程接收
  • 有等待的协程,从缓存接收
  • 接收缓存
  • 阻塞接收

我们接下来分点来了解一下:

  1. 有等待的协程,从协程接收

在数据接受之前,已经有协程在休眠等待发送了,且这个channe没有缓存,此时接收协程将数据直接从协程拷贝过来并唤醒协程。从实现上来讲,接收协程需要判断此时是否有协程在发送队列等待(进入recv方法)以及环形缓存中是否有数据,如果两者都满足就将发送队列中的协程中的数据取出来并将其唤醒。

  1. 有等待的协程,从缓存接收

在接受数据之前,已经有协程在休眠等待发送,且此时channel的环形队列中有缓存,那么接收协程从环形队列缓存区取出一个数据,将休眠协程的发送协程的数据放入缓存并唤醒该发送协程。从实现上来讲,接收协程首先判断是否有协程在发送队列中等待,如果有就进入recv方法中,然后再判断channel是否有缓存,有的话从缓存中取走一个数据,并将发送协程的数据放入缓存并唤醒该协程。

  1. 接收缓存

当没有协程在休眠等待,但是环形队列缓存区中有内容,就从缓存中取走数据。从实现来讲就是判断发送队列中是否没有协程在等待且此时channel缓存有内容,就直接从缓存中取一个数据。

  1. 阻塞接收

此时没有协程在休眠等待,且没有缓存,接收协程进入接收队列中等待接收数据。在实现上来说就是判断是否没有协程在发送队列等待以及此时缓存区没数据,如是接收协程就将自己包装成sudog,把自己放入接受等待队列中,并休眠。

网站地图