关于 Benchmark 的几个思考

之前搭建的push layer 需要有一个性能指标,leader 让我做的这个性能测试.其实Go的工具 go-tools 里面是有go test 的,可以测benchmark ,并且是支持Parallel的,非常的方便,不过我想借此机会了解一下benchmark的设计思路和原理,所以自己写了一个.


我测试的目标代码是一个网络io型程序,主要涉及到rpc的调用和redis的查询检索,偏向于网络io,涉及到的部分mqtt协议解包封包用到了一小部分现成的库并不是一个cpu消耗型的程序,逻辑不算复杂.


Benchmark 说起来很简单,就是并发的请求,记录响应时间,求出 qps,也可以很好的想到设计方式,使用单链接,密集发送tcp request.然而过程中还是遇到了一些思路上的问题.


1\   如何设计并发

其实这个还是涉及到什么叫做并发.现代PC几乎都是多道系统,cpu 大多是多核多线程,程序其实是在不停地在用户线程与系统内核线程之间进行切换,系统内核执行操作系统原语,用户线程通过调度类似与系统线程在core上执行.但是这种并发,其实是宏观的,只是说在一短时间内有多少个用户线程被执行了,这个宏观上看起来如同大家都在一起跑,微观上其实是多道分时操作系统划分了cpu时间片分别调度他们执行.golang在引入了gouroutine之后,本来N-1的模型,扩展到了N-M.本来很基础的问题最后成了我设计的测试程序的第一个逻辑错误.

2\qps峰值与压力的关系

实体机(非docker)下测试环境,性能与压力之间其实是一个曲线关系:

从启动到达到峰值,qps其实是随着压力的增大在不断的增大,因为此时性能并没有达到上限,服务器仍然有空余的服务能力

直到压力达到某个临界值,服务器能力恰好达到一个峰值,此时应该就是我们需求的性能峰值

从峰值之后,随压力继续增大,qps会开始下降,这个也可以理解,如果设计的类似buffer的缓存不太合理,达到服务瓶颈之后,就会有更多的请求链接来,而服务器堆积的请求自然就会增加(负反馈).导致服务器的额外开销增大,服务能力下降.

因此,一味的增大压力并不能直接的取得结果,而是应该采取探测方法,类似tcp发送窗口的慢启动和拥塞避免,启发式调节并发的数量,逼近测试结果.

3\开始设想的测试思路以及问题

一开始我想要得到qsp性能,于是我采用了计时的方式,希望记录10w请求处理完消耗的总时间(S),于是我采用了循环的方式尽可能多的开启goroutine来并发,以希望能够获得高的压力的同时精确的控制我的测量条件  qps  =  10w / S

然而这种测试方法有很多天然的问题:


1\使用for并发的开启goroutine速度十分快,快到了在0.01%的时间内就开起了所有的并发等待调度,这种情况最直接的表述就是,我在1ms内开启了10w并发,剩下的所有时间都在并行的调度...也就是说服务器在极短的时间内受到了成吨的压力!这样的并发是极其高的,如果不考虑go调度的机制,我希望的压力的与我实际给的压力之间可能有10^3的数量级差距.这种测试写出的demo一启动就会遇到TCP CONNECTION REFUSED 这种情况,其实说白了就是直接打满了buffer,很多请求直接被拒绝了.因此这种方式并不能算是真正的并发能力测试...


2\使用时间作为衡量的维度其实是不准确的,用涛哥的话来说,时间作为测量的标准是不准确的因为时间很肯能因为系统设计的不合理、以及额外的开销,进入一种负反馈状态(具体我还没想明白)...我理解的意思就是时间在系统上会有很多因素影响,并不一定是我期待的记录的时间,很肯会有所出入,因此,使用时间作为维度进行测量是不精确的.正确的方式应该是划定了时间看时间段(S)内能够完成的请求数量(Q)  qps = Q/S  时间段的设定可以相对的大,减小波动测量的更加准确

4\一种解决方案

其实,前面的问题说白了就是测量有两个维度,一个是时间,一个是请求数,测量时间可能会不准确,所以选择测量请求数.

但是我仍然不死心,希望通过控制并发数来测量.既然我的并发不均匀,那我就想办法让他均匀: 每发100请求,就sleep 1ms。通过这种控制既能均匀的取得并发不至于把服务器压死,也能满足我的需求.

不过这种方案的代码最后被我改成了一个打压力的工具...真正用来测量的方案还是选择了以时间为维度

5\最终的解决方案

最终采用的测试方法主要有两个关键点,时间和并发量:
并发量很好解释,就是到底有多少个gouroutine在进行并发.时间其实是一个标准,在t时间内进行了q个请求,qps就是q/t.
这种方法更容易控制变量,而且也更贴合真正的并发,我的1000并发其实是在顺序的执行request,但是每个goroutine又是相互独立的.只需要加锁来计算总数即可.
import (
	"flag"
	"fmt"
	"log"
	"runtime"
	"sync"
	"time"
)

func main() {
	runtime.GOMAXPROCS(8)
	p := flag.Int("p", 1000, "how many go to run")
	times := flag.Int("s", 30, "test time")
	flag.Parse()
	var i int
	l := sync.Mutex{}
	f := F()
	sum := 0
	max := time.Duration(0)
	min := time.Duration(1e10)
	total := time.Duration(0)
	count := 0
	for i = 0; i < *p; i++ {
		go func() {
			j := int64(i)
			for {
				rt := f(j)
				l.Lock()
				sum++
				if rt > time.Duration(1000000000) {
					count++
				}
				if rt < min {
					min = rt
				}
				if rt > max {
					max = rt
				}
				total = total + rt
				l.Unlock()
				log.Printf("%d mid cost %v time to deliver", j, rt)
			}
			defer l.Unlock()
		}()
	}
	time.Sleep(time.Duration(int64(*times * 1e9)))
	l.Lock()
	k := count * 100 / sum
	fmt.Printf("=============================================\n\n")
	fmt.Printf("Total request %d\n", sum)
	fmt.Printf("Max time %v\n", max)
	fmt.Printf("Min time %v\n", min)
	fmt.Printf("Avg time %v\n", time.Duration(int64(int64(total)/int64(sum))))
	fmt.Printf("QPS at %d parallel run for %d second : %d\n", *p, *times, sum/(*times))
	fmt.Printf("There are %% %d quarry cost more than 1s\n\n", k)
	fmt.Printf("=============================================\n")
}


6\ 遗留

说到并发就不得不说清楚go自己的并发机制,因为goroutine其实并不是真正意义上的协程,所以我打算这几天结合LR大神上次讲的好好梳理一下linux上的线程相关问题以及go的goroutine是如何调度,实现c100k问题


你可能感兴趣的:(tcp,性能测试,golang,Benchmark,parallel)