# 2020q1 Homework6 (sehttpd)
contributed by < `eecheng87` >
###### tags: `linux2020`
## sehttpd 運作原理
### mainloop.c
第 81 行正如註解所說,當 client 突然關閉時,系統會發出訊號 `SIGPIPE`,若將 `SIG_IGN` 給 `sa_handler` 代表忽略訊號。反之,`SIG_DF` 表示執行系統預設函數。當然也可以給使用者自訂函數給 `sa_handler` ( 可帶一個 `int` 參數,回傳 `void` )。
第 89 行的 `sock_set_non_blocking` 是用來對剛剛創的 `fd` 做描述。在 `sock_set_non_blocking` 中透過 `fcntl` 達到控制 `fd` 的效果。`fcntl` 的第二個參數傳入想要的操作 `cmd`。其中有用到的分別是 `F_GETFL` 取得目前 `fd` 的 flag; `F_SETFL` 設定 `fd` 的 flag。注意,我們在呼叫 `F_SETFL` 前必須透過 bitwise 對 `flag` 操作,再透過 `F_SETFL` 才能有效修改 `fd` 的 flag。這裡使用到的 flag 是 `O_NONBLOCK`,如果 `read` 没有可讀的資料,則返回 -1 和 `EAGAIN`。
接著的數十行是非常標準的 `epoll` 建立流程,忘記的話可以參考[這裡](https://hackmd.io/@eecheng/Sy9XsgjOI)。
再來是比較關鍵的 while loop,裡面也是標準的 epoll 流程,分兩種情況。第一種是,若目前從 `epoll_wait` 中改變的 `fd` 是 `listen_fd`,這代表有新的 client 想做連線,所以這個時候就需要做 socket programing 中固定的流程 `accept`。當然,設定 `event` 的細節、`infd` 的細節都是很制式的。比較特別的是 ( 在 sehttpd 獨有的 ),會透過 `init_http_request` 將這個 request 加入 list,此外又呼叫 `add_timer` 將 request 插入 priority queue。第二種情況是,在 epoll list 中變動的 `fd` 不是 `listen_fd`,這代表是已經建立連線的 client 想讀或想寫,所以呼叫 `do_request`。
### timer.c
之所以需要 timer 的而且把所有 timer 建構成 priority queue 的原因是方便知道哪個 timer 是接著最快會 expired 的。由 `add_time` 可以發現,priority queue 上的節點的 key 取決於 `current_msec + timeout`。所以,越快到期的 timer,key 值越小,在 pq 的最頂端。
### http.c
這個檔案內最重要的部分是在 mainloop 遇到 epoll 有動靜時會呼叫的 `do_request`。
## 引入 thread pool
![](https://i.imgur.com/e3DqUE5.png)
按照以上架構圖實作,只有一個 thread 負責安排工作 ( 可想成 producer ),然後不管怎樣,先丟到一個共用的 queue。接著有多個人開始搶工作 ( 可想成 consumer )。如此一來,就是標準的生產/消費者問題。實作的部分修改自 [mbrossard](https://github.com/mbrossard/threadpool) 的原始碼。
### thread pool 的資料結構
```cpp
typedef struct {
void (*function)(void *);
void *argument;
} threadpool_task_t;
struct threadpool_t {
pthread_mutex_t lock;
pthread_cond_t notify; /* CV for notify worker threads */
pthread_t *threads; /* threads set ( array ) */
threadpool_task_t *queue; /* array */
int thread_count;
int queue_size;
int head; /* index of first element */
int tail; /* index of last element */
int count; /* num of task */
int shutdown; /* flag */
int started; /* num of threads are working */
};
```
前面提到公共的 queue 就是 `threadpool_task_t *queue`,他是一個 `task_t` 型態的陣列,每個成員存有 `function` 和 `argument`。一個 queue 上有一把鎖 `lock`,避免同時兩人對 queue 做插入等競爭 ( contention ) 的動作。雖然本專案的 `function` 都一樣,但是為了擴充性,這邊就先不寫死。
### 初始化 thread pool
```cpp
threadpool_t *threadpool_create(int thread_count, int queue_size){
...
}
```
內容其實就是根據 `thread_count` 和 `queue_size` 來配置記憶體大小,另外初始化一些成員變數。至於呼叫的地方是在 mainloop.c 中,本次選用的參數是開 16 個 thread 和大小為 256 的 queue。最後,這函數需要將所有 thread 初始化它們的執行函數。
```cpp
if (pthread_create(&(pool->threads[i]), NULL, threadpool_thread,
(void *) pool) != 0) {
threadpool_destroy(pool);
return NULL;
}
```
確保每條 thread 都在做 `threadpool_thread`。簡單來說這個函數就是消費者,持續和 queue 拿 task 來做,稍後會詳細解釋。
### 安排新工作進 queue
在 `mainloop.c` 中 epoll 發現 client 端有動靜 ( 想寫/讀 ),所以把改變的 `events` 丟到 queue 中。`do_request` 就是 task 的 function,`events[i].data.ptr` 是 function 的參數。
```cpp=172
if (threadpool_add(pool, do_request, events[i].data.ptr) < 0) {
printf("Error occur when adding task in queue\n");
}
```
### 如何在插入任務時避免競爭
```cpp
int threadpool_add(threadpool_t *pool, void (*function)(void *), void *argument){
..
if (pthread_mutex_lock(&(pool->lock)) != 0) {
return threadpool_lock_failure;
}
next = (pool->tail + 1) % pool->queue_size;
do {
/* Queue size checking */
if (pool->count == pool->queue_size) {
..
}
/* Shutdown checking */
if (pool->shutdown) {
..
}
/* Add task to queue */
..
/* Notify *_wait */
if (pthread_cond_signal(&(pool->notify)) != 0) {
err = threadpool_lock_failure;
break;
}
} while (0);
/* Release lock */
if (pthread_mutex_unlock(&pool->lock) != 0) {
err = threadpool_lock_failure;
}
..
}
```
前面有提到,我們不希望有多個人同時對 queue 操作,所以我們進入 `add` 時,需要拿鎖。因為這個鎖是 queue 獨有的,只有一把,所以搶到的人保證不會有其他人進來。 也因為我們拿到鎖了,所以在接下來的 critical section 可以盡情地修改 shared variable。這邊還有一個比較特別的地方,`pthread_cond_signal` 的呼叫將在稍後的 `void *threadpool_thread(void *threadpool)` 中解釋用途。
### 拿 queue 上的 task 執行
前面有提到 `threadpool_thread` 的角色就像消費者,會一直和 queue 拿 task 來做。接著來解釋一些實作重點。由於 `threadpool_thread` 是 thread 的 routine,需要一直做,所以用 `for(;;)` 來達成效果。此外,同 `add`,`threadpool_thread` 也涉及對共有變數 queue 的操作,所以我們需要上鎖。
```cpp=211
/* Grab our task */
task.function = pool->queue[pool->head].function;
task.argument = pool->queue[pool->head].argument;
pool->head = (pool->head + 1) % pool->queue_size;
pool->count -= 1;
/* Unlock */
pthread_mutex_unlock(&(pool->lock));
/* Call worker */
(*(task.function))(task.argument);
```
以上因為在 critical section,所以可以大膽操作 shared variable。`(*(task.function))(task.argument)` 就是**消費**掉 task 的地方。
前面有提到 condition variable 的運用有出現在此。沒錯,在 202 行的地方使用了。
```cpp=202
while ((pool->count == 0) && (!pool->shutdown)) {
pthread_cond_wait(&(pool->notify), &(pool->lock));
}
```
當 queue 中已經沒有 task 了,我們就要呼叫 sleep ( wait ),這時此 thread 會釋放先前拿到的鎖,這樣當有新任務要加到 queue 中才能拿到 (避免 deadlock)。當有新任務透過 `threadpool_add` 加入 queue 時,會呼叫 signal。此時,睡在 condition variable 上的 thread 會被叫醒,重新獲得 lock,繼續往下執行。
### 實驗的 script
希望達到能重複執行不同的 concurrency 參數,並將 htstress.c 的效能數據紀錄到 `.out` 方便接下來的 `.gp` 來製圖
```shell
#!/usr/bin/env bash
# usage: repeat iterate_time cmd
DEST=$1
repeat(){
T=$1
shift
for i in `seq 10 $T`;
do
# test several time for each Concurrecy value
echo -n $i >> $DEST
$1 $i $2 >> $DEST
echo -n $i >> $DEST
$1 $i $2 >> $DEST
echo -n $i >> $DEST
$1 $i $2 >> $DEST
done
}
repeat 50 "./htstress -n 10000 -c " " -t 3 http://localhost:8081/"
```
在不同的 concurrency 會執行 `htstress` 3 次 (考量到時間,所以只做三次)來評估效能(平衡一下極端值)
### 效能改善
![](https://i.imgur.com/eZikuLE.png)
可以觀察到引入 thread pool 的 server 能處理的 requests 提升。
## 引入 lock free thread pool
原始碼在分支 [lfpool](https://github.com/eecheng87/sehttpd/tree/lfpool),修改自 [xhjcehust](https://github.com/xhjcehust/LFTPool) 實作。
lock free 其實就是不用鎖來達到 multi-thread programing 的技巧,**理論上**效能可以提升,因為鎖會占用很多資源。但是,這種不用鎖又有達到避免競爭的程式設計是非常困難的,常常會有沒考慮到的競爭問題,例如: [ABA Problem](https://en.wikipedia.org/wiki/ABA_problem)。由於架構設計關係,本次 lock free thread pool 不會遇到 ABA 問題。(註: [Solution to ABA problem](https://lumian2015.github.io/lockFreeProgramming/aba-problem.html))
![](https://i.imgur.com/vdKwFcT.png)
以上是 lock free thread pool 的架構,有別於 lock thread pool,為了避免消費者和生產者同時對 queue 操作產生競爭,所以我們盡可能的讓競爭的種類下降,使 lock free programing 更容易一些。「 讓競爭的種類下降 」 具體的做法是,把原本共用的 queue 改成,每條 thread 都有自己的 queue。這樣一來,每條 thread (消費者) 只會拿自己的 task,不會去**競爭**屬於別條 thread 的 task。而生產者安排工作給 queue 的方式是採用 [round robin scheduling](https://en.wikipedia.org/wiki/Round-robin_scheduling) 以達到近似公平的安排工作 (真正公平需要看當前所有 thread 上的 task 數量來決定)。
### lock free thread pool 的資料結構
```cpp
/* entry in work queue */
struct threadpool_task_t {
void (*function)(void *);
void *argument;
};
/* pack real thread `pthread_t` */
/*
`pop_index` and `push_index` are
both gaurantee contention-free.
*/
struct threadpool_thread_t {
pthread_t thread; /* real POSIX thread */
threadpool_task_t *queue; /* work queue belong each thread */
int size; /* size of queue */
int pop_index; /* next index for pop */
int push_index; /* next index for push */
int task_count; /* shared var between enq and deq */
};
struct threadpool_t {
threadpool_thread_t *threads; /* threads set ( array ) */
int thread_count;
int shutdown; /* flag */
};
```
每個程式有一個 threadpool (`threadpool_t`),在 pool 裡有多條 thread (`threadpool_thread_t`),而每條 thread 上有一條屬於該 thread 的 queue (`threadpool_task_t *`)。每個 queue 的成員是由 `threadpool_task_t` 組成,紀錄任務和參數。
### 初始化
因為不涉及資料競爭,所以和 lock based thread pool 的實作一樣。
### 釐清需要考慮競爭的部分
由於是 lock free,所以無法像前面,只要加個鎖接著就可以盡情操作 shared variable。以下討論三個成員變數:
* push_index: 是用來決定下一個要插入新任務的 index。因為這個變數每條 thread 都有,而且只有在呼叫 `threadpool_qpush` 時才會改動值。恰巧在 `mainloop.c` 中呼叫此函數是走單執行續,所以不會有人同時呼叫 `threadpool_qpush`。
* pop_index: 是用來決定下一個準備要做的 task 的 index。變數只有在 `threadpool_qpop` 中才會改變。雖然有多個 thread 會改動 `pop_index`,但是上圖提供的架構巧妙的避開這個問題。因為每條 thread 都有自己的 `pop_index`,所以儘管同時做,也都是改自己的。而每條 thread 相對自己都是一條單執行緒,所以 `pop_index` 不會遇到 contention 的問題。
* task_count: 當 `push` 或 `pop` 時都會影響值,所以這個變數是會遇到 contention 的。
所以,接著對成員變數的操作只需要對 task_count 做競爭考慮,其他正常操作即可。
### 安排新工作進 queue
同 lock based thread pool,在 mainloop 中當 client 想傳東西時被 epoll 發現,則將此視為任務,加到 queue 中等待執行。呼叫 `threadpool_qpush(pool, do_request, events[i].data.ptr)`。
### 實作 threadpool_qpush
```cpp
int threadpool_qpush(threadpool_t *pool, void (*task)(void *), void *arg)
{
threadpool_thread_t *dest = round_robin_schedule(pool);
return dispatch(dest, task, arg);
}
```
首先需要透過 RR (註: 使用到 [Static variable inside of a function in C](https://stackoverflow.com/questions/5033627/static-variable-inside-of-a-function-in-c) 技巧) 來決定新的任務要丟到哪一條 thread 上的 queue。接著的 `dispatch` 才是真正把 task 插入 queue 的函數。
```cpp
do {
/* There isn't contention in push_index */
int p = dest->push_index;
/* check whether full */
if (__sync_bool_compare_and_swap(&(dest->task_count), dest->size,
dest->size)) {
printf("Queue is full\n");
return -1;
}
/* insert task into queue */
dest->queue[dest->push_index].function = task;
dest->queue[dest->push_index].argument = arg;
dest->push_index = (p + 1) % dest->size;
/* task_count is shared variable for qpop and qpush */
__sync_fetch_and_add(&(dest->task_count), 1);
break;
} while (1);
```
以上可以觀察到我們只有在操作 `task_count` 才需要透過 atomic 操作來避免競爭([原因](https://hackmd.io/hJriZE4NQ96AZnohbBYYpw?view#%E9%87%90%E6%B8%85%E9%9C%80%E8%A6%81%E8%80%83%E6%85%AE%E7%AB%B6%E7%88%AD%E7%9A%84%E9%83%A8%E5%88%86))。由於 `pop` 的觀念類似,所以不再解釋。
### 每條執行緒在做甚麼
在 `create` pthread 的時候,我們綁定的函數是 `threadpool_thread`,意味著執行緒會一直執行該函數。而在 `threadpool_thread` 做的事就是不停的做 queue 上的 task (消費者)。
```cpp
threadpool_thread_t *thread = (threadpool_thread_t *) arg;
for (;;) {
/* Grab task from queue belong this thread */
threadpool_task_t *next_task = threadpool_qpop(thread);
/* Call worker */
if (next_task) {
(next_task->function)(next_task->argument);
}
/* Performance improvement */
/* Improve 5k requests */
sched_yield();
}
```
這邊值得注意的是 `sched_yield` 的使用。原先沒有使用的效能比 lock based thread pool 還差 5K 左右,但是參考 [jwang](https://github.com/jwang0306/sehttpd) 同學的實作發現他有加 `sched_yield`,沒想到竟然差這麼多。
呼叫 [sched_yield](http://man7.org/linux/man-pages/man2/sched_yield.2.html) 時,該 thread 會放棄在 cpu 上的使用權,自動跑到尾巴。
### 效能比較
先說結論,lock free thread pool 的效能並沒有比 lock based thread pool 還要好 (輸了 1K 以上的 requests 量)。
### 不同 consuming 策略對效能的影響
雖然整體效能不如 lock based,但是還是有其他東西可以研究。由 `sched_yield` 得到靈感,想到用不同的方式消耗 queue 中的 task 是否會影響效能? 於是實驗兩種不同的 scheme:
* scheme 1: 若輪到我 (thread) 做 (拿到 cpu),一定一直做到 queue (屬於該 thread 的 queue) 中沒任務可做,才主動呼叫 `sched_yield` 釋放 cpu 占用權。
* scheme 2: 我 (thread) 每次拿到 cpu 時只做 queue 中的一個 task 就釋放權力,給其他人做。
#### 效能
![](https://i.imgur.com/LJX4e4S.png)
可以觀察到 scheme 1 表現普遍較好,我覺得主要的原因在於沒有頻繁的呼叫 `sched_yield`。一次做完該 thread 的 queue 上面的事再把控制權給其他 thread。省去的是頻繁的 context switch ( thread A 做一個任務就換 B,B 再做一個再換 A => 頻繁 context switch )。
以上是透過正常執行方式,接著實驗把程式固定在一個 cpu 上看看效能是否影響 ( `./taskset 0x1` )
![](https://i.imgur.com/TkhjWAM.png)
有趣的是,這次變成 scheme 2 表現較好。我想理由應該是,若在單 cpu 上要達成多執行緒的效果本來就很需要大量 context switch 製造多執行緒的假象,若如 scheme 1 堅持要把該 queue 上的東西做完,很有可能每一次迭代都會被 cpu 剝奪控制權。反觀 scheme 2,每做一個 task 就自動呼叫 `sched_yield` 讓出控制權,這也是 cpu 為了完成多執行緒所比較想看到的事。
所以結論,在一般執行程式,能夠有多個 cpu 狀況下,建議用 scheme 1。
:::warning
thread switch 的成本跟 cpu affinity 及核心內部結構有關。以 [lwan](https://lwan.ws/) 來說,捨棄對 thread switch 的依賴,而引入 coroutine,從而確保更好的效率
:notes: jserv
:::
#### lock 和 lock-free 的 thread pool
至少就我這次的比較來看,lock free 沒有比較好,甚至在開發的過程會帶來很多困擾。於是為了說服自己不要再用 lock free programing,我找到了一篇[文章](https://www.modernescpp.com/index.php/c-core-guidelines-concurrency-and-lock-free-programming)當成我不使用 lock free 繼續開發的背書。節錄部分:
#### Don't program lock-free
Sure, you don't believe me but based on my experience giving many concurrency classes and workshops this is my first rule. Honestly, I agree with many of the most appreciated C++ experts worldwide. Here are the key statements and cites from their talks:
* Herb Sutter: Lock-free programming is like playing with knives.
* Anthony Williams: "Lock-free programming is about how to shoot yourself in the foot."
* Tony Van Eerd: "Lock-free coding is the last thing you want to do."
* Fedor Pikus: "Writing correct lock-free programs is even harder."
* Harald Böhm: "The rules are not obvious."
雖然上面的人名都不認識,但是姑且就信了,接著改善效能的方向還是回到 lock based 的 thread pool。
## 閱讀資料
- [x] [高效能 Web 伺服器開發](https://hackmd.io/@sysprog/fast-web-server)
- [x] [Toward Concurrency](https://hackmd.io/@ktvexe/ryq21wT2?type=view)