內存與磁盤IO原理
一般來說,索引本身也很大,不可能全部存儲在內存中,因此索引往往以索引文件的形式存儲的磁盤上。這樣的話,索引查找過程中就要產生磁盤I/O消耗,相對於內存存取,I/O存取的消耗要高幾個數量級,所以評價一個數據結構作為索引的優劣最重要的指標就是在查找過程中磁盤I/O操作次數的漸進復雜度。換句話說,索引的結構組織要盡量減少查找過程中磁盤I/O的存取次數。
內存IO
簡單點說說內存讀取,內存是由一系列的存儲單元組成的,每個存儲單元存儲固定大小的數據,且有一個唯一地址。當需要讀內存時,將地址信號放到地址總線上傳給內存,內存解析信號並定位到存儲單元,然后把該存儲單元上的數據放到數據總線上,回傳。
寫內存時,系統將要寫入的數據和單元地址分別放到數據總線和地址總線上,內存讀取兩個總線的內容,做相應的寫操作。
內存存取效率,跟次數有關,先讀取A數據還是后讀取A數據不會影響存取效率。
磁盤IO
磁盤I/O涉及機械操作。磁盤是由大小相同且同軸的圓形盤片組成,磁盤可以轉動(各個磁盤須同時轉動)。磁盤的一側有磁頭支架,磁頭支架固定了一組磁頭,每個磁頭負責存取一個磁盤的內容。磁頭不動,磁盤轉動,但磁臂可以前后動,用於讀取不同磁道上的數據。磁道就是以盤片為中心划分出來的一系列同心環(如圖標紅那圈)。磁道又划分為一個個小段,叫扇區,是磁盤的最小存儲單元。
磁盤讀取時,系統將數據邏輯地址傳給磁盤,磁盤的控制電路會解析出物理地址,即哪個磁道哪個扇區。於是磁頭需要前后移動到對應的磁道,消耗的時間叫尋道時間,然后磁盤旋轉將對應的扇區轉到磁頭下,消耗的時間叫旋轉時間。所以,適當的操作順序和數據存放可以減少尋道時間和旋轉時間。
為了盡量減少I/O操作,磁盤讀取每次都會預讀,大小通常為頁的整數倍。即使只需要讀取一個字節,磁盤也會讀取一頁的數據(通常為4K)放入內存,內存與磁盤以頁為單位交換數據。因為局部性原理認為,通常一個數據被用到,其附近的數據也會立馬被用到。
01 CPU與內存
CPU的內部架構和工作原理
CPU從邏輯上可以划分成3個模塊,分別是控制單元、運算單元和存儲單元,這三部分由CPU內部總線連接起來。如下所示:
*控制單元*:控制單元是整個CPU的指揮控制中心,由程序計數器PC(Program Counter), 指令寄存器IR(Instruction Register)、指令譯碼器ID(Instruction Decoder)和操作控制器OC(Operation Controller)等,對協調整個電腦有序工作極為重要。它根據用戶預先編好的程序,依次從存儲器中取出各條指令,放在指令寄存器IR中,通過指令譯碼(分析)確定應該進行什么操作,然后通過操作控制器OC,按確定的時序,向相應的部件發出微操作控制信號。操作控制器OC中主要包括節拍脈沖發生器、控制矩陣、時鍾脈沖發生器、復位電路和啟停電路等控制邏輯。
*運算單元*:是運算器的核心。可以執行算術運算(包括加減乘數等基本運算及其附加運算)和邏輯運算(包括移位、邏輯測試或兩個值比較)。相對控制單元而言,運算器接受控制單元的命令而進行動作,即運算單元所進行的全部操作都是由控制單元發出的控制信號來指揮的,所以它是執行部件。
*存儲單元*:包括CPU片內緩存和寄存器組,是CPU中暫時存放數據的地方,里面保存着那些等待處理的數據,或已經處理過的數據,CPU訪問寄存器所用的時間要比訪問內存的時間短。采用寄存器,可以減少CPU訪問內存的次數,從而提高了CPU的工作速度。但因為受到芯片面積和集成度所限,寄存器組的容量不可能很大。寄存器組可分為專用寄存器和通用寄存器。專用寄存器的作用是固定的,分別寄存相應的數據。而通用寄存器用途廣泛並可由程序員規定其用途,通用寄存器的數目因微處理器而異。這個是我們以后要介紹這個重點,這里先提一下。
CPU緩存的原理
CPU的運算處理速度與內存讀寫速度的差異非常巨大,為了解決這種差異充分利用CPU的使用效率,CPU緩存應運而生,它是介於CPU處理器和內存之間的臨時數據交換的緩沖區。
CPU緩存和內存都是一種斷電即掉的非永久隨機存儲器RAM,那么它和內存在物理上有什么差異嗎?當然有,CPU緩存基本是由SRAM(static RAM)構成(也有IBM的Power系列處理是eDRAM構成的CPU緩存),而內存經常稱之為DRAM,其實它是SDRAM(同步動態隨機存儲器),是DRAM的一種。構成內存的DRAM只含有一個晶體管和一個電容器,集成度非常高可用輕易的做到大容量,但因為靠電容器來存儲信息所以需要不間斷刷新電容器的電荷,而充放電之間的時間差導致DRAM的數據讀寫速度較SRAM慢的多。
構成緩存的SRAM卻比構成內存的DRAM的復雜度高了不止一籌,所以占據空間大,成本高,集成度很低,以至於在CPU工藝低下的前期,CPU緩存不能集成進CPU內部而只有集成到主板上,但它的好處卻是不需要刷新電路所以讀寫速度快。
如果說SRAM與DRAM物理結構及性能的不同展現了CPU高速緩存的物理原理,那么時間局部性原理和空間局部性原理則是支撐CPU高速緩存的邏輯原理。時間局部性原理是說:被引用過的內存位置很可能在不遠的將來還會被多次引用,空間局部性原理說的是:如果一個內存位置不引用了,那么程序很可能會在不遠的將來引用該內存位置附近的內存位置。
CPU緩存的層次結構
最開始對CPU緩存進行分類是由於CPU內部集成的CPU緩存已經不能滿足高性能CPU的需要,而制造工藝上的限制又不能在CPU內部大幅提高緩存的數量,所以出現了集成在主板上的緩存,當時人們就把CPU內部的緩存稱為一級緩存,即L1 Cache,在CPU外部主板上的緩存稱為二級緩存,即L2 Cache。而一級緩存其實還分為一級數據緩存(Data Cache,D-Cache,L1d)和一級指令緩存(Instruction Cache,I-Cache,L1i),分別用於存放數據和執行數據的指令解碼,兩者可以同時被CPU訪問,減少了CPU多核心,多線程爭用緩存造成的沖突。
早期Intel和AMD似乎對最后一層緩存L2上存在不同的見解,每個CPU核心都具備獨立的一級緩存L1,那么二級緩存L2呢?AMD的做法是依然每個CPU核心使用獨立的二級緩存,但Intel卻采用了一個CPU的多個核心共享二級緩存的設計,即所謂的“Smart Cache”技術,這在當時確實要比AMD的設計性能更好。
隨着制造工藝的提升,L2也被集成進了CPU緩存,但是接踵而來對大數據處理和游戲性能等等需要,在高端CPU上出現了三級緩存L3 Cache。三級緩存的出現據說對CPU的性能有着爬坡似的提升。當然到了2018年的今天,擁有三級緩存已經不再是高端CPU的特權,在一些特殊的CPU上據說還出現了四級緩存,當然這並不是說緩存的級數越多越能提升性能,到了三級緩存之后由於距離CPU的傳輸距離和本身容量的提升,CPU訪問緩存和直接訪問內存所能帶來的性能提升已經被逐漸抵消,所以與其增加所謂的四級緩存還不如就直接訪問內存。 綜上所述,大概描述了CPU緩存的層次結構,下圖是來至《深入理解計算機系統》書中關於CPU緩存和內存、硬盤存儲器的層次結構:
深入理解計算機系統一書將寄存器划分為L0級緩存,接着依次是L1,L2,L3,內存,本地磁盤,遠程存儲。越往上的緩存存儲空間越小,速度越快,成本也更高;越往下的存儲空間越大,速度更慢,成本也更低。從上至下,每一層都可以看做是更下一層的緩存,即:L0寄存器是L1一級緩存的緩存,L1是L2的緩存,依次類推;每一層的數據都是來至它的下一層,所以每一層的數據是下一層的數據的子集
每個核心擁有獨立的運算處理單元、控制器、寄存器、L1、L2緩存,然后一個CPU的多個核心共享最后一層CPU緩存L3,使其可以同時運行一個進程的多個線程,如下圖所示:
單核兩線程是什么?其實這就是所謂的超線程技術(HyperthreadingTechnology),就是通過采用特殊的硬件指令,可以為一個邏輯內核再模擬出來一個物理芯片,所以如果你通過Windows的設備管理查看處理器你會看到四個,其實有兩個都是模擬出來的,這樣做可以將CPU內部暫時閑置的資源充分“調動”起來,因為我們的CPU在運行一個程序時其實還有很多執行單元是被閑置的。模擬出一個核就是為了使用CPU一些空閑的地方(資源),充分榨取CPU的性能。但是模擬出來的內核畢竟是虛擬的,所以它會和被模擬的邏輯核共享寄存器,L1,L2,因此就算是雙核四線程還是只有2個一級緩存L1,2個二級緩存L2,一個三級緩存L3,所以假如物理核與它的模擬核中的線程要同時使用同一個執行單元里的東西時,或者訪問同一個緩存行數據,還是只能一個一個的來。
那么雙CPU或者雙處理器呢?前面所說的雙核心是在一個處理器里擁有兩個處理器核心,核心是兩個,但是其他硬件還都是兩個核心在共同擁有,而雙CPU則是真正意義上的雙核心,不光是處理器核心是兩個,其他例如緩存等硬件配置也都是雙份的。一個CPU對應一個物理插槽,多處理器間通過QPI總線相連。我們常見的計算機(例如上面我的筆記本)幾乎都是單CPU多核心的,真正的多CPU並不是個人PC所常用的。
CPU緩存的內部結構 對CPU緩存的層次結構有了了解之后,我們再深入進CPU緩存內部,看看它內部的結構。
上圖是一個CPU緩存的內部結構視圖,來至《深入理解計算機系統》一書,結合上圖我這里只做簡單的說明,若要細致深入的了解請參考原書。
CPU緩存內部一般是由S組構成,這個S的大小與該緩存的存儲大小尋址空間有關,然后每一組里面又有若干緩存行cache line,例如上圖每一組有E行cache line,E等於2,每一個緩存行包含一個標記其是否有效的有效位和t個標記位,然后才是真正存儲緩存數據部分有B個字節大小。整個緩存區的大小C=B*E*S.
而一個內存地址在做緩存查找的時候,首先中間的s位指明了應該放在哪一組,高位的t位指明位於組中的哪一行,低位的b位表示應該從緩存行中的多少個偏移開始讀取,畢竟一個緩存行可以存放很多數據的,一般是64個字節。
這里面,代表行數量的E等於1的時候稱之為“直接映射高速緩存”,E等於C/B即一個組包含所有行的時候稱之為“全相聯高速緩存”,當1>E>C/B即緩存行數介於這之間時稱之為“組相聯高速緩存”。由於CPU緩存的空間一般很小,內存數據映射到CPU緩存的算法必然將導致有很多不同的數據將被映射放置到相同的緩存行,這種訪問同一個緩存行的不同數據就將導致緩存不命中,需要重新到下一級緩存或內存加載數據來替換掉原來的緩存,這種不命中稱之為“沖突不命中”,如果這種沖突不命中持續產生,我們將之稱之為“抖動”。很顯然,直接映射高速緩存每一組只有一行所以這種“抖動”將可能是很頻繁的,而這顯然也不是最高的緩存設計方案。而“全相聯高速緩存”雖然能最大限度的解決這種“抖動”但是由於行數太多想要CPU能夠快速的在比較大的緩存中匹配出想要的數據也是非常困難的,而且代價昂貴。所以它只適合做小的高速緩存。最后只有“組相聯高速緩存”才是我們最佳的方案。
在上面CPU-Z的截圖中,我的CPU緩存就是采用的組相聯高速緩存,L1/L2后面的8-way說明它們每一組有8行,L3有12行,L1 d/L1 i的緩存總大小都是是32KB(注意前面有個乘以2 其實就是指有兩個核心),L2的緩存大小是256KB....
一般緩存行的大小是64個字節(不包含有效位和標記位),即B等於64,其實我的這個筆記本也是,這在上面CPU-Z的第二張圖中可以看到,這些信息還可以通過CoreInfo工具或者如果我們用Java編程,還可以通過CacheSize API方式來獲取Cache信息, CacheSize是一個谷歌的小項目,java語言通過它可以進行訪問本機Cache的信息。示例代碼如下:
public static void main(String[] args) throws CacheNotFoundException {
CacheInfo info = CacheInfo.getInstance();
CacheLevelInfo l1Datainf = info.getCacheInformation(CacheLevel.L1, CacheType.DATA_CACHE);
System.out.println("第一級數據緩存信息:"+l1Datainf.toString());
CacheLevelInfo l1Instrinf = info.getCacheInformation(CacheLevel.L1, CacheType.INSTRUCTION_CACHE);
System.out.println("第一級指令緩存信息:"+l1Instrinf.toString());
}
打印結果如下:
第一級數據緩存信息:CacheLevelInfo [cacheLevel=L1, cacheType=DATA_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]
第一級指令緩存信息:CacheLevelInfo [cacheLevel=L1, cacheType=INSTRUCTION_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]
顯然這是和實際相符的,cacheSets表面有64組,cacheCoherencyLineSize表明緩存行的大小為64字節,cacheWaysOfAssociativity表示一組里面有8個緩存行,totalSizeInBytes就是整個緩存行的大小32KB,L1數據/指令緩存大小都為:C=B×E×S=64×8×64=32768字節=32KB。
CPU緩存的讀寫與緩存一致性協議 首先是讀,CPU執行一條讀內存字w的指令時是從上往下依次查找的,下層查找到包含字w的緩存行之后,再由下層將該緩存行返回給上一層高速緩存,上一層高速緩存將這個緩存行放在它自己的一個高速緩存行中之后,繼續返回給上一層,直到到達L1。L1將數據行放置到自己的緩存行之后,從被存儲的緩存行中抽取出CPU真正需要的字w,然后將它返回給CPU。大概就是高速緩存確定一個請求是否命中,然后1)組選擇;2)行匹配;3)字抽取。
這里面有一個很重要的地方就是,CPU緩存在不命中的時候,向下層緩存請求的時候,返回的數據是以一個緩存行為單位的,並不是只返回給你想要的單個字,另外當出現不命中沖突的時候,會執行相應的替換策略進行替換。
最后,關於寫分為兩種請況:
1.要寫一個已經緩存了的字w,即寫命中:首先更新本級緩存的w副本之后,怎么更新它的下一級緩存?最簡單是“直寫”,即立即將包含w的高速緩存行寫回到第一層的緩存層, 這樣做雖然簡單,但是你知道CPU每時每刻可能都在進行寫數據,如果大家都不停的寫勢必會產生很大的總線流量,不利於其他數據的處理;另一種方法稱為“寫回”,盡可能的推遲更新,只有當替換策略需要替換掉這個更新過的緩存行時才把它寫回到緊接着的第一層的緩存中,這樣總量流量減少了,但是增加了復雜性,高速緩存行必須額外的維護一個“修改位”,表明這個高速緩存行是否被修改過。
2.寫一個不在緩存中的字,即寫不命中:一種是寫分配,就是把不命中的緩存先加載過來,然后再更新整個緩存行,后面就是寫命中的處理邏輯了;另一種是非寫分配,直接把這個字寫到下一層。
緩存一致性協議MESI
說到CPU緩存的寫操作還有一個很重要的話題,那就是緩存一致性協議MESI。關於緩存一致性協議及其變種又是另一個繁雜的內容,而MESI其實僅僅是眾多一致性協議中最著名的一個,其名字的得名也來至於該協議中對四種緩存狀態的縮寫簡稱,緩存一致性協議規定了如何保證緩存在各個CPU緩存的一致性問題:
以MESI協議為例,每個Cache line有4個狀態,可用2個bit表示,它們分別是:
M(Modified):這行數據有效,但數據被修改了,和內存中的數據不一致,數據只存在於本Cache中。
E(Exclusive):這行數據有效,數據和內存中的數據一致,數據只存在於本Cache中。
S(Shared):這行數據有效,數據和內存中的數據一致,數據存在於很多Cache中。
I(Invalid):這行數據無效。
關於緩存一致性協議,由於其又是一個比較繁多的內容,我這里僅僅粗略的說一下我的理解,總之它是一種保證數據在多個CPU緩存中一致的手段,至於到底是什么樣的手段,根據各個CPU廠商采用的一致性協議的不同而不同,以我目前的了解,主要有以下幾種:
1. 當CPU在修改它的緩存之前,會通過最后一級緩存L3(因為最后一級緩存是多核心共享的)或者總線(多CPU跨插槽的情況)廣播到其他CPU緩存,使其它存在該緩存數據的緩存行無效,然后再更改自己的緩存數據,並標記為M,當其他CPU緩存需要讀取這個被修改過的緩存行時(或者由於沖突不命中需要被置換出去時),會導致立即將這個被修改過的緩存行寫回到內存,然后其他CPU再從內存加載最新的數據到自己的緩存行。
2. 當CPU緩存采用“直寫”這種一更改馬上寫回內存的方式更新緩存的時候,其他CPU通過嗅探技術,從總線上得知相關的緩存行數據失效,則立即使自己相應的緩存行無效,從而再下次讀不命中的時候重新到內存加載最新的數據。
3. 當CPU修改自己的緩存行數據時,主動將相關的更新通過最后一級緩存L3或者總線(如果是多CPU跨插槽的情況)發送給其它存在相關緩存的CPU,使它們同步的更新自己的緩存到一致。
總之,達到CPU緩存一致性的手段層出不窮,並且通過以上3種方式,可以看到在處理緩存一致性問題的時候,如果是單CPU多核心處理器,那么總是免不了使用最后一級緩存L3來傳遞數據,而這還不是最糟糕的,當多處理器跨插槽的時候,數據還要穿過總線跨插槽進行傳輸以保證緩存一致性, 這對性能將是更嚴峻的考驗。這種CPU緩存一致性帶來的問題將是我們在文章開始提出的“偽共享”的根本所在,具體講在下一章節進行說明
02 內核與虛擬內存
電腦或手機開機以后,上電跑啟動代碼,運行OS內核,內核里也有線程,這個我們把它叫做內核態。
內核啟動以后, 內核將物理內存管理起來。內核提供虛擬內存管理機制給每個進程(應用程序App)內存服務。
它的思路是什么呢?每個進程(應用App) 都有自己的虛擬內存空間,注意這里的空間只是一個數字空間,沒有划分實際的物理內存。
這樣做的好處是多個進程(應用App)內存都是獨立的相互不影響,物理內存只有一個,多個進程(應用App)不會因為直接使用物理內存而沖突。
那么OS是如何管理物理內存的呢?進程(應用App)需要內存的時候,OS分配一塊虛擬內存(起點—終點),然后OS再從自己管理的物理內存里面分配出來物理內存頁,然后通過頁表或者段表,將分配的虛擬內存與物理內存映射起來,這樣,讀寫虛擬內存地址最終通過映射來使用物理內存地址,這樣每個進程之間的內存是獨立的,安全的。每個進程會把虛擬內存空間分成4個段(代碼段, 數據段,堆,棧)
1頁表與物理內存映射關系
2段與物理內存映射關系
3頁表加段表與物理內存映射關系
代碼段:用來存放進程(應用App)的代碼指令。
數據段:用來存放全局變量的內存。
堆:調用os的malloc/free 來動態分配的內存。
棧:用來存放局部變量,函數參數,函數調用與跳轉。
每個進程(應用App)相當於一個容器,所有應用App里面需要的資源和機制都在進程里面。
線程是OS獨立調度執行的單元,OS調度執行的單位就是線程,線程需要以進程作為容器和使用進程相關的環境。
應用態沒有進程就不會有線程。
內存分配原理
內存分配策略
關於內存分配,通常有2種比較常用的分配策略:可變大小分配策略、固定尺寸分配策略。
可變大小分配策略,關鍵就在用他們的通用性上,通過他們,用戶可以向系統申請任意大小的內存空間,顯然,這樣的分配方式很靈活,應用也很廣泛,但是他們也有自己致命的缺陷,不過,對於我們來說,影響最大的大約在2個方面: 1、為了滿足用戶要求和系統的要求,不得不做一些額外的工作,效率自然就會有所下降;2、在程序運行期間,可能會有頻繁的內存分配和釋放動作,利用我們已有的數據結構和操作系統的知識,這樣就會在內存中形成大量的、不連續的、不能夠直接使用的內存碎片,在很多情況下,這對於我們的程序都是致命的。如果我們能夠每隔一段時間就重新啟動系統自然就沒有問題,但是有的程序不能夠中斷,就算是能夠中斷,讓用戶每隔一段時間去重起系統也是不現實的(誰還敢用你做的東東?)
固定尺寸分配策略,這個策略的關鍵就在於固定,也就是說,當我們申請內存時,系統總是為我們返回一個固定大小(通常是2的指數倍)的內存空間,而不管我們實際需要內存的大小。和上面我們所說的通用分配策略相比,顯得比較呆板,但是速度更快,不會產生太多的細小碎片。
一般情況下,可變大小分配策略和固定尺寸分配策略經常共同合作,例如,分配器會有一個分界線M,當申請的內存大小小於M的時候,就采用固定尺寸分配,當申請的大小大於M的時候就采用可變大小的分配。其實,在SGI STL里面就是采用的這種混合策略,它采用的分界線是128B,如果申請的內存大小超過了128B,就移交第一級配置器處理,如果小於128B,則采用內存池策略,維護一個16個free-lists的小額區塊。
內存服務層次
內存分配有很多種策略,那么我們怎么知道是誰負責內存分配的呢? 內存的分配服務和存儲結構一樣,也是分層次的: 第一層,操作系統內核提供最基本服務,這是內存分配策略的基礎,也是和硬件系統聯系最緊密的,所以說不同的平台這些服務的特點也是不一樣的。 第二層,編譯器的缺省運行庫提供自己的分配服務,new/malloc提供的就是基於本地的分配服務,具體的服務方式要依賴於不同的編譯器,編譯器的服務可以只對本地分配服務做一層簡單的包裝,沒有考慮任何效率上的強化,例如,new就是對malloc的一層淺包裝。編譯器的服務也可以對本地服務進行重載,使用去合理的方式去分配內存。 第三層,標准容器提供的內存分配服務,和缺省的運行庫提供的服務一樣,他也可以簡單的利用編譯器的服務,例如,SGI中的標准配置器allocator,雖然為了符合STL標准,SGI定義了這個配置器,但是在實際應用中,SGI卻把它拋棄了。也可以對器進行重載,實現自己的策略和優化措施。,例如,SGI中使用的具有此配置能力的SGI空劍配置器。 最外面的一層,用戶自定義的容器和分配器提供的服務,我們可以對容器的分配器實施自己喜歡的方案,也可以對new/delete重載,讓他做我們喜歡的事情。
內存分配的開銷
內存的開銷主要來自兩部分:維護開銷、對齊開銷。
1、維護開銷
在可變大小的分配策略下,在分配的時候,會采用一定的策略去維護分配和釋放內存空間的大小,例如,在VC6下面,就會在分配的內存塊其實位置放一個Cookie,,當進行delete的時候,指針前移4個字節,讀出內存大小size,然后釋放size+4的空間,我們可以用下面的小程序進行簡單的測試:
#include <iostream>
using namespace std;
class A
{
public:
A() {cout<<"A"<<endl;}
int i;
~A() {cout<<"~A"<<endl;}
};
int main()
{
A* pA=new A[5];
int* p=(int*)pA;
*(p-1)=1;
delete []pA;
return 0;
}
對於固定大小分配策略,因為已經知道內存塊的確定大小,自然就不需要這方面的開銷。
2、對齊開銷
struct A
{
char c1;
int i;
char c2;
};
在我們進行如下運算的時候,我們可能會發現sizeof(A)=12,但是char只是占用1個字節,int占用4個字節,加起來也不過6個字節,怎么會多了一倍呢?
這就是對齊現象在起作用,實際占用的空間是這3個變量都占用4個字節,在每一個char型的末尾都會填充3個字節的0。那你把char c2和 int i交換位置,看看結果是多少?怎么解釋呢?
初看起來,只是一種浪費,為什么會有這個特點呢?目的很簡單,就是要使bus運輸量達到最大。
03 進程與線程
上面說過進程是容器,應用態的線程必須要基於進程來創建出來。
那么進程與線程他們之間到底是一個什么樣的關系,接下來我們來分析一下。
例如"在桌面上雙擊打開一個App", 桌面App程序會調用OS的系統調用接口fork,讓OS 創建一個進程出來,OS為你准備好進程的結構體對象,將這個App的文件(xxx.exe, 存放編譯好的代碼指令)加載到進程的代碼段,同時OS會為你創建一個線程(main thread), 在代碼里面,還可以調用OS的接口,來創建多個線程,這樣OS就可以調度這些線程執行了。
虛擬內存空間是進程的概念,那么線程如何使用的呢?各線程使用共享進程的代碼段,數據段,堆,每個線程在進程的棧空間創建一個屬於自己的棧空間。
04 OS如何調度線程的
CPU一般會有多個核心,每個核心都調度一個線程執行。
CPU有幾個核心,最多同時可調度幾個線程(多核能讓電腦更快就是這個原理)。
OS的功能就是要在合適的時候分配CPU核心來調度合適的線程。
為了能實現多任務並發,OS不允許一個OS核心長期固定調度一個線程。
OS是如何調度CPU核心來執行各個線程呢?
OS會根據線程的優先級分配每次調度最多執行的時間片,這個時間一到,無論如何都要重新調度一次線程(也許還是調度到這個線程,這個不重要)。
除了時間片以外,線程會等待某些條件(磁盤讀取文件,網卡發送完數據,線程休眠, 等待用戶操作)這樣也會把這個線程掛起,OS會重新找一個新的線程繼續執行,只到掛起的這個線程的條件滿足了,重新把這個線程放到可調度隊列里面,這個線程又有機會被OS調度CPU核心來執行。
當我們打開電腦的任務管理器,你會發現很多線程的CPU占有率為0%, 說明這些線程都由於某些條件而掛起了,沒有被OS調度。
每個線程“隨時隨地”都可能被OS中斷執行,並調度到其它的線程執行。
OS是如何保證一個線程在調度出去后,再重新調度回來能繼續之前的數據狀態來執行呢?
OS是這么做的:每個線程都會有一個運行時的環境(運行時CPU的每個寄存器的值、棧獨立。棧的內存數據不會變。數據段、堆共用,可能調度回來會變)。
當OS要把某個CPU核心調度出去給其它線程的時候,首先會把當前線程的運行環境(寄存器的值等)保存到內存,然后調度到其它線程,等再次調度回來的時候,再把原來保存到內存的寄存器的值,再設置會CPU核心的寄存器里面,這樣就回到了調度出去之前的進度。
因為多線程之間共用了代碼段(代碼段只讀,不會改),數據段(全局變量調度回來后,可能被其它線程篡改,不是調度之前的那個值了),堆(調度回來后,動態內存分配的對象內存數據可能被其它線程出篡改),調度回來后,棧上的數據是不變的,因為每個線程都有自己的棧空間。線程調度前后哪些會變,哪些不變你要清楚。這樣你寫多線程代碼的時候才能清晰。
線程調度的開銷就是:保存上下文執行環境,內核態運行算法決定接下來調度那個線程,切換這個線程的上下文環境。
05 線程鎖的核心原理是什么?
多線程切換的時候,棧、代碼段的數據不會變,數據段與堆的數據切換前后可能會發生改變,這個就造成了"競爭", 如果某些關鍵數據,在執行代碼的時候,不允許這種競爭性的改變,怎么辦呢?這個時候多線程就給了一個機制,這個機制就是鎖,那么鎖的原理是什么?接下來我來這你詳細講解。
例如: 我編寫一個函數
void funcA() {
lock(鎖); // 要保護的數據的邏輯部分。
unlock(鎖);
}
當線程A調用FuncA(),線程B也調用FUNCA(),OS如何設計鎖能保證他們競爭的唯一性的呢?我們把具體過程來分析一下。
假設線程A調用funcA();它獲取了鎖,執行到中間某個代碼的時候,時間片用完了,被OS調度出去,OS調度線程B來執行funcA(), 當線程B跑到lock(鎖),發現這個鎖已經被線程A拿了,此時,線程B會主動把自己掛起到鎖這個“事件”上(等着鎖釋放)。
OS從新調度線程執行,當重新調度到線程A的時候,線程A執行,執行完成以后,釋放掉這個鎖,那么線程B又從等待這個鎖的隊列,到線程調度的就緒隊列,又可被OS調度到,等線程A調度出去后,線程B去lock這個鎖,就占用了這個鎖,然后繼續執行。這樣就保證了lock/unlock之間的代碼永遠只有一個線程跑進去了。這樣保護了這段代碼里面相關的數據和邏輯。
所以這樣就得到一些結論如下:
每個線程共享進程的代碼段內存空間,所以我們編寫多線程代碼的時候,可以在任何線程調用任何函數。
每個線程共享進程的數據段內存空間,所以我們編寫多線程代碼的時候,可以在任何線程訪問全局變量。
每個線程共享進程的堆,所以我們編寫多線程代碼的時候,可以在一個線程訪問另外一個線程new/malloc出來的內存對象。
每個線程都有自己的棧的空間,所以可以獨立調用執行函數(參數,局部變量,函數跳轉)相互之間不受影響。
由於共享資源可以被不同的線程訪問從而引發了多線程數據一致性問題
多線程
01線程的生命周期
線程池
又以上介紹我們可以看出,在一個應用程序中,我們需要多次使用線程,也就意味着,我們需要多次創建並銷毀線程。而創建並銷毀線程的過程勢必會消耗內存。而在Java中,內存資源是及其寶貴的,所以,我們就提出了線程池的概念。
線程池:Java中開辟出了一種管理線程的概念,這個概念叫做線程池,從概念以及應用場景中,我們可以看出,線程池的好處,就是可以方便的管理線程,也可以減少內存的消耗。
Java中已經提供了創建線程池的一個類:Executor
而我們創建時,一般使用它的子類:ThreadPoolExecutor
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
這是其中最重要的一個構造方法,這個方法決定了創建出來的線程池的各種屬性,下面依靠一張圖來更好的理解線程池和這幾個參數:
線程池參數
線程池中的corePoolSize就是線程池中的核心線程數量,這幾個核心線程,只是在沒有用的時候,也不會被回收,maximumPoolSize就是線程池中可以容納的最大線程的數量,而keepAliveTime,就是線程池中除了核心線程之外的其他的最長可以保留的時間,因為在線程池中,除了核心線程即使在無任務的情況下也不能被清除,其余的都是有存活時間的,意思就是非核心線程可以保留的最長的空閑時間,而util,就是計算這個時間的一個單位,workQueue,就是等待隊列,任務可以儲存在任務隊列中等待被執行,執行的是FIFIO原則(先進先出)。threadFactory,就是創建線程的線程工廠,最后一個handler,是一種拒絕策略,我們可以在任務滿了知乎,拒絕執行某些任務。
線程池的執行流程又是怎樣的呢?
有圖我們可以看出,任務進來時,首先執行判斷,判斷核心線程是否處於空閑狀態,如果不是,核心線程就先就執行任務,如果核心線程已滿,則判斷任務隊列是否有地方存放該任務,若果有,就將任務保存在任務隊列中,等待執行,如果滿了,在判斷最大可容納的線程數,如果沒有超出這個數量,就開創非核心線程執行任務,如果超出了,就調用handler實現拒絕策略。
handler的拒絕策略:
第一種AbortPolicy:不執行新任務,直接拋出異常,提示線程池已滿
第二種DisCardPolicy:不執行新任務,也不拋出異常
第三種DisCardOldSetPolicy:將消息隊列中的第一個任務替換為當前新進來的任務執行
第四種CallerRunsPolicy:直接調用execute來執行當前任務
四種常見的線程池:
CachedThreadPool:可緩存的線程池,該線程池中沒有核心線程,非核心線程的數量為Integer.max_value,就是無限大,當有需要時創建線程來執行任務,沒有需要時回收線程,適用於耗時少,任務量大的情況。
SecudleThreadPool:周期性執行任務的線程池,按照某種特定的計划執行線程中的任務,有核心線程,但也有非核心線程,非核心線程的大小也為無限大。適用於執行周期性的任務。
SingleThreadPool:只有一條線程來執行任務,適用於有順序的任務的應用場景。
FixedThreadPool:定長的線程池,有核心線程,核心線程的即為最大的線程數量,沒有非核心線程