channel使用方法与底层原理解析【golang】

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

在golang并发编程的学习中,必不可少的是了解channel的底层与GPM模型的关系,这是实现“以通信的方式共享内存”的核心内容。我在学习这个板块时遇到了非常多的小知识点,也踩过非常多的坑,我希望总结这些内容在这篇笔记中,能给初入golang并希望学习channe底层的同学一个较为系统的了解。如果文章有哪些地方不严谨或者有谬误,或者希望与作者讨论、共同学习,欢迎在评论区留言或私信作者。

一、channel(通道)是什么?

channel(通道)是golang中实现协程间通信的一种特殊类型,它在保证了线程安全的前提下,能够确保以以正确的顺序发送和接收数据。我们可以将channel理解为跨协程的先进先出的管道,在coding时我们可以通过channel完成协程间的数据交换和顺序调度,由于golang是一门强类型语言(区别于php、Nodejs等),因此channel也是具备数据类型的(例如int,string,struct{}等),channel只能发送和接收相同类型的数据,在golang中,channel的操作符是<-,箭头的指向就是数据的流向。

二、channel使用方法(已经了解使用,希望直接了解底层的同学可以跳过此部分)

1、变量定义和初始化

var ch chan int
ch = make(chan int)

前言中提到,channel是具备数据类型的,因此在进行channel的变量定义和内存分配时,需要指定channel的数据类型,当然,在实际coding时,我们更常用的是“:=”的赋值方式:

ch := make(chan int)

上面的代码make了一个无缓冲的channel,那么什么是无缓冲的channel呢?

我们需要引入2个定义,无缓冲channel和有缓冲channel:无缓冲channel需要有接收者时,发送者才可以发送,如果没有接收者,那么发送者就会被阻塞,引发死锁;而有缓冲的管道,若容量为n,则channel中可以存储n个数据,也就是说发送者可以预先将至多n个数据发送到管道中而不被阻塞,当接收者出现时,会以先进先出的顺序逐个接收channel中的数据。

此时对于channel没有了解的同学看了这两个文字定义可能颇为困惑,没关系,在讲完channel的发送和接收操作后,我们会举一个实际的例子说明有缓冲channel和无缓冲channel在代码中的具体区别,继续往下看就好。

上面我们已经讲过如何make一个无缓冲的channel,下面我们make一个有缓冲的,容量为1的channel

ch := make(chan int,1)

我们发现和make无缓冲channel的唯一区别就是,make中多了一个参数,这个参数就是这个管道的容量

2、发送数据

ch <- 10

代码表示,向channel发送了10这个int数据,发送数据的代码十分简单,这里不作更多讲解

3、接收数据

channel接收数据的方式多种多样,这里我们主要讲解3种主要的接收方式:
第一种是最简单的,以<-接收数据:

<-ch

这里表示,我接收了ch这个channel中的值,但我并不关心这个值是什么,这种方式往往用于控制协程间的执行顺序

v:=<-ch

我们也可以将ch中的值以变量的形式接收,这样就完成了协程间的数据交换

第二种,我们可以使用for range去优雅地接收channel中的数据,执行一些和channel有关的条件循环:

 for i := range ch {
        fmt.Println(i)
    }

这几行代码表示,每当我们从ch接收到一个数据i,就打印这个数据i;当ch关闭后,跳出这个循环,如何关闭一个channel文章后续会讲到

第三种,利用select关键字读取channel中的数据,select会随机捕获到满足条件的case来执行,如果没有case满足条件,就执行defult:

func main() {
	ch := make(chan int,1)
	go func() {
		ch <- 10
	}()
	for {
		select {
		case v := <-ch:
			fmt.Println(v)
			return
		default:
			fmt.Println("no data")
		}
	}
}

上面这段代码表示,首先我们初始化了一个有缓冲,容量为1的channel ch,起了一个go routine,将数据10发送给ch,接下来,我们利用select关键字循环接收ch中的值,如果接收到ch中的数据,直接打印这个数据并返回,否则打印no data。

这段代码的执行结果是,打印出0或数行no data后打印出10,然后返回,原因是主协程并不会等待其他go routine的执行,也就是说,初次进入接收数据的for循环时,10这个数据可能还没有被发送到ch中,此时就会执行defult中的操作,打印出no data,直到10被发送到ch中,执行case v := <-ch,将ch中的10读出,打印并返回。

4、关闭通道

关闭通道可以使用golang的内建函数close()

close(ch)

我们可以看到上面讲解 select的例子代码并没有执行ch的close操作,实际上并不是所有的ch都需要close,一般情况下,只要没有协程持有ch,ch就会被回收掉,因此一部分情况下,我们没有必要主动执行close操作
关闭channel的代码十分简单,那么我们在什么情况下需要主动关闭channel?
答:关闭channel有两种作用,第一种作用是跳出for range接收ch的循环,第二种则是通过关闭channel的方式通知其他接收协程,任务已结束,不要再对通道进行读写动作。
在从通道中接收数据时,我们可以通过这样的代码来感知channel是否已经被关闭

v,ok:=<-ch

如果通道没有被关闭,那么v会从ch中接收值,ok的值为true;如果通道已经被关闭了,那么v的值是对应类型的0值,ok的值为false
从这里我们可以发现,即使通道被关闭了,再去读通道中的值,也只是读出0值和false,并不会有其他问题,但是重点是,如果对一个已关闭的通道进行写入,此时程序会panic
因此我们得到了两条经验论:1)如果多个协程中只有一个向channel发送数据的协程,那么channel最好由发送数据的协程来关闭
2)如果有多个向channel发送数据的协程,那么在发送数据之前需要判断channel是否已经被关闭

5、无缓冲通道和有缓冲通道的区别

我们在通道的变量定义以及初始化内存空间时给出了有缓冲通道和无缓冲通道的定义,在我们已经了解了通道的基本操作后,我们给一个例子说明无缓冲通道与有缓冲通道的区别:

func main() {
	ch := make(chan int, 1)
	ch <- 10
	v := <-ch
	fmt.Println(v)
}

我们首先创建了一个有缓冲的通道ch并向ch中发送了一个数字10,然后用变量v从ch中接收了10这个数字并打印v的值,这段代码看似平平无奇,但如果我们将ch初始化为一个无缓冲通道:

func main() {
	ch := make(chan int)
	ch <- 10
	v := <-ch
	fmt.Println(v)
}

我们首先创建了一个无缓冲通道,当我们尝试向ch发送10这个数据时,由于此时接收者还没有进行初始化,程序就会阻塞在ch <- 10这一行,造成了死锁,也就是说,无缓冲通道必须存在接收者,发送者才能向通道发送数据,否则会造成发送者的阻塞(具体的原理会在channel底层中讲到),在使用有缓冲通道时,如果在没有接收者情况下,向通道发送了大于其容量的数据,同样也会导致阻塞。所以本质上来说,无缓冲通道就是容量为0的通道,有缓冲通道就是容量大于0的通道。

三、channel底层

1、底层结构体hchan

golang有一个对于学习者很友好的地方,就是它的runtime库的代码均是开源的,我们可以看看channel的底层结构:

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 mutex
}

这里着重介绍其中涉及进程切换和channel发送、接收的元素:
qcount是通道中目前拥有的元素,datasiz是通道的容量
buf是一个循环队列,在buf中存储着channel中的数据,buf的长度就是我们在make channel时指定的通道容量
其中sendx是下一个发送的数据的下标,而recvx是下一个接收的数据的下标
sendq是发送等待者队列,当向一个无缓冲通道发送数据时或在通道的容量达到最大值后继续发送数据时,发送者go routine会阻塞并抽象为指针进入sendq;revcq是接收等待者队列,当通道没有数据,但接收者go routine前来接收数据时,接收者go routine会抽象为一个指针并进入recvq
lock是实现channel线程安全的锁(保证了buf的线程安全)

2、发送流程

当我们向channel发送数据时,如果recvq中有go routine处于阻塞状态(有接收者正在等待数据),将数据从发送者go routine直接拷贝到接收者go routine并唤醒接收者go routine,否则mutex执行锁操作,将数据放入buf内,sendx++,因此;当sendx达到最大容量时,sendx重新置为0(循环队列),此时如果再向channel发送数据,这个go routine就会发生阻塞,它会让出对应的内核线程(GPM中的M),等待被唤醒,进入sendq(等待者队列)当中
当channel中有数据被接收,buf中出现空位,从sendq中拿出对应的go routine,唤醒此go routine

3、接收流程

当我们从channel中接收数据时,若recvq为空,mutex执行锁操作,若此时有数据在buf内,recvx++,并唤醒sendq中的goroutine;若此时buf为空,同样形成阻塞,此时这个go routine同样会让出内核线程,等待被唤醒,抽象成一个指针并进入recvq中
当有数据发送时,从recvq中拿出对应的go routine指针并唤醒此go routine,此时发送的数据并不会进入buf,而是从发送者go routine直接拷贝到接收者 go routine,此时就可以省去对buf加锁的步骤,提高了程序性能,因此即使buf容量为0(无缓冲通道),也可以进行发送与接收,但接收者必须能够初始化,发送者才能进行发送操作,如果没有接收者go routine,发送者go routine就没有地方去拷贝数据了

4、从底层原理解析无缓冲通道性能优势

其实很多人包括我,在看到有缓冲通道和无缓冲通道的第一反应是,无缓冲通道可能更节省内存,但是有缓冲通道能够有效避免死锁,权衡之下滥用有缓冲通道,并在接收者初始化之前直接将数据发送至channel,但从接收消息的底层原理来说,我们可以发现,一个无缓冲通道在唤醒接收者go routine的时候,是不会从buf中进行数据复制的(由发送者直接copy给接收者),所以此时也不需要对channel加锁,能够较大地提升效率

总结

本文主要介绍了golang中重要且特殊的类型channel的使用方法和底层原理,希望大家在学习golang的同时在空暇时间可以尽可能了解runtime库的部分重要结构体的底层原理,无论在实际coding中或者面试中都可以帮助到大家。最后留一个经典的代码题:起3个goroutine,每个goroutine只输出cat dog fish中的一个字符串,顺序输出cat dog fish10次,这道题我在面试中遇到不止一次,大家有时间也可以进行思考和coding~

  • 19
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值