網(wǎng)站開發(fā)語言揭陽百度seo公司
目錄
- 一 協(xié)程的概述
- 1.1 并行與并發(fā)
- 1.2 線程
- 1.3 新的思路
- 1.4 Goroutine
- 二 第一個入門程序
一 協(xié)程的概述
我查看了網(wǎng)上的一些協(xié)程的資料,發(fā)現(xiàn)每個人對協(xié)程的概念都不一樣,但是我認可的一種說法是:協(xié)程就是一種輕量級的線程框架(Kotlin),在我之前學到Akka框架,都是為了解決線程在高并發(fā)下能力不足的問題,這里參考了一下大神的文章《并發(fā)之痛 Thread,Goroutine,Actor》,也許你會有更深的理解。
文章地址:并發(fā)之痛 Thread,Goroutine,Actor
1.1 并行與并發(fā)
- 并發(fā)(concurrency) 并發(fā)的關注點在于任務切分。舉例來說,你是一個創(chuàng)業(yè)公司的CEO,開始只有你一個人,你一人分飾多角,一會做產(chǎn)品規(guī)劃,一會寫代碼,一會見客戶,雖然你不能見客戶的同時寫代碼,但由于你切分了任務,分配了時間片,表現(xiàn)出來好像是多個任務一起在執(zhí)行。
- 并行(parallelism) 并行的關注點在于同時執(zhí)行。還是上面的例子,你發(fā)現(xiàn)你自己太忙了,時間分配不過來,于是請了工程師,產(chǎn)品經(jīng)理,市場總監(jiān),各司一職,這時候多個任務可以同時執(zhí)行了。
- 所以總結下,并發(fā)并不要求必須并行,可以用時間片切分的方式模擬,比如單核cpu上的多任務系統(tǒng),并發(fā)的要求是任務能切分成獨立執(zhí)行的片段。而并行關注的是同時執(zhí)行,必須是多(核)cpu,要能并行的程序必須是支持并發(fā)的。本文大多數(shù)情況下不會嚴格區(qū)分這兩個概念,默認并發(fā)就是指并行機制下的并發(fā)。
1.2 線程
開始我們的程序是面向過程的,數(shù)據(jù)結構+func。后來有了面向對象,對象組合了數(shù)結構和func,我們想用模擬現(xiàn)實世界的方式,抽象出對象,有狀態(tài)和行為。但無論是面向過程的func還是面向對象的func,本質上都是代碼塊的組織單元,本身并沒有包含代碼塊的并發(fā)策略的定義。于是為了解決并發(fā)的需求,引入了Thread(線程)的概念。
線程(Thread)
- 系統(tǒng)內核態(tài),更輕量的進程
- 由系統(tǒng)內核進行調度
- 同一進程的多個線程可共享資源
- 線程的出現(xiàn)解決了兩個問題,一個是GUI出現(xiàn)后急切需要并發(fā)機制來保證用戶界面的響應。第二是互聯(lián)網(wǎng)發(fā)展后帶來的多用戶問題。
- 最早的CGI程序很簡單,將通過腳本將原來單機版的程序包裝在一個進程里,來一個用戶就啟動一個進程。但明顯這樣承載不了多少用戶,并且如果進程間需要共享資源還得通過進程間的通信機制,線程的出現(xiàn)緩解了這個問題。
- 線程的使用比較簡單,如果你覺得這塊代碼需要并發(fā),就把它放在單獨的線程里執(zhí)行,由系統(tǒng)負責調度,具體什么時候使用線程,要用多少個線程,由調用方?jīng)Q定,但定義方并不清楚調用方會如何使用自己的代碼,很多并發(fā)問題都是因為誤用導致的,比如Go中的map以及Java的HashMap都不是并發(fā)安全的,誤用在多線程環(huán)境就會導致問題。另外也帶來復雜度:
- 競態(tài)條件(race conditions) 如果每個任務都是獨立的,不需要共享任何資源,那線程也就非常簡單。但世界往往是復雜的,總有一些資源需要共享,比如前面的例子,開發(fā)人員和市場人員同時需要和CEO商量一個方案,這時候CEO就成了競態(tài)條件。
- 依賴關系以及執(zhí)行順序 如果線程之間的任務有依賴關系,需要等待以及通知機制來進行協(xié)調。比如前面的例子,如果產(chǎn)品和CEO討論的方案依賴于市場和CEO討論的方案,這時候就需要協(xié)調機制保證順序。
為了解決上述問題,我們引入了許多復雜機制來保證:
- Mutex(Lock) (Go里的sync包, Java的concurrent包)通過互斥量來保護數(shù)據(jù),但有了鎖,明顯就降低了并發(fā)度。
- semaphore 通過信號量來控制并發(fā)度或者作為線程間信號(signal)通知。
- volatile Java專門引入了volatile關鍵詞來,來降低只讀情況下的鎖的使用。
- compare-and-swap 通過硬件提供的CAS機制保證原子性(atomic),也是降低鎖的成本的機制。
如果說上面兩個問題只是增加了復雜度,我們通過深入學習,嚴謹?shù)腃odeReview,全面的并發(fā)測試(比如Go語言中單元測試的時候加上-race參數(shù)),一定程度上能解決(當然這個也是有爭議的,有論文認為當前的大多數(shù)并發(fā)程序沒出問題只是并發(fā)度不夠,如果CPU核數(shù)繼續(xù)增加,程序運行的時間更長,很難保證不出問題)。但最讓人頭痛的還是下面這個問題:
系統(tǒng)里到底需要多少線程?
這個問題我們先從硬件資源入手,考慮下線程的成本:
- 內存(線程的??臻g) 每個線程都需要一個棧(Stack)空間來保存掛起(suspending)時的狀態(tài)。Java的??臻g(64位VM)默認是1024k,不算別的內存,只是??臻g,啟動1024個線程就要1G內存。雖然可以用-Xss參數(shù)控制,但由于線程是本質上也是進程,系統(tǒng)假定是要長期運行的,??臻g太小會導致稍復雜的遞歸調用(比如復雜點的正則表達式匹配)導致棧溢出。所以調整參數(shù)治標不治本。
- 調度成本(context-switch) 我在個人電腦上做的一個非嚴格測試,模擬兩個線程互相喚醒輪流掛起,線程切換成本大約6000納秒/次。這個還沒考慮??臻g大小的影響。國外一篇論文專門分析線程切換的成本,基本上得出的結論是切換成本和棧空間使用大小直接相關。
- CPU使用率 我們搞并發(fā)最主要的一個目標就是我們有了多核,想提高CPU利用率,最大限度的壓榨硬件資源,從這個角度考慮,我們應該用多少線程呢?
這個我們可以通過一個公式計算出來,100/(15+5)*4=20,用20個線程最合適。但一方面網(wǎng)絡的時間不是固定的,另外一方面,如果考慮到其他瓶頸資源呢?比如鎖,比如數(shù)據(jù)庫連接池,就會更復雜。
作為一個1歲多孩子的父親,認為這個問題的難度好比你要寫個給孩子喂飯的程序,需要考慮『給孩子喂多少飯合適?』,這個問題有以下回答以及策略:
- 孩子不吃了就好了(但孩子貪玩,不吃了可能是想去玩了)
- 孩子吃飽了就好了(廢話,你怎么知道孩子吃飽了?孩子又不會說話)
- 逐漸增量,長期觀察,然后計算一個平均值(這可能是我們調整線程常用的策略,但增量增加到多少合適呢?)
- 孩子吃吐了就別喂了(如果用逐漸增量的模式,通過外部觀察,可能會到達這個邊界條件。系統(tǒng)性能如果因為線程的增加倒退了,就別增加線程了)
- 沒控制好邊界,把孩子給給撐壞了 (這熊爸爸也太恐怖了。但調整線程的時候往往不小心可能就把系統(tǒng)搞掛了)
通過這個例子我們可以看出,從外部系統(tǒng)來觀察,或者以經(jīng)驗的方式進行計算,都是非常困難的。于是結論是:
讓孩子會說話,吃飽了自己說,自己學會吃飯,自管理是最佳方案。
然并卵,計算機不會自己說話,如何自管理?
但我們從以上的討論可以得出一個結論:
- 線程的成本較高(內存,調度)不可能大規(guī)模創(chuàng)建
- 應該由語言或者框架動態(tài)解決這個問題
線程池方案
Java1.5后,Doug Lea的Executor系列被包含在默認的JDK內,是典型的線程池方案。
線程池一定程度上控制了線程的數(shù)量,實現(xiàn)了線程復用,降低了線程的使用成本。但還是沒有解決數(shù)量的問題,線程池初始化的時候還是要設置一個最小和最大線程數(shù),以及任務隊列的長度,自管理只是在設定范圍內的動態(tài)調整。另外不同的任務可能有不同的并發(fā)需求,為了避免互相影響可能需要多個線程池,最后導致的結果就是Java的系統(tǒng)里充斥了大量的線程池。
1.3 新的思路
從前面的分析我們可以看出,如果線程是一直處于運行狀態(tài),我們只需設置和CPU核數(shù)相等的線程數(shù)即可,這樣就可以最大化的利用CPU,并且降低切換成本以及內存使用。但如何做到這一點呢?
陳力就列,不能者止
這句話是說,能干活的代碼片段就放在線程里,如果干不了活(需要等待,被阻塞等),就摘下來。通俗的說就是不要占著茅坑不拉屎,如果拉不出來,需要醞釀下,先把茅坑讓出來,因為茅坑是稀缺資源。
要做到這點一般有兩種方案:
- 異步回調方案 典型如NodeJS,遇到阻塞的情況,比如網(wǎng)絡調用,則注冊一個回調方法(其實還包括了一些上下文數(shù)據(jù)對象)給IO調度器(linux下是libev,調度器在另外的線程里),當前線程就被釋放了,去干別的事情了。等數(shù)據(jù)準備好,調度器會將結果傳遞給回調方法然后執(zhí)行,執(zhí)行其實不在原來發(fā)起請求的線程里了,但對用戶來說無感知。但這種方式的問題就是很容易遇到callback hell,因為所有的阻塞操作都必須異步,否則系統(tǒng)就卡死了。還有就是異步的方式有點違反人類思維習慣,人類還是習慣同步的方式。
- GreenThread/Coroutine/Fiber方案 這種方案其實和上面的方案本質上區(qū)別不大,關鍵在于回調上下文的保存以及執(zhí)行機制。為了解決回調方法帶來的難題,這種方案的思路是寫代碼的時候還是按順序寫,但遇到IO等阻塞調用時,將當前的代碼片段暫停,保存上下文,讓出當前線程。等IO事件回來,然后再找個線程讓當前代碼片段恢復上下文繼續(xù)執(zhí)行,寫代碼的時候感覺好像是同步的,仿佛在同一個線程完成的,但實際上系統(tǒng)可能切換了線程,但對程序無感。
GreenThread
- 用戶空間 首先是在用戶空間,避免內核態(tài)和用戶態(tài)的切換導致的成本。
- 由語言或者框架層調度
- 更小的??臻g允許創(chuàng)建大量實例(百萬級別)
幾個概念
- Continuation 這個概念不熟悉FP編程的人可能不太熟悉,不過這里可以簡單的顧名思義,可以理解為讓我們的程序可以暫停,然后下次調用繼續(xù)(contine)從上次暫停的地方開始的一種機制。相當于程序調用多了一種入口。
- Coroutine 是Continuation的一種實現(xiàn),一般表現(xiàn)為語言層面的組件或者類庫。主要提供yield,resume機制。
- Fiber 和Coroutine其實是一體兩面的,主要是從系統(tǒng)層面描述,可以理解成Coroutine運行之后的東西就是Fiber。
1.4 Goroutine
Goroutine其實就是前面GreenThread系列解決方案的一種演進和實現(xiàn)。
- 首先,它內置了Coroutine機制。因為要用戶態(tài)的調度,必須有可以讓代碼片段可以暫停/繼續(xù)的機制。
- 其次,它內置了一個調度器,實現(xiàn)了Coroutine的多線程并行調度,同時通過對網(wǎng)絡等庫的封裝,對用戶屏蔽了調度細節(jié)。
- 最后,提供了Channel機制,用于Goroutine之間通信,實現(xiàn)CSP并發(fā)模型(Communicating Sequential Processes)。
- 因為Go的Channel是通過語法關鍵詞提供的,對用戶屏蔽了許多細節(jié)。其實Go的Channel和Java中的SynchronousQueue是一樣的機制,如果有buffer其實就是ArrayBlockQueue。
Goroutine調度器
這個圖一般講Goroutine調度器的地方都會引用,想要仔細了解的可以看看原博客。這里只說明幾點:
- M代表系統(tǒng)線程,P代表處理器(核),G代表Goroutine。Go實現(xiàn)了M:N的調度,也就是說線程和Goroutine之間是多對多的關系。這點在許多GreenThread/Coroutine的調度器并沒有實現(xiàn)。比如Java1.1版本之前的線程其實是GreenThread(這個詞就來源于Java),但由于沒實現(xiàn)多對多的調度,也就是沒有真正實現(xiàn)并行,發(fā)揮不了多核的優(yōu)勢,所以后來改成基于系統(tǒng)內核的Thread實現(xiàn)了。
- 某個系統(tǒng)線程如果被阻塞,排列在該線程上的Goroutine會被遷移。當然還有其他機制,比如M空閑了,如果全局隊列沒有任務,可能會從其他M偷任務執(zhí)行,相當于一種rebalance機制。這里不再細說,有需要看專門的分析文章。
- 具體的實現(xiàn)策略和我們前面分析的機制類似。系統(tǒng)啟動時,會啟動一個獨立的后臺線程(不在Goroutine的調度線程池里),啟動netpoll的輪詢。當有Goroutine發(fā)起網(wǎng)絡請求時,網(wǎng)絡庫會將fd(文件描述符)和pollDesc(用于描述netpoll的結構體,包含因為讀/寫這個fd而阻塞的Goroutine)關聯(lián)起來,然后調用runtime.gopark方法,掛起當前的Goroutine。當后臺的netpoll輪詢獲取到epoll(linux環(huán)境下)的event,會將event中的pollDesc取出來,找到關聯(lián)的阻塞Goroutine,并進行恢復。
Goroutine是銀彈么?
Goroutine很大程度上降低了并發(fā)的開發(fā)成本,是不是我們所有需要并發(fā)的地方直接go func就搞定了呢?
Go通過Goroutine的調度解決了CPU利用率的問題。但遇到其他的瓶頸資源如何處理?比如帶鎖的共享資源,比如數(shù)據(jù)庫連接等。互聯(lián)網(wǎng)在線應用場景下,如果每個請求都扔到一個Goroutine里,當資源出現(xiàn)瓶頸的時候,會導致大量的Goroutine阻塞,最后用戶請求超時。這時候就需要用Goroutine池來進行控流,同時問題又來了:池子里設置多少個Goroutine合適?
二 第一個入門程序
- https://github.com/Kotlin/kotlinx.coroutines
maven
<dependency><groupId>org.jetbrains.kotlinx</groupId><artifactId>kotlinx-coroutines-core</artifactId><version>1.7.3</version>
</dependency>
- 測試案例
/*** @description:* @author: shu* @createDate: 2023/8/10 20:32* @version: 1.0*/
import kotlinx.coroutines.*@OptIn(DelicateCoroutinesApi::class)
fun main() {GlobalScope.launch {// 在后臺啟動一個新協(xié)程,并繼續(xù)執(zhí)行之后的代碼delay(1000L)// 非阻塞式地延遲一秒println("World!")// 延遲結束后打印}println("Hello,")//主線程繼續(xù)執(zhí)行,不受協(xié)程 delay 所影響Thread.sleep(2000L)
// 主線程阻塞式睡眠2秒,以此來保證JVM存活
}
協(xié)程在 CoroutineScope
(協(xié)程作用域)的上下文中通過 launch
、async
等協(xié)程構造器(coroutine builder
)來啟動,哈哈第一個入門程序我們就寫完了,看起來還是特別簡單,這里推薦一個視頻來了解攜程
- https://www.bilibili.com/video/BV1KJ41137E9(凱哥)