七叶笔记 » golang编程 » 那些fasthttp优化性能的技巧

那些fasthttp优化性能的技巧

上一篇文章阐述了fasthttp的workpool原理。除了workerpool,fasthttp还大量使用了别的技巧来提升性能,本文将对典型的技巧予以一一介绍。并在最后介绍fasthttp推荐的一些best practices。

fasthttp使用的技巧

1. 大量使用sync.Pool

fasthttp中很多对象都通过使用sync.Pool达到复用的目的,一是减少内存分配,二是减少GC。复用的对象包括:

  • workerpool
  • RequestCtx
  • reader
  • writer

我写了一个benchmark代码,比较利用sync.Pool创建RequestCtx和直接创建:

 import (
"sync"
"testing"

"github.com/valyala/fasthttp"
)

func BenchmarkPool(b *testing.B) {
ctxPool := sync.Pool{
New: func() interface{} {
return new(fasthttp.RequestCtx)
},
}
for i := 0; i < b.N; i++ {
ctx := ctxPool.Get()
ctxPool.Put(ctx)
}
}

func BenchmarkNoPool(b *testing.B) {
var ctx *fasthttp.RequestCtx
for i := 0; i < b.N; i++ {
ctx = new(fasthttp.RequestCtx)
_ = ctx
}
}
  

执行benchmark:

  go test -bench=. -benchtime=10s -benchmem syncpool_test.go  

结果如下:

 BenchmarkPool-12        958146309               11.90 ns/op            0 B/op          0 allocs/op
BenchmarkNoPool-12      38607038               281.9 ns/op          1408 B/op          1 allocs/op  

可以看出(11.90 ns/op vs 281.9 ns/op),使用sync.Pool性能提升了 20 多倍。

2. 用slice而非map来存储kv

众所周知,http请求的header以及body其实就是一个kv字典,所以一般用map[string]string或者map[string][]string来表示。但是fasthttp使用了[]slice来存储kv,这样做的好处是:当参数使用完需要清理时不用释放内存,只需将长度变为0,其申请的内存还在,下次使用的时候直接覆盖就可以了,这样便于复用,避免重新申请内存。来看具体的代码:

 type Args struct {
noCopy noCopy //nolint:unused,structcheck

args []argsKV
buf  []byte
}

type argsKV struct {
key     []byte
value   []byte
noValue bool
}

// Reset clears query args. 清理时只需要将长度变为0
func (a *Args) Reset() {
a.args = a.args[:0]
}

func allocArg(h []argsKV) ([]argsKV, *argsKV) {
n := len(h)
if cap(h) > n { //分配时,如果容量够,那么不需要重新申请内存,直接增加长度即可
h = h[:n+1]
} else { //容量不够时才需重新分配
h = append(h, argsKV{
value: []byte{},
})
}
return h, &h[n]
}
  

这样做有一个 弊端 :当要查询的时候,时间复杂度是O(N),因为要遍历整个slice。但是一般一个请求kv项不会特别多,所以查询的性能损耗不明显:

 func peekArgBytes(h []argsKV, k []byte) []byte {
for i, n := 0, len(h); i < n; i++ {
kv := &h[i]
if bytes.Equal(kv.key, k) {
return kv.value
}
}
return nil
}
  

3. 大量使用[]byte,而非string

在golang中,string是不可变的。也就是说要修改一个string,需要重新分配一块内存。而使用[]byte有两个好处:

1)[]byte可以支持修改,不需要重新申请内存。

2)[]byte使用后可以像以下一样将其长度置为0以表示清理,这样可以用来复用,避免下次使用重新申请。

 var h []byte
func Reset(h []byte){
    h = h[:0]
}
  

4. []byte和string的转换

string的底层其实是StringHeader结构:

 type StringHeader struct {
Data uintptr
Len  int
}  

而slice底层是SliceHeader结构:

 type SliceHeader struct {
Data uintptr
Len  int
Cap  int
}  

如果直接用类型强转,则需新申请一块内存来存放Data数据。

然而两者结构相似,只是slice多了一个Cap属性。fasthttp基于这一点优化了两者的相互转换。
[]byte转换为string时,只需要将指针强转即可,相当于丢弃了Cap属性:

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

string转换为[]byte时,则新建一个string结构体,但是将[]byte的Data和Len赋给新建的结构体,无需新申请一块Data内存。

 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
}
  

注: []byte和string的优化依赖golang底层对两者的实现,如果后面的版本中底层实现有所变动,可能该优化不再适用。

5. bufio.Peek代替bufio.ReadBytes

在bufio库中,Peek和ReadBytes都是读取字节,不同的是Peek是直接返回Reader的buf,而ReadBytes新申请一块内存,并将要读的那些字节从reader.buf中拷贝到新申请的内存中。所以,适用Peek的好处是读取字节时,可避免新申请内存。

 func (b *Reader) Peek(n int) ([]byte, error) {
// ...

// 0 <= n <= len(b.buf)
var err error
if avail := b.w - b.r; avail < n {
// not enough data in buffer
n = avail
err = b.readErr()
if err == nil {
err = ErrBufferFull
}
}
return b.buf[b.r : b.r+n], err
}

func (b *Reader) ReadBytes(delim byte) ([]byte, error) {
full, frag, n, err := b.collectFragments(delim)
// Allocate new buffer to hold the full pieces and the fragment.
buf := make([]byte, n)
n = 0
// Copy full pieces and fragment in.
for i := range full {
n += copy(buf[n:], full[i])
}
copy(buf[n:], frag)
return buf, err
}
  

Best Practices

在fasthttp的基础上,这里罗列一些关于golang开发提升性能的best practice:

1)不要分配对象和[]byte,尽量复用它们,建议使用sync.Pool

2)可以通过pprof来分析程序:

 go tool pprof --alloc_objects your-program mem.pprof # 分析对象分配
go tool pprof your-program cpu.pprof #分析cpu  

3)写一些压测代码,来比较性能,golang官方测试文档

4)避免直接用类型强转[]byte和string,这会带来一些内存分配和拷贝开销。fasthttp提供巧妙的方法来做转换,避免了内存分配和拷贝,但是依赖于golang底层对slice和string的实现。

5)关于[]byte的一些小tip:

  • golang可以使用nil类型的slice:
 var (
    // 均未初始化
    dst []byte
    src []byte
 )

// 以下case,即使dst或者src是nil,都是没有问题的
 dst = append(dst, src...)  
 copy(dst, src)
 (string(src) == "") 
 (len(src) == 0)  
 src = src[:0]

 // src是nil时也不会panic
 for i, ch := range src {
  doSomething(i, ch)
  }  

所以像下面的长度检查是不需要的:

 srcLen := 0
if src != nil {
 srcLen = len(src)
}  

直接这样用就可以:

 srcLen := len(src)  
  • string可以直接append在[]byte后面
 dst = append(dst, "foobar"...)  
  • []byte可以将其长度扩展至cap,这在[]byte复用的时候经常用到
 buf := make([]byte, 100)
a := buf[:10]  // len(a) == 10, cap(a) == 100.
b := a[:100]  // is valid, since cap(a) == 100.  
  • []byte和string的转换优化,这个上面已经提过好几次了。

总结

fasthttp是一个优秀的http框架,虽然它的一些用法和标准库不太一样,但是其底层优化性能的技巧很值得借鉴,在优化服务性能的时候,这些经验都可以拿来学习。

相关文章