坊间传言 fasthttp 在某些场景下比 nginx 还要快,说明 fasthttp 中应该是做足了优化。我们来做一些相关的验证工作。
先是简单的 hello server 压测。下面的结果是在 mac 上得到的,linux 下可能会有差异。
fasthttp:
标准库:
rust 的 actix,编译选项带 --release:
好家伙,虽然是 hello 服务,但是 fasthttp 在性能上竟然赶上 rust 编写的服务了,确实有点夸张。这也间接证明了,“某些场景”至少可能真的和不带 GC 的语言性能差不多。
说明 fasthttp 里所做的优化是值得我们做点研究的。不过 fasthttp 搞的这些优化点也并不是很神奇,首先是很常见的 goroutine workerpool,对创建的 goroutine 进行了重用,其本身的 workerpool 结构:
核心就是 ready 数组,该数组的元素是已经创建出来的 goroutine 的 job channel。
这么多年过去了,基本的 workerpool 的模型还是没什么变化。最早大概是在 handling 1 million requests with go 提到,fasthttp 中的也只是稍有区别。
具体的请求处理流程也比较简单:
tcp accept -> workerpool.Serve -> 从 workerpool 的 ready 数组中获取一个 channel -> 将当前已 accept 的连接发送到 channel 中 -> 对端消费者调用 workerFunc
这里这个 workerFunc 其实就是 serveConn,之所以不写死成 serveConn 主要还是为了在测试的时候能替换掉做 mock,不新鲜。
主要的 serve 流程:
在整个 serve 流程中,几乎所有对象全部都进行了重用,ctx(其中有 Request 和 Response 结构),reader,writer,body read buffer。可见作者对于内存重用达到了偏执的程度。
同时,对于 header 的处理,rawHeaders 是个大 byte 数组。解析后的 header 的 value 如果是字符串类型,其实都是指向这个大 byte 数组的,不会重复生成很多小对象:
如果是我们自己写这种 kv 结构的 header,大概率就直接 map[string][]string 上了。
通过阅读 serveConn 的流程我们也可以发现比较明显的问题,在执行完用户的 Handler 之后,fasthttp 会将所有相关的对象全部释放并重新推进对象池中,在某些场景下,这样做显然是不合适的,举个例子:
当用户流程中异步启动了 goroutine,并且在 goroutine 中使用 ctx.Request 之类对象时就会遇到并发问题,因为在 fasthttp 的主流程中认为 ctx 的生命周期已经结束,将该 ctx 放回了 sync.Pool,然而用户依然在使用。想要避免这种问题,用户需要将各种 fasthttp 返回的对象人肉拷贝一遍。
从这点上来看,基于 sync.Pool 的性能优化往往也是有代价的,无论在什么场景下使用 sync.Pool,都需要对应用程序中的对象生命周期进行一定的假设,这种假设并不见得适用于 100% 的场景,否则这些手段早就进标准库,而非开源库了。
对于库的用户来说,这样的优化手段轻则带来更高的心智负担,重则是线上 bug。在使用开源库之前,还是要多多注意。非性能敏感的业务场景,还是用标准库比较踏实。
参考资料
[1] https://github.com/valyala/fasthttp
[2] https://medium.com/smsjunk/handling-1-million-requests-per-minute-with-golang-f70ac505fca
如果您的朋友也在学习 Go 语言,相信这篇文章对 TA 有帮助,欢迎转发分享给 TA,非常感谢!