0%

经过持续厚脸皮的 ping,Michael 大佬终于开始 review 那个 cgo 优化的 PR 了。只不过,大佬不喜欢 copy 几百行汇编的方案,希望用 hack 的方式。

好吧,这么大个改动也不早说,谁让咱朝中无人呢,只能这么任人摆布了。

这两天居家隔离,正好也就搞下了。虽然搞得头大,不过也对 cgo 的认识更深了一些,趁着热乎,简单记录一下的。

之前从运行时,调度策略等方面分享过 cgo,今儿就从编译器视角,来介绍下 cgo 的。

简单说

常规编译 Go 代码,就是编译 + 链接,这两步。

cgo 的主要区别是:

  1. 多了预编译这一步,用来插入一些包裹代码
  2. 链接的时候,需要链接 C 和 Go 的两种产物

三步走

预编译

将原始的 Go 预编译,生成中间的 Go + C 代码

比如,这么一个 Go 函数

1
2
3
4
//export AddFromGo
func AddFromGo(a int64, b int64) int64 {
return a + b
}

会编译为两份,其中,Go 代码:

1
2
3
4
5
6
7
8
9
10
//go:cgo_export_dynamic AddFromGo
//go:linkname _cgoexp_83bc3e2136d8_AddFromGo _cgoexp_83bc3e2136d8_AddFromGo
//go:cgo_export_static _cgoexp_83bc3e2136d8_AddFromGo
func _cgoexp_83bc3e2136d8_AddFromGo(a *struct {
p0 int64
p1 int64
r0 int64
}) {
a.r0 = AddFromGo(a.p0, a.p1)
}

这里的 Go 函数,接受的参数是一个结构体指针,结构体中存了真实的函数参数。

以及 C 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
extern void _cgoexp_83bc3e2136d8_AddFromGo(void *);

CGO_NO_SANITIZE_THREAD
GoInt64 AddFromGo(GoInt64 a, GoInt64 b)
{
size_t _cgo_ctxt = _cgo_wait_runtime_init_done();
typedef struct {
GoInt64 p0;
GoInt64 p1;
GoInt64 r0;
} __attribute__((__packed__, __gcc_struct__)) _cgo_argtype;
static _cgo_argtype _cgo_zero;
_cgo_argtype _cgo_a = _cgo_zero;
_cgo_a.p0 = a;
_cgo_a.p1 = b;
_cgo_tsan_release();
crosscall2(_cgoexp_83bc3e2136d8_AddFromGo, &_cgo_a, 24, _cgo_ctxt);
_cgo_tsan_acquire();
_cgo_release_context(_cgo_ctxt);
return _cgo_a.r0;
}

这里的 AddFromGo 就变成了一个标准的 C 函数了,可以被其他的 C 无缝调用了。
同时,也可以看到,上面 Go 函数需要的结构体,是如何封装的了。

这里的 crosscall2,咱们就不展开了,感兴趣可以翻看 以前的分享

编译

这个环节很普通,就是常规编译器的套路

C 代码用 gcc/clang 编译,Go 用 go compiler 编译。

链接

本质上,这里的链接也是常规套路,只是链接的来源,有 C 和 Go 的两种目标产物。

此时,有个小问题是,这两个目标产物,需要如何链接。

这里就需要用到第一步预编译中,产生的编译指令了。

比如,Go 中这两行

1
2
//go:linkname _cgoexp_83bc3e2136d8_AddFromGo _cgoexp_83bc3e2136d8_AddFromGo
//go:cgo_export_static _cgoexp_83bc3e2136d8_AddFromGo

其作用是,导出 _cgoexp_83bc3e2136d8_AddFromGo 这个 Go 函数

然后在 C 里面,刚好通过 extern 指定引入了这个函数。

1
extern void _cgoexp_83bc3e2136d8_AddFromGo(void *);

这样就完成了 C 和 Go 之间的配置。

这里的示例,是从 Go 函数导出给 C,如果是 Go 引用 C 函数,则会用到 cgo_import_static 这样的编译指令。

有啥用呢

整个编译过程,其实也没多少信息量,主要是知道了,Go 提供的这些 编译指令

可以通过这些编译指令,来更底层的控制 C 和 Go 之间的符号链接。 某些时候,可以更方便做 C 和 G 之间的交互了。

比如,C 里面搞个全局变量,Go 里面通过 cgo_import_import/dynamic 就可以链接到 C 变量的地址,从而直接读写 C 变量了。
(这个理论上 go 已经能做到了,go 内部自己就有这么用的,只是没有暴露对外,加了一些使用上的限制)

当然,如果是作为普通 cgo 的使用者,这些个编译指令,hack 的玩法,通常是用不着的。

以后,有需要再试试的。

对于 http filter,Envoy 提供了一大堆状态码,虽然每个都有不少的注释,但是依旧很头大,傻傻搞不清楚。

本文记录一下自己的理解,如有错误,欢迎指正~

http filter 是什么

Envoy 提供的 http 层的扩展机制,开发者可以通过实现 Envoy 约定的接口,在 Envoy 的处理流程中注入逻辑

比如,这两个请求阶段的接口:

1
2
Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, bool end_stream)
Http::FilterDataStatus decodeData(Buffer::Instance& data, bool end_stream)

如果想修改请求头,那就在 decodeHeaders 中修改 headers,如果想修改请求的 body,那就在 decodeData 中修改 data

本文所关注的状态码,就是这些函数的返回值,比如示例中的 FilterHeadersStatusFilterDataStatus

Envoy 为 filter 提供了啥

  1. 流式

    这个从上面的从 API 形式就可以看出来,核心是,http header 以及 http body 的每个 data 块,是一个个处理的。

  2. 异步

    当一个 filter 在处理过程中,如果需要等待外部响应,也可以先反馈给 Envoy 某种状态,等 filter ready 之后再继续。

  3. 并发

    这个其实很自然,因为有流式,所以,从逻辑上来说,不同的 filter 之间是存在并发的。

    但是,又容易被忽视,因为 Envoy 的工作模式中,具体到某个 http 请求是工作在单线程上的,所以容易有误解。

所以,Envoy 内部有一个 filter manager 模块,目的是用于管理 filter 的运行,也可以简单理解为,一个复杂的状态机。

状态码

交代完背景,我们具体看看状态码,以及 filter 和 filter manager 的状态机运转关系

Header 状态码

以 header 的状态码为例,咱们先挨个解读一下,找找感觉

  1. Continue

    这个最简单,表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理了

  2. StopIteration

    表示 header 还不能继续交给下一个 filter 来处理

  3. ContinueAndDontEndStream

    表示 header 可以继续交给下一个 fitler 处理,但是下一个 filter 收到的 end_stream = false,也就是标记请求还未结束;以便当前 fitler 再增加 body。

  4. StopAllIterationAndBuffer

    表示 header 不能继续交给下一个 filter,并且当前 filter 也不能收到 body data。

    意思是,请求中的 body data 先 filter manager 缓存起来,如果缓存大小超过了 buffer limit(一个配置值),那就直接返回 413 了。

  5. StopAllIterationAndWatermark
    同上,区别是,当缓存超过了 limit,filter manager 就会启动流控,也就是暂停从连接上读数据了。

过了一遍之后,有这么几个关键点

  1. StopIteration,只是先不交给下一个 filter 处理,但是并不停止从连接读数据,继续触发 body data 的处理
  2. header 阶段,也可以对 body data 进行写操作,可想而知,也就是 add 操作了。
  3. filter manager 有一个 data 的缓冲区,帮 filter 临时缓冲数据

data 状态码

再看 data 的状态码,

  1. Continue

    跟 header 类似,表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理了。

    只是,如果 header 之前返回的是 StopIteration,且尚未交给下一个 fitler,那么,此时 header 也会被交给下一个 fitler 处理。

  2. StopIterationAndBuffer

    表示当前 data 不能继续交给下一个 filter,由 fitler manager 缓存起来。

    并且,与 header 类似,如果达到 buffer limit,直接返回 413。

  3. StopIterationAndWatermark

    同上,只是达到 buffer limit,只是触发流控。

  4. StopIterationNoBuffer

    表示当前 data 不能继续交给下一个 filter,但是,fitler manager 也不需要缓存 data。

这里也有几个点:

  1. 如果 data 要被交给下一个 filter 处理了,header 是肯定也会被交给下一个 fitler 处理了。

    我们可以把 header 理解为,一个带有特殊语义的首个 data 块,无论怎么流式处理,数据的顺序,是必须要保证的。

  2. data 阶段多了一个 NoBuffer 的状态,这又是什么目的呢?

关键解读

有几个容易误解的地方,咱们展开聊聊

异步

为了支持 filter 的异步,fitler 可以返回 Stop 语义的状态码,这样 filter manager 不会继续后续的 filter,但是:

并不意味着整个 filter manager 都停止了,当前 filter 以及之前的 filter,还是会接受到当前请求上的数据

并发

每个 filter 接受到的数据,在处理期间,是独享的,没有并发风险,但是 filter manager 的缓冲区,只有一个,这个是有并发风险的。

因为 filter 是逻辑上并发的,但是 filter manager 只有一个,所以是存在逻辑并发风险的。

举个例子:

filter A => filter B => filter C,这样一个处理链表

先来了第一个 data 块:filter A 和 B 反馈 continue,filter C 返回 StopIterationAndBuffer,此时 buffer 中是 data 1,

再来第二个 data 块,filter A 返回了 StopIterationAndBuffer,此时 buffer 中是 data 1 和 2 了,

然后 filter A 通知 filter manager 恢复处理,那么此时,filter B 看到的数据,就是 data 1 和 2 了。

显然,这并不符合预期。

也就是说,对于每个 filter 而言,如果在 data continue 之后,再返回 StopIterationAndBuffer 的话,就可能有缓冲区并发风险。

简单的理解,如果要复用 filter manager 的缓冲区,每个 filter 只有首次异步的机会。

如果需要随时异步,那怎么办呢?

解决方案,也就是 StopIterationNoBuffer,filter 自己搞缓冲,也就不存在并发风险了。至于副作用嘛,主要就是自己多实现一些代码。

理论上来说,StopIterationNoBuffer 是最灵活的,不过也是更麻烦的,效率也会略差一点的。

个人体会

Envoy 的状态设计,为大部分常见的场景,提供了比较方便易用的设计,但是相对复杂的场景,就需要自己多实现一些逻辑了。

只是这么搞了之后,对于新人的上手门槛就高了,如果不搞懂这些状态,闭着眼睛用,也是容易踩坑的。

最后

一个小问题,MOE 作为把 MOSN(Go)塞进 Envoy 的方案,此时的开发语言已经是 Go 这种天然异步的,MOE 又提供的是什么样的开发体验呢?

MOE 近期会开源第一版,等开源之后,后面可以聊聊~

没有意外,也没有惊喜

弃坑 NGINX 的,CF 不是第一个,也不会是最后一个。

有点看头的是,用 rust 重新撸了个 Pingora 来替换,不过,披露出来的信息量也不大,没啥惊喜。

不意外

作为一款开源软件,其生命力来源于:

  1. 开发者
  2. 用户

开发者,是生产者,可以发展完善软件;用户,是消费者,不断拓展开源软件的应用场景,实现开发者的价值。二者互相成就的过程,就是开源软件的生命力。

近几年,业界各种诟病 NGINX 的声音越来越多,而 NGINX 选择性的忽略了这些声音(也不影响其继续闷头赚钱)。

作为开发者之一,我能感受到的是,有那么一批开发者,以肉眼可见的速度,一个个用脚投票,逃离了 NGINX 这个坑。包括,我也是其中一员。

尽管,从统计数据看,NGINX 的市场份额还在涨,用户量还是很健康,只是在开发者眼中,NGINX 已经不再诱人。

作为 CF 这种,同时具备开发者和用户双重角色的,选择弃坑 NGINX,在我看来一点也不意外。毕竟 CF 定位的是科技创新,不是求稳的网络运营商,相对来说,开发者的声音会被更多的考虑。

没惊喜

尽管 CF 的博客,有说明弃坑的理由,以及用 Rust 重新撸的好处,不过,在我看来,没多少信息量。

这些个槽点,优势,算不上新闻。在我看来,也就表达一个意思,我 CF 真的干了,他们说的是对的,没骗人。

这些年 Rust 很吸人眼球,不少的开发者跃跃欲试。一个新的语言,涌入了一批批开发者,很自然的就会出现一阵风潮,把现有的轮子重新撸一遍,用 Rust 撸一个 NGINX 的替代品,早晚的事。

我更关心这两个:

  1. 替换的完成度
    原文只说了,1 trillion requests a day,看起来是个挺大的数字,说明并不是玩具,是真实的生产了哟
    但是,原有的 NGINX 还剩多少呢,这个没有看到。
  2. 投入了多少人力
    这个应该更加机密一些,一般都不会公开说。如果有了解内幕的,可以私下透露~

像 CF 这种基于 NGINX 深耕多年的庞大系统,要完整替换,肯定不是一朝一夕搞得完的。

个人不负责任的猜测,应该还只是部分相对简单场景的尝试,还是有大量的老 NGINX 在运行的。

原来在 NGINX 上写下的一大坨 c 和 Lua 代码,要用 Rust 全部重写?想想都觉得很酸爽。

还是蛮期待 CF 的后续,看看他们是如何完成老代码的迁移,真的全部用 Rust 重写?

两层架构

现代的网关,已经很复杂了,在转发之上,承载了非常多的流量管控能力,而且随着 Service Mesh 愈演愈烈,网关上承载的逻辑,越来越贴近业务语义。

尽管在云原生浪潮中,网关承载的复杂流量管控,也开始标准化, 一个 yaml 配置走天下。

然而,标准化配置只能覆盖大部分常见的场景,真实的情况下,还充斥着大量的定制化需求。

这就依赖网关软件能提供方便的定制能力,也就是说,除了网关自身的基础实现,还需要一套方便的机制,能够方便的实现定制需求。

NGINX 世界里,标准的 c module 扩展方式成本很高,于是有了 OpenResty 通过 Lua 来完成定制需求。Envoy 世界里,标准的 c++ filter 开发门槛也是很高,于是也提供了 Lua,ext_proc,Wasm 等多种扩展方式。

CF 的 Pingora 怎么搞呢?绑死在 Rust 这一颗树上?

我在干啥

MOE,MOSN on Envoy,MOSN 的新一代架构。

包含两层含义:

  1. 基于 Envoy 提供 Go 语言扩展能力
  2. 复用 MOSN 成熟的扩展机制/能力

为什么搞 MOE?

有机会的话,也可以写个长篇大论,简单说,就是看好 Envoy 和 Go。

能搞成么?

所谓的行业趋势,只是大家一个个用脚投票走出来的,走着看呗。

MOE 正在开源筹备中,很快会有第一版,欢迎感兴趣的朋友,一起来玩。

最后

借用一个图,我的理解,NGINX 已经到了高峰期,而 rust 呢?或许还没有到期望的顶峰。

开源世界,如同自然世界,也需要多样性,百花齐放好过一枝独秀。

最后,放个梗,张华考上了大学,李萍进了技校,我当了工人,我们都有美好的前途。

一直在调侃,程序猿的归宿是跑滴滴、送外卖。我内心还是比较喜欢送外卖的,哈哈,至少可以多运动一下的,偶尔爬爬楼也挺好的。只是,没想到这么快就实现了 …

今儿在家里聊天,本来是聊去哪儿玩,突然就聊起了外卖众包,一激灵,我也心动了,提议我也搞一个玩玩 …

白天去了个博物馆,被要求 72 小时核酸,愣是没进去。晚上没啥事了,又想起了外卖众包这茬,立马就注册了美团外卖众包 …

注册

注册还挺快的,扫脸实名制之后,立马就审核通过了,估计是机器自动通过的。

然后是在线学习,也就是看几段视频,再加一些简单的考试题,新手骑士就可以上路了。众包要求就这么低,也不用穿啥服装,嘿嘿。

第一单

因为我的小电驴,没啥地方装东西了,所以选单比较谨慎,生怕碰上东西太多,或者有啥汤水的。也不敢让系统自动派单,怕给我推太多,完不成。

第一单,选了个小龙虾外卖,离我家 1 公里多点。 取餐还挺顺利的,到那之后,直接从门口的桌子上,按照取餐号码拿就行了,很明显这个是主营外卖的店。

不过,送餐就比较惨了,在一个城中村的深处,七拐八绕的,路上人还挺多,环境不太好。好在导航还算靠谱,总归是送到了。

之后,就干脆跑出了这个村,再开始接单了。

战况

今晚战况:

  1. 跑单两小时,11 公里,完成 7 单,预计收入 39.2 RMB
  2. 基本都是接一单,送一单的模式。
  3. 只有一次,是刚接单的时候,系统就推送了另外两个顺路的单,就顺手接了,也算挑战一下。当时手里并发有 3 个单,这也算我的高光时刻了,哈哈。

外卖战况

算账

考虑到我是新人,时薪 20 RMB,也还算凑合了。

不过,值得一提的是,我跑单是 8 - 10 点,应该是高峰期。而且还有新人优先派单名额,也就是系统会优先推送一些近的单给我,50 秒内,我有优先选择权。

期间在路上,碰到一个大哥,聊了一会的。

他已经跑了 44 单了,说今儿算少的,平常这个时候(9点多)已经 50 单了,
而且他说,他是在饿了么搞兼职的,一单 9 块钱,好像是跟月单量挂钩的,一个月搞一万多 RMB 还是可以的。

大哥还给我看了他的排名,他是他们站里的第二名。我问大哥,这么厉害,有没有啥诀窍,大哥说,就是熬着,早上 7 点上线,晚上搞到 10 点,中午 2 点之后,能休息一阵。

这么算下来,大哥每天工作 10 来个小时,时薪可以到 40-50 RMB,也还是不错滴~

最后

一些简单的感想:

  1. 除去新鲜感之外,其实跑外卖不好玩,我感觉骑行环境不太好,尤其是要穿一些小巷子
  2. 要想提高收入,就得提高并发,多接几个比较顺路的单,不过这就真考验骑手的计算能力了,也不能并发太多,倒是超时了(这是真人工智能)
  3. 不知道系统自动派单的时候,这方面做得咋样,感觉这里面要考虑得还是挺多的,感觉骑手的调度比滴滴车的调度应该更复杂一些。
  4. 目前骑手比较依赖手机,感觉这个对于骑行中的人来说,安全挑战还是比较大的,毕竟眼睛是要多看路的。或许这种时候,可以考虑搞个手表,利用震动这种触感,来给骑手一些提示,比如到了某个地方。

近一年有开始折腾 Envoy 了,在 bazel 这块踩了一些坑,总结记录下的,主要是个人的体感,不一定准确,欢迎批评指正。

bazel 是个啥

编译构建工具,跟常用的 Makefile 是类似的。

只是,bazel 更复杂,上手门槛更高,以我的个人的体验来看,主要是为了提升表达能力,可编程能力更强。

Makefile 通常是用于简单的描述,偶尔会搞个函数啥的;但是在 bazel 里,会看到大面积的自定义函数。

为啥 Envoy 需要 bazel

Envoy 是个 C++ 项目,C/C++ 这种比较老的语言,没有内置依赖包管理器这种先进特性(相比较而言,Go 语言在这块就先进了许多)。

我的理解,对于 Envoy 来说,bazel 一个主要的作用就是,补齐了依赖包管理。如果仅仅是编译,Makefile 之类的简单工具,应该也够用了。

几个概念

有几个常用的基本概念,先了解一下的

WORKSPACE

在项目的根目录,会有一个 WORKSPACE 文件,用于描述整个项目的,最主要是描述了依赖库,比较类似于 Go 语言中的 go.modgo.sum

比如 Envoy 的 WORKSPACE 文件中,有这样的描述:

1
2
load("//bazel:repositories.bzl", "envoy_dependencies")
envoy_dependencies()

其中,具体依赖库的详细信息,在 bazel/repository_locations.bzl 文件里。
比如下面这个示例:

1
2
3
4
5
6
7
8
boringssl = dict(
project_name = "BoringSSL",
project_url = "https://github.com/google/boringssl",
version = "098695591f3a2665fccef83a3732ecfc99acdcdd",
sha256 = "e141448cf6f686b6e9695f6b6459293fd602c8d51efe118a83106752cf7e1280",
strip_prefix = "boringssl-{version}",
urls = ["https://github.com/google/boringssl/archive/{version}.tar.gz"],
),

描述的是依赖库 boringssl,是不是很像 go.modgo.sum

另外,在这里还可以频繁看到 @envoy @envoy_api 这种,这里也表示的一个依赖库的作用域。

BUILD

BUILD 文件会有很多个,用于描述一个目标的编译过程,类似于 Makefile 中描述一个目标的构建过程。

比如,这样子的:

1
2
3
4
5
6
7
8
9
10
envoy_cc_library(
name = "lua_filter_lib",
srcs = ["lua_filter.cc"],
hdrs = ["lua_filter.h"],
deps = [
":wrappers_lib",
"//envoy/http:codes_interface",
"@envoy_api//envoy/extensions/filters/http/lua/v3:pkg_cc_proto",
],
)

.bazelrc

这也是在项目根目录下的,用于描述 bazel 的默认配置。

比如,这个:

1
build:linux --copt=-Wno-deprecated-declarations

可以指定一些编译参数之类的。

执行

有了上面这些描述信息之后,最终要执行编译构建的命令就简单许多了

比如:

1
bazel build envoy

这里的构建目标 envoy,来自项目根目录下的 BUILD 文件。

也可以是这样子的:

1
bazel build //source/exe:envoy

此时的构建目标 //source/exe:envoy,来自项目根目录下的 source/exe/BUILD 文件了。

踩过的坑

除了上面这些一手体感,还有一些坑,也记录下的

变更编译器版本

有一次,想换个高版本的 gcc,但是修改了 PATH 后重新构建,始终不生效。

原来是,bazel 是增量编译的,所以会使用上一次编译时使用编译器,以保证整个项目是使用的同一个编译器。

所以,如果想更换编译器,需要清空下缓存:

1
bazel clean --expunge

split DWARF

较新版的 Envoy,启用了 -gsplit-dwarf 这个特性,也就是将调试符号放到独立的 .dwo 文件里了,以减少生成的二进制文件大小。

不过呢,这个特性还比较新,可能会有一些坑,至少在我的环境下(gdb 11,这个版本也不低了),就有 .dwo 读取错误。
比如这样子的:

1
DW_FORM_strp pointing outside of .debug_str section

所以,干脆关掉这个特性,就一切正常了(主要是 bt full 这类查看局部变量的功能)。

具体操作是,注释掉 .bazelrc 中的这一行:

1
# build:linux --features=per_object_debug_info

最后

记录下,我这边可以完整工作的环境:

  1. g++ 11
  2. gdb 11

在这个环境下,gdb 是可以完整工作的,局部变量都可以看。

之前在一个比较老的版本上,工作是不太顺利的。

最近我们发现 go 编译为 so 之后,内存占用涨了好多,初步分析下来,是动态符号导致的,感觉不太符合常识
趁着娃还在外面放假,正好学习学习~

hello world

1
2
3
int main() {
printf("hello world\n")
}

在最简单的 hello world 中,printf 最终也是来自 libc.so 这个动态链接库
通过 objdump,我们可以找到这一行:

1
400638:  call   400520 <printf@plt>

这里的 printf@plt,表示 printf 这个函数是依赖的外部函数,是要动态寻址的。

为什么需要函数寻址

不同于静态链接,函数地址在链接期(执行前),就可以确定下来了。
然而,动态链接库的地址,是在程序执行的时候,加载 so 文件时才能确定的。那么,要调用动态库中的函数,是没有办法提前知道的地址的,所以需要一套机制来寻找函数的地址。

具体而言,分为两种寻址:

  1. so 中导出的函数地址
  2. so 内部调用非导出的函数地址

简而言之

第一种,是通过函数名来寻址的,相当于在主程序里调用了 dlsym(x, "printf") 来寻址,然后 dlsym 会在 so 文件里找 printf 的地址
第二种,是通过偏移量来寻址的,虽然绝对地址不固定,但是 so 文件内部,两个函数之间的偏移量是固定的。

缓存加速

通过字符串来查找,想想也知道是比较低效的,那有什么办法提速呢?原理也简单,就是加缓存。
具体而言呢,是通过可执行文件中的两个段的配合,其中 .plt 可执行,.got.plt 可写,来实现缓存的效果。

还是从这一行 call 指令开始

1
400638:  call   400520 <printf@plt>

400520 来自 .plt 段,而且 .plt 是可执行的
继续用 objdump 可以看指令:

1
2
3
400520:   jmp    QWORD PTR [rip+0x200afa]  # 601020 <printf@GLIBC_2.2.5>
400526: push 0x0
40052b: jmp 400500 <.plt>

这里有两个 jmp
第一个 jmp 的地址来自 601020,而这个 601020 来自 .got.plt 段,.got.plt 是可写的
首次执行的时候,601020 里存的就是 400526,此时意味着慢路径,需要动态查找。
当查到地址之后,会修改 601020 中的值,这样后续就可以直接一个 jmp 就完成寻址了,不需要再按照字符串查找了。

查找逻辑

至于慢路径查找,最终会调用到 _dl_lookup_symbol_x,大体而言是这么个逻辑:

  1. 先在当前可执行文件中,通过 0x0 这个偏移量,找到函数名,也就是 printf
  2. 然后再从 so 文件中,根据 printf 来查找函数地址

核心会用到两个段的数据(上面的 1 和 2 两步都会用到这两个段,只是对应来自两个不同的文件)

  1. .dynsym 用来存符号,也就是 Elf_Sym 这个结构,这个结构体里存了函数偏移地址,名称偏移地址等
  2. .dynstr 用来存字符,比如 printf 这个字符串本身就存在这里

使用 nm -D 可以看到类似这样的数据,其中 U 表示 undefined,需要外部寻址的函数

1
2
00000000004004e8 T _init
U printf

内部调用

这个就简单很多了,偏移量是固定的,不用动态查找,直接调用 call 指令就行了。x86 上的 call 执行也有好几个,其中有一个就是按照偏移量来的。
这里有一个有意思的小细节,比如这个例子:

1
2
3
4
5
000000000040061e <main>:
40061e: 48 83 ec 08 sub rsp,0x8
400622: bf 01 00 00 00 mov edi,0x1
400627: e8 e6 ff ff ff call 400612 <add>
40062c: 89 c6 mov esi,eax

call指令的跳转地址是 0x400612,这个是怎么来的呢?
e8 表示按照相对地址寻址,然后就有了这么结果:0x40062c + 0xffffffe6 = 0x400612

平常用 objdumpgdb 看到 call 指令的地址,也都是计算后的,不注意的话,会以为都是绝对地址。

总结

  1. 调用动态链接库中的函数,是通过函数名动态查找的
  2. 导出的函数,以及依赖的外部函数,都在 .dysym 里记录了元信息
  3. 函数名字符串,是存在 .dynstr 里的
  4. .plt.got.plt 这一对配合,用于寻址缓存
  5. 内部调用直接用偏移量,call 指令有一种就是按照偏移量来计算的

广告

如果觉得有意思,欢迎关注我的公众号~

微信公众号

书接上回,从一个 core 文件,生成内存引用关系火焰图时,虽然可以从 core 文件中,读到所有的内存对象,但是并不知道它们的类型信息。

这是因为 go 作为静态类型语言,在运行时,内存对象的类型是已知的;也就是说,并不需要想动态类型语言那样,为每个内存对象,在内存中存储其类型信息(Go 有点例外的是 interface)。

比如这个 go 语言例子:

1
2
3
4
5
6
7
8
type Foo struct {
a uint64
b int64
}

func foo(f *Foo) int64 {
return f.b
}

foo 函数在使用 f 这个指针时,并不需要判断其类型,直接读一个带偏移量地址就能得到 f.b,也就是一条指令:mov rax, qword ptr [rax + 8],就是这么简单直接。

再看 Lua 语言这个例子

1
2
3
4
function foo(f)
return f.b
end
foo({ b = 1 })

foo 函数在执行的时候,首先得判断 f 的类型,如果是 table,则按照 key 取 b 的值;如果不是,则抛运行时 error。

能够运行时判断 f 的类型,是因为 Lua 中变量是用 TValue 来表示的,这个 TValue 结构中,就有一个头信息用来存储变量类型。

逆向类型推导

逆向类型推导的逻辑是,根据已知内存的类型信息,推导被引用的内存对象的类型信息。

比如这个例子:

1
2
3
4
5
6
7
8
type Foo struct {
a uint64
b int64
}
type Bar struct {
f *Foo
}
var b Bar

如果我们知道了 b 的类型是 Bar,那么 b 中第一个 field 指向的内存对象,就是 Foo 类型了(前提是合法的内存对象地址)

既然存在推导,那我们怎么知道一些初始值呢,一共有两类来源:

  1. 全局变量
  2. 协程中每一帧函数的局部变量

全局变量

go 在编译的时候,默认会生成一些调试信息,按照 dwarf 标准格式,放在 ELF 文件中 .debug_* 这样的段里。

这些调试信息中,我们关注两类关键信息:

  1. 类型信息,包括了源码中定义的类型,比如某个 struct 的名字,大小,以及各个 field 类型信息
  2. 全局变量,包括变量名,地址,类型
    调试信息中的,全局变量的地址,以及其类型信息,也就是构成推导的初始值。

函数局部变量,要复杂一些,不过基本原理是类似的,这里就不细说了~

推导过程

推导过程,跟 GC mark 的过程类似,甚至初始值也跟 GC root 一样。
所以,全部推导完毕之后,GC mark 认为是 alive的内存对象,其类型信息都会被推导出来。

interface

Go 语言中 interface 比较类似动态类型,如下是空接口的内存结构,每个对象都存储了其类型信息:

1
2
3
4
type eface struct {
_type *_type
data unsafe.Pointer
}

按照类型推导,我们能知道一个对象是 interface{},但是其中 data 指向对象,是什么类型,我们则需要读取 _type 中的信息了。

_type 中有两个信息,对我们比较有用:

  1. 名字
    不过比较坑的是,只存了 pkg.Name 并没有存完整的 include path
    这个也合理的,毕竟 go 运行时并不需要那么精确,也就是异常时,输出错误信息中用一下。不过在类型推导的时候,就容易踩坑了。
  2. 指针信息
    具体存储形式有点绕,不过也就是表示这个对象中,有哪些偏移量是指针

有了这两个信息之后,就可以从全量的类型中,筛选出符合上面两个信息的类型。

通常情况下,会选出一个正确的答案,不过有时候选出多个,仅仅根据这两个信息还不能区分出来,一旦一步错了,后面可能就全推导不出来了。

我们给 go 官方 debug 贡献了一个补丁,可以进一步的筛选,有兴趣的可以看 CL 419176

unsafe.pointer

其实,在上面的 interface 示例中,最根源的原因,也就是 data unsafe.pointer,这个指针并没有类型信息,只是 interface 的实现中,有另外的字段来存储类型信息。

不过,在 go runtime 中还有其他的 unsafe.pointer,就没有那么幸运了。
比如 mapsync.map 的实现都有 unsafe.Pointer,这种就没有办法像 interface 那样统一来处理了,只能 case by case,根据 map/sync.map 的结构特征来逆向写死了…

我们给 go 官方 debug 贡献了 sync.map 的逆向实现,有兴趣的可以看 CL 419177

隐藏类型

除了源码中显示定义的类型,还有一些隐藏的类型,比如,Method ValueClouse 的实现中,也都是用 struct 来表示的,这些属于不太容易被关注到的 “隐藏”类型。

Method Value 在逆向推导中,还是比较容易踩坑的,我们给 go 官方 debug 贡献了这块的实现,有兴趣的可以看 CL 419179

相比 Method Value 这种固定结构的,Closure 这种会更难搞一些,不过幸运的是,我们目前的使用过程中,还没有踩坑的经历。

还有吗

这种逆向推导要做到 100% 完备,还是挺难的,根本原因,还是 unsafe.pointer

reflect.Value 中也有 unsafe.pointer,据我所知,这个是还没有逆向推导实现的,类似的应该也还有其他未知的。

甚至,如果是标准库中的类型,我们还是可以一个个按需加上,如果是上层应用代码用到的 unsafe.pointer,那就很难搞了。

还有一种可能,推导不出来的原因,就是内存泄漏的来源,我们就碰到这样一个例子,以后有机会再分享~

也还好

幸运的是,如果是只是少量的对象没有推导出来,对于全局内存泄漏分析这种场景,通常影响其实也不大。

另外,对于一个对象,只需要有一个路径可以推导出来,也就够了。

也就是说,如果一条推导线索因为 unsafe.pointer 断了,如果另外有一个线索可以推导到这个对象,那也是不影响的。因为从 GC root 到一个 GC obj 的引用关系链,可能会不止一条。

最后

Go 虽然是静态类型语言,不过由于提供了 unsafe.pointer,给逆向类型推导带来了很大的麻烦。好在 Go 对于 unsafe.pointer 的使用还是比较克制,把标准库中常用到的 unsafe.pointer 搞定了,基本也够用了。

理论上来说,逆向推导这一套也适用于 C 语言,只不过 C 语言这种指针漫天飞的,动不动就来个强制类型转换,就很难搞了。

pprof 确实很好用,设计实现都很精巧,半年前写过一篇,go 语言 pprof heap profile 实现机制
用 pprof 来分析内存泄漏,通常情况下,是够用了,不过,有时候也不够用~
为啥呢,因为 pprof 只是记录了内存对象被创建时的调用栈,并没有引用关系。也就是说,没有办法知道,内存对象是因为被谁引用了,导致没有被释放。
对此,同事元总有一个很形象的比喻,pprof 只能看到出生证,却查不了暂住证。

需要引用关系

有些场景下,我们知道了泄漏的内存,是从哪里申请的,但是翻了半天代码,也搞不清楚内存为啥没有释放。
比如,内存对象经过复杂的调用传递,或者复杂的内存池复用机制,又或者传给了某个不熟悉第三方库,在第三方库中有非预期的使用 …
这些情况下,一个很直觉的想法是,想看看这些内存对象的引用关系

内存引用关系火焰图

内存引用关系火焰图,是一种内存对象引用关系的可视化方式,由春哥首创,最早应用于 OpenResty XRay 产品。这个工具确实是内存分析神器,给不少的客户定位过内存问题,感兴趣的可以移步 OpenResty 官方博客
下图是分析一个 MOSN 服务产生的,从下到上表示的是,从 GC root 到 GC object 的引用关系链,宽度表示的是对象大小(也包括其引用的对象的大小之和)
有了这样的可视化结果,我们可以直观的看到内存对象的引用关系。
图中可以看到,大部分的内存是,MOSN 中 cluster_manager 全局变量中引用的 cluster 内存对象:

内存引用关系火焰图

实现原理

在生成火焰图之前,首先我们需要提取两个关键信息:

  1. 每个内存对象之间的引用关系
  2. 每个内存对象的类型

引用关系

获取引用关系比较简单,首先,我们可以在 heap 中找到所有的 GC 对象。然后遍历所有的对象,再结合 bitmap 信息,获取这个对象引用的其他对象。
基本原理跟 GC mark 是类似的,不过实现上很不一样,因为这个是离线工具,可以简单粗暴的实现。

类型推导

Go 语言作为编译型静态语言,是不需要为每个内存对象存储类型信息的(有点例外的是 interface)。如果是动态类型语言,比如 Lua,则会方便很多,每个 GC 对象都存储了对象的类型。
所以,要获取每个对象的类型,还是比较麻烦的,也是投入时间最多的一块~
当然,也是有解决办法,简单来说就是做逆向类型推导,根据已知内存的类型信息,推导被引用的内存对象的类型信息。
这块还是比较复杂的,后面有空可以单独写一篇来分享~

生成过程

有了这两个关键信息之后,生成过程还是比较清晰的:

  1. 获取所有的内存对象,包括类型,大小,以及他们之间的引用关系,形成一个图
  2. 从 root 对象出发,按照层次遍历,形成一棵树(也就是剪枝过程,每个对象只能被引用一次)
  3. 将这棵树的完整引用关系,当做 backtrace dump 下来
    count 是当前节点的总大小(包括所有子节点),也就是火焰图上的宽度
  4. 从 bt 文件生成 svg,这一步是 brendangregg 的 FlameGraph 标准工具链

使用方式

这个工具是基于 go 官方的 debug 改进来的,不过鉴于 go 官方不那么热心维护 viewcore 了,MOSN 社区先 fork 了一份,搞了个 mosn 分支,作为 MOSN 社区维护的主分支。
待 go 官方先接受了我们之前提的 bugfix 之后,我们再去提交这个 feature。
所以,使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 编译 mosn 维护的 viewcore
git clone git@github.com:mosn/debug.git
cd debug/cmd/viewcore
go build .

# 假设已经有了一个 core 文件(CORE-FILE)
# 以及对应的可执行程序文件(BIN-FILE)
viewcore CORE-FILE --exe BIN-FILE objref ref.bt

# 下载 FlameGraph 工具
git clone git@github.com:brendangregg/FlameGraph.git
../FlameGraph/stackcollapse-stap.pl ref.bt | ../FlameGraph/flamegraph.pl > ref.svg

# 浏览器打开 ref.svg 即可看到火焰图

如果使用碰到问题,欢迎联系~
如果成功定位了某个问题,也欢迎反馈给我们,一起开心下的~

广告

如果觉得有意思,欢迎关注我的公众号~

微信公众号

真的很重要

接入层网关,承接了所有对外服务的入口流量,其重要程度不言而喻。稍不注意就出搞个故障,如果不幸赶上黑天鹅,全站故障,那就是整个公司都下线了。
从事网关开发这么些年,也经历过一些故障,全网下线的经历过两次,都是上市公司,故障时间小时级。这种级别的故障,半小时内就会惊动公司最高层,现场排查恢复的压力可能而知。而且,故障恢复之后,还有各种漫长的复盘和改进讨论。

一场故障搞下来耗时耗力,所以稳定性是第一位的。简单谈谈我的想法

bug 与 故障

首先,bug 并不等同于故障,bug 常有,而故障不常有。
对于一个复杂的软件,bug 是很难完全避免的。有时候,面对一个长期稳定运行的软件,人们容易误以为没有 bug 了,其实不然,可能只是规模还没到,还碰到触发 bug 的场景,亦或是,软件多个模块之间的配合,刚好能够让 bug 不容易出现而已,比如这张经典的图
刚好工作

当潮水褪去,才能发现原来 bug 一直在裸泳 …

虽然 bug 一直都有,但是大部分系统通常还是正常运行的,因为系统稳定运行并不要求完全没 bug,而只需要当前用到的场景下,没有 bug 也就够了。

实际上,我们认为很稳定的系统,可能就像以上面的图那样在运行着的,看起来很魔幻,可是现实世界有时候就是这么魔幻,且真实…

接下来,从两个视角来聊聊(仅仅是两个视角,并非特指两个工种)

开发视角

开发会认为,最重要的是,产出优秀的代码。
从软件架构,模块化设计,到测试用例的设计,甚至到编程语言的选择,等等,这些是产出优秀代码应该多关心的。

核心就一点,让代码尽量少 bug,就是开发对稳定性最大的贡献。

另外,即使故障发生时,开发视角最关心的是如何定位 bug,甚至来个 hotfix 直接修复上线就更好了。
所以,在故障发生时,最希望是能够保留现场,甚至可以上线 debug …

运维视角

运维视角最关心一个问题,为什么原来是正常的,现在就有问题了。

也就是说,运维并不关心 bug 是什么,只关心是什么触发了 bug,只要触发条件没了,那就能恢复正常。

以运维视角来看,变更是一切故障的起因,这里的变更,包括了软件版本更新,软件配置更新,软件使用场景的更新。
所以,运维会构建一套系统来追踪这些变更,当故障发生时,通过时间线来推导是哪个变更产生的,以便回滚恢复。

简单点说,故障发生的时候,运维第一反应是,看看哪个变更最可疑,回滚掉 …

变更三板斧

为了能否快速 & 精确的回滚,运维会有一整套完善的系统,从变更发布,到监控报警,甚至自动回滚止血。

其中,对于发布变更,最核心的就是变更三板斧:可灰度,可监控,可回滚。

  1. 可灰度
    一方面可以控制影响范围,另一方面也可以作为对比因素,用于故障到变更的的推导,比如故障的发生范围,跟变更的灰度范围是否一致。
  2. 可监控
    有两层含义,一是:如果产生了故障,至少可以知道;二来,变更产生了某些变化,也需要能被看到
  3. 可回滚
    这可是救命操作,如果确认是某个变更产生的故障,不能回滚,或者因为回滚产生另外的故障,那就是个大写的悲剧了。

接入层网关的变更

就接入层网关而言,很关键的一个点,配置变更的管理,同样需要三板斧。
因为软件版本更新的三板斧能力,通常在运维系统中就已经有很好的支撑了。

而以网关目前的演进状态来说,也就是抽离了控制面这个单独的概念,来控制网关的运行配置的状态,配置变更是很频繁的操作,而且可能会有多个的控制面来控制配置。这种情况下,配置变更产生故障的风险就高很多了。
也是目前演进状态中,可能容易不被重视的一个点,而且以常见的网关软件架构,也是比较难做到的一个点。

至于接入流量的变更,这种很难被纳入运维系统管理了(除了新接入业务流量),因为变更来源是来自外部,甚至可能就是突然来一波黑客攻击。
这种可能需要系统具备快速的流量拆分调度能力,能把“坏流量”摘出来,尽量减少影响面,也缩小问题范围。

黑天鹅

运维系统完善,三板斧做得好,可以大幅降低故障风险。不过,黑天鹅要来,谁也挡不住。

如果发生了大面积故障,一通操作猛如虎,一看还是没有恢复,能咋办呢?
这种时候,另外开一条支线,拉上开发直接 online debug,没准也能提供一些有用的线索,比如某些流量特征会导致故障,可以指导快速切分“坏流量”;甚至来个 hotfix,直接一把修复了。

当然,online debug 也不是好干的活,也需要一套的系统来支持,生产环境通常是最小化的,缺少很多开发依赖的 debug 工具。
那么,作为开发,紧急 online debug,我想可能需要的两类工具:

  1. 快速看到当前软件运行全景图的,比如 CPU 火焰图工具;这样可以有一个大致的定性认识
  2. 快速动态加 debug log,在某些关键路径上,动态打印一些信息;这样可以确认/排除某些原因,从而确认/缩小问题

这里必须得说,OpenResty XRay 在这方面是走得很前沿的商业产品
如果只论技术方案的话,基于 ebpf 的工具链,比如 bpftrace,个人感觉会比较有前途

保持敬畏之心

稳定性是系统的一部分,需要用系统性思维来解决,也没有银弹。

需要的是,保持敬畏之心,敬畏每一个环节,每一个视角;学习新技术,有思考,有总结,学而时习之~

前言

istio 文档有很多,但是多是面向使用者的角度,介绍了好多个新概念,对于开发者而言,不是很友好。
最近折腾了一番 istio,以开发者的视角,简单谈谈我的理解。

一句话介绍

Istio 是零散的网关配置的组装 & 推送通道

背景

在现代网关软件中,已经积累了大量的通用能力,比如:路由,限流,鉴权,日志,等等各式各样的能力。
而且这些能力并不是一成不变的,而是在不同业务场景下,需要有不同的策略调整。
所以,网关软件会通过暴露配置,来满足各种场景的定制需求。

以往常规的搞法是,搞个配置文件,网关软件启动之后,读取本地的配置文件,按照配置文件的规则来执行网关的各种逻辑。
比如,经典的 Nginx,就是提供了一套配置语法,用户将配置写在文件里。
但是,随着网关规模越来越大,配置越来越多,更新也越来越频繁,配置文件已经不太能满足需求了。(主要是,不太好做到动态局部更新)
所以,又单独搞了一个软件,负责管理这些配置,其主要用途就是将配置同步给网关软件。

这里,也就对应了两个概念:

  1. 控制面,提供配置的软件;本文主角 istio 就是代表
  2. 数据面,具体执行网关逻辑的软件;比如 Envoy,它通常是作为 Istio 的搭档出场。

干什么的

搞懂一个软件,核心是抓住输入 & 输出

  1. 输入:各种零散的网关配置
  2. 输出:Envoy 能理解的配置

这里引出两个层次概念:

Istio CRD

如上所述,在网关领域,有各种各样的配置,那么,在 Istio 这一层,则对这些配置进行了一层语义抽象。
按照 k8s 的套路,也就有了 CRD 这个概念,Custom Resource Description(k8s 把啥都抽象为了资源)。
(当然,Istio 也可以读取 k8s 里的中的标准资源,比如 Service,Endpoints 等)

具体是咋个抽象的呢?列举几个 CRD:

  1. Gateway:描述网关层,流量进来的端口,协议,域名之类的
  2. VirtualService:描述的虚拟服务,主要是路由规则,比如不同请求的转发处理策略
  3. Destination Rule:描述服务的流量策略,复杂均衡算法,连接池策略等
  4. Service Entry:描述服务,地址,端口,等

举个栗子,有这么个需求:http://example.com/v1.txt 随机转发到 1.1.1.1:802.2.2.2:80 这两个地址,需要拆分为四个资源:

  1. Gateway,定义一个网关,端口是 80,协议是 http,域名是 example.com
  2. Virtual Service,定义个虚拟服务,uri 是 /v1.txt 的转发到一个目标 xxx
  3. Destination Rule,指定 xxx 目标的复杂均衡策略:随机
  4. Service Entry,指定 xxx 服务的地址:1.1.1.1:802.2.2.2:80

这里不同种类的资源,也就是 Istio 的输入,零散的网关配置。
CRD 是面向使用的抽象,用户只需要将希望的效果,通过 CR 描述出来,具体的实现是不用关心的。

为啥要拆开成这么多资源呢?以我开发者的视角,主要还是为了方便局部动态更新,比如应用服务扩容了,只需要更新 Service Entry 就行了,其他的不用动。

xDS

在数据面,也就是 Envoy 这一层,并不是直接接受 CR 的,而是又提供了一套自己的抽象。
也就是 LDS,RDS,CDS,EDS 这些,统称为 xDS,这里就不详细展开了。

简单点来说,xDS 的抽象更贴近 Envoy 的实现细节,Istio CRD 更贴近用户描述。
xDS,既是 Envoy 的输入,也是 Istio 的输出。

工作流程

首先,基本模式是,Envoy 是向 Istio 订阅指定资源。

当 Istio 收到一个 xDS 订阅时(比如 LDS),大致有这么几个环节:

  1. 筛选当前订阅的 xDS,所需要用到的 CR
    这里的筛选条件还是挺复杂的,也不细说了。举一个简单的,用 Envoy 所属的 namespace 来筛选
  2. 组装 & 转换
    一个 xDS 可能会对应多个 CR,这个时候需要组装起来,转换为 xDS 格式
  3. 推送
    推送给 Envoy,如果后续某个 CR 有更新,也是这个推送流程

那么,Istio 用到的 CR 资源是从哪里来呢?
有多种方式,从 k8s API server 来,从 MCP server 来,从本地 watch 的文件来,等。

Istio 收到了 CR 之后,就全量存储在本地内存中(是的,就是这么简单粗暴)

  1. 收到了 Envoy 的订阅,就走上面的筛选 & 组装 & 推送流程
  2. 收到了 CR 更新,也是走类似的筛选 & 组装 & 推送流程

总结

打个比方,Istio 就是一个工厂,将原材料 CR,加工成为 xDS,也就是它的下游(Envoy)所需要的原材料。
不过呢,它对需要的原材料是有标准的,也就是 CRD 描述的标准。

最后,软件系统就像积木一样,是一层层搭建出来的,每一层都有抽象出一套自己的标准。
如果抽象得好,表达能力强,适用范围广,大家都遵循这套标准,那就成为了业界标准,也就有了更长远的生命力。
如果只是一个小范围流行,生命力就没那么强了,不是说一定活不下去,但是很难说得上绚烂。