「推薦」從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