Try   HackMD

contributed by < YLowy >

lwan 的 Hello World

前言

本篇會參照 lwan sample 中的 Hello World 進行程式碼部分解說

在好幾年前自己在學習 C 時,按照書上所提及 “如何寫一個簡單的網頁伺服器” 按部就班的透過 fork 做出可以支援多人連線的小程式,之後再也沒有更新過對於伺服器的了解 。

非常強烈的建議先參考老師的 高效能 Web 伺服器開發 可以更清楚的了解 lwan 其程式碼結構

Socket 通訊

透過建立起 Socket Pair ,可以完成兩台電腦中 ( Client , Server ) 的訊息傳遞。 透過 read() write() 對於雙方的 file description 進行讀寫,而送出的資訊皆以字串進行傳遞。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

HTTP 概要

超文本傳輸協定(HTTP) 算是個挺古老的協議,運作在網頁請求回應上。正如上述所說,兩處不同地方的電腦在進行通訊時視作字串的讀寫,如何將內容更人性化呈現(論 Client 端或者 Server 端 ),HTTP 標準為此誕生。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

HTTP 存在多種版本(HTTP 0.9、 HTTP1.0、 HTTP1.1、 HTTP2.0、 HTTP3.0、 HTTPS),可以看作為歷史演進的路程,此處會先從HTTP1.1 開始。

Request and Response

以一個最簡單之例子如下 :

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

在完成連線後,Client 端以及 Server 端 Process 皆會持有各一個 fd ,且各自指向 Open File Table 所指向的檔案。
Client 端對所持有的 fd 寫入 (write) Request 內的字串後,可以在Server 端讀取 (read) 此其持有 fd 而得到 Request 字串。故對於 Server 端而言必須先透過 Parser (如http-parser) 了解該 Request 的需求。

此處先提及 Request 幾個比較值得注意之部分:

  1. Method : 使用之方法 Get, Post
  2. URl : 路徑例如 /hello
  3. 協議版本 : HTTP/1.1
  4. Request Header : Connextion: keep-alive
  5. Request Body

在伺服器從 fd 獲取 Request 後,會對於該內容引導至伺服器對該路徑所需要進行行為的程式碼段落開始執行,且每次的伺服器請求都該看做為"獨立"的請求。而執行完成後所產生的資料(此處應該視為字串)會被夾帶至 Response 中。

此處先提及 Response 幾個比較值得注意之部分:

  1. 協議版本 : HTTP/1.1
  2. 狀態碼 : 200 (OK)
  3. Response Header : Date,Content-Type
  4. Response Body : <s>Hello<s>... 伺服器回傳內容

也就是說伺服器必須完成 Response Header 並夾帶資料回給 Client 端才可以讓 Client 端的網頁完整呈現。

HTTP Keep-alive

先前所說,每一次 Request 視作獨立請求,想像今天一個購物網站頁面中存在 50 張圖片,所以存在 50 次 request 必須 accept() connect() close(),這會存在許多 TCP 連接的成本。
Keep-alive 即是為了解決這樣的議題,當存在 HTTP1.1 或者 Request Header 存在 Connection: keep-alive 時,伺服器不會主動 close() 連線。

HTTP Pipelining

在 Keep-alive 中,仍然存在一項問題 : 每筆 Request 必須等上一筆 Request 完成後才能執行下筆。
Pipelining 為 HTTP 欲改善此所做的設計,Client 可以不等待上一筆 Response 就發出 Request ,這伺服器該如何處理該服務也是一項議題。

Message Format

無論 Request 或者 Response 都為相同的 Message Format 如下。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Header 放置 Request line 以及 Request Header。
空白 : /r/n
Body 則存放內容主體,而此並非必須。

MIME

MIME(Multipurpose Internet Mail Extensions) 用途為允許不同檔案格式的傳遞,利用此來描述檔案類型。


整理上述,若是想要建構一高效能伺服器必須考量的要點:

  1. Server 端對 fd 讀取資料的時機點
    當 TCP 連線建立之後,Client 端會寫入請求給予 Socket Pair ,然而對於 Server 端而言,就必須等待 Client 寫完才能繼續下一步,當一次湧入多筆請求時,這樣的等待就是個很大的成本。比較效率的做法為利用 epoll 之方法,當 Client 端寫入完成後,Server 所持有的 fd 被掛起在進行處理才是較好的方法。

  2. Parallel Processing
    HTTP 每筆請求皆為獨立,也就是說可以透過建立多 Thread 的方式進行平行處理,但也並非建立越多 Thread 越好。對於 Linux 同時處理多項 Thread 時,在content-switch 上的成本也會跟著增加(考量到 kernel-level thread 中 kernel stack 的 io system call成本!) 。最佳為建立等同 CPU 數量的Thread 並且等量平均散佈任務出去。

  3. Connection Keep-alive
    當完成一筆連線時需要考慮其 Keep-alive 機制,若是讓伺服器等待下筆 Request 而占用掉該 Thread 資源是個不希望遇到的情形。如何維持該筆連線狀態且又可以將該處理時間挪用在其他連線上會是一個對效能提升有許大幫助的點。這裡提供 coroutine 透過對於每筆連線建立各自的 user-level stack 空間,對伺服器而言只需要在該空間處理 Ruquest 以及 Response。
    也因如此,Thread 需要 coroutine 以及其管理的機制,需要考慮到 time-out 將該 coroutine 回收等等議題。對於每筆 Thread 僅需要不斷 Yield 至所管理的 Defer 上。

  4. HTTP Parser
    對於 Client 所給的 Request 並非固定格式順序,從讀取到的字串中最有效率的方式整理該筆請求也會是對於效能能有基礎的提升。

  5. Pipeline Request
    考慮到當 Client 端一次送出兩筆以上 Requests ,但是對於 Server 端必須解析到該次讀取內容包含兩次以上 Request,且分別對應送出多次正確的 Response。

設計行為

  1. Main Process (Thread 1) : 專處理不同 Client 建立的連線,當存在連線時剛該筆連線 fd 傳遞給應對的 Working Thread。
  2. Working Thread 對於各 CPU 上可以專注運行處理之 Task。會接收Main Process 給定的 fd 進行工作處理。
  3. 每一個 fd 皆是代表一 connection,也代表每個都需要一段屬於該自己的 coroutine 空間以及資源,而 Working Thread 僅負責管理自身 connnection 的 defer。
  4. Timer Wheel 的過期時間管控,適時的 close 掉連線將 fd 還給 OS。

lwan 高效能伺服器所設計架構

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

main process

main_process(Thread 1)

在 Thread 1 initial 時期對於 socket 的設定與一般建立伺服器相同 :

  1. 建立 fd 之 socket
  2. bind : bind a name to socket
  3. listen : 通知OS/network socketfd的socket已經可以接受建立連線
  4. accept

提及 lwan 中關鍵程式碼部分

fd = setup_socket_normally(l)
fd 會為 main socket 的 fd (由於運行在 4 核心電腦上,故這此時 fd = 14)。

void lwan_socket_init(struct lwan *l) { int fd, n; lwan_status_debug("Initializing sockets"); ... fd = setup_socket_normally(l); ... l->main_socket = fd; }

在執行至此階段前會將 .conf 檔案內容寫進 l 此 lwan structure 中,故在此寫入設定,並呼叫 bind_and_listen_addrinfos(addrs, l->config.reuse_port)
若成功則呼叫

static int setup_socket_normally(struct lwan *l) { char *node, *port; char *listener = strdupa(l->config.listener); sa_family_t family = parse_listener(listener, &node, &port); ... int fd = bind_and_listen_addrinfos(addrs, l->config.reuse_port); freeaddrinfo(addrs); return fd; }

int fd = socket(addr->ai_family, addr->ai_socktype, addr->ai_protocol); 會得到一個對於此 socket 之 fd。
並 bind 此 socket 以及 ai_addr。
若是成功則呼叫 listen_addrinfo(fd, addr) 則進行下一步 listen

static int bind_and_listen_addrinfos(struct addrinfo *addrs, bool reuse_port) { const struct addrinfo *addr; /* Try each address until we bind one successfully. */ for (addr = addrs; addr; addr = addr->ai_next) { int fd = socket(addr->ai_family, addr->ai_socktype, addr->ai_protocol); if (fd < 0) continue; SET_SOCKET_OPTION(SOL_SOCKET, SO_REUSEADDR, (int[]){1}); SET_SOCKET_OPTION_MAY_FAIL(SOL_SOCKET, SO_REUSEPORT, (int[]){reuse_port}); if (!bind(fd, addr->ai_addr, addr->ai_addrlen)) return listen_addrinfo(fd, addr); close(fd); } lwan_status_critical("Could not bind socket"); }

此處就是對於 listen 的設定

static int listen_addrinfo(int fd, const struct addrinfo *addr) { if (listen(fd, lwan_socket_get_backlog_size()) < 0) lwan_status_critical_perror("listen"); ... lwan_status_info("Listening on http://%s:%s", host_buf, serv_buf); return set_socket_flags(fd); }

fcntl file control

對於 fd 對應的 main socket 調定為 FD_CLOEXEC 以及 O_NONBLOCK

static int set_socket_flags(int fd) { int flags = fcntl(fd, F_GETFD); if (flags < 0) lwan_status_critical_perror("Could not obtain socket flags"); if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) < 0) lwan_status_critical_perror("Could not set socket flags"); flags = fcntl(fd, F_GETFL); if (flags < 0) lwan_status_critical_perror("Could not obtain socket flags"); if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) lwan_status_critical_perror("Could not set socket flags"); return fd; }

lwan_main_loop

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 被喚醒。

for (int i = 0; i < 10; i++) { bool pushed = spsc_queue_push(&t->pending_fds, fd); if (LIKELY(pushed)) return; /* Queue is full; nudge the thread to consume it. */ lwan_thread_nudge(t); }

對於 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 筆請求也是沒用,還是得看後面的速度。 (但是當然也代表可以存放連線量提升,不會有寬宏無法大量問題)

bitmap

在建立的 conns[1024] 陣列中 conns[fd] 為當下瞬間中需要處理的任務,而 lwan 如何達到快速的從 1024 大小陣列中找到對應需要處理的?
此處是使用 bitmap 紀錄所有的有任務的 thread。
首先觀察 l->conn[i].thread 之 initial 程式碼 :

l->conns[i].thread = &l->thread.threads[i % l->thread.count];

由於此處 thread.count 為 working thread 數量,可以觀察到 conns 陣列對應 thread 其實就是如下 :

schedule_client 會決定這個 fd 要排程給哪一個 thread ,再透過 bitwise 操作讓每一個 bit 紀錄這些 thread。

int core = schedule_client(l, fd); cores->bitmap[core / 64] |= UINT64_C(1)<<(core % 64);

回來看一下 bitmap 定義結構 :

struct core_bitmap { uint64_t bitmap[4]; };

也就是說這張 bitmap 可以紀錄 64 * 4 個 working thread 是否存在任務,再不用走訪所有 thread 資訊情況可以快速搜尋。(無奈我跑 lwan 為 4 cpu 的硬體架構為,這裡的加速微乎其微)

在此可以看到 main process 如何用最小的成本對僅需要 lwan_thread_nudge 之 working thread 寫入 event,觸發其 epoll_wait()。

for (size_t i = 0; i < N_ELEMENTS(cores.bitmap); i++) { for (uint64_t c = cores.bitmap[i]; c; c ^= c & -c) { size_t core = (size_t)__builtin_ctzl(c); lwan_thread_nudge(&l->thread.threads[i * 64 + core]); } }

working thread

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 工作。

working thread 程式架構

pthread_barrier_wait(&lwan->thread.barrier) 之後會讓 4 個 thread loop 同時啟動,在 loop 中可以分為兩階段

  1. 聆聽階段
    每個 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 取出。

  2. 回應任務階段
    n_fds 會取得 epoll 所有興趣的 event 的數量,之後會對於每個 event 做處理如下 :
    這邊要從 event ,timeout_queue 概念理解。

int n_fds = epoll_wait(epoll_fd, events, max_events, timeout); ... for (struct epoll_event *event = events; n_fds--; event++) { struct lwan_connection *conn; if (UNLIKELY(!event->data.ptr)) { accept_nudge(read_pipe_fd, t, lwan->conns, &tq, &switcher, epoll_fd); continue; } conn = event->data.ptr; if (UNLIKELY(event->events & (EPOLLRDHUP | EPOLLHUP))) { timeout_queue_expire(&tq, conn); continue; } resume_coro(&tq, conn, epoll_fd); timeout_queue_move_to_last(&tq, conn); }

coroutine 簡化資源管理問題

每當 client 端結束連線, lwan 可以自動收回分配資源,回收 fd 以及其 reference,也就是說我們不需用使用 garbage collector 也能完成回收,有助於其延遲降低。

yield

使用 coroutine 實做 user-level thread
coroutine 的 "yield" 是屬於程式語言層面,透過特定技巧或機制,讓原本循序執行的陳述指令,得以做出交錯執行的結果

Yield 所要面對情境有兩種 :

  1. 當下 working thread 所 interest epoll event 尚未結束(non-blocked) :
    這種情況則需要此次 conns 狀態保存起來,等待下一次被 yeild 到,此處比較麻煩的是要考慮到當下連線是不是 “活著”,這邊需要用到 dead queue thread 幫忙處理 conn expired 問題。

  2. 該次 conns 之 client 請求已完成 :
    若以完成則代表該次 conns 結束可以不需要再被 yield 指到。

Resume 為上述 _coro_swapcontext_ 之結果。

coro_new

working thread 中每存在個 conns 便會對產生其 coro。

應用點主要為兩個 :

  1. 先前所提及更小成本的完成 coroutine
  2. dead queue 操作
ALWAYS_INLINE struct coro * coro_new(struct coro_switcher *switcher, coro_function_t function, void *data) { struct coro *coro; coro = lwan_aligned_alloc(sizeof(struct coro) + CORO_STACK_SIZE, 64); if (UNLIKELY(!coro)) return NULL; coro_defer_array_init(&coro->defer); coro->switcher = switcher; coro_reset(coro, function, data); return coro; }

death queue

每次當一筆連線被 main thread 分發到一個 working thread 中,這筆連線會附上其 expired time 且同時會被 push 進 death queue 中。

當 death queue 有存在 conns 時,epoll 會檢查該 list 之 fd 所對應到的 expired time,若過期就將其剔除。

伺服器並非無時無刻皆在處理網路請求,lwan 在 initial 時期會為了 idle 期間創立該 idle Thread 並給定 idle job :

  1. pthread_mutex_lock(&job_wait_mutex)為確保進入 idle job 並且執行時不會被其他請求打斷

  2. idle job 目的為執行 timedwait(had_job) 中的 pthread_cond_timedwait(&job_wait_cond, &job_wait_mutex, &rgtp) ,讓其他高優先權的 Thread 先執行

  3. had_job |= job->cb(job->data) : 透過 OR operator 如果當下該 job list 存在任何代處理工作時,had_jobtrue

if (pthread_mutex_lock(&job_wait_mutex)) lwan_status_critical("Could not lock job wait mutex"); while (running) { bool had_job = false; if (LIKELY(!pthread_mutex_lock(&queue_mutex))) { struct job *job; list_for_each(&jobs, job, jobs) had_job |= job->cb(job->data); pthread_mutex_unlock(&queue_mutex); } timedwait(had_job); }

timedwait(had_job) 會隨著 had_job 狀態不同而等待不同時間,這是很符合伺服器架構。當存在 job 需要被執行時,就需要每秒進行 job check,但當不存在 job 時可以不斷拉長 idle thread 的 wait time。

static void timedwait(bool had_job) { static int secs = 1; struct timeval now; if (had_job) secs = 1; else if (secs <= 15) secs++; gettimeofday(&now, NULL); struct timespec rgtp = { now.tv_sec + secs, now.tv_usec * 1000 }; pthread_cond_timedwait(&job_wait_cond, &job_wait_mutex, &rgtp); }
  1. queue_mutex 用於 job queue 的 mutex lock,在進行 had_job 判斷時,應該是要當下 list 所有的 job ,也就是說在執行其中程式碼時,其他的 job 是加不進來的。

lwan's Request and Response

瀏覽器怎麼存取網頁內容?

存取網頁伺服器的內容流程 :

  1. Client 端送出 request (header + body)
  2. Server 端接收到此 request 根據內容做出 response (header + body)

    server 都是 "無狀態" 的,也就是說會根據 method 的方式以header/body 對伺服器做請求,還可以考慮 local 端的 cache 資料..

  3. Server 端對 Client 端送出 response
  4. Client 端對應得到 response 內容做出回應

    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

(gdb) set print elements 500 (gdb) p request->helper [0]->buffer [0] $17 = { value = 0xfffff45dffc8 "GET /clock HTTP/1.1\r\nHost: 192.168.79.95:8080\r\nConnection: keep-alive\r\nCache-Control: max-age=0\r\nUpgrade-Insecure-Requests: 1\r\nUser-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\r\nAccept: 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\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7\r\n\r\n", len = 495}

這邊首先要先介紹一下 Http1.0 以及 Http1.1,這牽涉到 lwan 程式碼如何撰寫以及下一篇 lwan 效能測試 中會提到 ab.exe 為何是不準的。

http1.0

可以直接想成最初的連線模型 : 一次只能完成一筆請求,用完就要 close ,若是接下來要繼續下一筆連線則需要再建立一次連線。這樣的狀態其實建立在一開始的伺服器 "無狀態" 概念之下,然而缺點顯而易見,建立一個新的連線造成效能損弱。

http1.1

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 資訊。

void lwan_process_request(struct lwan *l, struct lwan_request *request) { enum lwan_http_status status; struct lwan_url_map *url_map; status = read_request(request); if (UNLIKELY(status != HTTP_OK)) { /* This request was bad, but maybe there's a good one in the * pipeline. */ if (status == HTTP_BAD_REQUEST && request->helper->next_request) return; /* Response here can be: HTTP_TOO_LARGE, HTTP_BAD_REQUEST (without * next request), or HTTP_TIMEOUT. Nothing to do, just abort the * coroutine. */ lwan_default_response(request, status); coro_yield(request->conn->coro, CONN_CORO_ABORT); __builtin_unreachable(); } status = parse_http_request(request); if (UNLIKELY(status != HTTP_OK)) { lwan_default_response(request, status); return; } lookup_again: url_map = lwan_trie_lookup_prefix(&l->url_map_trie, request->url.value); if (UNLIKELY(!url_map)) { lwan_default_response(request, HTTP_NOT_FOUND); return; } status = prepare_for_response(url_map, request);// 如果找到對應 url 會來到這裡 if (UNLIKELY(status != HTTP_OK)) { lwan_default_response(request, status); return; } status = url_map->handler(request, &request->response, url_map->data); if (UNLIKELY(url_map->flags & HANDLER_CAN_REWRITE_URL)) { if (request->flags & RESPONSE_URL_REWRITTEN) { if (LIKELY(handle_rewrite(request))) goto lookup_again; return; } } return (void)lwan_response(request, status); }

status = url_map->handler(request, &request->response, url_map->data); 執行指定的 handler,並接著將 handler 所回傳的資料存在 request->response。
最後再透過 lwan_response 回傳給 client 端。

static enum lwan_http_status prepare_for_response(struct lwan_url_map *url_map, struct lwan_request *request) { request->url.value += url_map->prefix_len; request->url.len -= url_map->prefix_len; if (UNLIKELY(url_map->flags & HANDLER_MUST_AUTHORIZE)) { if (!lwan_http_authorize_urlmap(request, url_map)) return HTTP_NOT_AUTHORIZED; } while (*request->url.value == '/' && request->url.len > 0) { request->url.value++; request->url.len--; } // 這裡決定 POST method 與否 if (lwan_request_get_method(request) == REQUEST_METHOD_POST) { if (url_map->flags & HANDLER_HAS_POST_DATA) return read_post_data(request); enum lwan_http_status status = discard_post_data(request); return (status == HTTP_OK) ? HTTP_NOT_ALLOWED : status; } return HTTP_OK; }

到這裡 lwan 已經處理完成要回傳 response,最後再 write/send 回原本發出請求的 socket。不過 lwan 當然也沒有這麼簡單,回傳時需要考慮回傳資料的 MIME type 以及 http1.1 的 chunk

void lwan_response(struct lwan_request *request, enum lwan_http_status status) { const struct lwan_response *response = &request->response; char headers[DEFAULT_HEADERS_SIZE]; if (UNLIKELY(request->flags & RESPONSE_CHUNKED_ENCODING)) { /* Send last, 0-sized chunk */ lwan_strbuf_reset(response->buffer); lwan_response_send_chunk(request); log_request(request, status); return; } if (UNLIKELY(request->flags & RESPONSE_SENT_HEADERS)) { lwan_status_debug("Headers already sent, ignoring call"); return; } if (UNLIKELY(!response->mime_type)) { /* Requests without a MIME Type are errors from handlers that should just be handled by lwan_default_response(). */ return lwan_default_response(request, status); } log_request(request, status); // 這裡在 lwan server 端介面顯示 if (request->flags & RESPONSE_STREAM) { if (LIKELY(response->stream.callback)) { status = response->stream.callback(request, response->stream.data); } else { status = HTTP_INTERNAL_ERROR; } if (UNLIKELY(status >= HTTP_CLASS__CLIENT_ERROR)) { request->flags &= ~RESPONSE_STREAM; lwan_default_response(request, status); } return; } size_t header_len = lwan_prepare_response_header(request, status, headers, sizeof(headers)); if (UNLIKELY(!header_len)) return lwan_default_response(request, HTTP_INTERNAL_ERROR); if (!has_response_body(lwan_request_get_method(request), status)) return (void)lwan_send(request, headers, header_len, 0); char *resp_buf = lwan_strbuf_get_buffer(response->buffer); const size_t resp_len = lwan_strbuf_get_length(response->buffer); if (sizeof(headers) - header_len > resp_len) { /* writev() has to allocate, copy, and validate the response vector, * so use send() for responses small enough to fit the headers * buffer. On Linux, this is ~10% faster. */ memcpy(headers + header_len, resp_buf, resp_len); return (void)lwan_send(request, headers, header_len + resp_len, 0); } struct iovec response_vec[] = { {.iov_base = headers, .iov_len = header_len}, {.iov_base = resp_buf, .iov_len = resp_len}, }; return (void)lwan_writev(request, response_vec, N_ELEMENTS(response_vec)); }

response 的 header 以及 response:

(gdb) p headers $38 = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Sat, 02 Jan 2021 17:34:53 GMT\r\nExpires: Tue, 09 Feb 2021 17:34:53 GMT\r\nServer: lwan\r\n\r\nHello, World!
(gdb) p request->response ->buffer [0] $36 = {buffer = 0xaaaaaaaddc70 <message> "Hello, World!", capacity = 13, used = 13, flags = 0}

此外上述的 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 :

  1. write()
    Unix 的 "Everyting is file" 概念,對 fd 寫入值,算是為基礎系統呼叫之一。
  2. writev()
    假設欲對多個不同段記憶體內容寫入一個檔案情況,有以下兩種做法 :
    A. 做多次 write
    這裡會存在多次系統呼叫導致多餘的成本。
    B. 先全部複製到同一段連續記憶體再寫入
    writev() 為使用到 iovec 結構之呼叫一次系統呼叫即可完成上述情況的一種系統呼叫,但其存再缺點為 writev() 回傳值無法直接作為成功與否依據。
  3. send()
    可以想成多帶了 flag 的 write()

此處會利用 writev() 對 fd 寫入 response header 以及 body ,而比較小的 response 會透過 MSG_MORE flag 利用 send 實作。

回到 keep-alive 這部分,我們可以利用 chunked-encoding 的特點做出lwan's clock 的效果。

htstress

htstress

/Desktop/htstress$ ./htstress -n 10000 -c 100 -t 1 192.168.79.95:8080 0 requests 1000 requests 2000 requests 3000 requests 4000 requests 5000 requests 6000 requests 7000 requests 8000 requests 9000 requests requests: 10000 good requests: 10000 [100%] bad requests: 0 [0%] seconds: 1.231 requests/sec: 8125.827
~/Desktop/htstress$ ./htstress -n 10000 -c 100 -t 8 192.168.79.95:8080 0 requests 1000 requests 2000 requests 3000 requests 4000 requests 5000 requests 6000 requests 7000 requests 8000 requests 9000 requests requests: 10000 good requests: 10000 [100%] bad requests: 0 [0%] seconds: 1.168 requests/sec: 8562.040

Bench Test Tool

ApacheBench ab.exe

上一篇 高效能網頁伺服器 - 事前準備
下一篇 lwan's bench

還有你 lwan's clock

isolcpus