iris 真的是最快的Golang 路由框架吗?

依照我的前一篇文章(超全的Go Http路由框架性能比较)对各种Go http路由框架的比较, Iris明显胜出,它的性能远远超过其它Golang http路由框架。

但是,在真实的环境中,Iris真的就是最快的Golang http路由框架吗?

2016-04-05 更新: 我已经提交了一个Bug, 作者Makis已经做了一个临时的解决方案,性能已经恢复,所以准备使用Iris的读者不必担心。
根据我的测试,最新的Iris的测试如下:

  1. 在业务逻辑需要10毫秒时,吞吐率可以达到9281 request/s
  2. 在业务逻辑需要1000毫秒时,吞吐率可以达到95 request/s
    性能已经很不错了。

我会做一个其它路由框架的测试,看看其它的框架是否也有本文所说的问题。

Benchmark测试分析

在那篇文章中我使用的是Julien Schmidt的测试代码,他模拟了静态路由、Github API、Goolge+ API、Parse API的各种情况,因为这些API是知名网站的开放的API,看起来测试挺真实可靠的。

但是,这个测试存在着一个严重的问题,就是Handler的业务逻辑非常的简单,各个框架的handler类似,比如Iris的Handler的实现:

1
2
3
4
5
6
7
8
9
func irisHandler(_ *iris.Context) {}
func irisHandlerWrite(c *iris.Context) {
io.WriteString(c.ResponseWriter, c.Param("name"))
}
func irisHandlerTest(c *iris.Context) {
io.WriteString(c.ResponseWriter, c.Request.RequestURI)
}

几乎没有任何的业务逻辑,最多是往Response中写入一个字符串。

这和生产环境中的情况严重不符!

实际的产品肯定会有一些业务的处理,比如参数的校验,数据的计算,本地文件的读取、远程服务的调用、缓存的读取、数据库的读取和写入等,有些操作可能花费的时间很多,一两个毫秒就可以搞定,有的却很耗时,可能需要几十毫秒,比如:

  • 从一个网络连接中读取数据
  • 写数据到硬盘中
  • 调用其它服务,等待服务结果的返回
  • ……

这才是我们常用的case,而不是一个简单的写字符串。

因此那个测试框架的Handler还应该加入时间花费的情况。

模拟真实的Handler的情况

我们模拟一下真实的情况,看看Iris框架和Golang内置的Http路由框架的性能如何。

首先使用Iris实现一个Http Server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main
import (
"os"
"strconv"
"time"
"github.com/kataras/iris"
)
func main() {
api := iris.New()
api.Get("/rest/hello", func(c *iris.Context) {
sleepTime, _ := strconv.Atoi(os.Args[1])
if sleepTime > 0 {
time.Sleep(time.Duration(sleepTime) * time.Millisecond)
}
c.Text("Hello world")
})
api.Listen(":8080")
}

我们可以传递给它一个时间花费的参数sleepTime,模拟这个Handler在处理业务时要花费的时间,它会让处理这个Handler的暂停sleepTime毫秒,如果为0,则不需要暂停,这种情况类似上面的测试。

然后我们使用Go内置的路由功能实现一个Http Server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
import (
"log"
"net/http"
"os"
"strconv"
"time"
)
// There are some golang RESTful libraries and mux libraries but i use the simplest to test.
func main() {
http.HandleFunc("/rest/hello", func(w http.ResponseWriter, r *http.Request) {
sleepTime, _ := strconv.Atoi(os.Args[1])
if sleepTime > 0 {
time.Sleep(time.Duration(sleepTime) * time.Millisecond)
}
w.Write([]byte("Hello world"))
})
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

编译两个程序进行测试。
1、首先进行业务逻辑时间花费为0的测试
运行程序iris 0,然后执行wrk -t16 -c100 -d30s http://127.0.0.1:8080/rest/hello进行并发100,持续30秒的测试。
iris的吞吐率为46155 requests/second。

运行程序gomux 0,然后执行wrk -t16 -c100 -d30s http://127.0.0.1:8080/rest/hello进行并发100,持续30秒的测试。
Go内置的路由程序的吞吐率为55944 requests/second。

两者的吞吐量差别不大,iris略差一点

2、然后进行业务逻辑时间花费为10的测试
运行程序iris 10,然后执行wrk -t16 -c100 -d30s http://127.0.0.1:8080/rest/hello进行并发100,持续30秒的测试。
iris的吞吐率为97 requests/second。

运行程序gomux 10,然后执行wrk -t16 -c100 -d30s http://127.0.0.1:8080/rest/hello进行并发100,持续30秒的测试。
Go内置的路由程序的吞吐率为9294 requests/second。

3、最后进行业务逻辑时间花费为1000的测试
这次模拟一个极端的情况,业务处理很慢,处理一个业务需要1秒的时间。

运行程序iris 1000,然后执行wrk -t16 -c100 -d30s http://127.0.0.1:8080/rest/hello进行并发100,持续30秒的测试。
iris的吞吐率为1 requests/second。

运行程序gomux 1000,然后执行wrk -t16 -c100 -d30s http://127.0.0.1:8080/rest/hello进行并发100,持续30秒的测试。
Go内置的路由程序的吞吐率为95 requests/second。

可以看到,如果加上业务逻辑的处理时间,Go内置的路由功能要远远好于Iris, 甚至可以说Iris的路由根本无法应用的有业务逻辑的产品中,随着业务逻辑的时间耗费加大,iris的吞吐量急剧下降。

而对于Go的内置路由来说,业务逻辑的时间耗费加大,单个client会等待更长的时间,但是并发量大的网站来说,吞吐率不会下降太多。
比如我们用1000的并发量测试gomux 10gomux 1000

  • gomux 10: 吞吐率为47664
  • gomux 1000: 吞吐率为979

这才是Http网站真实的情况,因为我们要应付的网站的并发量,网站应该支持同时有尽可能多的用户访问,即使单个用户得到返回页面需要上百毫秒也可以接受。

而Iris在业务逻辑的处理时间增大的情况下,无法支持大的吞吐率,即使在并发量很大的情况下(比如1000),吞吐率也很低。

深入了解Go http server的实现

Go http server实现的是每个request对应一个goroutine (goroutine per request), 考虑到Http Keep-Alive的情况,更准确的说是每个连接对应一个goroutine(goroutine per connection)。

因为goroutine是非常轻量级的,不会像Java那样 Thread per request会导致服务器资源不足,无法创建很多的Thread, Golang可以创建足够多的goroutine,所以goroutine per request的方式在Golang中没有问题。而且这还有一个好处,因为request是在一个goroutine中处理的,不必考虑对同一个Request/Response并发读写的问题。

如何查看Handler是在哪一个goroutine中执行的呢?我们需要实现一个函数来获取goroutine的Id:

1
2
3
4
5
6
7
8
9
10
func goID() int {
var buf [64]byte
n := runtime.Stack(buf[:], false)
idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0]
id, err := strconv.Atoi(idField)
if err != nil {
panic(fmt.Sprintf("cannot get goroutine id: %v", err))
}
return id
}

然后在handler中打印出当前的goroutine id:

1
2
3
4
func(c *iris.Context) {
fmt.Println(goID())
……
}

1
2
3
4
func(w http.ResponseWriter, r *http.Request) {
fmt.Println(goID())
……
}

启动gomux 0,然后运行ab -c 5 -n 5 http://localhost:8080/rest/hello测试一下,apache的ab命令使用5个并发并且每个并发两个请求访问服务器。
可以看到服务器的输出:

1
2
3
4
5
6
7
8
9
10
21
18
17
19
20
33
35
36
37
34

因为没有指定-k参数,每个client发送两个请求会创建两个连接。

你可以加上-k参数,可以看出会有重复的goroutine id出现,表明同一个持久连接会使用同一个goroutine处理。

以上是通过实验验证我们的理论,下面是代码分析。

net/http/server.go第2146行 go c.serve()表明,对于一个http连接,会启动一个goroutine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (srv *Server) Serve(l net.Listener) error {
defer l.Close()
if fn := testHookServerServe; fn != nil {
fn(srv, l)
}
var tempDelay time.Duration // how long to sleep on accept failure
if err := srv.setupHTTP2(); err != nil {
return err
}
for {
rw, e := l.Accept()
……
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // before Serve can return
go c.serve()
}
}

而这个c.serve方法会从连接中读取request交由handler处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (c *conn) serve() {
……
for {
w, err := c.readRequest()
……
req := w.req
serverHandler{c.server}.ServeHTTP(w, w.req)
if c.hijacked() {
return
}
w.finishRequest()
if !w.shouldReuseConnection() {
if w.requestBodyLimitHit || w.closedRequestBodyEarly() {
c.closeWriteAndWait()
}
return
}
c.setState(c.rwc, StateIdle)
}
}

ServeHTTP的实现如下,如果没有配置handler或者路由器,则使用缺省的DefaultServeMux

1
2
3
4
5
6
7
8
9
10
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
if req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
handler.ServeHTTP(rw, req)
}

可以看出这里并没有新开goroutine,而是在同一个connection对应的goroutine中执行的。如果试用Keep-Alive,还是在这个connection对应的goroutine中执行。

正如注释中所说的那样:

   // HTTP cannot have multiple simultaneous active requests.[*]
   // Until the server replies to this request, it can't read another,
   // so we might as well run the handler in this goroutine.
   // [*] Not strictly true: HTTP pipelining.  We could let them all process
   // in parallel even if their responses need to be serialized.
  serverHandler{c.server}.ServeHTTP(w, w.req)

因此业务逻辑的时间花费会影响单个goroutine的执行时间,并且反映到客户的浏览器是是延迟时间latency增大了,如果并发量足够多,影响的是系统中的goroutine的数量以及它们的调度,吞吐率不会剧烈影响。

Iris的分析

如果你使用Iris查看每个Handler是使用哪一个goroutine执行的,会发现每个连接也会用不同的goroutine执行,可是性能差在哪儿呢?

或者说,是什么原因导致Iris的性能急剧下降呢?

Iris服务器的监听和为连接启动一个goroutine没有什么明显不同,重要的不同在与Router处理Request的逻辑。

原因在于Iris为了提供性能,缓存了context,对于相同的请求url和method,它会从缓存中使用相同的context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (r *MemoryRouter) ServeHTTP(res http.ResponseWriter, req *http.Request) {
if ctx := r.cache.GetItem(req.Method, req.URL.Path); ctx != nil {
ctx.Redo(res, req)
return
}
ctx := r.getStation().pool.Get().(*Context)
ctx.Reset(res, req)
if r.processRequest(ctx) {
//if something found and served then add it's clone to the cache
r.cache.AddItem(req.Method, req.URL.Path, ctx.Clone())
}
r.getStation().pool.Put(ctx)
}

由于并发量较大的时候,多个client的请求都会进入到上面的ServeHTTP方法中,导致相同的请求会进入下面的逻辑:

1
2
3
4
if ctx := r.cache.GetItem(req.Method, req.URL.Path); ctx != nil {
ctx.Redo(res, req)
return
}

ctx.Redo(res, req)导致不断循环,直到每个请求处理完毕,将context放回到池子中。

所以对于Iris来说,并发量大的情况下,对于相同的请求(req.URL.Path和Method相同)会进入排队的状态,导致性能低下。

参考资料

  1. https://blog.golang.org/context
  2. https://www.reddit.com/r/golang/comments/3xz1f3/go_http_server_and_go_routines/
  3. http://screamingatmyscreen.com/2013/6/http-request-and-goroutines/
  4. https://groups.google.com/forum/#!topic/golang-nuts/iwCz_pqu8R4
  5. https://groups.google.com/forum/#!topic/golang-nuts/ic3FxWZRyHs