owned this note
owned this note
Published
Linked with GitHub
# 2024q1 Homework1 (lab0)
contributed by < `fennecJ` >
#### Reviewed by `HenryChaing`
> 學到很多之前曾未想過得串列實作方式,其他留言放在內文。
## 開發環境
```bash!
# gcc --version
gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 48 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 12
On-line CPU(s) list: 0-11
Vendor ID: AuthenticAMD
Model name: AMD Ryzen 5 7600X 6-Core Processor
CPU family: 25
Model: 97
Thread(s) per core: 2
Core(s) per socket: 6
Socket(s): 1
Stepping: 2
CPU max MHz: 5453.0000
CPU min MHz: 400.0000
BogoMIPS: 9381.96
```
### `q_insert_head`
> commit [56af0a](https://github.com/fennecJ/lab0-c/commit/56af0a32d63075c8841efdd85244605e77edd505)
:::danger
對照 Dictionary.com 對 [implement](https://www.dictionary.com/browse/implement) 一詞的解釋:
* to fulfill; perform; carry out:
* to put into effect according to or by means of a definite plan or procedure.
* Computers. to realize or instantiate (an element in a program), often under certain conditions as specified by the software involved.
* to fill out or supplement
「實作」會是符合上述 "implement" 語意的譯詞,取「起」以「創作」的意思,而「製造」本質上是固定輸入並通常產生固定輸出的行為。
$\to$ [資訊科技詞彙翻譯](https://hackmd.io/@sysprog/it-vocabulary)
:::
一開始我的作法如下:
```c!
bool q_insert_head(struct list_head *head, char *s)
{
...
list_add(&(ele->list), head);
return true;
}
```
:::warning
不要寫錯重要軟體的名稱。
:::
但這樣在嘗試 commit 的時候會一直被 Cppcheck 擋下來:
```
error: Memory leak: ele [memleak]
```
一開始嘗試查閱 [Cppcheck manual](https://cppcheck.sourceforge.io/manual.pdf) ,但卻沒能解決我的問題。
花了一些時間後我猜 `ele->list` 如果沒有初始化的話 `list->next`, `list->prev` 不知道會指到哪邊造成潛在的錯誤。因此一開始以 INIT_LIST_HEAD 初始化 `ele->list` 的方式通過 Cppcheck。 但仔細檢視 list_add 後留意到它會明確將 `list->next`, `list->prev` 指到原本的 `head->next` 和 `head` ,不符前述假設。
之後做了許多嘗試,最後發現問題出在最後面的 `list_add(&(ele->list), head)` ,將其改為 `list_add(&ele->list, head)` (移除 ele->list 前後的括號)後就能通過 Cppcheck 的靜態檢查。
```diff
bool q_insert_head(struct list_head *head, char *s)
{
...
- list_add(&(ele->list), head);
+ list_add(&ele->list, head);
return true;
}
```
### `q_remove_head`
看到 remove 這個字的時候,一開始直覺認為會需要 free 記憶體空間,直到檢視 `queue.h` 中的註解:
```!
NOTE: "remove" is different from "delete"
The space used by the list element and the string should not be freed.
The only thing "remove" need to do is unlink it.
```
才知道只需 unlink 即可。
檢視 [List Management Functions](https://www.kernel.org/doc/html/latest/core-api/kernel-api.html) ,留意到 `list_del` 這個 API ,到 `list.h` 中可看到對應實作僅有將 node 進行 unlink:
```c!
static inline void list_del(struct list_head *node)
{
struct list_head *next = node->next;
struct list_head *prev = node->prev;
next->prev = prev;
prev->next = next;
#ifdef LIST_POISONING
node->prev = (struct list_head *) (0x00100100);
node->next = (struct list_head *) (0x00200200);
#endif
}
```
同時可以留意裡面的 LIST_POISONING 會將 node 的前後指向非法的記憶體位址,用來防止 access via deleted node's next/prev,為何不將 node's next/prev 直接指向 NULL,這是為了能幫助我們更好的 debug ,可參考 [What is the role of LIST_POISON1 and LIST_POISON2](https://lists.kernelnewbies.org/pipermail/kernelnewbies/2016-March/015888.html) 的討論,若我們知道 page fault 的 addr 是 LIST_POISON1/2 ,我們能更容易定位到可能出錯的程式碼有哪些。
> 對照 [2024-02-{20,27} 問答簡記](https://hackmd.io/@sysprog/H1QORp-h6)
> 授課老師有建議可以多使用中文專有名詞,如節點。
> [name=HenryChaing]
### `q_merge`
> commit [285041](https://github.com/fennecJ/lab0-c/commit/2850412984dfccf483b1e686315f4841d7a29796)
不斷的將第二個以後的 queue merge 到第一個 queue 中即可。而在 merge 二個已排序的雙向鏈結串列時,為了方便處理迴圈結束的判斷條件,會將二個鏈結串列都改為 singular list
```c !
...
entry->q->prev->next = NULL;
...
```
最後透過一個 helper function [rebuild_list_link](https://github.com/fennecJ/lab0-c/blob/1e64e3ee14b40767bf3caf65d5dab3062393cb81/queue.c#L325)
將全部合併完的 list 回復為雙向鏈結串列。
值得一提的是在合併比較時用了一個 bitwise 的小技巧:
```c!
node = ((strcmp(ele1->value, ele2->value) < 0) ^ descend) ? &l1 : &l2;
```
直接用 xor descend 來判斷接下來的 node 應該是接 l1 的 node 或是 l2 的 node ,但會導致 sort unstable 的問題。
> 可以了解互斥或操作,但想請教您為何結果會 unstable
> [name=HenryChaing]
> 您好,stable sort 的定義為若存在元素 A, B 滿足兩者鍵值相等,則 sort 後 A, B相對位置不變。現在我們檢視上述透過互斥或的操作,假設 ele1->value 和 ele2->value 相等且 ele1 在 ele2 前面,若要滿足 stability , ele1 在排序後也需要在 ele2 前面。若此時 descend = ture ,則 (strcmp(...) < 0 ) ^ descend 會等於 (0) ^ 1 = 1 。導致 ele2 先於 ele1 被放進 sorting 後的佇列中, ele1, ele2 兩者的前後關係發生改變。造成結果 unstable 。 在這裡用 ^ descend 的問題為,在 ele1, ele2 兩者相等時也會達成交換順序的條件。
> [name=fennecJ]
### `q_delete_mid`
> commit [e51e73](https://github.com/fennecJ/lab0-c/commit/e51e737f4f415f5c5ac7d1562f5503f30b8c51ac)
:::danger
改進你的漢語表達。
:::
我的作法是用兩個指標 forward, backward 以對向、相同移動速度來存取節點。由於這裡處理的是雙向鏈結串列,因此當兩個指標以相反等速的方向移動時,他們最終會相遇,下圖是一個簡單的示意:
![image](https://hackmd.io/_uploads/ryYHGyETa.png)
為了說明為何他們相遇在中間點,現在我們將他們的起點切開,把圓攤平:
![image](https://hackmd.io/_uploads/HyevX1ET6.png)
此時我們假設 Backward 和 Forward 相遇時, Backward 移動了 K 個距離。由於 Forward 以相同的速度移動,因此 Forward 的移動距離亦為 K 。我們可以得到
$$
2 K = L \\
K = \dfrac{L}{2}
$$
因此他們相遇的地方就在中間點。取出中間點後把它刪除即可。
```c
...
struct list_head *forward, *backward;
forward = head->next;
backward = head->prev;
while(forward != backward && forward->next != backward){
forward = forward->next;
backward = backward->prev;
}
// backward will be the target we want to delete
```
### `q_reverse`
> commit [b7150e](https://github.com/fennecJ/lab0-c/commit/b7150ed37c51c53496df2276620a9678e2500a92)
將每個 node 的 prev 和 next 對調即可。
```c!
list_for_each_safe (n, sf, head) {
tmp = n->next;
n->next = n->prev;
n->prev = tmp;
}
```
不過要注意 list_for_each 系列的 MACRO 都是從 head->next 開始 traverse ,所以要記得另外更新 head 的 prev 和 next
```c!
tmp = head->next;
head->next = head->prev;
head->prev = tmp;
```
### `q_reverseK`
> commit [1e64e3e](https://github.com/fennecJ/lab0-c/commit/1e64e3ee14b40767bf3caf65d5dab3062393cb81)
`q_reverseK` 的處理步驟如下:
* 將 list 以 K 個 node 為一組切成數組 sublists
* 找到每個 sublists 的 start 和 tail nodes,若找不到 tail (i.e. 剩餘 nodes 不足 K 個 ) 則直接 return
* 除了 start 和 tail node 之外,sublist 中其餘 node 的處理方式都和 q_reverse 相同:將 node 的 prev 和 next 對調。
* 最後單獨處理 start 和 tail :
* start->next 指向原本的 tail->next
* start->prev 指向原本的 start->next
* tail->next 指向原本的 tail->prev
* tail->prev 指向原本的 start->prev
### `q_descend`, `q_ascend`
> commit [1e64e3e](https://github.com/fennecJ/lab0-c/blob/1e64e3ee14b40767bf3caf65d5dab3062393cb81)
由於處理 `q_descend` 和 `q_ascend` 的主要邏輯相同,因此這裡只介紹 q_decend 的作法:
從最後一個 node `head->prev` 開始往前面的 node 看,並紀錄目前看過得最大 string max_str 。若往前看時該 node 的值小於 max_str ,則移除該 node。
---
## 網頁伺服器
研讀 [整合網頁伺服器](https://hackmd.io/@sysprog/linux2024-lab0/%2F%40sysprog%2Flinux2024-lab0-c) 時,學到了一個系統呼叫 [select()](https://man7.org/linux/man-pages/man2/select.2.html) 。
目前的理解如下:
使用者可將多個 file descriptor 傳入同一個集合 set 中,而 select 會同時 monitor 這些 file descriptor ,並透過 return value 告知使用者本次讀到的 file descriptor 是哪一個 file descriptor。
:::warning
用數位邏輯裡頭的 MUX 解釋。
:::
Mux (多工器)在數位邏輯中指的是可根據 sel 訊號從多個 input 中選擇一個訊號傳送到 output 的硬體,一個常見的 2-to-1 mux 構造如下:
![image](https://hackmd.io/_uploads/Hy76BFrT6.png)
以硬體描述語言 (HDL) 展現其行為:
```verilog!
module Mux(
input i0,
input i1,
input sel,
output out
)
assign out = (sel == 0) ? i0 : i1;
endmodule;
```
而 `select()` 系統呼叫可被視為:
* 可同時接收多個 I/O 輸入訊號。
* 根據不同的 I/O event 選擇對應的訊號輸出。
![image](https://hackmd.io/_uploads/Hk89vYSp6.png)
* 我們可以透過 `FD_ISSET()` 得知 select 訊號 S0 為何
Demux (解多工器) 在數位邏輯中指的是可根據 sel 訊號將一個訊號傳送到指定的接收端的硬體,一個常見的 1-to-2 demux 構造如下:
![image](https://hackmd.io/_uploads/H13-v3L66.png)
我們透過 `FD_ISSET()` 得知 select 訊號 S0 之後,可以根據 S0 進行後續的處理,就像是 Demux 的行為一樣:
![image](https://hackmd.io/_uploads/ByJROnUp6.png)
可對照 `console.c` 中 `cmd_select 的實做`:
```c!
if (readfds && FD_ISSET(infd, readfds)) {
// readline from stdin
...
} else if (readfds && FD_ISSET(web_fd, readfds)) {
// read cmd from web server
...
}
```
### line_edit()
在 `linenoise.c` 中,主要讀取使用者輸入的 function 是 `line_edit()` 這個 function :
```c!
...
while (1) {
signed char c;
int nread;
char seq[5];
nread = read(l.ifd, &c, 1);
...
}
```
這裡的 l.ifd 會是 stdin 的 fd ,透過 read 從 stdin 讀取一個 char 到 c 在交由後續進行處理。 若要讓 linenoise 也支援透過網頁伺服器接收命令,我們可以用 select() function 做 I/O multiplexing ,並依據收到的輸入是經由網路還是 stdin 做對應的處理。
### web command
:::danger
file 的翻譯是「檔案」,而非「文件」(document)
:::
檢視 `console.c` ,可以看到 `do_web` 這個 function ,透過字串查找卻沒有看到有哪個<s>文件</s> 檔案有透過 `do_web(` 呼叫這個 function 。然而透過 ./qtest 開啟程式後輸入命令 web
```bash!
# ./qtest
cmd> web
listen on port 9999, fd is 3
```
:::warning
對照閱讀[你所不知道的 C 語言:前置處理器應用篇](https://hackmd.io/@sysprog/c-preprocessor)
:::
可以看到它會去執行 `do_web` 函式。仔細檢視和 web 相關的程式碼,發現 console.c 中有使用到一個巨集:
```c!
...
ADD_COMMAND(web, "Read commands from builtin web server", "[port]");
...
```
`ADD_COMMAND` 巨集展開如下:
```c!
#define ADD_COMMAND(cmd, msg, param) add_cmd(#cmd, do_##cmd, msg, param)
```
可以看到它會藉由 `do_##cmd` 將 `ADD_COMMAND(web,...)` 展開為
`add_cmd(web, do_web, ...)`
當 console 接收到 web 命令時,就會執行對應的 do_web() ,這裡我們簡單檢視其實作(詳見 [github](?) :
:::danger
改進上方描述
:::
```c!
static bool do_web(int argc, char *argv[])
{
...
web_fd = web_open(port);
if (web_fd > 0) {
printf("listen on port %d, fd is %d\n", port, web_fd);
use_linenoise = false;
}
...
}
```
可以注意到其中的 `use_linenoise = false` ,這是因為藉由 curl 透過網頁伺服器傳命令到 console 中必須要傳輸完整的命令,用不上自動補全,所以當啟用 web 命令後會將 `use_linenoise` 設為 false
檢視 `run_console()` 可得知 console 處理 input 的流程:
```c!
bool run_console(char *infile_name)
{
...
if (!has_infile) {
char *cmdline;
while (use_linenoise && (cmdline = linenoise(prompt))) {
interpret_cmd(cmdline);
line_history_add(cmdline); /* Add to the history. */
line_history_save(HISTORY_FILE); /* Save the history on disk. */
line_free(cmdline);
while (buf_stack && buf_stack->fd != STDIN_FILENO)
cmd_select(0, NULL, NULL, NULL, NULL);
has_infile = false;
}
if (!use_linenoise) {
while (!cmd_done())
cmd_select(0, NULL, NULL, NULL, NULL);
}
} else {
while (!cmd_done())
cmd_select(0, NULL, NULL, NULL, NULL);
}
...
}
```
先判斷是否有指定輸入檔案,若無 (i.e. stdin or via web) 則接著判斷是否能用 use_linenoise ,並透過 cmd_select 處理 input 。
cmd_select 是一個根據不同的 fd (stdin or web_fd) 進行對應處理的 function ,其實作可見 [github](https://github.com/sysprog21/lab0-c/blob/39c85e97b1b5b7b222a23e9d2066419463ab750b/console.c#L557)
#### 上述實作的缺陷
當開啟 web server 後,便再也不能使用 linenoise 的功能。有沒有辦法能同時支援 web server 又支援 linenoise 呢?
#### 我的作法
> commit [ed8a2ad](https://github.com/fennecJ/lab0-c/commit/ed8a2ad68c1ff80112098bf026620f66dfcb1b5f)
由於使用 `web` 命令後便會把 linenoise 的功能關掉,因此我決定新增一個命令 `web_close` 讓使用者能在開啟 web server 後把它關起來,而在關掉 web server 的同時會再次將 linenoise 的功能打開。
#### 嘗試改善
> millaker's [commit](https://github.com/millaker/lab0-c/commit/039b945a7066215b706704693047973562f9c72c)
增加 `web_close` 讓使用者能夠重新開啟 linenoise 的功能,但在 web server 運作時仍會導致 linenoise 失效。可否進一步使 web server 運作的同時 console 中的 linenoise 也能同時運作呢?
強者我朋友 millaker 提出了一個解決思路:
無論何時都 call 到 linenoise 中,在 line_edit 內才分辨目前的 fd 是 stdin 還是 web_fd ,並根據對應的 fd 做後續的處理:
只要是 std_in 就進行 linenoise 的自動補全;只要是 web_fd 就直接處理 web_fd 傳來的命令。
但這樣還是會有一個缺陷,就是當使用者在 console 中使用了自動補全之後,經由 curl 傳到 web server 的命令沒辦法立刻被收到,要等到使用者在 console 中按下 enter 後才會被接收到。和 millaker 討論後,我們發現 linenoise 中的 complete_line() 有一個 while loop ,直到使用者按下 enter 後才會離開這個迴圈,這時程式才有機會注意到 web_fd 中有尚未讀取的 data 的原因導致這個現象。我們可以很直接的修改 complete_line() 中處理的流程來解決 curl 被 linenoise 阻塞住的問題,但這會導致 linenoise 套件的運作邏輯被大幅改動,不利於後續的維護。
查資料的過程中,大致看了 SIGIO/poll/epoll 等等可以用來處理 socket data 的作法,但無論是哪個 function 似乎都不能很好 / 優雅的處理上述的問題。
如果要作到兩邊都能即時有反應,我目前只想到透過 context switch 的方式輪流處理 stdin 和 web_fd ,不知道還有什麼好方法?
:::warning
對照 https://github.com/sysprog21/lab0-c/pull/162
:::
[vax-r](https://hackmd.io/@vax-r) 同學也對同時使用網頁伺服器和 linenoise 提出他的 [解決方案](https://github.com/sysprog21/lab0-c/pull/162) ,我十分贊同他 「盡可能對 linenoise 做最小改動」的這個想法,由於 linenoise 是額外整合在作業中的套件,日後若要將其 sync 到 upstream ,較小的改動能讓我們更容易進行更新的維護以及減少 bug 發生的機會。
我嘗試了 vax-r 同學的 repo 但仍會出現前述問題。
為了清楚說明,我將開啟兩個 terminal 並分成兩個名字: term_stdin, term_web
其中 term_stdin 用來透過終端機藉由 linenoise 直接和 qtest 互動
term_web 僅會透過 curl 將命令傳入 qtest 中的網頁伺服器。
依照下列步驟執行可重現我的問題:
* 在 term_stdin 輸入 web 開啟網頁伺服器
* 在 term_stdin 輸入 ne ,其後按下 `tab` 使其自動補全為 new
* 補全後不要在 term_stdin 中按下任何鍵,直接切換到 term_web (此時 term_std 畫面如下)
```
cmd> web
listen on port 9999, fd is 3
cmd> new▮
```
( ▮ 為游標符號 )
* 移動到 term_web 視窗,輸入 curl `http://localhost:9999/it/a` 並按下 enter ,會發現該命令被 block 住, qtest 不會處理它
* 這時回到 term_stdin 按下 enter 後會發現 it a 命令在 new 之後才被處理,此時term_stdin 畫面如下:
```
cmd> web
listen on port 9999, fd is 3
cmd> new
l = []
cmd>
l = [a]
cmd>
```
我認為先送出的命令 (it a 先被 enter 送出) 應先被處理較合理,但顯然 output 不符合先送出先處理的原則。(若先處理 it a 預期應可見到 `Warning: Calling insert tail on null queue` 等訊息)
:::warning
> [name=I-HSIN Cheng]
經過測試後,上述問題的發生是由於函式 `complete_line` 當中的 `read` 函式呼叫引起的阻塞問題,當我們按下 tab ( `line_edit` 當中的 main loop 監聽) 進行自動補全後,程式行為實際上是交給 `complete_line` 來進行自動補全,並在補全後依然停留在 `complete_line` 當中的 `read` 函式等待使用者進一步輸入,造成此 `./qtest` 這個 process 阻塞在此處。
同時您在 term_web 下達的 `curl` 命令則被寫入 socket fd 指向的 file 當中等待讀取,直到您在 term_stdin 按下 `ENTER` 使得 `./qtest` 跳出 `complete_line` 的迴圈並完成對應命令後,才去處理 socket fd 當中的指令。
:::
:::warning
> [name=fennecJ]
原來如此,謝謝您耐心解釋程式流程和前述現象發生的原因。
:::
:::warning
> [name=I-HSIN Cheng]
此處我認為有兩個解決方法。第一種是同時間處理多個請求的話,利用多執行緒的方法分開處理,畢竟 `linenoise` 原本的設計就建立在面對單一命令輸入的情形。第二種是同樣利用 `web_eventmux` 在`complete_line` 內做監聽,將 `term_web` 傳送進來的命令暫存,並在 `complete_line` 回傳時讓 `line_edit` 有兩個命令必須執行。我認為第一個方法較為恰當,第二個方法會大幅改動到 `linenoise` 套件預設行為(試想如果同時開啟 n 個 terminal 並對 `./qtest` 發送請求,此方法就要暫存 `n` 條指令)。 此處應請教 Jserv 老師何種變更較為合適。
我認為還有一種較暴力的可能方法是在 `web_eventmux` 當中若收到網路封包請求就直接執行 `interpret_cmd` 將該命令直接執行,執行完後回到 `linenoise` 中的 `complete_line` 或 `line_edit` 繼續監聽 standard input 的命令,但此方法同樣涉及大量改動與變更 `web` 預設行為,我認為能讓功能執行但不適當。
:::
:::warning
> [name=fennecJ]
原先我和 [millaker](https://hackmd.io/@millaker/HyXNtXmna) 討論的解法也是您提到的第二點 - 在 complete_line 中進行 I/O 的監聽,但正如您提到的,此種方法將大幅改動 `linenoise` 原先的行為,可能導致後續維護上的困難。
:::
:::warning
> [name=I-HSIN Cheng]
我使用一個替代方案,依據我上次提交的 PR ,如果在開啟 `web` 命令後,在 command-line 寫入任何指令(不按 tab ),並在另一個 terminal 傳入 command ,則程式會將 command buffer 當中的指令替換成 web request 傳送進來的指令,於是原本 command-line 的命令會被丟棄。如果此行為是可接受的,那只要在 `complete_line` 當中進行以下更動,即可做到相同的行爲。如果此行為是可接受的,我將於近日提交新的 PR 以修復此問題。
```diff
ls->pos = saved.pos;
ls->buf = saved.buf;
} else {
refresh_line(ls);
}
+ if (eventmux_callback != NULL) {
+ int result = eventmux_callback(ls->buf);
+ if (result != 0) {
+ free_completions(&lc);
+ return ENTER;
+ }
+ }
```
:::
:::warning
當初設計這項作業時,希望引導學員引入 coroutine 來解決問題,例如 [Coroutines in C](https://www.chiark.greenend.org.uk/~sgtatham/coroutines.html),工程領域本來就常見殊途同歸,很難說一個方法就絕對優於另一者,尚有其他的權衡 (trade-off) 因素,如程式碼的維護成本、持續擴充的考量。
:notes: jserv
:::
### 實作 q_shuffle
> commit [7002a4](https://github.com/fennecJ/lab0-c/commit/7002a49a82858ff2f52fb18ab5bbbe351a52f1ea)
照著教授提供的 [Fisher–Yates shuffle](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm) 演算法可很容易的實作出來,比較特別的是和 [404allen404](https://hackmd.io/@404allen404) 討論時他指出其實不用特別花心思處理要交換的 `node->prev`, `next` 等 link 的資料,而是可以直接交換 node 中 element 存的值。我覺得這個想法很好,因此用這個方法進行交換。
### 研讀論文〈Dude, is my code constant time〉
#### Misc
* make valgrind trace-17 每次都會過(目前試過 7 次),但 make test trace-17 幾乎都會 failed ( 14 次僅一次 pass)
* q_merge() 改良(改成頭尾兩兩合併)
:::warning
研讀指定的論文
:::
# ttt
## 將 ttt 引入 lab0-c
為了方便後續的維護,我將 ttt 獨立為一個目錄並整合進 lab0-c 中,詳見 commit [613374](https://github.com/fennecJ/lab0-c/commit/613374acf04088ba2ec4b4820b0f20aa65b06429)
## Concurrent programming
我將 ttt 的運作過程分為幾個並行程式:
* listen_keyboard 負責監聽任何按下按鍵的事件
* player_X_move 負責下 X 的 agent, ai 為 negamax
* player_O_move 負責下 O 的 agent, 根據 ttt_mode 的 option
* update_screen 負責顯示時間資訊和重新繪製棋盤等工作
* game_manager 負責判斷是否有人獲勝或是以滿足平手條件
task 的 schedule 則參考 [preempt_sched](https://github.com/sysprog21/concurrent-programs/tree/master/preempt_sched) 中的實做,同時在必要的地方直接呼叫 `schedule()` 將 cpu 使用權交出去增進使用體驗。
要注意的是,由於任一邊( O 或 X )下完之後都有可能滿足獲勝或平手的條件,因此實做上需要在 player_X_move/player_O_move 後都呼叫一次 game_manager 。另外也記得要分別呼叫 update_screen ,避免
```c
task_add(listen_keyboard, g_args);
task_add(player_X_move, g_args);
task_add(update_screen, g_args);
task_add(game_manager, g_args);
task_add(player_O_move, g_args);
task_add(update_screen, g_args);
task_add(game_manager, g_args);
```
此外,為了方便不同並行程式間的溝通,我定義了一個結構 `game_arg` :
```c
typedef int(game_agent_t)(char *table, char mark);
typedef struct {
char turn;
char table[N_GRIDS];
bool game_play;
game_agent_t *agentX;
game_agent_t *agentO;
int pX_score;
int pO_score;
int draw;
char *pX_name;
char *pO_name;
bool echo;
} game_arg;
```
而 `player_X_move` 和 `player_O_move` 運作上會分別呼叫 `agentX` 和 `agentO` 來決定要下在哪裡,如此只要替換掉 更改 agentX 和 agentO 指向的 function 便能很簡單的替換不同的 ai 邏輯。
### ai vs ai
> commit [2628ae](https://github.com/fennecJ/lab0-c/commit/2628ae2ccd06ba2a7696738dc5d481bb41bd56e8)
### listen_keyboard
> commit [4a2111](https://github.com/fennecJ/lab0-c/commit/4a2111e53c992bbd89e25af3b6ebdb97ead7009f)
我留意到大部分同學在進行 ai vs human 的作業時無法同時顯示出當前的時間(精確到秒),而想要達到這樣的效果需要解決一個問題,就是若直接透過 `read()` 去捕捉使用者的輸入,則因為 nonblocking read 以及處理 IO 時會關閉 preemption 的關係,若使用者完全沒有在進盤上進行任何輸入,則畫面會直接卡在那邊無法透過 scheduler 跳到別的 coroutine 執行。
這裡我嘗試透過 `poll()` 系統呼叫,當 poll 偵測到 stdin 有新的 input ,則會直接由 read 讀取一個字元並寫入 input buffer ,直到偵測到使用者按下 enter 後才根據 input buffer 內容執行對應操作。若 poll 尚未偵測到 stdin 有新的 input ,則會直接呼叫 scheduler 進行排程讓畫面時間能夠刷新。而由於只有在明確知道有字元需要讀取時才會呼叫 `read()` ,此時由於 STDIN 有尚未讀取的字元存在所以立刻會處理完往後執行,而不會卡在那邊等新的輸入,如此便能造成即時反應的效果達到顯示出當前的時間(精確到秒)的功能。
```c
nfds_t nfds = 1;
while (run_ttt) {
poll(&pfd, nfds, 0);
if (!(pfd.revents & POLLIN))
schedule();
// read part
preempt_disable();
(void) !read(STDIN_FILENO, &c, 1);
...
```
### ai vs human
> commit [76d90c](https://github.com/fennecJ/lab0-c/commit/76d90c424c2cd2e28969c0cfa0c497953b0a6be3)
為了讓 ai vs human 也能順利顯示出當前的時間(精確到秒),我們仍需要 scheduler 的介入適時切換到印出時間資訊的 task 。
我在 listen_keyboard 中額外處理了 ENTER 和 BACKSPACE 事件,讓使用者能藉由 listen_keyboard 輸入座標並顯示在終端機上,同時透過 shceduler 來呼叫 update_screen 使時間能順利更新。
以下為 demo 影片:
{%youtube s2wYWHZ0K3s %}
## 定點數運算
### log
實做主要參考 Clay S. Turner 提出的 [A Fast Binary Logarithm Algorithm](http://www.claysturner.com/dsp/BinaryLogarithm.pdf) 以及對應的實現 [程式碼](https://github.com/dmoulding/log2fix)
### sqrt
使用牛頓法進行開根號運算:
假設我們要以牛頓法求 val 之根:
令方程式
$$f(x) = x^2 - val$$
我們要求的根 a 滿足
$$f(a) = 0$$
用牛頓法進行迭代,假設 a 初始值為 $a_0$ , 可得
$$e_0 = f(a_0) = a_0^2 - val$$
已知
$$f'(x) = 2x$$
則我們可以得出過點 ($a_0, e_0$) 之切線方程 L1 為
$$L1(x) = 2a_0 * x + e_0 - 2a_0^2$$
令 $a_1$ 為 $L1(x) = 0$ 的根,則可求出
$$ a_1 = \dfrac {2a_0^2 - e_0}{2a_0} $$
$$ = a_0 - \dfrac{e_0}{2a_0} $$
$$ = a_0 - \dfrac{a_0^2 - val}{2a_0} $$
$$ = a_0 - \dfrac{a_0}{2} + \dfrac{val}{2a_0} $$
$$ = \dfrac{1}{2}(a_0 + \dfrac{val}{a_0}) $$
之後我們便能再用 a_1 進行後續的迭代。
加速牛頓法的迭代
若初始值足夠靠近目標點則可以用更少的迭代次數取得足夠的精度。
已知 $val = 2^{MSB} + ...$ ,則我們可以知道 $\sqrt{val}$ 必定滿足
$\sqrt{val} >= 2^{\lfloor \frac{MSB}{2} \rfloor}$
因此我們可將初始值設為
```c
int x = 1 << (MSB >> 1);
```
而 MSB 的計算可由
```
MSB = 32 - __builtin_clz(val);
```
得出。
然而這種估計方式只對整數域有效,假設傳入的定點數小數域共有 PRECISION 個 bits ,則
```c
int x = 1 << (MSB >> 1);
```
會變成
```c
int x = (1 << PRECISION) << (MSB >> 1);
// to express 1 in fixed point, we need to left shift PRECISION bits
```
且在計算 MSB 時,也要把 PRECISION 佔的 bit 數剪掉:
```
MSB = 32 - PRECISION - __builtin_clz(val);
```
最後將其整理為初始值的計算公式:
$$ (1 << PRECISION) << (MSB >> 1) $$
$$ = 1 << (PRECISION + (MSB >> 1) $$
$$ = 1 << (PRECISION + (32 - PRECISION - CLZ) >> 1) $$
$$ = 1 << ((PRECISION >> 1) + 16 - (CLZ >> 1)) $$
```c
uint32_t clz_res = __builtin_clz(val);
uint64_t x = 1 << ((precision >> 1) + 16 - (clz_res >> 1));
```
目前取 PRECISION 為 20 bits ,經過實驗,只要進行四次牛頓法就能收斂到可接受的誤差範圍。
之所以取 20 的原因是因為 mcts 演算法中的 ITERATION 次數為 $10^6 \approx 2^{20}$ 而計算 ust 的公式為
$$\dfrac{wins}{ITERATION} +\sqrt{\dfrac{2 * log(parent\_try)}{ITERATION}}$$
因此小數部份需要能有 $10^{-6} \approx 2^{-20}$ 的精度,因此取 20 bits 。
最後我們可以實做出對應的開根號運算:
```c
uint32_t sqrtFix(uint32_t val){
clz_res =(uint32_t) __builtin_clz(val | 1);
uint64_t x = 1 << ((PRECISION >> 1) + 16 - (clz_res >> 1));
x = (x + fixDiv(val, x, PRECISION)) >> 1;
x = (x + fixDiv(val, x, PRECISION)) >> 1;
x = (x + fixDiv(val, x, PRECISION)) >> 1;
x = (x + fixDiv(val, x, PRECISION)) >> 1;
return x;
}
```
## div/mult
直接進行乘除運算後記得 div 要左移; mult 要右移 PRECISION 位即可。
```c!
uint32_t fixMult(uint32_t a, uint32_t b, int precision){
uint64_t res = a * b;
return (uint32_t)(res >> precision);
}
uint32_t fixDiv(uint32_t a, uint32_t b, int precision){
uint64_t t_a = (uint64_t)a << precision;
// or 1 to prevent div by zero
uint32_t res = t_a / (b | 1);
return res;
}
```