《技術男征服美女HR》—Fiber、Coroutine和多執行緒那些事

太白上仙發表於2020-12-04

img

1、起點

我叫小白,坐在這間屬於華夏國超一流網際網路公司企鵝巴巴的小會議室裡,等著技術面試官的到來。

令我感到不舒服的,是坐在我對面的那位HR美女一個勁兒的盯著我打量!雖說本人帥氣,但是也不能這麼毫無顧忌的看我吧!

我正要回懟的時候,外面傳來了一個懶懶的聲音:“一個實習生我來就夠了吧,什麼實習生需要兩個P20的科學家來面試啊”。

話音剛落,一個穿著灰色西服的大伯路人S和一個穿著黑色西服的大叔路人B進了會議室。逗我吧?企鵝巴巴有P20這個職級?我也不知道他們是不是P20的,所以偷拍了他們兩個的樣子,你們看看我被騙了麼?

img

我心中一萬句MMP飄過,這是看我長得帥所以面試格外對待?要不是這兩天的際遇讓我依然處於恍惚之中,我早就溜了!

路人S向美女HR點頭示意一番後,坐到了我的對面,立刻對我形成了等級壓制!而路人B看到了美女HR一陣錯愕,好久了才拘謹的坐下來!真是沒有定力的人呢!

2、突如其來的面試

Round 1

科學家路人S:小夥子我看你簡歷上什麼也沒寫,這次也是第一面,那我們就隨便問點簡單的多執行緒問題吧。先說說什麼是Java的多執行緒吧,使用多執行緒有什麼好處?有什麼壞處?

媽媽說專家的話不能信!果然,問個多執行緒還問好處壞處?我不想用不會用能進企鵝巴巴麼?

但是作為打工人,我認真的回答道:Java的多執行緒是指程式中包含多個執行流,即在一個程式中可以同時執行多個不同的執行緒來執行不同的任務。

而使用多執行緒的好處是可以提高 CPU 的利用率。在多執行緒程式中,一個執行緒必須等待的時候,CPU 可以執行其它的執行緒而不是等待,這樣就大大提高了程式的效率。也就是說允許單個程式建立多個並行執行的執行緒來完成各自的任務。

至於多執行緒的壞處麼,主要有三點。第一點是執行緒也是程式,所以執行緒需要佔用記憶體,執行緒越多佔用記憶體也越多;第二點是多執行緒需要協調和管理,所以需要 CPU 時間跟蹤執行緒;最後是執行緒之間對共享資源的訪問會相互影響,必須解決競用共享資源的問題。

Round 2

科學家路人S繼續追問:你剛才講了“並行”這個詞,那你說說並行和併發有什麼區別?

併發,英文單詞是concurrency,就是多個任務在同一個 CPU 核上,按細分的時間片輪流(交替)執行,從邏輯上來看那些任務是同時執行。

並行,英文單詞是parallelism,就是單位時間內,多個處理器或多核處理器同時處理多個任務,是真正意義上的“同時進行”。

這兩句話,我相信99%的同學都知道!但是,如果想進企鵝巴巴,如果想應付P20的科學家!我就一定要自行的結合業務回答併發並行的優勢!

現在的系統動不動就要求百萬級甚至千萬級的併發量,而多執行緒併發程式設計正是開發高併發系統的基礎,利用好多執行緒機制可以大大提高系統整體的併發能力以及效能。面對複雜業務模型,並行程式會比序列程式更適應業務需求,而併發程式設計更能吻合這種業務拆分 。

Round 3

路人S和路人B果然都露出了滿意的笑容
路人B開始追問道:那你說說看,在作業系統中使用者級執行緒和核心級執行緒是什麼?這兩個執行緒在多核CPU的計算機上是否都能並行?

在作業系統的設計中,為了防止使用者操作敏感指令而對OS帶來安全隱患,我們把OS分成了使用者空間(user space)和核心空間(kernel space)。

通過使用者空間的庫類實現的執行緒,就是使用者級執行緒(user-level threads,ULT)。這種執行緒不依賴於作業系統核心,程式利用執行緒庫提供建立、同步、排程和管理執行緒的函式來控制使用者執行緒。

說著,我拿了一支筆,畫了這麼一張圖:

img在圖裡,我們可以清楚的看到,執行緒表(管理執行緒的資料結構)是處於程式內部的,完全處於使用者空間層面,核心空間對此一無所知!當然,使用者執行緒也可以沒有執行緒表!

相應的,由OS核心空間直接掌控的執行緒,稱為核心級執行緒(kernel-level threads,KLT)。其依賴於作業系統核心,由核心的內部需求進行建立和撤銷。接著,我畫下了這張圖:

img

同樣的,在圖中,我們看到核心執行緒的執行緒表(thread table)位於核心中,包括了執行緒控制塊(TCB),一旦執行緒阻塞,核心會從當前或者其他程式(process)中重新選擇一個執行緒保證程式的執行。

對於使用者級執行緒來說,其執行緒的切換髮生在使用者空間,這樣的執行緒切換至少比陷入核心要快一個數量級。但是該種執行緒有個嚴重的缺點:如果一個執行緒開始執行,那麼該程式中其他執行緒就不能執行,除非第一個執行緒自動放棄CPU。因為在一個單獨的程式內部,沒有時鐘中斷,所以不能用輪轉排程(輪流)的方式排程執行緒。

也就是說,同一程式中的使用者級執行緒,在不考慮調起多個核心級執行緒的基礎上,是沒有辦法利用多核CPU的,其實質是併發而非並行

對於核心級執行緒來說,其執行緒在核心中建立和撤銷執行緒的開銷比較大,需要考慮上下文切換的開銷。

但是,核心級執行緒是可以利用多核CPU的,即可以並行

這回答的累死我了,不過為了能進企鵝巴巴,走向人生巔峰,一切都值了!

Round 4

路人B點了點頭說:嗯,小夥子基礎還是比較牢靠的!那你說說Java裡的多執行緒是使用者級執行緒還是核心級執行緒呢?

是...當我要脫口而出的時候,發現不對,這面試官在套路我!堂堂科學家,套路還沒入職的孩子麼?

img

Java裡的多執行緒,既不是使用者級執行緒,也不是核心級執行緒!

首先,Java是跨操作平臺的語言,是使用JVM去執行編譯檔案的。不同的JVM對執行緒的實現不同,相同的JVM對不同操作平臺的執行緒實現方式也有區別!

其次,要講明白程式級別實現多執行緒,就必須先說一下多執行緒模型。

裂開!怎麼感覺這又是一道大題啊!B是作業系統的科學家吧!感覺問的都是很底層的東西了啊,現在程式設計師內捲成這樣了麼?實習生都問這麼底層的問題了?雖然百般不爽,但是為了拿下美女HR,不!是橫掃offer。我要給路人B講明白這個執行緒模型!

上面我說過OS上的執行緒分為ULT和KLT,我們寫程式的程式碼只能是在使用者空間裡寫程式碼!而程式執行中,基本上都會進入核心執行,所以我們在實現程式級別多執行緒的時候,必須讓ULT對映到KLT上去。在程式級別的多執行緒設計裡,有以下三種多執行緒模型。

多對1模型:在多對一模型中,多個ULT對映到1個KLT上去。此時ULT的程式表處於程式之中。

img

1對1模型:在一對一模型中,1個ULT對應1個KLT。自己不在程式中建立執行緒表來管理,幾行程式碼之後直接通過系統呼叫調起KLT就能實現。

img

多對多模型:在多對多模型中,N個ULT對應小於等於N個的KLT。這種模型結合了1對1和多對1的優點,使用者建立執行緒沒有限制,阻塞核心系統的命令不會阻塞整個程式。

img

最後,就拿最熱門的HotSpot VM來說吧,他在Solaris上就有兩種執行緒實現方式,可以讓使用者選擇一對一或多對多這兩種模型;而在Windows和Linux下,使用的都是一對一的多執行緒模型,Java的執行緒通過一一對映到Light Weight Process(輕量級程式,LWP)從而實現了和KLT的一一對應。

Round 5

路人B聽到這個回答,眼睛都亮了!直接追問道:ULT如何對映到KLT?怎麼調起的?

ULT在執行的過程中,如果執行的指令需要進入核心態,則ULT會通過系統呼叫調起一個KLT!

所謂系統排程,就是在OS中分割使用者空間和核心空間的API。

Round 6

路人B繼續追問道:ULT的執行過程中可以不調起KLT麼?舉個例子。

可以不調起,比如ULT中就只有sleep這個指令,就不會進入核心態執行,更不會調起KLT。

問到這裡,我有點吐血了都!看著B對我的回答很滿意,我心中卻把B已經問候了一百遍!

Round 7

路人S總算接過了話題:看來同學對於底層的知識理解還湊合,那你有沒有看過HotSpot的原始碼?能不能簡單說說看Java的執行緒是怎麼執行的?

這問的還上癮了?P20的問題咋這麼“簡單”呢!說實話,自從前幾天發生了靈異事件之後,我確實技術突飛猛進,這個原始碼我好像還真的瞄了一眼,不過我不能暴露自己擁有金手指的祕密啊!

於是我撓了撓頭,思考了1分鐘,然後說道:原始碼以前看過,只能記得一個大概。

1、在Java中,使用java.lang.Thread的構造方法來構建一個java.lang.Thread物件,此時只是對這個物件的部分欄位(例如執行緒名,優先順序等)進行初始化;

2、呼叫java.lang.Thread物件的start()方法,開始此執行緒。此時,在start()方法內部,呼叫start0() 本地方法來開始此執行緒;

3、start0()在VM中對應的是JVM_StartThread,也就是,在VM中,實際執行的是JVM_StartThread方法(巨集),在這個方法中,建立了一個JavaThread物件;

4、在JavaThread物件的建立過程中,會根據執行平臺建立一個對應的OSThread物件,且JavaThread保持這個OSThread物件的引用;

5、在OSThread物件的建立過程中,建立一個平臺相關的底層級執行緒,如果這個底層級執行緒失敗,那麼就丟擲異常;

6、在正常情況下,這個底層級的執行緒開始執行,並執行java.lang.Thread物件的run方法;

7、當java.lang.Thread生成的Object的run()方法執行完畢返回後,或者丟擲異常終止後,終止native thread;

8、最後就是釋放相關的資源(包括記憶體、鎖等)

大概就是以上這麼個步驟吧。

回答完這個,我要跪謝我的金手指了!我看見路人S在電腦上敲著什麼,估計他也比較懵,沒想到我居然能答得上來吧!

img

Round 8

路人S對此不置可否,說道:那你說說什麼是上下文切換吧。

多執行緒程式設計中一般執行緒的個數都大於 CPU 核心的個數,而一個 CPU 核心在任意時刻只能被一個執行緒使用,為了讓這些執行緒都能得到有效執行,CPU 採取的策略是為每個執行緒分配時間片並輪轉的形式。

時間片是CPU分配給各個執行緒的時間,因為時間非常短,所以CPU不斷通過切換執行緒,讓我們覺得多個執行緒是同時執行的,時間片一般是幾十毫秒。

當一個執行緒的時間片用完的時候就會重新處於就緒狀態讓給其他執行緒使用,這個過程就屬於一次上下文切換。

概括來說就是:當前任務在執行完 CPU 時間片切換到另一個任務之前會先儲存自己的狀態,以便下次再切換回這個任務時,可以再載入這個任務的狀態。任務從儲存到再載入的過程就是一次上下文切換

Round 9

路人S繼續問道:頻繁切換上下文會有什麼問題?

上下文切換通常是計算密集型的,每次切換時,需要儲存當前的狀態起來,以便能夠進行恢復先前狀態,而這個切換時非常損耗效能。

也就是說,它需要相當可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統來說意味著消耗大量的 CPU 時間,事實上,可能是作業系統中時間消耗最大的操作。

Linux 相比與其他作業系統(包括其他類 Unix 系統)有很多的優點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。

Round 10

S繼續問:減少上下文切換的方式有哪些?

通常減少上下文切換的方式有:

1、無鎖併發程式設計:可以參照concurrentHashMap鎖分段的思想,不同的執行緒處理不同段的資料,這樣在多執行緒競爭的條件下,可以減少上下文切換的時間。

2、CAS演算法:利用Atomic下使用CAS演算法來更新資料,使用了樂觀鎖,可以有效的減少一部分不必要的鎖競爭帶來的上下文切換。

3、使用最少執行緒:避免建立不需要的執行緒,比如任務很少,但是建立了很多的執行緒,這樣會造成大量的執行緒都處於等待狀態。

4、協程:在單執行緒裡實現多工的排程,並在單執行緒裡維持多個任務間的切換。

Round 11

路人B聽了,眼睛一亮,立刻追問道:協程是什麼?和使用者執行緒有什麼區別?

我聽了真想抽自己幾個嘴巴子,怎麼又來了!B是隻會OS吧!

img

協程的英文單詞是Coroutine,這是一個程式元件,它既不是執行緒也不是程式。它的執行過程更類似於一個方法,或者說不帶返回值的函式呼叫。

我看到過stack overflow和很多部落格裡,都認為這兩者是一個東西。但是,在我的理解中,這兩者還是有區別的。

不可否認的是,協程和ULT做的是同一個事情。所以從某種角度上講,他們確實是等價的!

但是,ULT這個概念被提出的時候,其背後的思想本質是講ULT是個本機執行緒,也就是使用了OS的使用者空間內提供的庫類直接建立的執行緒。這個時候,你不需要在OS上面新增一些其他第三方的庫類。

而協程這個概念是康威定律的提出者Melvin Edward Conway在1958年提出的一個概念,其背後的思想是不直接使用OS本身的庫類,自己做一些庫類去實現併發。在那個年代,OS上面的第三方庫類並不像現在這麼流行,OS本身的庫類和其他第三方庫類的結合也並不像今天這麼容易。所以協程並不是本機執行緒,他是需要藉助一些其他不屬於OS的第三方庫類呼叫OS使用者空間的庫類來實現達到ULT的效果。

當然,這個概念在今天來看,就會顯得很讓人混淆了。因為到底哪些庫類算是OS本機的庫類,哪些算是第三方庫類?這和1960年的時候已經有絕大的區別了!所以大家認為這兩者是一個東西,其實也不能說他說的不對,只能說可能對這個思想本身背後代表的東西不明白。

Round 12

路人B聽了,立刻坐直了身體,繼續追問道:那你知道fiber麼?這個和上面兩個名詞有什麼區別?

fiber也是一種本機執行緒,其本質是一種特殊的ULT,即更輕量級的ULT。說白了就是這種ULT的執行緒表一定存於程式之中

而我們在構建一對一多執行緒模型的時候,ULT的執行緒表其實還是交給核心了!這是兩者之間最直接的差別。所以我們經常稱fiber就是協同排程的ULT,在win32中可以呼叫fiber來構建多對多的多執行緒模型。

其實,fiber、coroutine和ULT在使用者層面能看到的效果是基本等價的。

其中ULT是描述OS庫本身提供的功能;fiber描述的是OS提供的協同排程的ULT;coroutine描述的是第三方實現的併發並行功能。

這些名詞很多都是歷史原因的問題,同時也是深入研究需要了解的事情,我們普通程式設計師在使用的時候,更多的關心的是應用層方面的東西。而這些名詞的理解已經深入到原始碼層了。

Round 13

路人S估計被我秀的腦殼痛了,立刻說道:先不說歷史問題了,還是講講看在 Java 程式中怎麼保證多執行緒的執行安全吧。

Java的執行緒安全在三個方面體現:

原子性:提供互斥訪問,同一時刻只能有一個執行緒對資料進行操作,在Java中使用了atomic和synchronized這兩個關鍵字來確保原子性;

可見性:一個執行緒對主記憶體的修改可以及時地被其他執行緒看到,在Java中使用了synchronized和volatile這兩個關鍵字確保可見性;

有序性:一個執行緒觀察其他執行緒中的指令執行順序,由於指令重排序,該觀察結果一般雜亂無序,在Java中使用了happens-before原則來確保有序性。

Round 14

路人S繼續問道:你剛才講了有序性,那你說說程式碼為什麼會重排序?

在執行程式時,為了提高效能,處理器和編譯器常常會對指令進行重排序。

Round 15

路人S繼續追問:重排序是想怎麼重排就重排麼?

這面試官也很難纏啊,怎麼一直在追問,是需要我給他孝敬一根華子麼?要不是看著旁邊有個美女HR,我早就孝敬S他老人家了!

img

當然不是!不能隨意重排序,不是你想怎麼排序就怎麼排序,它需要滿足以下兩個條件:

1、在單執行緒環境下不能改變程式執行的結果;

2、存在資料依賴關係的不允許重排序。

所以重排序不會對單執行緒有影響,只會破壞多執行緒的執行語義。

Round 16

路人S繼續追問道:那你講講看在Java中如何保障重排序不影響單執行緒的吧。

保障這一結果是因為在編譯器,runtime 和處理器都必須遵守as-if-serial語義規則。

為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關係,這些操作可能被編譯器和處理器重排序。

我來舉個例子吧,說著我拿著筆在紙上寫了三行簡單的程式碼:

img

我們看這個例子,A和C之間存在資料依賴關係,同時B和C之間也存在資料依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面,如果C排到A和B的前面,那麼程式的結果將會被改變。但A和B之間沒有資料依賴關係,編譯器和處理器可以重排序A和B之間的執行順序。

這就是as-if-serial語義。

Round 17

路人S繼續問道:那你說說看你剛才講的happens-before原則吧。

happens-before說白了就是誰在誰前面發生的一個關係。

HB規則是Java記憶體模型(JMM)向程式設計師提供的跨執行緒記憶體可見性保證。

說的直白一點,就是如果A執行緒的寫操作a與B執行緒的讀操作b之間存在happens-before關係,儘管a操作和b操作在不同的執行緒中執行,但JMM向程式設計師保證a操作將對b操作可見。

具體的定義為:

1、如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

2、兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法。

具體的規則有8條:

1、程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。

2、監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

3、volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。

4、傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

5、start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。

6、Join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。

7、程式中斷規則:對執行緒interrupted()方法的呼叫先行於被中斷執行緒的程式碼檢測到中斷時間的發生。

8、物件finalize規則:一個物件的初始化完成(建構函式執行結束)先行於發生它的finalize()方法的開始。

Round 18

路人S接著追問:你剛才說HB規則不代表最終的執行順序,能不能舉個例子。

就拿講as-if-serial提到的例子舉例吧,例子很簡單就是面積=寬*高。

利用HB的程式順序規則,存在三個happens-before關係:

  1. A happens-before B;
  2. B happens-before C;
  3. A happens-before C。

這裡的第三個關係是利用傳遞性進行推論的。這裡的第三個關係是利用傳遞性進行推論的。

A happens-before B,定義1要求A執行結果對B可見,並且A操作的執行順序在B操作之前;但與此同時利用HB定義中的第二條,A、B操作彼此不存在資料依賴性,兩個操作的執行順序對最終結果都不會產生影響。

在不改變最終結果的前提下,允許A,B兩個操作重排序,即happens-before關係並不代表了最終的執行順序。

Round End

“沒有想到一個三本學歷沒有什麼專案經驗的人,居然能知道這麼多!怪不得讓我們企鵝巴巴董事長的千金給你破例安排了面試!”路人A說道!

“那我是不是可以入職企鵝巴巴了?”我激動得說道!

img

“不可能!”,路人A和B還沒有表態,美女HR立刻凶巴巴的說道!等等,剛才路人S說她就是企鵝巴巴董事長的女兒?傳說中未來要成為企鵝巴巴的女總裁的——白仙仙?

看著大家都看向自己,白仙仙矜持的拿捏了一下,說道:“我們企鵝巴巴哪裡有一輪面試就過關的?如果想在企鵝巴巴當程式設計師,至少需要再多面幾輪才行的!你呢~先回去等下次面試的通知吧!”

說著,不由分說的拉著兩位P20的科學家走開了。

3、回憶

我有點恍惚,其實今天能來面試,和這幾天發生的各種靈異事件是分不開的!而這一切,都要從頭說起。

我叫小白,一個三本計算機專業畢業的普通大學生。2020年疫情來臨之後,網際網路技術崗的內卷化就急劇凸顯出來,而我作為一名三本的學生,毫無意外的沒找到工作。

正當我面試又一次失敗後站在公交車站思考人生的時候,一個老頭過來拍了我兩下,說道:“小夥子,我看你骨骼清奇,是個...”。我立刻跳開說道:“我沒錢,別訛我!”

那老頭哼了一聲,說道:“哼,這年頭的人都這麼難忽悠麼,算了,時間已經不早了,我就便宜你了!”說完也沒等我有所反應,只感覺眼前一花,一道金光向我襲來,嚇得我大叫一聲。

周圍的人紛紛向我看來,而我翻回頭去看,哪裡有什麼老頭?感覺到人們看神經病的目光,我也感覺可能是我壓力太大出現了幻覺。

可惡的是非但有幻覺,居然還有幻聽在我耳邊迴盪:“老夫太白上仙,傳百分之一技術感悟給你;作為回報,你就是老夫的試驗品。命已改,世界rebuild!”

雖然我窮的褲兜裡一分錢沒有,但是為了我的身體健康,我覺得要去醫院好好的檢查一下。而且看周圍人看我神經病的眼神,我頭一暈,腦一熱,決定奢侈一把——刷卡使用了昂貴的共享單車,向著企鵝巴巴附屬醫院騎去。

作者的話

大家好,我是太白上仙。下一章就開始緣起部分了,會介紹小白一個三本的學生如何能參加企鵝巴巴的面試的。文章會以小說的形式推進,採取故事+面試的方式,希望大家在娛樂中學習,希望這種題材能得到大家的喜歡。

在文章中,我會不時的寫一些其他地方找不到的回答,有的甚至在wiki上面都不會有明確的描述,希望每個認真閱讀的人都會有收穫,就當是我給予讀者們的小驚喜吧!

如果喜歡太白上仙,請關注公眾號:【太白上仙】
相關作品彙總請移步github

相關文章