Golang 的 Channel 是一种免费的无锁实现吗?

今天看到一段代码,是用 Prometheus 的 client_go 暴露 metrics 的代码。里面不是简单的将对应的一个 metric counter inc()的操作,而是自己实现了一个非常奇怪的逻辑:

  1. 当程序需要将 counter +1 的时候,没有直接操作对应的 metrics,而是将要增加的 metrics 用自己的格式打包出一个对象,然后将对象发送到一个 channel,每一个 metric 都对应一个 channel
  2. 程序启动之初就会启动全局唯一的 worker goroutine, 这个 goroutine 负责操作所有的 metrics:它从不同的 channel 中拿到消息,然后解包出来,找到对应的应该增加的 metrics,然后执行最终的增加操作。

实际的操作还要复杂的多,先创建一个 MetricsBuilder,然后 MetricsBuilder 有 Add() 函数,实际上是往 Channel 里面发送了一条信息,Channel 里面读出来,又通过一些系列的层层调用执行到 metrics + 1 上。

事先声明, 本人不会写 golang,本文可能写的不对,请读者指正。

感觉本身就是一行 metrics.Add() 的事情,为什么要搞这么复杂呢?思来想去,我觉得唯一可能的解释就是:这是一个负载极高的系统,希望将 metrics 的操作变成异步的操作,不占用业务处理的时间。但是用 channel 还涉及到打包和解包,真的能快吗?

一开始我以为 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 不容易被获取到。那我就像不到什么比较好的实现方法了。

 

 

Prometheus alert rules 拆分成多个查询表达式

Alertmanager 发送出来的告警是一条消息,一般我们会用 annotation 来说明发生什么事了。

但是 Grafana 发出来的,就会直接带上你的查询表达式当前的状况:

这个图是非常有用的,如果有图的话,基本上一看你就知道发生什么事情了,因为它可以告诉你一个 time series 图,是一个 Range 消息,如果没有图的话,就相当于你只能知道一个数据点的信息(Instant)。后面需要连接 VPN,打开对应的监控,去查看到底发生了什么。

但是我们依然没有选择用 Grafana 来做 Alert 系统,而是选择了 Alertmanager 来做。事实上,上面这个优点几乎是 Grafana 唯一的优点,在其他方面它都不如 Alertmanager,这个后面有时间再说吧。本文是想谈谈有什么可能让 Alertmanager 发出来消息的时候带上一个曲线图。

其实这件事情没有什么难度,只要在发出告警的带出一条渲染好的 URL 就可以了。唯一比较复杂的是,prometheus 对 alert 的逻辑是:如果一条查询的结果不是 null,那么就 fire,否则,说明一切正常。

比如 up == 0,正常情况下 up 值是 1,所以这条查询不会有结果。如果目标挂了,这么就成立了,这条查询就会有结果。

比如 rss_memory > 1G,正常情况下小于 1G,也不会有结果。但是如果超过了,查询就有结果了,就会 fire alerts。

所以如果你把 alert rules 的这条 expression 画出来的,就会长这样:

可以看到,触发 alerts 的时候就不是 null,threshold 以下的就都是 null 了。

一个解决方法是,我们可以将这个查询表达式拆开,分成作值和右值。分开画图。就可以看到像下面这样的效果:

本以为会有现成的工具可以拆开 alert rules,将其拆成两个表达式。然后调查了一番,发现并没有这种东西。

那么 Grafana 是怎么做到的呢?很简单……Grafana 设置的 alert rules 的右值只能是一个固定的数字,必须按照固定的格式填入。

Prometheus 用户组发现一个哥们也有相同的问题,一个哥们回答说 Prometheus 的 go 库就支持这个功能,有一个 ParseExpr 函数,传入一个 string 的 PromQL 代码,传回一个 Expr 解析好的表达式。这哥们是 Promlens 的,这是一个付费的服务,可以解析和可视化 PromQL。

然后又找到了一些可以解析 PromQL 相关的库:

最后决定用 metricsql 来实现。唯一的缺点是这个库是 golang 写的(VictoriaMetrics 所有的产品都是用 golang 写的)。我的项目是基于 Python 的。

我决定用 golang 实现我需要的功能:传入一个 expression,然后传回 split 好的语法树。然后用 CGO 编译成一个 shared library,最后在 Python 中用 cffi 来调用。

核心的逻辑很简单,即使将 expression 用 metricsql.Parse 函数去处理,得到一个语法树,然后递归遍历一遍:

  • 如果遇到逻辑操作符,比如 and, or, unless, 那么说明左边和右边依然是一个 expression,递归分析;
  • 如果是比较操作符:==, >, < 等,那么它的左值和右值就直接拆开,用于画图,到此递归结束。有的时候会遇到表达式依然是比较的情况:(a > 10) < 100,这种情况可以将 a > 10 看成是一条普通的线,直接返回即可,没有必要继续拆分;

为了方便测试和维护代码结构,go 写的核心的逻辑和 CGO 处理的部分分开。下面再写一个 CGO 函数,它的入口参数必须都是 import "C" 这个库提供的,将会用于 ABI。返回的参数也是。

编译方法如下:

go build -buildmode=c-shared -o libpypromql.so

这样就得到了一个 .so 文件和 header 文件。

下面就是用 Python 去调用这个 so 文件了。直接用 cffi 的接口就可以,这个很简单,基本上就是描述一下这个 so 的 ABI,然后用 cffi 去调用,解析结构。需要特别注意的是,golang 的 gc 不会释放它 return 的 C.CString,这部分必须手动释放:

最后一步,因为是用 Poetry 打包的 Python package,所以还需要写一个 build.py 告诉 poetry 怎么 build whl

还写了一个命令行的工具,可以从命令行拆分 expression,效果如下:

为了测试解析结果的正确性,我把 awesome-prometheus-alerts 项目所有的 alerts 抓下来了,用来生成了 400 多个单元测试。

项目的地址是:https://github.com/laixintao/promqlpy

 

Prometheus HTTP SD 框架

Prometheus 是现在比较流行的监控系统,它的工作模式是拉的模式:要监控的目标要负责把 metris 数据暴露出来,格式是普通的文本格式,协议是 HTTP,就像开放了一个普通的 HTTP 服务一样,然后 Prometheus 定时来这些 HTTP 接口收集 metrics,保存起来。

那么如何告诉 Prometheus 应该去哪里抓取这些 metrics 呢?

你可以直接把要抓取的目标写在 Prometheus 的配置文件里面去。但是这样一些经常变动的目标就不是很友好。

Prometheus 本身支持很多服务发现,比如 consul_sd, 配置一个 consul 的地址,Prometheus 会通过 Consul 发现抓取的目标。还有其他的一些支持云上的发现方式,比如 aws 的 ec2_sd 等。

但是也有很多是不支持的,比如 Etcd,Redis 等就不支持。

更重要的是,很多公司都会有自己内部的一些 cmdb 管理系统,机器资源管理系统,服务治理等,如果要让 Prometheus 自动根据这些信息去抓取 metrics,一种方法是定期向 Prometheus 支持的一种 sd 去同步数据。另一种方法是使用 Prometheus 的 file_sd 或者 http_sd, 即将自己的信息转换成某种 Prometheus 能够直接使用的格式。

prometheus-http-sd 就是这样一个框架,它支持用户自定义编程,通过实现一个 generate_targets() 函数,来返回 Prometheus 的 targets 格式。

然后,启动 prometheus-http-sd,它会 listen 一个端口,通过 HTTP 的服务暴露出来抓取的目标,给 Prometheus 做服务发现。每次 Prometheus 发送来 HTTP 请求的时候,会向后端去调用用户定义的 generate_targets() 函数,然后将函数的返回结果通过 HTTP Response 返回给 Prometheus。

这个项目的想法和 prometheus 的本职一样,所有的东西都通过 http 暴露出来 (metrics),如果你有一个接口,那么你就可以将其转换成 http 格式的 metrics;如果你有一个数据源,那么你就可以将其转换成 HTTP 格式的 targets。

https://github.com/laixintao/prometheus-http-sd

它以树状的目录组织 targets,即你可以将同一级不同目录的 targets 分配给不同的 Prometheus 实例来抓取。这样,也支持同一组目标用两个 job 来抓不同的 metrics 路径。

支持热加载,如果修改了 target generator 不需要重启应用。推荐用户使用 git 维护 target generator,然后只要更新这个文件夹就可以了。

支持 Dry run 模式,这样就可以将 prometheus-http-sd 集成到 CI 里面,每次修改 target generator 就可以在 CI 里面自动检查目标生成是否符合 Prometheus 的规范,Python 代码是否有问题。

除了支持用 Python 文件定义抓取目标,还支持静态的文件定义目标,支持 json,和 Yaml(Prometheus 原生的 file_sd 不支持 Yaml,不知道为啥)。

另外 prometheus-http-sd 本身也支持暴露 metrics,可以监控自身,比如 latency,generator 被调用的次数,生成的目标数量等等。

支持 admin 页面,可以用来 debug。

……

其他的一些功能大家可以直接看 Github 的项目主页,一些新加的 feature 都会写在 readme 上面。

目前使用下来,就是我们内部的一些系统比较慢,Prometheus 每 60s 一次来做服务发现的话,会导致有的时候会超时,虽然这大部分时候不是因为一个问题,因为 Prometheus 在 http_sd 失败的时候,会继续使用上一次的结果,但是为了保护后端的系统,我打算给 http_sd 加一个 cache 的功能。

 

程序 Hot reload config 的实现方式

有时候一些进程必可避免的有重启代价太大的问题。比如有一些长连接的进程,重启会断开连接,然后所有的客户端都需要重连,或者进程的内存中已经缓存了很多内容,如果启动的话缓存就凉了,要重新预热。

但是又有一些配置,我们希望修改进程的配置,而不需要重启进程。

在 像设计 UI 一样去设计配置项 中聊过有一种方式是做一个配置中心,然后如果有配置修改的时候,就推送一个请求给应用程序,这样程序那边收到配置的更新的时候做一个 callback 来执行配置的变更。

这个方案很好,但是依赖一些其他的组件,中心化的配置中心,以及程序内需要有 SDK 依赖去接收和解析配置更新的内容。

本来讨论不依赖其他组件的实现方式,即更新文件,不需要重启进程来让文件生效。

File Watch

这是一种比较自然想到的实现方式,也是最糟糕的方式。

它的原理是,进程在启动之后,就开一个线程负责 watch 这个文件所有的变化,一旦发现变化,就执行配置更新的 callback。

这种实现糟糕的地方在于:

  1. 不同系统的文件变更的 API 是不同的,比如 Linux 的 inotify,Mac/BSD 的 kqueue,和 Windows 的 ReadDirectoryChangesW. 为了兼容这些不同系统的 API,一般需要引入一个专业的 SDK 来封装,比如 watchdog
  2. 占用系统的 thread,虽然这个也有可能做成异步的
  3. 占用系统的 fd,这个看起来不严重,但是有的时候在系统的 fd 用完了的时候,我们想要用 hotreload 去修改一个参数来控制程序的行为,就麻烦了;

好处在于 import 一个 SDK 之后,不需要写很多代码就可以实现。

每一次都重新 load

这种方法就是,每次在使用配置项的时候,不使用全局变量,而每次都去解析配置文件。这样也就不存在说要 hot reload 的问题了,因为每一次使用都是一次 reload,所有用到配置的情况,都是最新的。

很多人乍一看觉得这种实现非常蠢,但是你仔细想一下,很多需要 hot reload 的配置的都不是读写很高的。

比如 lobbyboy 作为一个个人用的 ssh server,每次在有连接进来的时候,花几 ms 去 load 一下最新的 ssh key 配置,是完全可以接受的。

最近在写的 prometheus-http-sd 里面的加载 script 和文件的时候,也是对于每一个请求都是即时 load 的,即时这样,也能在 5ms 内完成请求。

这样节省了非常大的工作量:

  1. 不需要写更新配置的 callback 了,直接复用 load 的代码
  2. 不需要引入额外的依赖了
  3. 非常简单,每个人都能理解

这两种方式有一个问题,就是一旦文件发生更改,就立即生效了,没有时间让你去验证文件对不对,而且这是一个单向的 fire and forget 的操作,操作的时候不知道自己的动作对不对,必须要通过旁路去检查,比如看一下日志或者监控。虽然可以有其他 work around,比如先放到其他 location 验证。

SIGHUP

这是最好的方案。程序注册一个 signal handler,在收到 SIGHUP 信号的时候,reload config。

这样的好处是:

  1. reload 是明确的操作人的意图,而不是单纯文件修改了就 reload,语义没有歧义
  2. 实现成本很小

而且,我们一般可以控制 reload 的行为。比如说使用 systemctl reload service 的时候,我们可以自定义 reload 命令:

  1. 先检查配置文件是否合法,如果不合法,放弃 reload;
  2. 给程序发送 reload 信号;

以下是一个例子,在 systemd 的 unit 文件里面可以定义 ExecReload,即定义执行 systemctl reload serivce 的时候所做的事情。

这里有两个技巧:

  1. Systemd 里面会给你一个 $MAINPID 让你用,即主进程的 PID,直接往这里发送信号即可;
  2. 使用 ; 可以定义多个命令,一个启动成功才会执行下一个。要注意这不是 shell 的 ;

这样,我们在 reload 的时候,如果第一个检查不通过,systemctl reload 的 return code 就不是0,我们就知道 reload 失败了。

在操作的时候,我们可以直接在 Ansible 的操作结果中看到动作出错,而不是需要去 ssh 到机器上,查看执行结果是否正确。

 

Coredns 源码解析:启动流程

Coredns 的启动流程不是很好阅读,因为它本质上是一个 caddy server, 是基于 caddy 开发的。也就是说,它是将自己的逻辑注入到 caddy 里面去,相当于把 caddy 当做一个框架来用,实际的启动流程其实是 caddy 的启动流程,Coredns 里面不会看到很明确的准备启动,Start server 之类的代码,而大部分都是注册逻辑到 caddy 代码。类似于 Openresty 和 Nginx 的关系。这就导致一开始阅读源码不是很好上手,需要搞清楚哪些东西是 Coredns 的,哪些接口是 caddy 的。而 caddy 的文档又不是很多,而且 Coredns 所使用的 caddy 已经不是官方的了,而是自己维护的一个版本,也加大了阅读的难度。所以我将启动流程梳理了这篇博客,希望能理清它的逻辑。

在开始分析源代码之前,读者需要具备的准备工作是:

  1. Golang 的基本语法,但是我发现 Golang 很简单,看完 https://gobyexample.com/ 足矣;
  2. 知道 DNS 的基本工作原理,知道 DNS Resolver, DNS Root Server, DNS TLS Server 的区别,能区分 Cloudflare 1.1.1.1 服务和 AWS 的 Route53 分别是什么角色,知道 Coredns 是哪一种 Server(其实 Coredns 哪一个都能做);
  3. 需要先看完 Coredns 的使用文档,知道它怎么配置;

在完成了这些之后,就基本可以知道 Coredns 是怎么工作的了。在开始进入源码之前,可以通过已知的文档来思考一下:如果 Coredns 需要完成已知的这些功能,需要做哪些事情呢?

猜想的实现模块

因为在之前的博客中已经介绍过,Coredns 其实是基于一个 caddy 的 server,所以我们可以猜想 Coredns 必须要完成以下事情:

  1. 需要能够解析 Corefile,这是 Coredns 的配置文件;
  2. 需要维护一个 Plugin Chain,因为 Coredns 工作方式的本质是一个一个的 Plugin 调用;
  3. Plugin 需要初始化;
  4. Coredns 是基于 caddy 的,那么 Coredns 必须有地方告诉 caddy 自己处理请求的逻辑,监听的端口等等;

你可能还想到了其他的功能,这样,在接下来的源码阅读中,就可以尝试在源代码中找到这些逻辑,看看 Coredns 是怎么实现的。

Coredns 的入口程序是 coredns.go,里面只有两部分,第一部分是 import,第二部分是 main

Golang 的 import 并不是简单地引入了 package 的名字,如果 package 里面有 init() 的话,会执行这个 init()。所以这里的 import 其实做了很多事情。

注册

import package 的时候,imported package 还会 import 其他的 package,最终,这些 package 的 init() 都会被执行。

import 的链路和最终执行过的 init 如下,其中一些不重要的,比如 caddy.init() 在图中忽略了。

重要的注册主要有两部分,第一部分是将所有的 Plugins 都 import 了一遍,并且执行了这些 Plugin 里面的 init()。这个 Plugin 的 import 列表其实是生成的,在之前的博客中提到过。

因为 Golang 是静态编译的语言,所以要修改支持的 Plugin 列表,必须要重新编译 Coredns:

  1. 修改 plugin.cfg 文件
  2. 使用仓库中的 directives_generate.go 生成 zplugin.go 源代码中文,这时 import 列表更新
  3. 重新编译 Coredns

Plugin 中的 init() 做的事情很简单,就是调用 coredns.plugin.Register 函数,将 Plugin 注册到 caddy 中去,告诉 caddy 两个事情:

  1. 这个 Plugin 所支持的 ServerType 是 DNS
  2. Plugin 的 Action,即初始化函数。这里只是注册,并没有运行过。

第二部分是 register 中的 init 函数,主要的动作是使用 caddy 的接口 caddy.RegisterServerType 注册上了一个新的 Server 类型。

注册的时候,要按照 caddy 的接口告诉 caddy:

  1. Directives: 新的 ServerType 支持的 Directives 是什么;
  2. DefaultInput: 在没有配置文件输入的时候,默认的配置文件是什么,这个选项其实不重要;
  3. NewContext: 这个是最重要的,如何生成对应这个 ServerType 的 Context,Context 是后面管理 Config 实例的主要入口;

coremain 里面也有一个 init() 主要是处理了 Coredns 启动时候的命令行参数,然后注册了 caddyFileLoader,即读取(注意还没有解析)配置文件的函数。

到这里,import 阶段就结束了,目前为止所做的工作大部分都是将函数注册到 caddy,告诉 caddy 应该做什么,函数并没有运行。

然后回到 coredns.go 文件的第二部分:coremain.Run()

启动

数据结构

启动的大致流程是,初始化好各种 Instance, Context, 和 Config, 然后启动 Server. 不同的阶段所初始化的数据结构不同,要理解这个过程,最好先明白这些数据结构之间的关系。主要的数据结构以及它们之间的互相应用如下图。黄色的表示 caddy 中的数据结构,绿色的表示 Coredns 中的数据结构。

其中,caddy.Controllercaddy.Instance 是 caddy 中定义的结构,主要是 caddy server 在使用,Coredns 中并没有看到很多用到的地方。

dnsContext 是实现了 caddy.context,内部保存了和 Config 之间的关系,实现了 caddy 中定义的 InspectServerBlocks 和 MakeServers 接口,是一个主要的数据结构。对应 caddy.Instanse 全局只有一个,由 caddy 创建。

Config 就完全是 Coredns 内部的结构了,是最重要的一个结构,里面保存了 Plugin 列表,在处理 DNS 请求的时候,主要通过 Config 去调用 Plugin. 对于每一个 Corefile 配置文件中的 ServerBlock 和 Zone 都会有一个 Config 实例。

启动流程

Coredns 的启动流程之所以复杂,一个原因是真正的流程在 caddy 中而不是在 Coredns 中,另一个原因是随之而来的各种逻辑,本质上是 Coredns 定义的,然后注册到 caddy 中,caddy 执行的代码实际上是 Coredns 写的。

所以为了说明白这个启动的流程,我先画了一个图。启动流程的本职是初始化好上文中描述的各种数据结构。下图中,上面是数据结构,下面是代码的执行流程。在下图中,实线表示实际调用关系,虚线表示这段代码初始化了数据机构实例。

从 coremain.Run 开始,这里逻辑很简单,先是执行了上文提到过的注册的 caddyFileLoader 。然后调用了 caddy.Start,由 caddy 负责主要的启动流程。

下面我们找深度优先描述这个启动过程。

Caddy 先创建了一个 Instnace ,然后调用 ValidateAndExecuteDirectives。

ValidateAndExecuteDirectives 中,根据我们之前 load 出来的 caddy file 中的 ServerType string,拿到 DNS Server Type,就是上文提到的我们在 init 的过程中注册进去的。

然后执行 loadServerBlocks,这是 caddy 内置的函数,根据我们之前注册的 ServerType.Directives 返回的 Directives 去解析 caddy file,这时候是作为一个普通的 caddy 文件解析的,没有 coredns 的解析逻辑。这时候原来的 caddy file 被解析成 Tokens。由此也可以看出,Coredns 的配置文件和 Corefile 和 caddy 的格式必须是一样的,遵循一样的语法规范,因为解析器都是用的 caddy 中的(如果 Coredns fork 的版本没有修改的话)。

下一步是调用 ServerType 注册的第二个重要方法:NewContext 创建出来一个 context,实质的类型是 dnsContext

然后调用 context.InspectServerBlocks,这个逻辑也是 Coredns 中实现的,是 dnsContext 实现的接口。主要做了两件事,一是检查 Corefile 是否合法,有无重复定义等。然后是创建 dnsserver.Config,Config 主要是和 dnsContext 关联,我们后面拿到这个 Config 主要也是通过 Context。比如在 config.go 中通过 Controller 拿到对应 Config 的方法实现

实际上是通过 Controller 找到 Instance, 然后找到 Instance 上的 Context (c.Context() 逻辑)。然后通过一个 Utils 去从 Context 上找到对应的 Config。这个实现和上图也是完全符合的。

上文提到过,每一个 Server Block 中的每一个 Key 都会有一个对应的 Config,那么这么多的 Config,我们怎么找到对应的呢?

其实就是 keyForConfig 的逻辑,context 中记录了 Server Block Index + Server Block Key Index 组合,和 Config 的一一对应关系。

回到 ValidateAndExecuteDirectives 的逻辑,调用完 InspectServerBlocks 之后,就是针对每一个 Directive 去拿到 Action 然后去执行。即,初始化每一个 Plugin。

完成之后,逻辑回到 caddy.Start中,会调用 dnsContext 中实现的第二个重要的接口:MakeServers. 初始化好 Coredns 中的 Server.

最后一步,就是 startServers 了,这里实际的逻辑又回到了 Coredns 实现的接口上。感觉比较清晰,没有什么难以理解的地方。主要是实现了两个接口,一个是 Listen, 一个是 Serve

然后就可以开始处理请求了。

开始处理请求

最后 Server 实现了 caddy.Server 的 Serve 方法, 里面做的事情主要就是根据 DNS 查询请求里面的 zone 匹配到对应的 Config,然后 PluginChain 保存在 Config 里面,通过调用 h.pluginChain.ServeDNS 来完成请求的处理

本文的代码解析就到这里,至此启动流程就完成了。本文尽量少贴代码,试图缕清启动的流程,具体的代码如果对照本文的图片和解释应该都找到的,并且剩下的部分应该不难看懂。如果发现错误或者有疑问,欢迎在评论区交流。

 

本文基于 Coredns 源码 Commit:3288b111b24fbe01094d5e380bbd2214427e00a4

对应最近的 tag 是:v1.8.6-35-gf35ab931