31 性能下降10倍的真凶:阻塞函数 你好,我是温铭。

通过前面几个章节的学习,相信你已经对 LuaJIT、OpenResty 的架构,以及Lua API 和测试等方面有了全面的了解。下面,我们就要进入本专栏内容最多,也是最容易被忽视的性能优化章节了。

在性能优化章节中,我会带你熟悉 OpenResty 中性能优化的方方面面,并把前面章节中提到的零散内容,总结为全面的 OpenResty 的编码指南,以便你编写出更高质量的 OpenResty 代码。

要知道,提升性能并不容易,你需要考虑到系统架构优化、数据库优化、代码优化、性能测试、火焰图分析等不少步骤。但相反,降低性能却很容易,就像今天这节课的标题一样,你只需要加几行代码,就可以让性能下降 10 倍甚至更多。如果你使用了 OpenResty 来编写代码,但性能却一直提不上去,那么很可能就是因为使用了阻塞函数。

所以,在介绍性能优化的具体方法之前,让我们先来了解下 OpenResty 编程中的一个重要原则:避免使用阻塞函数

我们从小就被家长和老师教育,不要玩火,不要触碰插头,这些都是危险的行为。同样的,在 OpenResty 中也存有这种危险的行为。如果你的代码中存在阻塞的操作,就会导致性能的急剧下降,那么我们使用 OpenResty 来搭建高性能服务端的初衷,也将会落空。

为什么不要用阻塞操作?

了解哪些行为是危险的,并避免使用它们,是性能优化的第一步。让我们先来回顾下,为什么阻塞操作会影响 OpenResty 的性能。

OpenResty 之所以可以保持很高的性能,简单来说,是因为它借用了 Nginx 的事件处理和 Lua 的协程机制,所以:

  • 在遇到网络 I/O 等需要等待返回才能继续的操作时,就会先调用 Lua 协程的 yield 把自己挂起,然后在 Nginx 中注册回调;
  • 在 I/O 操作完成(也可能是超时或者出错)后,由 Nginx 回调 resume,来唤醒 Lua 协程。

这样的流程,保证了 OpenResty 可以一直高效地使用 CPU 资源,来处理所有的请求。

在这个处理流程中,如果没有使用 cosocket 这种非阻塞的方式,而是用阻塞的函数来处理 I/O,那么 LuaJIT 就不会把控制权交给 Nginx 的事件循环。这就会导致,其他的请求要一直排队等待阻塞的事件处理完,才会得到响应。

综上所述,在 OpenResty 的编程中,对于可能出现阻塞的函数调用,我们要特别谨慎;否则,一行阻塞的代码,就会把整个服务的性能拖垮。

下面,我再来介绍几个常见的坑,也就是一些经常会被误用的阻塞函数;我们也一起来体会下,如何用最简单的方式“搞破坏“,快速让你的服务性能下降 10 倍。

执行外部命令

在很多的场景下,开发者并不只是把 OpenResty 当作 web 服务器,而是会赋予更多业务的逻辑在其中。这种情况下,就有可能需要调用外部的命令和工具,来辅助完成一些操作了。

比如杀掉某个进程: os.execute(“kill -HUP “ .. pid)

或者是拷贝文件、使用 OpenSSL 生成密钥等耗时更久的一些操作:

os.execute(“ cp test.exe /tmp “) os.execute(“ openssl genrsa -des3 -out private.pem 2048 “)

表面上看,

os.execute 是 Lua 的内置函数,而在 Lua 世界中,也确实是用这种方式来调用外部命令的。但是,我们要记住,Lua 是一种嵌入式语言,它在不同的上下文环境中,会有完全不同的推荐用法。

在 OpenResty 的环境中,

os.execute 会阻塞当前请求。所以,如果这个命令的执行时间特别短,那么影响还不是很大;可如果这个命令,需要执行几百毫秒甚至几秒钟的时间,那么性能就会有急剧的下降。

问题我们明白了,那么应该如何解决呢?一般来讲,有两个解决方案。

方案一:如果有 FFI 库可以使用,那么我们就优先使用 FFI 的方式来调用。

比如,上面我们是用 OpenSSL 的命令行来生成密钥,就可以改为,用 FFI 调用 OpenSSL 的 C 函数的方式来绕过。

而对于杀掉某个进程的示例,你可以使用

lua-resty-signal 这个 OpenResty 自带的库,来非阻塞地解决。代码实现如下,当然,这里的

lua-resty-signal ,其实也是用 FFI 去调用系统函数来解决的。 local resty_signal = require “resty.signal” local pid = 12345 local ok, err = resty_signal.kill(pid, “KILL”)

另外,在 LuaJIT 的官方网站上,专门有一个页面,里面分门别类地介绍了各种 FFI 的绑定库。当你在处理图片、加解密等 CPU 密集运算的时候,可以先去里面看看,是否有已经封装好的库,可以拿来直接使用。

方案二:使用基于

ngx.pipe 的

lua-resty-shell 库。

正如之前介绍过的一样,你可以在

shell.run 中运行你自己的命令,它就是一个非阻塞的操作: $ resty -e ‘local shell = require “resty.shell” local ok, stdout, stderr, reason, status = shell.run([[echo “hello, world”]]) ngx.say(stdout) ‘

磁盘 I/O

我们再来看下,处理磁盘 I/O 的场景。在一个服务端程序中,读取本地的配置文件是一个很常见的操作,比如下面这段代码: local path = “/conf/apisix.conf” local file = io.open(path, “rb”) local content = file:read(“/*a”) file:close()

这段代码使用

io.open ,来获取某个文件中的所有内容。不过,虽然它是一个阻塞的操作,但别忘了,事情都要在实际场景下来考虑。如果你在 init 和 init worker 中调用,那么它其实是个一次性的动作,并没有影响任何终端用户的请求,是完全可以被接受的。

当然,如果每一个用户的请求,都会触发磁盘的读写,那就变得不可接受了。这时,你就需要认真地考虑解决方案了。

第一种方式,我们可以使用

lua-io-nginx-module 这个第三方的 C 模块。它为 OpenResty 提供了“非阻塞”的 Lua API,不过,这里的非阻塞是加了引号的,你不能像 cosocket 一样,随心所欲地去使用它。因为磁盘的 I/O 消耗并不会平白无故地消失,只不过是换了一种方式而已。

这种方式的原理是,

lua-io-nginx-module 利用了 Nginx 的线程池,把磁盘 I/O 操作从主线程转移到另外一个线程中处理,这样,主线程就不会因为磁盘 I/O 操作而被阻塞。

不过,使用这个库时,你需要重新编译 Nginx,因为它是一个 C 模块。它的使用方法如下,和 Lua 的 I/O 库基本是一致的: local ngx_io = require “ngx.io” local path = “/conf/apisix.conf” local file, err = ngx_io.open(path, “rb”) local data, err = file: read(“/*a”) file:close()

第二种方式,则是尝试架构上的调整。对于这类磁盘 I/O,我们是否可以换种方式,不再读写本地磁盘呢?

这里我举一个例子,你可以举一反三去思考。在多年之前,我经手的一个项目中,需要在本地磁盘中记录日志,以便统计和排除问题。

当时的开发者,是用

ngx.log 来写这些日志的,就像下面这样: ngx.log(ngx.WARN, “info”)

这行代码调用的是 OpenResty 提供的 Lua API,看上去没有任何问题。但是,缺点在于,你不能频繁地去调用它。首先,

ngx.log 本身就是一个代价不小的函数调用;其次,即使有缓冲区,大量而频繁的磁盘写入,也会严重地影响性能。

那该如何解决呢?让我们回到原始的需求——统计和排错,而写入本地磁盘,本就只是达成目的的手段之一。

所以,你还可以把日志发送到远端的日志服务器上,这样就可以用 cosocket 来完成非阻塞的网络通信了,也就是把阻塞的磁盘 I/O 丢给日志服务,不要阻塞对外的服务。你可以使用

lua-resty-logger-socket ,来完成这样的工作: local logger = require “resty.logger.socket” if not logger.initted() then local ok, err = logger.init{ host = ‘xxx’, port = 1234, flush_limit = 1234, drop_limit = 5678, } local msg = “foo” local bytes, err = logger.log(msg)

其实,你应该也发现了,上面两个方法的本质都是一样的:如果阻塞不可避免,那就不要阻塞主要的工作线程,丢给外部的其他线程或者服务就可以了。

luasocket

最后,我们来说说 luasocket ,它也是容易被开发者用到的一个 Lua 内置库,经常有人分不清 luasocket 和 OpenResty 提供的 cosocket。luasocket 也可以完成网络通信的功能,但它并没有非阻塞的优势。如果你使用了 luasocket,那么性能也会急剧下降。

但是,luasocket 同样有它独特的使用场景。不知道你还记得吗?前面我们讲过,cosocket 在不少阶段是无法使用的,我们一般可以用

ngx.timer 的方式来绕过。同时,你也可以在

init_by_lua/* 和

init_worker_by_lua/* 这种一次性的阶段中,使用 luasocket 来完成 cosocket 的功能。越熟悉 OpenResty 和 Lua 的异同,你就越能找到类似这样的有趣的解决方案。

另外,

lua-resty-socket 其实就是一个二次封装的开源库,它做到了 luasocket 和 cosocket 的兼容。这个内容也值得进一步研究,如果你学有余力,这里我给你准备了继续学习的资料

写在最后

总的来说,在OpenResty 中,认识到阻塞操作的类型和解决方法,是做好性能优化的基础。那么,在实际的开发中,你遇到过类似的阻塞操作吗?你又是如何来发现和解决的呢?欢迎留言和我分享你的经验,也欢迎你把这篇文章分享出去。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/OpenResty%e4%bb%8e%e5%85%a5%e9%97%a8%e5%88%b0%e5%ae%9e%e6%88%98/31%20%e6%80%a7%e8%83%bd%e4%b8%8b%e9%99%8d10%e5%80%8d%e7%9a%84%e7%9c%9f%e5%87%b6%ef%bc%9a%e9%98%bb%e5%a1%9e%e5%87%bd%e6%95%b0.md