contributed by < YLowy
>
本篇會參照 lwan sample 中的 Hello World 進行程式碼部分解說
在好幾年前自己在學習 C 時,按照書上所提及 “如何寫一個簡單的網頁伺服器” 按部就班的透過 fork 做出可以支援多人連線的小程式,之後再也沒有更新過對於伺服器的了解 。
非常強烈的建議先參考老師的 高效能 Web 伺服器開發 可以更清楚的了解 lwan 其程式碼結構
透過建立起 Socket Pair ,可以完成兩台電腦中 ( Client , Server ) 的訊息傳遞。 透過 read()
write()
對於雙方的 file description 進行讀寫,而送出的資訊皆以字串進行傳遞。
超文本傳輸協定(HTTP) 算是個挺古老的協議,運作在網頁請求回應上。正如上述所說,兩處不同地方的電腦在進行通訊時視作字串的讀寫,如何將內容更人性化呈現(論 Client 端或者 Server 端 ),HTTP 標準為此誕生。
HTTP 存在多種版本(HTTP 0.9、 HTTP1.0、 HTTP1.1、 HTTP2.0、 HTTP3.0、 HTTPS…),可以看作為歷史演進的路程,此處會先從HTTP1.1 開始。
以一個最簡單之例子如下 :
在完成連線後,Client 端以及 Server 端 Process 皆會持有各一個 fd ,且各自指向 Open File Table 所指向的檔案。
Client 端對所持有的 fd 寫入 (write) Request 內的字串後,可以在Server 端讀取 (read) 此其持有 fd 而得到 Request 字串。故對於 Server 端而言必須先透過 Parser (如http-parser) 了解該 Request 的需求。
此處先提及 Request 幾個比較值得注意之部分:
在伺服器從 fd 獲取 Request 後,會對於該內容引導至伺服器對該路徑所需要進行行為的程式碼段落開始執行,且每次的伺服器請求都該看做為"獨立"的請求。而執行完成後所產生的資料(此處應該視為字串)會被夾帶至 Response 中。
此處先提及 Response 幾個比較值得注意之部分:
<s>Hello<s>...
伺服器回傳內容也就是說伺服器必須完成 Response Header 並夾帶資料回給 Client 端才可以讓 Client 端的網頁完整呈現。
先前所說,每一次 Request 視作獨立請求,想像今天一個購物網站頁面中存在 50 張圖片,所以存在 50 次 request 必須 accept()
connect()
close()
,這會存在許多 TCP 連接的成本。
Keep-alive 即是為了解決這樣的議題,當存在 HTTP1.1 或者 Request Header 存在 Connection: keep-alive 時,伺服器不會主動 close()
連線。
在 Keep-alive 中,仍然存在一項問題 : 每筆 Request 必須等上一筆 Request 完成後才能執行下筆。
Pipelining 為 HTTP 欲改善此所做的設計,Client 可以不等待上一筆 Response 就發出 Request ,這伺服器該如何處理該服務也是一項議題。
無論 Request 或者 Response 都為相同的 Message Format 如下。
Header 放置 Request line 以及 Request Header。
空白 : /r/n
Body 則存放內容主體,而此並非必須。
MIME(Multipurpose Internet Mail Extensions) 用途為允許不同檔案格式的傳遞,利用此來描述檔案類型。
整理上述,若是想要建構一高效能伺服器必須考量的要點:
Server 端對 fd 讀取資料的時機點
當 TCP 連線建立之後,Client 端會寫入請求給予 Socket Pair ,然而對於 Server 端而言,就必須等待 Client 寫完才能繼續下一步,當一次湧入多筆請求時,這樣的等待就是個很大的成本。比較效率的做法為利用 epoll 之方法,當 Client 端寫入完成後,Server 所持有的 fd 被掛起在進行處理才是較好的方法。
Parallel Processing
HTTP 每筆請求皆為獨立,也就是說可以透過建立多 Thread 的方式進行平行處理,但也並非建立越多 Thread 越好。對於 Linux 同時處理多項 Thread 時,在content-switch 上的成本也會跟著增加(考量到 kernel-level thread 中 kernel stack 的 io system call成本!) 。最佳為建立等同 CPU 數量的Thread 並且等量平均散佈任務出去。
Connection Keep-alive
當完成一筆連線時需要考慮其 Keep-alive 機制,若是讓伺服器等待下筆 Request 而占用掉該 Thread 資源是個不希望遇到的情形。如何維持該筆連線狀態且又可以將該處理時間挪用在其他連線上會是一個對效能提升有許大幫助的點。這裡提供 coroutine 透過對於每筆連線建立各自的 user-level stack 空間,對伺服器而言只需要在該空間處理 Ruquest 以及 Response。
也因如此,Thread 需要 coroutine 以及其管理的機制,需要考慮到 time-out 將該 coroutine 回收等等議題。對於每筆 Thread 僅需要不斷 Yield 至所管理的 Defer 上。
HTTP Parser
對於 Client 所給的 Request 並非固定格式順序,從讀取到的字串中最有效率的方式整理該筆請求也會是對於效能能有基礎的提升。
Pipeline Request
考慮到當 Client 端一次送出兩筆以上 Requests ,但是對於 Server 端必須解析到該次讀取內容包含兩次以上 Request,且分別對應送出多次正確的 Response。
在 Thread 1 initial 時期對於 socket 的設定與一般建立伺服器相同 :
提及 lwan 中關鍵程式碼部分
fd = setup_socket_normally(l)
fd 會為 main socket 的 fd (由於運行在 4 核心電腦上,故這此時 fd = 14)。
在執行至此階段前會將 .conf 檔案內容寫進 l
此 lwan structure 中,故在此寫入設定,並呼叫 bind_and_listen_addrinfos(addrs, l->config.reuse_port)
。
若成功則呼叫
int fd = socket(addr->ai_family, addr->ai_socktype, addr->ai_protocol);
會得到一個對於此 socket 之 fd。
並 bind 此 socket 以及 ai_addr。
若是成功則呼叫 listen_addrinfo(fd, addr)
則進行下一步 listen
此處就是對於 listen 的設定
fcntl – file control
對於 fd 對應的 main socket 調定為 FD_CLOEXEC
以及 O_NONBLOCK
lwan main loop
所做的只有傳遞資料 (先前在做 listen 的 socket ) 給其他四個 working thread 。
簡單而言,在 accept4() 所回傳的 fd 會透過 schedule_client()
決定其運行在哪個 working thread ,4 個 working thread 皆共用一份 1024 個元素大小的 conns table ,可以想成 conns[fd] ->thread 此次連線的 working thread。
在決定完成 working thread 之後,會在 lwan_thread_add_client()
透過 spsc_queue (也就是每個 thread 的 pending_fd
) 傳給各自的 thread。
Q : 為何是使用 spsc_queue ?
2 個 thread 傳遞資料所需要利用 buffer 進行緩衝,確保連線之 FIFO ,且 ring buffer 可以達到快速 push pop 大量的連線,透過 原子指令可以處理在 thread 間高速傳遞訊息。
若欲對 pending_fd 做 push 時剛好滿了,且嘗試10 次都沒辦法 push , lwan 會直接呼叫 lwan_thread_nudge(t) 將 event (=1) 寫入 pipe 中,讓 working thread 的 epoll 被喚醒。
對於 main loop 單一禎首先會對上述 accept 取得當下連線資料以及排程至對應 thread (下方會解釋 block i/o 問題以及其解法) 之後,會根據 bitmap 判斷哪些 thread 有任務要做,再傳 event (=1) 到對應 thread pipe 當中, 而此時其他 working thread 會處於 epoll_wait() 的狀態(此時其他 working thread 有可能處於 block 或者 non-block 狀態),再讓個別的 thread 做自己的任務。
Q: conns 為紀錄連線資訊只有 1024 個,代表一次只能處理 1024 筆連線,這樣還能算是高速高效能伺服器?
main process 一次處理 1024 筆 fd ,受限於作業系統,但是我們可以透過修改作業系統將 1024 此限制提升到更大的空間,但是此治標不治本,且此處判斷高速高效能伺服器這樣講法是錯的。
此處我們透過 accept4()
接受連線至 main socket 之請求,然而 listen 階段作業系統會存在一個 Queue 存放進行 TCP 交握的請求,也就是說此處理一次處理 1024 筆,是從 Queue 中取出處理的。
Queue 取出後還是依賴後方 working thread 消化,就算一次 main socket 可以接受 100,000 筆請求也是沒用,還是得看後面的速度。 (但是當然也代表可以存放連線量提升,不會有寬宏無法大量問題)
在建立的 conns[1024]
陣列中 conns[fd]
為當下瞬間中需要處理的任務,而 lwan 如何達到快速的從 1024 大小陣列中找到對應需要處理的?
此處是使用 bitmap
紀錄所有的有任務的 thread。
首先觀察 l->conn[i].thread
之 initial 程式碼 :
由於此處 thread.count
為 working thread 數量,可以觀察到 conns 陣列對應 thread 其實就是如下 :
schedule_client
會決定這個 fd 要排程給哪一個 thread ,再透過 bitwise 操作讓每一個 bit 紀錄這些 thread。
回來看一下 bitmap 定義結構 :
也就是說這張 bitmap 可以紀錄 64 * 4 個 working thread 是否存在任務,再不用走訪所有 thread 資訊情況可以快速搜尋。(無奈我跑 lwan 為 4 cpu 的硬體架構為,這裡的加速微乎其微)
在此可以看到 main process 如何用最小的成本對僅需要 lwan_thread_nudge
之 working thread 寫入 event,觸發其 epoll_wait()。
lwan 根據電腦 cpu 數量創造相同數量的 working thread
Q: 為何此做法可以增加效率?
A: 減少伺服器的 context switch 成本。
運行在 linux 的 lwan 其實會先在 linux 上 scheduler 決定排程,當下排程若排給 lwan process 則可以在 lwan busy 時期 ( 4 個 thread 都在 working ) ,每一個 thread 直接對應到 kernel thread。
Q: 扣除處理 deadth queue 以及 readahead 的低優先權 thread ,main_process + CPU 數量的 working thread 共 1 + n_cpus 可以滿足上述情況?
A: main process 在 working thread busy 時其實是被 block 住的,所以該同一時間只會讓 working threads 工作。
在 pthread_barrier_wait(&lwan->thread.barrier)
之後會讓 4 個 thread loop 同時啟動,在 loop 中可以分為兩階段
聆聽階段
每個 thread 有自己的 epoll_fd ,在開始啟動伺服器時所有的 thread 會 block 在 epoll_wait() ,此時由於 epoll_fd 所正在 listen 的 fd 只有 main process 所要寫入 events 的 pipe_fd(13) ,不存在需要執行任務情況下 timeout = -1 ,也就是 block 直到第一筆任務進來。
再來此時若出現第一或多筆連線, accept_nudge()
會從 spsc_queue 中取出 fd 並將這些 fd 加入每個 thread 各自的 epoll interest list 中。當 interest list 擁有 main_socket 以外的 fd 之後,epoll_wait() 會變成 non-block 狀態,而 timeout 時間會隨著 interest list fd 數量改變。
如果該任務 fd 太久沒有動作則視作 expire ,透過 wheel 將此從 epoll interest list 取出。
回應任務階段
n_fds 會取得 epoll 所有興趣的 event 的數量,之後會對於每個 event 做處理如下 :
這邊要從 event ,timeout_queue 概念理解。
Arm 的 coroutine 實作: https://github.com/JesseBusman/ARM_C_Coroutines
每當 client 端結束連線, lwan 可以自動收回分配資源,回收 fd 以及其 reference,也就是說我們不需用使用 garbage collector 也能完成回收,有助於其延遲降低。
使用 coroutine 實做 user-level thread
coroutine 的 "yield" 是屬於程式語言層面,透過特定技巧或機制,讓原本循序執行的陳述指令,得以做出交錯執行的結果
Yield 所要面對情境有兩種 :
當下 working thread 所 interest epoll event 尚未結束(non-blocked) :
這種情況則需要此次 conns 狀態保存起來,等待下一次被 yeild 到,此處比較麻煩的是要考慮到當下連線是不是 “活著”,這邊需要用到 dead queue thread 幫忙處理 conn expired 問題。
該次 conns 之 client 請求已完成 :
若以完成則代表該次 conns 結束可以不需要再被 yield 指到。
Resume 為上述 _coro_swapcontext_
之結果。
working thread 中每存在個 conns 便會對產生其 coro。
應用點主要為兩個 :
每次當一筆連線被 main thread 分發到一個 working thread 中,這筆連線會附上其 expired time 且同時會被 push 進 death queue 中。
當 death queue 有存在 conns 時,epoll 會檢查該 list 之 fd 所對應到的 expired time,若過期就將其剔除。
伺服器並非無時無刻皆在處理網路請求,lwan 在 initial 時期會為了 idle 期間創立該 idle Thread 並給定 idle job :
pthread_mutex_lock(&job_wait_mutex)
為確保進入 idle job 並且執行時不會被其他請求打斷
idle job 目的為執行 timedwait(had_job)
中的 pthread_cond_timedwait(&job_wait_cond, &job_wait_mutex, &rgtp)
,讓其他高優先權的 Thread 先執行
had_job |= job->cb(job->data)
: 透過 OR operator
如果當下該 job list 存在任何代處理工作時,had_job
為 true
timedwait(had_job)
會隨著 had_job
狀態不同而等待不同時間,這是很符合伺服器架構。當存在 job 需要被執行時,就需要每秒進行 job check,但當不存在 job 時可以不斷拉長 idle thread 的 wait time。
queue_mutex
用於 job queue 的 mutex lock,在進行 had_job
判斷時,應該是要當下 list 所有的 job ,也就是說在執行其中程式碼時,其他的 job 是加不進來的。存取網頁伺服器的內容流程 :
server 都是 "無狀態" 的,也就是說會根據 method 的方式以header/body 對伺服器做請求,還可以考慮 local 端的 cache 資料..
MIME …
以 Chrome 對 lwan - 送出請求
General :
Request URL: http://192.168.79.95:8080/
Referrer Policy: strict-origin-when-cross-origin
Request header :
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
Connection: keep-alive
Host: 192.168.79.95:8080
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36
Q: 怎麼沒有 Request Method ?
我猜是瀏覽器有預設掉,之後會用 wireshark 實驗。
不過在 lwan 有確實有收到完整的 request
這邊首先要先介紹一下 Http1.0 以及 Http1.1,這牽涉到 lwan 程式碼如何撰寫以及下一篇 lwan 效能測試 中會提到 ab.exe 為何是不準的。
可以直接想成最初的連線模型 : 一次只能完成一筆請求,用完就要 close ,若是接下來要繼續下一筆連線則需要再建立一次連線。這樣的狀態其實建立在一開始的伺服器 "無狀態" 概念之下,然而缺點顯而易見,建立一個新的連線造成效能損弱。
http1.1 引入了 Chunked transfer encoding,允許 "不確定檔案大小之傳輸"、cache 上改進、資料傳輸上壓縮…,做重要是奠定下方 keep alive 的連線機制。
Q : 是不是說 http1.1 才能使用 keep alive ?
A : 錯誤,無論是 https1.0 或者 http1.1 都是可以做到 keep alive 的,差別在於 http1.0 預設為關閉,我們可以透過在 header 增加 Connection : Keep alive 做到這件事情,當然 http1.1 也可以關閉這項功能。但是這當然是取決在在 server 端。
Http2.0 3.0 目前不在這裡提及,lwan 目前也是不支援。
lwan 在 client 端建立連線之後,會對 request 做字串處理。
上述的 url_map 會透過 lwan_trie_lookup_prefix 去找尋先前所寫的 handler。
首先會對 request 字串一層一層的先撥出 method ,以及 url … header 資訊。
status = url_map->handler(request, &request->response, url_map->data);
執行指定的 handler,並接著將 handler 所回傳的資料存在 request->response。
最後再透過 lwan_response
回傳給 client 端。
到這裡 lwan 已經處理完成要回傳 response,最後再 write/send 回原本發出請求的 socket。不過 lwan 當然也沒有這麼簡單,回傳時需要考慮回傳資料的 MIME type 以及 http1.1 的 chunk …
response 的 header 以及 response:
此外上述的 lwan_response
存在兩個寫入 socket 端的方式,分別為
return (void)lwan_send(request, headers, header_len + resp_len, 0);
以及
return (void)lwan_writev(request, response_vec, N_ELEMENTS(response_vec));
提及 linux 中的 3 個 system call :
write()
writev()
writev()
為使用到 iovec 結構之呼叫一次系統呼叫即可完成上述情況的一種系統呼叫,但其存再缺點為 writev()
回傳值無法直接作為成功與否依據。send()
write()
此處會利用 writev() 對 fd 寫入 response header 以及 body ,而比較小的 response 會透過 MSG_MORE
flag 利用 send 實作。
回到 keep-alive 這部分,我們可以利用 chunked-encoding 的特點做出lwan's clock 的效果。
上一篇 高效能網頁伺服器 - 事前準備
下一篇 lwan's bench
還有你 lwan's clock
isolcpus