当前位置: 首页 > 工具软件 > fasthttp > 使用案例 >

那些fasthttp优化性能的技巧

舒俊雄
2023-12-01


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

fasthttp使用的技巧

1. 大量使用sync.Pool

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

我写了一个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

可以看出,使用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 pratice

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

 类似资料: