啃碎併發(三):Java執行緒上下文切換

猿碼道發表於2018-06-01

0 前言

在過去單CPU時代,單任務在一個時間點只能執行單一程式。之後發展到多工階段,計算機能在同一時間點並行執行多工或多程式。雖然並不是真正意義上的“同一時間點”,而是 多個任務或程式共享一個CPU,並交由作業系統來完成多工間對CPU的執行切換,以使得每個任務都有機會獲得一定的時間片執行

再後來發展到多執行緒技術,使得在一個程式內部能擁有多個執行緒並行執行。一個執行緒的執行可以被認為是一個CPU在執行該程式。當一個程式執行在多執行緒下,就好像有多個CPU在同時執行該程式

多執行緒比多工更加有挑戰。多執行緒是在同一個程式內部並行執行,因此會對相同的記憶體空間進行併發讀寫操作。這可能是在單執行緒程式中從來不會遇到的問題。其中的一些錯誤也未必會在單CPU機器上出現,因為兩個執行緒從來不會得到真正的並行執行。然而,更現代的計算機伴隨著多核CPU的出現,也就意味著 不同的執行緒能被不同的CPU核得到真正意義的並行執行

所以,在多執行緒、多工情況下,執行緒上下文切換是必須的,然而對於CPU架構設計中的概念,應先熟悉瞭解,這樣會有助於理解執行緒上下文切換原理。

1 多核、多CPU、超執行緒、多執行緒

1.1 為什麼要多核

先要說的是多核、多CPU、超執行緒,這三個其實都是CPU架構設計的概念,一個現代CPU除了處理器核心之外還包括暫存器、L1L2快取這些儲存裝置、浮點運算單元、整數運算單元等一些輔助運算裝置以及內部匯流排等。一個多核的CPU也就是一個CPU上有多個處理器核心,這樣有什麼好處呢?比如說現在我們要在一臺計算機上跑一個多執行緒的程式,因為是一個程式裡的執行緒,所以需要一些共享一些儲存變數,如果這臺計算機都是單核單執行緒CPU的話,就意味著這個程式的不同執行緒需要經常在CPU之間的外部匯流排上通訊,同時還要處理不同CPU之間不同快取導致資料不一致的問題,所以在這種場景下多核單CPU的架構就能發揮很大的優勢,通訊都在內部匯流排,共用同一個快取

1.2 為什麼要多CPU

前面提了多核的好處,那為什麼要多CPU呢?這個其實很容易想到,如果要執行多個程式(程式)的話,假如只有一個CPU的話,就意味著要經常進行程式上下文切換,因為單CPU即便是多核的,也只是多個處理器核心,其他裝置都是共用的,所以 多個程式就必然要經常進行程式上下文切換,這個代價是很高的

1.3 為什麼要超執行緒

超執行緒這個概念是Intel提出的,簡單來說是在一個CPU上真正的併發兩個執行緒,聽起來似乎不太可能,因為CPU都是分時的啊,其實這裡也是分時,因為前面也提到一個CPU除了處理器核心還有其他裝置,一段程式碼執行過程也不光是隻有處理器核心工作,如果兩個執行緒A和B,A正在使用處理器核心,B正在使用快取或者其他裝置,那AB兩個執行緒就可以併發執行,但是如果AB都在訪問同一個裝置,那就只能等前一個執行緒執行完後一個執行緒才能執行。實現這種併發的原理是 在CPU里加了一個協調輔助核心,根據Intel提供的資料,這樣一個裝置會使得裝置面積增大5%,但是效能提高15%~30%。

1.4 為什麼要多執行緒

這個問題也許是面試中問的最多的一個經典問題了,一個程式裡多執行緒之間可以共享變數,執行緒間通訊開銷也較小,可以更好的利用多核CPU的效能,多核CPU上跑多執行緒程式往往會比單執行緒更快,有的時候甚至在單核CPU上多執行緒程式也會有更好的效能,因為雖然多執行緒會有上下文切換和執行緒建立銷燬開銷,但是單執行緒程式會被IO阻塞無法充分利用CPU資源,加上執行緒的上下文開銷較低以及執行緒池的大量應用,多執行緒在很多場景下都會有更高的效率

1.5 執行緒與程式

程式是作業系統的管理單位,而執行緒則是程式的管理單位;一個程式至少包含一個執行執行緒。不管是在單執行緒還是多執行緒中,每個執行緒都有一個程式計數器(記錄要執行的下一條指令),一組暫存器(儲存當前執行緒的工作變數),堆疊(記錄執行歷史,其中每一幀儲存了一個已經呼叫但未返回的過程)。雖然執行緒寄生在程式中,但與他的程式是不同的概念,並且可以分別處理:程式是系統分配資源的基本單位,執行緒是排程CPU的基本單位

一個執行緒指的是程式中一個單一順序的控制流,一個程式中可以並行多個執行緒,每條執行緒並行執行不同的任務。每個執行緒共享堆空間,擁有自己獨立的棧空間

  1. 執行緒劃分尺度小於程式,執行緒隸屬於某個程式;
  2. 程式是CPU、記憶體等資源佔用的基本單位,執行緒是不能獨立佔有這些資源的;
  3. 程式之間相互獨立,通訊比較困難,而執行緒之間共享一塊記憶體區域,通訊方便;
  4. 程式在執行過程中,包含:固定的入口、執行順序和出口而程式的這些過程會被應用程式控制

程式&執行緒表項

2 上下文切換

支援多工處理是CPU設計史上最大的跨越之一。在計算機中,多工處理是指同時執行兩個或多個程式。從使用者的角度來看,這看起來並不複雜或者難以實現,但是它確實是計算機設計史上一次大的飛躍。在多工處理系統中,CPU需要處理所有程式的操作,當使用者來回切換它們時,需要記錄這些程式執行到哪裡。上下文切換就是這樣一個過程,允許CPU記錄並恢復各種正在執行程式的狀態,使它能夠完成切換操作。

多工系統往往需要同時執行多道作業。作業數往往大於機器的CPU數,然而一顆CPU同時只能執行一項任務,如何讓使用者感覺這些任務正在同時進行呢? 作業系統的設計者 巧妙地利用了時間片輪轉的方式, CPU給每個任務都服務一定的時間,然後把當前任務的狀態儲存下來,在載入下一任務的狀態後,繼續服務下一任務任務的狀態儲存及再載入, 這段過程就叫做上下文切換。時間片輪轉的方式使多個任務在同一顆CPU上執行變成了可能。

任務的狀態儲存及再載入, 這段過程就叫做上下文切換

2.1 基本概念

上下文切換(有時也稱做程式切換或任務切換)是指CPU從一個程式或執行緒切換到另一個程式或執行緒。

  1. 程式(有時候也稱做任務)是指一個程式執行的例項。
  2. 在Linux系統中,執行緒 就是能並行執行並且與他們的父程式(建立他們的程式)共享同一地址空間(一段記憶體區域)和其他資源的 輕量級的程式
  3. 上下文 是指某一時間點 CPU 暫存器和程式計數器的內容。
  4. 暫存器 是 CPU 內部的數量較少但是速度很快的記憶體(與之對應的是 CPU 外部相對較慢的 RAM 主記憶體)。暫存器通過對常用值(通常是運算的中間值)的快速訪問來提高計算機程式執行的速度
  5. 程式計數器是一個專用的暫存器,用於表明指令序列中 CPU 正在執行的位置,存的值為正在執行的指令的位置或者下一個將要被執行的指令的位置,具體依賴於特定的系統。

上下文切換可以認為是核心(作業系統的核心)在 CPU 上對於程式(包括執行緒)進行以下的活動:

  1. 掛起一個程式,將這個程式在 CPU 中的狀態(上下文)儲存於記憶體中的某處;
  2. 恢復一個程式,在記憶體中檢索下一個程式的上下文並將其在 CPU 的暫存器中恢復;
  3. 跳轉到程式計數器所指向的位置(即跳轉到程式被中斷時的程式碼行),以恢復該程式。

2.2 切換種類

上下文切換在不同的場合有不同的含義,在下表中列出:

上下文切換種類 描述
執行緒切換 同一程式中的兩個執行緒之間的切換
程式切換 兩個程式之間的切換
模式切換 在給定執行緒中,使用者模式和核心模式的切換
地址空間切換 將虛擬記憶體切換到實體記憶體

2.3 切換步驟

在上下文切換過程中,CPU會停止處理當前執行的程式,並儲存當前程式執行的具體位置以便之後繼續執行。從這個角度來看,上下文切換有點像我們同時閱讀幾本書,在來回切換書本的同時我們需要記住每本書當前讀到的頁碼。在程式中,上下文切換過程中的“頁碼”資訊是儲存在程式控制塊(PCB, process control block)中的。PCB還經常被稱作“切換楨”(switchframe)。“頁碼”資訊會一直儲存到CPU的記憶體中,直到他們被再次使用

PCB通常是系統記憶體佔用區中的一個連續存區,它存放著作業系統用於描述程式情況及控制程式執行所需的全部資訊,它使一個在多道程式環境下不能獨立執行的程式成為一個能獨立執行的基本單位或一個能與其他程式併發執行的程式。

  1. 儲存程式A的狀態(暫存器和作業系統資料);
  2. 更新PCB中的資訊,對程式A的“執行態”做出相應更改;
  3. 將程式A的PCB放入相關狀態的佇列
  4. 將程式B的PCB資訊改為“執行態”,並執行程式B
  5. B執行完後,從佇列中取出程式A的PCB,恢復程式A被切換時的上下文,繼續執行A

執行緒切換和程式切換的步驟也不同。程式的上下文切換分為兩步:

  1. 切換頁目錄以使用新的地址空間
  2. 切換核心棧和硬體上下文

對於Linux來說,執行緒和程式的最大區別就在於地址空間。對於執行緒切換,第1步是不需要做的,第2是程式和執行緒切換都要做的。所以明顯是程式切換代價大。執行緒上下文切換和程式上下文切換一個最主要的區別是 執行緒的切換虛擬記憶體空間依然是相同的,但是程式切換是不同的。這兩種上下文切換的處理都是 通過作業系統核心來完成的。核心的這種切換過程伴隨的 最顯著的效能損耗是將暫存器中的內容切換出

對於一個正在執行的程式包括 程式計數器、暫存器、變數的當前值等 ,而這些資料都是 儲存在CPU的暫存器中的,且這些暫存器只能是正在使用CPU的程式才能享用在程式切換時,首先得儲存上一個程式的這些資料(便於下次獲得CPU的使用權時從上次的中斷處開始繼續順序執行,而不是返回到程式開始,否則每次程式重新獲得CPU時所處理的任務都是上一次的重複,可能永遠也到不了程式的結束出,因為一個程式幾乎不可能執行完所有任務後才釋放CPU),然後將本次獲得CPU的程式的這些資料裝入CPU的暫存器從上次斷點處繼續執行剩下的任務

作業系統為了便於管理系統內部程式,為每個程式建立了一張程式表項:

程式表項

2.4 切換檢視

在Linux系統下可以使用vmstat命令來檢視上下文切換的次數,下面是利用vmstat檢視上下文切換次數的示例:

上線文切換檢視

vmstat 1指每秒統計一次, 其中cs列就是指上下文切換的數目. 一般情況下, 空閒系統的上下文切換每秒大概在1500以下.

3 切換原因

引起執行緒上下文切換的原因,主要存在三種情況如下:

  1. 中斷處理:在中斷處理中,其他程式”打斷”了當前正在執行的程式。當CPU接收到中斷請求時,會在正在執行的程式和發起中斷請求的程式之間進行一次上下文切換。中斷分為硬體中斷和軟體中斷,軟體中斷包括因為IO阻塞、未搶到資源或者使用者程式碼等原因,執行緒被掛起。
  2. 多工處理:在多工處理中,CPU會在不同程式之間來回切換,每個程式都有相應的處理時間片,CPU在兩個時間片的間隔中進行上下文切換。
  3. 使用者態切換:對於一些作業系統,當進行使用者態切換時也會進行一次上下文切換,雖然這不是必須的。

對於我們經常 使用的搶佔式作業系統 而言,引起執行緒上下文切換的原因大概有以下幾種:

  1. 當前執行任務的時間片用完之後,系統CPU正常排程下一個任務;
  2. 當前執行任務碰到IO阻塞,排程器將此任務掛起,繼續下一任務;
  3. 多個任務搶佔鎖資源,當前任務沒有搶到鎖資源,被排程器掛起,繼續下一任務;
  4. 使用者程式碼掛起當前任務,讓出CPU時間;
  5. 硬體中斷;

4 切換損耗

上下文切換會帶來 直接和間接 兩種因素影響程式效能的消耗。

  1. 直接消耗:指的是CPU暫存器需要儲存和載入, 系統排程器的程式碼需要執行, TLB例項需要重新載入, CPU 的pipeline需要刷掉;
  2. 間接消耗:指的是多核的cache之間得共享資料, 間接消耗對於程式的影響要看執行緒工作區運算元據的大小;

5 減少切換

既然上下文切換會導致額外的開銷,因此減少上下文切換次數便可以提高多執行緒程式的執行效率。但上下文切換又分為2種:

  1. 讓步式上下文切換:指執行執行緒主動釋放CPU,與鎖競爭嚴重程度成正比,可通過減少鎖競爭來避免;
  2. 搶佔式上下文切換:指執行緒因分配的時間片用盡而被迫放棄CPU或者被其他優先順序更高的執行緒所搶佔,一般由於執行緒數大於CPU可用核心數引起,可通過調整執行緒數,適當減少執行緒數來避免。

所以,減少上下文切換的方法有無鎖併發程式設計、CAS演算法、使用最少執行緒和使用協程

  1. 無鎖併發:多執行緒競爭時,會引起上下文切換,所以多執行緒處理資料時,可以用一些辦法來避免使用鎖,如將資料的ID按照Hash取模分段,不同的執行緒處理不同段的資料;
  2. CAS演算法:Java的Atomic包使用CAS演算法來更新資料,而不需要加鎖;
  3. 最少執行緒:避免建立不需要的執行緒,比如任務很少,但是建立了很多執行緒來處理,這樣會造成大量執行緒都處於等待狀態;
  4. 使用協程:在單執行緒裡實現多工的排程,並在單執行緒裡維持多個任務間的切換;

6 執行緒數目

合理設定執行緒數目,關鍵點是:1. 儘量減少執行緒切換和管理的開支;2. 最大化利用CPU

對於1,要求執行緒數儘量少,這樣可以減少執行緒切換和管理的開支;

對於2,要求儘量多的執行緒,以保證CPU資源最大化的利用;

所以 對於任務耗時短的情況,要求執行緒儘量少,如果執行緒太多,有可能出現執行緒切換和管理的時間,大於任務執行的時間,那效率就低了;

對於耗時長的任務,要分是CPU任務,還是IO等型別的任務。如果是CPU型別的任務,執行緒數不宜太多;但是如果是IO型別的任務,執行緒多一些更好,可以更充分利用CPU。

高併發,低耗時的情況:建議少執行緒,只要滿足併發即可,因為上下文切換本來就多,並且高併發就意味著CPU是處於繁忙狀態的, 增加更多地執行緒也不會讓執行緒得到執行時間片,反而會增加執行緒切換的開銷;例如併發100,執行緒池可能設定為10就可以;

低併發,高耗時的情況:建議多執行緒,保證有空閒執行緒,接受新的任務;例如併發10,執行緒池可能就要設定為20;

高併發高耗時:1. 要分析任務型別;2. 增加排隊;3. 加大執行緒數;

相關文章