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

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

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

《Awesome Interviews》 歸納的常見面試題中,無論前後端,併發與異步的相關知識都是面試的中重中之重,《併發編程》系列即對於面試中常見的併發知識再進行回顧總結;你也可以前往 《Awesome Interviews》,在實際的面試題考校中瞭解自己的掌握程度。也可以前往《Java 實戰》、《Go 實戰》等了解具體編程語言中的併發編程的相關知識。

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

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

這樣既耗費時間又浪費空間,所以我們纔要研究多線程。一個進程創建的所有線程,都是共享一個內存空間的,所以線程做任務切換成本就很低了。現代的操作系統都基於更輕量的線程來調度,現在我們提到的“任務切換”都是指“線程切換”。

進程與線程

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

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

進程(Process)

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

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

image

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

image

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

線程(Thread)

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

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

線程共享的環境包括:進程代碼段、進程的公有數據、進程打開的文件描述符、信號的處理器、進程的當前目錄、進程用戶 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 文章與代碼索引矩陣中查看文章與項目源代碼等更詳細的目錄導航信息。最後,你也可以關注微信公衆號:『某熊的技術之路』以獲取最新資訊。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章