3

最近一段时间,我在做一件有趣的事情,让一个 Nginx C module 通过 Go 代码来访问 gRPC 服务。不得不感慨 Go 真的很流行,让人无法拒绝。之前我做 wasm-nginx-module 时就试图把 tinygo 跑在 Nginx 里面,这次则是把正宗的 Go 跑在 Nginx 里面。这就是无法回避的命运吗?!

作为一个想要挑战 C 语言历史地位的后起之秀,Go 提供了和 C 的紧密结合的功能,方便用户更好地过渡到新世界当中。这个功能有个朴素的名字,就是 CGO。当然,由于 Go 自己的野望,它并不甘心于复用 C 的那一套,结果就是 CGO 的调用性能不如人意。当然本文不是关乎调用性能和细节的,而是讲讲 Nginx C module 整合 Go 时遇到的一些挑战。

一开始,我计划通过静态链接把 Go 代码直接编译到 Nginx 里面,这样就不需要额外再打包代码了。编译是成功了,但是在开发过程中,我发现对应的 Go 代码并没有执行。通过 dlv 查看发现,goroutine 是创建出来了,却没有被调度到。花了些时间查找资料,我这才发现由于 Go 是多线程应用,而在 fork 之后,子进程并不会继承父进程的所有线程,结果到了子进程那里就没有线程可以调度 goroutine 了。对于一般的 C 项目可能不是大问题,但不巧的是 Nginx 是 master-worker 架构的。如果走静态链接的方式, master 上的 Go 线程将无法复制到 worker 上。为了解决这个问题,我把 Go 代码编译成动态链接库,再在 worker 进程上完成加载。

为了协调 Go 协程和 Lua 协程,我们实现了一个任务队列机制。当 Lua 代码从 gRPC 发起一个 IO 操作时,它会向 Go 端提交一个任务,然后挂起自己。这个任务由 Go 协程执行,其结果被写入队列中。在 Nginx 端有一个后台线程,消耗任务执行的结果,并重新安排相应的 Lua 协程继续执行 Lua 代码。这样,在 Lua 代码看来,gRPC 的 IO 操作与普通的 socket 操作没有什么区别。

后台线程由 Nginx 的 thread_pool 指令配置。它会阻塞在消费任务执行结果队列中,直到返回至少一个结果。但是在开发中,我发现 Nginx 在退出时,会等待所有 thread_pool 里的线程完成任务。这意味着如果后台线程无限期地阻塞在结果队列中,Nginx 将无法退出。所以我增加了一个等待时间,一旦超过给定时间后,仍未消费到任务结果,后台线程会直接返回。如果此时 Nginx 尚无退出,且还有任务没有完成,那么后台线程会重新尝试消费结果队列。

在进行 gRPC 操作时,我遇到了另一个难题。通常我们在 Go 中使用 gRPC,会基于 .proto 文件生成对应的 Go 代码,然后编译到项目中。Go 结构体和二进制的 Protobuf 间的转换,是在生成的代码里完成的。但是我做的是一个 Nginx C module,而且要求在运行时能够加载 gRPC 的 .proto 文件,自然没办法事先生成好 Go 代码并编译进来。这么一来,我得探索一条别出心裁的道理。凭借阅读 grpc-go 的代码,我发现通过 encoding.RegisterCodec 这个接口,可以注册自定义的 Marshal/Unmarshal 来覆盖掉内置的处理。 于是我自定义了对应的方法,允许直接传二进制形式的 Protobuf 而不是某个 Go 的结构体。这么搞之后,我们就可以在 Lua 代码里面动态加载 .proto 文件,根据加载的 schema 完成 Protobuf 的编解码操作。

由于 gRPC 交互包含了若干 IO 操作(比如先 connect,接着 send,然后 read),所以我们需要多次向 Go 端提交任务,而这些任务要共享一个上下文。每次 IO 操作之后,我们需要保留这个上下文,后面相关的操作时会带上。整个过程是这样的:

  1. Nginx 端创建一个上下文(对象 C)
  2. 以对象 C 的地址作为 key,到 Go 端的对应 sync.Map 里面查找是否有 Go 端的上下文
  3. 如果没有,那么创建一个上下文(对象 G)。对象 G 是对象 C 在 Go 端的化身。
  4. 使用这个上下文来完成 gRPC 操作
  5. 后续围绕对象 C 的一系列 IO 操作,都会由对象 G 在 Go 端完成。
  6. 当我们销毁对象 C 时,到 Go 端来 delete 掉 sync.Map 的引用,释放对象 G。

上述流程把 C 和 G 绑定了起来,确保如果对象 C 的生命周期是正确的,那么对象 G 的生命周期也是正确的。当我们用 ASAN 等机制保证了对象 C 的分配没有内存泄漏,那么对象 G 也不会被束缚在 sync.Map 里。

目前该 Nginx C module 已经支持 gRPC 全部四种请求类型:

  1. unary
  2. client stream
  3. server stream
  4. bidirectional stream

有些细节部分还需要打磨,不过功能已经基本可用了。

我们已经把它开源出来:https://github.com/api7/grpc-...
欢迎大家试用,并参与到项目的开发中来。

番外篇:是否可以使用 Go 拓展 Nginx?

由于 Go 并不是作为嵌入式语言设计的,所以把 Go 嵌入到 Nginx 里面,会受一些限制:

  1. Nginx 许多 API 不是线程安全的,比如基本的 log 操作。Go 作为多线程的后台组件,没办法保证调用 Nginx 时的线程安全。举个例子,Go 里面调用 ngx.log 时,Nginx 也可能正在调用 ngx.log,两者会有 race。所以在开发 grpc-client-nginx-module 时,我没办法让 Go 侧的代码直接打日志到 Nginx 的 error.log 中,给调试增加了难度。后面我另外在 Go 侧代码里实现了一套用于 debug 的 log 机制。
  2. Go 的 recover 只能捕获自己所在的 goroutine 里面的崩溃,救不了被调用的函数里面起的 goroutine 导致的崩溃。一旦崩溃跨越语言的边界,就会带着 Nginx worker 进程一起挂掉。

在是否决定引入 Go 代码到你的 Nginx 项目之前,可以衡量下它带来的好处能否超过对应的局限。


spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.