# [筆記] Golang 撰寫共時性 ( Concurrency ) ### 為什麼使用共時性 目前的 CPU 時脈已達到上限,硬體大多往多核心發展,然而如果程式碼沒有共時性,則無法真正地發揮平行處理使效能提升 #### 共時性 ( Concurrent ) 與平行處理 ( Parallel ) 的差別 * 共時性 : 每個工作結束後,另外一個工作才會開始 ![](https://i.imgur.com/Rq7N9Lo.png) * 平行處理 : 在同個時間運行多個工作 ![](https://i.imgur.com/6zUY5yO.png) #### 撰寫共時性程式碼要做的事 * 我們需要決定哪個工作可以被平行處理 * 撰寫程式時,我們不需考慮將工作對應到硬體,由這些處理 : * Operating system * Go runtime scheduler #### Hiding Latency 既然共時性無法同個時間運行多個工作,那為何需要共時性呢? * 就算沒有平行處理,共時性也可以改善效能 * 許多工作需要定期去等待一些東西,例如 : * 讀取記憶體 * I/O * 此時再等待的空檔,共時性就可以使程式先運行其他工作 ### 共時性基礎 #### 程序 ( Processes ) Processes * 一個跑程式碼的程序 * 每個程序有自己的 : * 記憶體 * code * stack * heap * shared libraries * ... * 暫存器 * program counter * data regs * stack ptr * ... Operating system * 排程並管理程序,確保它們不會相互干擾且合理的使用資源 * 使程序執行擁有共時性 * 因為程序轉換速度很快 * 所以使用者會感受到程序在平行處理 #### 排程 程序排程 ![](https://i.imgur.com/BJfqthT.png) * Operating system 負責排程、分配資源 ( CPU、memory... ) * 實現接近平行處理的感覺 Context switch ![](https://i.imgur.com/6YKTVEB.png) * 控制程序間的轉換 * 轉換前會儲存狀態與內存,直到下次轉換到自己時能繼續執行 #### Thread 與 Goroutine Thread 與 Processes 的差別 * 在程序間的轉換內容還是太慢了,於是有了 Thread * Thread 會共享部分內容 * 一個程序裡可以存在多個 Thread ![](https://i.imgur.com/h0xHc7C.png) Goroutine ![](https://i.imgur.com/6wEfl2R.png) * Goroutine 類似在 Golang 裡的 Thread * 在 OS 看來一樣是轉換 Thread * 但 Go 可以轉換這些 Thread 裡的 Goroutine,就像 OS 轉換 Thread 一樣 Go Runtime Scheduler * 負責排程 Thread 裡的 Goroutine * 就像 OS 裡存在較小的 OS #### Interleaving * 撰寫共時性程式是困難的,當程式出錯時較難發現錯誤,因為執行順序可能會交錯 * 單一的 task,程式碼的運行順序是知道的 * 共時的多個 task 間,task 的運行順序是未知的 * 多個 task 運行時,各 task 內的程式碼甚至會交錯運行 ![](https://i.imgur.com/x9JZaOy.png) * 假設有兩個 task ![](https://i.imgur.com/DOFTePT.png) * 所有的交錯都是有可能的 * 必須考慮到所有的可能 * 執行順序是不確定性的 #### 競爭危害 ( Race Condition ) ![](https://i.imgur.com/EjbFhO8.png) * 因共時性的交錯執行使執行順序是不確定性的,而執行順序影響到執行結果,這就是競爭危害 Task 間的溝通 * 這些執行緒間大程度上必須是獨立的,但並不是完全獨立,他們會共享訊息 * 例如 : * 網頁伺服器,每個用戶一個執行緒 * 使每個用戶可以同時瀏覽不同的網頁,共享同樣的訊息 ![](https://i.imgur.com/4cbFum6.png) * 圖像處理,每個像素一個執行緒 * 使執行緒可以同時處理多個像素,但像是模糊化,必須跟其他執行緒共享鄰近的像素訊息 ![](https://i.imgur.com/xsUoxyn.png) ### Go 裡的執行緒 #### 建立 Goroutine * `main()` 會自動建立 Goroutine * 而其他建立方法必須使用關鍵字 `go` ```go= go foo() ``` #### 離開 Goroutine * 當程式碼執行完就會離開 Goroutine * 而當 `main()` 執行完後,所有的 Goroutine 都會結束執行 * 所以可能因為 `main()` 較早完成,而存在尚未完成的 Goroutine ```go= func main() { go fmt.Printf("New routine") fmt.Printf("Main routine") } ``` * 有可能 Main routine 先完成,也有可能 New routine 先完成 * 若是 Main routine 先完成,New routine 就不會輸出 * 解決方法 : * 延遲 `main()` ```go= func main() { go fmt.Printf("New routine") time.Sleep(100 * time.Millisecond) fmt.Printf("Main routine") } ``` * 在 `main()` 裡增加延遲,給其他 Goroutine 完成的機會 * 但這方法不優,因為時間是不確定性的,也許再延遲的這段時間內,OS 在執行其他執行緒或是 Go Runtime Scheduler 在執行其他 Goroutine * 需要使用較正式的同步結構 #### 同步結構基礎 * 使用 global events 來控制執行的相對順序 ```go= x = 1 x = x + 1 GLOBAL EVENT ``` * Task1 ```go= if GLOBAL EVENT print x ``` * Task2 * global events 使 `print` 發生在 `x = x + 1` 之後 #### WaitGroup * 來自 sync package * 可以強迫 Goroutine 去等待其他 Goroutine * 包含一個計數器 * 假設要等待的 Goroutine 為 x 個,那計數器就設為 x * 每當完成一個 Goroutine 就遞減,直到遞減到 0,才執行此正在等待的 Goroutine * `Add()` : 遞增計數器 * `Done()` : 遞減計數器 * `Wait()` : 等待直到計數器歸零 ```go= func foo(wg *sync.WaitGroup) { fmt.Printf("New routine") wg.Done } fun main() { var wg sync.WaitGroup wg.Add(1) go foo(&wg) wg.Wait() fmt.Printf("Main routine") } ``` * ![](https://i.imgur.com/b7KddcC.png) #### Goroutine 間的溝通 * 在大型的 Task 裡,Goroutine 間勢必要分享資料來溝通 * 一個簡單的例子,假設要計算四個數的乘積 : 1. 創建兩個 Goroutine,分別執行相乘兩個數 2. Main Goroutine 再將這兩個結果相乘 * main routine 必須將資料傳給兩個 sub-routines * 而 sub-routines 必須將相乘後的結果傳回給 main routine * 但資料的傳送不一定是在 Goroutine 的開始或結束,也有可能在中間 * 因此 Goroutine 間的溝通需使用 Channel 來完成 Channel * 用於在 Goroutine 間傳送資料 * 需要使用某種型別來創建通道,來定義傳送的資料類型 * 使用 `make()` 創建 Channel ```go= c := make(chan int) ``` * 使用 `<-` 來傳送與接收資料 * 傳送 : ```go= c <- 3 ``` * 接收 : ```go= x := <- c ``` ```go= func prod(v1 int, v2 int, c chan int) { c <- v1 * v2 } func main() { c := make(chan int) go prod(1, 2, c) go prod(3, 4, c) a := <- c b := <- c fmt.Println(a * b) } ``` * channel 默認上並不能暫存資料 * sending 會被鎖住直到 channel 接收到資料 * receiving 會被鎖住直到 channel 將資料送出 * 所以當 receiving 卻不使用此資料時就相當於 WaitGroup `Wait()` 的效果 * Task 1 : ```go= c <- 3 ``` * Task 2 : ```go= <- c //receiving 不使用此資料 ``` Buffer Channel * 擁有暫存功能的 Channel ```go= c := make(chan int, 3) ``` * 當 channel 的 buffer 是滿的時,sending 會被鎖住 * 當 channel 的 buffer 是空的時,receiving 會被鎖住 * 使用 Buffer Channel 的原因 * 若沒有 buffer,當 sending 與 receiving 速度不同時,勢必有一方必須等待另外一方 * 這樣降低了共時性的效果,所以使用 buffer 可以解決這問題 ### 同步通訊 #### 迭代通道 ```go= for i := range c { fmt.Println(i) } ``` * 持續接收來自通道 c 的數據 * 將接收到的數據放入 i,在大括弧中做使用 * 當發送端將通道關閉 `close(c)`,則此 for 迴圈將會結束,否則將持續執行 * 通常在發送端確定不會再發送數據時,才會使用 `close(c)` #### 從多個通道讀取 ![](https://i.imgur.com/j8pzofk.png) * Goroutine `T3` 向 Goroutine `T1` 與 Goroutine `T2` 獲取資料 ```go= a := <- c1 b := <- c2 fmt.Pritln(a * b) ``` * 從多個通道讀取,但會互相等待 ```go= select{ case a = <- c1: fmt.Println(a) case b = <- c2: fmt.Println(b) } ``` * 使用 select 從多個通道讀取,不會因為要等待其他通道而被鎖住 #### select ```go= select{ case a = <- inchan: fmt.Println("received a") case outchan <- b: fmt.Println("sent b") } ``` * select 可以傳送也可以接收 * 哪個 case 條件達到,哪個 case 先處理 ```go= for { select{ case a = <- c: fmt.Println(a) case <- abort: return } } ``` * 在 select 使用 abort 通道,來中止異常 * 當 abort 通道 ( 異常通道 ) 有資料進來,for 迴圈將會中止 * 除此之外,就只是不斷地從 c 通道獲取資料並處理 ```go= select{ case a = <- c1: fmt.Println(a) case b = <- c2: fmt.Println(b) default: fmt.Println("nop") } ``` * 在 select 使用 default * 當 case 條件都尚未達成時,就執行默認值 default #### 互斥 ```go= var i int = 0 var wg sync.WaitGroup func inc() { i = i + 1 wg.Done() } func main() { wg.Add(2) go inc() go inc() wg.Wait() fmt.Println(i) } ``` * 照這樣共享變數 i 來看,i 應該要是 2 ![](https://i.imgur.com/XJ1sc6m.png) * 以這兩種流程來看也沒什麼問題 * 但事實上流程的交錯是發生在較低階的機器語言上,並不是我們看到的 go 語言 * 在機器語言上,`i = i + 1` 有三個動作,讀取 i、運算加 1、寫入 i ![](https://i.imgur.com/dFBkqbh.png) * 這就會導致 i 不等於 2 #### 如何預防互斥 1. 別讓兩個或兩個以上 Goroutine 共享變數 2. 使用 `sync.Mutex` * 他使用一個二進制的標誌 * 當此變數被使用時,標誌打開 * 變數沒被使用時,標誌關閉 * `Lock()` : 變數被使用時,標誌打開 * `Unlock()` : 變數沒被使用時,標誌關閉 ```go= var i int = 0 var mut sync.Mutex func inc() { mut.Lock() i = i + 1 mut.Unlock() } ``` * 將函式改寫成這樣就解決互斥問題了 #### 在使用多個 Goroutines 中如何初始化 * 初始化 * 只執行一次 * 在所有程序前執行 1. 在執行 Goroutines 前先初始化 2. 使用 `sync.Once` * 可以在需要初始化的 Goroutines 開頭中調用 * 而他只會執行一次 * 其中一個在執行初始化時,其他程序將會阻塞直到初始化完成 * `Do(f)` : 初始化執行 `f` 函式 ```go= var wg sync.WaitGroup var on sync.Once func setup() { fmt.Println("Init") } func dostuff() { on.Do(setup) fmt.Println("hello") wg.Done() } func main() { wg.Add(2) go dostuff() go dostuff() wg.Wait() } ``` * 初始化的基本範例 * 打印結果為 : ```= Init hello hello ``` #### Deadlock 假設有兩個 Goroutines G1、G2,他們互相依賴 * 當 G1 等待 G2 * 而 G2 等待 G1 * 這就是所謂的 Deadlock ```go= var wg sync.WaitGroup func dostuff(c1 chan int, c2 chan int) { <- c1 c2 <- 1 wg.Done() } func main() { ch1 := make(chan int) ch2 := make(chan int) wg.Add(2) go dostuff(ch1, ch2) go dostuff(ch2, ch1) wg.Wait() } ``` * Deadlock 的簡單範例 * 當所有 Goroutines 都 Deadlock 時,Golang 將會自動偵測出來 * 但當 Goroutines 的子集 Deadlock 時,Golang 是沒辦法偵測的 #### Dining philosophers problem ![](https://i.imgur.com/sDN7NBf.png) * 有五位哲學家要吃飯 * 中間各有一支筷子 * 需要兩支筷子才能吃飯 * 先拿左邊筷子再拿右邊 * 沒辦法一次所有人一起吃飯 * 這就是 Dining philosophers problem ```go= type ChopS struct { sync.Mutex } type Philo struct { leftCS, rightCS *ChopS } func (p Philo) eat() { for { p.leftCS.Lock() p.rightCS.Lock() fmt.Println("eating") p.leftCS.Unlock() p.rightCS.Unlock() } } func main() { //初始化 CSticks := make([] *ChopS, 5) for i := 0;i < 5;i++ { CSticks[i] = new(ChopS) } philos := make([] *Philo, 5) for i := 0;i < 5;i++ { philos[i] = &Philo{CSticks[i], CSticks[(i + 1) % 5]} } //eat for i := 0;i < 5;i++ { go philos[i].eat() } } ``` * 實作 Dining philosophers problem * 當出現所有哲學家左手都拿筷子的情形後,所有程序就無法進行,形成 Deadlock * 解法 : 將筷子依序編號,哲學家必須先拿起便號較低的筷子 * 如此一來編號最低筷子的兩個科學家都會優先拿起這隻筷子 * 這樣就解決了 Deadlock,但可能會造成編號最低筷子與編號最高筷子間的哲學家餓死 ###### tags: `筆記` `程式語言` `Golang`