# Linux 核心專題: 高效網頁伺服器
> 執行人: Hualing-Chiu
> [專題解說影片](https://www.youtube.com/watch?v=ovZv4nGmv3M)
### Reviewed by `Wufangni`
>TasksQueue 以及 Thread Pool 在這邊扮演的角色就是送貨員以及送貨車,Taskqueue 會接收由 worker process 來的工作,處理完之後再把結果送回 worker process 中。
>cserv 也實作同樣的主從式架構,但與 NGINX 相異處在於 cserv 以 coroutine 作到 cooperative multitasking 以達到相對高效的結果。
想請問 cserv 與 NGINX 的差異只在於多了 cooperative multitasking 嗎?對於時長過長的任務也是採用 TaskQueue 來處理嗎?
### Reviewed by `nosba0957`
ab, htstress, httperf 三種測試工具的差異為何?為何選擇這三種?
## 任務簡介
開發高性能網頁伺服器,探討不同執行緒模型的影響。
## TODO: 分析第七次作業的 cserv
> cserv 採用 multiple processes),試著去執行,含效能評比,要對照 Nginx
### 架構設計描述與探討
#### 主從式架構
##### NGINX
> NGINX 的組成為一個 Master 行程,負責做初始化相關的工作,例如組態設定, 建立 worker, 管理 worker (接收來自外界的 signal,向各 worker 發送 signal,監控 worker 的運行狀態)。至於 Worker 就是專門來處理客戶端的請求 (一般是網頁瀏覽器),NGINX 的 worker 會對 CPU 設定 affinity,因此可降低 thread (worker) 間的 context switch 數量,抑制其引起效能低落的可能性。
![image](https://hackmd.io/_uploads/Hk5aIcHIC.png)
從 [NGINX 開發日誌](https://www.f5.com/company/blog/nginx)中以送貨員以及排隊等候的機制去看待這個問題
![image](https://hackmd.io/_uploads/SkmCwqB8C.png)
假設有一堆顧客,他們需要取貨,而有些貨物(1-3 號箱)盡在眼前,同時也很多貨物存在遙遠的倉庫中,此時注意這邊這位工作者(worker) 只會盲目的遵從目前顧客的要求,並且服務目前的顧客,假設有一個顧客要求比較遠的 4 號箱子,如此一來造成了護衛效應,擋住了後面也許有機會服務更快的顧客。
讓我們再想想另外一個情形,當這個 worker 學乖了,他利用一個送貨區,當每次有太遠的貨物需要取貨時,他並不會自己慢慢跑過去,而是一通電話叫送貨人員送來。如此一來就可以更快的服務到其他顧客,與此同時目前顧客也不會因服務其他人而導致他的任務沒有進展。
![image](https://hackmd.io/_uploads/B1jbF9HIC.png)
而 NGINX 的 worker 也一樣,當一個進程可能需要很長的操作時間時,他不會自己處理,而是將這個任務放進 TaskQueue 中,而任何空閒的 Thread 都可將此 queue 中的任務取出來執行。
TasksQueue 以及 Thread Pool 在這邊扮演的角色就是送貨員以及送貨車,Taskqueue 會接收由 worker process 來的工作,處理完之後再把結果送回 worker process 中。
![image](https://hackmd.io/_uploads/ryrQKqS8A.png)
為了驗證這個事實,可以先使用以下命令
```
$ ps -ef --forest | grep nginx
root 1415 1 0 12:11 ? 00:00:00 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
www-data 1416 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
www-data 1417 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
www-data 1418 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
www-data 1419 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
www-data 1420 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
www-data 1421 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
www-data 1422 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
www-data 1423 1415 0 12:11 ? 00:00:00 \_ nginx: worker process
```
數一下的確可發現 `nginx: worker process` 存在於 `nginx: master process` 底下。
##### cserv
cserv 也實作同樣的主從式架構,但與 NGINX 相異處在於 cserv 以 coroutine 作到 cooperative multitasking 以達到相對高效的結果。
* [cooperative multitasking](https://en.wikipedia.org/wiki/Cooperative_multitasking) 會要求每一個運行中的程式,定時放棄自己的執行權利,告知作業系統可讓下一個程式執行。相較於 preemptive multitasking,減少了作業系統需要進行 context switch 的頻率,因此效能可以大大提升。
:::danger
cooperative multitasking 和「相對高效」的關聯在哪?
:::
cserv 也有 worker
```c
// src/process.c
void process_init()
{
...
for (int i = 0; i < g_worker_processes; i++) {
struct process *p = &worker[i];
p->pid = INVALID_PID;
p->cpuid = i % get_ncpu();
}
}
```
可以從 `process_init()` 這個函式中看到此 worker 一開始被分配到不同的 cpu 底下,由此可以得知 cserv 中也有 worker process。
:::warning
每個 process 只有一個 thread => 沒有 thread-pool 設計
:::
### 測試環境
```bash
$ cat /etc/os-release
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04 LTS (Noble Numbat)"
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 39 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 8
On-line CPU(s) list: 0-7
Vendor ID: GenuineIntel
Model name: Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz
CPU family: 6
Model: 158
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
Stepping: 9
CPU(s) scaling MHz: 19%
CPU max MHz: 4200.0000
CPU min MHz: 800.0000
BogoMIPS: 7200.00
Caches (sum of all):
L1d: 128 KiB (4 instances)
L1i: 128 KiB (4 instances)
L2: 1 MiB (4 instances)
L3: 8 MiB (1 instance)
NUMA:
NUMA node(s): 1
NUMA node0 CPU(s): 0-7
```
### 壓力測試與比較
評測工具:
* [ab](https://httpd.apache.org/docs/current/programs/ab.html)
* [htstress](https://github.com/sysprog21/khttpd/blob/master/htstress.c)
* [httperf](https://github.com/httperf/httperf)
### 評測結果
#### ab
100000 requests with 500 requests at a time(concurrency)
`ab -n 100000 -c 500 -k http://127.0.0.1:8081/`
| | Requests per sec | Time per request (mean, across all concurrent requests) |
| -------- | -------- | -------- |
| cserv | 26123.73 [#/sec] | 0.038 [ms] |
| nginx | 35381.40 [#/sec] | 0.028 [ms] |
在嘗試執行 nginx 時有發生 `nginx: [emerg] still could not bind()` 的問題,從 `/etc/nginx/nginx.conf` 路徑查看 conf 檔案發現 nginx 預設的 port number 為`80`,可能的問題為此 port number 被佔用了。
可以透過 `netstat -ntlp|grep 80` 命令去查看指定的 port number 是否被佔用,所以我更改 `nginx.conf` 檔案中的預設 port number 為`8080`即可解決問題。
#### htstress
100000 requests with 500 requests at a time(concurrency)
`./htstress -n 100000 -c 500 127.0.0.1:8081/`
| | requests/sec | total time |
| -------- | -------- | -------- |
| cserv | 22771.289 #/sec | 4.391 s |
| nginx | 16535.379 #/sec | 6.048 s |
#### httperf
total 100000 requests with 500 connections
`httperf --server 127.0.0.1 --port 8081 --num-conn 500 --num-call 200 --http-version 1.0`
| | requests/sec | connection rate |
| -------- | -------- | -------- |
| cserv | 19140.9 #/sec | 9570.4 conn/s |
| nginx | 38758.8 #/sec | 193.8 conn/s |
:::info
在第三個 httperf 評測工具測試出來的結果讓我有些困惑,對比 [ktcp](https://hackmd.io/@sysprog/linux2024-ktcp/%2F%40sysprog%2Flinux2024-ktcp-d#%E5%A3%93%E5%8A%9B%E6%B8%AC%E8%A9%A6%E8%88%87%E6%AF%94%E8%BC%83) 中壓力測試的結果,nginx 的 connection rate 只有193.8,相較於 cserv 來說是不穩定的,但每一秒接收的 request 卻大於 cserv 每一秒接收到的 request。
:::
:::danger
從實驗結果,搭配設計架構去解讀為何如此。
:::
## TODO: 研讀[第 8 週測驗題](https://hackmd.io/@sysprog/linux2024-quiz8) 和[並行程式設計: 排程器原理](https://hackmd.io/@sysprog/concurrency-sched),紀錄你的認知和提問
> 注意看 neco,是 M:N threading model
### [第 8 週測驗題](https://hackmd.io/@sysprog/linux2024-quiz8)
[task](https://gist.github.com/jserv/745dab1d9be33a9a635b193aeb53191e) 是個運用 coroutine 實作的任務排程器,針對 `AAAA`, `BBBB` 以及 `CCCC`的主要函式分別進行探討。
#### `task_lock`
這個函式實作了 spinlock,確保多個 thread 不會同時進入 critical section,利用 `task_locker` 來控制 lock 的狀態。
:::danger
你要討論 coroutine 如何搭配 POSIX Thread 使用,留意 stackful vs. stackless
詳見 [並行程式設計: 排程器原理](https://hackmd.io/@sysprog/concurrency/%2F%40sysprog%2Fconcurrency-sched),留意 `__thread` (thread-local storage; tls) 的運用。
:::
使用 `atomic_compare_exchange_weak` 嘗試將 `task_locker` 的值從 false 變成 true,如果交換失敗表示 lock 目前已經被佔用,就會<s>調用</s> 呼叫 `sched_yield0()` 讓出 CPU。
:::danger
注意用語:
* call 是「呼叫」,而非「調用」
>已修正
:::
:::danger
不該急著對原始程式碼逐行解讀,你應該揣摩程式設計者的想法,給予高階的描述,特別著重於「要解決什麼問題?」
盲目解釋程式碼,可能會淪落到「舉燭」。
:::
```c
// task.c
static void task_lock(void)
{
bool expected = false;
while (!atomic_compare_exchange_weak(&task_locker, &expected, true)) {
expected = false;
sched_yield0();
}
}
```
#### `task_unlock`
釋放 spinlock,所以 `AAAA` 應該要設置為 false,表示 lock 被釋放。
```c
static void task_unlock(void)
{
atomic_store(&task_locker, AAAA);
}
```
#### `task_entry`
這個函式的目的是初始話並執行一個新的 coroutine。
:::danger
不用抄寫 AAAA, BBBB, CCCC 等填空項目,你專注在程式碼本身。
:::
`BBBB` 是一個 atomic 的操作,參閱 [Atomic library](https://en.cppreference.com/w/c/atomic) 後可以得知 `BBBB` 應填入 `atomic_fetch_add`,用來確保分配給 coroutine 的 ID 具有唯一性以及 thread 的安全。
當 coroutine 完成工作時,使用 `task_switch` 函式進行 context switch,所以 `CCCC` 用來表示這次切換是否是最終的切換,所以應該填入 true 表示 coroutine 結束。
```c
tatic void task_entry(void *udata)
{
/* Initialize a new coroutine on the user's stack. */
struct task task_stk = {0};
struct task *co = &task_stk;
co->llco = coro_current();
co->id = BBBB(&task_next_id, 1) + 1;
co->udata = udata;
co->prev = co;
co->next = co;
if (task_cur) {
/* Reschedule the coroutine that started this one */
task_list_push_back(&task_yielders, co);
task_list_push_back(&task_yielders, task_cur);
task_nyielders += 2;
task_switch(false, false);
}
task_cur = co;
if (task_user_entry) {
task_user_entry(udata);
}
/* This coroutine is finished. Switch to the next coroutine. */
task_switch(false, CCCC);
}
```
而 `task_test.c` 針對 coroutine 的基本操作和特性進行測試,確保系統在不同情況下都能正常運行,測試內容包含 coroutine 的 `test_task_start`, `test_task_sleep`, `tesk_task_pause`, `test_task_detach`, `test_task_exit`,`test_task_order` 以及 `test_task_detach`。
:::danger
上面程式碼給你什麼啟發?融合 coroutine 和 pthread,對於設計高性能網頁伺服器的助益是什麼?
:::
### [並行程式設計: 排程器原理](https://hackmd.io/@sysprog/concurrency-sched)
在閱讀完 coroutine 的部份時,其實對於 coroutine 與 thread 的差異還很難區分
在什情況下應該要使用 coroutine 而不是 thread?
:::danger
你看了解說錄影嗎?告訴我哪個時間段不理解。
:::
<s>
之後另外閱讀到一份教材 [coroutine](https://hackmd.io/@YLowy/HkT8-S20D) 有解釋 coroutine 與 thread 之間的差異為何,也有針對我的疑問給出了解答。
</s>
:::danger
為何不讀授課教師編撰的《Demystifying the Linux CPU Scheduler》?寫書就是你們理解結構清晰的知識,結果時間花在 Google 搜尋找他人的隻字片語,何必呢!
:::
#### Coroutine vs. Thread
| Coroutine | Thread |
| -------- | -------- |
| 使用non-preemptive multitasking,這意味著 coroutine 會自行決定何時讓出控制權給其他 coroutine | 使用preemptive multitasking,這意味著作業系統會定期切換 thread,以確保每個 thread 都有機會運行。 |
| 適合用於 I/O 密集型任務,例如 web request,因為 coroutine 可以在等待 I/O 操作完成時讓出控制權,從而提高 CPU 利用率。| 適合 CPU 密集型任務和需要利用多核 CPU 的情境 |
:::danger
注意用語,務必使用本課程教材規範的詞彙。
:::
:::danger
coroutine 亦可做到 preemptive scheduling,參見上方教材。不要淪落為「搬運工」,要能判斷和驗證資訊本身。
:::
<s>
統整 [coroutine](https://en.wikipedia.org/wiki/Coroutine) 的幾點特性:
* 跟 thread 很像,但是採用 cooperative multitasking 而非 preemptively multitasking
* 擁有自己的 stack 跟 program counter
* 可以利用 dispatcher 輕易的在 thread 之間轉換
* 可以中斷並且繼續執行,跟一般的函式不同
* 是一種在 multi-threading 普及之前產生的概念,優點是不會產生 race-condition
* 通常由 user 控制
</s>
:::danger
使用本課程的教材,注意 Wikipedia 只是介紹通例,不該在沒有驗證的狀況下,全盤接受其說法,否則只是人云亦云。
[並行程式設計: 排程器原理](https://hackmd.io/@sysprog/concurrency-sched)裡頭就有 preemptive coroutine 程式碼,為何你不實際執行呢?
:::
#### neco
[neco](https://github.com/tidwall/neco) 是個 C 語言撰寫的函式庫,藉由 coroutine 提供並行處理功能。它體積小且速度快,旨在降低並行 I/O 和網路程式設計的難度。建構於以下元件:
* [llco](https://github.com/tidwall/llco): low-level coroutine
* [sco](https://github.com/tidwall/sco): coroutine scheduler
特色:
* coroutine: 啟動、休眠、暫停、恢復、建立和收合
* 同步處理:channels, generators, mutexes, condition variables, 和 waitgroups
* coroutine 生命週期由內建的排程器管理
* 快速的使用者層級的上下文切換
* stackful coroutine,可支援巢狀執行
閱讀 `neco.c` 並找出其中實施 multithread 的部份。
:::danger
neco 要解決什麼問題?
:::
##### 結構體定義
首先在 `worker_opts` 結構體中定義了一個 worker process 最多可以有幾個 thread 以及 thread entry
```c
// deps/worker.c
#define WORKER_DEF_MAX_THREADS 2
#define WORKER_DEF_MAX_THREAD_ENTRIES 32
struct worker_opts {
int max_threads; // def: 2
int max_thread_entries; // def: 32
int64_t thread_timeout; // nanoseconds, def: 1 second
void*(*malloc)(size_t); // def: system malloc
void(*free)(void*); // def: system free
};
```
定義 `worker` 結構體來管理多個 worker threads
```c
struct worker {
int nthreads;
struct worker_thread **threads;
void (*free)(void*);
};
```
##### `worker_free`
首先檢查 worker 是否為空,以確保有 thread 可以釋放,如果為空則進入迴圈遍歷每個 thread。
針對每個 thread,先使用 `pthread_mutex_lock` 鎖定狀態,標記 thread 結束後通過 `pthread_cond_signal` 發送訊號通另外一個正在處於阻塞等待狀態的線程,使其脫離阻塞狀態。
隨後 `pthread_mutex_unlock` 解鎖並等待 thread 退出。
```c
void worker_free(struct worker *worker)
{
if (worker) {
if (worker->threads) {
for (int i = 0; i < worker->nthreads; i++) {
struct worker_thread *thread = worker->threads[i];
if (thread) {
pthread_mutex_lock(&thread->mu);
thread->end = true;
pthread_t th = thread->th;
pthread_cond_signal(&thread->cond);
pthread_mutex_unlock(&thread->mu);
if (th) {
pthread_join(th, 0);
}
worker->free(thread->entries);
worker->free(thread);
}
}
worker->free(worker->threads);
}
worker->free(worker);
}
}
```
##### `worker_new`
此函式主要目的是 `malloc` 一個 `worker` 結構體,並初始化其中的 worker threads 及其他相關資源。
```c
struct worker *worker_new(struct worker_opts *opts)
{
...
for (int i = 0; i < worker->nthreads; i++) {
struct worker_thread *thread = malloc_(sizeof(struct worker_thread));
if (!thread) {
worker_free(worker);
return NULL;
}
memset(thread, 0, sizeof(struct worker_thread));
worker->threads[i] = thread;
thread->timeout = timeout;
thread->nentries = nentries;
thread->entries = malloc_(sizeof(struct worker_entry) * nentries);
if (!thread->entries) {
worker_free(worker);
return NULL;
}
memset(thread->entries, 0, sizeof(struct worker_entry) * nentries);
pthread_mutex_init(&thread->mu, 0);
pthread_cond_init(&thread->cond, 0);
thread->nentries = nentries;
}
return worker;
}
```
##### `worker_entry`
當 `thread->len > 0`時,表示 thread 中還有任務尚未處理完成,從 `thread->entries` 中取出下一個要處理的任務,並更新 `thread->pos` 指向下一個位址,如果 `thread->pos` 達到上限則重置為0。
當 `thread->len == 0` 時,表示沒有待處理的工作。使用 `clock_gettime` 獲取當前時間,並將超時時間設置為1秒後。
如果在超時前收到訊號(即有新的工作近來),則會重新把 thread lock 起來,結束之後再跳出迴圈結束工作。
```c
while (1) {
while (thread->len > 0) {
struct worker_entry entry = thread->entries[thread->pos];
thread->pos++;
if (thread->pos == thread->nentries) {
thread->pos = 0;
}
thread->len--;
pthread_mutex_unlock(&thread->mu);
if (entry.work) {
entry.work(entry.udata);
}
pthread_mutex_lock(&thread->mu);
}
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 1;
pthread_cond_timedwait(&thread->cond, &thread->mu, &ts);
if (thread->len == 0) {
if (!thread->end) {
pthread_detach(thread->th);
}
thread->th = 0;
thread->end = false;
break;
}
}
```
##### `worker_submit`
此函式目的在於指派 worker 工作任務,根據計算出來的 `pin` 值和 worker 中 thread 數量,決定一個適當的目標 thread。
`thread->len < thread->nentries` 用來判斷此 thread 的 thread entry 是否已滿,若此條件成立表示可以提交新的工作任務。
計算下一個可用位置 `pos`,將 `work` 和 `udata` 存入 `thread->entries` 中,並將 thread 的長度加1表示新增了一個工作任務。
```c
bool worker_submit(struct worker *worker, int64_t pin, void(*work)(void *udata),
void *udata)
{
if (!worker || !work) {
return false;
}
static __thread uint32_t worker_next_index = 0;
if (pin < 0) {
pin = worker_next_index;
}
worker_next_index++;
struct worker_thread *thread = worker->threads[pin%worker->nthreads];
bool submitted = false;
pthread_mutex_lock(&thread->mu);
if (thread->len < thread->nentries) {
int pos = thread->pos + thread->len;
if (pos >= thread->nentries) {
pos -= thread->nentries;
}
thread->entries[pos].work = work;
thread->entries[pos].udata = udata;
thread->len++;
if (!thread->th) {
int ret = pthread_create(&thread->th, 0, worker_entry, thread);
if (ret == -1) {
pthread_mutex_unlock(&thread->mu);
return false;
}
}
submitted = true;
pthread_cond_signal(&thread->cond);
}
pthread_mutex_unlock(&thread->mu);
return submitted;
}
```
## TODO: 改寫 cserv,使得其具備 M:N threading model
> 進行效能評比和解讀