1. 程式人生 > 其它 >Golang協程和Java執行緒

Golang協程和Java執行緒

前言

最近剛讀完Java併發程式設計實戰、深入理解Java虛擬機器。打算寫一篇總結性文章,思來想去文章的內容,最後決定還是不要限定於Java這門語言,應該從提升效能的整體出發,所以就有了這篇文章。


一、什麼是序列程式?

序列程式就是一次只能做一件事情。拿一個早上起床去上班的例子來說,它分為以下幾個步驟,這些步驟跟序列程式的語義是一樣的。它們必須一件一件來完成。

二、什麼是併發程式

現在假設人的需求變了,需要在刷牙的時候煮個雞蛋、熱個牛奶當早餐吃。如果完全按照序列程式的語義來執行,事情就會變成這樣:我在煮雞蛋的過程中什麼都不能做,必須等雞蛋煮好後才能走開去熱牛奶。顯然在現實生活中,雞蛋放在鍋裡煮的時候我就可以去刷牙洗臉了,不必一直在這裡無意義的等待
,所以,在程式世界中也必須能夠支援這些符合現實世界的行為。

等待雞蛋煮好、牛奶熱好的動作,在程式裡有一個名詞叫“IO等待”。處於IO等待時程序(或執行緒)將處於一種叫“阻塞”的狀態,此時是不消耗CPU時間的,所以可以用來做其他事情。這就叫併發程式。

三、摩爾定律

摩爾定律是由Intel公司創始人之一Gordon Moore發現的規律,意思是隨著晶片製造技術的發展,電晶體的體積越來越小,從而有可能將越來越多的電晶體放入一個晶片中(引自Andrew S.Tanenbaum和Herbert Bos的《現代作業系統》)。這種基於經驗的法則揭示了CPU執行指令(可比喻現實世界的某個事件)的速度會越來越快,但是實際上並非CPU越快程式就越快了,這中間存在著溝通問題。

比如有個程式設計師,ta的程式設計能力很強,但是程式設計這個動作是需要需求來驅動,如果溝通過程中不夠順暢,很片段化,那麼整個開發進度就會被溝通成本拖慢。同樣,在計算器系統中也存在這樣的問題。假如有一個程式是列印一個“hello world”,hello程式的機器指令最初是存放在磁碟上,當程式載入時,它們被複制到主存,當處理器要執行程式時,指令又被複制到處理器。這其中,CPU從主存讀取到暫存器的速度大概是從磁碟讀取到主存的1000萬倍,從暫存器讀取的速度大概是主存的100倍。所以就有了後來的多核處理器,也就是多個cpu組合在一起,這樣才能同時做更多的事情。

四、什麼是並行程式

1. 程序

程序是處理器對自己的抽象,它由程式加資料

兩部分組成,程序在建立時候會在暫存器、CPU快取記憶體(如果有的話)、主存中載入需要的資料和程式本身(指令集合),主存的資料是每個程序獨有的,程式是可以多個程序共享的。在單核處理器系統中,同一時刻只能有一個程序在執行,但是CPU切換程序的速度特別快,導致大家以為程式都是同時執行的,這個叫做偽併發

人們通常能夠感知到的時間大概是以秒為單位的,但現在CPU的時鐘週期已經遠遠超過了人們的感知尺度,比如一個1GHZ的CPU在一秒內能有10的9次方個時鐘週期,如果三個時鐘週期能執行一個指令,那麼在一秒內CPU就能執行大約3億個指令,很顯然在這種速度下人們就會誤以為程式是同時執行的了。

2. 執行緒

執行緒是在程序之上的抽象,一個程序可以有多個執行緒,一個執行緒必須屬於某個程序。執行緒可以共享程序的資料,可以把程序理解為一個主執行緒。當主執行緒被譬如磁碟IO之類的進入阻塞狀態後,可以再建立一個執行緒來做其他的操作,這就是上面所說的併發。特別是在網際網路伺服器的程序上,執行緒發揮了很大的作用,因為很多的請求都不是CPU密集型的,都是IO密集型的。

3. 超執行緒技術

為了讓單核CPU能夠並行執行程式,CPU製造廠商發明了超執行緒技術。顧名思義,並行就是同一時間做很多事情。比如人在聽音樂(是指耳朵)的同時可以擺動身體(手腳等),因為它們都是我們身體的不同部分,所以可以同時執行(暫且用這個詞表達。手動狗頭)。CPU也是一樣,它可以分析指令用到了哪些執行單元,比如一條指令用到了加法器另一條用到了浮點數計算器,那麼就可以同時讓這兩條指令執行。所以大家在買CPU的時候要小心了,需要區分什麼是兩核四執行緒和四核CPU。

4. 多核處理器系統

多核CPU大家應該通過上文可以猜到了,它就是將多個CPU整合到一起的一個“CPU”。擁有多核CPU的作業系統就會有並行的能力了。並行就是同時能處理多個任務,比如蓋房子的時候,CPU的一個核心就代表一個工人,增加工人就代表著並行搬磚或砌磚的能力。

只有多核處理器才能同時執行多個程序,同時執行程序數等於核心數。多核處理器系統特別適合軟體演算法中的分治法,將一個規模很大的問題分解為若干個小問題,通過並行解決這些小問題,來解決大問題。隨著現在網際網路使用者越來越多,高併發的場景也就越密集,多核cpu能支撐單核cpu所不能承載的高併發訪問。而且多核處理器可以避免一些執行緒的上下文切換開銷,在第六節會講到。

五、阿姆達爾定律

從摩爾定律提升cpu運算能力的時代,到多核高併發的時代,出來了一個新的概念,叫阿姆達爾定律(Amdahl's law,由 Gene Amdahl 於1967年提出)。它揭示了一個問題和一個預測公式,問題就是:有些程式並不是增加cpu核心數就可以讓它的執行速度成比例增長的,有時甚至是完全沒有作用的。就比如,你可以通過增加工人來加快造房子的速度,但你無法通過增加設計師來加快設計房屋圖紙的速度。因為很多程式在本質上還是序列的,如果不把序列這部分改為並行程式,將無法利用多核處理器的優勢。

預測公式是指:在增加CPU核心的情況下,程式在理論上能夠達到的加速比,它的公式如下:

Speedup= 1/(F+(1−F)/N)

其中Speedup代表加速比,F代表程式的序列部分,N代表CPU核心數。在N接近於無窮大時,最大加速比接近於1/F,假設F為50%,那麼加速比最多為2(不管多少個CPU核心)。因此需要將程式中序列的部分減少,來利用多處理器的優勢。

在阿姆達爾定律的作用下,程式要不僅要提升單執行緒程式的效能,還要提高程式的可伸縮性,可伸縮性就是指程式在提升計算機資源(CPU核心數、記憶體、IO頻寬等)的情況下,吞吐量或者處理能力能否相應的增加。可伸縮性在併發程式設計中尤為重要,因為很多程式都還是序列的。

不僅要優化邏輯上的序列,還要儘量避免在訪問共享區域時使用同步,將同步鎖分解、分段,如果可以的話應該使用CAS(compare and swap)。這些技術能有效地降低執行緒在訪問共享區域時的序列動作。

有一個最快衡量程式並行度的方法,那就是檢視伺服器的CPU利用率,如果不是你們業務量特別低的話,CPU的利用率應該長期處於接近於滿載的狀態才是最好的並行程式(雖然並不能由此得出你們的應用程式效率有多低,但是至少知道CPU能力沒有被完整開發)。

六、執行緒實現的模型

實現執行緒有兩種方式,一種是核心執行緒、一種是使用者執行緒。

1、核心執行緒

核心執行緒就是在作業系統核心態實現的執行緒,是受作業系統直接管理的執行緒模型。Java的Hot spot虛擬機器就是用的這種執行緒模型,一個輕量級執行緒輕量級執行緒(lightweight process)是專門用來對應核心執行緒的使用者態執行緒)對應一個核心執行緒,1:1的關係。在這種模型下,執行緒排程、上下文切換都是由核心來完成的,Hot spot虛擬機器只需要呼叫作業系統原語即可。

它的缺點就在於執行緒上下文切換。由於執行緒是跟程序共享資料的,所以沒有虛擬記憶體的資料需要儲存。執行緒上下文的內容就是:程式計數器: 指令序列中下一條指令的位置、暫存器中的運算元:運算中的變數的值。

通常中斷一個執行緒並執行另一個執行緒時,需要將當前執行緒上下文存在主存,然後把下一個要執行的執行緒的上下文恢復到cpu的暫存器中,並且讓cpu從程式計數器處繼續執行。這些過程會讓CPU重複無效勞動,如果過於頻繁的話,就會降低程式的吞吐量。

只要執行緒的數量超過了CPU的核心數,就會發生上下文切換。有些執行緒池比如Java的ForkJoinPool,就會預設執行緒數為CPU可用核心數,防止執行緒上下文切換的開銷。

2、使用者執行緒

使用者執行緒是指在使用者態的程序自己實現執行緒的管理、排程演算法等。它採用1:M的模式,即一個輕量級執行緒多個使用者執行緒。因為核心不知道使用者執行緒的存在,所以核心只能排程核心執行緒。這種實現模式就是靈活,但缺點也很明顯,排程演算法實現很複雜,並且這種模型無法利用多核CPU的優勢。

七、Golang的執行緒模型-協程

Golang作為專為併發而生的程式語言,它原生支援併發程式設計,最大限度的使易於編寫和高併發性融合在一起。第六節我們講到,核心執行緒和使用者執行緒的實現模型分別是1:1和1:M,它們各有優缺點。而Golang使用的是M:N模型,也就是多個核心執行緒對應多個使用者執行緒,如下圖所示(LWP是輕量級執行緒的簡寫

Go會使用M個核心執行緒來支撐N的名叫goroutine的協程,由於核心執行緒可以由核心排程,就充分利用了多核CPU的優勢,而且goroutine的排程演算法是由Go來實現的,所以能夠減少核心執行緒在高併發時頻繁的上下文切換問題。

Goroutine非常輕量級,linux作業系統執行緒的上限是1024個,用滿時會佔用記憶體1個G。一個執行緒大概需要1MB的棧,而協程可能只需要不到1kb,如果按1G的記憶體算的話,goroutine能建立大約10萬個。最重要的是goroutine的排程完全是GO自己實現的機制,它可以在某個goroutine被IO阻塞時,將CPU資源分配給其他協程,再加上使用者態的上下文切換,CPU浪費被大大優化。

八. Java的解決方案

golang的協程主要解決了兩個大問題,一是:IO等待讓CPU處於閒置狀態不作為,二是:核心執行緒頻繁的上下文切換導致CPU做無用功。對於這兩個問題,Java早就有了自己的解決方案。這些解決方案有些是受到作業系統、Java虛擬機器甚至是硬體級別支援的。

1. NIO

NIO(Non-blocking-I/O)就跟它的名字一樣,非阻塞IO。跟BIO(Blocking I/O)不同的是,它在接收資料的Buffer處不會發生阻塞,而是通過一個select的系統呼叫來等待某些Buffer裡資料的到來,當資料到來時select會通知等待的執行緒來處理,也不用執行緒一直輪詢呼叫系統函式來查詢有沒有資料,屬於多路複用I/O。這樣實現的效果就是,執行緒不會因為某個客戶端不傳送訊息就阻塞在那裡不做其他的工作,還可以接收其他客戶端發來的資料,從而提高併發性。

2. 自旋鎖

除了核心排程演算法強制拿走CPU執行權,Java併發程式效能受影響的部分主要就是執行緒在訪問記憶體共享區的同步操作了。所以Java引入了CAS這種處理器級別的原子操作原語,使用這個原語修改共享資料時不會發生阻塞,而是以測試的方式來判斷是否可修改,不能修改就迴圈嘗試,直到可修改為止。

在Java的java.util.concurrent.atomic下有很多原子變數類,可以實現原子的共享資料訪問。而且不止是在編碼層面,在虛擬機器層面也會統計獲取鎖的時間,如果很短的話也會優化成自旋操作。這麼做好處就是不會頻繁的上下文切換,但是會佔用CPU時間片。

3. 鎖消除、鎖粗化

Hot spot虛擬機器的JIT編譯器會自動優化一些不必要的鎖獲取操作,比如一個引用沒有逃逸出方法的StringBuffer的連續append操作,實際上可以不用鎖。鎖粗化就是指有些地方連續不必要的加鎖解鎖,就算不存線上程競爭,這種操作會導致效能更低下。於是虛擬機器就會把這些鎖粗化到包含它們。

4. 鎖升級

Jdk1.6後增加新的鎖優化機制,分別是偏向鎖、輕量級鎖。也就是說,在某個需要同步的物件上,被第一個執行緒加鎖時,會使用偏向鎖模式,如果後面沒有其他執行緒競爭過這個物件上的鎖,第一個執行緒下次再進入臨界區的時候就不會再加鎖。當虛擬機發現有其他執行緒競爭時,就會升級為輕量級鎖。輕量級鎖是用CAS操作來嘗試進入臨界區的,如果執行緒獲取鎖的操作是斷斷續續的,輕量級鎖就會繼續下去。但是如果某個執行緒在持有鎖時,被其他執行緒發現了,就會升級成重量級鎖。重量級鎖就會在每次拿不到鎖時阻塞並導致上下文切換了。

5. 纖程

纖程(Fiber)是一個實驗中的專案,目的也是要做到和Golang的協程一樣做自己排程的混合型執行緒,它屬於OpenJDK在2018年開始的Loom專案。


總結

軟體開發沒有銀彈,這是軟體界有名的書《人月神話》的作者說的。所謂的沒有銀彈是指沒有任何一項技術或方法可使軟體工程的生產力在十年內提高十倍。Golang在近幾年的開發者和金融領域的應用確實越來越多,但是Java的整個生態和多年的優化還是不可替代的,特別是還在實驗室的一些專案比如Graal VM、Graal編譯器、Loom專案都非常有潛力。不管是軟體優化還是架構,都沒有一套完整而又高效能的框架,是需要一步一步迭代出來,沒有最適合的只有更適合的。最後,編寫完這篇文章的時間正好是2021年10月24日,1024程式設計師節,祝各位程式設計師朋友節日快樂!