返回首页  设为首页  加入收藏  今天是:
网站首页电脑主板电脑cpu电脑内存电脑硬盘电脑显卡电脑电源显示器电脑配件电脑维修
相关文章
 Go 高性能编程技法
 24万买了不亏 NVIDIA最强显卡…
 AMD显卡将支持“黑科技”HYP…
 台式电脑显卡性能天梯图2022…
 3299元!索泰上架新版RTX 30…
 显卡推荐排行榜2020天梯图 台…
 要想电脑主机更省电?这些金…
 300W以下电源怎么选?10款名…
 教你选个好电源 机箱电源频道…
 装机时电源应该选择多少W除此…
 正智总经理老袁教你如何调选…
 移动硬盘什么牌子好 怎么挑选…
 2019硬盘性能排行公布:最强…
 哪个牌子的移动硬盘好 八大移…
 希捷推出Exos Mach2硬盘 最大…
 300多块钱的国产大华C900 1T…
 购买的主板无法使用 华硕却以…
 华硕: 无微不至!华硕主板新…
 相当重口味!独家华硕ROG主板…
 正宗陈教授的主讲 主板维修视…
 Intel 25千兆网卡修复丢包掉…
 【出售】华硕二手笔记本两台…
 裕德里 个人产权 精装
 出售自用台式电脑一套_1
 人人租严选-二手电子产品租赁…
 “因公司经营不善高配9成新电…
 Win10新版发布:CPU内存占用…
 电脑cpu占用过高怎么办 电脑…
 电脑cpu温度过高怎么解决 电…
 perfmonexe占用CPU过高怎么办…
 电脑玩游戏cpu占用过高怎么办…
 拒绝两块大屏的桌面压抑我入…
 2999元!戴尔推出首款便携显…
 双十一好价提前享这几款专业…
 为梦想努力奔跑的人在使用这…
 你见过像平板电脑一样便携的…
 浅谈电脑主板的各个接口的作…
 电脑主板上有哪些接口(笔记本…
 主板上的接口都有哪些 分别是…
 电脑主板接口知识全知道
 简单说明了解电脑主板上的插…
 2022年四六级报名时间是下半…
 内存条要降价?三星、海力士…
 KLEVV科赋CRAS XR5 RGB超频电…
 实测32GB对比16GB:不同内存…
 电脑小知识:装机不求人!10…
 双十一福利活动持续至11月18…
 显卡价格行情(11月15日)矿…
 AMD RX 7000 公版显卡曝光:…
 苏姿丰确认 RX 7000 显卡带来…
专题栏目
网络
您现在的位置: 电脑评测网 >> 电脑内存 >> 正文
高级搜索
Go 高性能编程技法
作者:佚名 文章来源:本站原创 点击数: 更新时间:2022/11/16 12:33:27 | 【字体:

  魔兽世界不着痕迹代码的稳健、可读和高效是我们每一个 coder 的共同追求。本文将结合 Go 语言特性,为书写效率更高的代码,从常用数据结构、内存管理和并发,三个方面给出相关建议。话不多说,让我们一起学习 Go 高性能编程的技法吧。

  标准库 reflect 为 Go 语言提供了运行时动态获取对象的类型和值以及动态创建对象的能力。反射可以帮助抽象和简化代码,提高开发效率。

  Go 语言标准库以及很多开源软件中都使用了 Go 语言的反射能力,例如用于序列化和反序列化的 json、ORM 框架 gorm、xorm 等。

  基本数据类型与字符串之间的转换,优先使用 strconv 而不是 fmt,因为前者性能更佳。

  为什么性能上会有两倍多的差距,因为 fmt 实现上利用反射来达到范型的效果,在运行时进行类型的动态判断,所以带来了一定的性能损耗。

  有时,我们需要一些工具函数。比如从 uint64 切片过滤掉指定的元素。

  很多时候,我们可能只需要操作一个类型的切片,利用反射实现的类型泛化扩展的能力压根没用上。退一步说,如果我们线 以外类型的切片进行过滤,拷贝一次代码又何妨呢?可以肯定的是,绝大部份场景,根本不会对所有类型的切片进行过滤,那么反射带来好处我们并没有充分享受,但却要为其带来的性能成本买单。

  可以看到,反射涉及了额外的类型判断和大量的内存分配,导致其对性能的影响非常明显。随着切片元素的递增,每一次判断元素是否在 map 中,因为 map 的 key 是不确定的类型,会发生变量逃逸,触发堆内存的分配。所以,可预见的是当元素数量增加时,性能差异会越来大。

  binary.Read 和 binary.Write 使用反射并且很慢。如果有需要用到这两个函数的地方,我们应该手动实现这两个函数的相关功能,而不是直接去使用它们。

  encoding/binary 包实现了数字和字节序列之间的简单转换以及 varints 的编码和解码。varints 是一种使用可变字节表示整数的方法。其中数值本身越小,其所占用的字节数越少。Protocol Buffers 对整数采用的便是这种编码方式。

  下面以我们熟知的 C 标准库函数 ntohl() 函数为例,看看 Go 利用 binary 包如何实现。

  该函数也是参考了 encoding/binary 包针对大端字节序将字节序列转为 uint32 类型时的实现。

  可见使用反射实现的 encoding/binary 包的性能相较于针对具体类型实现的版本,性能差异非常大。

  不要反复从固定字符串创建字节 slice,因为重复的切片初始化会带来性能损耗。相反,请执行一次转换并捕获结果。

  尽可能指定容器容量,以便为容器预先分配内存。这将在后续添加元素时减少通过复制来调整容器大小。

  向 make() 提供容量提示会在初始化时尝试调整 map 的大小,这将减少在将元素添加到 map 时为 map 重新分配内存。

  注意,与 slice 不同。map capacity 提示并不保证完全的抢占式分配,而是用于估计所需的 hashmap bucket 的数量。因此,在将元素添加到 map 时,甚至在指定 map 容量时,仍可能发生分配。

  在尽可能的情况下,在使用 make() 初始化切片时提供容量信息,特别是在追加切片时。

  与 map 不同,slice capacity 不是一个提示:编译器将为提供给 make() 的 slice 的容量分配足够的内存,这意味着后续的 append() 操作将导致零分配(直到 slice 的长度与容量匹配,在此之后,任何 append 都可能调整大小以容纳其他元素)。

  行内字符串的拼接,主要追求的是代码的简洁可读。 能够接收不同类型的入参,通过格式化输出完成字符串的拼接,使用非常方便。但因其底层实现使用了反射,性能上会有所损耗。

  运算符 + 只能简单地完成字符串之间的拼接,非字符串类型的变量需要单独做类型转换。行内拼接字符串不会产生内存分配,也不涉及类型地动态转换,所以性能上优于。

  字符串拼接还有其他的方式,比如、、和,这几种不适合行内使用。当待拼接字符串数量较多时可考虑使用。

  从结果可以看出,、、和 的性能相近。如果结果字符串的长度是可预知的,使用 且预先分配容量的拼接方式性能最佳。

  所以如果对性能要求非常严格,或待拼接的字符串数量足够多时,建议使用 预先分配容量这种方式。

  使用了 Grow 优化后的版本的性能测试结果如下。可以看出相较于不预先分配空间的方式,性能提升了很多。

  Go 中遍历切片或数组有两种方式,一种是通过下标,一种是 range。二者在功能上没有区别,但是在性能上会有区别吗?

  函数用于生成指定长度元素类型为 int 的切片。从最终的结果可以看到,遍历 []int 类型的切片,下标与 range 遍历性能几乎没有区别。

  range 只遍历 []struct 下标时,性能比 range 遍历 []struct 值好很多。从这里我们应该能够知道二者性能差别之大的原因。

  Item 是一个结构体类型 ,Item 由两个字段构成,一个类型是 int,一个是类型是 [1024]byte,如果每次遍历 []Item,都会进行一次值拷贝,所以带来了性能损耗。

  此外,因为 range 时获取的是值拷贝的副本,所以对副本的修改,是不会影响到原切片。

  切片元素从结构体 Item 替换为指针 *Item 后,for 和 range 的性能几乎是一样的。而且使用指针还有另一个好处,可以直接修改指针对应的结构体的值。

  如果 range 迭代的元素较小,那么 index 和 range 的性能几乎一样,如基本类型的切片 []int。但如果迭代的元素较大,如一个包含很多属性的 struct 结构体,那么 index 的性能将显著地高于 range,有时候甚至会有上千倍的性能差异。对于这种场景,建议使用 index。如果使用 range,建议只迭代下标,通过下标访问元素,这种使用方式和 index 就没有区别了。如果想使用 range 同时迭代下标和值,则需要将切片/数组的元素改为指针,才能不影响性能。

  在 Go 中,我们可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数。

  可以看到,Go 中空结构体 struct{} 是不占用内存空间,不像 C/C++ 中空结构体仍占用 1 字节。

  因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。一是节省资源,二是空结构体本身就具备很强的语义,即这里不需要任何值,仅作为占位符,达到的代码即注释的效果。

  Go 语言标准库没有提供 Set 的实现,通常使用 map 来代替。事实上,对于集合来说,只需要 map 的键,而不需要值。即使是将值设置为 bool 类型,也会多占据 1 个字节,那假设 map 中有一百万条数据,就会浪费 1MB 的空间。

  因此呢,将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。

  如果想使用 Set 的完整功能,如初始化(通过切片构建一个 Set)、Add、Del、Clear、Contains 等操作,可以使用开源库golang-set。

  有时候使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程的并发。这种情况下,使用空结构体作为占位符就非常合适了。

  在部分场景下,结构体只包含方法,不包含任何的字段。例如上面例子中的 Door,在这种情况下,Door 事实上可以用任何的数据结构替代。

  无论是 int 还是 bool 都会浪费额外的内存,因此呢,这种情况下,声明为空结构体最合适。

  CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。

  这么设计的目的,是减少 CPU 访问内存的次数,加大 CPU 访问内存的吞吐量。比如同样读取 8 个字节的数据,一次读取 4 个字节那么只需要读取 2 次。

  CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数,例如:

  变量 a、b 各占据 3 字节的空间,内存对齐后,a、b 占据 4 字节空间,CPU 读取 b 变量的值只需要进行一次内存访问。如果不进行内存对齐,CPU 读取 b 变量的值需要进行 2 次内存访问。第一次访问得到 b 变量的第 1 个字节,第二次访问得到 b 变量的后两个字节。

  从这个例子中也可以看到,内存对齐对实现变量的原子性操作也是有好处的,每次内存访问是原子的,如果变量的大小不超过字长,那么内存对齐后,对该变量的访问就是原子的,这个特性在并发场景下至关重要。

  简言之:合理的内存对齐可以提高内存读写的性能,并且便于实现变量操作的原子性。

  编译器一般为了减少 CPU 访存指令周期,提高内存的访问效率,会对变量进行内存对齐。Go 作为一门追求高性能的后台编程语言,当然也不例外。

  对于数组类型的变量 x,unsafe.Alignof(x) 等于构成数组的元素类型的对齐系数。

  其中函数 用于获取变量的对齐系数。对齐系数决定了字段的偏移和变量的大小,两者必须是对齐系数的整数倍。

  因为内存对齐的存在,合理的 struct 布局可以减少内存占用,提高程序性能。

  可以看到,同样的字段,因字段排列顺序不同,最终会导致不一样的结构体大小。

  每个字段按照自身的对齐系数来确定在内存中的偏移量,一个字段因偏移而浪费的大小也不同。

  接下来逐个分析,首先是 demo1:a 是第一个字段,默认是已经对齐的,从第 0 个位置开始占据 1 字节。b 是第二个字段,对齐系数为 2,因此,必须空出 1 个字节,偏移量才是 2 的倍数,从第 2 个位置开始占据 2 字节。c 是第三个字段,对齐倍数为 4,此时,内存已经是对齐的,从第 4 个位置开始占据 4 字节即可。

  对于 demo2:a 是第一个字段,默认是已经对齐的,从第 0 个位置开始占据 1 字节。c 是第二个字段,对齐倍数为 4,因此,必须空出 3 个字节,偏移量才是 4 的倍数,从第 4 个位置开始占据 4 字节。b 是第三个字段,对齐倍数为 2,从第 8 个位置开始占据 2 字节。

  demo2 的对齐系数由 c 的对齐系数决定,也是 4,因此,demo2 的内存占用为 12 字节。

  空结构与空数组在 Go 中比较特殊。没有任何字段的空 struct{} 和没有任何元素的 array 占据的内存空间大小为 0。

  因为这一点,空 struct{} 或空 array 作为其他 struct 的字段时,一般不需要内存对齐。但是有一种情况除外:即当 struct{} 或空 array 作为结构体最后一个字段时,需要内存对齐。因为如果有指针指向该字段,返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)。

  可以看到,demo3{} 的大小为 4 字节,与字段 b 占据空间一致,而 demo4{} 的大小为 8 字节,即额外填充了 4 字节的空间。

  知道变量逃逸的原因后,我们可以有意识的控制变量不发生逃逸,将其控制在栈上,减少堆变量的分配,降低 GC 成本,提高程序性能。

  小的拷贝好过引用,什么意思呢,就是尽量使用栈变量而不是堆变量。下面举一个反常识的例子,来证明小的拷贝比在堆上创建引用变量要好。

  我们都知道 Go 里面的 Array 以 pass-by-value 方式传递后,再加上其长度不可扩展,考虑到性能我们一般很少使用它。实际上,凡事无绝对。有时使用数组进行拷贝传递,比使用切片要好。

  sliceFibonacci() 函数中分配的局部变量切片因为要返回到函数外部,所以发生了逃逸,需要在堆上申请内存空间。从测试也过也可以看出,arrayFibonacci() 函数没有内存分配,完全在栈上完成数组的创建。这里说明了对于一些短小的对象,栈上复制的成本远小于在堆上分配和回收操作。

  需要注意,运行上面基准测试时,传递了禁止内联的编译选项 -l,如果发生内联,那么将不会出现变量的逃逸,就不存在堆上分配内存与回收的操作了,二者将看不出性能差异。

  编译时可以借助选项 -gcflags=-m 查看编译器对上面两个函数的优化决策。

  那么多大的变量才算是小变量呢?对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。一般是

  值传递会拷贝整个对象,而指针传递只会拷贝地址,指向的对象是同一个。返回指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

  一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择返回指针。对于只读的占用内存较小的结构体,直接返回值能够获得更好的性能。

  如果变量类型不确定,那么将会逃逸到堆上。所以,函数返回值如果能确定的类型,就不要使用 interface{}。

  我们还是以上面斐波那契数列函数为例,看下返回值为确定类型和 interface{} 的性能差别。

  可见,函数返回值使用 interface{} 返回时,编译器无法确定返回值的具体类型,导致返回值逃逸到堆上。当发生了堆上内存的申请与回收时,性能会差一点。

  sync.Pool 是 sync 包下的一个组件,可以作为保存临时取还对象的一个“池子”。个人觉得它的名字有一定的误导性,因为 Pool 里装的对象可以被无通知地被回收,可能 sync.Cache 是一个更合适的名字。

  sync.Pool 是可伸缩的,同时也是并发安全的,其容量仅受限于内存的大小。存放在池中的对象如果不活跃了会被自动清理。

  对于很多需要重复分配、回收内存的地方,sync.Pool 是一个很好的选择。频繁地分配、回收内存会给 GC 带来一定的负担,严重的时候会引起 CPU 的毛刺,而 sync.Pool 可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系统的性能。

  sync.Pool 的使用方式非常简单,只需要实现 New 函数即可。对象池中没有对象时,将会调用 New 函数创建。

  Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。

  我们以 bytes.Buffer 字节缓冲器为例,利用 sync.Pool 复用 bytes.Buffer 对象,避免重复创建与回收内存,来看看对性能的提升效果。

  这个例子创建了一个 bytes.Buffer 对象池,每次只执行 Write 操作,及做一次数据拷贝,耗时几乎可以忽略。而内存分配和回收的耗时占比较多,因此对程序整体的性能影响更大。从测试结果也可以看出,使用了 Pool 复用对象,每次操作不再有内存分配。

  fmt.Printf() 的调用是非常频繁的,利用 sync.Pool 复用 pp 对象能够极大地提升性能,减少内存占用,同时降低 GC 压力。

  加锁是为了避免在并发环境下,同时访问共享资源产生的安全问题。那么,在并发环境下,是否必须加锁?答案是否定的。并非所有的并发都需要加锁。适当地降低锁的粒度,甚至采用无锁化的设计,更能提升并发能力。

  利用硬件支持的原子操作可以实现无锁的数据结构,原子操作可以在 lock-free 的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展。很多语言都提供 CAS 原子操作(如 Go 中的 atomic 包和 C++11 中的 atomic 库),可以用于实现无锁数据结构,如无锁链表。

  我们以一个简单的线程安全单向链表的插入操作来看下无锁编程和普通加锁的区别。

  (1)无锁单向链表实现时在插入时需要进行 CAS 操作,即调用方法进行插入,如果插入失败则进行 for 循环多次尝试,直至成功。

  (2)为了方便打印链表内容,实现一个方法遍历链表,且使用值作为接收者,避免打印对象指针时无法生效。

  下面再看一下链表 Push 操作的基准测试,对比一下有锁与无锁的性能差异。

  串行无锁是一种思想,就是避免对共享资源的并发访问,改为每个并发操作访问自己独占的资源,达到串行访问资源的效果,来避免使用锁。不同的场景有不同的实现方式。比如网络 I/O 场景下将

  改为主从 Reactor 多线程模型,避免对同一个消息队列锁读取。这里我介绍的是后台微服务开发经常遇到的一种情况。我们经常需要并发拉取多方面的信息,汇聚到一个变量上。那么此时就存在对同一个变量互斥写入的情况。比如批量并发拉取用户信息写入到一个 map。此时我们可以将每个协程拉取的结果写入到一个临时对象,这样便将并发地协程与同一个变量解绑,然后再将其汇聚到一起,这样便可以不用使用锁。即独立处理,然后合并。

  如果加锁无法避免,则可以采用分片的形式,减少对资源加锁的次数,这样也可以提高整体的性能。

  以一个简单的示例,通过对分片前后并发写入的对比,来看下减少锁竞争带来的性能提升。

  可以看到,通过对分共享资源的分片处理,减少了锁竞争,能明显地提高程序的并发性能。可以预见的是,随着分片粒度地变小,性能差距会越来越大。当然,分片粒度不是越小越好。因为每一个分片都要配一把锁,那么会带来很多额外的不必要的开销。可以选择一个不太大的值,在性能和花销上寻找一个平衡。

  所谓互斥锁,指锁只能被一个 Goroutine 获得。共享锁指可以同时被多个 Goroutine 获得的锁。

  Go 标准库 sync 提供了两种锁,互斥锁(sync.Mutex)和读写锁(sync.RWMutex),读写锁便是共享锁的一种具体实现。

  互斥锁的作用是保证共享资源同一时刻只能被一个 Goroutine 占用,一个 Goroutine 占用了,其他的 Goroutine 则阻塞等待。

  我们可以通过在访问共享资源前前用 Lock 方法对资源进行上锁,在访问共享资源后调用 Unlock 方法来释放锁,也可以用 defer 语句来保证互斥锁一定会被解锁。在一个 Go 协程调用 Lock 方法获得锁后,其他请求锁的协程都会阻塞在 Lock 方法,直到锁被释放。

  读写锁是一种共享锁,也称之为多读单写锁 (multiple readers, single writer lock)。在使用锁时,对获取锁的目的操作做了区分,一种是读操作,一种是写操作。因为同一时刻允许多个 Gorouine 获取读锁,所以是一种共享锁。但写锁是互斥的。

  读锁之间不互斥,没有写锁的情况下,读锁是无阻塞的,多个协程可以同时获得读锁。

  读写锁的存在是为了解决读多写少时的性能问题,读场景较多时,读写锁可有效地减少锁阻塞的时间。

  大部分业务场景是读多写少,所以使用读写锁可有效提高对共享数据的访问效率。最坏的情况,只有写请求,那么读写锁顶多退化成互斥锁。所以优先使用读写锁而非互斥锁,可以提高程序的并发性能。

  入参 rpct 用来调节读操作的占比,来模拟读写占比不同的场景。rpct 设为 80 表示读多写少(读占 80%),rpct 设为 50 表示读写一致(各占 50%),rpct 设为 20 表示读少写多(读占 20%)。

  可见读多写少的场景,使用读写锁并发性能会更优。可以预见的是如果写占比更低,那么读写锁带的并发效果会更优。

  这里需要注意的是,因为每次读写 map 的操作耗时很短,所以每次睡眠一微秒(百万分之一秒)来增加耗时,不然对共享资源的访问耗时,小于锁处理的本身耗时,那么使用读写锁带来的性能优化效果将变得不那么明显,甚至会降低性能。

  Go 程(goroutine)是由 Go 运行时管理的轻量级线程。通过它我们可以轻松实现并发编程。但是当我们无限开辟协程时,将会遇到致命的问题。

  这个例子实现了 math.MaxInt32 个协程的并发, 约为 20 亿个,每个协程内部几乎没有做什么事情。正常的情况下呢,这个程序会乱序输出 0 ~ 2^31-1 个数字。

  对单个 file/socket 的并发操作个数超过了系统上限,这个报错是 fmt.Printf 函数引起的,fmt.Printf 将格式化后的字符串打印到屏幕,即标准输出。在 Linux 系统中,标准输出也可以视为文件,内核(Kernel)利用文件描述符(File Descriptor)来访问文件,标准输出的文件描述符为 1,错误输出文件描述符为 2,标准输入的文件描述符为 0。

  那如果我们将 fmt.Printf 这行代码去掉呢?那程序很可能会因为内存不足而崩溃。这一点更好理解,每个协程至少需要消耗 2KB 的空间,那么假设计算机的内存是 4GB,那么至多允许 4GB/2KB = 1M 个协程同时存在。那如果协程中还存在着其他需要分配内存的操作,那么允许并发执行的协程将会数量级地减少。

  前面的例子过于极端,一般情况下程序也不会无限开辟协程,旨在说明协程数量是有限制的,不能无限开辟。

  如果我们开辟很多协程,但不会导致程序崩溃,可以吗?如果真要这么做的话,我们应该清楚地知道,协程虽然轻量,但仍有开销。

  Go 的开销主要是三个方面:创建(占用内存)、调度(增加调度器负担)和删除(增加 GC 压力)。

  时间上,协程调度也会有 CPU 开销。我们可以利用runntime.Gosched()让当前协程主动让出 CPU 去执行另外一个协程,下面看一下协程之间切换的耗时。

  可见一次协程的切换,耗时大概在 100ns,相对于线程的微秒级耗时切换,性能表现非常优秀,但是仍有开销。

  GC 开销创建 Go 程到运行结束,占用的内存资源是需要由 GC 来回收,如果无休止地创建大量 Go 程后,势必会造成对 GC 的压力。

  上面的分析目的是为了尽可能地量化 Goroutine 的开销。虽然官方宣称用 Golang 写并发程序的时候随便起个成千上万的 Goroutine 毫无压力,但当我们起十万、百万甚至千万个 Goroutine 呢?Goroutine 轻量的开销将被放大。

  系统地资源是有限,协程是有代价的,为了保护程序,提高性能,我们应主动限制并发的协程数量。

  上例中创建了缓冲区大小为 3 的 channel,在没有被接收的情况下,至多发送 3 个消息则被阻塞。开启协程前,调用,若缓存区满,则阻塞。协程任务结束,调用

  从日志中可以很容易看到,每秒钟只并发执行了 3 个任务,达到了协程并发控制的目的。

  上面的例子只是简单地限制了协程开辟的数量。在此基础之上,基于对象复用的思想,我们可以重复利用已开辟的协程,避免协程的重复创建销毁,达到池化的效果。

  协程池化,我们可以自己写一个协程池,但不推荐这么做。因为已经有成熟的开源库可供使用,无需再重复造轮子。目前有很多第三方库实现了协程池,可以很方便地用来控制协程的并发数量,比较受欢迎的有:

  ants 是一个简单易用的高性能 Goroutine 池,实现了对大规模 Goroutine 的调度管理和复用,允许使用者在开发并发程序的时候限制 Goroutine 数量,复用协程,达到更高效执行任务的效果。

  使用 ants,我们简单地使用其默认的协程池,直接将任务提交并发执行。默认协程池的缺省容量 math.MaxInt32。

  如果自定义协程池容量大小,可以调用 NewPool 方法来实例化具有给定容量的池,如下所示:

  Golang 为并发而生。Goroutine 是由 Go 运行时管理的轻量级线程,通过它我们可以轻松实现并发编程。Go 虽然轻量,但天下没有免费的午餐,无休止地开辟大量 Go 程势必会带来性能影响,甚至程序崩溃。所以,我们应尽可能的控制协程数量,如果有需要,请复用它。

  sync.Once 是 Go 标准库提供的使函数只执行一次的实现,常应用于单例模式,例如初始化配置、保持数据库连接等。作用与 init 函数类似,但有区别。

  init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间。

  sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的。

  在多数情况下,sync.Once 被用于控制变量的初始化,这个变量的读写满足如下三个条件:

  sync.Once 用来保证函数只执行一次。要达到这个效果,需要做到两点:

  下面看一下 sync.Once 结构,其有两个变量。使用 done 统计函数执行次数,使用锁 m 实现线程安全。果不其然,和上面的猜想一致。

  sync.Once 仅提供了一个导出方法 Do(),参数 f 是只会被执行一次的函数,一般为对象初始化函数。

  抛去大段的注释,可以看到 sync.Once 实现非常简洁。Do() 函数中,通过对成员变量 done 的判断,来决定是否执行传入的任务函数。执行任务函数前,通过锁保证任务函数的执行和 done 的修改是一个互斥操作。在执行任务函数前,对 done 做一个二次判断,来保证任务函数只会被执行一次,done 只会被修改一次。

  done 在热路径中,done 放在第一个字段,能够减少 CPU 指令,也就是说,这样做能够提升性能。

  热路径(hot path)是程序非常频繁执行的一系列指令,sync.Once 绝大部分场景都会访问 o.done,在热路径上是比较好理解的。如果 hot path 编译后的机器码指令更少,更直接,必然是能够提升性能的。

  为什么放在第一个字段就能够减少指令呢?因为结构体第一个字段的地址和结构体的指针是相同的,如果是第一个字段,直接对结构体的指针解引用即可。如果是其他的字段,除了结构体指针外,还需要计算与第一个值的偏移(calculate offset)。在机器码中,偏移量是随指令传递的附加值,CPU 需要做一次偏移值与指针的加法运算,才能获取要访问的值的地址。因为,访问第一个字段的机器代码更紧凑,速度更快。

  我们以一个简单示例,来说明使用 sync.Once 保证函数只会被执行一次和多次执行,二者的性能差异。

  考虑一个简单的场景,函数 ReadConfig 需要读取环境变量,并转换为对应的配置。环境变量在程序执行前已经确定,执行过程中不会发生改变。ReadConfig 可能会被多个协程并发调用,为了提升性能(减少执行时间和内存占用),使用 sync.Once 是一个比较好的方式。

  sync.Once 中保证了 Config 初始化函数仅执行了一次,避免了多次重复初始化,在并发环境下很有用。

  sync.Cond 是基于互斥锁/读写锁实现的条件变量,用来协调想要访问共享资源的那些 Goroutine,当共享资源的状态发生变化的时候,sync.Cond 可以用来通知等待条件发生而阻塞的 Goroutine。

  互斥锁 sync.Mutex 通常用来保护共享的临界资源,条件变量 sync.Cond 用来协调想要访问共享资源的 Goroutine。当共享资源的状态发生变化时,sync.Cond 可以用来通知被阻塞的 Goroutine。

  sync.Cond 经常用在多个 Goroutine 等待,一个 Goroutine 通知(事件发生)的场景。如果是一个通知,一个等待,使用互斥锁或 channel 就能搞定了。

  有一个协程在异步地接收数据,剩下的多个协程必须等待这个协程接收完数据,才能读取到正确的数据。在这种情况下,如果单纯使用 chan 或互斥锁,那么只能有一个协程可以等待,并读取到数据,没办法通知其他的协程也读取数据。

  这个时候,就需要有个全局的变量来标志第一个协程数据是否接受完毕,剩下的协程,反复检查该变量的值,直到满足要求。或者创建多个 channel,每个协程阻塞在一个 channel 上,由接收数据的协程在数据接收完毕后,逐个通知。总之,需要额外的复杂度来完成这件事。

  sync.Cond 内部维护了一个等待队列,队列中存放的是所有在等待这个 sync.Cond 的 Go 程,即保存了一个通知列表。sync.Cond 可以用来唤醒一个或所有因等待条件变量而阻塞的 Go 程,以此来实现多个 Go 程间的同步。

  每个 Cond 实例都会关联一个锁 L(互斥锁 *Mutex,或读写锁 *RWMutex),当修改条件或者调用 Wait 方法时,必须加锁。

  Wait 用于阻塞调用者,等待通知。调用 Wait 会自动释放锁 c.L,并挂起调用者所在的 goroutine。如果其他协程调用了 Signal 或 Broadcast 唤醒了该协程,那么 Wait 方法在结束阻塞时,会重新给 c.L 加锁,并且继续执行 Wait 后面的代码。

  对条件的检查,使用了 for !condition() 而非 if,是因为当前协程被唤醒时,条件不一定符合要求,需要再次 Wait 等待下次被唤醒。为了保险起,使用 for 能够确保条件符合要求后,再执行后续的代码。

  我们实现一个简单的例子,三个协程调用 Wait() 等待,另一个协程调用 Broadcast() 唤醒所有等待的协程。

  write() 中的暂停了 1s,一方面是模拟耗时,另一方面是确保前面的 3 个 read 协程都执行到 Wait(),处于等待状态。main 函数最后暂停了 3s,确保所有操作执行完毕。

电脑内存录入:admin    责任编辑:admin 
  • 上一个电脑内存:

  • 下一个电脑内存: 没有了
  •  
     栏目文章
    普通电脑内存 Go 高性能编程技法 (11-16)
    普通电脑内存 2022年四六级报名时间是下半年吗 四六级报名网… (11-16)
    普通电脑内存 内存条要降价?三星、海力士等厂商不答应 (11-16)
    普通电脑内存 KLEVV科赋CRAS XR5 RGB超频电竞内存条体验评测 (11-16)
    普通电脑内存 实测32GB对比16GB:不同内存下的体验差距你感… (11-16)
    普通电脑内存 电脑小知识:装机不求人!10 分钟电脑配置挑选… (11-16)
    普通电脑内存 电脑运行内存4g和6g的区别 (11-15)
    普通电脑内存 内存和SSD疯狂降价清库存:好日子快到头了 (11-15)
    普通电脑内存 DRAM(内存)板块11月14日涨102%兆易创新领涨… (11-15)
    普通电脑内存 内存 - OFweek电子工程网 (11-15)
    普通电脑内存 科技笑谈——500G内存的电脑是什么鬼 (11-15)
    普通电脑内存 英语双语报官网 (英语双语报官网答案2022) (11-15)
    普通电脑内存 从顶级科技企业到折戟科创板:联想欠倪光南院… (11-15)
    普通电脑内存 儿童动画学英语颜色(幼儿学颜色英语) (11-15)
    普通电脑内存 电脑提示svchost占用内存过高怎么办 (11-15)
    普通电脑内存 干得漂亮!谷歌将减少Chrome在Win10内存占用 (11-15)
    普通电脑内存 太笨了2w月薪工程师连电脑蓝屏都修不好!幻龙… (10-21)
    普通电脑内存 「内存」这个名字误导性太强为什么没有一个更… (10-21)
    普通电脑内存 计算机常用电脑 英语 缩写学习伍娇近况夏沫之… (10-21)
    普通电脑内存 计算机外存储器有哪些计算机内存器和外存储器… (10-21)