golang chan传递数据的性能开销

这篇具有很好参考价值的文章主要介绍了golang chan传递数据的性能开销。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

这篇文章并不讨论chan因为加锁解锁以及为了维持内存模型定义的行为而付出的运行时开销。

这篇文章要探讨的是chan在接收和发送数据时因为“复制”而产生的开销。

在做性能测试前先复习点基础知识。

本文索引

  • 数据是如何在chan里流动的
    • 情况1:发送的数据有读者在读取
    • 情况2:发送的数据没有读者在读取
    • 特例中的特例
  • 为什么要复制
  • 复制导致的开销
  • 如何避免开销
    • 只传小对象
    • 只传指针
    • 使用lock-free数据结构替代chan
    • 开销可以接受的情况
  • 总结

数据是如何在chan里流动的

首先我们来看看带buffer的chan,这里要分成两类来讨论。那没buffer的chan呢?后面会细说。

情况1:发送的数据有读者在读取

可能需要解释一下这节的标题,意思是:发送者正在发送数据同时另一个接收者在等待数据,看图可能更快一些↓

golang chan传递数据的性能开销

图里的chan是空的,发送者协程正在发送数据到channel,同时有一个接收者协程正在等待从chan里接收数据。

如果你对chan的内存模型比较了解的话,其实可以发现此时是buffered chan的一种特例,他的行为和无缓冲的chan是一样的,事实上两者的处理上也是类似的。

所以向无缓冲chan发送数据时的情况可以归类到情况1里。

在这种情况下,虽然在图里我们仍然画了chan的缓冲区,但实际上go有优化:chan发现这种情况后会使用runtime api,直接将数据写入接收者的内存(通常是栈内存),跳过chan自己的缓冲区,只复制数据一次。

这种情况下就像数据直接从发送者流到了接收者那里一样。

情况2:发送的数据没有读者在读取

这个情况就简单多了,基本上除了情况1之外的所有情形都属于这种:

golang chan传递数据的性能开销

图里描述的是最常见的情况,读者和写者在操作不同的内存。写者将数据复制进缓冲区然后返回,如果缓冲满了就阻塞到有可用的空位为止;读者从缓冲区中将数据复制到自己的内存里然后把对应位置的内存标记为可写入,如果缓冲区是空的,就阻塞到有数据可读为止。

可能有人会问,如果缓冲区满了导致发送的一方被阻塞了呢?其实发送者从阻塞恢复后需要继续发送数据,这时是逃不出情况1和情况2的,所以是否会被阻塞在这里不会影响数据发送的方式,并不重要。

在情况2中,数据先要被复制进chan自己的缓冲区,然后接收者在读取的时候在从chan的缓冲区把数据复制到自己的内存里。总体来说数据要被复制两次。

情况2中chan就像这个水池,数据先从发送者那流进水池里,过了一段时间后再从水池里流到接收者那里。

特例中的特例

这里要说的是空结构体:struct{}。在chan直接传递这东西不会有额外的内存开销,因为空结构体本身不占内存。和处理空结构体的map一样,go对这个特例做了特殊处理。

当然,虽然不会消耗额外的内存,但内存模型是不变的。为了方便起见你可以把这个特例想象成情况2,只是相比之下使用更少的内存。

为什么要复制

在情况1里我们看到了,runtime实际上有能力直接操作各个goroutine的内存,那么为什么不选择将数据“移动”到目标位置,而要选择复制呢?

我们先来看看如果是“移动”会发生什么。参考其他语言的惯例,被移动的对象将不可再被访问,它的数据也将处于一种不确定但可以被安全删除的状态,简单地说,一点变量里的数据被移动到其他地方,这个变量就不应该再被访问了。在一些语言里移动后变量将强制性不可访问,另一些语言里虽然可以访问但会产生“undefined behavior”使程序陷入危险的状态。go就比较尴尬了,既没有手段阻止变量在移动后继续被访问,也没有类似“undefined behavior”的手段兜底这些意外情况,随意panic不仅消耗性能更是稳定性方面的大忌。

因此移动在go中不现实。

再来看看在goroutine之间共享数据,对于可以操作goroutine内存的runtime来说,这个比移动要费事的多,但也可以实现。共享可能在cpu资源上会有些损耗,但确实能节约很多内存。

共享的可行性也比移动高一些,因为不会对现有语法和语言设计有较大的冲击,甚至可以说完全是在这套语法框架下合情合理的操作。但只要一个问题:不安全。chan的使用场景大部分情况下都是在并发编程中,共享的数据会带来严重的并发安全问题。最常见的就是共享的数据被意外修改。对于以便利且安全的并发操作为卖点的go语言来说,内置的并发原语会无时不刻生产出并发安全问题,无疑是不可接受的。

最后只剩下一个方案了,使用复制来传递数据。复制能在语法框架下使用,与共享相比也不容易引发问题(只是相对而言,chan的浅拷贝问题有时候反而是并发问题的温床)。这也是go遵循的CSP(Communicating Sequential Process)模型所提倡的。

复制导致的开销

既然复制有正当理由且不可避免,那我们只能选择接受了。因此复制会带来多大开销变得至关重要。

内存用量上的开销很简单就能计算出来,不管是情况1还是情况2,数据一个时刻最多只会有自己本体外加一个副本存在——情况1是发送者持有本体,接收者持有副本;情况2是发送者持有本体,chan的缓冲区或者接收者(从缓冲区复制过去后缓冲区置空)持有副本。当然,发送者完全可以将本体销毁这样只有一份数据留存在内存里。所以内存的消耗在最坏情况下会增加一倍。

cpu的消耗以及对速度的影响就没那么好估计了,这个只能靠性能测试了。

测试的设计很简单,选择大中小三组数据利用buffered chan来测试chan和协程直接复制数据的开销。

小的标准是2个int64,大小16字节,存进一个缓存行绰绰有余:

type SmallData struct {
    a, b int64
}

中型大小的数据更接**常的业务对象,大小是144字节,包含十多个字段:

type Data struct {
	a, b, c, d     int64
	flag1, flag2   bool
	s1, s2, s3, s4 string
	e, f, g, h     uint64
	r1, r2         rune
}

最后是大对象,大对象包含十个中对象,大小1440字节,我知道也许没人会这么写,也许实际项目里还有笔者更重量级的,我当然只能选个看起来合理的值用于测试:

type BigData struct {
	d1, d2, d3, d4, d5, d6, d7, d8, d9, d10 Data
}

鉴于chan会阻塞协程的特殊性,我们只能发完数据后再把它从chan里取出来,不然就得反复创建和释放chan,这样代来的杂音太大,因此数据实际上要被复制上两回,这里我们只关注内存复制的开销,其他因素控制好变量就不会有影响。完整的测试代码长这样:

import "testing"

type SmallData struct {
	a, b int64
}

func BenchmarkSendSmallData(b *testing.B) {
	c := make(chan SmallData, 1)
	sd := SmallData{
		a: -1,
		b: -2,
	}
	for i := 0; i < b.N; i++ {
		c <- sd
		<-c
	}
}

func BenchmarkSendSmallPointer(b *testing.B) {
	c := make(chan *SmallData, 1)
	sd := &SmallData{
		a: -1,
		b: -2,
	}
	for i := 0; i < b.N; i++ {
		c <- sd
		<-c
	}
}

type Data struct {
	a, b, c, d     int64
	flag1, flag2   bool
	s1, s2, s3, s4 string
	e, f, g, h     uint64
	r1, r2         rune
}

func BenchmarkSendData(b *testing.B) {
	c := make(chan Data, 1)
	d := Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	for i := 0; i < b.N; i++ {
		c <- d
		<-c
	}
}

func BenchmarkSendPointer(b *testing.B) {
	c := make(chan *Data, 1)
	d := &Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	for i := 0; i < b.N; i++ {
		c <- d
		<-c
	}
}

type BigData struct {
	d1, d2, d3, d4, d5, d6, d7, d8, d9, d10 Data
}

func BenchmarkSendBigData(b *testing.B) {
	c := make(chan BigData, 1)
	d := Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	bd := BigData{
		d1:  d,
		d2:  d,
		d3:  d,
		d4:  d,
		d5:  d,
		d6:  d,
		d7:  d,
		d8:  d,
		d9:  d,
		d10: d,
	}
	for i := 0; i < b.N; i++ {
		c <- bd
		<-c
	}
}

func BenchmarkSendBigDataPointer(b *testing.B) {
	c := make(chan *BigData, 1)
	d := Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	bd := &BigData{
		d1:  d,
		d2:  d,
		d3:  d,
		d4:  d,
		d5:  d,
		d6:  d,
		d7:  d,
		d8:  d,
		d9:  d,
		d10: d,
	}
	for i := 0; i < b.N; i++ {
		c <- bd
		<-c
	}
}

我们选择传递指针作为对比,这是日常开发中另一种常见的作法。

Windows11上的测试结果:

golang chan传递数据的性能开销

Linux上的测试结果:

golang chan传递数据的性能开销

对于小型数据,复制带来的开销并不是很突出。

对于中型和大型数据就没那么乐观了,性能分别下降了20%50%

测试结果很清晰,但有一点容易产生疑问,为什么大型数据比中型大了10倍,但复制速度上只慢了2.5倍呢?

原因是golang会对大数据启用SIMD指令增加单位时间内的数据吞吐量,因此数据大了确实复制会更慢,但不是数据量大10倍速度就会慢10倍的。

由此可见复制数据带来的开销是很难置之不理的。

如何避免开销

既然chan复制数据会产生不可忽视的性能开销,我们得想些对策来解决问题才行。这里提供几种思路。

只传小对象

多小才算小,这个争议很大。我只能说说我自己的经验谈:1个缓存行里存得下的就是小。

一个缓存行有多大?现代的x64 cpu上L1D的大小通常是32字节,也就是4个普通数据指针/int64的大小。

从我们的测试来看小数据的复制开销几乎可以忽略不记,因此只在chan里传递这类小数据不会有什么性能问题。

唯一要注意的是string,目前的实现一个字符串本身的大小是16字节,但这个大小是没算字符串本身数据的,也就是说一个长度256的字符串和一个长度1的字符串,自身的结构都是16字节大,但复制的时候一个要拷贝256个字符一个只用拷贝一个字符。因此字符串经常出现看着小但实际大小很大的实例。

只传指针

32字节实在是有点小,如果我需要传递2-3个缓存行大小的数据怎么办?这个也是实际开发中的常见需求。

答案实际上在性能测试的对照组里给出了:传指针给chan。

从性能测试的结果来看,只传指针的情况下,无论数据多大,耗时都是一样的,因为我们只复制了一份指针——8字节的数据。

这个作法也能节约内存:只复制了指针,指针引用的数据没有被复制。

看起来我们找到了向chan传递数据的银弹——只传指针,然而世界上并没有银弹——

  1. 传指针相当于上一节说的“共享”数据,很容易带来并发安全问题;
  2. 对于发送者,传指针给chan很可能会影响逃逸分析,不仅会在堆上分配对象,还会使情况1中的优化失去意义(调用runtime就为了写入一个指针到接收者的栈上)
  3. 对于接收者来说,操作指针引用的数据需要一次或多次的解引用,而这种解引用很难被优化掉,因此在一些热点代码上很可能会带来可见的性能影响(通常不会有复制数据带来的开销大,但一切得以性能测试为准)。
  4. 太多的指针会加重gc的负担

使用指针传递时切记要充分考虑上面列出的缺点。

使用lock-free数据结构替代chan

chan大部分时间都被用作并发安全的队列,如果chan只有固定的一个发送者和固定一个的接收者,那么可以试试这种无锁数据结构:SPSCQueue。

无锁数据结构相比chan好处在于没有mutex,且没有数据复制的开销。

缺点是只支持单一接收者和单一发送者,实现也相对复杂所以需要很高的代码质量来保证使用上的安全和运行结果的正确,找不到一个高质量库的时候我建议是最好别尝试自己写,也最好别用。(一个坏消息,go里可靠的无锁数据结构库不是很多)

开销可以接受的情况

有一类系统追求正确性和安全性,对性能损耗和资源消耗有较高的容忍度。对于这类系统来说,复制数据带来的开销一般是可接受的。

这时候明显复制传递比传指针等操作简单而安全。

另一种常见情形是:chan并不是性能瓶颈,复不复制对性能的影响微乎其微。这时候我也倾向于选择复制传递数据。

总结

总体来说chan还是很方便的,在go里又是还不得不用。

我写这篇文章不是为了吓唬大家,只是提醒大家一些使用chan时可能发生的性能陷阱和对应的解决办法。

至于你怎么用chan,除了要结合实际需求之外,性能测试是另一个重要的参考标准。

如果问我,那么我倾向于复制数据优先于指针传递,除非数据十分巨大/性能瓶颈在复制上/接收方发送方需要在同一个对象上做些协同作业。同样性能测试和profile是我采用这些方式的参考标准。文章来源地址https://www.toymoban.com/news/detail-797381.html

到了这里,关于golang chan传递数据的性能开销的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • golang函数传参——值传递理解

    做了五年的go开发,却并没有什么成长,都停留在了业务层面了。一直以为golang中函数传参,如果传的是引用类型,则是以引用传递,造成这样的误解,实在也不能怪我。我们来看一个例子,众所周知,slice是个引用类型,我们以slice为例。 猜猜,打印出来的会是什么结果?

    2024年02月14日
    浏览(33)
  • 【Golang】golang中http请求的context传递到异步任务的坑

    在golang中,context.Context可以用来用来设置截止日期、同步信号,传递请求相关值的结构体。 与 goroutine 有比较密切的关系。 在web程序中,每个Request都需要开启一个goroutine做一些事情,这些goroutine又可能会开启其他的 goroutine去访问后端资源,比如数据库、RPC服务等,它们需要访

    2024年02月08日
    浏览(36)
  • golang slice 作为参数值传递情况说明

    golang 中引用类型有:slice、map、interface、channel golang所有类型在函数中都是值传递,这个跟上面golang中有的数据类型为引用类型并不冲突,用下面四个slice实例帮助理解 说明: main 函数中slice[0]说明 fn 函数里面的修改,也改变函数外的值,这跟slice为引用类型的缘故 说明:s

    2024年02月15日
    浏览(40)
  • 【博客618】docker容器重启后读写层数据并不丢失的原理

    当我们对docker容器执行restart后,其实容器中原本读写层里对临时数据还在。只有我们删除了这个容器,重新创建的容器是基于镜像的只读层然后挂载上新的空的读写层,此时临时数据是不在的 镜像,静态容器,运行时容器之间的区别 Image:统一只读文件系统) 静态容器 :未

    2024年02月01日
    浏览(34)
  • 零额外开销最快的数据库连接池?HikariCP 入门介绍

    从零开始手写 mybatis (三)jdbc pool 如何从零手写实现数据库连接池 dbcp? 万字长文深入浅出数据库连接池 HikariCP/Commons DBCP/Tomcat/c3p0/druid 对比 Database Connection Pool 数据库连接池概览 c3p0 数据池入门使用教程 alibaba druid 入门介绍 数据库连接池 HikariCP 性能为什么这么快? Apache

    2024年03月17日
    浏览(44)
  • 一篇文章彻底弄懂Golang私有仓库配置问题

    一般通过 go get 拉取的是公共仓库的代码(如: github.com中的代码),是不需要任务权限就能拉下来。但当我们配置的私有仓库一般都需要用户名密码来登录才能拉取代码,所以私有仓库主要是解决认证问题。 在早期版本的Go中,“go get”用于构建和安装包。现在,“go get”专门用

    2024年01月16日
    浏览(46)
  • Qt事件传递及相关的性能问题

    在使用Qt时,我们都知道能通过mousePressEvent,eventFilter等虚函数的重写来处理事件,那么当我们向一个界面发送事件,控件和它的父控件之间的事件传递过程是什么样的呢? 本文将以下图所示界面为例,结合源码介绍Qt事件传递的过程。 父到子的关系依次为: MyWindow-MyButton-

    2024年01月24日
    浏览(39)
  • 性能测试工具 jmeter 录制脚本,传递 cookie,循环执行接口

    目录 前言: 代理录制脚本 循环重复添加接口 登录并传递 cookie 给新建产品接口 循环执行脚本  在使用JMeter进行性能测试时,录制脚本是一种常用的方法。录制脚本可以帮助你捕获和重放用户与应用程序之间的交互,以模拟真实的用户行为。

    2024年02月16日
    浏览(68)
  • [RDMA] 高性能异步的消息传递和RPC :Accelio

    1. Introduce Accelio是一个高性能异步的可靠消息传递和RPC库,能优化硬件加速。 RDMA和TCP / IP传输被实现,并且其他的传输也能被实现,如共享存储器可以利用这个高效和方便的API的优点。Accelio 是 Mellanox 公司的RDMA中间件,用于高性能异步的可靠消息传递和RPC库。 Accelio提供了一

    2024年02月12日
    浏览(41)
  • Golang Web框架性能对比

    Golang Web框架性能对比 github star排名依次: Gin Beego Iris Echo Revel Buffalo 性能上gin、iris、echo网上是给的数据都是五星,beego三星,revel两星 beego是国产,有中文文档,文档齐全 根据star数,性能,易用程度,社区活跃度和具体应用场景来选择的话,当前我更加倾向于使用gin作为基础开

    2024年01月24日
    浏览(33)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包