你不好奇 CPU 是如何執行任務(wù)的?
你清楚下面這幾個(gè)問(wèn)題嗎?
本文引用地址:http://dyxdggzs.com/article/202011/420225.htm有了內存,為什么還需要 CPU Cache?
CPU 是怎么讀寫(xiě)數據的?
如何讓 CPU 能讀取數據更快一些?
CPU 偽共享是如何發(fā)生的?又該如何避免?
CPU 是如何調度任務(wù)的?如果你的任務(wù)對響應要求很高,你希望它總是能被先調度,這該怎么辦?
…
這篇,我們就來(lái)回答這些問(wèn)題。
CPU 如何讀寫(xiě)數據的?
先來(lái)認識 CPU 的架構,只有理解了 CPU 的 架構,才能更好地理解 CPU 是如何讀寫(xiě)數據的,對于現代 CPU 的架構圖如下:
可以看到,一個(gè) CPU 里通常會(huì )有多個(gè) CPU 核心,比如上圖中的 1 號和 2 號 CPU 核心,并且每個(gè) CPU 核心都有自己的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分為 dCache(數據緩存) 和 iCache(指令緩存),L3 Cache 則是多個(gè)核心共享的,這就是 CPU 典型的緩存層次。
上面提到的都是 CPU 內部的 Cache,放眼外部的話(huà),還會(huì )有內存和硬盤(pán),這些存儲設備共同構成了金字塔存儲層次。如下圖所示:
從上圖也可以看到,從上往下,存儲設備的容量會(huì )越大,而訪(fǎng)問(wèn)速度會(huì )越慢。至于每個(gè)存儲設備的訪(fǎng)問(wèn)延時(shí),你可以看下圖的表格:
你可以看到, CPU 訪(fǎng)問(wèn) L1 Cache 速度比訪(fǎng)問(wèn)內存快 100 倍,這就是為什么 CPU 里會(huì )有 L1~L3 Cache 的原因,目的就是把 Cache 作為 CPU 與內存之間的緩存層,以減少對內存的訪(fǎng)問(wèn)頻率。
CPU 從內存中讀取數據到 Cache 的時(shí)候,并不是一個(gè)字節一個(gè)字節讀取,而是一塊一塊的方式來(lái)讀取數據的,這一塊一塊的數據被稱(chēng)為 CPU Line(緩存行),所以 CPU Line 是 CPU 從內存讀取數據到 Cache 的單位。
至于 CPU Line 大小,在 Linux 系統可以用下面的方式查看到,你可以看我服務(wù)器的 L1 Cache Line 大小是 64 字節,也就意味著(zhù) L1 Cache 一次載入數據的大小是 64 字節。
那么對數組的加載, CPU 就會(huì )加載數組里面連續的多個(gè)數據到 Cache 里,因此我們應該按照物理內存地址分布的順序去訪(fǎng)問(wèn)元素,這樣訪(fǎng)問(wèn)數組元素的時(shí)候,Cache 命中率就會(huì )很高,于是就能減少從內存讀取數據的頻率, 從而可提高程序的性能。
但是,在我們不使用數組,而是使用單獨的變量的時(shí)候,則會(huì )有 Cache 偽共享的問(wèn)題,Cache 偽共享問(wèn)題上是一個(gè)性能殺手,我們應該要規避它。
接下來(lái),就來(lái)看看 Cache 偽共享是什么?又如何避免這個(gè)問(wèn)題?
現在假設有一個(gè)雙核心的 CPU,這兩個(gè) CPU 核心并行運行著(zhù)兩個(gè)不同的線(xiàn)程,它們同時(shí)從內存中讀取兩個(gè)不同的數據,分別是類(lèi)型為 long 的變量 A 和 B,這個(gè)兩個(gè)數據的地址在物理內存上是連續的,如果 Cahce Line 的大小是 64 字節,并且變量 A 在 Cahce Line 的開(kāi)頭位置,那么這兩個(gè)數據是位于同一個(gè) Cache Line 中,又因為 CPU Line 是 CPU 從內存讀取數據到 Cache 的單位,所以這兩個(gè)數據會(huì )被同時(shí)讀入到了兩個(gè) CPU 核心中各自 Cache 中。
我們來(lái)思考一個(gè)問(wèn)題,如果這兩個(gè)不同核心的線(xiàn)程分別修改不同的數據,比如 1 號 CPU 核心的線(xiàn)程只修改了 變量 A,或 2 號 CPU 核心的線(xiàn)程的線(xiàn)程只修改了變量 B,會(huì )發(fā)生什么呢?
分析偽共享的問(wèn)題
現在我們結合保證多核緩存一致的 MESI 協(xié)議,來(lái)說(shuō)明這一整個(gè)的過(guò)程,如果你還不知道 MESI 協(xié)議,你可以看我這篇文章「10 張圖打開(kāi) CPU 緩存一致性的大門(mén)」。
①最開(kāi)始變量 A 和 B 都還不在 Cache 里面,假設 1 號核心綁定了線(xiàn)程 A,2 號核心綁定了線(xiàn)程 B,線(xiàn)程 A 只會(huì )讀寫(xiě)變量 A,線(xiàn)程 B 只會(huì )讀寫(xiě)變量 B。
②1 號核心讀取變量 A,由于 CPU 從內存讀取數據到 Cache 的單位是 Cache Line,也正好變量 A 和 變量 B 的數據歸屬于同一個(gè) Cache Line,所以 A 和 B 的數據都會(huì )被加載到 Cache,并將此 Cache Line 標記為「獨占」狀態(tài)。
③接著(zhù),2 號核心開(kāi)始從內存里讀取變量 B,同樣的也是讀取 Cache Line 大小的數據到 Cache 中,此 Cache Line 中的數據也包含了變量 A 和 變量 B,此時(shí) 1 號和 2 號核心的 Cache Line 狀態(tài)變?yōu)椤腹蚕怼範顟B(tài)。
④1 號核心需要修改變量 A,發(fā)現此 Cache Line 的狀態(tài)是「共享」狀態(tài),所以先需要通過(guò)總線(xiàn)發(fā)送消息給 2 號核心,通知 2 號核心把 Cache 中對應的 Cache Line 標記為「已失效」狀態(tài),然后 1 號核心對應的 Cache Line 狀態(tài)變成「已修改」狀態(tài),并且修改變量 A。
⑤之后,2 號核心需要修改變量 B,此時(shí) 2 號核心的 Cache 中對應的 Cache Line 是已失效狀態(tài),另外由于 1 號核心的 Cache 也有此相同的數據,且狀態(tài)為「已修改」狀態(tài),所以要先把 1 號核心的 Cache 對應的 Cache Line 寫(xiě)回到內存,然后 2 號核心再從內存讀取 Cache Line 大小的數據到 Cache 中,最后把變量 B 修改到 2 號核心的 Cache 中,并將狀態(tài)標記為「已修改」狀態(tài)。
所以,可以發(fā)現如果 1 號和 2 號 CPU 核心這樣持續交替的分別修改變量 A 和 B,就會(huì )重復 ④ 和 ⑤ 這兩個(gè)步驟,Cache 并沒(méi)有起到緩存的效果,雖然變量 A 和 B 之間其實(shí)并沒(méi)有任何的關(guān)系,但是因為同時(shí)歸屬于一個(gè) Cache Line ,這個(gè) Cache Line 中的任意數據被修改后,都會(huì )相互影響,從而出現 ④ 和 ⑤ 這兩個(gè)步驟。
因此,這種因為多個(gè)線(xiàn)程同時(shí)讀寫(xiě)同一個(gè) Cache Line 的不同變量時(shí),而導致 CPU Cache 失效的現象稱(chēng)為偽共享(False Sharing)。
避免偽共享的方法
因此,對于多個(gè)線(xiàn)程共享的熱點(diǎn)數據,即經(jīng)常會(huì )修改的數據,應該避免這些數據剛好在同一個(gè) Cache Line 中,否則就會(huì )出現為偽共享的問(wèn)題。
接下來(lái),看看在實(shí)際項目中是用什么方式來(lái)避免偽共享的問(wèn)題的。
在 Linux 內核中存在 __cacheline_aligned_in_smp 宏定義,是用于解決偽共享的問(wèn)題。
從上面的宏定義,我們可以看到:
如果在多核(MP)系統里,該宏定義是 __cacheline_aligned,也就是 Cache Line 的大??;
而如果在單核系統里,該宏定義是空的;
因此,針對在同一個(gè) Cache Line 中的共享的數據,如果在多核之間競爭比較嚴重,為了防止偽共享現象的發(fā)生,可以采用上面的宏定義使得變量在 Cache Line 里是對齊的。
舉個(gè)例子,有下面這個(gè)結構體:
結構體里的兩個(gè)成員變量 a 和 b 在物理內存地址上是連續的,于是它們可能會(huì )位于同一個(gè) Cache Line 中,如下圖:
所以,為了防止前面提到的 Cache 偽共享問(wèn)題,我們可以使用上面介紹的宏定義,將 b 的地址設置為 Cache Line 對齊地址,如下:
這樣 a 和 b 變量就不會(huì )在同一個(gè) Cache Line 中了,如下圖:
所以,避免 Cache 偽共享實(shí)際上是用空間換時(shí)間的思想,浪費一部分 Cache 空間,從而換來(lái)性能的提升。
我們再來(lái)看一個(gè)應用層面的規避方案,有一個(gè) Java 并發(fā)框架 Disruptor 使用「字節填充 + 繼承」的方式,來(lái)避免偽共享的問(wèn)題。
Disruptor 中有一個(gè) RingBuffer 類(lèi)會(huì )經(jīng)常被多個(gè)線(xiàn)程使用,代碼如下:
你可能會(huì )覺(jué)得 RingBufferPad 類(lèi)里 7 個(gè) long 類(lèi)型的名字很奇怪,但事實(shí)上,它們雖然看起來(lái)毫無(wú)作用,但卻對性能的提升起到了至關(guān)重要的作用。
我們都知道,CPU Cache 從內存讀取數據的單位是 CPU Line,一般 64 位 CPU 的 CPU Line 的大小是 64 個(gè)字節,一個(gè) long 類(lèi)型的數據是 8 個(gè)字節,所以 CPU 一下會(huì )加載 8 個(gè) long 類(lèi)型的數據。
根據 JVM 對象繼承關(guān)系中父類(lèi)成員和子類(lèi)成員,內存地址是連續排列布局的,因此 RingBufferPad 中的 7 個(gè) long 類(lèi)型數據作為 Cache Line 前置填充,而 RingBuffer 中的 7 個(gè) long 類(lèi)型數據則作為 Cache Line 后置填充,這 14 個(gè) long 變量沒(méi)有任何實(shí)際用途,更不會(huì )對它們進(jìn)行讀寫(xiě)操作。
另外,RingBufferFelds 里面定義的這些變量都是 final 修飾的,意味著(zhù)第一次加載之后不會(huì )再修改, 又由于「前后」各填充了 7 個(gè)不會(huì )被讀寫(xiě)的 long 類(lèi)型變量,所以無(wú)論怎么加載 Cache Line,這整個(gè) Cache Line 里都沒(méi)有會(huì )發(fā)生更新操作的數據,于是只要數據被頻繁地讀取訪(fǎng)問(wèn),就自然沒(méi)有數據被換出 Cache 的可能,也因此不會(huì )產(chǎn)生偽共享的問(wèn)題。
CPU 如何選擇線(xiàn)程的?
了解完 CPU 讀取數據的過(guò)程后,我們再來(lái)看看 CPU 是根據什么來(lái)選擇當前要執行的線(xiàn)程。
在 Linux 內核中,進(jìn)程和線(xiàn)程都是用 tark_struct 結構體表示的,區別在于線(xiàn)程的 tark_struct 結構體里部分資源是共享了進(jìn)程已創(chuàng )建的資源,比如內存地址空間、代碼段、文件描述符等,所以 Linux 中的線(xiàn)程也被稱(chēng)為輕量級進(jìn)程,因為線(xiàn)程的 tark_struct 相比進(jìn)程的 tark_struct 承載的 資源比較少,因此以「輕」得名。
一般來(lái)說(shuō),沒(méi)有創(chuàng )建線(xiàn)程的進(jìn)程,是只有單個(gè)執行流,它被稱(chēng)為是主線(xiàn)程。如果想讓進(jìn)程處理更多的事情,可以創(chuàng )建多個(gè)線(xiàn)程分別去處理,但不管怎么樣,它們對應到內核里都是 tark_struct。
所以,Linux 內核里的調度器,調度的對象就是 tark_struct,接下來(lái)我們就把這個(gè)數據結構統稱(chēng)為任務(wù)。
在 Linux 系統中,根據任務(wù)的優(yōu)先級以及響應要求,主要分為兩種,其中優(yōu)先級的數值越小,優(yōu)先級越高:
實(shí)時(shí)任務(wù),對系統的響應時(shí)間要求很高,也就是要盡可能快的執行實(shí)時(shí)任務(wù),優(yōu)先級在 0~99 范圍內的就算實(shí)時(shí)任務(wù);
普通任務(wù),響應時(shí)間沒(méi)有很高的要求,優(yōu)先級在 100~139 范圍內都是普通任務(wù)級別;
調度類(lèi)
由于任務(wù)有優(yōu)先級之分,Linux 系統為了保障高優(yōu)先級的任務(wù)能夠盡可能早的被執行,于是分為了這幾種調度類(lèi),如下圖:
Deadline 和 Realtime 這兩個(gè)調度類(lèi),都是應用于實(shí)時(shí)任務(wù)的,這兩個(gè)調度類(lèi)的調度策略合起來(lái)共有這三種,它們的作用如下:
SCHED_DEADLINE:是按照 deadline 進(jìn)行調度的,距離當前時(shí)間點(diǎn)最近的 deadline 的任務(wù)會(huì )被優(yōu)先調度;
SCHED_FIFO:對于相同優(yōu)先級的任務(wù),按先來(lái)先服務(wù)的原則,但是優(yōu)先級更高的任務(wù),可以搶占低優(yōu)先級的任務(wù),也就是優(yōu)先級高的可以「插隊」;
SCHED_RR:對于相同優(yōu)先級的任務(wù),輪流著(zhù)運行,每個(gè)任務(wù)都有一定的時(shí)間片,當用完時(shí)間片的任務(wù)會(huì )被放到隊列尾部,以保證相同優(yōu)先級任務(wù)的公平性,但是高優(yōu)先級的任務(wù)依然可以搶占低優(yōu)先級的任務(wù);
而 Fair 調度類(lèi)是應用于普通任務(wù),都是由 CFS 調度器管理的,分為兩種調度策略:
SCHED_NORMAL:普通任務(wù)使用的調度策略;
SCHED_BATCH:后臺任務(wù)的調度策略,不和終端進(jìn)行交互,因此在不影響其他需要交互的任務(wù),可以適當降低它的優(yōu)先級。
完全公平調度
我們平日里遇到的基本都是普通任務(wù),對于普通任務(wù)來(lái)說(shuō),公平性最重要,在 Linux 里面,實(shí)現了一個(gè)基于 CFS 的調度算法,也就是完全公平調度(Completely Fair Scheduling)。
這個(gè)算法的理念是想讓分配給每個(gè)任務(wù)的 CPU 時(shí)間是一樣,于是它為每個(gè)任務(wù)安排一個(gè)虛擬運行時(shí)間 vruntime,如果一個(gè)任務(wù)在運行,其運行的越久,該任務(wù)的 vruntime 自然就會(huì )越大,而沒(méi)有被運行的任務(wù),vruntime 是不會(huì )變化的。
那么,在 CFS 算法調度的時(shí)候,會(huì )優(yōu)先選擇 vruntime 少的任務(wù),以保證每個(gè)任務(wù)的公平性。
這就好比,讓你把一桶的奶茶平均分到 10 杯奶茶杯里,你看著(zhù)哪杯奶茶少,就多倒一些;哪個(gè)多了,就先不倒,這樣經(jīng)過(guò)多輪操作,雖然不能保證每杯奶茶完全一樣多,但至少是公平的。
當然,上面提到的例子沒(méi)有考慮到優(yōu)先級的問(wèn)題,雖然是普通任務(wù),但是普通任務(wù)之間還是有優(yōu)先級區分的,所以在計算虛擬運行時(shí)間 vruntime 還要考慮普通任務(wù)的權重值,注意權重值并不是優(yōu)先級的值,內核中會(huì )有一個(gè) nice 級別與權重值的轉換表,nice 級別越低的權重值就越大,至于 nice 值是什么,我們后面會(huì )提到。
于是就有了以下這個(gè)公式:
你可以不用管 NICE_0_LOAD 是什么,你就認為它是一個(gè)常量,那么在「同樣的實(shí)際運行時(shí)間」里,高權重任務(wù)的 vruntime 比低權重任務(wù)的 vruntime 少,你可能會(huì )奇怪為什么是少的?你還記得 CFS 調度嗎,它是會(huì )優(yōu)先選擇 vruntime 少的任務(wù)進(jìn)行調度,所以高權重的任務(wù)就會(huì )被優(yōu)先調度了,于是高權重的獲得的實(shí)際運行時(shí)間自然就多了。
CPU 運行隊列
一個(gè)系統通常都會(huì )運行著(zhù)很多任務(wù),多任務(wù)的數量基本都是遠超 CPU 核心數量,因此這時(shí)候就需要排隊。
事實(shí)上,每個(gè) CPU 都有自己的運行隊列(Run Queue, rq),用于描述在此 CPU 上所運行的所有進(jìn)程,其隊列包含三個(gè)運行隊列,Deadline 運行隊列 dl_rq、實(shí)時(shí)任務(wù)運行隊列 rt_rq 和 CFS 運行隊列 csf_rq,其中 csf_rq 是用紅黑樹(shù)來(lái)描述的,按 vruntime 大小來(lái)排序的,最左側的葉子節點(diǎn),就是下次會(huì )被調度的任務(wù)。
這幾種調度類(lèi)是有優(yōu)先級的,優(yōu)先級如下:Deadline > Realtime > Fair,這意味著(zhù) Linux 選擇下一個(gè)任務(wù)執行的時(shí)候,會(huì )按照此優(yōu)先級順序進(jìn)行選擇,也就是說(shuō)先從 dl_rq 里選擇任務(wù),然后從 rt_rq 里選擇任務(wù),最后從 csf_rq 里選擇任務(wù)。因此,實(shí)時(shí)任務(wù)總是會(huì )比普通任務(wù)優(yōu)先被執行。
調整優(yōu)先級
如果我們啟動(dòng)任務(wù)的時(shí)候,沒(méi)有特意去指定優(yōu)先級的話(huà),默認情況下都是普通任務(wù),普通任務(wù)的調度類(lèi)是 Fail,由 CFS 調度器來(lái)進(jìn)行管理。CFS 調度器的目的是實(shí)現任務(wù)運行的公平性,也就是保障每個(gè)任務(wù)的運行的時(shí)間是差不多的。
如果你想讓某個(gè)普通任務(wù)有更多的執行時(shí)間,可以調整任務(wù)的 nice 值,從而讓優(yōu)先級高一些的任務(wù)執行更多時(shí)間。nice 的值能設置的范圍是 -20~19, 值越低,表明優(yōu)先級越高,因此 -20 是最高優(yōu)先級,19 則是最低優(yōu)先級,默認優(yōu)先級是 0。
是不是覺(jué)得 nice 值的范圍很詭異?事實(shí)上,nice 值并不是表示優(yōu)先級,而是表示優(yōu)先級的修正數值,它與優(yōu)先級(priority)的關(guān)系是這樣的:priority(new) = priority(old) + nice。內核中,priority 的范圍是 0~139,值越低,優(yōu)先級越高,其中前面的 0~99 范圍是提供給實(shí)時(shí)任務(wù)使用的,而 nice 值是映射到 100~139,這個(gè)范圍是提供給普通任務(wù)用的,因此 nice 值調整的是普通任務(wù)的優(yōu)先級。
在前面我們提到了,權重值與 nice 值的關(guān)系的,nice 值越低,權重值就越大,計算出來(lái)的 vruntime 就會(huì )越少,由于 CFS 算法調度的時(shí)候,就會(huì )優(yōu)先選擇 vruntime 少的任務(wù)進(jìn)行執行,所以 nice 值越低,任務(wù)的優(yōu)先級就越高。
我們可以在啟動(dòng)任務(wù)的時(shí)候,可以指定 nice 的值,比如將 mysqld 以 -3 優(yōu)先級:
如果想修改已經(jīng)運行中的任務(wù)的優(yōu)先級,則可以使用 renice 來(lái)調整 nice 值:
nice 調整的是普通任務(wù)的優(yōu)先級,所以不管怎么縮小 nice 值,任務(wù)永遠都是普通任務(wù),如果某些任務(wù)要求實(shí)時(shí)性比較高,那么你可以考慮改變任務(wù)的優(yōu)先級以及調度策略,使得它變成實(shí)時(shí)任務(wù),比如:
總結
理解 CPU 是如何讀寫(xiě)數據的前提,是要理解 CPU 的架構,CPU 內部的多個(gè) Cache + 外部的內存和磁盤(pán)都就構成了金字塔的存儲器結構,在這個(gè)金字塔中,越往下,存儲器的容量就越大,但訪(fǎng)問(wèn)速度就會(huì )小。
CPU 讀寫(xiě)數據的時(shí)候,并不是按一個(gè)一個(gè)字節為單位來(lái)進(jìn)行讀寫(xiě),而是以 CPU Line 大小為單位,CPU Line 大小一般是 64 個(gè)字節,也就意味著(zhù) CPU 讀寫(xiě)數據的時(shí)候,每一次都是以 64 字節大小為一塊進(jìn)行操作。
因此,如果我們操作的數據是數組,那么訪(fǎng)問(wèn)數組元素的時(shí)候,按內存分布的地址順序進(jìn)行訪(fǎng)問(wèn),這樣能充分利用到 Cache,程序的性能得到提升。但如果操作的數據不是數組,而是普通的變量,并在多核 CPU 的情況下,我們還需要避免 Cache Line 偽共享的問(wèn)題。
所謂的 Cache Line 偽共享問(wèn)題就是,多個(gè)線(xiàn)程同時(shí)讀寫(xiě)同一個(gè) Cache Line 的不同變量時(shí),而導致 CPU Cache 失效的現象。那么對于多個(gè)線(xiàn)程共享的熱點(diǎn)數據,即經(jīng)常會(huì )修改的數據,應該避免這些數據剛好在同一個(gè) Cache Line 中,避免的方式一般有 Cache Line 大小字節對齊,以及字節填充等方法。
系統中需要運行的多線(xiàn)程數一般都會(huì )大于 CPU 核心,這樣就會(huì )導致線(xiàn)程排隊等待 CPU,這可能會(huì )產(chǎn)生一定的延時(shí),如果我們的任務(wù)對延時(shí)容忍度很低,則可以通過(guò)一些人為手段干預 Linux 的默認調度策略和優(yōu)先級。
評論