我在多年的工程生涯中發現很多工程師碰到一個共性的問題:Linux工程師很多,甚至有很多有多年工作經驗,但是對一些關鍵概念的理解非常模糊,比如不理解CPU、記憶體資源等的真正分佈,具體的工作機制,這使得他們對很多問題的分析都摸不到方向。比如進程的調度延時是多少?Linux能否硬實時?多核下多線程如何執... ...
我在多年的工程生涯中發現很多工程師碰到一個共性的問題:Linux工程師很多,甚至有很多有多年工作經驗,但是對一些關鍵概念的理解非常模糊,比如不理解CPU、記憶體資源等的真正分佈,具體的工作機制,這使得他們對很多問題的分析都摸不到方向。比如進程的調度延時是多少?Linux能否硬實時?多核下多線程如何執行?系統的記憶體究竟耗到哪裡去了?我寫的應用程式究竟耗了多少記憶體?什麼是記憶體泄漏,如何判定記憶體是否真的泄漏?CPU速度、記憶體大小和系統性能的關聯究竟是什麼?記憶體和I/O存在著怎樣的千絲萬縷的聯繫?
若不能回答上述問題,勢必造成Linux開發過程中的抓瞎,出現關鍵bug和性能問題後丈二摸不著。從某種意義上來說,進程調度和記憶體管理之於Linux,類似任督兩脈之於人體。任督兩脈屬於奇經八脈,任脈主血,為陰脈之海;督脈主氣,為陽脈之海。任督兩脈分別對十二正經脈中的手足六陰經與六陽經脈起著主導作用,任督通則百脈皆通。對進程調度和記憶體管理的理解,可以極大地打通我們對Linux系統架構,性能瓶頸,進程資源消耗等一系列問題的理解。
但是,對這兩個知識點的理解,本身有一定的難度,尤其是記憶體管理,看資料都很難看懂。若調度器是懸疑驚悚片鬼才大衛·林奇的《穆赫蘭道》,記憶體管理則極似他的《內陸帝國》,為Linux最晦澀的部分。坦白講,《穆赫蘭道》給我的感覺是晦澀而驚艷,而《內陸帝國》讓我感覺到自己在吃屎,實在是只有陰暗、晦澀、看不到希望。
我在學習Linux記憶體管理的時候,同樣有看《內陸帝國》的強烈不愉悅感,整部電影構造的弗洛伊德《夢的解析》的世界有太多蒼白的細節,沉悶的對白,陰暗的畫面,而沒有一個最初層疊的整體概念。逃離這個噩夢,唯一的方法,我們勢必應該以一種最簡單可靠地方式來理解進程調度和記憶體管理的精髓,這個時候,細節已經顯得不那麼重要,而concept則需要吃透再吃透。很多人讀Linux的書陷入了紛繁蕪雜的細節,而沒有理解concept,這個時候,細節會顯得那麼蒼白無力和流離失所。所以,我們更有必要明確每一個工作機制,以及這些工作機制背後的原因,此後,細節只是一個具體的實現。細節是會變的,唯概念不破。
帶著問題上路
一切的學習都是為瞭解決問題,而不是為了學習而學習。為了學習而學習,這種行為實在是太傻了,因為最終也學不好。所以我們要弄清楚進程調度和記憶體管理究竟能解決什麼樣的問題。
Linux進程調度以及配套的進程管理回答如下問題:
1.Linux進程和線程如何創建、退出?進程退出的時候,自己沒有釋放的資源(如記憶體沒有free)會怎樣?
2.什麼是寫時拷貝?
3.Linux的線程如何實現,與進程的本質區別是什麼?
4.Linux能否滿足硬實時的需求?
5.進程如何睡眠等資源,此後又如何被喚醒?
6.進程的調度延時是多少?
7.調度器追求的吞吐率和響應延遲之間是什麼關係?CPU消耗型和I/O消耗型進程的訴求?
8.Linux怎麼區分進程優先順序?實時的調度策略和普通調度策略有什麼區別?
9.nice值的作用是什麼?nice值低有什麼優勢?
10.Linux可以被改造成硬實時嗎?有什麼方案?
11.多核、多線程的情況下,Linux如何實現進程的負載均衡?
12.這麼多線程,究竟哪個線程在哪個CPU核上跑?有沒有辦法把某個線程固定到某個CPU跑?
13.多核下如何實現中斷、軟中斷的負載均衡?
14.如何利用cgroup對進行進程分組,並調控各個group的CPU資源?
15.CPU利用率和CPU負載之間的關係?CPU負載高一定用戶體驗差嗎?
Linux記憶體管理回答如下問題:
1.Linux系統的記憶體用掉了多少,還剩餘多少?下麵這個free命令每一個數字是什麼意思?
2.為什麼要有DMA、NORMAL、HIGHMEM zone?每個zone的大小是由誰決定的?
3.系統的記憶體是如何被內核和應用瓜分掉的?
4.底層的記憶體管理演算法buddy是怎麼工作的?它和內核裡面的slab分配器是什麼關係?
5.頻繁的記憶體申請和釋放是否會導致記憶體的碎片化?它的後果是什麼?
6.Linux記憶體耗盡後,系統會發生怎樣的情況?
7.應用程式的記憶體是什麼時候拿到的?malloc()成功後,是否真的拿到了記憶體?應用程式的malloc()與free()與內核的關係究竟是什麼?
8.什麼是lazy分配機制?應用的記憶體為什麼會延後以最懶惰的方式拿到?
9.我寫的應用究竟耗費了多少記憶體?進程的vss/rss/pss/uss分別是什麼概念?虛擬的,真實的,共用的,獨占的,究竟哪個是哪個?
10.記憶體為什麼要做文件系統的緩存?如何做?緩存何時放棄?
11.Free命令裡面顯示的buffers和cached分別是什麼?二者有何區別?
12.交換分區、虛擬記憶體究竟是什麼鬼?它們針對的是什麼性質的記憶體?什麼是匿名頁?
13.進程耗費的記憶體、文件系統的緩存何時回收?回收的演算法是不是類似LRU?
14.怎樣追蹤和判決發生了記憶體泄漏?記憶體泄漏後如何查找泄漏源?
15.記憶體大小這樣影響系統的性能?CPU、記憶體、I/O三角如何互動?它們如何綜合決定系統的一些關鍵性能?
以上問題,如果您都能回答,那麼恭喜您,您是一個概念清楚的人,Linux出現吞吐低、延遲大、響應慢等問題的時候,你可以找到一個可能的方向。如果您只能回答低於1/3的問題,那麼,Linux對您仍然是一片空白,出現問題,您只會陷入瞎貓子亂抓,而撈不到耗子的困境,或者胡亂地意測問題,陷入不斷的低水平重試。
試圖回答這些問題
本文的目的不是回答這些問題,因為回答這些問題,需要洋洋灑灑數百頁的文檔,而本文檔不會超過10頁。所以,本文的目的是試圖給出一個回答這些問題的思考問題的出發點,我們倡導面對任何問題的時候,先要弄明白系統的設計目標。
吞吐vs.響應
首先我們在思考調度器的時候,我們要理解任何操作系統的調度器設計只追求2個目標:吞吐率大和延遲低。這2個目標有點類似零和游戲,因為吞吐率要大,勢必要把更多的時間放在做真實的有用功,而不是把時間浪費在頻繁的進程上下文切換;而延遲要低,勢必要求優先順序高的進程可以隨時搶占進來,打斷別人,強行插隊。但是,搶占會引起上下文切換,上下文切換的時間本身對吞吐率來講,是一個消耗,這個消耗可以低到2us或者更低(這看起來沒什麼?),但是上下文切換更大的消耗不是切換本身,而是切換會引起大量的cache miss。你明明weibo跑的很爽,現在切過去微信,那麼CPU的cache是不太容易命中微信的。
不搶肯定響應差,搶了吞吐會下降。Linux不是一個完全照顧吞吐的系統,也不是一個完全照顧響應的系統,它作為一個軟實時的操作系統,實際上是想達到某種平衡,同時也提供給用戶一定的配置能力,在內核編譯的時候,Kernel Features ---> Preemption Model選項實際上可以讓我們編譯內核的時候,是傾向於支持吞吐,還是支持響應:
越往上面選,吞吐越好,越好下麵選,響應越好。伺服器你一個月也難得用一次滑鼠,而桌面則顯然要求一定的響應,這樣可以保證UI行為的表現較好。但是Linux即便選擇的是最後一個選項“Preemptible Kernel (Low-Latency Desktop)”,它仍然不是硬實時的。因為,在Linux有三類區間是不可以搶占調度的,這三類區間是:
-
中斷
-
軟中斷
-
持有類似spin_lock這樣的鎖而鎖住該CPU核調度的情況
如下圖,一個綠色的普通進程在T1時刻持有spin_lock進入一個critical section(該核調度被關),綠色進程T2時刻被中斷打斷,而後T3時刻IRQ1裡面喚醒了紅色的RT進程(如果是硬實時RTOS,這個時候RT進程應該能搶入),之後IRQ1後又執行了IRQ2,到T4時刻IRQ1和IRQ2都結束了,紅色RT進程仍然不能執行(因為綠色進程還在spin_lock裡面),直到T5時刻,普通進程釋放spin_lock後,紅色RT進程才搶入。從T3到T5要多久,鬼都不知道,這樣就無法滿足硬實時系統的“可預期”的確定性的延遲性,因此Linux不是硬實時操作系統。
Linux的preempt-rt補丁試圖把中斷、軟中斷線程化,變成可以被搶占的區間,而把會關本核調度器的spin_lock替換為可以調度的mutex,它實現了在T3時刻喚醒RT進程的時刻,RT進程可以立即搶占調度進入的目標,避免了T3-T5之間延遲的非確定性。
CPU消耗型 vs. I/O消耗型
在Linux運行的進程,分為2類,一類是CPU消耗型(狂算),一類是I/O消耗型(狂睡,等I/O),前者CPU利用率高,後者CPU利用率低。一般而言,I/O消耗型任務對延遲比較敏感,應該被優先調度。比如,你正在瘋狂編譯安卓,而等滑鼠行為的用戶界面老不工作(正在狂睡),但是滑鼠一點,我們應該優先打斷正在編譯的進程,而去響應滑鼠這個I/O,這樣電腦的用戶體驗才符合人性。
Linux的進程,對於RT進程而言,按照SCHED_FIFO和SCHED_RR的策略,優先順序高先執行;優先順序高的睡眠了後優先順序的執行;同等優先順序的SCHED_FIFO先ready的跑到睡,後ready的接著跑;而同等優先順序的RR則進行時間片輪轉。比如Linux存在如下4個進程,T1~T4(內核裡面優先順序數字越低,優先順序越高):
那麼它們在Linux的跑法就是:
RT的進程調度有一點“惡霸”色彩,我高優先順序的沒睡,低優先順序的你就靠邊站。但是Linux的絕大多數進程都不是RT的進程,而是採用SCHED_NORMAL策略(這符合蜘蛛俠法則)。NORMAL的人比較善良,我們一般用nice來形容它們的優先順序,nice越高,優先順序越低(你越nice,就越喜歡在地鐵讓座,當然越坐不到座位)。普通進程的跑法,並不是nice低的一定堵著nice高的(要不然還說什麼“善良”),它是按照如下公式進行:
vruntime = pruntime * NICE_0_LOAD/ weight
其中NICE_0_LOAD是1024,也就是NICE是0的進程的weight。vruntime是進程的虛擬運行時間,pruntime是物理運行時間,weight是權重,權重完全由nice決定,如下表:
在RT進程都睡過去之後(有一個特例就是RT沒睡也會跑普通進程,那就是RT加起來跑地實在太久太久,普通進程必須喝點湯了),Linux開始跑NORMAL的,它傾向於調度vruntime(虛擬運行時間)最小的普通進程,根據我們小學數學知識,vruntime要小,要麼分子小(喜歡睡,I/O型進程,pruntime不容易長大),要麼分母大(nice值低,優先順序高,權重大)。這樣一個簡單的公式,就同時照顧了普通進程的優先順序和CPU/IO消耗情況。
比如有4個普通進程,如下表,目前顯然T1的vruntime最小(這是它喜歡睡的結果),然後T1被調度到。
pruntime |
Weight |
vruntime |
|
T1 |
8 |
1024(nice=0) |
8*1024/1024=8 |
T2 |
10 |
526(nice=3) |
10*1024/526 =19 |
T3 |
20 |
1024(nice=0) |
20*1024/1024=20 |
T4 |
20 |
820(nice=1) |
20*1024/820=24 |
然後,我們假設T1被調度再執行12個pruntime,它的vruntime將增大delta*1024/weight(這裡delta是12,weight是1024),於是T1的vruntime成為20,那麼這個時候vruntime最小的反而是T2(為19),此後,Linux將傾向於調度T2(儘管T2的nice值大於T1,優先順序低於T1,但是它的vruntime現在只有19)。
所以,普通進程的調度,是一個綜合考慮你喜歡幹活還是喜歡睡和你的nice值是多少的結果。鑒於此,我們去問一個普通進程的調度延遲究竟有多大,這個問題,本身意義就不是特別大,它完全取決於當前的系統裡面還有誰在跑,取決於你喚醒的進程的nice和它前面喜歡不喜歡睡覺。
明白了這一點,你就不會在Linux裡面問一些讓回答的人吐血的問題。比如,一個普通進程多久被調度到?明確地說,不知道!裝逼的說法,就是“depend on …”,依賴的東西太多。再裝逼的說法,就是“一言難盡”,但這也是大實話。
分配vs. 占據
Linux作為一個把應用程式員當傻逼的操作系統,它必須允許應用程式犯錯。所以這類問題就不要問了:進程malloc()了記憶體,還沒有free()就掛了,那麼我前面分配的記憶體沒有釋放,是不是就泄漏掉了?明確的說,這是不可能的,Linux內核如果這麼傻,它是無法應付亂七八糟的各種開源有漏洞軟體的,所以進程死的時候,肯定是資源皆被內核釋放的,這類傻問題,你明白Linux的出發點,就不會再去問了。
同樣的,你在應用程式裡面malloc()成功的一刻,也不要以為真的拿到了記憶體,這個時候你的vss(虛擬地址空間,Virtual Set Size)會增大,但是你的rss(駐留在記憶體條上的記憶體,Virtual Set Size)記憶體會隨著寫到每一頁而緩慢增大。所以,分配成功的一刻,頂多只是被忽悠了,和你實際占有還是不占有,暫時沒有半毛錢關係。
如下圖,最初的堆是8KB,這8KB也寫過了,所以堆的vss和rss都是8KB。此後我們調用brk()把堆變大到16KB,但是實際上它占據的記憶體rss還是8KB,因為第3頁還沒有寫,根本沒有真正從記憶體條上拿到記憶體。直到寫第3頁,堆的rss才變為12KB。這就是Linux針對app的lazy分配機制,它的出發點,當然也是防止應用程式傻逼了。
代碼段的記憶體、堆的記憶體、棧的記憶體都是這樣懶惰地拿到,demanding page。
我們有一臺1GB記憶體的32位Linux系統,我們關閉swap,同時透過修改overcommit_memory為1來允許申請不超過進程虛擬地址空間的記憶體:
$ sudo swapoff -a
$ sudo sh -c 'echo 1 >/proc/sys/vm/overcommit_memory'
此後,我們的應用可以申請一個超級大的記憶體(比實際記憶體還大):
上述程式在1GB的電腦上面運行,申請2GB記憶體可以申請成功,但是在寫到一定程度後,系統出現out-of-memory,上述程式對應的進程作為oom_score最大(最該死的)的進程被系統殺死。
隔離vs. 共用
Linux進程究竟耗費了多少記憶體,是一個非常複雜的概念,除了上面的vss, rss外,還有pss和uss,這些都是Linux不同於RTOS的顯著特點之一。Linux各個進程既要做到隔離,但是隔離中又要實現共用,比如1000個進程都用libc,libc的代碼段顯然在記憶體只應該有一份。
下麵的一幅圖上有3個進程,pid為1044的 bash、pid為1045的 bash和pid為1054的 cat。每個進程透過自己的頁表,把虛擬地址空間指向記憶體條上面的物理地址,每次切換一個進程,即切換一份獨特的頁表。
僅從此圖而言,進程1044的vss和rss分別是:
vss= 1+2+3
rss= 4+5+6
但是是不是“4+5+6”就是1044這個進程耗費的記憶體呢?這顯然也是不准確的,因為4明顯被3個進程指向,5明顯被2個進程指向,壞事是大家一起乾的,不能1044一個人背黑鍋。這個時候,就衍生出了一個pss(按比例計算的駐留記憶體, Proportional Set Size )的概念,僅從這一幅圖而言,進程1044的pss為:
rss= 4/3 +5/2 +6
最後,還有進程1044獨占且駐留的記憶體uss(Unique Set Size ),僅從此圖而言,
Uss = 6。
所以,分析Linux,我們不能模棱兩可地停留於錶面,或者想當然地說:“Linux的進程耗費了多少記憶體?”因為這個問題,又是一個要靠裝逼來回答的問題,“dependon…”。坦白講,每次當我問到老外問題,老外第一句話就是“depend on…”的時候,我就想上去抽他了,但是我又抑制了這個衝動,因為,很多問題,不是簡單的0和1問題,正反問題,黑白問題,它確實是一個“depend on …”的問題。
有時候,小白問大拿一個問題,大拿實在是無法正面回答,於是就支支吾吾一番。這個時候小白會很生氣,覺得大拿態度不好,或者在裝逼。你實際上,明白很多問題不是簡單的0與1問題之後,你就會理解,他真的不是在裝逼。這個時候,我們要反過來檢討自己,是不是我們自己問的問題太LOW逼了?
思考大於接受
我們前面提出了30個問題,而本文也僅僅只是回答了其中極少的一部分。此文的目的在於建立思維,導入方向,而不是洋洋灑灑地把所有問題回答掉,因為哥確實沒有時間寫個幾百頁的文檔來一一回答這些問題。很多事情,用口頭描述,比直接寫冗長地文檔要更加容易也輕鬆。
最後,我仍然想要強調的一個觀點是,我們在思維Linux的時候,更多地可以把自己想象成Linus Torvalds,如果你是Linus Torvalds,你要設計Linux,你碰到某個訴求,比如調度器和記憶體方面的訴求,你應該如何解決。我們不是被動地接受“是什麼”,更多地要思考“為什麼”,“怎麼辦”。
如果你是Linus Torvalds,有個傻逼應用程式員要申請1GB記憶體,你是直接給他,還是假裝給他,但是實際沒有給他,直到它寫的時候再給他?
如果你是Linus Torvalds,有個家伙打開了串口,然後進程就做個1/0運算或者訪問空指針掛了,你要不要在這個進程掛的時候給它關閉串口?
如果你是Linus Torvalds,你是要讓nice值低(優先順序高)的普通進程在睡眠前一直堵著nice值高的進程,還是雖然它優先順序高,但是由於跑的時間比較長後,也要讓給優先順序低(nice值高)的進程?如果你認為nice值低的應該一直跑,那麼如何照顧喜歡睡覺的I/O消耗型進程?萬一nice值低的進程有bug,進入死迴圈,那麼nice高的進程豈不是絲毫機會都沒有?這樣的設計,是不是反人類?
…
當你帶著這些思考,武裝這些concept,再去看Linux的時候,你就從被動的“接受”,變成了主動地“思考”,這正好是任何一個優秀程式員都具備的品質,也是打通進程調度和記憶體管理任督二脈的關鍵。
原來便在這頃刻之間,張無忌所練的九陽神功已然大功告成,水火相濟,龍虎交會。要知布袋內真氣充沛,等於是數十位高手各出真力,同時按摩擠逼他周身數百處穴道,他內內外外的真氣激蕩,身上數十處玄關一一衝破,只覺全身脈絡之中,有如一條條水銀在到處流轉,舒適無比。
——金庸 《倚天屠龍記》