「推荐」从openresty谈到rust
- 2019 年 10 月 31 日
- 筆記
本文转载自知乎:https://zhuanlan.zhihu.com/p/87922545?utm_source=wechat_session&utm_medium=social&utm_oi=870758040972959744&from=singlemessage
本文小编觉得讲得极好,是作者多年一线实践及细心思考的结果。故在此强烈推荐仔细阅读。
大概是2015年,我开始关注nginx,在这之前,我一直从事C++的网络开发工作(通信网的信令协议栈研发,还有CORBA框架的实现),大概有七八年吧,都沉浸在C++的世界里,没有接触过什么更高级更现代的语言。开眼看世界也是最近三四年的事情,惭愧。
接触到nginx,很自然注意到了openresty,觉得很不错。nginx代码我拜读过,觉得实现得很优化,例如http解析就用了2000多行来做,充分考虑了时空性能。当时候nginx是声名在外。openresty引入了lua,封装了cosocket,使得能在nginx的基础上很简单地做二次开发,并且因为luajit,二次开发的性能代价很小。总而言之,当时候觉得openresty十分得惊艳,进而也膜拜章亦春大神。
当时工作有点乏味,然后也有点心思想跳槽,但是想到自己这七年来都是独孤一味地钻研C++相关的底层项目,感觉自己缺乏竞争力,所以很想学点东西,于是想到可不可以我也写一个http框架呢?luajit本身的ffi很厉害,不需要codegen就可以动态加载并访问任何C库函数,它的jit性能也很高,luajit的作者,Mike Pall也是编译器的翘楚,所以我想,可否我连nginx本身也用lua来重写呢?同时我对上提供openresty一样的api,这样所有*-resty的第三方库就可以直接拿来用了。这种思路类似于linus当年编写linux内核一样,对内重写,对外兼容POSIX,使得app可以直接拿来重用,例如bash。
重写的工作很有趣,有很多挑战,例如我要用纯luajit来实现cosocket。openresty的cosocket,非阻塞和select都在nginx的C层面,所以每次陷入阻塞读写的时候,会先yield到C层面。另外,openresty的协程是有父子关系的,表现在一次http请求由一个父协程来处理,它生成的其他协程(一般用来访问外部资源,例如redis),则是其子协程。父协程可以等待(或者同时等待多个)子协程,而父协程退出后,子协程也会退出。纯luajit没有C的承托,所以只能通过lua的exception来做,通过特殊的异常抛出和捕获来实现openresty的cosocket。还有一个有趣的地方,就是nginx的热重启,是通过保留文件描述符并且通过父子进程的环境变量来透传重现的,用纯lua来做,并且还考虑linux的信号处理,则要费了一点心思了,但最后还是做出来了,当时候心情很愉悦。
这个重写最终发布的开源项目就是luajit.io,这个名字也很有意思,一方面,这是一个我申请的io后缀的域名,另一方面它也是项目的名字,io框架嘛,一语双关。各种实现细节肯定不如nginx这般精致,所以性能不会达到nginx这么好,有80%就足够了,做出来后也符合我的预期。用20%的性能换取更简单的代码实现,我觉得已经很有意义了。试想,nginx和openresty的C代码加起来这么多,而我用lua重写,只有区区5000多行代码。
发布后,收到了不少关注。不过,我也就是当一个玩具工程来练手罢了。我后来再反思,其实cosocket虽然惊艳,但是并非一枝独秀,golang就完整实现了协程化,不仅仅socket,文件访问和cpu密集型任务都可以融入到协程里面来做,所以golang具有更完整意义的cosocket。
openresty受欢迎,我觉得很大程度得益于它站在了巨人的肩膀上,那就是nginx和luajit。但是更好的事物都有时代的局限性。我这里展开来说一些它们的缺点。
先说nginx吧,nginx是多进程架构,每个worker进程(单线程)公平地去抢夺进来的tcp连接,独立处理每个tcp连接上的http请求。socket读写非阻塞,每个worker进程都有一个selector来select所有socket。处理一个http请求没有进程间切换意味着更好的性能。但多进程也有弊端:
- 在接受连接后就只能固定在一个进程里面,如果恰好该进程所处理的连接里面的http请求很多很繁忙,那么它也无法委托给其他进程来代劳,即便其他进程是空闲的,对于http2而言,我觉得这一缺点尤为突出。
- 多进程之间无法安全地共享资源。nginx的方案是放数据在共享内存里面,例如openresty的queue就是放里面的,并且通过放在共享内存里面的pthread mutex来同步。但是弊端很明显,对共享内存的操作不是原子的,例如上锁后,要对共享内存里面的红黑树做remove操作,那么对应的C代码就不少,对应到共享内存上,就有很多步操作,那么如果进行操作的进程异常退出,那么就会留下一个无法收拾的局面。例如,上锁后退出,资源就一直处于加锁状态,其他进程无法获取继续访问,这个还比较容易观察和调试出来。一般多进程系统都需要一个父进程来清理残局,但nginx没有这样做。
- worker进程是单线程,无法用它来做CPU密集型任务或者磁盘IO任务,nginx为了解决这个问题,引入worker thread pool,但openresty很难利用这个新特性,因为受限于lua虚拟机只能支持单线程的事实,如果利用,线程间交互以及数据拷贝是很大问题。
- nginx本身只是一个平台,一个特定的平台,起来一个http server给你让你处理http请求,并且能做的实现依赖于nginx导出了什么api给你,所以有时候你很难施展拳脚去适配自己的项目,例如我访问kafka,要作为它的consumer,那么就没法做了,因为没法作为server给kafka调用。
而且nginx最著名的特点:性能,也并非一枝独秀,目前rust就完全可以追上它,我后面会提到。
好了,再来说一下luajit,作者确实是一个天才,我那段时间看了很多他写的文章,他的各种理论都是如此高深莫测,他的dynasm可谓解放了汇编开发的生产力,而luajit更是让人佩服。用lua来写业务逻辑,很自然会担心性能,相比官方原生的lua的解释器性能和C不是一个等级,luajit的jit弥补了这一点,使得你既可以用lua很高兴很轻松写代码,又不必过分担心性能代价。但是,有如下问题:
- 最大的问题是lua版本的分裂,自lua5.2后,很多地方不再和官方lua兼容,并且长期停留在5.1上,作者没有意愿去改变这个局面。
- 源码实现太复杂,几乎只有Mike Pall自己才能维护它,但作者近几年来的开发活跃度很低,几年来都没发布2.1的正式版本,长期停留在beta,不知道他在忙什么。Mike Pall似乎早就说过要找接班人,但好像一直找不到。
- 你写的lua代码要极力去适配luajit的脾胃,才能让luajit给你实现编译,才能真的达到高性能,先不说如何调试适配是多么痛苦的事情,就说你适配了,你的代码有时候也变得很丑陋很怪异,例如要用tail call去替代循环。我写luajit.io的时候就深有体会。没错,如果jit得好,那么甚至有时候会比C更快,之所以更快,你可以认为是经过了profile适配(PGO)的C比普通的C快。但是你要极力去优化,使得有很高的编译通过率才行,这一点就不是每个人都能做到,是一个明显的心智负担。尤其对于大型项目而言,留心费神去优化每一行代码是不现实的。说白了吧,普通的C写出来有80%的好性能,但普通的lua写出来不调优,就只能有50%甚至更低的性能(虽然luajit的解释器也很快,但再快比C还是差了一大截)。所以jit,很多时候只是镜花水月而已。
终于说到openresty了。作者章亦春也是一个大神,它的coscoket在当时来说还是很前卫的概念。我就冒昧来谈谈它的缺点:
- openresty的所有功能源自nginx,也就受限于nginx。而nginx只是一个特定平台,不是一门语言,所以可扩展性是有局限性的。再进一步说,nginx是用C写的,扩展模块也要用C写,openresty之后就要用lua来写的(openresty就是为了提高生产力出现的),但lua本身是一个极其简单的嵌入式语言,没有自己的生态链,其功能完全依赖于宿主系统,在这里宿主就是openresty,也就是说,你能通过lua来做的完全取决于openresty提供多少api给你,没有给你的,你做不了,举个例子,我想开一个线程来做CPU密集的加密任务,没办法,因为没API给你。但如果是一门语言,那么你想做什么就做什么。
- 你不能调用阻塞的lua api或者C函数,或者做一些CPU密集型的任务,或者大量读写文件,因为这样会阻塞nginx的worker进程的单线程,使得性能大幅度下降,而且很容易出现一些让开发者痛苦的事情,例如发现访问redis超时了,明明通过tcpdump看到redis的响应包及时到了,但就是超时,很矛盾很纠结,结果经过一番折腾后发现原来是因为做了一些阻塞的事情,使得nginx的selector在处理io事件之前先处理了timer事件,使得socket明明有数据也被openresty的api报告超时。
- lua和C之间的数据转换是一个overhead。由于lua的数据结构和C那么的不同,所以交换数据要互相拷贝。这一点对于http请求承载大量数据的应用来说很痛苦。例如我在K公司实现文件服务器的功能,这个文件服务器不能直接委托给nginx的file send,因为要对原始文件数据做处理,例如md5校验。这也是为什么openresty后面慢慢提供一些通过luajit ffi来实现的api接口,就是为了减少拷贝,提高性能。
- 无法实现高性能的缓存,因为luajit的string interning很死板,对每个字符串,不管是常量还是动态生成的变量,都统一经过内部的哈希表来存放和去重,其目的就是为了使得用字符串作为table的key时,加快查找速度,因为比对是否同一个gcobject即可。但对缓存逻辑是一个噩梦,因为每生成一个字符串都需要哈希操作,而缓存恰好会生成很多字符串,luajit的interning哈希表在海量字符串的量级下性能很差。我在k公司做的项目对此有很深的体会。
在我看来,openresty相比rust,最大的好处就是lua代码能被动态更新和替换,对于静态编译语言来说是不可能的(dynamic load可以,但dynamic unload是不行的,因为符号之间的引用关联实在没法很轻易解耦)。
我2017年去K公司的时候,发现K公司很钟情openresty,很多项目都基于openresty来做,甚至公司还向openresty捐助过一笔小钱。但K公司的人是滥用openresty,在不知道其原理机制的前提下做了很多错事,很多项目其实不应该用openresty但也用。正如后来我去到E公司发现很钟情springboot一样,我觉得现在的公司很喜欢用一些品牌项目作为基础,或因其名气,或因其简单易入门,而不是具体问题具体分析,按项目实际需要来选型。
我曾一度觉得golang是openresty更好的选择,但golang的http性能确实不好。直到最近这半年我对rust的研究,觉得rust才是未来。
golang的语言设计很简陋,而相比之下,rust很美很优雅。这里不展开解释。我只说一点,那就是golang从无到有自己实现一门语言,包括编译器完全自己来做,甚至连C库都抛开,直接封装系统调用,这是我最不喜欢的,为什么呢?
- 无法充分利用这十几年来的社区成果,例如gcc和llvm,所以优化度很低,例如llvm的simd,它就无法享用。
- 和C互通代价太大,但很多时候C库是避不开的。
- 不兼容目前经典的调试器,例如gdb、valgrind、systemtap,而它自带的调试器功能相对简陋。
而rust呢?在语言特性上非常先进,例如通过ownership解决了C/C++的问题,还不需要付出gc的代价。并且充分利用社区成果,做好语言层面就好了,生成代码和链接代码就交给更专业的llvm,这样一来既专注在语言层面,提供更多更好的特性给用户(例如最近的await),和C互通又很低成本,因为它没有绕开C库。
golang的协程,在rust里面就是通过futrure/async/await来做,开发效率是一样的,运行效率更是胜于golang,因为rust的协程是在编译阶段解析生成的,所有栈数据是用heap上的struct/enum来包装,并且在所有suspend点做了drop,使得内存不需要像golang的协程栈那样在运行时增量分配,也不需要gc来干扰。
我这两三年一直做golang的开发,尤其在K公司。
golang的生态链比较丰富,上下游的各种库和框架都比较全,在国内各大公司都能见到go的踪影。例如这是我最近发布的开源项目,大家有空关注一下:
kingluo/pgcatgithub.com
这是postgresql的增强逻辑复制软件,当时候做的时候也考虑过是否用rust,但是rust没有postgresql的replication相关的库,如果要完全自己写,工作量比较大,而go的pgx库则完全满足要求,另有一个pgoutput的简单库用来解析pgoutput plugin的输出,所以我就选择了go。所以很多时候语言选型跟语言的生态链确实有很大关系。
但是我现在始终觉得rust才是未来,rust的生态链也会慢慢丰富起来,在我接下来的技术生涯里面我会phase out掉golang。
最后,我给一个小例子来验证一下rust的性能。
这个小例子就是http server和hello world。
golang的实现:
package main import ( "net/http" ) func main() { http.HandleFunc("/", HelloServer) http.ListenAndServe(":8080", nil) } var str = []byte("hello") func HelloServer(w http.ResponseWriter, r *http.Request) { w.Write(str) }
openresty的实现:
worker_processes auto; error_log logs/error.log; events { worker_connections 1024; } http { access_log off; server { listen 8080; location / { default_type 'text/plain; charset=utf-8'; content_by_lua_block { ngx.print("hello") } } } }
rust hyper:
usehyper::service::{make_service_fn,service_fn};usehyper::{Body,Request,Response,Server};asyncfn hello(_: Request<Body>)-> Result<Response<Body>,Infallible>{letmutres=Response::new(Body::from("hello"));res.headers_mut().insert("Content-Type",HeaderValue::from_static("text/plain; charset=utf-8"),);Ok(res)}asyncfn run_server()-> Result<(),Box<dynstd::error::Error+Send+Sync>>{pretty_env_logger::init();letmake_svc=make_service_fn(|_conn|async{Ok::<_,Infallible>(service_fn(hello))});letaddr=([0,0,0,0],8080).into();letserver=Server::bind(&addr).serve(make_svc);println!("Listening on http://{}",addr);server.await?;Ok(())}fn main(){letrt=tokio::runtime::Builder::new().build().unwrap();rt.block_on(run_server()).unwrap();}
rust actix-web:
useactix_web::{web,App,HttpRequest,HttpServer,Responder};fn greet(_: HttpRequest)-> implResponder{"hello"}fn main(){HttpServer::new(||App::new().route("/",web::get().to(greet))).bind("0.0.0.0:8080").expect("Can not bind to port 8080").run().unwrap();}
服务端运行在一个双核的服务器上,在同一局域网段的另一个双核服务器上运行wrk作为客户端来压测:
wrk -c100 -d60s http://testserver:8080
结果如下,从好到坏排列:
- rust actix-web
2 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 654.26us 226.01us 13.21ms 97.09% Req/Sec 73.74k 9.06k 123.48k 41.13% 8810858 requests in 1.00m, 0.99GB read Requests/sec: 146603.79 Transfer/sec: 16.92MB
2. rust hyper
2 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 786.47us 273.89us 16.47ms 92.97% Req/Sec 63.19k 2.39k 70.41k 67.67% 7544745 requests in 1.00m, 0.85GB read Requests/sec: 125738.24 Transfer/sec: 14.51MB
3. openresty
2 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 801.19us 353.80us 20.29ms 97.67% Req/Sec 62.05k 2.20k 67.38k 66.08% 7409230 requests in 1.00m, 1.32GB read Requests/sec: 123460.63 Transfer/sec: 22.60MB
4. golang
2 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 1.33ms 652.90us 22.42ms 68.23% Req/Sec 37.89k 712.77 41.19k 76.00% 4523628 requests in 1.00m, 522.00MB read Requests/sec: 75392.66 Transfer/sec: 8.70MB
虽然这个小例子不算严谨,但性能结果之间的比例还是可以参考的。rust的actix-web最好,这个跟网上对actix-web的赞誉是一致的,但它唯一的缺点是在代码上还没过渡到async/await。而rust的hyper也不错,跟openresty的性能差不多,这已经让我觉得很舒服。golang性能最差,这符合我一直以来对它的性能预期。
补充一下fasthttp的benchmark:
package main import ( "flag" "fmt" "log" "github.com/valyala/fasthttp" ) var ( addr = flag.String("addr", ":8080", "TCP address to listen to") ) func main() { flag.Parse() h := requestHandler if err := fasthttp.ListenAndServe(*addr, h); err != nil { log.Fatalf("Error in ListenAndServe: %s", err) } } func requestHandler(ctx *fasthttp.RequestCtx) { fmt.Fprint(ctx, "hello") ctx.SetContentType("text/plain; charset=utf8") }
2 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 620.40us 552.92us 42.16ms 97.75% Req/Sec 76.52k 16.12k 98.31k 51.25% 9137712 requests in 1.00m, 1.17GB read Requests/sec: 152256.88 Transfer/sec: 20.04MB
居然比actix-web还快,我在K公司也用过fasthttp,当时候的版本唯一的缺点我认为就是无法流读取request body(对于上传文件尤为重要),目前好像仍然是这样?
Streaming request body · Issue #622 · valyala/fasthttpgithub.com