inc()
- 当程序需要将 counter +1 的时候,没有直接操作对应的 metrics,而是将要增加的 metrics 用自己的格式打包出一个对象,然后将对象发送到一个 channel,每一个 metric 都对应一个 channel
- 程序启动之初就会启动全局唯一的 worker goroutine, 这个 goroutine 负责操作所有的 metrics:它从不同的 channel 中拿到消息,然后解包出来,找到对应的应该增加的 metrics,然后执行最终的增加操作。
实际的操作还要复杂的多,先创建一个 MetricsBuilder,然后 MetricsBuilder 有 Add() 函数,实际上是往 Channel 里面发送了一条信息,Channel 里面读出来,又通过一些系列的层层调用执行到 metrics + 1 上。
事先声明, 本人不会写 golang,本文可能写的不对,请读者指正。
metrics.Add()
一开始我以为 channel 可能是一种高性能的无锁的操作,但是看了 golang 的 runtime 部分,发现也是有锁的,如果多个线程同时往一个 channel 里面写,也是存在竞争条件的。
而 Prometheus 的 client_golang 只是执行了一个 Add 操作:atomic.AddUint64(&c.valInt, ival)
虽然 atomic 也是一个 CAS 操作,但直觉上我觉得用 channel 是不会比 atomic 快的。
写了两段代码来比较这两种情况(测试代码和运行方式可以在 atomic_or_channel 这里找到):
直接用 atomic 代码:
模拟开一个 channel 负责增加的情况:
参数如下,意在模拟 100 个并行连接,需要增加1百万次:
实际运行的结果也和我想的一样,atomic 要比 channel 的方式快了 15 倍。
atomic 2s 就可以完成模拟 100 个客户端并行增加1百万次,即可以支持5千万的 QPS (还只是在我的笔记本上),而相同的操作用上文描述的 channel 的方式需要 30-40s。慢了15倍。
虽然我在有些地方说 atomic 很慢,但是这个速度对于 metrics 统计的这个场景来说,是完全足够了。
Twitter 上的 @Kontinuation 提醒有一种优化的方式,我觉得这个很好:
每一个 thread 维护自己的 threadlocal 变量,这样完全不需要锁。只是在 collect metrics 的时候采取收集每一个 thread 的 counter 等。TiKV 中就是使用这个方法实现的 Local 指标(@_yeya24),即每一个线程保存自己的指标在 Thread Local Storage,然后每 1s 刷新到全局变量(其实我觉得可以只有在 metrics 被收集的时候才刷新?),这样可以减少锁的次数。
但是在 golang 里面,从这篇文章发现 go 语言官方是不想让你用 thread local 的东西的,而且为此还专门让 go id 不容易被获取到。那我就像不到什么比较好的实现方法了。