contributed by < AmyLin0210
>
探討 threaded-logger 的實作,分析其中 reader-writer 議題,包含 atomics 和 futex 的使用,思索後續的改進
編譯
執行測試程式
logger
命令與參數定義為以下範例,前七個皆為必填:
預設的測試命令與參數為:
在這個專案裡,所有印出的 log
預設都是丟到 stderr
,但為了方便呈現,故在測試程式內有將 stderr
給 redirect 至 build/out.log
這份檔案中,執行後可以在終端機及檔案中看到執行結果。
首先看到專案內的檔案架構
首先來看到參數設定的部份,main
函式中第 22 行會把 opts 給預設為 LOGGER_OPT_PREALLOC
,可以在 logger.h
中找到他的值為 4
在第 24 ~ 27 行的地方,設定 NONBLOCK
以及 PRINTLOST
,預設皆為 0
在第 48 ~ 52 行的地方,使用了 clock_gettime 函式去紀錄 fprintf
開始前與結束後的時間,以計算一個 fprintf
需要多久
在第 55 行的地方,呼叫了 logger_init
,放入的參數分別是
在實際去看過 logger_init
的程式碼後,會發現這個函式裡處理的是 reader 的 thread
如果將 logger_init
改成 logger_reader_init
是不是比較符合程式碼所想要做的事情?
在第 63 ~ 71 行的地方,初始化 wirter 的 thread
queue_size
是一個介於 lines_min
與 lines_max
之間的隨機數,但還沒了解為什麼要這樣設計從程式碼內可以看到 logger_pthread_create
的目標是處理 writer thread 若改成 logger_writer_create
之類帶有 writer 語意的名稱可能會比較好
在第 74 ~ 93 行的地方,使用了 non-blocking 的 pthread_tryjoin_np,目標為若有一條 writer 執行緒已經結束了,但是還有一些 log 還沒被讀到,會再重新建立一條 writer 執行緒
這個函式所作的事情就是將訊息寫入 queue 內,並且計算該動作需要多少時間
thp->chances
內會是一個整數,在預設的命令中為 10 ,表示有 1 / 10 的機率會 sleepLOG_LEVEL
會需要多久的時間執行,並且將資訊丟入 queue 中在這邊可以看到每個 write thread 的 queue 宣告
在 C11 中有定義出了 _Thread_local ,表示 thread storage duration ,以此宣告的變數,它會存在於該 thread 中,在該 thread 消失時,會跟著一起被消失。
thread storage duration. The storage duration is the entire execution of the thread in which it was created, and the value stored in the object is initialized when the thread is started. Each thread has its own, distinct, object. If the thread that executes the expression that accesses this object is not the thread that executed its initialization, the behavior is implementation-defined. All objects declared _Thread_local have this storage duration.
在這裡把 futex(2) 包成了巨集來使用,分別有 futex_wait
, futex_timed_wait
, futex_wake
,裡面的參數 FUTEX_xxx_PRIVATE
根據 futex(2) 內的說明,是為了讓 kernel 了解只會在這個 process 內使用該 mutex ,故有利於最佳化。
It tells the kernel that the futex is process-private and not shared with another process (i.e., it is being used for synchronization only between threads of the same process). This allows the kernel to make some additional performance optimizations.
在 futex_op
的內容設為 FUTEX_WAKE
時,val
所代表的就是最高一次喚醒的 thread 數量,而回傳值則是有多少的 waiter 被喚醒。
This operation wakes at most val of the waiters that are waiting (e.g., inside FUTEX_WAIT) on the futex word at the address uaddr.
而當 futex_op
的內容設為 FUTEX_WAIT
時,會去比較說內含的數值是否和 val 相同,要是相同會進入 wait 的狀態,要是不同會回傳出 EAGAIN
在這邊對 logger 進行初始參數、 reader_thread 等等動作,由於在第 3 行的時候,有將 logger 內的記憶體位置皆初始化為 0 ,故沒有特別設定的都將會是 0。
本函式是 read thread 的主要函式
fuse_nr
被設成了 logger.queues_nr
,根據在 logger.h
內的註解,代表有多少已經 allocated 的 queue ( Number of queues allocated )futex_wait
來進入等待的狀態,等到有人呼叫 futex_wake
後,會將 logger.reload
設為 0,接著執行 continue。初始化 fuse_queue
,在這裡把每個 fuse
的 wrq
一一指向 logger
的 wrq
回傳值是目前初始化了多少的 fuse_queue
,也就是有多少的 logger.wrq
內有東西
在這邊會去計算還有多少的 empty queue ,並且把 ts 小的往 array 中 index 小的地方放
確認該 queue 是否為空
ts
設為 ~0
ts
設為 wrq->lines[index].ts
。初始化 writer thread 並設定相對應的參數
在這邊使用了 pthread_cleanup_push
與 pthread_cleanup_pop
來做 thread 的 clean-up hanlder。
根據 linux man page 內的敘述,在執行了 pthread_cleanup_push
後,會將指定的 routine 給 push 置 clean-up handler stack 上;pthread_cleanup_pop
有一個參數名為 execute,若該參數不為零,會在 pop 該 routine 時去執行它。
並會在以下三種情境將該 routine 從 stack 內 pop 出去並決定是否要執行:
pthread_exit()
中止掉一條 thread 時,所有在 clean-up handlers 內的 routine 都會被執行pthread_cleanup_pop
後,會將 stack 最上方的 routine 給 pop 出,並根據參數決定是否被執行在程式碼的第 26 行,看到 pthread_exit,在這個地方執行主要的 thread function
困惑點:在 pthread_exit 執行完後,理論上就應該會把 cleanup routine 內的東西給 pop 並執行,那為什麼還會需要下面的兩個 pop ?
在這裡的話,是去判斷是否有適當的 write queue,如果有的話,就回傳該 queue,若沒有的話,就生成一個。
如果目前沒有 write queue,可以直接看到下方程式碼的第 40 行,會去 alloc 一個新的 write queue。
在這個函式中,會給予 write queue 一個空間並初始化。
PREALLOC
的參數,給與新增出的空間一些數值,目標是繞過 Linux 的最佳化,確保 Linux 有真的給予該參數空間。&logger.reload
是否為 0,如果為 0 ,那會將 &logger.reload
的值改變為 1。此函式的目標為將指定的內容格式化後放入 queue 中,並呼叫 wakeup_reader_if_need
去判斷是否需要喚醒 reader
第 45 ~ 53 行程式碼的地方,有沒有可能因為編譯器最佳化,造成 l->ready
先被改變,而後才執行 vsnprintf()
而造成結果與預期不一致?
在這邊會去判斷 logger 是不是處於 waitting 的狀態,如果是的話,就將它喚醒
目前的實做是 multiple-writer / single-reader 的形式
當有一條新的 write 執行緒產生時,它會先去尋找是否有空的 queue ,若是有的話,將自己的 _own_wrq
指向該 queue;若沒有的話,會呼叫 alloc_write_queue
函式,產生出一條新的 queue。
在 queue 內會有 n 條 line,功能為儲存需要被 log 的資訊
(在這裡假設每條 queue 內有 10 條 line。)
目前使用一條新的 queue 作為範例,在最開始的時候 wr_seq
與 rd_seq
皆為零,目前沒有任何的 log 被礎存在該 queue 中
wr_seq
: 儲存要被寫入的位置rd_seq
: 儲存要被讀取的位置當 write 執行緒寫入了一個新的 log 時,會將 wr_seq
的位置往後挪一個
在 write 執行緒將 log 放入 line 的函式中,也會嘗試將 read 執行緒給喚醒 (wake) ,read 執行緒將會去檢查所有的 queue 中是否東西,如果有的話,變更 rd_seq
的位置並將其印出
原始程式碼內的命名會比較通用,但是套到這份專案上面時,會發現比較不易閱讀。
舉 logger_pthread_create
為例子,在本專案中,它處理的事情為設定參數並創建出一個 pthread。但由於該專案的設計邏輯,single-reader / multi-writer ,已經有個專門創建 reader 執行緒的函式,故只會在創立 writer 執行緒的時候被使用到。
故想要將該專案內的函式針對專案內的功能,進行重新命名。
Initialize the logger manager
logger_reader_create
reader_thread
所該要執行的內容在按照以上的邏輯命名發完 pull request 後,老師針對 logger_init 的變更回覆為:
This change violates the idea to encapsulate the essential operations. The terms "reader" and "writer" are meant to be distinguished internally.
針對整份 pull request 的回覆為
For the sake of API naming convention, it is not necessary to address readers and writers in public functions.
看完回覆後發現針對 logger_init
,的確是要維持原本的命名方式會比較好,會比較符合他的原始行為
而 logger_thread_func
是一個 private 的函式,我想若使用 logger_reader_func
也是可以的。而且由於在這邊的執行緒分成兩種類型 (reader / writer),將 reader 放在函式的名稱中,會比較好被理解
logger_pthread_create
對我來說最大的問題點是,在 logger_init
內也有一條 pthread 被創立,怕會造成混淆。但是經過思考後,這個函式的名稱與他所作的事情的確是相符合,因此也決定不做任何更動。
由於 116 行中的變數 r
在程式碼內沒有被使用,因此移除。
由於 int 型別的數字範圍為 0 ~ 2147483647,若把一個整數變成字串的話,大小為 0 byte ~ 10 byte ,在此有機會會造成 overflow。觀察程式碼的語意,在這邊由於印出的數字為 thread 的最大數量,而一般的 multi-thread 程式中也不會設定產生超過 10000 的數字,因此在程式碼運行的部份理論上不會造成影響。但是在資訊安全領域的方面,由於 thread 的最大數量是由使用者所決定的,如此可能會造成不正程的程式行為。
在此有兩種改善方案
1. 增大 tnm
的大小,以避免 overflow
2. 在程式碼中限制 thread
的最大數量,需要介於 1 ~ 10000 之間
在 write_line
函式中,可以發現有可能的 buffer overflow 問題。
下面為 write_line
的原始程式碼,會發現 linestr 的大小為 LOGGER_LINE_SZ
+ LOGGER_MAX_PREFIX_SZ
,也就是應該要被印出的 log 與額外的像是 date/time 的資訊。
回頭看一下 l->str
這個參數是如何被賦值的,看到 logger_printf
這個函式,可以找到下方的程式碼,會發現 str 的長度是有被固定住的
由此判斷這個 warning 在程式碼的邏輯中,是有被妥善處理,不用修正的。
在 logger_printf
的地方有以下的程式碼,當中的 l->str
與 l->ready
並沒有相依性,因此可能由於最佳化,而造成順序對調。
研究原始程式碼後,發現有可能因為 l->ready
先被執行,而造成結果錯誤。
以下擷取自 logger_printf
函式 (writer)
以下擷取自 set_queue_entry
函式 (reader)
假設最佳化會造成 ready 先被指派的結果,若 writer 執行緒在執行到以下情形時, reader
就先行依照 ready
參數的設定結果,判斷是否有需要被印出的字串 (ready
已經被設立,但是 str
還沒有被改變),就有機會造成錯誤發生
為了要避免上面提到最佳化時造成的可能問題,我決定要將 ready 改成 atomic 的變數,並適度的設定 memory barrier,然後實驗看看與未改成 atomic 變數時,會造成多大的效能影響。