关于nginx:把-Go-放到-Nginx-C-module-之中

1次阅读

共计 2375 个字符,预计需要花费 6 分钟才能阅读完成。

最近一段时间,我在做一件乏味的事件,让一个 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 我的项目之前,能够掂量下它带来的益处是否超过对应的局限。

正文完
 0