天天看點

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

1、起點

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

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

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

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

我心中一萬句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)。這種線程不依賴于作業系統核心,程序利用線程庫提供建立、同步、排程和管理線程的函數來控制使用者線程。

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

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

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

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

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

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

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

但是,核心級線程是可以利用多核CPU的,即可以并行!

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

Round 4

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

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

Java裡的多線程,既不是使用者級線程,也不是核心級線程!

首先,Java是跨操作平台的語言,是使用JVM去運作編譯檔案的。不同的JVM對線程的實作不同,相同的JVM對不同操作平台的線程實作方式也有差別!

其次,要講明白程式級别實作多線程,就必須先說一下多線程模型。

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

上面我說過OS上的線程分為ULT和KLT,我們寫程式的代碼隻能是在使用者空間裡寫代碼!而程式運作中,基本上都會進入核心運作,是以我們在實作程式級别多線程的時候,必須讓ULT映射到KLT上去。在程式級别的多線程設計裡,有以下三種多線程模型。

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

1對1模型:在一對一模型中,1個ULT對應1個KLT。自己不在程序中建立線程表來管理,幾行代碼之後直接通過系統調用調起KLT就能實作。

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

最後,就拿最熱門的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在電腦上敲着什麼,估計他也比較懵,沒想到我居然能答得上來吧!

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

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吧!

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

協程的英文單詞是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他老人家了!

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

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

1、在單線程環境下不能改變程式運作的結果;

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

是以重排序不會對單線程有影響,隻會破壞多線程的執行語義。

Round 16

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

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

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

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

我們看這個例子,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說道!

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

《技術男征服美女HR》——Fiber、Coroutine和多線程那些事1、起點2、突如其來的面試3、回憶作者的話

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

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

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

3、回憶

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

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

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

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

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

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

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

作者的話

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

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

如果喜歡太白上仙,請關注公衆号:【太白上仙】

相關作品彙總請移步github