併發面試必備系列之進程、線程與協程

座標上海松江高科技園,誠聘高級前端工程師/高級 Java 工程師,有興趣的看 JD:https://www.lagou.com/jobs/63...

併發面試必備系列之進程、線程與協程

《Awesome Interviews》 概括的常見面試題中,不管先後端,併發與異步的相關知識都是面試的中重中之重,《併發編程》系列即對於面試中常見的併發知識再進行回顧總結;你也能夠前往 《Awesome Interviews》,在實際的面試題考校中瞭解本身的掌握程度。也能夠前往《Java 實戰》、《Go 實戰》等了解具體編程語言中的併發編程的相關知識。javascript

在未配置 OS 的系統中,程序的執行方式是順序執行,即必須在一個程序執行完後,才容許另外一個程序執行;在多道程序環境下,則容許多個程序併發執行。程序的這兩種執行方式間有着顯著的不一樣。也正是程序併發執行時的這種特徵,才致使了在操做系統中引入進程的概念。進程是資源分配的基本單位,線程是資源調度的基本單位html

應用啓動體現的就是靜態指令加載進內存,進而進入 CPU 運算,操做系統在內存開闢了一段棧內存用來存放指令和變量值,從而造成了進程。早期的操做系統基於進程來調度 CPU,不一樣進程間是不共享內存空間的,因此進程要作任務切換就要切換內存映射地址。因爲進程的上下文關聯的變量,引用,計數器等現場數據佔用了打段的內存空間,因此頻繁切換進程須要整理一大段內存空間來保存未執行完的進程現場,等下次輪到 CPU 時間片再恢復現場進行運算。前端

這樣既耗費時間又浪費空間,因此咱們纔要研究多線程。一個進程建立的全部線程,都是共享一個內存空間的,因此線程作任務切換成本就很低了。現代的操做系統都基於更輕量的線程來調度,如今咱們提到的「任務切換」都是指「線程切換」。java

進程與線程

本部分節選自 《Linux 與操做系統/進程管理》

在未配置 OS 的系統中,程序的執行方式是順序執行,即必須在一個程序執行完後,才容許另外一個程序執行;在多道程序環境下,則容許多個程序併發執行。程序的這兩種執行方式間有着顯著的不一樣。也正是程序併發執行時的這種特徵,才致使了在操做系統中引入進程的概念。進程是資源分配的基本單位,線程是資源調度的基本單位node

進程(Process)

進程是操做系統對一個正在運行的程序的一種抽象,在一個系統上能夠同時運行多個進程,而每一個進程都好像在獨佔地使用硬件。所謂的併發運行,則是說一個進程的指令和另外一個進程的指令是交錯執行的。不管是在單核仍是多核系統中,能夠經過處理器在進程間切換,來實現單個 CPU 看上去像是在併發地執行多個進程。操做系統實現這種交錯執行的機制稱爲上下文切換。python

操做系統保持跟蹤進程運行所需的全部狀態信息。這種狀態,也就是上下文,它包括許多信息,例如 PC 和寄存器文件的當前值,以及主存的內容。在任何一個時刻,單處理器系統都只能執行一個進程的代碼。當操做系統決定要把控制權從當前進程轉移到某個新進程時,就會進行上下文切換,即保存當前進程的上下文、恢復新進程的上下文,而後將控制權傳遞到新進程。新進程就會從上次中止的地方開始。git

image

虛擬存儲管理一節中,咱們介紹過它爲每一個進程提供了一個假象,即每一個進程都在獨佔地使用主存。每一個進程看到的是一致的存儲器,稱爲虛擬地址空間。其虛擬地址空間最上面的區域是爲操做系統中的代碼和數據保留的,這對全部進程來講都是同樣的;地址空間的底部區域存放用戶進程定義的代碼和數據。程序員

image

  • 程序代碼和數據,對於全部的進程來講,代碼是從同一固定地址開始,直接按照可執行目標文件的內容初始化。
  • 堆,代碼和數據區後緊隨着的是運行時堆。代碼和數據區是在進程一開始運行時就被規定了大小,與此不一樣,當調用如 malloc 和 free 這樣的 C 標準庫函數時,堆能夠在運行時動態地擴展和收縮。
  • 共享庫:大約在地址空間的中間部分是一塊用來存放像 C 標準庫和數學庫這樣共享庫的代碼和數據的區域。
  • 棧,位於用戶虛擬地址空間頂部的是用戶棧,編譯器用它來實現函數調用。和堆同樣,用戶棧在程序執行期間能夠動態地擴展和收縮。
  • 內核虛擬存儲器:內核老是駐留在內存中,是操做系統的一部分。地址空間頂部的區域是爲內核保留的,不容許應用程序讀寫這個區域的內容或者直接調用內核代碼定義的函數。

線程(Thread)

在現代系統中,一個進程實際上能夠由多個稱爲線程的執行單元組成,每一個線程都運行在進程的上下文中,並共享一樣的代碼和全局數據。進程的個體間是徹底獨立的,而線程間是彼此依存的。多進程環境中,任何一個進程的終止,不會影響到其餘進程。而多線程環境中,父線程終止,所有子線程被迫終止(沒有了資源)。github

而任何一個子線程終止通常不會影響其餘線程,除非子線程執行了 exit() 系統調用。任何一個子線程執行 exit(),所有線程同時滅亡。多線程程序中至少有一個主線程,而這個主線程其實就是有 main 函數的進程。它是整個程序的進程,全部線程都是它的子線程;咱們一般把具備多線程的主進程稱之爲主線程web

線程共享的環境包括:進程代碼段、進程的公有數據、進程打開的文件描述符、信號的處理器、進程的當前目錄、進程用戶 ID 與進程組 ID 等,利用這些共享的數據,線程很容易的實現相互之間的通信。線程擁有這許多共性的同時,還擁有本身的個性,並以此實現併發性:

  • 線程 ID:每一個線程都有本身的線程 ID,這個 ID 在本進程中是惟一的。進程用此來標識線程。
  • 寄存器組的值:因爲線程間是併發運行的,每一個線程有本身不一樣的運行線索,當從一個線程切換到另外一個線程上時,必須將原有的線程的寄存器集合的狀態保存,以便 未來該線程在被從新切換到時能得以恢復。
  • 線程的堆棧:堆棧是保證線程獨立運行所必須的。線程函數能夠調用函數,而被調用函數中又是能夠層層嵌套的,因此線程必須擁有本身的函數堆棧, 使得函數調用能夠正常執行,不受其餘線程的影響。
  • 錯誤返回碼:因爲同一個進程中有不少個線程在同時運行,可能某個線程進行系統調用後設置了 errno 值,而在該 線程尚未處理這個錯誤,另一個線程就在此時 被調度器投入運行,這樣錯誤值就有可能被修改。 因此,不一樣的線程應該擁有本身的錯誤返回碼變量。
  • 線程的信號屏蔽碼:因爲每一個線程所感興趣的信號不一樣,因此線程的信號屏蔽碼應該由線程本身管理。但全部的線程都共享一樣的信號處理器。
  • 線程的優先級:因爲線程須要像進程那樣可以被調度,那麼就必需要有可供調度使用的參數,這個參數就是線程的優先級。

image.png

線程模型

線程實如今用戶空間下

當線程在用戶空間下實現時,操做系統對線程的存在一無所知,操做系統只能看到進程,而不能看到線程。全部的線程都是在用戶空間實現。在操做系統看來,每個進程只有一個線程。過去的操做系統大部分是這種實現方式,這種方式的好處之一就是即便操做系統不支持線程,也能夠經過庫函數來支持線程。

在這在模型下,程序員須要本身實現線程的數據結構、建立銷燬和調度維護。也就至關於須要實現一個本身的線程調度內核,而同時這些線程運行在操做系統的一個進程內,最後操做系統直接對進程進行調度。

這樣作有一些優勢,首先就是確實在操做系統中實現了真實的多線程,其次就是線程的調度只是在用戶態,減小了操做系統從內核態到用戶態的切換開銷。這種模式最致命的缺點也是因爲操做系統不知道線程的存在,所以當一個進程中的某一個線程進行系統調用時,好比缺頁中斷而致使線程阻塞,此時操做系統會阻塞整個進程,即便這個進程中其它線程還在工做。還有一個問題是假如進程中一個線程長時間不釋放 CPU,由於用戶空間並無時鐘中斷機制,會致使此進程中的其它線程得不到 CPU 而持續等待。

線程實如今操做系統內核中

內核線程就是直接由操做系統內核(Kernel)支持的線程,這種線程由內核來完成線程切換,內核經過操縱調度器(Scheduler)對線程進行調度,並負責將線程的任務映射到各個處理器上。每一個內核線程能夠視爲內核的一個分身,這樣操做系統就有能力同時處理多件事情,支持多線程的內核就叫作多線程內核(Multi-Threads Kernel)。

程序員直接使用操做系統中已經實現的線程,而線程的建立、銷燬、調度和維護,都是靠操做系統(準確的說是內核)來實現,程序員只須要使用系統調用,而不須要本身設計線程的調度算法和線程對 CPU 資源的搶佔使用。

使用用戶線程加輕量級進程混合實現

在這種混合實現下,即存在用戶線程,也存在輕量級進程。用戶線程仍是徹底創建在用戶空間中,所以用戶線程的建立、切換、析構等操做依然廉價,而且能夠支持大規模的用戶線程併發。而操做系統提供支持的輕量級進程則做爲用戶線程和內核線程之間的橋樑,這樣可使用內核提供的線程調度功能及處理器映射,而且用戶線程的系統調用要經過輕量級進程來完成,大大下降了整個進程被徹底阻塞的風險。在這種混合模式中,用戶線程與輕量級進程的數量比是不定的,即爲 N:M 的關係:

Golang 的協程就是使用了這種模型,在用戶態,協程能快速的切換,避免了線程調度的 CPU 開銷問題,協程至關於線程的線程。

Linux 中的線程

在 Linux 2.4 版之前,線程的實現和管理方式就是徹底按照進程方式實現的;在 Linux 2.6 以前,內核並不支持線程的概念,僅經過輕量級進程(Lightweight Process)模擬線程;輕量級進程是創建在內核之上並由內核支持的用戶線程,它是內核線程的高度抽象,每個輕量級進程都與一個特定的內核線程關聯。內核線程只能由內核管理並像普通進程同樣被調度。這種模型最大的特色是線程調度由內核完成了,而其餘線程操做(同步、取消)等都是核外的線程庫(Linux Thread)函數完成的。

爲了徹底兼容 Posix 標準,Linux 2.6 首先對內核進行了改進,引入了線程組的概念(仍然用輕量級進程表示線程),有了這個概念就能夠將一組線程組織稱爲一個進程,不過內核並無準備特別的調度算法或是定義特別的數據結構來表徵線程;相反,線程僅僅被視爲一個與其餘進程(概念上應該是線程)共享某些資源的進程(概念上應該是線程)。在實現上主要的改變就是在 task_struct 中加入 tgid 字段,這個字段就是用於表示線程組 id 的字段。在用戶線程庫方面,也使用 NPTL 代替 Linux Thread,不一樣調度模型上仍然採用 1 對 1 模型。

進程的實現是調用 fork 系統調用:pid_t fork(void);,線程的實現是調用 clone 系統調用:int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...)。與標準 fork() 相比,線程帶來的開銷很是小,內核無需單獨複製進程的內存空間或文件描寫敘述符等等。這就節省了大量的 CPU 時間,使得線程建立比新進程建立快上十到一百倍,可以大量使用線程而無需太過於操心帶來的 CPU 或內存不足。不管是 fork、vfork、kthread_create 最後都是要調用 do_fork,而 do_fork 就是根據不一樣的函數參數,對一個進程所需的資源進行分配。

內核線程

內核線程是由內核本身建立的線程,也叫作守護線程(Deamon),在終端上用命令 ps -Al 列出的全部進程中,名字以 k 開關以 d 結尾的每每都是內核線程,好比 kthreadd、kswapd 等。與用戶線程相比,它們都由 do_fork() 建立,每一個線程都有獨立的 task_struct 和內核棧;也都參與調度,內核線程也有優先級,會被調度器平等地換入換出。兩者的不一樣之處在於,內核線程只工做在內核態中;而用戶線程則既能夠運行在內核態(執行系統調用時),也能夠運行在用戶態;內核線程沒有用戶空間,因此對於一個內核線程來講,它的 0~3G 的內存空間是空白的,它的 current->mm 是空的,與內核使用同一張頁表;而用戶線程則能夠看到完整的 0~4G 內存空間。

在 Linux 內核啓動的最後階段,系統會建立兩個內核線程,一個是 init,一個是 kthreadd。其中 init 線程的做用是運行文件系統上的一系列」init」腳本,並啓動 shell 進程,因此 init 線程稱得上是系統中全部用戶進程的祖先,它的 pid 是 1。kthreadd 線程是內核的守護線程,在內核正常工做時,它永遠不退出,是一個死循環,它的 pid 是 2。

Coroutine | 協程

協程是用戶模式下的輕量級線程,最準確的名字應該叫用戶空間線程(User Space Thread),在不一樣的領域中也有不一樣的叫法,譬如纖程(Fiber)、綠色線程(Green Thread)等等。操做系統內核對協程一無所知,協程的調度徹底有應用程序來控制,操做系統無論這部分的調度;一個線程能夠包含一個或多個協程,協程擁有本身的寄存器上下文和棧,協程調度切換時,將寄存器上細紋和棧保存起來,在切換回來時恢復先前保運的寄存上下文和棧。

協程的優點以下:

  • 節省內存,每一個線程須要分配一段棧內存,以及內核裏的一些資源
  • 節省分配線程的開銷(建立和銷燬線程要各作一次 syscall)
  • 節省大量線程切換帶來的開銷
  • 與 NIO 配合實現非阻塞的編程,提升系統的吞吐

好比 Golang 裏的 go 關鍵字其實就是負責開啓一個 Fiber,讓 func 邏輯跑在上面。而這一切都是發生的用戶態上,沒有發生在內核態上,也就是說沒有 ContextSwitch 上的開銷。協程的實現庫中筆者較爲經常使用的譬如 Go Routine、node-fibersJava-Quasar 等。

Go 的協程模型

Go 線程模型屬於多對多線程模型,在操做系統提供的內核線程之上,Go 搭建了一個特有的兩級線程模型。Go 中使用使用 Go 語句建立的 Goroutine 能夠認爲是輕量級的用戶線程,Go 線程模型包含三個概念:

  • G: 表示 Goroutine,每一個 Goroutine 對應一個 G 結構體,G 存儲 Goroutine 的運行堆棧、狀態以及任務函數,可重用。G 並不是執行體,每一個 G 須要綁定到 P 才能被調度執行。
  • P: Processor,表示邏輯處理器,對 G 來講,P 至關於 CPU 核,G 只有綁定到 P(在 P 的 local runq 中)才能被調度。對 M 來講,P 提供了相關的執行環境(Context),如內存分配狀態(mcache),任務隊列(G)等,P 的數量決定了系統內最大可並行的 G 的數量(物理 CPU 核數 >= P 的數量),P 的數量由用戶設置的 GOMAXPROCS 決定,可是不論 GOMAXPROCS 設置爲多大,P 的數量最大爲 256。
  • M: Machine,OS 線程抽象,表明着真正執行計算的資源,在綁定有效的 P 後,進入 schedule 循環;M 的數量是不定的,由 Go Runtime 調整,爲了防止建立過多 OS 線程致使系統調度不過來,目前默認最大限制爲 10000 個。

在 Go 中每一個邏輯處理器(P)會綁定到某一個內核線程上,每一個邏輯處理器(P)內有一個本地隊列,用來存放 Go 運行時分配的 goroutine。多對多線程模型中是操做系統調度線程在物理 CPU 上運行,在 Go 中則是 Go 的運行時調度 Goroutine 在邏輯處理器(P)上運行。

Go 的棧是動態分配大小的,隨着存儲數據的數量而增加和收縮。每一個新建的 Goroutine 只有大約 4KB 的棧。每一個棧只有 4KB,那麼在一個 1GB 的 RAM 上,咱們就能夠有 256 萬個 Goroutine 了,相對於 Java 中每一個線程的 1MB,這是巨大的提高。Golang 實現了本身的調度器,容許衆多的 Goroutines 運行在相同的 OS 線程上。就算 Go 會運行與內核相同的上下文切換,可是它可以避免切換至 ring-0 以運行內核,而後再切換回來,這樣就會節省大量的時間。

在 Go 中存在兩級調度:

  • 一級是操做系統的調度系統,該調度系統調度邏輯處理器佔用 cpu 時間片運行;
  • 一級是 Go 的運行時調度系統,該調度系統調度某個 Goroutine 在邏輯處理上運行。

使用 Go 語句建立一個 Goroutine 後,建立的 Goroutine 會被放入 Go 運行時調度器的全局運行隊列中,而後 Go 運行時調度器會把全局隊列中的 Goroutine 分配給不一樣的邏輯處理器(P),分配的 Goroutine 會被放到邏輯處理器(P)的本地隊列中,當本地隊列中某個 Goroutine 就緒後待分配到時間片後就能夠在邏輯處理器上運行了。

Java 協程的討論

目前,JVM 自己並未提供協程的實現庫,像 Quasar 這樣的協程框架彷佛也仍非主流的併發問題解決方案,在本部分咱們就討論下在 Java 中是否有必要必定要引入協程。在普通的 Web 服務器場景下,譬如 Spring Boot 中默認的 Worker 線程池線程數在 200(50 ~ 500) 左右,若是從線程的內存佔用角度來考慮,每一個線程上下文約 128KB,那麼 500 個線程自己的內存佔用在 60M,相較於整個堆棧不過爾爾。而 Java 自己提供的線程池,對於線程的建立與銷燬都有很是好的支持;即便 Vert.x 或 Kotlin 中提供的協程,每每也是基於原生線程池實現的。

從線程的切換開銷的角度來看,咱們常說的切換開銷每每是針對於活躍線程;而普通的 Web 服務器自然會有大量的線程由於請求讀寫、DB 讀寫這樣的操做而掛起,實際只有數十個併發活躍線程會參與到 OS 的線程切換調度。而若是真的存在着大量活躍線程的場景,Java 生態圈中也存在了 Akka 這樣的 Actor 併發模型框架,它可以感知線程什麼時候可以執行工做,在用戶空間中構建運行時調度器,從而支持百萬級別的 Actor 併發。

實際上咱們引入協程的場景,更多的是面對所謂百萬級別鏈接的處理,典型的就是 IM 服務器,可能須要同時處理大量空閒的連接。此時在 Java 生態圈中,咱們可使用 Netty 去進行處理,其基於 NIO 與 Worker Thread 實現的調度機制就很相似於協程,能夠解決絕大部分由於 IO 的等待形成資源浪費的問題。而從併發模型對比的角度,若是咱們但願能遵循 Go 中以消息傳遞方式實現內存共享的理念,那麼也能夠採用 Disruptor 這樣的模型。

Java 線程與操做系統線程

Java 線程在 JDK1.2 以前,是基於稱爲「綠色線程」(Green Threads)的用戶線程實現的,而到了 JDK1.2 及之後,JVM 選擇了更加穩健且方便使用的操做系統原生的線程模型,經過系統調用,將程序的線程交給了操做系統內核進行調度。所以,在目前的 JDK 版本中,操做系統支持怎樣的線程模型,在很大程度上決定了 Java 虛擬機的線程是怎樣映射的,這點在不一樣的平臺上沒有辦法達成一致,虛擬機規範中也並未限定 Java 線程須要使用哪一種線程模型來實現。線程模型只對線程的併發規模和操做成本產生影響,對 Java 程序的編碼和運行過程來講,這些差別都是透明的。

對於 Sun JDK 來講,它的 Windows 版與 Linux 版都是使用一對一的線程模型實現的,一條 Java 線程就映射到一條輕量級進程之中,由於 Windows 和 Linux 系統提供的線程模型就是一對一的。也就是說,如今的 Java 中線程的本質,其實就是操做系統中的線程,Linux 下是基於 pthread 庫實現的輕量級進程,Windows 下是原生的系統 Win32 API 提供系統調用從而實現多線程。

在如今的操做系統中,由於線程依舊被視爲輕量級進程,因此操做系統中線程的狀態實際上和進程狀態是一致的模型。從實際意義上來說,操做系統中的線程除去 new 和 terminated 狀態,一個線程真實存在的狀態,只有:

  • ready:表示線程已經被建立,正在等待系統調度分配 CPU 使用權。
  • running:表示線程得到了 CPU 使用權,正在進行運算。
  • waiting:表示線程等待(或者說掛起),讓出 CPU 資源給其餘線程使用。

對於 Java 中的線程狀態:不管是 Timed Waiting ,Waiting 仍是 Blocked,對應的都是操做系統線程的 waiting(等待)狀態。而 Runnable 狀態,則對應了操做系統中的 ready 和 running 狀態。Java 線程和操做系統線程,實際上同根同源,但又相差甚遠。

延伸閱讀

您能夠經過如下導航來在 Gitbook 中閱讀筆者的系列文章,涵蓋了技術資料概括、編程語言與理論、Web 與大前端、服務端開發與基礎架構、雲計算與大數據、數據科學與人工智能、產品設計等多個領域:

此外,你還可前往 xCompass 交互式地檢索、查找須要的文章/連接/書籍/課程;或者在 MATRIX 文章與代碼索引矩陣中查看文章與項目源代碼等更詳細的目錄導航信息。最後,你也能夠關注微信公衆號:『某熊的技術之路』以獲取最新資訊。

相關文章
相關標籤/搜索