golang性能分析实战

20241112 晓宇发的

万字长文讲透Go程序性能优化


很早之前写过一篇递归的缺点–以斐波那契数列为例, 只进行了程序的时间消耗对比,对于内存/CPU资源的占用,没有进行比照. 再以此为例,继续分析.


参考:




20240918记录

看起来这哥们的视频讲的不错,下载到本地了~

Golang 性能优化实战


Golang 性能优化实战
https://www.bilibili.com/video/BV1WU411d7S9/

这哥们叫 熊豹, 知乎的

评论:

这个memory wall的图存疑:以前有倍频的说法,当年的倍频设置很多都是10倍以上。如今ddr5 5600mhz与CPU 4.5ghz睿频的比值不足10倍了(虽然ddr的频率与寻址速率有差异,但以前倍频年代末尾也是ddr内存)。这个图的说法与现实感受不符。另外这是个硬件叙事图,与系统的malloc之间有gap。虽然不影响解说的内容,但缺乏支撑力。
2024-05-28 09:43

1

回复

看我的滑板鞋

感谢指出问题,我对硬件的理解确实不是特别深入
2024-05-28 14:05

回复

是不是用c++更好些 14、17也不错了
2024-05-29 08:43

看我的滑板鞋

语言只是一方面,不好好搞复用 rust 也干不过 go https://mp.weixin.qq.com/s/eYWwBS979K6xFWOPM5DX-w




好的,那我们就开始了。今天给大家分享的内容是 Go 语言的性能优化。在过去的一两年里,我写了很多 Go 代码,积累了不少经验。最近发现我们在 Call Do 和 Data Kit 项目中都遇到了一些性能问题,因此我尝试总结了一些理解,和大家一起交流一下。

首先,性能优化的第一步应该是查看 pprof 的 profile 文件的火焰图。这里是我们拿到的一个 Code 查的 profile,实际上这是一个非常典型的例子。我们从火焰图上可以看到,约 37.6% 的时间都花在了 runtime.MallocGC 上。在火焰图的中间左侧部分,可以看到一个名为 GC Duration 的部分,它表示当 runtime 试图分配内存时,发现内存不足,因此重新扫描堆上的已有内存并尝试回收。这表明大约 37.6% 的 CPU 时间都耗费在了分配新内存和回收旧堆内存上。

接下来,我们再看一个 观测 Insert 的 profile。这里的 MallocGC 开销实际上是可以优化的。观测 Insert 是杭州的一个项目,现在每秒的实际吞吐量大概是 10Gbps,目前有四个实例在运行,处理所有 SaaS 用户的数据。它的处理流程包括一些文本协议的解析、简单的预处理、字段判断等,最后将数据序列化为 JSON,并发给 Doris。中间还涉及到内部的文件队列等逻辑。尽管这个流程看似复杂,但实际上并没有太多的内存分配。

因此,我们可以看到,对于像 Code 这样未经优化的应用,普通的写法可能会导致大量的内存分配开销。换句话说,在进行 Go 语言性能优化时,大家只需要关注一件事---内存。关注内存的分配和回收就足够了,不必过分关注程序中某个算法是否是最优的。当前大多数后台应用程序的主要开销都集中在内存上,因此我们应该首先聚焦在内存的处理上。

内存墙问题

这里给大家展示一张网上找到的图。大致意思是,CPU 性能的提升速度非常快,每年提升约 60%。然而,内存的存取性能提升的速度并没有那么快。这意味着程序执行时,CPU 的速度可能很快,但由于总线带宽或内存频率的限制,内存的读写速度跟不上 CPU 存取数据的速度。这种现象被称为 内存墙。结合我们刚才看到的 profile,大家应该能意识到,内存性能增长跟不上 CPU 增长是导致程序性能瓶颈的根本原因。因此,如果代码没有经过优化,大量的时间会被消耗在内存分配和回收上。

性能优化的三个部分

接下来,今天的内容主要分为三个部分:

  1. 哪些地方的内存分配是有害的:这一部分需要大家培养一种感觉,当你看到一个函数时,能够快速判断出是否存在内存问题。

  2. 内存复用的方法:这里会介绍一些常见的内存复用技巧,比如对象复用、slice 复用等,甚至一些 hack 技巧。

  3. 生命周期管理:这一部分是最关键的,涉及到代码组织的思路,如何设计数据流。

内存分配的有害性

首先,我们需要关注代码中的隐式内存分配。一个非常简要的原则是:当你看到一个函数时,如果没有主动传入内存(如 slice 或其他数据结构),但函数返回了一个字符串或 slice 时,函数内部一定进行了新的内存分配。而且,这些内存分配通常是分配到堆上的,并且 GC 无法主动回收它们。

例如,函数给你分配了内存,你用完后只能等待 GC 回收。这就会导致我们之前看到的问题---大量的时间花费在内存分配和回收上。

示例分析

来看几个例子:

  1. io.ReadAll:这是一个非常好用的函数,你可以给它传入一个 io.Reader,它会将所有数据读到一个 []byte 的 slice 中。然而,需要注意的是,它在内部必然会 make 一个新的 slice,这个内存分配会发生在堆上。而且,它的初始分配大约是 512 字节,之后会不断扩容,这意味着多次内存分配,之前的分片也需要等待 GC 回收。这种情况在数据密集的处理路径上(如循环中频繁调用)会成为性能杀手。

  2. strings.Replace:这个函数表面上看起来无害,但需要注意的是,string 是不可变的,这意味着每次替换操作都会生成一个新的字符串,并分配到堆上。原始的字符串需要等待 GC 回收,而新的字符串则占用了额外的内存,导致内存双倍使用,并增加了 CPU 开销。

  3. json.Marshal:这个函数与 io.ReadAll 类似,你将一个结构体传给它,它返回一个 []byte,这个 []byte 也是分配在堆上的。

  4. point 库中的 encode 方法:根据我们之前的原则,这个方法也是分配了新的内存,且无法复用。

这些函数只要返回字符串或 slice,就需要警惕它们内部的内存分配,它们可能是性能下降的主要来源。特别是处理 字符串 时,字符串是不可变的,拼接或格式化操作都会生成新的字符串,并分配到堆上。

内存复用的方法

接下来,我们介绍一些常见的内存复用方法,避免不必要的内存分配。

  1. sync.Pool:这是一个用于内存复用的池子,随系统的垃圾回收机制清空池子中的数据。在没有 GC 时,sync.Pool 可以用来存放和复用已经分配好的内存对象。不过要注意,复用的应该是对象持有的内存,而不仅仅是对象的结构体壳子。

通过这些方法和技巧,我们可以避免频繁的内存分配,从而提升程序的性能。


整理后的内容如下:

在那个结构体上,它本身可能没有数据,只有一些其他的东西,比如说像 encoder 这个结构体,它只有一个 encoder 的函数,这个函数引用了另外一个函数,而它自己本身没有其他东西。当你复用这个结构体的壳子时,效率其实是很低的。

这里可以举两个正面的例子。比如说 TX 的 pool,结构体上肯定有一个 TX 的 slice。当你复用时,实际上你希望这个 slice 不要反复地申请内存,而是能够复用它。还有一个例子是 rose 的结构体。在进 pool 之前,或者从 pool 取出来之后,你需要对结构体上所有的 slice 数据进行重置。在这种情况下,你要关注的是复用结构体上的字段,而不仅仅是外层的壳子。外层壳子占用的内存其实很少,假设你在像 pool 这样的结构中没有正确复用 TX,那么你的复用可能就失败了。

基于 single put 的基础,我们可以补充一种用法:使用 channel 来池化数据。左边的代码展示了一个泛型 pool 的用法,代码非常简单。最终的效果是,pool 中原始的 single 保存的数据会在 GC(垃圾回收)时被释放。如果你希望 pool 持有一些固定数量的对象,可以使用一个 channel,将数据放在 channel 中来保存。通过这种方式,你可以提高内存的利用率。

右边则是实际的一个用例,比如在代码中,我可能有一个与 CPU 核心数相对应的并发数量。至少在我的结构体中,需要保存与 CPU 核心数相等的数目。当负载高峰时,可能会突发性分配更多的资源,但用完之后就可以回收掉。通过这种小技巧,可以提升缓存命中率,避免每次 GC 时 pool 里都是空的。

前面我们介绍了 single put 的复用方法,但需要注意的是,使用 single pool 的目的是复用对象。但复用对象不仅限于 single pool,single pool 的访问是有开销的。它是一个全局一致性的缓存,为了保证多个 goroutine 并发访问的安全性,它内部包含了一些读屏障、写屏障的设计,还涉及指令重排。它保证了多线程访问到的数据一致性,但这些设计带来了额外的性能开销。因此,访问全局 pool 的开销要比访问本地变量重。

在这种情况下,你可以通过局部的设计来复用对象。比如说,第一种方式是在循环外设置一个变量,在每次循环时将其重置。在左下角的例子中,我有一个 current accumulator 对象,每次使用它时,先重置它,然后再使用,使用完后再重置,这样就实现了对象的复用,而不需要通过 single put。

第二种方式是全局 context。比如在观测数据库中,有很多算子或函数,这些函数在计算时需要一些临时的 slice 和变量来存储结果。这些数据是临时的,最终不会存在。因此,我们可以在请求进来时分配一个全局的 context,每个算子处理时可以直接从 context 的 buffer 中取数据,处理完毕后,这些数据会随着 context 一起复用。通过这种方式,可以在项目中设计一些类似的机制。虽然它不是全局的,但它可以跟请求的生命周期或数据处理流程相关联。

第三种方式是将对象放在你要处理的结构体上。结构体上可能挂了一些函数,你可以在结构体的某个字段上挂一些缓存变量。右下角的例子中,有一个 storage 结构体,我在这个结构体上挂了一个 previous accumulator 的 slice。每次使用后,我将其重置并清除,然后重新放回 storage 上。这样下次循环时,当逻辑处理到相同的分支时,就可以从 storage 中再次取出来使用。

我们应该意识到,在代码结构中存储一些临时变量是可行的,不一定非要依赖 single put。实际上,single put 的访问效率并不高。最近我们对观测数据库做了一些性能优化,将一些从 single put 中取出的变量放到了局部的上下文中,这样性能提升了大概 20% 到 30%。

接下来,我们介绍 slice 的复用。介绍 slice 复用之前,先简单过一下 slice 的基础知识。slice 在 Go 语言中是通过一个 slice header 结构体来表示的。它的第一个元素是一个指针,指向堆上的内存,那块内存是真正存储数据的数组(array)。例如,在 make([]int, 4, 6) 的例子中,堆上会分配 6 个 int 类型的元素,每个 int 占用 8 个字节,总共 48 字节。slice 的第一个指针指向这块堆内存,后面两个 int 分别存储 slice 的长度和容量。

slice 的长度决定了程序能看到的数据范围,而容量决定了底层数组的大小。在使用 slice 时,我们可以通过一些操作来控制它的长度和容量。

在 Go 1.20 或 1.21 版本中引入了 clear 方法。这个方法会将 slice 内部的元素重置为零值,而不会改变 slice 的长度。你还可以通过 slice[:n] 的方式来重置 slice 的长度。比如,当你将长度重置为 0 时,再通过 append 方法往 slice 中添加新数据,这样就能覆盖以前的数据。

当 slice 的容量不足时,append 会触发自动扩容,这可能导致多次内存分配。为了避免多次扩容的开销,可以提前计算需要的容量,使用 make 一次性分配足够的内存。通过这种方式,可以减少多次扩容带来的性能损失。

在某些情况下,你也可以根据索引直接访问 slice 的数据。如果需要一个固定大小的 slice,可以先判断容量是否足够,若不够则重新分配。

在右下角的例子中,当我从 storage 中取出 previous accumulator 时,通过调用 encoding/youtubereset 方法实现了 slice 的重置。这种方式确保了后续对数据的处理有足够的容量。


整理后的内容如下:

当我使用 reset 方法处理 slice 时,我可以得到一个定长的 slice,这样就可以避免反复的扩容。这两个方法(resetrecap)是非常好用的工具方法。前面提到的操作涉及到一些手动控制 slice 长度的语法,大家需要熟练掌握这些操作。此外,当你复用 slice 时,可能需要重置内部的数据,可以使用 clear 方法来重置。

需要注意的是,假如我们像第三行那样把 slice 的长度设置为 0,其实内部的 array 数据并没有被清空,只是这些数据不可见了。这和 clear 的区别在于,clear 会重置 slice 内部的数据,而设置长度为 0 只是改变了 slice 的长度。

接下来我们来看一个稍微复杂一点的例子,讨论 slice 内部对象的复用。左边的结构体定义了一个 text 结构体,其中包含一个 tag 的 slice,tag 包含两个 keyvalue 的 byte slice。这是一个比较常见的结构体。

假设我们要复用这个 text 结构体,不希望每次都重新分配一个新的 tag。右边的代码展示了如何实现这一点。当我们要往 text 结构体中添加 key-value 时,首先判断 tag 的容量(cap)是否大于当前长度(len)。如果容量足够,我们只需要将长度增加一位,使得之前不可见的内存变得可见,然后直接使用这部分内存即可。如果容量不够,则通过 append 分配新的内存。

接下来,通过取地址操作获取 tag,并将新的 keyvalue 添加到 tag 上。整个操作过程中,如果 tag 的容量足够,那么我们实际上没有分配新的内存,而是复用了堆上的已有内存。keyvalue 的 slice 也通过 append 操作复用了原有的数据结构。最终,tag 结构体和 tag 上的 keyvalue 的 slice 都被成功复用,没有进行新的内存分配,实现了有效的内存复用。

再来看一个稍微高级一点的例子,讨论 slice 的 slice 的复用。左边的结构体定义了一个 roll,它是一个单个 Influx 行协议的抽象,包含 timestampmeasurementtagfieldtagfield 都存储 keyvaluefield 没有直接存储原始数据类型,而是存储了一个 escapestring

在复用 roll 时,我们可能会遇到一些问题。首先,tag 上的 keyvalue 都是 string 类型,而 string 是不可变的,无法复用。每次创建一个新的 string 都会分配新的内存。第二个问题是,不同的 roll 结构体的 tagfield 的长度不同。有的 roll 可能只有两个 tag 或者三个 field,而有的 roll 可能有 30 到 50 个 tagfield

如果我们将 roll 放到 single pool 中,每次数据进来时从 single pool 中取出,再像前面那样根据 cap 是否足够来决定是否扩容,会导致一个问题:所有 roll 对象上的 tagfield 的容量都会被拉伸到某些极端情况下的最大值,导致内存浪费,降低复用效率。

为了应对这些问题,我们提出了一个解决方案:将 roll 抽象为一个请求的 batch,包含多个 roll 对象。我们重点关注的是 tag poolfield poolsingle pool。每个 roll 结构体中的 tag 实际上只是持有 tag pool 中子切片的引用。

正常的数据处理流程是:首先将数据 append 到 tag pool 中,然后将 tag pool 中的某段子切片(比如从 offset 5 到 10 的部分)赋值给 rolltag 字段,field 也是类似的处理方式。这样,在复用 roll 时,我们只需要复用整个 batch,而不需要复用单个 roll 上的数据。

需要注意的是,tag 上的 keyvaluestring 类型,string 是不可变的,无法直接复用。因此,我们先将从网络协议(如 HTTP)读取到的 string 数据 append 到 string pool 中,然后从 string pool 取出对应的 byte slice,通过 unsafe 操作将其转换为 string,并赋值给 tag

最终的效果是,所有的数据都保存在 batch 中,而 roll 结构体中的 tagfield 只是引用了 batch 中的数据。这样,在处理请求时,所有的数据复用都发生在 tag poolfield poolstring pool 这些全局池子中,没有新分配的内存。

前面提到的 rosereset 方法,每次数据处理完后会将结构体中的数据重置。

接下来我们讨论 stringbyte slice 的区别。左边的图展示了它们在 Go 语言内存中的表示。slice 结构体包含三个部分:指向原始数据的指针、长度(len)和容量(cap)。string 结构体与 slice 非常相似,区别在于 string 没有 cap,因为 string 是不可变的,不需要扩容。只需要告诉 string 数据存储的位置和长度即可。

右边展示了两个 unsafe 函数的使用例子。假设我们有一个字符串,需要计算它的 MD5 哈希值,而 MD5 函数只接受 byte slice 类型的参数。在这种情况下,我们需要将 string 转换为 byte slice 来进行操作。


整理后的内容如下:

你现在需要用 HTTP 的方式将一个字符串(string)POST 到其他地方。当你 POST 的时候,需要写入一个 body,而 body 只接收 byte slice。因此,很多时候你需要在 stringbyte slice 之间做一些转换。由于某些函数的接口可能无法直接匹配,如果你直接用 string 包装 byte slice,或者用 byte slice 包装 string,这通常会产生一份额外的堆内存拷贝。

为了避免这种情况,我们可以使用一些函数技巧和强制类型转换。比如,右边的这些函数就展示了一些方法。上面这个例子中,byte slice 的 header 是 string header 的超集,它的数据部分比 string 多,因此这个转换相对简单。前 8 个字节存储指针,后 8 个字节存储长度(len),实际上还存储了 8 个字节的 cap。在进行强制的内存转换时,我们把 byte slice 转换为了 string

在这种转换中,编译器只会读取前 16 个字节,即 string 的长度和数据部分,而 byte slicecap 部分并没有被读取。但这并不重要,因为 stringbyte slice 的超集,所以这种转换是可以正常工作的。

使用 unsafe 进行这种类型转换是可行的,但唯一的问题是你需要清楚在转换后,是否会修改原始的 byte slice。如果你修改了它,转换后的 string 也会跟着变化,因为它们引用的是同一块内存。因此,在某些简单的上下文中,比如你只是想将 string POST 发出去,或者是计算一个 MD5 哈希值,byte slice 数据不会发生改变。在这种情况下,使用 unsafe 的效率会非常高,没有什么副作用。

下面这个稍微复杂一点,因为 string 相比 slice 结构少了一个 cap,所以你需要重新创建一个新的 byte slice header。具体地说,你需要 make 一个新的 byte slice header,然后重置数据指针和长度。这样这个 byte slice 就可以复用原来的 string 数据。

这两个函数其实比较直观,在某些代码场景中可以避免 stringbyte slice 的额外内存拷贝。

接下来我们给之前的例子增加一些难度。我们先看右边的函数调用的例子,再看左边它是如何实现的。右边的这个函数叫 marshalInt64s,从函数签名可以看到它的作用是将传入的 int64 slice 序列化到 dst 这个 byte slice 中,并返回这个 byte slice

在实际实现中,我们用了一个相对 hack 的方法,叫 toUnsafeBytes。对应到左边的代码,我们看到传入的 ts 先判断内部元素的长度,然后将 byte slice 的 header 和 int64 slice 的 header 数据部分进行赋值,同时根据元素长度重置 byte slice 的长度。举个例子,如果有一个 int64,那么 byte slice 的长度应该是 1×8,即 8 个字节。通过这种方式,我们得到了一个新的 byte slice,它引用了原来的 int64 slice 的内存。

这意味着我们可以直接访问 Go 语言在内存中对 int64 slice 的表示,而不需要手动处理大端和小端,或者用算法序列化 int64。我们直接使用了 Go 语言 runtime 的内存结构,将 byte slice 追加到 dst 中并返回。这样做的性能显然要比任何序列化操作高得多。

接下来是一个 unsafe 的反序列化函数,它与上面的逻辑是反向操作。你传入一个 byte slice,然后我们尝试将这段 byte slice 读取为 int64 slice 的结构。通过这种方式,可以以更高的性能将数据读出。

这是我们在新写的数据序列化协议中使用的一部分。这个协议我们起名为 GColumn。我做过一些基准测试,它的序列化和反序列化性能远超 PROTOBUF 和 Influx 行协议。性能上大约是 Influx 的 100 倍,PROTOBUF 的 10 倍左右。当前我们正在将这个协议接入系统,接入后在序列化和反序列化方面应该会有比较大的提升。

前面提到的类型强转相对复杂一些。现在我们来看一个更复杂的例子,不过这个用法并不特别推荐。在某些场景下可以视情况使用。接下来我先讲一下我们做了什么。

首先看左边的序列化部分。我们通过反射获取了原始 struct 的指针,然后通过反射获取这个指针的地址。我们来看框起来的部分,首先获取 struct 单个元素的长度,然后我们进行 unsafe 转换,将它转换为一个 byte slice。这个 byte slice 就是 Go 语言在内存中对这个结构体的表示。接着我们 make 一个新的 byte slice,并将两个 byte slice 进行拷贝。这样我们就把原来的 struct 数据拷贝到了新的 byte slice 中。

需要注意的是,这里完成的是一次浅拷贝。如果 struct 中包含非原始数据类型,比如 string 或其他指针,这些指针仍然指向同一个内存地址。对于这种情况,代码的后续部分(未展示)有一个 for 循环处理这些非原始类型。对于只有布尔值、整数、浮点数等原始类型的结构体,这样的拷贝已经足够了。

右边的部分是我们尝试将这段内存通过类型断言转换回一个指针。在这种形式下,你可以把数据存储到一个 byte slice 中,然后在需要时将其从 byte slice 转换回来。你可以自己管理 byte slice 的生命周期,而不必关心原始指针在堆上的内存什么时候会被 GC 回收。

最后,我们将讨论 Arena。Arena 通常不是在一般项目中使用的,只有在特定情况下才会考虑使用。与 sync.Pool 相比,Arena 的使用要复杂一些。接下来我将为大家介绍 Arena 的一些基本概念。


整理后的内容如下:

介绍完之后,你需要判断自己是否适合使用 Arena。我先要提醒一下,Arena 的使用会非常困难。简单来说,Arena 本质上就是一块内存。当你需要使用它时,可以通过类型转换将这块内存转为对象来使用;当不再需要这些对象时,便可以将这块内存重置(reset),然后再次复用它。实际上,它是在做手动的内存管理,这样你就可以避开 Go 语言 runtime 的堆内存分配和垃圾回收(GC)。

Arena 的使用场景与 sync.Pool 有所不同。sync.Pool 更适合分配和回收时机比较零散的场景,比如你可能随时在分配新的对象,也随时在回收旧对象。在这种情况下,使用 sync.Pool 是很方便的。

但是,如果你需要一次性生成一批小对象,而这些小对象的生命周期几乎同步(即你一次性生成,然后又一次性回收),那么使用 sync.Pool 可能就不太合适了。比如,当你尝试从 sync.Pool 中获取对象时,发现池中没有对象,那么 sync.Pool 需要重新 make 对象;而当你一次性生成很多对象,全部塞回池中时,池的容量超了,它又会删除对象。在这种情况下,sync.Pool 并不高效。这时你可能需要探索一下 Arena 这种方式。当然,前提是你已经尝试过本地缓存、slice 等方法,发现这些方法都不太适合你的场景,这时再考虑 Arena。

在监控系统中,有一个非常典型的场景。假设你有一个 step,比如每隔 5 秒做一次查询,同时你有很多 group,而且查询的时间跨度也比较长。每次查询时,你都需要把所有的数据拆分成很多小的时间片和分组。比如,一个查询需要返回 3000 个点,那么你在内存中需要分配 3000 个临时的聚合对象。每个对象会接收数据,做一些累加或平均等操作。在这种场景下,你需要关注的是:一次请求进来时需要分配几千个对象,而请求结束时几千个对象又要同时回收掉。这种场景下,Arena 会比较适合。

接下来介绍 Arena 的一些现状。首先,有两个非常关键的点:

  1. 你需要关注 Arena 自身的生命周期,以及你通过 Arena 分配的对象的生命周期。如果处理不当,会产生大问题。
  2. Go 官方其实有一个试验性的 arenas 库,你可以通过某些 flag 启用它。但是,这个库目前基本处于暂停状态,原因是 API 设计上存在一些问题,暂时没有新的解决思路。所以,这个项目短期内不会转正。稍后我们会具体说明这个库的问题。

关于 arenas 库的现状,有几点需要提到:

  1. 默认情况下,arenas 分配的内存块比较大。无论什么请求进来,默认先分配 64MB 的内存。如果并发稍微高一点,内存消耗就会很大。
  2. 我们之前的做法是让不同的请求共享同一个 arenas,比如每 5 秒的请求都使用同一个 arenas。然而,这也有问题。单个 arenas 分配的内存无法 reset 或 release,只能等 GC 回收。所以,某些时段的内存占用不是特别理想。
  3. arenas 库不是并发安全的。如果你需要并发分配内存,必须自己加锁,否则会引发 panic。
  4. arenas 库只支持两种用法:一种是 new 一个新的对象,另一种是在 arenasmake 一个 slice。然而,它不支持标准库中的 mapmap 是非常常见的数据结构,但由于 map 的内部结构对用户不透明,它的 bucket 会动态扩容,而这些逻辑都是写在标准库中的,无法与 arenas 库联动。因此,arenas 库无法让 map 的内存分配在 Arena 上。

arenas 库有一个优势(也可能是劣势),即它的内存分配不需要手动释放,数据的回收由 GC 处理。稍后我们会讨论 arenas 库与 GC 不兼容时会出现什么问题。

在开源社区中,有一个叫做 milks 的库,它是目前最流行的 arenas 实现。相较于 Go 官方的 arenas 库,它有几个优势:

  1. 内存块支持线性增长。你可以设定单个块的大小和总块数,当需要分配内存时,它会在堆上惰性分配内存,避免初始化时就占用过多内存。
  2. 支持 resetrelease。它可以 reset 掉分配的内存块。
  3. 支持并发分配。它在 newmake slice 时会加锁。

然而,它仍然有一些问题,主要是它与 GC 不兼容。假设你有一个对象,这个对象通过 arenas 分配,并且它引用了堆上分配的另一个对象。当 GC 扫描时,它不知道 arenas 那块内存上是否引用了堆上的对象,可能会将堆上的对象回收掉,这时你的 arenas 分配的对象就会出问题。也就是说,arenas 分配的数据对堆上的内存是弱引用,无法保证堆上的内存不会被回收。因此,使用时需要特别小心,否则很容易出错。

同样,这个库也不支持标准库的 map。为了解决这个问题,我们开发了一个新的库。基于 Arena,我们实现了一个使用哈希链法的 map。这个 map 的所有数据,包括其内部的 bucket,都是在 Arena 上分配的。这样就绕过了 arenas 库中不支持 map 的问题。

不过,这种方式有一些约束。你在 Arena 上分配的内存结构必须是自己精心设计过的。最好只包含原始类型,比如 stringstruct,但不要包含指针,也不要包含 any 类型的接口。如果你有一个不确定类型的数据,并把它存储为 any,这也是不行的,因为 any 在内存中的表示其实也是一个指向对象的指针。因此,有些类型不适合放在 Arena 上。

使用 Arena 时,你需要非常清楚自己在做什么,一旦出错,可能会出现难以预料的问题。比如,Go 中常见的 bad pointer in Go heap 错误就是指针出错了,而这种错误几乎无法排查,因为它不会告诉你是哪一行代码出错了。

接下来是我们在使用 arenas 时的一些封装与用例。比如,第一张图展示了我们如何 new 一个 LastAccumulator 对象;第二张图展示了我们如何 make 一个 string slice 和一个 map,其中 map 的 key 是 stringvaluetime value。下面展示了向 slice 中追加数据的几个例子。

尤其要关注最后一个例子。在往 slice 中追加数据时,我们使用了一个 clone 方法,该方法将传入的 string 拷贝到 Arena 上,然后再追加到 strings 中。如果你直接使用堆上的 string,在追加后可能会出现问题,因为你引用了堆内存,而在 GC 时 string 可能会被回收掉,导致难以排查的问题。

另外,你还需要关注 append 操作可能会触发扩容。在第二张图中,append 操作可能会在堆上分配内存,而第三张图中的 arena append 会确保在 Arena 上重新分配内存并拷贝数据。这是因为 arena append 内部会检查 cap 是否足够,如果不够,它会在 Arena 上重新分配一块 slice。而第二张图中的 append 可能会在堆上分配内存。

在代码中使用 Arena 时,你需要格外小心。每一行代码都可能涉及细节,比如你是否需要克隆 string,以及 append 操作在哪里分配内存。如果你决定使用 Arena,你必须非常清楚这些细节。


这个内容中涉及到了一些编程技巧和代码优化的思路,尤其是关于对象复用、内存管理和数据流动等方面的探讨。经过整理后,内容如下:


前面我们已经讲解了一些技巧,包括对象的复用方法,通过 sync.Pool、通过 channel 复用对象,还有局部 cache 的复用。另外,还探讨了如何修改 slicecap 以复用内存,以及 slice 的复用方式。接着,我们介绍了一些类型强转的知识点,比如 string 类型的强转方法,以及 slice 之间的强转方式、byte slice 和对象之间的强转方式。我们还介绍了 arenas 的一些用法。这些都是比较直接、简单的技巧,可以直接在代码中应用。

接下来我要分享一些关于写代码的思考,或者说你需要按照这种思路去编写代码,才能更好地管理数据和内存的分配。

一、改造代码结构,使数据单向流动

首先,你需要关注数据是在什么地方被生产的。我可以给大家举一些例子,比如我们现在有一个日志采集器,它需要采集文件中的日志,然后通过 HTTP 将日志发送出去。日志数据的生产点在于你读取文件的那一刻,也就是使用 file.Read 的地方。数据被生产后,它会经过一系列处理,最终在 HTTP 发送完毕后结束它的生命周期。

这里有一个关键点:数据在生产后到结束的过程中,不应该在中间环节分配新的数据或对象。如果中间环节分配了新的对象,它不应该影响原始数据的生命周期。原始数据的生命周期应该是确定的、从生产到结束的流动过程。

举个例子,你可以在数据的起点(文件读取处)分配对象,然后在终点(发送完毕后)将对象放回 sync.Pool 或进行其他复用操作。通过这种方式,你可以在起点取出对象,在终点复用它,尽量减少中间环节对数据的修改或变更。

二、案例分析

1. 左侧案例:读取 Influx 行协议的数据

在这个案例中,我们从请求体 (request body) 中读取数据,然后将其反序列化为 rows 的内存结构。接着调用回调函数 insertRows 来处理这些数据。在这个过程中,数据的生产点是从 request body 读取数据的那一刻。反序列化之后,数据存储在 InfluxRowsslice 中。

中间的环节不应该持有 InfluxRows 的所有权。换句话说,这些环节可以访问 InfluxRows 中的数据,但不能主动回收或者重置它们。分配和回收的责任应由 Influx.Parse 函数内部完成。

如果你需要从 InfluxRows 中获取某个 string,比如 measurement,并且要将其添加到一个 map 中,这时就需要特别小心。直接将 InfluxRows 中的数据放到 map 中可能会导致问题,因为 string 可能会发生变化,导致 map 中的数据出错。因此,如果需要保存这些数据,应该进行一次拷贝。

总结来说,在这个例子中,数据的生命周期是单向的:数据从 request body 生产,经过一系列处理,最终在 Influx.Parse 中结束。中间环节仅持有引用,不持有所有权。

2. 右侧案例:table 的处理

在这个案例中,我们调用了 input.Next() 获取一个 table,然后通过 for 循环对 table 进行处理。在处理完毕后,我们调用 table.Release() 将其放回 sync.Pool 中。

这个案例与左侧的不同之处在于,右侧的代码在 Next() 调用时,完全获得了 table 的所有权,因此在使用完毕后,必须将其释放。而中间的 output.Consumer 函数并不持有 table 的所有权,当它需要 table 中的数据时,可能需要进行拷贝。

左侧和右侧的处理模式有所不同。左侧是引用模式,中间环节只持有引用;右侧是所有权模式,调用者在使用完后需要负责回收对象。

三、数据写入的案例

1. 左侧案例:数据写入

在这个例子中,我们将 metatagfield 等数据序列化到一个 byte slice 中,随后使用 ZSTD 压缩算法进行压缩,最后通过 post 请求发送数据。整个过程非常清晰:我们在某一行分配了一个 byte buffer,并在使用完毕后立即回收。

这个逻辑很清晰:我们知道何时分配内存,何时回收内存,整个数据流向是明确且单向的。

2. 右侧案例:gzip 的错误使用

右侧的代码曾经出过问题,它尝试复用 gzip.Writer 结构体,并使用了 sync.Pool。代码看起来没有问题,输入一个未压缩的 byte slice,输出一个压缩后的 byte slice。然而,这段代码实际上存在严重问题。

问题在于:gzip.Writer 返回的 byte slicetarget 内存,而这个 target 内存来自于 gzip.Writer 结构体。然而,这个结构体在函数结束后立即被放回了 sync.Pool,导致 target 的内存所有权被错误地返回给了外部。此时,另一个线程可能会从池中获取这个 target,并写入新的数据,导致内存冲突。

修复后的代码

修复后的代码不再复用 target,而是直接通过 make 分配一块新的内存。虽然这样做避免了冲突,但代码的内存生命周期仍然不够清晰。content 的生命周期在数据被 gzip 处理后就结束了,未能得到有效复用,而 result 每次调用 gzip 都会分配一个新的缓冲区,这增加了不必要的内存分配和 GC 压力。

四、总结

正确的做法是:如果你的函数需要分配内存,那么缓冲区应该从外部传入,而不是在函数内部分配。这样可以确保内存的分配和回收在整个代码结构中是清晰的。例如,gzip 函数的签名应该类似于 ZSTD 的接口,结果缓冲区和原始数据都由外部传入,函数内部只进行 CPU 操作,不进行内存分配。

这样可以通过 sync.Pool 或其他机制来有效管理内存,减少频繁的分配和回收,提升性能。


这就是今天的分享内容。如果大家有任何问题,特别是想使用一些高级的内存管理方式,比如 arena,可以随时找我讨论。

谢谢大家!如果没有问题,我们就结束今天的分享。



请举例说明arena的内存管理方式。

arena 是一种内存管理技术,主要用于减少频繁的内存分配和释放操作,进而提升性能。它通过一次性分配大块内存(称为 arena),然后在这块内存上进行对象的分配。所有对象都位于同一个内存区域内,直到整个 arena 被释放。这种方式避免了频繁的垃圾回收(GC),特别是在有大量短生命周期对象的场景下非常有用。

使用 arena 的典型场景

  1. 批量处理:当程序需要处理大量对象并且这些对象的生命周期一致时,可以使用 arena。在这种情况下,所有对象会被分配到相同的内存区域,处理完后一次性释放整个 arena

  2. 减少GC压力:在多次小对象分配的场景下,频繁的内存分配和垃圾回收会成为性能瓶颈。通过 arena,可以避免每次分配时触发 GC,并且延迟内存释放,直到 arena 被显式释放。

  3. 复杂数据结构的构建:对于某些复杂的数据结构(例如树、图等),可以将所有节点分配在同一个 arena 中,避免大量的独立分配操作。

举例说明

假设我们有一个批量处理任务,每次需要处理大量的数据对象,这些对象的生命周期都很短,而在处理完后这些对象就会被丢弃。我们可以使用 arena 来提高内存分配和释放的效率。

Go 中假设的 arena 示例

Go 本身目前还没有官方的 arena 支持,但我们可以假设有一个类似的库或者机制。以下是一个简单的示例,展示了如何使用 arena 进行批量对象分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"fmt"
"arena" // 假设有一个 arena 包
)

// 假设我们有一个简单的结构体
type MyObject struct {
ID int
Name string
}

func main() {
// 创建一个新的 arena
myArena := arena.New()

// 在 arena 中分配 10000 个对象
objects := make([]*MyObject, 10000)
for i := 0; i < 10000; i++ {
// 通过 arena 分配对象,而不是使用 `new` 或 `make`
objects[i] = myArena.New(func() *MyObject {
return &MyObject{
ID: i,
Name: fmt.Sprintf("Object-%d", i),
}
})
}

// 处理这些对象
processObjects(objects)

// 处理完成后,释放整个 arena
myArena.Free()

// 此时,所有分配的对象都已经被一次性释放了
}

func processObjects(objects []*MyObject) {
for _, obj := range objects {
fmt.Println(obj.ID, obj.Name)
}
}

解释

  1. myArena := arena.New():我们创建了一个新的 arena,这块区域将用于分配所有的对象。

  2. myArena.New():在 arena 中分配对象,而不是使用 Go 的常规分配方式(如 newmake)。这样做的好处是,所有分配的对象都位于同一块内存区域中,减少了独立的小块分配和回收的开销。

  3. myArena.Free():当批量处理完成后,我们可以一次性释放整个 arena,而不需要逐个回收对象。这极大地简化了内存管理,并且避免了 GC 的频繁触发。

优点

  • 批量分配和释放:所有对象都在同一个 arena 中分配,处理完成后可以一次性释放整个内存区域,减少了 GC 的开销。
  • 内存局部性好:由于所有对象都在一块连续的内存区域中,可能提高 CPU 缓存的命中率。
  • 减少锁竞争:如果在并发环境中频繁进行小块内存分配和释放,可能会引发锁竞争。arena 可以减少这种竞争。

缺点

  • 生命周期一致性arena 中的所有对象必须具有相同的生命周期。如果某些对象需要更长时间的存在,无法将其单独回收。
  • 内存浪费:如果 arena 中的部分对象早于其他对象结束生命周期,内存也不能提前释放,可能会造成内存浪费。

实际应用场景

  1. 批量日志处理:假设我们有一个日志系统,每秒钟会收到大量的日志条目。这些日志条目需要被处理、发送到远程服务器,之后就会被丢弃。使用 arena 可以在处理时批量分配内存,处理完一次性释放,减少了频繁的 GC 开销。

  2. 批处理系统:在数据处理系统中,通常会有批量数据处理的任务。每批数据处理完后,这些数据就不再需要。可以使用 arena 在同一块内存中分配所有的数据对象,处理完后一次性释放。

  3. 高频率的短生命周期对象:在某些系统中,会频繁创建和销毁大量短生命周期的对象,例如网络请求的处理对象、临时缓存等。使用 arena 可以有效减少 GC 压力。

总结

arena 是一种适用于批量、短生命周期对象的内存管理方式。它通过集中分配和回收内存,减少了频繁的垃圾回收开销,特别适合高性能场景。不过,使用 arena 需要确保所有对象具有相同的生命周期,否则可能会导致内存浪费或其他问题。



原始字幕:

好的那那个嗯我我就开始了, 那个嗯, 今天给大家分享的内容是那个呃, go浪的性能优化哦, 那个呃在嗯过去的一两年里头, 然后呃我写了很多的那个go的代码, 然后嗯其实积累了不少的经验, 然后最近呢其实是发现嗯我们call do, 然后包括data kit项目在呃性能优惠, 在那个性能上都遇到了一些问题, 然后我在呃, 我就尝试着把我呃的一些一一些一些理解, 然后尝试嗯总结了一下, 然后跟大家一起做一个交流, 呃首先我们来看嗯, 第一个就是性能优化的第一步, 应该就是其实就是先直接去看那个PPUTH的呃, p p p pop的profile的那个呃文件的火焰图啊, 这里其实是我们拿到的一个code叉的那个profile呃, 这个profile其实非常的典型, 呃, 这里呃我们从那个火原图上, 然后呃可以看到, 其实呃这里那个性能呃, 有37.6%的时间, 都在那个run time的MALLOCGC上啊, 其中在麦lock gc的那个呃, 下面中间左边有一部分, 大家可以应该可以看到是那个GC抖瑞啊, 这个是那个GC再去呃, 就是当他当那个rn time想要去分配内存的时候, 他发现那个内存不够了, 然后他又重新去S干那个堆上的已有的内存, 然后尝试去回收哦, 这里头就是总共是37.6%的CPU, 都耗在呃, 那个分配新的内存和回收旧的堆栈上的呃, 那个堆上的内存, 那我们我这里再看, 我们再看一个case, 就是呃我们现在的那个观测insert的profile呃, 我们其实那个MALLOCGC的开销, 其实是可以把它优化下来的啊, 然后观测insert现在是呃, 这是杭州的, 杭州, 大概是现在每秒吞吐应该是实际BPS的数据, 我们现在总共也就四个实例, 然后呃, 就等于是所有全量的那个SARS的用户的数据, 采上来, 然后经过我们观测insert做处理, 然后我们里头也有一些行协议的呃, 文本的解析, 然后有一些那个行数据的一些呃, 简单的一些预处理, 然后一些字段的判断呀之类的, 然后我们再把它序列化成那个JASON, 然后发给那个DORIS, 然后中间我们可能其实自己内部, 还有一个那个呃文件的队列啊等等, 这些逻辑加起来其实都没怎么分配内存啊, 所以我们可以看到就是code x这种未经优化的呃, 未经优化的一般的应用, 就是大家的普通的写法, 其实是会造成非常多的那个呃内存的分配的, 开销的啊, 那其实我们就呃我们其实换一句话说, 其实我们如果要做呃go浪的性能的优化的时候, 其实大家只需要关注一个事儿, 你就盯着内存, 盯着内存的分配和盯着内存的回收就好了, 其实你不太需要去关注呃, 你程序中的某一个地方的算法是不是最优化, 其实我们现在大部分的那个后台应用, 或者说我我们当前遇到的这些可观测的场景, 其实主要是在处理数据呃, 其实主要的开销应该还是在内存上, 我们应该先把目光聚焦到内存的处理上, 这里我们再给一个呃一些数据, 这是我在网上找到的一个图, 就是大概的意思, 就是呃在过去的这一段时间中呃, 然后CPU的那个性能的提升是很快的, CPU的性能提升, 大概可能是每年60%的一个性能提升, 但是内存的存存取的性能, 其实提升速度是没有那么快的哦, 那换句话说就是大家的程序可能写出来之后, 你的CPU跑的很快, 但是你的那个因为它会受, 他会受受到那个你的那个呃总线带宽, 或者是你本身的那个呃, 内存的那个频率之类的影响, 就导致你的那个呃内存的读写带宽, 其实是跟不上你CPU去存取数据的速度的哦, 那中间的这个gap就叫那这个就叫内存墙, 那回想到我们刚才看的那个profile, 其实大家应该也也要能意识到, 就是根本的原因其实是内存的那个速度增长, 比不上CPU的增长, 所以在我们自己的内存中, 如在自己的程序中, 如果没有, 如果你的那个呃代码不经优化, 那么你的那个大就会有大量的时间片, 就耗在那些分配内存和回收内存上, OK好那我们今天主要的那个内容呃, 应该是分这几个部分呃, 呃应该是主要分三个部分, 哪些地方的内存分配是有害的, 就是你需需要你培养一种感觉, 就是你一看这个函数, 你就知道哪里有问题, 第二个部分就是教大家一些呃, 内存复用的方法啊, 比较常见的, 比如说对象怎么来复用, slice怎么来复用呃, 还有一些稍微hack1些的方法, 就是你可能怎么去复用, 然后那些hack的方法呃, 有的会有一些门槛, 我们等会儿可以来一起来看呃, 中间的这部分其实是一些技巧, 一些代码中的编码的技巧啊, 这些呃应该是你学你学到之后, 你立即能用上的, 最后一个其实我觉得是最最关键的, 叫生命周期的管理, 就是这个可能就是呃, 会涉及到你的代码的组织的思路, 就是它会影响你去怎么设计你的那个代码的, 那个数据流, 这个东西你选就是这个东西, 我们最后来讨论它是一个呃方法嗯, 好那我们先看第一个呃, 就是我们需要关注到你代码中的一切的, 影视的内存的分配, 那呃我给大家一个呃, 就是一个非常简要的一个原则, 就是当你看到一个函数, 你没有主动的传入呃内存的那个, 比如说你没有主动的传入一个slice切片, 你没有主动传入一个什么数据的时候, 它给你返回了一个字字符串, 给你返回了一个切片的时候, 那这个时候这个函数的内部, 一定是有内新的内存分配的, 那而且就是那它有内存分配, 那么分配完了之后, 就是他是主动返给你的, 然后呢嗯你返给你了之后, 然后你如果是没有正确的回收, 他其实他不会给你接口, 让你去回收它的, 所以就它分配内存给你, 然后你拿来用完了之后, 你其实就呃嗯放在那, 然后等GC来回收, 那就是就会出现我们刚才看到的那个问题, 大量的时间都会花花花花花费在那个分配上, 那这里我们我们可以可以看一下, 我们这里的几个case, 第一个case叫IO read w, 这个方法就是呃很好用对吧, 呃, 我丢进去一个IORADER, 然后我直接我就能把里头所有的数据读出来, 然后读出来读到一个re呃, result bts的一个呃slice里头对吧, 这个这个方法是很好用, 但是我们要知道, 他一定是在内部去make了一个新的slice, 这个内存会分配到堆上, 而且我们还要注意到, 其实呃呃你你可以你可以自己去追进去, 那个IRIDER的方法, IO read word里头去追进去, 你会发现它有一个循环, 他会先呃它的初始分配, 大概是一个500多个字节, 好像是, 然后呃读出来数据读满了之后, 它会那个重新扩容, 这个切片扩容了之后呢, 再重新读, 读满了继续扩容, 就是它在不知道你的原始数据有多大的时候, 他可能会去进行多次的分配, 那如果是进行多次分配, 其实我刚才可能第一遍说的, 我说他会在那里面分配一分配一一个内存到data, 顺到堆上的这个问题可能会更严重了, 就是它可能会分配多次, 然后那多次扩容之前的那些原始分片, 可能也要去等待GC的回收, 就是那就是如果说你就是像这种I/O read wor, 那如果你在一些你的代码的, 一些密集的数据处理流程上, 你你有一个就是你你的那个代码的主要逻辑上, 你就是比如说你有一个循环, 或者说你要经常用这个代码的话, 就是一些热的处理路径上, 你你要经常跑这种函数, 那你一定要注意你, 它就会是你的那个性能的杀手, 它就会是你性能下降的最主要的来源, 那这里其实是我截到的cod里头的一个, 好像是呃去读那个backup日志的一段代码, 那这个时候其实我们可能要去read很多个文件, 然后你raid出来的内存其实呃就分配到堆上, 然后一方面是把那个你自己的程序的那个内存, 打高了, 另一方面是本身它也会分配的时候, 他也会占据额外的CPU, 像这种路, 像这种case, 你其实是要警惕的, 第二个是呃这里的呃, 右边这里有一个string的函数, 它是一个escape, 就是呃我传入了一个转译呃, 传入一个原始的那个字符串, 它可能是经过一些引号转义的, 就是比如说单引号转移一个空格, 类似这种东西, 然后呢呃它返给你一个原始的字符串, 把那些转义字符去掉啊, 这个函数呢呃它这个escape呢, 它是使用那个strings呃, 标准库里头的那个REPLIER呃, 那个那个结构体来做的, 就是你这个标准库的方法啊, 看起来这个函数人畜无害, 但实际上string是那个呃string它是不可变的, 那换句话说就是你丢进来一个死卷, 他在这个escape的过程中, 他重新make了一个相同大小的死卷在堆内存上, 然后呢呃你用完了之后, 然后你原始的字符串你可能也不用了, 要等待回收, 然后他新的呢又给你分配了一个, 然后呢, 第一方面是你的那个你的代码中的那个堆, 上的内存可能double, 第二个是呢还还是刚才说的, 也有CPU的开销, 那我们看第三个case是JASONMARSHALL这个方法呃, 跟刚才的I/O read work其实很像, 就你丢给它一个结构体, 它返给你一个better slice, the better slice哪来的, 它分配的分配到堆上了, 然后最后一个case是呃我们的那个嗯point库, point库里头的一段encode方法, 那这个其实我按我们刚才的原则, 其实你也看到, 就是很显然嗯它分配了内存, 然后这个内存给你了, 然后他嗯这个内存肯定是没有办法被复用的, 就是它也会分配到堆上, 就是这些函数它只要是给你反了字符串, 反了切片的, 你就要警惕它, 它内部可能有内存分配, 它会是你的性能下降的一个主要的问题哦, 那这里我可能还要补充一点, 就是你可能还需要更关注一点, 就是字符串, 字符串它是不可变的, 也就是说呃切片我们都能还能想办法来复用, 但是字符串是很难复用的啊, 就是如果是你在处理字符串的时候, 你发你可能要注意, 就是你的每一个操作, 都可能会产生一个新的字符串, 你的字符串拼接format, 都会把原来的那个字符串嗯, 其实没用上, 然后你又生成了一个新的, 这些新的都会到对内存上, OK好, 那这就是我们呃第一个部分, 就是我们大家可能要去呃, 从从现在起, 大家在写代码的时候, 可能你就要你就要意识到你现在写的这个代码, 你现在调的这个函数, 它是不是给你分配内存了, 他是不是分配到堆上了, 他要是分配内存了, 那他可能就会影响你的程序的性能, OK好, 那我们下一趴呃, 那这里呢我们那个介绍从从现在开始, 我们来介绍一些呃常见的呃复用内存的方法, 就是那我们就是避避免去做一些分配嘛对吧, 就是我们可以就应该是, 要把一些已经分配好的内存, 那反复的用就好了对吧, 这里我们呃从现在开始, 我给大家逐渐逐逐步的来介绍, 一一系列的一些手段哦, 那第一个就是SINGPO啊, 这个其实呃呃就是你你你可能已经用过了, 你可能也已经用到了, 正在使用了呃, singer put呢, 它是呃一个对内对内存上的一个池子, 可以这么理解, 就是它会随你的垃圾回收, 你就是那个系统GC的时候, 他会把这个池子里的数据去清空一遍, 但是在没有GC的时候, 那个池子里头他有一定的那个配额吧, 类似于你可以放进去一个内存, 然后他是没GC, 你就可以重新取出来再用, 那嗯这个东西呢就是其实是很好用的, 但是呢就是我可能需要就是嗯强调有一个点, 就是你要复用的是你对象持有的内存, 而不是只复用一个对象结构体的那个壳子嗯, 你有时候你想要复用的东西, 你可能没有复用上哦, 哦我我我我我这里截图可能没截出来, 我前我昨天我在看那个point库的时候, 我发现那个point库的那个呃有有一些复用, 有一些那个复用呢其实复用的比较浅, 就是他可能只复用了一个壳子,

p2:

就是那个结构体上面, 它可能比本身那个结构体上面它并没有数据, 它只有一些其他, 比如说一个呃好像是encoder那个结构体吧, 它只有一个encoder的一个函数, 它引用了另外一个函数, 然后它自己上面什么东西都没有, 那你复用这个壳子, 其实嗯嗯就是效率很低啊, 那这里有有有有两个正面一点的case, 就是呃比如说像这个呃TX的破, 就是上面肯定是有一个呃TX的一个slice, 然后当你复用的时候, 你其实是希望你的这个text的slice, 不要去反复的去申请内存了啊, 你是要复用它, 这里也有一个一些呃rose的一个结构体, 就是那你在嗯进铺之前或者是从破拿出来之前, 拿出来之后, 你对它上面所有的数据, 对这些slice都去进行一个重置啊, 那你这时候你可能要关注的就是你复用了呃, 这上面的结构体上面的这些嗯字段, 而不仅仅是那个外面的那个外层的壳子, 那个外层的壳子, 它占用的内存可能其实很少, 可能你有很多东西, 假设假设你你像左边的这个破的这个text, 这个结构, 你要是没有正确复用这个TX, 那你的这个复用可能是失败的好, 那我这里在那个singer put的基础上, 我们补充一个呃一种用法, 就是用channel来池化一些数据啊, 左边是一个呃经过泛型的一个布的, 那一个一个一个用法吧, 嗯就是这个代码很简单, 大概的大概最终的效果呢, 就是呃破那个原始的那个singer, 破它保存的数据会在GC的时候会被释放, 呃, 如果说你的那个你希望那个破, 它持有一些固定数量的一些对象的话, 你可以呃你可以同时的来使用一个channel, 然后把那个数据放在channel里头来保存啊, 左边的这个代码应该就看明白了, 比如说像右边哦, 这里是一个实际的一个用例, 就是那我在代码中, 我可能会有一个CPU核心数的一个并发, 那我可能至少是我的这个嗯至少是这个结构体, 我要在我的P里头保存, CPU的这个核心数的这么多个数啊, 多的时候他可能嗯burst一下呃, 突突发一下, 多多分配几个, 但是呃用完了之后可能就回收掉就好了, 那这个时候你可以用这种方式, 这种小技巧来提升你的那个缓冲的命中率, 不至于每次GC的时候, 你的破里头是空的好了, 前面我们介绍了那个sink put的那种呃, 复用的方法嗯, 但是think of, 但是呢大家也要意识到我们使用think kpool, 其实是为了去复用那个对象, 但是呃复用对象不仅仅只有single psingle, put的访问是有开销的, single put呢它是一个全局一致性的一个缓存, 就是它为了保障你的多个gal team的并发访问, 它是并发安全的, 它为了保证你的并发访问, 它内部是有一些呃读屏障, 写屏障的一些设计, 然后它有一些指令重排, 然后他保证你多线程读到的数据是一致, 一致一致的, 就是这些设计, 它是其实是有额外的性能的开销的啊, 就是你当你访问那个全局的铺的时候, 它其实呃比你访问一个本地的一个变量, 的开销是要重的呃, 那这个时候就是你可能需要关注到, 就假设呃你其实是可以补充一些设计, 然后来通过局部开启的方式来来复用你的对象, 比如说第一种呃, 很简单, 就是你在循环外去呃, 设置一个变量, 然后你在循环的时候, 你每次循环你把它去reset掉, 那这里在左下角, 我这里有一个case, 是一个呃current accumminator的一个对象, 那我在每次我要用它的时候, 我就把它这个结构体reset掉, 然后我正常用, 用完了我再reset, 那这样的话我就其实呃我还是在复用这个object, 但是我可能不是通过single put的形式嗯, 第二个case呢是呃一个全局的一个context啊, 就是我像像现在观测dB中呃, 我有那个很多的算子啊, 我有很多的函数, 然后那些函数呢有很多函数, 它在计算的时候, 它需要一些临时的slice, 临时的变量, 它存储一些非常临时的结果啊, 就是最终数据它肯定不存在, 这但是它需要一些临时的切片, 这个时候呢我其实是有一个全局的一个context, 他从请求的请求进来的时候就会给他分配, 然后你在每一个算子处理的时候呢, 他都可以直接从这个context上的buffer上面去啊, 直接拿来就用, 用完了之后呢, 它会随着整个context来复用哦, 就是你其实是你可以自己在你的那个项目中, 做一些类似的设计, 就是它不一定是全局的, 它它也是一个呃嗯, 它也是一个相对局部的一个一个变量, 就是它可能不跟你的函数作用域挂钩, 但是他可能跟你的请求的生命周期啊, 或者是一些呃数据的处理流啊, 就是呃中间的一些流程挂钩啊, 你可以放在这种东西上, 就是你的catch可以放在这种东西上, 第三个呢, 就是你可以把你的对象, 放在你要处理的那个结构体嗯, 就是你结构体上可能挂了一些函数嘛, 你可以在结构体上的某一个字段上挂一些呃, catch的变量, 比如说右下角这里有一个storage的一个呃, 一一个结构, 然后呢, 我在storage那个结构上挂了一个呃previous的ACCMINATOR, 这个这个slice, 然后其实我在用完之后, 我也是把它reset掉了啊, 我是把它clear掉, clear掉了, 然后重新把它放回storage上, 那就是我下一次循环, 我要重新走到这个逻辑分析, 逻逻辑处理分支的时候, 其实我又重新把它从那个store上取出来呃, 就是呃大家应该是要呃意识到, 就是你应该要尝试去在你自己的代码结构上, 去去存取存储一些这种临时变量啊, 它不一定全是在single put里头, single put的访问效率没有那么高哦, 就是我们呃这两天其实是对那个呃观测dB, 做了一些嗯性能的优化, 我们把有一些从那个singer put取的变量, 放到了这种局部的呃上下文里头, 然后性能提升大概可能也有个就是这一个改动, 大概性能提升可能有个二三十%好, 那我们接着来介绍整个slice的呃复用, 那我们先可能在介绍slice的复用之前, 我们先过一点点基础知识, 就是呃史莱斯的这个呃, 它的那个结构是怎么在go浪的内存中, 是怎么表示的嗯, SSLICE呢它有那个呃斯斯莱斯, 它在go浪里头, 它是有一个slice的header的这么一个结构, 在对内存上嗯存储的, 然后第一个呢就是呃第一个元素呢是一个呃, 而是一个指针, 它会指向一一片那个堆上的内存, 然后那一块内存是真正存储数据的啊, 就是下面的这个array, 就你你分配, 比如说像我们下面的这一个case, 是你make一个int的一个slice, 然后他的呃nth是四, cap是六, 那么我们就会在堆上去分配一个六个元素的, 六个int呃, 呃每单个英特尔是八个字节, 然后呢就462 14, 然后各呃字节这么个长度的一个呃内存, 然后它分配在堆上, 的这么一个结构, 然后我们把第一个指针指向那个对内存的地方, 然后后面是两个int存储呃, 这一个切切这一个slice切片的LSE和cap哦, 那其中呢就是我们的认识会决定于, 会决定这个咳咳, 这个slice的哪些部分的数据对我们是可见的, 那额是他的那个cap呢, 就决定于这决定这个array的原始的那个数据的, 上限的大小, 那额我们来看一些slice的一些基础的用法哦, 那左边这里我们可以呃先简单过一下, 第一个呢是呃, 我们分配了一个int int的一个slice, 然后呢第二行呢叫clear, 这是呃, 我大约记得可能是在1.20还是1.21呃, 刚加的一个clear方法, 然后这个clear方法呢, 是把这个slice内部的元素都重置到那个呃, 重置到那个凝脂啊, 他跟它跟你的那个元素的类型有关, 比如说如果是int的话, 它的零值就都是零, 那我肯念一下的话, 这个int死的slice内部的数据就全是零, 但是它不改变那个数据的呃, N他只他只是把那个底层的那个, array的那个数额, 就是到你的那个N死的这一段的那个数据啊, 全部呃元素清零了, 然后第三行呢就是这是一个呃改变这个嫩死的, 改变这个slice的一个呃认识的一个用法, 就是用冒号后面接一个数字, 这个数字呢代表你会重置这个呃, int死的一个nth, 把它重置到几吧, 这里就是其实是取的是从0~0, 那么它的nth就变成了零, 那也就是说你你就假设哦, 我这里的那个PPT可能准备的不太好, 就是假设我们现在这里不是零, 我把这里的那个nth写成了四哦, 那这里int死的认识就变成了四, 然后呃虽然我们最开始初始化的时候, 它它的nth是零, 但是我们现在那我们重置之后, 这个int x它就N水就变成四, 中间前四个都是直接把, 等于是把那个指针挪到了那个, 你能现在能可见那个六六个元素, 中间前面的四个了, 但是呃中间都是零值哈, 然后第四呃, 然后再在下面有一个case, 是我append数据到这个int里头去, 然后呢, 呃右边是我把这个int x先把它NSE制成零, 然后又往里头append123, 那这种写法就其实你是在尝试去复用, 这个int死的这个内部的元素, 就是我现在其实这一行大概你可以表达, 可以可以理解他的意思是, 我现在不管这个int死里头的认识有多长, 我也不管他之前的数据是什么, 我把它认识重置到零, 然后我把我的123重新加进去, 那这个时候呢, 就是你等于是覆盖了之前这个int原始索引的, 那些数据了啊, 然后下面有两个额, 这等于是其实是两个, 类似于两个函数那种东西吧, 就是当你的slice的cap不太够了啊, 比如说你现在我嗯就是其实也不叫不太cap, 不太够了, 就是你现在你有一个slice, 然后你现在想往里头加100个或者是十个, 我这里举的例子是十个, 但是你实际场景中可能是更大一个数, 比如说你要往里头加100个或者是1000个数据, 但是呢你如果直接加的时候, 你的slice如果本身的cap比较小, 它可能会经过反复的几次的扩容对吧, 呃不太够它扩一倍, 然后你就如果你就一直往里头append数据的话, 它不太够, 他就扩一倍, 不太够就扩一倍, 但是它可能会反复扩几次, 这会会导致额外的对内存的开销哦, 这个时候呢你可以尝试, 其实是你是可以尝试呃, 我们把它的容量一次性扩到位啊, 那这里我我我有一个简单的一个呃, 这这这种方式, 比如说我们一次就来判断你要加的呃, 数据是多大, 然后他现在的那个nice是多少, 它的那个总共的cap是多少, 然后你一算还差几个, 然后你就make一个新的, 你就只分配一次, 然后append进去, 这样你的int死的那个容量就够了啊, 但是你可能它就可以避免那个反复的扩容啊, 然后像呃我们刚才在, 哦分子啊, 那是slice, 我们我们先看下面吧, 然后这里呢是类似的, 就是如果你你想要往你投呃, 你你想要这个slice呢有一个固定的size, 比如说你你现在要用它, 然后你可以比如说你现在要用一个slice, 它你希望他的size是十个, 然后呢比如说你要根据索引去访问你的它, 它里头的数据, 那这个时候呢, 你应该是可以直接去先去判断它的cap够不够, 如果cap够呢, 你直接用那个呃冒号后面加N加你要的size, 这样就可以取到那个对应大小的那个数据, 然后否则的话你可能要make一个新的啊, 那这种方式呢, 就是你可以直接拿到一个, 你预期的size的一个slice, 那我前面这里有一个case, 在右下角这里, 我们从那个storage去取那个previous accumminator的时候, 我我这里就用到了一个encoding, YOUTUBE的一个reset的方法啊, 这里其实就是我预期我想要一个呃, 我后面那个呃aggregate function, 是这么个长度的一个slice啊, 我也不知道现在这个previous accriminor, 长度够不够哦,

p3:

那我就呃我我用reset方法我处理一下, 那这个时候我就拿到了一个定长的一个slice, 然后就是可以避免一些反复的扩容, 然后呢这这两个方法是不呃reset recap是呃, 我我觉得是比较好用的工具方法, 然后前面呢主要是有一些你要去手动去操作, 它的认识的呃, 这样一些嗯, 这这种语法, 希望大家也要能够相对熟练的来掌握啊, 然后再就是啊, 有些时候当你复用的时候, 你可能需要重置内部的数据, 你可以用clear来重置数据, 啊这里可能要注意的就是假设比如说像第三行, 那我第三行这里我们把他的LTH制成零了, 但是他那就是我假设是直接调的, 这样这样调的, 它内部的array上面的数据是没有被清空的, 就是我们只不过是让那个呃, 后面的数据不可见了啊, 可能这是他跟clear的差异, 一个是重置的那个嫩死的那个指针的位置, 然后一个是重置内部的数据了, 好, 那这里我们看一个呃稍微复杂一点的一个例子, 就是是slice内部的对象要怎么来复用呃, 左边这里有一个结构的一个定义, 叫一个text的一个STRACT, 然后上面呢有一个呃tag的slice, 然后那个tag上面呢有两个key和value呃, 有有有两个key和value的呃, BT的slice啊, 这这是一个嗯, 就是应该还是一个比较常见的这么一个结构, 那这个结构呢比如说我们现在要复用它, 我们要复用这个text这个结构对吧, 那我们不想要每次去呃, 分配一个新的tag进来对吧, 那右边是呃, 右边是这个代码, 然后它呃这这里的用法呢是啊, 比如说我们要往这个text上面去添加呃, 这个KV的时候, 我们应该我们可以先去判断它的cap, 如果他的cap大于嫩死, 那么我们就可以把它嫩死往后移一位, 这个时候呢你的那个呃, 这个时候呢就说明, 其实你的那个堆上的那个array的, 原始的那个内存其实是够的, 只不过是有一部分数据没可见, 那你把它弄死往后走一位呢, 这个时候那个tag你就可见了, 你就可以拿过来用了, 那否则的话那就直接往往里头去append一个tag呃, 那个else分支比较好理解, 那前面这个分支呢, 其实是我们刚才应该是看过的一个重置, slice length的一个写法, 那我们看紧接着看下面这个if else, 过了之后呢, 我们用了一个取地址, 把tag把tag取出来, 然后取出来取出来了之后呢, 我们直接往那个tag的key和value上去, append的数据, 那呃这样这样一顿操作下来呢, 就是你会发现我们的tag就是正常情况下, 假设那个TX的tab是够用的, 情况下, 就是假设走前面的分支, 那这个时候呢这一个A的tag, 我内部我们是没有分配新的内存的, 这个tag对象其实是已经在堆上已经分配好的, 那个它只不过之前没可见, 我们现在让它可见了, 然后我们把它复用了, 而且呢这个key和这个key和value呢, 我们也是直接把它拷贝到了那个tag的KV上面, 然后我们拷贝到那个tag的key的时候, K的K上面的时候呢, 其实就是我们刚才应该也看过的, 那个append的到一个slice上面的时候呢, 我们直接把这个slice的那个NENSE先置为零, 然后直接把数据open上去, 这样的话就等于我们把那个嗯, 我们用我们新添加进来的这个key, 覆盖了原有的tag上的K的数据, 那这个时候呢, 就是我们发现第一tag结构体复用了, 第2tag上面的key和value的那个slice复用了, 那这一个函数一顿写下来, 我们一一个一块一块内存都没有分配, 那这个就呃, 达到了一个非常好的一个复用的一个效果, 但是呃大家可能需要去呃, 熟悉和理解这样的一些操作, 好我们再看再来看一些case, 再来看一个case叫slice的slice, 这个可能稍微高级一点, 我们先看左边的结构体的一个定义额, 左边结构体的定义呢是呃跟刚才有点像, 它是一个呃单呃, 这个结构体呢是roll, 它是一个呃, 单个in flux的一个行协议的这么一个抽象啊, 就是上面有一些呃, 有有一些时间戳, 有measurement, 有tag和field, 然后呢, tag field呢里头都是存的一些存的KK和value啊, 就大概是这些东西啊, 那个field上面, 现在它没有直接存那个原始的数据类型, 它是存了一个啊escape的一个STR啊, 这个这个这个这个也不重要, 这个也不重要啊, 哦哦大概左边的是这样一个结构, 那当我们想要复用这个呃roll的时候, 我们可能会意识到这里会有一些问题呃, 我举举一些例子, 比如说第一个这个tag上面的K和V都是string, string是不可变的, string没法复用, 那我新创建一个string跟, 就是我就就是它就是一块新的内存, 它就是复用不了, 对吧啊, 这是第一个问题, 第二个问题呢是不同的肉的tags和fields, 长度是不一样的, 比如说我有的肉, 我可能那个我的tag很少, 比如说两个tag或者说field的很少, 或者是三个三个field, 然后呢我有的roll他的那个text和field会更长一点, 比如说30几个到四五十个, 那这个时候呢, 你你如果你把这个rose, 直接放到你的那个single po里头, 然后你每次来数数据进来, 你从新INGPO里头取出来, 取出来了之后呢, 呃你那个你你你reuse它, 像我们前面刚才就大概可能类似这样的方式呃, 数据那个cap不够, 你就往里头append的数据对吧, 往里头append一个新的tag, 这样会导致有一个问题, 就是呃你的所有的肉, 所有的肉对象上面的tags和fields的长度的, 那个cap都会被拉伸到那个你的那个项目里头, 一些最大的那个TX或者是field的那个长度, 就是所有的都会被一些极极端的case来撑大, 导致你在复用的时候, 其实你的效率不一定很高, 大家本身原原来的这个text可能参差不齐的, 但是呃你在你的堆内存里头, 复用的那个对象的那个呃, TX的长度都都拉大了, 这样会造成一些浪费, 嗯好那我们现在有两个问题, 那这其实就涉及到了slice的, slice要怎么来复用呃, 右边是呃, 一个解决方案, 一个办法就是我们抽象了一个rose出来, 这个东西呢, 我们可以把它理解为一个呃, 一个单个请求的一个batch呃, 就是这一个batch呢里头包含一堆的肉啊, 它是一堆的行, 然后呢下面有下面这三个结构是最, 我是我们现在更关更关注的更关键的一个结构, 它是TX的破field铺和single p好strings的破, 这里你看到这, 你可能已经知道它在我们现在是怎么用的了哦, 我们实际上呢是呃左边, 比如说单个的roll上面持有的tags, 其实它只持有了一个text铺上面的一个子的切片, 比如说我们, 那我们正常的按MUSHU的流程呢是什么样子呢, 我们先把数据按MUSHO到text库里头上, 数据先统一append到text库里头, 然后呢我们再把TEX铺里头的那一段, 我们正在用的这一段, 这这这这一段内存, 比如说大概可能是我现在随便举个例子, 它的offset可能是从啊510, 那510的这一个子的切片, 把它赋值到这个roll上面啊, 那个field的类似, 然后那这个时候呢, 这个rose其实上面我们要复用rose, rose的时候, 那这个rose上面其实呃他的数据是嗯, 原始数据是来自rose上面的这个铺上面的啊, 那其实我们就不用复用它了, 我们我们最后在复用的时候, 我们就整个batch来考虑就好了, 这样的话你的那个复用效率就可以提升啊, 同时我们要还要关注到这里有一个, 这里有一个case, 我们可能稍微晚一点再把它讲的更清楚一点, 现在我们先大概聊一下, 就是这个tag上面的K和V啊, 呃tag上面这KV它是string啊, string是没法复用, 所以实际上我们复用的时候, 是我们先把所有的tag的那个, 我们从那个网络协议HTTP里头读到的数据, 先append到string pool上面, 然后我们从string铺上面, 比如说往string铺里头去, 我我读到了一个hello, 然后这是五个BT, 然后我把这五个BT直接写到这个string库里头去, 然后我们就拿时间铺里头的, 这后面最后的这五个字节的这个BT, slice的一个子的切片, 用n safe转换, 把它转成了一个hello的字符串, 然后把它放到tag上去, 那最终的效果呢就是呃最终的效果呢, 就是我们所有的数据其实是存在这个batch, 这个rose上面的, 然后呢在roll的这个结构上面, 包括这个tag的结构上面, 持有的都是对啊, rose这个bags这些铺里头数据的引用, 我们可以啊这么来理解它哦, 那这样的话就是我可能我一个一个re, 一个呃一个请求进来, 然后我read一次, 你会发现我所有的数据全部是复用的, 我我我的那个所有的数据都会存在这个rose上面, TX铺啊, field铺啊, spring put上面, 然后我数据都是拷贝进去的, 我没有新分配新的内存, 那前面这里可能我刚才应该给过一个case, 就是呃这里rose的reset, 其实我会把我在每次啊数数据处, 数据读完了之后, 我会把里头的那些结构体再重置掉啊, 数据重置掉, 好那呃我们看下一个case, 就是我们可能刚才讲的粗略一点, 就是string和BTS的区别是什么, 左边是呃, 他在那个嗯go浪中的内存的表示这个slice, 其实嗯刚才最开始的时候, 刚才有一个那个彩色的那个图呃, 其实也是在表达这个意思, 只不过他可能画的更具象一点啊, 那他在那个, 内存中其实就是嗯第一个是data结构, 它是存储了那个原始数据的指针, 然后有nice有cap, string呢跟slice很像, 非常像, 它区别是什么呢, 它少一个cap对吧啊, 因为string是不可变的, 所以它也不需要扩容, 那你直接告诉我我的数据存在哪, 有多大内存块就完事了, 就是有多长就完事了, 对吧啊, 所以它很向下, 那么右边呢其实是两个n safe的函数, 是我们假设现在代码中我们有一个呃, 假设代码中我现在有一个字符串, 我先要现在要去给他算一个MD5的哈希, MDD5的那个哈希的那个函数呢, 它又只接收bt slice对吧, 他又不接收string, 或者是你现在有一个死STR,

p4:

你现在要用那个HTTP的那个方式把这个死STR呢, 呃post得到一个什么别的地方去, 你post的时候呢, 你要write一个body, 那个body呢他又只接收一个better slice, 呃, 就是很多时候你的string和bad slice之间, 你其实是要做一些互相的转换的, 呃你你的这个有时候那个函数的那个接口, 那个约定它匹配不了啊, 如果你直接用一个string把那个BT标包起来, 或者用BT把spring包起来, 这个时候其实是会产生一份, 额外的堆内存的拷贝, 那我们呃就可以用上右边这个这样, 一些函数的一些技巧啊, 一些强制的类型转换, 比如说上面的这个就是因为因为BT的那个, 因为那个bt slides的header, 它是一个string header的一个超级, 它的数据其实是比死运多的, 所以你所以上面的这一个转换, 它嗯做的简单一点, 就是他第一个前面八个字节存了一个那个指针, 然后后面八个字节存了一个LSE, 其实它还有八个字节是在存了一个cap, 然后上面的这一个强行强行的内存, 转换的时候呢, 我们把它转成了一个string, 那这个时候呢那个嗯编译器呢, 其实就是那个它他在读那个他认为他是string之后, 他其实只读了前面16个字节, 最后那个sslice header, 它本身就讲道理来说, 他还有一个cap的那个八个字节, 他没读啊, 但是不重要, 因为它是他的超级, 所以这个试卷是可以正常工作的, 所以你用这种to n safe死卷的转换呢, 呃是可以用的, 它唯一的问题就是, 你可能需要知道你在用了之后, 你的你你的那个原始那个battle slice, 你会不会去改它啊, 你当然就是你你你得知道你你要不要改它, 如果你改了它, 那这个试卷是会变的啊, 因为他的那个数据啊, 呃它因为它的原始的那个data, 它还是其实还是那一块内存啊, 你你可能得得意识到这一点, 当然就是如果是你调一些很简单的一些函数, 像比如说刚才聊的, 你可能只是想把他post发出去, 或者是呃调一个MD5算一算一个哈希啊, 就这种呃上下文特别简单的, 你其实你在调用的过程中, 你的BT的数据是不会发生改变的, 这个时候你的这个unsafe的呃, 效率就其实很高的, 就是嗯没什么太大的副作用哦, 就可以, 然后呢下面这个就稍微复杂点啊, 因为那个史卷呢它对比slice结构呢, 它少一个nth, 所以你得重新make出来一个新的呃, 新一个新的header, 就是那个B那个header吧, 呃就是呃就是make一个新的, 那个bt slice的header啊, 然后你把那些数据data给它重置上去, 然后那些name type设一下, 这个时候呢, 这个bt slice, 也就是复用了原来的string的那些数据哦, OK那嗯这两个函数其实是比较直观的, 然后你应该是在你代码中的一些场合, 可以用得到, 可以避免一些STR到by此的, 一些额外的内存的拷贝, 呃我们给刚才的那个case我们增加一点难度啊, 比如说额我们先看右边的case吧, 我们先看右边的这个函数, 实际函数调用的这个case, 我们再看左边它是怎么实现的, 右边这里其实呃有一个嗯, 这是marshal int6664S这个方法呢, 嗯从这个函数签名呢, 我们可以读到大概的意思呢, 就是我们要把传进来的这个int64的这个, Slice, 序列化到呃这个DST的这个bt slice里头去, 然后把它返回对吧, 嗯他要做这个功能, 但是呢呃我们在实际的呃实际实现中呢, 我们用了一个相对hack的方法叫to unsafe bytes, 呃呃是那那对应到左边呢, 应该是左边的那个下面的那个函数, 你传进来的那个ts呢, 我会去先去做一个嗯, 先去判断你的那个内部的元素的长度, 然后呢, 我把bt slice的那个slice header, 和你的这个int64的slice header, 这两个header, 把他的数据贝塔的部分给它赋值过去, 然后把他的nth, 按照那个按照你的元素的长度去重置一下, 比如说你有一个int64, 那我那我的那个by的长度, 应该就是1×8对吧, 你八个字节嘛对吧, 那就一乘以那个元素的长度, 就原原来的那个slice的那个额长度, 得去乘以那些元素的长度, 那最后我就得到了一个新的一个bt slice, 就返回回来, 那这一个bbad slice呢, 它跟原来的那个int64的SNICE呢, 他们引用同一块内存, 那这是什么意思呢, 其实就等于是我们已经把go浪自己在内存中的, int64的那个slice那个切片的那个内存, 原始的在堆上的那个内存, 我们可以直接访问到了啊, 我们不需要手动的去呃, 做一些那个呃大端小端的一些判断, 然后用大端小端的算法去把这个int64序列, 画下来, 我们等于是直接用了go long runtime的对内存的结构, 我们直接把那个by slice拿到, 然后我们直接把它append到我们的DS, 那个DST里头去, 然后把它return, 那这个时候呢就它的那个性能, 很显然它会比任何序列化的性能都要高, OK然后下面有一个n safe的N马修哦, 他跟刚才的那个逻辑是反过来的, 就是你丢给我一个呃slice, 然后呢, 我尝试把这一段slice呢, 读成int64slice的结构啊, 这个时候呢就嗯跟上面那个是一个反操作, 那这样呢呃你就可以一个更更高的性能, 去把数据读出来, OK好, 那这是呃我们现在在呃, 在那个呃啊, 我们新写的那个数据的序列化协议吧, 就是呃我们给我们给它起了新的一个名字叫, G column, 然后呢他跟呃我我我自己做过一些benchmark, 就是它的序列化性能跟呃, 反序列化性能跟反序列化性能, 都是远超那个PROTOBUF和influx的行协议的啊, 现在应该是cod正在尝试接呃, 呃大概是influx的嗯百来倍, 然后是PROTOBUF的十来倍吧, 大概大概大约是这样一个性能的一个呃, 倍数关系啊, 最近cod在接, 就是接完之后在那个序列化, 反序列化的那一块应该会有一个比较大的提升, 呃前面这个呃对比呃, 前面这个类型强转呢是呃稍微复杂一点的, 然后我们看一个更复杂一点的, 但是呢这一个呃, 可能就不是特别推荐大家使用了啊, 在一些case下你可以看情况来用嗯, 但是呃我我这里先先直说, 我做我们做了一些什么东西啊, 然后这里呢就是首先是左边, 我们先看左边的序列化的部分, 是我们先走反射拿的呃, 呃左边的这个原始的这个value呢, 它应该是一个结构体的一个指针啊, 然后我们通过反射呢拿到了这个指针的地址啊, 这里我们可以呃我们先看呃, 我们看这些框起来的这一段代码吧, 左边呢是先拿到那个STRACT的, 那个单个STRACT的那个长度, 元素的长度, 然后呢我们尝试着去呃, 有你首先第一你现在有这个指针的地址了, 然后呢你也有数据的长度了, 我们直接一个NCF转换, 我们把它转成一个BT的slice, 那这个slice呢就是就是go long, 在内存中对这个结构体的表示, 那然后呢我们make一个新的bt slice, 我们直接把这两段slice对着拷贝, 这个时候我们新的slice里头就已经拥有了, 原来的STRACT的所有的数据, 呃, 注意这里其实是完成了一次浅拷贝, 如果你的STRACT上面有一些, 只有一些非原始类型的东西, 比如说有string或者是有别的指针, 那么大家还会指向同一个地址哈, 呃这里下面有一些呃, 下面没截完的地方, 有一个for循环里头是处理这种非原, 非那个原始类型的, 但是如果你的STRACT上只有一些布尔啊, 只有一些那个int呀, float啊这些东西的话, 你就这样一次拷贝, 你就已经是完成了一次, NCF的额数据的拷贝了, 然后右边呢是我们尝试把我们的这一段内存, 用类型断言的形式把它转成了一个指针呃, 就转回来了哦, 那呃那这种形式呢, 其实你是可以把数据存储到一个bad slides上, 然后呢在你需要的时候, 你又可以把它从那个bad slice再转回来, 然后呢, 你可以自己去管理那个battle slice的生命周期, 这样子, 那个你这样的话, 你就第一你就不需要关注那个原始的那个指针, 它在堆上的那段内存什么时候被GC, 什么时候被怎么样怎么样, 对吧啊, 当然这个我会晚一点再讨论, 他在什么场景下应该怎么用, 这个呃作为一个拓展的一个一个case, 在这里给大家讲一讲吧, 好啊这个这里就是我呃, 我们嗯呃我们再介绍一下arena啊, 这个arena呢其实嗯一般的项目里头呃, 没有这么用的, 然后呢呃在你需要在在一些场合吧, 就是呃你需要的时候再考虑, 然后它用起来它可能并没有single pool, 用起来那么简单啊, 我我给大家先介绍一下,

p5:

介绍完了之后呢, 呃你再去看你适不适合用它, 它用起来会呃我我先警告一下, 它用起来会非常的难, 嗯然后呢呃这里一句话介绍就是arena, 其实它就是一块, 你自己就是一块内存, 嗯然后呢你要用的时候呢, 其实是把这一块内存通过类型转换, 把这个BIOS转成对象来用, 然后呢当你不需要这个对象的时候, 你就把这段内存reset掉, 然后你自己再尝试去复用它, 其实呢它就是在做手动的内存管理啊, 然后你等于是呃避开了go long run time的堆, 内存的分配和GC啊, 它的使用场景呢跟single put是有差异的, single put比较适合那种呃, 你的那个分配和回收的时机都比较零碎, 就是我可能时时刻刻都在分配新的, 然后时时刻刻都在回收旧的, 就是那种场景下, 你可以你用single pool其实挺方便的, 但是如果呢, 假设你现在是要一口气生成一批的小对象, 然后呢这些小对象的生命周期又几乎是同步的, 就是你一批生成, 然后一批又回收掉, 那这个时候你用single put, 你一批生成的时候, 你尝试从single put拿, 发现那铺里头一个数据都没有, 那single铺也得make呀, single pool make完之后, 你又一劈一口气全塞回去, 那think ingkpo容量又超了, 他又给你删了, 就是那这种场景, 这种case下SINGPO就不是很好用, 那你可能呃就得探索一下, 这呃这个arena这这种方式哈, 就是就当然你可能还得呃, 在我们最前面讨论那些呃本地啊, 局部catch啊, 就那些方法, 包括slice啊, 那些东西你都已经用完了, 你已经觉得呃那前面的方法都不太适用你了, 你再来看这个arena嗯, 那这个arena呢就是呃在观测dB中呢, 我们有一个很典型的一个场景, 就是呃大家的那种DKL写了, 假设你写了一个呃step, 比如说是一个五秒, 然后呢你的你又拜了一下group特别多, 然后呢你时间又恰好又拉的比较长, 那我每一个每个查询呢, 其实我都是得把所有的数据拆分成特别多, 小的那个时间分片和分组, 比如说你一个查询你要返3000个点, 然后我其实就需要在内存中去分配这个3000个, 那种临时的那种聚合的那种对象, 有数据进来进到这个分片, 我那个聚合对象去, 得得对里头数据去做一些累加呀, 或者是平均呀, 或者一些东西, 就就那个对象, 就是我就是就是我在这个场景下需要关注的, 就是我一个请求进来, 我分配几千个, 然后你请求一结束, 你几千个委邀同时回收掉, 那这种像像这种场景就稍微适合点好, OK那我们来介绍arena, 它他的一些当前的一些现状哈, 就是第一就是这里有两个关, 有两个关比较关键的点, 第一个是呃你要用它, 你可能需要关注到arena自己的生命周期, 和你用arena分配的对象的生命周期啊, 这是这是非常关键的, 因为你一旦用坏了就会出现就有大问题了, 然后第二个呢是呃go浪的官方, 它其实当前是有一个试验性的arenas的一个库, 然后你通过flag可以开, 但是这个库当前嗯, 大约是一个暂停的一个状态吧, 应该是因为API设计有一些问题, 然后可能解决不了呃, 没再没有一些新的新的思路, 之前这个项目这个这个annex库, 应该可能一时半会都不会转正了, 然后呢, 等会我们可以稍微来具体一点来介绍这个呃, 他的问题是什么哦, 这里的arena是库呢, 呃呃有这个一宿, 然后大家感兴趣可以自己去翻, 然后呢我们之前就是用这个arenas库, 然后呢它有一些问题是, 第一个是呃, 我默认阿瑞纳斯它分配的那个内存比较大呃, 单个不管什么请求进来, 就大概大约就是不管什么请求进来, 我先分配64兆, 那你的那个并发高一点, 然后就他就啊内存就比较炸啊, 然后呢, 我们之前的做法呢, 是我们让不同的请求去共享一个arenas库, 就是比如说每五秒的请求, 在前五秒的请求我们都去用一个arenas库, 但是也有一些问题, 就是呃单个arenas我分配的那个内存呢, 它也没有办法支持我去reset掉啊, 也不能RERELEASE掉, 那就是等于是这五个请求我全部结束了之后呢, 这个arenas as这条内存呢, 还要等着GC去回收哦, 所以实际上呢这个内存占用呢, 之前在嗯前一段时间就做得不是特别好啊, 然后第三个问题呢就是他不是并发安全的, 而人as s那段内存呢, 你可能得自己去加一些锁, 当你要分配的时候, 自得自己去加加一些锁, 来保证那个不会并发的去分配啊, 你要是并发分配就panic了, 第四四个问题呢是这个arenas库呢, 呃它只支持两种用法, 第一你new一个新的object, 在这一个ANANAS的内存上去new一个object, 第二个呢是在这一个ANANAS上呢, 去make一个slice, 他问题是不支持标准库的map哦, 就是map其实是一个特别常见的结构对吧, 除了new呃, 除了new object, 然后那个make slice以外, 就是就是make一个map了, 然后就没有map, 为什么没有map呢, 是因为啊哈是因为map它对于我们用户来说, 它的内部结构是不透明的, 它内部有一些bucket, 然后那些bucket呢是会有一些动态的扩容的, 然后那些逻辑呢现在是全部写在了, 全部写死了, 然后大概就是那些内存都是在堆上分配的, 然后你新搞一个arenas库呢, 那些内存它不太好, 让现在的map把所有的内存全部分配到arena上去, 他做不到标准库的那个map, 它内部一堆逻辑, 他也没有办法跟这个而新的这一个库来做联动, 就做不了, 所以就一直没有支持啊, 然后这里呢就是它可能有一个优势, 但是我也不知道是不是优势, 就是因为它的数据是不能被手动释放的, 所以他能够跟GC一起来工作哦, 就是你数据分配了之后, 你就等着GC回收就好了啊, 然后我们后面来看它, 要是AREN纳斯库不跟GC兼容, 又是什么样子, 呃, 那个开源社区有一个叫milk的, 只是GUFFA的人, 然后这个库嗯, 嗯也算是社区里头最火的一个, arenas as的一个实现了, 然后呢他对比我们刚才看的那个arenas库呢, 它有有几有几个优势, 第一个是它的内存块是支持线性增长的啊, 我们可以设一个嗯单个块的大小, 然后再设一个总的块的个数, 然后当你要分配内存的时候, 它就去惰性的去呃在堆上分配内存, 然后给你用他的, 你就是至少是你的, 你的初始化的时候就一口气没那么大了嘛对吧, 然后你的内存的, 你的那个程序占用的内存就小了点, 第二个呢是它支持reset和release啊, 它把它分配的分配的那块内存, 它可以reset掉, 第三个呢是它有一个并发呃, 就是你啊, 他自己在new或者是呃make slice的时候呢, 可以加锁, 然后他的问题呢其实是嗯就是arenas, 是整个嗯我们现在遇到的最大的问题, 就是它的数据是弱引用的, 它跟GC是不兼容的, 呃我我举一个case, 嗯假设我们现在有一个object, 然后呢这个object上面有一个呃, 它是通过arenas来分配的, 然后呢, 它上面引用了一个额别的一个数据的指针, 比如说他引用了一个别的结构体, 那个结构体呢恰好又是在堆上分配的, 这个时候呢GC在scan的时候, 他不知道arenas那块内存上, 对啊, 你刚刚刚我们提到那个堆上的那个对象有引用, 那GC就可能会把那个堆上的那个对象删掉, GC掉, 然后呢你的那个arenas new出来的那个object呢, 它它它它它它就坏掉了哦, 大概大概是这个问题, 就是呃这个arenas库上面, arenas的分配的那个数据, 对堆上的内存都是弱引用嗯, 它并不能去保持堆上的那个那个那个, 那堆上的那个内存一直在占用状态, 就是那这个就可能你需要用起来的时候, 特别特别的小心啊, 一一就非常有可能就坏掉了呃, 然后他的问题呢是他嗯跟刚才的那个一样, 他不支持标准库的map啊, 然后呢我们写了一个新的库, 然后嗯可以认为是在logo上面的, 一个一一个一些一些增强吧, 嗯然后呢我们呃我们基于这个arena呢, 我们实现了自我们自己实现了, 用那个哈拉链法实现了一个map, 然后那个map呢它所有的数据呃内部的bucket, 然后呃各种数据都是支持在arenas上面分配的, 那就等于说我们绕过了这个刚才的那个提到的, 那个问题, 然后他的预他它它有约束呢, 就是你其实并你, 你要分配在arenas上面的那个内存呢, 就是你的数据结构得是你自己精心设计过的嗯, 你最好是包含一些原始类型啊, 死STR, 然后一些结构体, 但是不要包含指针, 最好不要包含在堆上的指针, 不要包含any啊, any类型的那个interface, 就是假设你有一个嗯数据, 你不知道它是什么类型, 你就直接写个any的那个接口啊, 这也是不行的, 因为那个any的那个在内存中的表示, 其实它也是一个呃指针啊, 也是一个相对象的指针, 所以啊, 有一些类型是不不适合放到arena上去的哦, 然后呢他的问题是, 你就是你需要知道你在做什么啊, 他呃111用坏, 就可能像右边这样, 就是它可能就会出现一些嗯, 不可预知的一些问题啊, 就是我们在go上面也也能够遇到这种呃, bad point in go hip什么的, 就是嗯指针坏掉了, 就是然后这种错误呢几乎不可排查啊, 就是他也不会告诉你是哪一行代码错了嗯, 然后下一页这里是我们使用arenas的时候, 我们内部的那个arenas的那个封装, 然后的一些使用的case, 那比如说第一, 比如说呃嗯第一张图呢就是呃这个new呢, 就是我们去new了一个last, ACCUMMINATOR的一个对象啊, 然后下面呢有两个是分别是去make了一个string, slice和make了一个map, 然后他的key是string那个value是那个time value啊, 下面呢是往这个额下面, 第二个呢是往那个斯莱斯里头去append的数据, 前面几个case都还, OK我们关注最后一个, 最后一个呢其实是你往这个, 你往这个slice里头去append的数据的时候呢, 我们我这里用了一个clone卷的方法, 这个方法内部是做了一个工作, 就是把我们把你传给我的这个string, 我把它拷贝到了arenas上面去了, 然后这个时候我们再把这个arenas上面的, 这个string, 然后再append到这个STRS上面去, 所以就假设, 如果你直接传递的是原来的堆上的那个死STR, 那这里你在append之后, 你有可能呃也会出现一些奇怪的问题, 因为你引用对内存了啊, 然后又可能在GC的时候, 那个string可能又没了, 就呃会很难排查, 然后另外呢嗯这里你可能还需要关注到的一点, 就是append它可能是会扩容的, 我在第二个图里头呢, 我去append strings的时候呢, 我没有去呃, 看第三个图吧, 第三个图我们再去append那个column names的时候, 是用了arena append, arena append呢它在内部append的数据的时候呢, 他会去检查那个cap够不够, 如果不够呢, 它会是一定是在那个arenas上, 重新分配一块slice, 然后呃拷然后然后然后拷拷贝里头的数据, 然后再走, 他不会在堆上分, 但是呢第二个图里头, 这个append是有可能在堆上分配内存的哦, 我这里为什么没有用arenas append呢, 是因为我在这个这里的这个switch前面去写了if啊, 这个LSE超过多少的以后, 我的数据就不要了, 所以我这里可以就直接写简单的append, 那这里就可能涉及到, 就是就就是你在写你的每一行代码的时候, 你就你你要用arena的时候, 你就像刚才这里我我提到有很多细节, 就是我可能又要可动string, 我还得关注append在哪里分配内存, 就是你你如果你要, 而你要用arena, 你就得跟我一样的思考, 就是他的那个呃他的那个薪资负担是特别高的, 就是嗯看场景, 如果你一定要这个啊, 你你你可以在在用的时候再找我聊交流, 然后最后呢呃前前面那就是我们到这一个章节, 就是我们已经把前面所有的那个呃内存复用的,

p6:

这个嗯一些技巧都已经讲清楚了, 就是呃前面有那个对象的复用啊, 通过通过SINGAPPOOL, 通过channel服用, 然后有那个局部的cat的复用, 然后有那个slice的呃, 那些nice cap的那些辨析吧, 然后你怎么去修改那个nice来复用, 然后slice来slice怎么来复用, 然后那个再介绍了一些类型强转的一些知识, 比如说好死string怎么强转, 然后有一些其他的slice, 跟史莱斯之间怎么强转, 然后那个battle slice跟一些object之间怎么强转, 然后再介绍了arenas的一些用法, 那前面的这些都是呃技巧嗯, 都是一些比较简单直接可以接受的, 你可以直接拿在代码中用的, 然后下面的我要介绍的是呃, 一一种你的写代码的一个一些一些思考吧, 或者说你你需要按照这种思维, 这种思路去写你的代码, 这样的话你才能去管好你的那个数据的, 那个内存的分配, 那我们先看第一点叫改造代码结构, 让数据在其中单向流动, 就是呃我们前面就是你可能你需要关注, 你的数据是在哪里被生产的, 我可以给大家举一些例子, 举一些case, 比如说那我们现在采比如说我们的那个采集器, 现在我们要去采集文件的日志, 然后我们把它read出来, 然后再把它发到那个HTTP, 那个用用HTP把它发出去, 这个时候呢那个日志日志的那个数据是哪来的, 那肯定从文件里头读出来的是吧, 我从那个呃用那个file把它read出来对吧, 那这个时候我们数据生产的地方, 就是在你read file的那个地方, OK那你把他那个数据在那里生产, 那这个时候呢你需要关注, 他在这里被生产了之后, 他在传递完后面所有的环节之后, 他在什么地方又结束了, 那他应该是在那个hp发送完之后会会会结束, 那你可能需要关注, 就是你在数据生产的地方, 你呃就是这里有一个头, 有一个尾, 那中间的环节是不应该去分配新的data的, 不应该去分配新的对象, 如果你中间环节分配了新的对象, 那额就是它不应该影响你的那个组的, 那个数据的生命周期, 它可能是一个旁路, 就你的数, 你的那个原始采集的那个数据生命周期, 它就是一个确定的一个流向哦, 有你有起点和终点, 那么你就可以尝试去在起点的, 你在终点的时候呃, 把它比如说是放到P里头呀, 或者是把它放到一些复用的object上面呀, 或者是什么上面, 然后你在起点的时候把那个对象从里头取出来, 你就能reuse就能够复用它了, 然后呢你尽量是要去减少一些中间环节, 对数据的一些呃改变和变更哦, 那呃我右边呢这里我们来给呃一些case, 我们来尝试梳理一下, 比如说嗯我们看左边的这个case呃, 比如说我这个case呢, 是那个我们在读取influx的行协议的数据呃, 就是他这个数据呢是从那个iq body读出来的, 就是那个请求的那个body, 然后呢呃读出来之后, 我们把它呃反序列化成一些rose的一些内存结构, 然后呢呃中间有一个在第50行, 那中间有一个回调, 我们去掉了那个insert rules, 然后呢, 这里头就很显然就是一些处理那个那些数据的, 那些逻辑吧, 大概就是额判断一下字段的一些类型啊, 然后组装成一个新的包啊, 然后把数据发出去对吧, 大概是这样一些逻辑, 那呃这里我们关注到, 就是第一数据是从IQ波底读出来了, 他在那里被生产, 然后呢他在那个呃pass完之后, 我们把所有的数据放到这个rose上面, 这个int flag呃, influx rose的这个slice上面, 然后呢呃中间的这些环节, 它应该是不持有这个influx rose的所有权的哦, 可以这么理解, 就是这些in flux rose呢是临时的, 可以访问这个influx rose上面的, 就是这个这个这个这个函数, 是可以临时的访问这个slice上面的数据, 但是呢它并不能去回收, 主动的去回收这个rose上面的数据, 比如说他不能将rose上面的数据reset掉, 对吧啊, 他也不能主动的去把它放到P里头去, 那所有的那个分配和回收, 因为我们都是用的回调嘛, 那分配和回收都应该是在influx, 点pass函数内完成的, 那我我现在其实我们这个我们前面的, 有一些例子, 我们其实已经讲到了, 我们这个in flux点roll上面啊, 引用那些数据的都是一些引用, 然后呢他的那些死STR呀, 也都是可能会发生变化的, 那这里可能就涉及到有一点, 就比如说当你在我们的下面的这个, in insert rose上面, 如果你想要从influx roll上面去取一个嗯, 字符串, 比如说把那个measurement呃读出来对吧, 你要把这个measurement的这个string, 你现在假设你要把它aid到一个map上去的话, 这个时候就坏了, 因为你你把那个数据放到map上, 就这个时候, 其实你可能呃我们刚才前面提到的那个数据, 它是n safe的这个map, 这个这个这个这个measurement, 这个死STR呢, 内部的数据可能会呃可能会发生改变, 然后如果你把它放到map上去, 那这个map肯定会坏掉, 然后呢就是你如果你要这么用, 你应该是要把这个数据克隆一次, 我我我我我是这么理解这个行为的, 就是你的这个insert, 下面这个insert函数呢, 你是指你现在是对这个rose, 是仅仅有一个只读的一个权限, 当你需要上面, 当你需要去调用上面任何的数据的时候, 你都应该是要去, 如果你要特别是你要保持它, 你只你可只可以访问, 你不能保存它, 你也不能把他的所有权去交到别人, 你也不能把他的字段, 存到你自己的一个什么地方啊, 大概是呃这么来梳理吧, 就是那呃这样的话, 那个数据的那个流向就是单向的, 就是清晰的, 你不会因为你突然把中间的一个子string, 然后交给另外一个函数, 然后让那个函数去持有它, 就是你这样的事情就不能做了, 那这样的数据的分配和回收, 就都在influx pass里头, 中间的所有的环节都是呃持有引用关系啊, 他们并不能去管理它, OK好, 然后我们看右边的这里有一个case, 就是呃它跟左边不一样, 那右边这个case呢就是这个table, 我们调用这个input点NEX的时候呢, 我们返回了一个table, 然后最后呢这个for循环在那个TRUCONSUMER呃, 处理那个table之后呢, 我们把这个table release掉了, 这个release背后对应的呢就是把这个table反放到那个, 放到那个SINGAPOR里头去了啊, 那这里跟左边是略有差异的, 但是你在写代码的时候, 你可能需要关注, 第一我调用这个next的时候, 我现在已经完全的获得了这个table的所有权, 我是拥有这个table的, 我在用完这个table之后, 我就得释放它, OK那那那反过来, 那我们那对应到中间的这个output的consumer, 这个方法呢, 就是它是不持有这个table的内部的数据的, 当它需要这个table上的任何数据的时候, 他都可能需要进行拷贝呃, 需要把数据拷贝出来, 那额就是左和右其实是嗯两种, 就是不同的模式吧, 第一个是你调用某个函数, 你能把他所有的钱拿过来啊, 左边的时候你左边的这个呢是一个纯引用关系, 你拿不过来, 但是呢就是你不管怎么设计, 你可能你自己的代码中你要有清晰的意识, 就是当你要复用内存的时候, 你就需要去关注这个内存的生命周期了, 它在什么时候被分配, 他在什么时候被回收, 中间的一切环节可能就不能去啊, 改动或者是持有这块内存, 长期的持有这块内存, 这里我们继续举例子, 左边这里是一个呃, 一个一个数据的一个写入的这么一个, 左边是一个数据写入的这么一个case吧, 就是呃我把一些这这什么meta呀, tag呀, field啊, 然后序列化到一个bad slice, 然后把这个bad slice呢走JSTD呃, 压缩算法进行了一个压缩呃, 压缩完了之后呢, 我们下面有一个嗯post请求, 把这个数据post出去, 然后呃最后回收了嗯, 就是这这样一个这个逻辑呢, 其实就非常的清晰啊, 那我在那个大概是170行吧, 我我分配一个嗯BB的一个嗯buffer, 然后呢我马上写了一个deer, 这个数据在我这个函数调用完之后, 它就会被回收, 173行呢, 我分配了一个呃ZBZSTD的一个buffer, 然后呢174行呢, 我马上就写了一个地方把它回收对吧, 那这里的数据流向, 你其实你可以看到它非常的清晰, 就是我在什么时候分配, 我在什么时候回收, 然后中间下面全部是一些使用的过程啊, 这个这个这个这个逻辑是清晰的, 那右边是cod里头的, 有两个代码, 我们赏析, 我们那个仔细来辨析一下中间的一些问题, 第一个是嗯这个是当时出过bug的一个代码, 他是尝试复用了那个呃zip的那个STRACT吧, 嗯然后他用了一个SINGKD, 他用了一个singer put嗯, 然后从里头举了一个G对吧, 然后呢呃有两个那个嗯, 有两个buffer分别是叫target和reader啊, 那个reader呢就是把那个数据读进去, 然后那个target呢就是存储马修完之后, 就是呃就不叫马修, 是呃zip压缩完之后的那个数据啊, 就存在那个target里头, 然后呢这里呢就大概后面一直都没什么问题, 然后呃一直close, 然后最后呢把压缩完的那个bt slice呢, 返回回去, 那看起来这个代码是没有什么大问题了对吧, 我传进来一个bad slice, 然后它是未压缩的, 我返回一个bad slice呢, 它是压缩的这个压缩后的对吧, 看起来没问题, 但是这里面问题很大呃, 我们回想一下我们最开始设计的, 最开始我们第一页的时候, 我们聊的那个问题就是呃, 当你调用一个函数的时候, 这个函数如果给你返回了一段新的内存, 你就要拷问他, 你这内存哪来的, 你你你为什么会有一个内存给我, 你内存哪来的, 你哪里分配了你这个问题一拷问, 你就你就知道问题在哪了, 这个g zip它返回的内存是哪来的, 是target的内存, 那target的内存是是哪来的, 是这个g zip的这一个g zip two的这个结构体上的, 那这个结构体, 把这个a target的内存的所有权返回回去了吗, 就通过这个函数就返回出去啦, 其其实没有, 你你看到那个大概就是12行还是多少R, 第二行吧, 这个这个这个g zip two, 马上又把这个G这个对象又放回池子里头去了, 那这里就马上就是就就会你, 你可能就意识到冲突了, 就是你的这个BSLICE的所有权, 通过函数返回到函数体外了, 然后另外一个线程, 可能会马上把这一段内存get出来, 重新去写数据, 那这个这个这个这个这个关系就乱掉了, 那这里就不对对吧, 嗯然后呢这里下面的这个是fix之后的, 我嗯这两天从那个cod里头嗯, 看到的最最新的代码, 这个target呢就是我们就不不复用了对吧, 我直接make1段新的就可以了对吧, 那肯定不会有冲突了, 但是这个代码呢其实也不太好看, 哪里不太好呢, 就是嗯你会发现, 这个时候你的数据的生命周期的流向, 它不单向了, 你的数据流向有点乱, 我给你我我们来我来给你举例子, 第一你的content是哪来的, 你的content可能是从那个嗯content的, 可能是从那个网络或者是从那个队列的那个呃, NSQ的SDK里头读出来的对吧, 那是数据生产的地方, 然后这一个buffer呢读出来了之后, 它在它在哪里, 它在哪里终结消失呢, 就是我们预期他可能是在那个呃post请求那块, 但实际上没有, 他在GZIP了这个之后, 他就不用了, 这个content进到这个函数体之后, 我们在这里read11顿, read1顿copy之后, 这个content的生命周期就结束了, 提示就结束了, 我们后面不会再用到这个content了, 然后这个content呢你就会呃, 其实就已经在等待GC去回收它了, 这个content后面也没有被复正确的复用掉, 所以你这块内存呢就只有一个分配, 然后再就是等GC的回收, 你这个内存就嗯有点有点可惜, 浪费了嗯, 然后呢这个result呢是一段新分配的内存, 就是你调用这个每调用一次g zip, 我们就分配一块同样大小的, 跟content同样大小的内存, 啊, 那这个时候呢, 就是呃就等于是你当你调用一个函数的时候, 这个函数内分配了一块内存, 然后你在外面拿去用, 用什么时候把它用完的时候呢, 用完了之后呢, 再去等GC的回收, 这个时候你会发现这个分配呢, 就是假设我们的函数调用, 它是一个树状的一个结构, 那中间可能是你的那个嗯, 你会在一些叶子节点上去分配一块新的内存, 然后再去等那个GC的回收, 就是你的那个整个的数据的这个流转呀, 你就是它嗯它不直接了, 不直观了啊, 那他跟左边的这个对比呢, 你说就就明显就你应该就注意就能注意到, 就是左边的这个所有的数据分配和回收的节点, 你是准确知道的, 你是清晰的, 右边的这个最大的问题就是你现在不清晰了, 你你你你分配的地方跟回收的地方, 它它一点关系都没有, 你你分配的地方叫g zip函数, 你回收的地方再叫一个什么呃, Post to in flux remote, 类似这种东西, 就是你分配和回收的这两个函数, 它如果你用通过函数名你来识别的话, 他们也不偶偶, 他们没有任何的耦合关系, 你们的内存分配, 它不是在一个内聚的一个结构体内, 所以这种呃这种这个扭扭转关系是有问题的, 所以那正确的写法是什么呢, 就是你可能需要关注, 如果你的函数需要分配内存, 你这段buffer可能不是得你自己来make, 你得外部给你传进来, 比如说JJP函数, 它的函数签名就应该改成, 跟左边ZSTD的这个接口签名类似的, 我们要压缩的那个就是你的结果的bad slice, 跟你的原原始的那个数据的bad slice, 都有外部传进来, 你那你在g zip函数内, 你只做CPU操作, 你不要给它分配内存, 这样就没有问题了, 然后外部的那个额外的那个result, 的那个那个那个buffer呢, 你想你其实是你可以用各种方式去存储它, 你可以我们前面提到有很多地方呃, single pool可以啊, 那我左边的case就是你可以用single put, 来管理这种buffer, 临时的buffer, 你也可以用那个context1些上下文啊, 你也可以用循环外的一些内存, 你也可以用一些呃呃一些结构体上的一些内存, 你来复用它都可以, 但是你就是不能每次make一个新的, 它就会增加你的内存的分配和回收的负担, 它会导致我们前面看到的那个, 30%几的那个和性能的开销哦, 是这个嗯好了, 我今天的所有的分享就是这些了, 然后我今天应该是开了一个那个连麦的功能的, 就是大家呃有对我前面这里提到的呃, 任何的问题呃, 有有有有什么想聊的呃, 可以我可以直接连麦聊啊, 或者打字聊都可以, 没有的话我们就可以结束了嗯, 好那我们就结束了, 然后那那个大家呃, 后面对这个呃这一块儿有任何的问题, 特别是如果你想用一些比较high的方式, 比如说arena这种东西的时候, 可以找我聊, 嗯好那就这样好, 各位拜拜,





20241003

Foundations of Go performance

Go 性能基础

帮我整理这一期英文播客,翻译为通顺的中文,请保留完整内容,不要删减,谢谢!

本篇内容是根据2020年2月份#117 Foundations of Go performance音频录制内容的整理与翻译

在这个多部分系列的第一部分中,IanJohnny 以及 Miriah PetersonBryan Boreham 一起揭开了 Go 程序性能的第一层重要内容。


过程中为符合中文惯用表达有适当删改, 版权归原作者所有.



Johnny Boursiquot: 好的,大家好,欢迎来到这一期的 Go Time 播客节目。今天我们有非常特别的一期节目。在我开始介绍今天的主题之前---这可是好东西哦---我想先介绍一下我的联合主持人 Ian Lopshire。Ian,和大家打个招呼吧。

Ian Lopshire: 大家好。

Johnny Boursiquot: [笑] 够简洁了。

Ian Lopshire: 我只是按指示行事。

Johnny Boursiquot: 确实是按指示行事。哈哈,这节目真是越来越有意思了。好了,我今天请来了几位嘉宾,和我一起讨论性能问题。哦,对了,我是 Johnny。 [笑] 我总是忘记介绍自己。不过你们应该已经听出我的声音了,你们知道我是谁了。无论如何,今天我请来了几位嘉宾,一起讨论性能问题,尤其是 Go 性能的基础知识。在介绍他们之前,或者让他们自我介绍之前,我想说一下我们这期节目的构思。希望这是一个系列节目的开始,围绕 Go 和性能问题展开,帮助你从零开始,最终成为高手。我们会给你提供一些指导,特别是针对初学者、中级开发者,甚至是一些高级 Go 开发者,帮助他们了解有哪些工具可用,Go 编程的惯用方法有哪些,以及在编写高效 Go 程序时应该注意什么。

为了帮助我讨论这些问题,我首先请来了 Miriah Peterson。Miriah,给大家介绍一下自己吧?

Miriah Peterson: 大家好,我是 Miriah Peterson。如果要用两个词来形容我自己,那就是“不要相信我的数据运维工程师这个头衔,因为数据工程师不怎么用 Go,但我用了。” 所以……

Johnny Boursiquot: 我们稍后会深入讨论这个问题的,哈哈。另外还有一位嘉宾是 Bryan Boreham。希望我发音没错,Bryan,来给大家介绍一下自己吧。

Bryan Boreham: 大家好,我是 Bryan Boreham。我做了很多 Go 性能优化的工作,已经使用 Go 近十年了。目前我在 Grafana Labs 工作,同时也是 Prometheus 的维护者。

Johnny Boursiquot: 很棒,我就说今天的节目会很有趣吧。我请来了真正懂行的嘉宾。好了,那我们开始深入讨论吧。在开始之前,我想先设定一下讨论的背景。假设你是团队中的一名开发人员,负责维护多个组件或者服务,不管它们是以 CLI 形式运行,还是作为开发工具,或者运行在某个集群上。无论如何,你是某些服务的负责人。然后,你的团队负责人找到你,说“嘿,这个组件,当我们给它输入更多数据时,表现得比其他服务更慢,更不可预测。我们怀疑可能有性能问题,可能是 CPU 或内存的瓶颈……但我们不确定。所以我让你来找出问题所在,并解决它。”

所以我会扮演这个角色,我会问一些问题。我假设自己对 Go 和 Go 性能优化并不了解。我会问一些可能不是愚蠢的问题,但一定是天真的问题。我会扮演那种不懂但想学的人。大家觉得怎么样?

Ian Lopshire: 好的……

Johnny Boursiquot: 听起来不错。我看到大家都点头了。好,那从一开始告诉我,关于 Go 的设计原则我知道它追求简单和高效……我知道它是一种垃圾回收语言。首先,我可能需要了解垃圾回收到底是什么?能不能帮我设定一下基础,让我明白在性能方面该如何理解 Go 的设计原则?你能为我提供一些关于 Go 性能哲学的起点吗?Bryan,为什么不由你开始呢?

Bryan Boreham: 我想说,在深入了解 Go 细节之前,如果我们首先知道某个组件运行缓慢,那我们接下来要了解的是它在做什么。它是因为占用了大量 CPU 而慢,还是因为在等待其他东西而慢?通常来说,这个其他东西要么是网络,要么是磁盘之类的东西。所以这是第一步,在深入 Go 代码或代码细节之前,先弄清楚它到底在做什么。 我几乎每天都对着屏幕喊这个问题。不过,因为这是 Go Time 不是网络时间或磁盘时间,我们可以假设我们已经完成了这一步,并且确定问题出在 Go 代码中,它占用了很多 CPU。那接下来该做什么呢?这个时候,进行性能分析是个好步骤。

Johnny Boursiquot: 所以一旦我知道了,假设我排除了网络或磁盘问题---假设我的是某种服务,它监听一个端口,并接收一些流量……那我该如何理解 Go 的设计和哲学呢?遇到性能问题时,我该如何处理?我应该从哪里开始思考?

Miriah Peterson: 当然可以。不过我想先强调一下 Bryan 刚才说的东西,这也是我经常遇到的情况。我从事软件开发只有六年,刚开始工作时,我只接触过云服务。所以有很多背景知识,比如理解性能分析,这些经验来自于“哦,我在 Linux 上做过一些事情”或者“哦,我有在不同内核、不同受限环境下工作的经验”。这些其实是非常基础的技能,帮助我们理解很多问题的根源。因为我们总是会说“云资源很便宜,所以我们可以随意使用。”

所以在深入 Go 之前,有很多背景知识需要掌握。然后,当我们确认问题出在代码时,接下来要问的是“有哪些工具可以使用?” 幸运的是,我们选择了 Go,很多工具都随标准库一起提供。所以这就是我们开始的地方。

Johnny Boursiquot: 我非常喜欢这个初步的思路。在我转向 Go 之前,我自己也做了很多 Ruby 编程,Ian,我不确定你是否也有类似的经历。但在我转换到 Go 的过程中,我发现即使是我那些天真的 Go 程序,性能也比我优化过的 Ruby 程序快得多。这并不是说 Ruby 不好,只是像 Go 这样的静态编译语言和 Ruby 或 Python 这样的动态语言有着不同的性能表现……在大多数情况下是如此。我并不想一概而论。但就我的情况而言,我在解决某些问题时,明显感到用 Go 事半功倍。所以,作为一个切换到 Go 的程序员,你可能会想,“好吧,我刚写了这个程序,结果它比我之前做的任何东西都要快得多。” 你可以长时间不必担心性能优化,除非你遇到某些情况---尤其是当你处理的项目规模较大时---你可能会发现需要进行优化。 比如在我们假设的情景下,你的老板找你说,“嘿,我们通过集群层面的分析发现,这个特定的服务是一个瓶颈。” 在这样的背景下,Ian,我很想知道在 Miriah 和 Bryan 提出的基础上,你接下来会怎么做?

Ian Lopshire: 是的,我想我会开始问一些问题,比如“为什么这是瓶颈?它是在丢请求吗?它的响应速度变慢了吗?还是它偶尔会停滞,完全停止运行?” 我觉得很多人认为性能问题就是速度问题,但其实关键是“我是否在我需要的约束条件内运行?” 所以我的第一步是弄清楚这些约束条件是什么,然后我们才能开始进行优化,以满足这些条件。

Bryan Boreham: [无法听清 00:10:05.17] 你可能还需要进一步分析,是每个请求都慢吗?还是某一类请求?或者是来自特定用户的请求?你或许可以归类这些问题,也可能无法归类……但如果能归类的话会非常有帮助,特别是如果问题是可以复现的。最糟糕的问题是那些偶尔发生、你不知道原因、也无法自己触发的问题。所以,找出问题的原因并能够复现它,这非常重要。

Johnny Boursiquot: 我确实想谈谈---我会稍微概括一下你刚才说的,Ian,关于预期的问题。这个特定服务的性能预期是什么?因为我认为这些预期通常在生产环境中会反映在资源分配上,比如 CPU 或内存的分配。刚开始接触编排工具时,我遇到过一个非常棘手的问题,比如 Docker 或其他类似工具。我发现,“哦,我的程序……” 我运行程序时,它们工作得很好,但当达到某个阈值时,突然就像被卡住了一样,无法继续执行某些操作。它会突然停止运行。我当时就想,“为什么这个程序运行得好好的突然就停了?” 没有堆栈跟踪,也没有错误信息……它就这么被终止了。我后来才意识到,“哦,原来是编排工具---”不管你用的是 ECS 还是 Kubernetes 或其他工具---为这个服务分配了固定的 CPU 和内存资源,每当我超出这个分配的资源时,进程就会被杀掉。我当时并不知道我的服务是被那个环境终止的。当我意识到这一点时,我就想,“哦,天哪。” 正如 Ian 所说,你得提前知道这些信息,不管是和运维团队沟通,还是你本身就是运维团队,做一些 SRE(站点可靠性工程)工作,了解“我需要提前做些什么?” 这会帮助你了解你的应用或服务在处理数据时应该做什么……这也引出了另一个问题,如果你需要处理大量数据,并且你觉得在处理这些数据时遇到了问题,接下来该怎么做?你如何找出问题所在?

Bryan Boreham: 嗯,我已经提过分析了,我还会再提一次。

Johnny Boursiquot: 好……

Miriah Peterson: 除了分析还有其他答案吗?这是我一直想知道的。我觉得 Johnny 提到了一个有趣的点---我从来没做过,但我知道有些人会这样做,就是坐下来计算,“我预计传输多少字节,占用多少空间。” 我一直是那种蛮力派,尽可能多地进行健全性测试,看看什么时候会崩溃。但这会导致另一个问题---概念上的知识有时并不总是存在。我觉得你不想遇到 CPU 和内存问题。通常你不用担心这些问题,直到你被锁定在机器外面,不得不去解决它们。就像是这样的问题突然出现了,你会想,“好吧,现在怎么办?” 于是你才想到,“我应该设置一个分析器。” 但到那时你已经撞上了墙,已经达到了那个阈值,程序已经崩溃了。这个时候再去考虑,“哦,天哪,我之前从来不用担心的东西现在成了我唯一关心的。” 所以,Bryan,我同意你的看法……

Bryan Boreham: 是的…… 提前去预估资源使用是非常困难的。但我觉得这是随着经验的积累而来的。另外,有一个概念叫“机械同情心”(Mechanical Sympathy),你听说过吗?

译者注:

—注释开始—
“机械同情心”(Mechanical Sympathy)是一个术语,最初由计算机科学家杰夫·阿特伍德(Jeff Atwood)提出,用来描述程序员对计算机系统和软件的深刻理解和同情。它强调了程序员在编写代码时,应理解并考虑计算机的内部工作原理。

主要概念

  1. 理解底层机制

    • 程序员应了解计算机硬件、操作系统和编程语言的运行机制,这样可以编写出更高效、更可靠的代码。
  2. 性能优化

    • 通过理解计算机的工作原理,程序员能够识别和消除性能瓶颈,从而提高软件的整体性能。
  3. 错误调试

    • 对系统内部运作的理解可以帮助开发者更快地找到和修复错误,减少调试时间。
  4. 系统设计

    • 在设计系统时,考虑到硬件和软件的交互可以帮助创建更具可扩展性和可维护性的架构。

实际应用

  • 在编写高性能程序时,程序员会考虑内存管理、CPU缓存、并发处理等因素。
  • 在选择数据结构和算法时,程序员会根据它们的时间复杂度和空间复杂度做出明智的选择。

机械同情心强调了对计算机系统深刻理解的重要性,从而帮助程序员在开发过程中做出更好的决策。

—注释结束—

Ian Lopshire: 这是我打算在这个播客里提到的内容之一。

Johnny Boursiquot: 那我们来聊聊吧。

Bryan Boreham: 我觉得这个概念来自一位一级方程式赛车手,他谈到“如果你理解车子的内部运作方式,你就能更好地驾驶它。” 计算机也有点类似。比如,CPU 每秒可以处理十亿个操作,而这些操作可能就是加两个数这样的简单操作。即便不需要知道太多细节---即使这个十亿的数字也有点偏差---我只是做个大致的简化。如果你坐在那里等待计算机返回结果,意味着它可能花了半秒钟左右的时间。那么在这段时间里,CPU 可以执行约五亿个操作。那么你到底在代码里写了些什么,让 CPU 执行了五亿个操作?这是我通常会思考的起点;它到底在做什么?到底是什么让它花了这么长时间来执行我要求的操作?

Johnny Boursiquot: 如果你真的需要处理十亿个操作---如果是这样,我为你感到遗憾……这确实是个难题…… [笑]

Miriah Peterson: 欢迎来到我的世界。

Johnny Boursiquot: [笑] 如果你不得不处理---其实,Miriah,作为一个数据处理人员,我知道你处理的数据量和方式可能会与那些编写网络应用程序的人有所不同。当然,你也会涉及一些网络方面的工作,但我觉得如果你处理的是---我应该这样说---不可预测的工作负载,那么情况会有所不同。如果你处理的是不可预测的工作负载,这将与处理一组你明确知道的数据有所不同。比如,你知道自己要处理 5GB 的文本处理任务,可能在编写代码时的方法就会和处理一个需要流数据的网络服务不同。这两者是不同的;就像你在处理一个更大数据池中的某个子集一样。

Miriah Peterson: 我觉得这个话题很有趣,也正好可以引入 Go 的讨论。最近我在为一个课程做研究,这个课程的主题是 Go 和数据工程。我一直在使用 Go 的性能分析工具 pprof,我们稍后可以回到这个话题。我一直在用 pprof,试图理解 goroutines 的运行机制……主要是在编排层面上,究竟是使用一个程序中的 goroutine 更好,还是将程序扩展出去更好?应该在 Kubernetes 中水平自动扩展,还是应该在内部使用worker线程?这些都是需要考虑的问题。部分问题涉及到基本的 API 调用,可以这样说。不管你在构建什么程序,Go 使用 io.Readerio.Writer 作为其大部分操作的接口---无论你是连接数据库,连接流服务,还是连接 API,所有东西都回归到这个层面。所以,不论你是在处理成千上万的 API 调用,还是成千上万的数据库写入,或者是处理成千上万个数据点,Go 的延迟通常不会---除非你的程序设计有问题---来自于实际的数据处理或数据点的操作,而是来自于文件系统连接或 API 连接的延迟。

因此,当你在 Go 中设计服务并尝试优化时,那些连接点往往是内存问题的来源。内存问题通常来自于这些连接点或 API 点,即从一个函数向另一个函数,或从一个系统向另一个系统传递数据的连接点,而不是来自于服务的水平扩展。这些问题往往出现在 IO 读写层面,比如“哦糟糕,我忘了关闭我的 writer”或者“哦糟糕,我开了 15 个连接,但实际上只需要一个。” 这些都是问题所在……我觉得不管你在构建什么程序,这些问题都是一样的,因为最终我们都在处理字节……而我发现很多人会转向 Kubernetes 日志或其他东西,而并没有使用 Go 内置的一些工具来帮助我们跟踪这些问题。

Johnny Boursiquot: 我认为在代码中也存在一些简单的错误,同样涉及到读取和 IO。比如,当我教 Go 时,我首先告诉大家的是,如果你需要处理磁盘上的文件,即使文件大小是可预测的,要知道如果你使用 io.ReadAll,你会把文件的每一个字节都加载到程序的内存中。这是一个容易犯的错误,很多人会想,“哦,我只需将所有内容读入内存,然后逐行进行某种转换,或者统计每一行。” 但实际上,你是在将整个文件加载到内存中。我解释说,“你应该采用流处理的方式,而不是将所有内容都读入内存。” 然后他们会反应过来,“哦,原来我可以这样做?我可以一次读取一行,并逐行处理?” 这就是“哦,原来可以这样做”的那种心态转变。如果我不知道这些库,或者不知道如何避免这些容易出错的方式,这些问题就会非常频繁地发生。

所以,当你遇到这些情况时,这就是我们开始引入更多工具的时候了。pprof 这个工具已经提到过几次了……我们来聊聊 pprof。它是什么?为什么它重要?

Bryan Boreham: pprof 这样的工具的基本原理是,你运行你的程序,让它执行它的任务,分析器每秒会中断 100 次左右。每次中断时,分析器会记录下当前正在执行的代码。经过几秒钟的运行,或者你让它运行的任何时间长度,它会统计这些数据,这就是为什么我们称之为“分析”。你可以统计出程序运行了 10 秒钟,分析器每秒中断 100 次,所以总共有 1000 次记录。在这 1000 次记录中,一半的时间花在了某个函数上,10% 的时间花在了另一个函数上,10% 的时间花在了另一个函数上。这就是分析结果。这个过程就是每秒中断 100 次,记录执行情况,然后加总这些计数,最后在屏幕上绘制出来。 我喜欢一种叫做火焰图的可视化方式,这种图表非常直观……不过在播客里描述并不太好,我现在手舞足蹈地比划着,但这并没有帮助。说真的,如果你从没见过,去找些展示这些图表的视频看看吧。基本上,火焰图上会有一些矩形条,条越长,表示在那个函数上花费的时间越多。所以你只需要看这些大条形图就可以了。

Johnny Boursiquot: 这就是你首先要看的地方……当然,这些是显而易见的标志,但这并不一定意味着你会在这些地方获得最大的优化效果。也许你正在处理的某个函数已经高度优化了,问题可能并不出在函数本身,可能是你传递给它的数据量过多,需要在其他地方优化。所以 pprof 给了你一个显而易见的起点,让你开始深入分析,对吧?

Bryan Boreham: 是的,你接下来可能会做的是---基本的过程是想出一些方法,看看如何让程序运行更快。你会怎么做呢?你可能会发现你在执行一些不必要的操作,那就跳过这些操作。或者你会找到一个更聪明的方式来执行这些操作。或者你会发现你重复做了某些事情,那就缓存结果,后面再用。你会使用这些技术中的某一种。所以你需要规划一下你的优化路径……这就是你在找的东西。而你刚才提到的,如果某个函数已经高度优化了,那么……如果有人已经在那里应用了所有的优化技术,你可能仍然可以通过并行化进一步优化。Go 是一个非常适合并行化的语言。如果你有足够的 CPU 资源,你可以将任务分解,分别在不同的 goroutines 上并发运行……并行和并发的区别我总是记不住……

Johnny Boursiquot: 我一般玩得比较安全,我会说“并发”。这样比较保险,哈哈……

Miriah Peterson: 这是另一个话题了,我觉得…… [笑]

Johnny Boursiquot: 好的,好的。所以,pprof 工具提供了很多调整的选项,而且确实有很多。不过我通常觉得有趣的是 CPU 分析,它和内存分析是不同的……还有一个追踪功能,可以更清楚地显示 goroutine 和相关的执行情况。我想,Bryan,按照你的思路,你有了一个起点,比如某个函数;接下来你要弄清楚,“有哪些选项?我可以做些什么?” 比如,确定那些“尴尬并行”的问题,看看是否有并发机会你没有利用到。也许问题就是这么简单吧?如果一个函数的多次运行之间没有什么依赖关系,也就是说,数据之间没有依赖,那这可能就是一个很好的并发机会,对吧?你可以启动一些 goroutines……如果你事先知道需要多少个,也许可以用一个等待组;如果不知道,也许可以用一些通道进行通信……然后你就开始逐步剥开问题的层层表面,弄清楚“接下来该怎么做?” 但说到这,我想回到那个函数可能已经优化过的观点……我们怎么知道它已经优化了呢?还有什么工具可以帮助我们确定这个函数在给定的数据下能够稳定地表现?

Miriah Peterson: 你提得很明确,我觉得你是在指向基准测试工具……[笑] 不过在我们讨论基准测试工具之前,我想说……我以前在本地组织了一个 Meetup(现在不再组织了),我曾经为这个 Meetup 准备过关于 pprof 的演讲,还做了一些视频……但老实说,我个人从来记不住 pprof 工具到底是怎么用的。我记不住这些工具的所有含义,也记不住所有图表的意思……Go 的 pprof 里有火焰图,传统的火焰图,还有一个叫做新火焰图的东西。这个工具可以告诉你,“Go 的编译器是否为你做了优化?它是否为你内联了一些函数?” 你可以从火焰图中直接看出来,编译器是否为你进行了优化。所以,第一步,把 pprof 加入到你的程序中。第二步,查看火焰图,看看“是否有函数已经为我内联了?编译器是否已经为我做了一些优化?” 接着,你可能会发现某个函数……你看着那个网页图---这是他们的另一个工具。顺便说一句,如果你想知道它的具体含义,我在 YouTube 上有一个视频,详细讲解了它。这是可视化的,所以更容易理解。

你可以看到,“哦,这是一个昂贵的函数,它占用了很大的 CPU 和内存……很好,我想看看能不能优化它。让我写一组基准测试,打开内存标志,看看能不能调整这个高成本函数。” 所以,我不觉得这是非此即彼的选择。我认为这些工具需要联合使用。

我个人觉得基准测试总是最后一步,最后的“很好,我的程序已经工作了……我现在试图优化它……也许它不是最优化的状态,但我尽量让它达到 80% 的优化。我已经识别出一些占用大量 CPU 的函数,或者占用大量内存的函数”,你可以通过 pprof 很容易地识别这些问题。“现在让我选择这些函数,打开正确的标志,开始编写基准测试,看看能否优化它们。” 基准测试的工作方式是,不是只运行一次,而是默认情况下在给定的时间窗口内尽可能多地运行它,从而给出一个平均性能。所以你可以看到,“很好,平均来说,我的这个函数分配了 3000 字节。” 这将是一个非常大的问题,你应该解决它。然后你可以问自己,“我能不能让它更低一点?能不能让它不那么占用内存?” 然后你发现,“哦,它每次运行需要 300 毫秒,但旁边的函数每次只需要 20 纳秒?” 也许我可以做一些权衡,这样这个函数就不会成为整个系统的瓶颈。

所以,当我讲解基准测试时,我总是说的两件事是:看看你的函数运行需要多长时间,然后看看你在系统中分配了多少字节。这是我认为可以开始调整的第一个开关。我自己在工作中不是特别常用。我很少需要通过基准测试来证明优化。我从没在那种公司工作过,速度慢到会影响公司盈利……我总是在那种需要把基础设施从一个地方迁移到另一个地方的公司,所以我总能有足够的预算去构建新东西。不管怎样,这仍然是我为大家指明的方向。

Johnny Boursiquot: 你真幸运。

Miriah Peterson: 我知道。当他们告诉我开始修复东西时,我就会说---

Johnny Boursiquot: “我走了。” [笑]

Miriah Peterson: “换新工作吧。去找一个新的‘绿地项目’。” 但这也是为什么我对这个话题如此感兴趣---也许我没有在工作中用到,但现在我需要学的足够好才能教别人,因为我确实认为它非常重要。所以这是给你的基准测试推荐,Johnny。

Johnny Boursiquot: 很好,很好,很好。Ian,对基准测试的讨论有什么要补充的吗?

Ian Lopshire: 抱歉,我有点走神了……没什么特别的……

Johnny Boursiquot: 你需要基准测试一下你的思路。

Ian Lopshire: 确实……我喜欢它们结合使用的想法。你发现了问题,用 pprof 找到了占用大量资源的地方,比如分配了大量内存,或者使用了很多 CPU 周期……接下来的步骤就是编写基准测试,这样你就能知道你是否真正做出了改变。我喜欢它们结合使用的想法,必须要结合使用,对吧?

Bryan Boreham: 是的,这让它变得可重复。我们最开始的假设例子是,在生产环境中测量某些东西,测量真实发生的事情……但你可能没法那么轻易地重新创建它,而且你也不想在生产环境中做太多实验……所以将特定的任务提取出来,做成一个小的独立程序---那就是基准测试。然后像 Miriah 说的那样,你可以反复运行它,这样我们就能得到一个平均的时长…… Go的测试框架会为你做这个。

我也做过一些这方面的教学,我觉得结果大概是对半开的。有些人看过基准测试,喜欢它们,经常用它们……而另一半的人基本上从来没接触过基准测试。也许他们在文件里偶尔刷到过几次,但从没真正看过它。

所以我当然会鼓励大家---这是一个非常简单的模式。你只需要写一个循环,反复运行你感兴趣的东西……而更复杂的部分是设置测试条件。但这与任何单元测试是一样的。它只是一个可以反复运行同一事务的单元测试。现在你真的可以开始迭代了,尝试一些想法,运行基准测试……它变快了吗?还是没有?再尝试其他东西。

一次只改变一件事。这是另一个重要的建议。当你兴奋的时候,你会有很多想法。“我要把它们都写进去。它一定会更快。” 但要一次只改一件事。改一个,测一次。再改一个,再测一次。这样才能真正弄清楚问题出在哪里。

Johnny Boursiquot: 有时候,改变那一件事可能意味着要将它部署到生产环境,看看结果是否真的不同,对吧?

Bryan Boreham: 是的,确实有可能。我是说,这取决于你的基准测试有多好。有些情况下,确实很难模拟真实的生产环境。还有一些需要注意的事情。你知道我之前提到过处理器每秒可以执行数十亿个操作吗?但前提是你不能使用超过几十KB的内存。一旦你超过了 L1 缓存,整个系统的速度就会下降 10 倍。如果你再超过 L2 缓存,速度又会再下降 10 倍。所以,当你在基准测试中试图重现问题时,你需要小心,不要让测试规模太小。因为如果它太小,就会不自然地适应处理器的紧凑缓存环境……

这属于“机械同情”(mechanical sympathy)的一部分知识。要了解处理器架构和不同级别的缓存等方面的知识是非常庞大的任务。我不认为每个人都需要学会这些,但至少应该了解一些基本的东西---比如,你不想让基准测试的规模太小。你不希望它大到需要一天时间来运行,但也不希望它小到不切实际地快。

Johnny Boursiquot: 说到缓存和内存,关于内存使用优化的整个话题有自己的一套术语。当我第一次学习堆(heap)、栈(stack)和内存分配这些概念时,我心想:“我在编写程序时需要关心这些吗?我需要担心变量的声明和保留吗?Go 是垃圾回收(garbage collected)的语言,难道它不会自动处理这些事情吗?” 我们能不能给内存优化的话题做一些定义?

Miriah Peterson: 不。[笑] 在 Go 的文档中,它明确指出:“你不需要知道什么是写入栈还是堆的区别。” 这是直接从 Go 的官网 go.dev 上摘下来的。我也在幻灯片中引用了这句话,我会告诉大家:“从技术上讲,这并不重要,但从概念上讲,我认为它确实有帮助。” 因为它可以帮助你做出选择,比如“哦,这里我是不是应该使用指针?或者这里我不应该使用指针?” 了解 Go 在幕后如何使用指针是有帮助的。比如,字符串在幕后总是使用指针……所以在 Go 中共享字符串要比共享字节切片(slice of bytes)或其他奇怪的东西更容易。但大多数情况下,这并不重要,因为垃圾回收器会处理它。但我认为它确实重要的时候,是当你做了一些蠢事来阻止垃圾回收器工作……人们经常会这样做。另一个它重要的时刻是,当你看到人们开始从其他语言中引入设计模式时。

我们总是开玩笑说那些从 Java 转来写 Go 的开发者,他们会带来一些可能在 JVM 上运行更好的代码设计,但这些设计并不适用于 Go 的编译器或类型系统。Bill Kennedy 的书《The Ultimate Go Notebook》 是我最喜欢的 Go 书之一,因为它包含了所有那些不常见但很实用的小技巧。其中有一条用加粗字体写着“不要使用 getter 和 setter。” 每次我说这句话时,所有曾经写过 Java 的人都会问:“为什么?!我们需要这些!” 我会回答:“有时候你确实需要它们。” 比如当你有一个私有的类型时,你可能需要通过方法来访问它。是的,这是 getter 和 setter 的一个好用例。但如果是公有类型,Go 编译器可以内联你所有的调用,并为你进行优化,而不是通过一个函数调用来执行这些操作,这样会增加堆栈上的字节数……每个函数都会占用更多的内存,并且需要通过接口进行额外的调用来完成所有这些事情……编译器的目标是快速;它只能在达到某个速度阈值之前进行有限量的内联操作。

所以我们应该按照 Go 的惯用方法来构建代码,这将帮助编译器更快地优化代码。这就是为什么我认为,虽然这些细节本身可能不重要,但如果我们了解这些背景知识,就可以更好地理解为什么 Go 的惯用方法是这样,为什么这段代码是好的,而那段代码是不好的,或者说是“Java 风格的代码”。不是说 Java 不好,只是说在 Go 中写类似 Java 的代码可能会,或者确实会,导致系统效率降低,因为这是一个不同的编译器、不同的系统、不同的类型签名。因此,虽然这些细节本身并不重要,但它们确实能帮助我们写出更好的代码。

Ian Lopshire: 我觉得我们应该把这叫做“编译器同情”(compiler sympathy),或者类似的东西……

Bryan Boreham: 是的……我认为值得尝试理解栈和堆这两者,它们的基本区别在于生命周期。如果你进入一段代码或函数,并且有一些数据,这些数据的生命周期只持续到函数结束。然后 Go 的整个系统---编译器和运行时会协同工作来进行内存管理。所以,如果你的数据的生命周期仅限于一个函数,编译器可以非常快速地清理它。这就是栈的概念。每次我们调用一个函数,数据会像堆叠一样叠加在我们之前使用的数据之上。而当我们离开函数时,我们可以清理掉所有这些数据,这基本上就是减去一个数字。

而堆则是存放我们不确定生命周期的地方。所以发生的事情是,你还在使用的东西和你不再需要的东西都会放在堆上。而你不再需要的东西,也就是你不再引用的东西---那就是垃圾。但系统的工作方式是,它会让所有东西堆积起来,直到某个时刻开始进行垃圾回收。而这才是真正影响性能的地方。

那么什么是垃圾回收呢?当 Go 运行时开始垃圾回收时,它会从程序中的那些可以访问数据的地方开始。所以包括所有的全局变量、所有局部变量中的指针等等。它会创建一个列表,接着会说:“好,这个指针指向了什么?哦,这个东西需要用到,我还能访问它。它有指针吗?好,我要访问它们的每一个。这些数据是需要的。当我到达那里时,它们有指针吗?” 这在大程序中,或者说在任何规模的程序中,都是非常繁琐的工作……它需要跟踪所有的指针,这就是会拖慢程序的原因。这就是为什么在 Go 中内存管理对性能非常重要。

影响垃圾回收成本的有两个因素。首先是你有多少指针。这基本上取决于你实际需要的内存有多大。如果你的整个程序只使用 16K 的内存,那就没多少指针。而我的程序通常会使用几 GB 的内存。所以会有成千上万,甚至几百万个指针,它们需要相当多的时间来追踪。

所以,指针的数量(基本上取决于堆的大小)是一个因素。然后是你留下垃圾的速度有多快?你生成新垃圾的速度有多快?这两个因素相乘,就会得出垃圾回收的成本。并且这两个因素都由你使用的内存量决定。第一个是你实际需要的内存量,第二个是你创建并丢弃的垃圾量。

Johnny Boursiquot: 而每次清理运行时,程序实际上都会暂停。

Miriah Peterson: 垃圾回收是一个“暂停世界”(stop the world)操作,没错。不过我从来没有感受到它的明显影响。它不会停止运行时,对吧?

Bryan Boreham: 自从 Go 1.5 以来就没有了。垃圾回收有两个阶段。标记操作---这一过程叫做“标记-清除”(mark and sweep)。标记阶段是我刚才提到的那个过程,我们会跟踪所有的指针。这个阶段可以并发进行;让我们再用这个词。与……我不知道哪个是并行,哪个是并发。希望会有人在推特上纠正我们。

Miriah Peterson: 可能是并发,因为如果它碰到锁的话就会生气。所以我同意,并发。[笑]

Bryan Boreham: 标记阶段可以和你程序的其他部分同时进行。当标记完成时,当我们知道哪些内存是需要的,哪些是不需要的,我们就进入清除阶段。在这个阶段,我们基本上把所有的垃圾变成可用的内存。这是一个“暂停世界”(stop the world)的操作,不过它真的非常短,通常只需要几微秒。而对于一个 GB 级别的堆,标记操作可能会持续几秒钟。

Miriah Peterson: 我现在要偷用这个解释了。我以前总是把垃圾回收解释为清除阶段……我总是说,垃圾回收的标记是在并行进行的,接着就是垃圾回收。我现在要改用这些术语了,不管它是更混乱还是更清楚,我不知道,但它确实更准确。而准确才是最重要的。所以谢谢你,Bryan,今天教了我这些东西。

Bryan Boreham: 不用谢,乐意之至。在 Go 1.5 之前,整个过程都是 [无法听清 00:41:14.15],这让很多人感到相当不满……但现在它是并行运行的。其实你可以在你的性能分析(profile)中看到这一点。在 CPU 性能分析中,你会看到垃圾回收器在运行。垃圾回收器的名字有点奇怪,它不会直接以大大的“垃圾回收器”字样出现,但你可以看到一些像 mallocgc 这样的函数。通常它们的名字中会有 gc 的字样,你可以在 CPU 性能分析中找到它们。但如果不想让事情变得太复杂,需要注意的不仅仅是标记和清除的时间……因为整个遍历内存的过程会把很多数据踢出 CPU 缓存。

我之前提到过,CPU 中间的那一点是唯一能以最快速度运行的部分……标记过程中,我们扫描所有数据,确定哪些是需要的,哪些是不需要的---这个过程会踢出缓存的数据,因为它会去访问所有的东西。因此,垃圾回收对程序整体的影响不仅仅是性能分析中显示的垃圾回收时间。

换句话说……如果你查看性能分析,发现垃圾回收占用了你整个 CPU 的 20%,然后你把垃圾回收减少一半,我会预期你的程序速度会提升 40%。因为它的影响是你所能看到的测量值的倍数,通常是两倍左右。 (译者注:个人经验是如果超过30%就要尝试干预,进行优化)

Johnny Boursiquot: 那么,作为程序员---我可以采用 Miriah 的方法,基本上说“你知道吗?没必要担心我是不是在这里用了指针,或者在那里用了某个值……”嗯,我不确定是否正确理解了---如果我理解错了,请纠正我,Miriah。如果我说错了,你可以指出来。但你是在说:“不要太担心你是否在用指针,或者是值传递……让垃圾回收器来处理这些问题。或者,先写出能运行的程序,然后再考虑是否有一些内存溢出,或者函数调用时是否有内存逃逸。” 那么,我们究竟应该多关注内存分配的生命周期呢?Bryan 提到的那些内存分配生命周期的影响到底有多大?我们应该如何处理这一问题呢?我还是经常遇到有人问:“什么时候应该用指针?什么时候应该传值?我该怎么做?从性能角度来看,这重要吗?还是说这只是语义问题?我应该返回 nil 还是返回一个零值?我该怎么处理这些问题?”

Miriah Peterson: 我通常会说,所谓的最佳实践---我的建议是默认不使用指针。然后在一些特殊情况下,这个规则有例外。比如:“哦,你正在使用一个接口,所以你必须有一个实现该接口的类型。” 这些就是例外情况……我不知道,我开始对编写优秀软件和编写优秀的 Go 代码变得挑剔起来。如果你到了某个阶段,所使用的类型已经对垃圾回收速度产生了影响,也许 Go 这个语言并不适合你。你可以去用 Rust,它没有垃圾回收机制,并且让你不得不考虑这些问题。

我不想遇到那样的问题。我使用 Go,我会说“垃圾回收器帮帮我吧。” 我不会使用指针,除非在某些情况下指针确实是最佳选择。我会使用切片,直到需要使用数组的时候。我觉得 Go 的设计是有意地把很多底层的东西抽象掉了,当我们需要用到这些低层知识时,我并不确定 Go 是否是最佳选择。也许我有点唱反调,但我只是用 Go 来做它擅长的事情。而 Go 擅长的是作为一个非常简单的语言,帮你完成很多工作。你应该还是要了解它的工作机制,还是要使用 pprof,做性能基准测试,了解底层原理,并写出优秀的 Go 代码,高效的 Go 代码。但当你超过了某个点,也许你应该考虑一下 Rust。我不知道。再说一次,我还没有遇到那个点,我还在使用 Go。所以这只是我的想法。

Johnny Boursiquot: 但我们经历过那样的一个时代---我相信你们都记得,在 Go 社区里我们经历过一个时期,那时大家都有点……我们都反对内存分配。我无法告诉你我见过多少关于 HTTP 路由器的性能测试,都是关于“哦,这个是零分配的”,或者“这个没有……” 我们经历过那个阶段……

Miriah Peterson: 那我们为什么还需要垃圾回收器呢?我不知道……只要写出好 Go 代码,而好 Go 代码是使用 Go 提供的工具写出来的。

Bryan Boreham: 我有一个例子……

Miriah Peterson: 你讲吧,Bryan。你比我经验丰富。

Bryan Boreham: …这个例子应该是比较中立的,这是很多人会用到的一个模式……

Miriah Peterson: 我并不是想划清界限,我只是说这是我的经验。你继续,Bryan。

Bryan Boreham想象一下,在你的程序中你决定要创建一个切片,并向其中加入一些元素。在 Go 语言中,使用 append 是非常便利的。基本上有两种方式来实现这个操作:一种是从一个完全空的切片开始,然后不断地使用 append 操作。假设你要在切片中加入 100 个元素,因此你会不断地 appendappendappendappend…… 在底层,刚开始时并没有分配任何内存,切片是空的,没有分配任何空间。当你放入第一个元素时,它会分配一个位置。然后你再放入另一个元素,它会说:“好吧,空间不够了,这次我要分配更多空间。” 我不想深入探讨细节,但假设下次它会为三个元素分配空间。然后你填满这些空间后,它可能会为八个元素分配空间。不要纠结具体的数值,重点在于那些我们不再需要的小切片会变成垃圾。如果我们一开始就知道要放入 100 个元素,我们可以在一开始就调用一个函数,创建一个有 100 个空间的切片。这样,整个 append 过程就不会产生垃圾。

我希望这个例子在音频中能够比较容易理解…… 有一些非常简单的模式,比如预先分配你的数据结构到一个合适的大小。如果你知道要放入 100 个元素,那就直接创建一个大小为 100 的切片。如果你大概知道是 100 个,可以分配成 120 个,类似这样。因为即使是一次不必要的内存分配,其代价也可能比多分配 10% 或 20% 的空间更大。

如果你不确定是 100 个还是 100 万个元素,那当然,你可能会有一些浪费…… 但尽量接近正确的大小,并倾向于稍微多分配一点,这会帮你提升性能。这类技巧有很多。

Miriah Peterson:我同意。这就是编写优秀 Go 代码的一个例子。我认为这正是你应该处理的方式……如果你能够预测到,比如你知道这个数据会如何表现,那么你应该明确地分配所需的空间。我也同意,切片在底层的实现很有趣。

Johnny Boursiquot:它们的代价一般是很小的,但确实存在一些成本……是的,这是一个很好的建议。随着讨论接近尾声,作为 Go 程序员,还有没有其他类似的显而易见的建议?不一定是为了进行过早优化,而是像你们所说的那样,在日常开发中,如果你知道一个数组或切片的大小,在一开始就进行预分配,这似乎是一个显而易见的好做法---不一定是在优化的精神下,而是为了编写良好的 Go 代码。你们还有其他类似的最佳实践建议吗?

Bryan Boreham:嗯,映射(maps)也是一样的。切片和映射都可以在创建时指定大小。映射要复杂得多,但基本原理相同:如果你知道会有 1000 个元素,告诉运行时在创建时为 1000 个元素留出空间,这样一切都会运转得更好。

很多节省内存分配的技巧都比较晦涩。不幸的是,除了这些基本操作之外,比如在循环开始时调用 make,设计接口时,当你调用某个函数并且它返回一个切片时,如果你可以传入目标切片会更好,因为你可能已经有一个大小合适的切片可以传入…… Go 标准库中有一些这样的 API。不过,这确实会让事情变得复杂……

我想说的是,尽量让代码优雅,然后再考虑性能优化。除非真的非常有必要,否则不要打破这个规则。通常优雅的代码本身就能运行得很快。我真的不希望大家把程序搞得乱七八糟,只是因为他们认为这样可以节省几个字节,或者几纳秒。遵循 80/20 法则。大部分时间消耗通常集中在一个地方或几个地方。在这些地方,你可能需要做一些小技巧。

Miriah Peterson我认为我的大部分建议都很显而易见…… 遵循惯用法,小心使用指针…… 我建议不要在没有必要时使用它们。你需要指针的地方是显而易见的---我觉得 Bryan 提到了一个很好的点…… 如果你要填充某个数据结构,并且需要修改其中的数据,传递指针并在对象内部修改数据要比传递一个副本并返回新对象好得多。这类操作我认为是编写良好 Go 代码的典范。但我还想说,尤其是对于新手,找一个优秀的代码检查工具(linter)非常重要。所有优秀的代码检查工具都会检查诸如“你是否关闭了 SQL 连接?你是否关闭了 HTTP 连接?你是否关闭了文件连接?” 这些小问题往往是我容易忘记的,导致内存问题…… 它们都很简单,在代码审查中也不一定能被发现。

因此,代码检查工具,尤其是当你对它们设置得非常严格时,能够帮助你编写出优雅的代码,也就是良好的 Go 代码,这可以避免很多愚蠢的内存问题或 CPU 问题…… 当问题真的出现时,它会是一个真正的问题,而不仅仅是因为有人忘记关闭文件。这些是我认为大家应该从一开始就注意的,然后再逐步深入。无论如何,它确实救了我几次。

Johnny Boursiquot:不错。在我们结束之前,Ian 还有什么要补充的吗?

Ian Lopshire:我对于早期优化的看法是:尽量减少工作量。因此,如果你要使用正则表达式,那就编译一次并重复使用。如果你要使用模板,那就编译一次并重复使用。我经常看到,有人在处理器中定义了正则表达式,并且每次都会重新编译。所以,即使在你进行性能分析或基准测试之前,如果你发现有一些不必要的工作量,那就是最低垂的果实---做更少的工作。

Jingle:(插播音乐)

Johnny Boursiquot:好吧,来吧,我听着呢,谁有不受欢迎的意见?

Miriah Peterson:我先来。

Johnny Boursiquot:请讲。

Miriah Peterson:我有两个不受欢迎的意见。第一个是“巧克力很难吃,因为我不喜欢巧克力。”

Johnny Boursiquot:[笑] 好的。

Miriah Peterson第二个---我可能之前提到过。我认为 Python 是一个不适合数据工程的语言。我觉得它非常适合数据分析或数据科学……但对于数据工程本身来说,Python 是一个缓慢、臃肿的语言,它只是封装了其他语言。那么,为什么我们要使用 Python,而不是它底层的那些语言呢?已经有很多人反驳我了,所以我知道这并不受欢迎。

Johnny Boursiquot:不可能吧……[笑] 哇,好激烈的言论。

Ian Lopshire:我希望它是对的……

Miriah Peterson: 希望?这是事实。这是在“不受欢迎的事实”部分,抱歉。

Johnny Boursiquot:哇……

Ian Lopshire:我每天都在 Go 语言中工作……然后为了处理数据,我还得切换到 Python,重新记得怎么用它……所以我很希望不必这么做。但基础设施及其他东西,使用别的语言做起来实在太麻烦了。

Miriah Peterson你还会经常看到的另一个 Python 场景是,Python 的 SDK 有时并不实际执行代码,只是配置另一个服务。而我想,我们不是已经解决了这个配置问题了吗……这就是为什么我们在 DevOps 世界中使用 YAML,因为它在配置方面比 Python 好得多?所以总之,我认为在数据工程中使用 Python 没有道理。

Johnny Boursiquot:哇。我们看看当我们进行调查时,你会因为这个观点招致多少批评……

Miriah Peterson:我们看看有多少数据工程师在听这个播客吧。这就是我们要看的。

Johnny Boursiquot:是的,这可能会给我们带来全新的听众群体……[笑] Bryan,你有不受欢迎的意见要分享吗?

Bryan Boreham:嗯……我的意见比巧克力要小众得多。我的不受欢迎意见是关于 Prometheus 查询语言 PromQL 的---我不知道你们是否熟悉它……

Miriah Peterson:我很熟悉。

Bryan Boreham它有两个类似的函数---``rateirate。我的观点是永远不要使用 irate

Johnny Boursiquot:告诉我们为什么。

Bryan Boreham它们的区别在于---你给出一个窗口,对吧?你想查看的时间范围。基本上,当你缩放时,你会查看更大的窗口。比如,当你放大时,你可能会查看一分钟内的 rate,而当你缩小时,可能是五分钟,进一步缩小可能是一个小时……而 irate 只考虑窗口中的最后两个点。因此,你不应该使用它,因为当你缩小窗口时,它会丢弃越来越多的数据。这大大增加了你会得到错误数据的可能性。假设你查看一个五分钟的窗口,而你只看窗口中最后的两个点。如果每五分钟有一个大的峰值---它看起来就像这个事情是持续发生的。

Johnny Boursiquot:对。

Bryan Boreham:它确实有用,但使用它的场景非常少,而且你必须对具体情况非常了解,所以我通常会说“永远不要使用 irate”。

Johnny Boursiquot:这对我来说其实很合理,所以我不确定这会有多不受欢迎……但我相信会有一些人认为这不受欢迎。

Bryan Boreham:是的,irate 被很多人使用。我在各种人的仪表板中都见过它。

Ian Lopshirei 代表什么?

Bryan Boreham:我认为是“瞬时(instantaneous)”。

Johnny Boursiquot:听起来它是一个针对非常特定用例的工具。

Bryan Boreham是的,我想人们喜欢它是因为它让你的图表---因为 rate 会随着缩放平滑数据……而 irate 会保留数据中的峰值。当你使用 irate 时,图表看起来更有活力,显示的内容更多。

Johnny Boursiquot:所以我认为这可能取决于你如何消费和可视化这些数据……

Bryan Boreham:是的。

Johnny Boursiquot:有意思。很酷。我有一个要带回家的观点……最近,苹果发布了一款有趣的开源产品---这不是经常能看到的:苹果开源。这不太常见。不过他们最近发布了一款让我觉得非常有趣的开源软件。他们推出了一种叫做 picklepkl 的配置编程语言……我敢说,pkl 比 JSON 和 YAML 加起来还要好。

Bryan Boreham:哇……

Ian Lopshire:它和 CUE 怎么比?

Miriah Peterson:我本来也想问这个问题。

Johnny Boursiquot:这是我脑海中第一个比较的对象。“嗯……CUE 语言。” 所以我要比较一下这两者,然后再汇报。事实上,我正在计划做一期关于 CUE 核心贡献者的节目。也许我会问他们:“嘿,现在你们似乎有了竞争对手。” 我觉得他们可能在解决同类问题---也许 CUE 有更多的细微差别,但它们可能在要解决的问题上有重叠。所以,是的,我需要更深入地研究……但我看了一个视频,读了一些文档,觉得:“你知道吗?这确实有道理。” 就像我当时看 CUE 时想的那样:“这确实有道理。” 所以我们拭目以待吧。但这就是我的不受欢迎意见。看看结果如何。

太棒了……那么,Ian,你有要补充的吗?

Ian Lopshire:没有……

Johnny Boursiquot:没有……[笑] 他今天只是沉默寡言。好吧,好吧,好吧。那我来结束吧。