蛮荆

fasthttp 为什么比标准库 net/http 快 10 倍 ?

2023-04-07

概述

fasthttp 是一个使用 Go 语言开发的 HTTP 包,主打高性能,针对 HTTP 请求响应流程中的 hot path 代码进行了优化,达到零内存分配,性能比标准库的 net/http 快 10 倍。

上面是来自官方 Github 主页的项目介绍,抛开其介绍内容不谈,光从名字本身来看,作者对项目代码的自信程度可见一斑。

本文不会讲解 fasthttp 的应用方法,而是会重点分析 fasthttp 高性能的背后实现原理。

基准测试

我们可以通过基准测试看看 fasthttp 是否真的如描述所言,吊打标准库的 net/http,下面是官方提供的基准测试结果:

net/http

$ GOMAXPROCS=4 go test -bench='HTTPClient(Do|GetEndToEnd)' -benchmem -benchtime=10s
BenchmarkNetHTTPClientDoFastServer-4                    	 2000000	      8774 ns/op	    2619 B/op	      35 allocs/op
BenchmarkNetHTTPClientGetEndToEnd1TCP-4                 	  500000	     22951 ns/op	    5047 B/op	      56 allocs/op
BenchmarkNetHTTPClientGetEndToEnd10TCP-4                	 1000000	     19182 ns/op	    5037 B/op	      55 allocs/op
BenchmarkNetHTTPClientGetEndToEnd100TCP-4               	 1000000	     16535 ns/op	    5031 B/op	      55 allocs/op
BenchmarkNetHTTPClientGetEndToEnd1Inmemory-4            	 1000000	     14495 ns/op	    5038 B/op	      56 allocs/op
BenchmarkNetHTTPClientGetEndToEnd10Inmemory-4           	 1000000	     10237 ns/op	    5034 B/op	      56 allocs/op
BenchmarkNetHTTPClientGetEndToEnd100Inmemory-4          	 1000000	     10125 ns/op	    5045 B/op	      56 allocs/op
BenchmarkNetHTTPClientGetEndToEnd1000Inmemory-4         	 1000000	     11132 ns/op	    5136 B/op	      56 allocs/op

fasthttp

$ GOMAXPROCS=4 go test -bench='kClient(Do|GetEndToEnd)' -benchmem -benchtime=10s
BenchmarkClientDoFastServer-4                           	50000000	       397 ns/op	       0 B/op	       0 allocs/op
BenchmarkClientGetEndToEnd1TCP-4                        	 2000000	      7388 ns/op	       0 B/op	       0 allocs/op
BenchmarkClientGetEndToEnd10TCP-4                       	 2000000	      6689 ns/op	       0 B/op	       0 allocs/op
BenchmarkClientGetEndToEnd100TCP-4                      	 3000000	      4927 ns/op	       1 B/op	       0 allocs/op
BenchmarkClientGetEndToEnd1Inmemory-4                   	10000000	      1604 ns/op	       0 B/op	       0 allocs/op
BenchmarkClientGetEndToEnd10Inmemory-4                  	10000000	      1458 ns/op	       0 B/op	       0 allocs/op
BenchmarkClientGetEndToEnd100Inmemory-4                 	10000000	      1329 ns/op	       0 B/op	       0 allocs/op
BenchmarkClientGetEndToEnd1000Inmemory-4                	10000000	      1316 ns/op	       5 B/op	       0 allocs/op

基准结果对比

从基准测试结果来看,fasthttp 的执行速度要比标准库的 net/http 快很多,此外,fasthttp 的内存分配方面优化到了 0, 完胜 net/http

核心优化点

笔者选择的 valyala/fasthttp 版本为 v1.45.0

对象复用

workerPool

workerpool 对象表示 连接处理 工作池,这样可以控制连接建立后的处理方式,而不是像标准库 net/http 一样,对每个请求连接都启动一个 goroutine 处理, 内部的 ready 字段存储空闲的 workerChan 对象,workerChanPool 字段表示管理 workerChan 的对象池。

// workerpool.go
type workerPool struct {
	ready []*workerChan

	workerChanPool sync.Pool
}

type workerChan struct {
    lastUseTime time.Time
    ch          chan net.Conn
}

请求/响应 对象

请求对象 Request 和响应对象 Response 都是通过对象池进行管理的,对应的代码如下:

// client.go

var (
	requestPool  sync.Pool
	responsePool sync.Pool
)

// 从对象池中获取 Request 对象
func AcquireRequest() *Request {
	...
}

// 归还 Request 对象到对象池中
func ReleaseRequest(req *Request) {
	...
}

// 从对象池中获取 Response 对象
func AcquireResponse() *Response {
    ...
}

// 归还 Response 对象到对象池中
func ReleaseResponse(resp *Response) {
    ...
}

Cookie 对象也是通过对象池进行管理的,对应的代码如下:

// cookie.go

var cookiePool = &sync.Pool{
	New: func() interface{} {
		return &Cookie{}
	},
}

// 从对象池中获取 Cookie 对象
func AcquireCookie() *Cookie {
    ...
}

// 归还 Cookie 对象到对象池中
func ReleaseCookie(c *Cookie) {
	...
}

其他对象复用

$ grep -inr --include \*.go "sync.Pool" $(go list -f {{.Dir}} github.com/valyala/fasthttp) | wc -l

# 输出如下
38

通过输出结果可以看到,fasthttp 中一共有 38 个对象是通过对象池进行管理的,可以说几乎复用了所有对象,So Crazy!

[]byte 复用

fasthttp 中复用的对象在使用完成后归还到对象池之前,需要调用对应的 Reset 方法进行重置,如果对象中包含 []byte 类型的字段, 那么会直接进行复用,而不是初始化新的 []byte, 例如 URI 对象的 Reset 方法:

// 重置 URI 对象
// 从方法的内部实现中可以看到,类型为 []byte 的所有字段都被复用了
func (u *URI) Reset() {
	u.pathOriginal = u.pathOriginal[:0]
	u.scheme = u.scheme[:0]
	u.path = u.path[:0]
	u.queryString = u.queryString[:0]
	u.hash = u.hash[:0]
	u.username = u.username[:0]
	u.password = u.password[:0]

	u.host = u.host[:0]
    ...
}

此外,涉及到单个字段的修改,如果字段是 []byte 类型,还是会直接复用,例如 Cookie 对象的这几个方法:

func (c *Cookie) SetValue(value string) {
	c.value = append(c.value[:0], value...)
}

func (c *Cookie) SetValueBytes(value []byte) {
	c.value = append(c.value[:0], value...)
}

func (c *Cookie) SetKey(key string) {
	c.key = append(c.key[:0], key...)
}

func (c *Cookie) SetKeyBytes(key []byte) {
	c.key = append(c.key[:0], key...)
}

上面几个方法的内部实现中,无一例外,都对 []byte 类型的参数进行了复用。

[]byte 和 string 转换

fasthttp 专门提供了 []bytestring 这两种常见的数据类型相互转换的方法 ,避免了 内存分配 + 复制,提升性能。

// s2b_new.go
func b2s(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}

// b2s_new.go
func s2b(s string) (b []byte) {
    bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
    bh.Data = sh.Data
    bh.Cap = sh.Len
    bh.Len = sh.Len
    return b
}

高性能的 bytebufferpool

fasthttp 并没有直接使用标准库中的 bytes.Buffer 对象,而是引用了作者的另外一个包 valyala/bytebufferpool, 这个包的核心优化点是 避免内存拷贝 + 底层 byte 切片复用,感兴趣的读者可以看看官方给出的 基准测试结果

避免反射

fasthttp 中的所有 对象深拷贝 内部实现中都没有使用 反射,而是手动实现的,这样可以完全规避 反射 带来的影响,例如 Cookie 对象的拷贝实现:

// cookie.go
// Cookie 对象拷贝实现
func (c *Cookie) CopyTo(src *Cookie) {
	c.Reset()
	c.key = append(c.key, src.key...)
	c.value = append(c.value, src.value...)
	c.expire = src.expire
	c.maxAge = src.maxAge
	c.domain = append(c.domain, src.domain...)
	c.path = append(c.path, src.path...)
	c.httpOnly = src.httpOnly
	c.secure = src.secure
	c.sameSite = src.sameSite
}

从上面的代码中可以看到,拷贝 的内部实现就是手动挨个复制字段,非常 原始 的解决方案。

另外,请求对象 Request 和响应对象 Response 的拷贝实现和 Cookie 有异曲同工之处:

// client.go
func (req *Request) CopyTo(dst *Request) {
    ...
}

func (resp *Response) CopyTo(dst *Response) {
  ...
}

fasthttp 的问题

软件工程没有银弹,高性能的背后必然是以某些条件作为代价的,fasthttp 的主要问题有:

  • 降低了代码可读性 (如果不了解 fasthttp 的设计理念,贸然读代码很可能无法理解各种方法实现)
  • 增加了开发复杂性,代码开发量要比使用标准库高,对象复用导致了 申请/归还 流程彷佛回到了 C/C++ 语言手动管理内存模式
  • 增加了开发者心智负担,如果已经习惯了标准库的开发模式,很容易写出 Bug
  • 如果业务中有 异步 处理场景,框架核心的 对象复用 机制可能导致各种问题,如对象提前归还、对象指针 hang 起、还有更严重的对象字段被重置后继续引用 (这类业务逻辑问题比较难排查)

多核系统的性能优化技巧

  • 使用 reuseport 监听 (SO_REUSEPORT 允许在多核服务器上线性扩展服务器性能,详细信息请参阅 这个链接 )
  • 使用 GOMAXPROCS=1 为每个 CPU 核运行一个单独的服务器实例 (进程和 CPU 绑定)
  • 确保多队列网卡的中断均匀分布在 CPU 内核之间,详细信息请参阅 这个链接

fasthttp 最佳实践

  • 尽可能复用对象和 []byte buffers, 而不是重新分配
  • 使用 []byte 特性技巧
  • 使用 sync.Pool 对象池
  • 在生产环境对程序进行性能分析,go tool pprof --alloc_objects app mem.pprof 通常比 go tool pprof app cpu.pprof 更容易体现性能瓶颈
  • hot path 上的代码编写测试和基准测试
  • 避免 []bytestring 直接进行类型转换,因为这可能会导致 内存分配 + 复制,可以参考 fasthttp 包内的 s2b 方法和 b2s 方法
  • 定期对代码进行 竞态检测, 一般会直接集成到 CI
  • 使用 quicktemplate 而非 html/template 模板

是否采用 fasthttp

fasthttp 是为一些高性能边缘场景设计的,如果你的业务需要支撑较高的 QPS 并且保持一致的低延迟时间,那么采用 fasthttp 是非常合理的, 反之 fasthttp 可能并不适合 (增加开发复杂度和开发者心智负担)。大多数情况下,标准库 net/http 是更好的选择,因为它简单易用并且兼容性很高。 如果你的业务流量很少,那么两者之间的 所谓性能差异 几乎可以忽略 (果断使用标准库的 net/http 包就可以)。

Reference

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。