contributed by < toastcheng
>
linux2021
重新回答第 1 周測驗題,附帶的「延伸問題」也需要完成
解釋程式運作原理時,應提供對應的 Graphviz 圖例,可參照 Linked List 題目 1 + 分析
先看程式內容,主要分為三個部分:
以下逐一說明。
資料結構 __node
定義了此連結串列 (linked list) 中的節點長相,由一個整數作為節點的值,以及一個 __node
的指標指向連結串列中的下一個節點。
list_add_node_t
: 將node
接在*list
的開頭。其中由兩個動作完成,考慮最初的情況,list
型別為 node_t
的指標的指標,對其取值可以得到某個節點的位址,在此用 node0
、node1
、…代表以節點 *list
為首的連結串列。
node->next = *list;
node
的指向的下一個節點位址設定成 list
的取值,即 node0
的位址。*list = node;
list_concat
: 將*left
的最後一個節點接上*right
的第一個節點。while (*left) left = &((*left)->next);
*left = right;
LLL 的部分一開始沒有想通 (c) 及 (d) 的差異,故以下花一點篇幅紀錄思考的過程:
left
為 node_t
的指標的指標, dereference 後不應該有 next
這個屬性。left
為 node_t
的指標的指標,而 node_t.next
為 node_t
的指標,不應該指派 node_t*
型別的值給 left
。而 (d) 的行為:
差別在於 (c) 不會改動到 left
指向的位址,只改動 left
本身的值,因為 left
是以 copy 的形式傳進函式,所以執行完後,外層的 *left
依然是第一個節點。
相對的 (d) 不改 left
本身的值,而是修改 left
指向的位址,雖然看似執行 while 迴圈時,有正確的走訪各個節點,但函式執行完後 *left
指向的節點會是 right
,而不是第一個節點。
quicksort
: 執行 quick sort 主要邏輯的函式。以下是對應程式碼解說。
首先處理空值的狀況,函式直接回傳。
接著指定連結串列的第一個節點為 pivot
。
以 pivot
的值為分界,將連結串列從頭到尾走訪過一遍,將值大於 pivot
的節點附加在 right
,否則附加在 left
。
根據這樣的邏輯 AAA
及 BBB
應分別是代表 right
和 left
的兩個連結串列,又 list_add_node_t()
預期的資料型別是指標的指標,因此應該分別填入 &right
及 &left
。
最後遞迴地對 left
、right
執行 quicksort 。
宣告一個新的連結串列 result
,將兩個遞迴的執行結果 left
和 right
依序串接。
依據 quicksort 的需求,排序好的連結串列其值要由小到大,因此將 left
、pivot
、right
串接的順序應該是:left -> pivot -> right
,因此 CCC
應為 list_concat(&result, pivot); list_concat(&result, right)
。
其中還有兩個在 main()
呼叫但沒有實作的函式:
連續執行 quicksort ,可以發現 random()
函式所產生的數值其實都是相同的序列,可以被輕易預測且重現。
這個現象直接和 PRNG 的機制有關,閱讀 Wikikpedia: PRNG上的資料可以了解到, PRNG 本質上就是一個函數,給 PRNG 輸入,他就回應一個數,但他不只是函數,他是有狀態 (state) 的,他會將過去產生的隨機數保存下來(依照函數的行為,需要保存下來的數量也會不一樣),並用過去的隨機數來計算出下一個。這也是為什麼呼叫 random()
時不用帶參數,因為 random()
把已產生的隨機數存下來了。
參考 glibc 專案中的 random.c
,可以看到 random()
計算用到的隨機表:
再用圖示表示其中的運作:
粗略地表示:
PRNG 本質上的組成就是:
照 man page 所述 random()
的 seed 預設是 1,所以很自然地每一次產生出來的隨機數會是相同的。
讓程式能夠每一次執行都有隨機的輸出就必須設置 seed 。最常見的做法是用目前的時間來當作 seed,如此就能達到每次執行都從不同的 seed 開始產生隨機數:
但看到 linD026 同學提到,若有兩支 process 在相同的時間點執行該程式,確實會有兩支 process 產生相同隨機數序列的問題,依照同學的解法是用*argv
的地址來計算 seed,利用每次程式都會在不同的記憶體位址這個特性來區分不同的 process。
而我在此嘗試另一個方式,回憶起 /dev/random
和 /dev/urandom
可以得到透過蒐集硬體雜訊來生成真正意義上隨機數,若是先用其作為 seed,就能保證總是從隨機的 seed 開始生成隨機數。
最赤裸的方式是直接來讀 /dev/random
!
從 Linux 3.17 開始,加入 getrandom 系統呼叫,因此可以將上面讀檔的行為簡化:
閱讀 man page 後了解到 random()
是一種 psuedorandom number generator,而更詳細地說,是一種nonlinear additive feedback random number generator,The GLIBC random number generator 這篇文章針對 glibc 中 random()
的機制做了說明,雖然這篇文章是 2007 年撰寫的,對照現在的 glibc 原始碼並沒有落差。使用的演算法經過對照其設置 state 、產生隨機數的方式以及所使用的常數(例: 16807),是 Lehmer random number generator,為一種 linear congruential generator (LCG) 演算法。但這一類的演算法被證實有著隨機數品質不佳的缺陷:
- Shorter-than-expected periods for some seed states (such seed states may be called "weak" in this context);
- Lack of uniformity of distribution for large quantities of generated numbers;
- Correlation of successive values;
- Poor dimensional distribution of the output sequence;
- Distances between where certain values occur are distributed differently from those in a random sequence distribution.
簡單來說就是產生出來的隨機序列不符合我們對「隨機」的期待,像是:找得到規律、數字出現的頻率有高低之分等等。有趣的是儘管 LCG 被批評產生出來的隨機序列品質不佳,但仍被廣泛使用,Java 也不例外,因為產生隨機數的需求非常大。
As an illustration, consider the widely used programming language Java. As of 2017, Java still relies on a linear congruential generator (LCG) for its PRNG, which are of low quality
做為改進,我參考了 List of random number generators 列出的一大票 PRNG ,這其中我發現了一類特性十分有趣的演算法 CBRNG,這也帶出了另外一個我沒想到的議題,前述如 LCG 類演算法都是一個一個地生成隨機數,而且理論上以好的 PRNG 來說前後兩個隨機數的關係應該要很難預測,也就是說要生成一組隨機序列免不了要序列地 (sequentially) 地執行。以至於這些 PRNG 「先天上」不太好受惠於現代多處理器、顯示卡等架構的平行運算加速。
例如我想要取得從 開始的 N 個隨機數,那我就必須從 N 開始計算 N 次,即便我有多個核心能夠平行計算,我還是得一個一個計算來。
但 CBRNG 這類的演算法並不一樣,CBRNG 在產生第 i 個隨機數 時不必依賴於第 i - 1 個隨機數 。這個特性讓 CBRNG 特別適合平行化運算。
我起初覺得 counter-based 這個形容詞不是很直觀,畢竟他的特色感覺比較像 hash function,不太懂為何會選擇 counter 這個字。但再進一步理解其運作原理後就能理解 CBRNG 的「上下文」了。CBRNG 並不是完全嶄新的概念,他其實參考了密碼學中的區塊加密 (block cipher) 中的 counter mode。
區塊加密是指將明文以相等大小切割,一個區塊一個區塊加密的方式。而 counter mode 是將每個區塊的會有一個遞增 counter,先對這個 counter 用鑰匙加密,再對每個區塊的明文XOR。
而如果將 seed 當作鑰匙,省略與明文做 XOR 的步驟,每個區塊的輸出不也有不可預測性嗎?這是 CBRNG 的基本邏輯,非常巧妙,隨機數之間也沒有前後相依。
詳細的資訊可以一覽 Udacity 上一系列密碼學課程。影片其中之一 Prng Implementation - Applied Cryptography 提及的 PRNG 實作方式就是一種 CBRNG。
這樣可以生產出非常密碼學上安全的隨機數,但是問題是比較耗時且許多應用並不要求這樣的安全性。因此 CBRNG 的發展方向就是追求更快的隨機數生成,放棄一定的安全性。
因此就照著這個方向找到了 Philox 和 Squares 這兩個 CBRNG,前者被近年許多軟體如 nVidia 的 curand 、Tensorflow 等使用,後者則是列表中最新的一個演算法,並沒有太多軟體實作,但其聲稱快過 Philox 而吸引了我的注意,其發表的論文中提到:
It is significantly faster than Philox and produces data of equivalent or better quality.
以下列出兩篇論文分別介紹了 Philox 及 Squares:
這篇論文非常詳細地討論一個好的 CBRNG 要有怎麼樣的取捨,由此說明 Philox 演算法的設計理由。
考量:週期為 也不夠大,好的 PRNG 的重複週期應該要大到能讓人安心。
One million cores, generating 10 billion random numbers per second, will take about half an hour to generate random numbers, which raises doubts
about the long-term viability of a single, unpararameterized PRNG with a periods of “only” .
考量:現代的區塊加密都有很多次的 round,來模糊同一個 block 的明文和密文之間的關係,但太多次 round 對於 PRNG 來說並不必要。
Most modern block ciphers consist of multiple iterations of simpler bijections, called “rounds.” Each round introduces some diffusion
For example, 128-bit AES has 10 rounds, DES has 16
rounds, and 256-bit Threefish has 72 rounds.
以 PRNG 來說區塊加密的運算效率太低,也不用像 TLS 等其他應用那麼要求安全性,因此其改善方向:減少 round 的數量、限縮 data path、簡化 key schedule(用來藏 key的步驟)。
這裡參雜著許多資安領域的概念:Feistel functions 以及 SP network (Substitution–permutation network),花了不少時間釐清他們之間的關係。
Feistel functions:
他有非常巧妙的性質,詳情可見 Computerphile 的說明,但簡單來說它透過 B
和 F
兩個函數的計算,可以把 LR
轉換成 L'R'
,並且一一對應。
SP network (Substitution–permutation network):
是更大的一個概念,由 S-box 和 P-box 組成,S-box 提供將輸入映射成不同值 (substitution) 的功能,而 P-box 則如圖所示,將每個輸入置換 (permutation)。
事實上 Fiestel function 其實就是一種 S-box。
作者利用兩個 instruction 來進行 Philox:
他們有以下性質:
而這兩個指令的特性可以很巧妙的組成 Fiestel function,需要的 round 不多速度也快:
其中為 XOR 運算。而 Philox 則是以 mulhi
和 mullo
組成的 Fiestel function 組成的 SP-network。
// TODO: 加原始碼分析
// TODO: 加說明
參考 Optimized QuickSort — C Implementation (Non-Recursive) 並重寫上述 quick sort 程式碼,避免使用遞迴呼叫
避免使用遞迴呼叫的方式
文章中提到使用遞迴呼叫雖然在程式碼上能夠更加簡潔以及更好實作,但也有些隱性地成本:
用變數 i
紀錄現在的 stack
在什麼位置,每次迴圈的開始都進行 pop 的操作:
依照相對於 pivot
大小整理節點的步驟沒有不同。而分類完 left
和 right
之後,本來應該各自遞迴計算的部分換成「放進 stack 中」。
這裡的順序至關重要:
因為希望能先處理小的節點,所以必須先放 right
,接著 pivot
,最後 left
,而 left
會最先被 pop 出來計算。每當從 stack 拿出單獨的節點,因為放入 stack 的順序一定會先處理值小的節點,所以可以保證單獨的節點都會是目前最小的,因此可以加入排序好的連結串列 r
中。
但有趣的是,文章作者在發佈的三年後補充提到其實作並不一定比常見的遞迴版本要好,建議讀者還是自行比較。
// TODO
Linux 核心內部也有 linked list 實作,但是 circular doubly-linked list,linux-list 仿效 Linux 核心的實作並予以簡化,在
examples/
目錄提供 quick sort 實作,請探討 Linux 的 linked list 和上述程式碼的落差,並改寫 linux-list 的 quicksort 範例,避免使用遞迴呼叫
參考資料: The Linux Kernel API - List Management Functions
在 linux-list
中 quicksort 演算法實作在 list_qsort
這個函式之中,查看可以發現跟 quiz1 最初的 quicksort 十分雷同:
list_less
及 list_greater
分別存放比 pivot 小及大的節點,同 quiz1 中的 left
和 right
。list_less
及 list_greater
執行。相較於 quiz1 不同之處在於連結串列的資料結構有所不同,linux-list
中的 listitem
定義如下:
除了 next
之外,還多了 prev
指標來指向連結串列的前一個節點,實作成 doubly-linked list。而再觀察 list.h
中幾個用於操作連結串列的函式可以發現:
他還是一個環狀 (circular) 的連結串列,如果都透過 list_
開頭的函式對其操作,將可以確保第一個節點的 prev
會指向最後一個節點;最後一個節點的 next
會指向第一個節點。
既然他是環狀連結串列,那照一般的方式走訪每個節點將不斷的循環,而讓 quicksort 在比對每個節點跟 pivot 值的大小時不重複走訪同一個節點的是 list_for_each_entry_safe
這個 macro,這是一個將 for 迴圈的初始條件、終止條件、更新值的部分封裝起來的 macro 。
防止重複走訪節點的邏輯在 &entry->member != (head)
,當發現回到 head
時終止 for 迴圈。
將 linux-list
避免使用遞迴呼叫的方式:
// TODO
研讀 Ching-Kuang Shene (冼鏡光) 教授撰寫的 A Comparative Study of Linked List Sorting Algorithms,思考高效率的 linked list 排序演算法,並落實於上述 (3) 的程式碼中
// TODO