理解golang調度之一 :操做系統調度

前言

這一部分有三篇文章,主要是講解go調度器的一些內容html

三篇文章分別是:golang

簡介

golang調度器的設計行爲可以使你的多線程go程序更有效率、性能更好,這要歸功於golang調度器對於操做系統調度器的支持。對於一個golang開發者來講,同時深入理解操做系統調度和golang調度器工做原理,可以讓你的golang程序設計和開發走到正確道路上。web

操做系統調度器

操做系統調度器十分複雜,它必需要考慮到它所運行的底層硬件層級結構,包括但不限於處理器數和內核數,cpu cache和NUMA(非統一內存訪問架構)。若是不考慮這些因素,調度器就沒辦法儘量有效的工做。好事情是,你沒必要深刻理解這些底層內容也能開發出好的程序。算法

你的程序其實就是一堆按順序執行的機器指令。爲了能讓其正常幹活,操做系統使用了線程的概念。線程會處理並執行分配給它的一系列的機器指令。線程會一直執行這些機器指令,直到沒有指令再去給線程執行了。這也是爲何把線程稱做"a path of execution"。數據庫

你運行的每一個程序都會建立一個進程而且每一個進程都會有一個初始線程。線程可以建立更多的線程。這些不一樣的線程獨立運行而且調度行爲是線程級別作決定的,而不是在進程級別。線程可以併發的執行(併發是說一個單獨內核上每一個線程會輪詢佔用一段cpu時間),而不是並行執行(在不一樣內核上同時執行)。線程同時會維持它本身的狀態,而且可以在本地安全、獨立地執行他本身的指令。這也說明了爲何線程是cpu調度的最小單位。編程

操做系統調度器,負責確保在有線程可以運行的時候內核不會空閒下來。它必需要製造出這樣一種錯覺——全部可以跑的線程此時都在同時執行。爲了製造這種錯覺,調度器須要優先執行高優先級的線程,可是它也必須保證低優先級的線程不會餓死(永遠沒有執行機會)。調度器也必須經過作出更聰明的決定將調度延時儘量的壓倒最少,。c#

幸運的是計算機發展了這麼長時間,許多算法的應用使得調度器更加高效。爲了可以理解上面的事情,須要解釋一些重要的概念。緩存

執行指令

程序計數器(PC),有時候也叫作指令指針(IP),可以讓你找到下一個要執行的指令在哪。大部分的處理器裏,PC指向下一個指令,而不是當前的指令。安全

若是你曾經注意到go程序的追蹤棧,你會注意到這些每一行末尾的16進制數字。例如Listing 1裏的+0x39和+0x72bash

Listing 1
goroutine 1 [running]:
   main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
       stack_trace/example1/example1.go:13 +0x39                 <- LOOK HERE
   main.main()
       stack_trace/example1/example1.go:8 +0x72                  <- LOOK HERE
複製代碼

這些數字表明瞭PC值,也就是從各自函數開始的偏移量。+0x39 PC偏移量表明瞭程序在還未panic的時候,線程在example方法執行的下一條指令。+0x72 PC偏移量表明若是example函數回到main函數裏,main裏的下一條指令。重要的是,指向指令的前一個指針告訴了你現正在執行什麼指令

看一下致使Listing 1 panic的程序

Listing 2
07 func main() {
08     example(make([]string, 2, 4), "hello", 10)
09 }

12 func example(slice []string, str string, i int) {
13    panic("Want stack trace")
14 }
複製代碼

十六進制數+0x39表明了PC偏移量,在example函數裏也就是距離函數開頭57(10進制)bytes的位置。下面的Listing 3裏,你能夠經過二進制文件看到example函數的objdump。找到最下面的第12條指令,注意到是它上面一行的指令致使了panic

Listing 3
$ go tool objdump -S -s "main.example" ./example1
TEXT main.example(SB) stack_trace/example1/example1.go
func example(slice []string, str string, i int) {
  0x104dfa0		65488b0c2530000000	MOVQ GS:0x30, CX
  0x104dfa9		483b6110		CMPQ 0x10(CX), SP
  0x104dfad		762c			JBE 0x104dfdb
  0x104dfaf		4883ec18		SUBQ $0x18, SP
  0x104dfb3		48896c2410		MOVQ BP, 0x10(SP)
  0x104dfb8		488d6c2410		LEAQ 0x10(SP), BP
	panic("Want stack trace")
  0x104dfbd		488d059ca20000	LEAQ runtime.types+41504(SB), AX
  0x104dfc4		48890424		MOVQ AX, 0(SP)
  0x104dfc8		488d05a1870200	LEAQ main.statictmp_0(SB), AX
  0x104dfcf		4889442408		MOVQ AX, 0x8(SP)
  0x104dfd4		e8c735fdff		CALL runtime.gopanic(SB)
  0x104dfd9		0f0b			UD2              <--- LOOK HERE PC(+0x39)
複製代碼

注意: PC始終是下一個指令,不是當前指令。Listing 3很好的說明了amd64下面,go線程是如何執行指令序列的。

線程狀態

另外一個重要概念就是「線程狀態」,線程狀態說明了調度器該如何處理此時的線程。線程有三個狀態:等待、可運行、執行中。

等待(Waiting):

此時意味着線程中止而且等待被喚醒。可能發生的緣由有,等待硬件(硬盤、網絡),操做系統(系統調用) 或者是同步調用(atomic,mutexes)。這些狀況是致使性能問題的根源

可運行(Runnable):

此時線程想要佔用內核上的cpu時間來執行分配給線程的指令。若是你有許多線程想要cpu時間,線程必需要等一段時間才能取到cpu時間。隨着更多線程爭用cpu時間,線程分配的cpu時間會更短。這種狀況下的調度延時也會形成性能問題。

執行中(Executing):

此時線程已經置於內核中,而且正在執行它的機器指令。應用程序的相關內容正在被處理。這種狀態是咱們所但願的

工做類型

線程有兩種工做類型。第一種叫CPU密集型,第二種叫IO密集型

CPU密集型(cpu-bound):

這種工做下,線程永遠不會被置換到等待(waiting)狀態。這種通常是進行持續性的cpu計算工做。好比計算Pi這種的就是cpu密集型工做

IO密集型(io-bound)

這種工做會讓線程進入到等待(waiting)狀態。這種狀況線程會持續的請求資源(好比網絡資源)或者是對操做系統進行系統調用。線程須要訪問數據庫的狀況就是IO密集型工做。同時我會把同步事件(例如mutexes、atomic),這種須要線程等待的狀況納入此類工做。

上下文切換(Context Switch)

若是你的程序運行在Linux、Mac或者是Windows上面,你的調度器則是搶佔式的。這意味着一些重要的事情。第1、它意味着調度器不會預先知道此時此刻會運行哪一個線程。線程優先級加上事務(例如接受網絡數據)讓調度器沒法肯定哪一個時間執行哪一個線程。

第2、你永遠不能按照歷史經驗去看,你以前幸運跑出來的代碼其實不能保證每次都按你所想去執行。若是你的代碼1000次都是按照一樣方式執行,你很容易覺得下次也保證按照同樣方式執行。若是你的程序須要肯定性的話,你必定要控制線程的同步和編排。

在內核上切換線程的物理行爲叫作上下文切換(context switch)。上下文切換髮生在這樣的狀況,調度器從內核換下正在執行的線程,替換上可執行的線程。線程是從運行隊列中取出,並設置成執行中(Executing)的狀態。從內核上下來的線程會置成可運行狀態,或者是等待狀態。

上下文切換的代價是昂貴的,由於它須要花時間去交換線程,從內核上拿下來再放上去。上下文切換的延時受到不少因素影響,可是一般狀況下,它會有1000--1500納秒的延時。考慮到硬件上每一個內核上平均每納秒執行12個指令,一次上下文切換會花費你12k--18k個指令延時。這本質上來講,你的程序在上下文切換過程當中失去了執行大量指令的機會。

若是你的程序集中於IO密集型(cpu-bound)的工做,上下文切換會相對有利。一旦一個線程進入到等待(waiting)狀態。另外一個處於可運行(Runnable)狀態的線程會取代它的位置。這會使得內核始終是處於工做狀態。這是調度器調度的一個重要方面,若是有事作(有線程處於可運行狀態)就不容許內核閒下來。

若是你的程序集中於cpu密集型(cpu-bound)的工做,那麼上下文切換會是性能的噩夢。由於線程要一直作事情,上下文切換會中止正在處理的工做。這種狀況和IO密集型形工做成鮮明對比。

少便是多(Less Is More)

在早期時候,處理器僅僅只有一個內核,調度器並不十分複雜。由於你有一個單獨的處理器,一個單獨的內核,因此任什麼時候間只能跑一個線程。方法是定義一個調度期(scheduler period) 而後嘗試在一個調度期內去執行全部可運行(Runnable)的線程。這樣沒問題:把調度期按照須要執行的線程數量去分每一小段。

舉例,若是你定義了你的調度期是10ms 而且你有兩個線程,那每一個線程會分到5ms。5個線程的話,每一個線程就是2ms。可是若是你有100個線程會怎麼樣?每一個線程時間片是10us(微秒), 這就會沒法工做,由於你須要大量時間去進行上下文切換(context switches)。

在最後一個場景,若是最小的時間切片是2ms 而且你有100個線程,調度期須要增長到2000ms也就是2s。要是若是你有1000個線程呢,如今調度期須要20s,也就是你要花20s才能跑完全部的線程若是每一個線程都能跑滿它的時間切片。

上面發生的是顯而易見的事情。調度器在作決定的時候還要考慮到更多的因素。你控制了應用程序裏的線程數量,當有更多線程的時候,而且是IO密集(IO-Bound)工做,就會有更多的混亂和不肯定行爲發生,調度和執行就花費更多時間。

這也是爲何說遊戲規則就是「少便是多(Less is More)」,可運行線程越少意味着調度時間越少,線程獲得的時間越多。更多的線程就意味着每一個線程得到的時間就越少,分配的時間內作的事情也就越少。

找到平衡點

你須要在內核數量和你的線程數量二者間,找到一個可以讓你的程序得到最好吞吐量的平衡點。想要去找到這樣的平衡點,線程池是一個很好的選擇。

使用go以前,做者使用C++和c#在NT上。在那個操做系統裏,使用IOCP(IO Completion Ports) 線程池對於寫多線程軟件十分重要。做爲一個工程師,你須要計算出你要用多少個線程池,以及每一個線程池的最大線程數,從而在肯定了內核數的系統裏最大化你的吞吐量。

當寫web服務時候,你須要和數據庫通訊。3是一個魔法數字,每一個內核設置3個線程彷佛在NT上有最好的吞吐量。換句話說,每內核3線程可以最小化上下文切換的延時,最大化在內核上的執行時間。當你建立一個IOPC線程池,我知道我能夠在主機上設置每一個內核1--3個線程數量。

若是我使用2個線程每一個內核,完成工做的時間會變長,由於原本須要有工做去作的內核會有空閒時間。若是我每一個內核用4個線程,也會花更長時間,由於我須要花更多時間進行上下文切換。平衡數字3,無論是什麼緣由,彷佛在NT上都是一個神奇的數字。

當你的服務須要處理許多不一樣類型的工做會如何呢。那會有不一樣而且不一致的延遲。可能它會產生許多須要去處理的不一樣系統級別的事件。這種狀況,你不可能去找到一個魔法數字,能讓你在全部時間全部不一樣的工做狀況下都有優秀的性能。當你使用線程池的時候,找到一個合適的配置會十分複雜。

緩存行(Cache Lines)

從主存訪問數據有很高的延遲(大概100~300個時鐘週期),所以處理器和內核會有緩存,可以讓線程訪問到更近的數據。從緩存訪問數據的延遲很是低(大概3~40個時鐘週期) 根據不一樣的緩存訪問方式。衡量性能的一個方面就是,處理器經過減小數據訪問延時而獲取數據的效率。編寫多線程的應用程序須要考慮到機器的緩存系統。

處理器和主存使用緩存行(cache lines)進行數據交換。一個緩存行是一個64 byte的內存塊,它在內存和緩存系統之間進行交換。每一個內核會分配它本身須要的cache副本,也就是意味着硬件使用的是值語義(區別於指針語義)。這也是爲何多線程中的內存突變會形成嚴重的性能問題。

當多線程並行運行,正在訪問相同數據,甚至是相鄰的數據單元,他們會訪問相同的緩存行。任何內核上運行的任何線程可以從相同的緩存行獲取各自的拷貝。

若是給一個內核,他上面的線程修改它的cache行副本,而後會經過硬件的神奇操做,同一cache行的全部其餘副本都會被標記爲無效。當一個線程嘗試讀寫無效cache行,須要從新訪問主存去獲取新的cache行副本(大約要100~300個時鐘週期)

也許在2核的處理器上這不是大問題,可是若是是一個32核處理器並行跑32個線程,而且同時訪問和修改一個相同的cache行呢?因爲處理器處處理器之間的通訊延遲增長,狀況會更糟。程序內存會發生顛簸,而且性能不好,並且極可能你也不知道爲何會這樣。

這就是cache的一致性問題( cache-coherency problem )或者是說是共享失敗(false sharing)。當編寫改變共享狀態的多線程應用時,cache系統必需要考慮在內。

調度決策場景

想象一下,我已經要求你根據我給你的高級信息編寫OS調度程序。 想一想你必須考慮的這種狀況。

你啓動了你的應用程序,主線程已經在core1上啓動。當線程正在執行,他須要去檢索cache行由於須要訪問數據。這個Thread如今決定爲了某些併發處理建立一個新的線程。那麼問題來了。

一旦線程建立好,而且準備要運行了,那麼調度器是否應該:

  1. 從core1上換下main主線程?這樣作有助於提升性能,由於這個新線程須要的相同數據被緩存的可能性很是大。可是主線程並無獲得它的所有時間片。
  2. 線程是否要一直等待直到main主線程完成它的時間後core1可用?線程並無在運行,可是一旦運行它獲取數據的延時將會消除。
  3. 線程等待下一個可用的core?這意味着所選擇的core的cache行會經歷沖刷、檢索、複製,從而致使延遲。可是線程會更快的啓動,而且主線程會完成它的時間片。

這些都是調度器在作決定時須要考慮到的一些有趣問題。我能告訴你的事情就是,若是有空閒的內核,它將會被使用。你但願當線程可以運行的時候它就會運行。

結論

這是第一部分,爲你提供了一些多線程編程時要考慮到線程和OS調度器的一些理解。這同時也是golang調度器須要考慮的事情。下面一部分,我會描述Go調度器的一些相關知識。




原文連接:www.ardanlabs.com/blog/2018/0…
相關文章
相關標籤/搜索