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(駐留在內存條上的內存,Resident SetSize)內存會隨着寫到每一頁而緩慢增大。所以,分配成功的一刻,頂多只是被忽悠了,和你實際佔有還是不佔有,暫時沒有半毛錢關係。

如下圖,最初的堆是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的時候,你就從被動的“接受”,變成了主動地“思考”,這正好是任何一個優秀程序員都具備的品質,也是打通進程調度和內存管理任督二脈的關鍵。

 

原來便在這頃刻之間,張無忌所練的九陽神功已然大功告成,水火相濟,龍虎交會。要知布袋內真氣充沛,等於是數十位高手各出真力,同時按摩擠逼他周身數百處穴道,他內內外外的真氣激盪,身上數十處玄關一一衝破,只覺全身脈絡之中,有如一條條水銀在到處流轉,舒適無比。

——金庸 《倚天屠龍記》

關於進程的直播

CSDN學院聯合筆者的這次關於進程的課程分成4個組成部分,每次課60分鐘。每次課後留下3~4個練習題,可以在微信羣或者Linuxer公衆號留言討論答案和做題心得。

第一部分深入徹底地搞清楚進程生命週期,進程生命週期創建、退出、停止,以及殭屍是個什麼意思;第二部分,深入分析進程創建的寫時拷貝技術、以及Linux的線程究竟是怎麼回事(爲什麼稱爲輕量級進程),此部分也會搞清楚進程0、進程1和託孤,以及睡眠時的等待隊列;第三部分,搞清楚Linux進程調度算法,不同的調度策略、實時性,完全公平調度算法;第四部分,搞清楚Linux多核下的CPU、中斷、軟負載均衡,cgroups調度算法以及Linux爲什麼不是一個實時操作系統,如何把Linux變成一個硬實時的操作系統。

通過這4部分的學習,徹底理清Linux的進程、線程,弄清楚你寫的內核和應用程序在系統裏面究竟是如何跑,知其然,知其所以然。

第一部分大綱

  1. Linux進程生命週期(就緒、運行、睡眠、停止、僵死)
  2. 殭屍是個什麼鬼?
  3. 停止狀態與作業控制,cpulimit
  4. 內存泄漏的真實含義
  5. task_struct以及task_struct之間的關係
  6. 初見fork和殭屍

練習題

  1. fork的例子
  2. life-period例子,觀察殭屍
  3. 用cpulimit控制CPU利用率

第二部分大綱

  1. fork、vfork、clone
  2. 寫時拷貝技術
  3. Linux線程的實現本質
  4. 進程0和進程1
  5. 進程的睡眠和等待隊列
  6. 孤兒進程的託孤,SUBREAPER

練習題

  1. fork、vfork、Copy-on-Write例子
  2. life-period例子,實驗體會託孤
  3. pthread_create例子,strace它
  4. 徹底看懂等待隊列的案例

第三部分大綱

  1. CPU/IO消耗型進程
  2. 吞吐率 vs. 響應
  3. SCHED_FIFO、SCHED_RR
  4. SCHED_NORMAL和CFS
  5. nice、renice
  6. chrt

練習題

  1. 運行2個高CPU利用率程序,調整他們的nice
  2. 用chrt把一個死循環程序調整爲SCHED_FIFO
  3. 閱讀ARM的big.LITTLE架構資料,並論述爲什麼ARM要這麼做?

第四部分大綱

  1. 多核下負載均衡
  2. 中斷負載均衡、RPS軟中斷負載均衡
  3. cgroups和CPU資源分羣分配
  4. Android和NEON對cgroups的採用
  5. Linux爲什麼不是硬實時的
  6. preempt-rt對Linux實時性的改造

練習題

  1. 用time命令跑1個含有2個死循環線程的進程
  2. 用taskset調整多線程依附的CPU
  3. 創建和分羣CPU的cgroup,調整權重和quota
  4. cyclictest

報名方法




發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章