首先要強調的是協程不是線程,如果一定要將它與線程作比較,那麼可能會陷入泥潭,個人認爲單純將協程看作一種編程方式感覺更容易理解. 協程的優點包括:
- 協程更加輕量,創建成本更小,降低了內存消耗
- 減少了 CPU 上下文切換的開銷
- 減少同步加鎖,整體上提高了性能
- 可以按照同步思維寫異步代碼
協程這麼厲害,那到底有什麼用呢?協程有一個很重要的場景,就是IO密集型任務。以前使用同步 IO 的情況下,如果出現了 IO 操作,線程就會被阻塞從而 CPU 可以執行其他線程,等 IO 操作就緒後繼續執行下面的任務。只要線程足夠多,那麼 CPU 就會得到充分利用。這樣的編程模式符合人類的思維習慣但是由於大量的線程切換帶來了大量的性能的浪費。
爲了解決上面的問題,出現了 IO 複用技術,Java 中的 selector 就能解決上面的問題。只用一個線程來處理任務,如果遇到了 IO 操作,就將 IO 操作以及後續的處理(回調函數)交給 Selector 線程 ,當 IO 就緒後執行回調函數(可以在另外一個線程執行)。這樣看起來向下面這個樣子
Worker 線程
- Worker 線程獲得任務
- Worker 線程發生 IO 操作 IO(Handler),Handler用於處理剩下的任務
- Worker 線程處理下一個任務
Master 線程
- Master 線程等待 IO 事件發生
- Master 線程拿到事件綁定的 Handler,並執行 Handler 處理剩下的任務
這個正是 Reactor 模型
- 應用程序註冊 IO 就緒事件和相關聯的事件處理器
- 事件分離器等待事件的發生
- 當發生讀就緒事件的時候,事件分離器調用第一步註冊的事件處理器
- 事件處理器首先執行實際的 IO 操作,然後根據讀取到的內容進行進一步的處理
通過上面的模型雖然可以解決線程太多的問題,但是代碼中會出現大量的回調函數,例如事件處理器中還有會嵌套 IO 操作,這樣層層嵌套的回調函數會影響到代碼的可讀性。
有沒有辦法能用和同步看起來一樣的方式完成異步操作?讓 Worker 線程的流程變成下面這樣子
- Worker 線程獲得任務
- Worker 線程發生 IO 操作 IO()
- Worker 線程處理剩下的任務 Handler()
- Worker 線程處理下一個任務
假設這裏的 IO() 不會阻塞線程,那麼無法保證 第3步一定在第2步之後執行。同樣爲了保證第3步一定在第2步之後執行,除了回調之外,那就只能阻塞。看起來是矛盾的,其實只要將這個兩個操作剝離出去,線程不用關心這兩個操作是否正常完成。因此,現在需要一種封裝方法將這個兩個步驟封裝起來,並且保證這兩步按照順序執行,當然這裏也不能讓線程阻塞。
假設有一種表達式可以插入到任意位置,它內部可以包含表達式,樣子像下面這樣
co{
yield IO();
Handler();
...
}
之前爲了實現讓Handler() 在 IO() 之後執行使用的是回調如 IO(Handler) ,那麼這裏通用也應該通過回調實現。代碼看起來是同步的,但是實際運行的時候還是利用回調實現的。這個轉換工作可以在編譯期完成。注意 yield 這個標記,編譯就是根據這個標記將 co 裏面的代碼分成幾個部分, 分別對應 switch 的幾種條件,因此回調的時候只要根據當前執行到哪個標記,就執行對應的 case 就可以了。
上面 co 代碼塊即使就在被調用的線程執行也不會阻塞線程,因爲這之前回調的流程本質是一樣的。但是看起來就好像這個 co 是被阻塞了一樣,實際上並沒有 co 並阻塞,線程也沒有被阻塞。
雖然看起來解釋的比較粗糙, 但是 co 代碼塊勉強算是協程。但是其內部不能使用阻塞方法,部分還是會導致當前執行的線程被阻塞。一個通用的協程應該能包含任意類型的代碼,所以我們還需要對 co 進行升級,通過 Excutor 框架將 co 代碼塊作爲一個 task 提交,這樣主線程就一定不可能被阻塞。主線程不會被阻塞,但是 co 代碼塊中的阻塞方法還是會導致執行 task 的線程阻塞。因此,還需要規定 yield 關鍵字引導的函數也會被轉化爲 co 代碼塊,在上一級 co 代碼塊中作爲 task 提交。
以上就是對協程的一些理解,如果想了解協程更具體的實現方法可以參考很多已有的框架,如 Kotlin, Go, Fiber等。這些上面簡陋的協程和這些框架實現的協程可能差很遠,但是也可以幫助理解協程概念。