1003
技術社區[雲棲]
阿裏內核月報2014年12月
Live kernel patching on track for 3.20
運行時間敏感的用戶一直以來都很希望有一種方法可以在不重啟係統的情況下對運行的操作係統內核打補丁。目前有幾個還沒有進入主線內核的實現(比如:kpatch, Kgraft)。很明顯,這些實現不可能全部進入upstream內核。從最近Jiri Kosina發出的一組補丁來看, 內核熱升級補丁的開發似乎又向前邁進了一步,kpatch和kGraft的開發,並且內核熱升級的核心功能已經被Reviewed/Acked過。並且雙方都同意共同進行下一步的開發工作。目前核心功能的補丁計劃在3.20版本進入到主線內核,我們即將在主線內核中看到內核熱升級功能。
從此前的開發角度來看,kpatch還是具有一定的優勢的,主要原因是kpatch使用stop_machine()的方式來對代碼進行替換,這一技術已經在ksplice上經過驗證並且確認是可以有效果工作的。而kGraft使用的類似RCU的機製則還缺少實際使用的驗證。此外,kpatch擁有較為完善的用戶太工具來幫助用戶生成、管理熱升級補丁。而kGraft在這一方麵走的較慢,用戶態工具的開發仍然較為緩慢。相信隨著內核熱升級補丁的核心功能進入主線內核以及兩個項目組共同的努力,很快我們就可以使用上較為晚上的內核熱升級功能。
對混合存儲設備的內核支持
我們這裏說的混合存儲,就是指用基於Flash的設備(如SSD,FusionIO卡等等)給傳統的旋轉式硬盤做Cache,達到加速的目的。Linux內核最近已經加入了不少軟件實現的混合存儲方案,比如Bcache和dm-cache,還有沒有進入內核主幹的flashcache。同時,硬件廠商們也有很多自己的硬件解決方案--在一個黑盒子裏裝上硬盤和一些Flash存儲設備,硬件做進去一些算法來自動判斷哪些數據是熱點,需要放到Flash設備上,讓它們能自動升降級。然後對外仍然表現出好像隻有一個設備的樣子,這樣不需要軟件支持也能做加速。
然而,判斷出某些數據比另一個數據更熱,更值得放在Flash設備上並不是容易的事,由於硬件所在的層次太低,有用的信息在漫長的IO路徑上已經被過濾掉不少,因此多數情況下上層應用和操作係統很可能比硬盤要更清楚哪些數據更有價值。如果能給軟件提供一些手段把這些hint發給硬件,硬件混合存儲設備就有希望工作得更有效率。Intel的Jason Akers最近提了一些patch來實現這類API。他建議的方法是複用已有的ionice係統調用(BTW,是不是很多人都不知道還有這樣一個調用?),加入諸如IOPRIO_ADV_EVICT、IOPRIO_ADV_DONTNEED、IOPRIO_ADV_NORMAL、IOPRIO_ADV_WILLNEED這四個命令字,分別表示接下來要讀寫的數據如果在cache裏則最好淘汰掉、接下來要讀寫的數據近期不會再次用到(但對是否淘汰不表態)、無建議、接下來要讀寫的數據近期要用到,一共四種建議。內核收到這些建議後再把它們轉成ATA命令發給硬件,ATA standard 3.2 已經定義了混合存儲設備的特性和接口,這樣上位機就有辦法把這些信息傳下去了。
這套patch短期內不大可能合並到主幹,社區的反對意見不少,但都是針對API的設計風格來的,並沒有人從根本上質疑有沒有必要這麼做。很多開發者認為指定per-process的粒度太大了,用戶可能想給不同的文件指定不同的建議,甚至給某文件的各個區域指定不同建議,使用上述API沒法達到這種目的。另外,Dave Chinner指出實際提交IO請求的線程往往不是最初產生數據的那些用戶線程,例如很多文件係統都有自己的work thread,由它們去做submit_bio(),這樣用戶進程給的建議就不起作用了。
總之,多數開發者認為應該實現一套可以按文件粒度來給出緩存建議的API,Jens Axboe甚至給出了一套按請求粒度來的參考實現,例如可以把這套API做到他最近提出的非阻塞buffer讀的補丁中。
另外,這套API的另一個問題是它與目前的混合存儲硬件設備耦合得過於緊密:它致力於精細地控製硬件的緩存策略,未來的硬件可能並不能很好地落到以上幾種建議的適應範圍內。需要軟件給出類似的“緩存建議”的場景也不隻是混合存儲硬件這一種,像帶持久化memory device和支持T10/T13的NFS 4.2也會需要類似建議。一般來說內核開發者傾向於設計出能適應多種場景的方案。針對這個問題,Martin Petersen提出了另一種風格的API:不強求軟件給出具體的緩存操作指示,把這些“建議”改成“描述”,讓軟件描述清楚它們現在的IO流是什麼風格的,再讓硬件自己去把這些風格映射到不同的緩存策略上。舉個例子:transaction(事務)類型的IO流,它要求IO操作要盡快完成、寫下去的數據未來很可能再次用到、再次用到的時候延遲要盡可能地低;streaming(流式)類型的IO流,它也要求IO操作要盡快完成,但寫下去的數據未來再次用到的可能性不大。其他的還有metadata類、Paging類、Background類等等。
目前看來上述問題短期內還不好解決,如果比較一下提出的這幾種方案,Martin Petersen的適應性最廣,對未來的新硬件也會適用,但他的方案的風格與Intel提的這套patch完全不同,采納他的方案就意味著開發者們又要從頭來過了。
open() flags: O_TMPFILE and O_BENEATH
open係統調用在Linux中扮演了重要的角色。除此之外,沒有其他訪問已經存在的文件的方法。不同的flag對open係統調用的行為影響很大,這裏介紹兩個flag,一個已經被加入到最新內核中,另一個還在討論階段。
O_TMPFILE
關於O_TMPFILE標識的討論由來已久,它被迅速加入到內核中,review不很充分,合並之後還有些問題。O_TMPFILE要求創建文件時,不創建文件目錄項,其他進程便無法訪問這個臨時文件。
Eric Rannaud最近提出,以如下的方式調用open會出現什麼情況
int fd = open("/tmp", O_TMPFILE | O_RDWR, 0);
flag標識要求創建一個可寫的文件,但是mode域確要求不能有任何讀寫權限。對於這種情況,POSIX明確規定mode參數在文件創建之後生效。也就是說,雖然這樣創建的文件進程不能訪問,在創建時還是可以獲得一個文件描述符。 然而,實際情況卻不是這樣。mode參數在創建時就生效,因此open調用失敗。這種行為被界定為bug,Eric的fix在3.18-rc3之後生效。
另一個比較好玩的情況是,
int fd = open("/tmp", O_TMPFILE | O_RDONLY, 0666);
帶O_RDONLY的O_TMPFILE調用會失敗。當引入O_TMPFILE的時候,大家覺得使用不能寫的臨時文件的情況不存在。但是後來有人找到了這樣的情況,open()調用之後跟著fsetxattr()調用,隨後用linkat()使文件可見。Linus最初決定支持這個的,後來又變卦了,所以這麼使用還是會fail。
還有一個glibc的bug最有意思了啊,glibc編譯帶O_TMPFILE參數的時候,mode參數根本沒有往內核裏麵傳。巧合的是在x86-64機器上調用open進入內核態的時候mode域使用的寄存器正好和約定的是同一個。使用openat()調用的時候就不這麼幸運了啊,mode的值就錯了。這個應該很快會修掉,但是現在的glibc版本下,不要用open_at()和O_TMPFILE一起。
O_BENEATH
使用openat()調用的時候,需要打開的文件通常是明確的。但是,處理路徑裏麵有符號鏈接的情況時候,再加上路徑上的一些奇巧淫技,打開的文件會錯掉啊。
David Drysdale給openat()係統調用增加了O_BENEATH flag來解決這個問題。當使用這個flag的時候,訪問的文件必須在path指定的目錄中或者更深的路徑裏麵。采取的限製措施很簡單,path不能以“/”開始或者包含“../",解析符號鏈接時也是這樣。
這個flag還有其他的應用,可以安全的給sandbox程序一個文件目錄。
這個flag應該很快會被加入到主線中。
An introduction to compound pages
複合頁是指物理上連續的兩個或兩個以上的頁組成一個單元,而被當作一個單獨的大頁使用。hugetlbfs或透明大頁係統經常用它來創建大頁,但除此之外也有其他一些使用場景。複合頁可以被內核用來當作匿名頁或者buffer,但是不能用來做page cache,因為page cache隻能處理單獨的頁。
分配複合頁仍然使用內存分配函數alloc_pages()但需要設置上__GFP_COMP分配標誌,且order至少為1。因為複合頁實現方式的問題,不能使用order 0(單獨一個頁)來創建複合頁(參數order是指分配以2為底以order為冪的頁個數,0對應於一個頁,1對應兩個頁等)。
需要注意的是一個複合頁不同於正常的高階(high-order)分配請求,下麵的調用:
pages = alloc_pages(GFP_KERNEL, 2); /* no __GFP_COMP */
將返回四個物理上連續的頁,但是它們不是一個複合頁。區別在於創建複合頁時需要創建一些元數據,雖然很多情況下這些元數據可能不需要另外生成。
大部分元數據都被放在了對應的struct page結構體中,首先來看一下struct page裏的flag。複合頁裏的第一個頁叫做“head page”,會設上PG_head標誌,其餘剩下的頁叫做“tail pages”,會設上PG_tail標誌。在64位係統下page flag位較多,是用這種方式存儲的。但對於32位係統,沒有多餘的page flag使用,采用的是另一個不同的方案:複合頁裏的所有頁都會設上PG_compound標誌,tail pages同時也會設上PG_reclaim。PG_reclaim標誌隻為page cache部分的代碼使用,但是因為複合頁不能用做page cache,因此可以拿來複用。操作複合頁的代碼不需要關心這些細節,隻需要調用PageCompound()就可查詢傳入的頁是否是一個複合頁。如果需要區分是head還是tail頁,就調用PageHead()和PageTail()。
每個tail page都有一個指針指向head page,指針放在了struct page結構的first_page域。first_page域和private域、該頁存儲頁表項時使用的spinlock域、該頁屬於一個slab時使用的slab_cache域 占用的是相同的存儲空間。compound_head()函數用來找到一個tail page的head page。
表示複合頁整體的有兩個信息:order和一個將頁釋放回係統時的析構函數。head page的struct page結構已經塞滿了沒有地方再存這些信息,因此order被存在了第一個tail頁的struct page的lru.prev域。struct page裏很多域都是用union存的,因此複合頁的order是被轉換成了一個指針類型再存進去。類似地析構函數的指針被存在了第一個tail頁struct page的lru.next域。正是因為需要用第二個頁的struct page來存儲元數據,所以複合頁最少需要包含兩個頁。現在內核裏隻定義了兩個複合頁析構函數,默認使用的是free_compound_page(),它會將內存返還給頁分配器。hugetlbfs使用另一個free_huge_page()來更新計數。
複合頁的所有頁是一個整體,當隻訪問其中的一個頁時也需要遵守這個規定。透明大頁就是一個例子,如果用戶空間嚐試修改其中一個頁的保護權限時,需要定位出整個大頁並拆分。有些驅動也使用複合頁來管理大塊buffer。
以上基本就是複合頁和普通高階分配的區別,大部分內核開發者都不會用到複合頁,但是當確實需要將一組頁作為一個整體時,複合頁就會是一個很好的選擇。
THP 引用計數
Caspar
Linux 下大多數架構都用4KB大小的頁麵(譯者:有的例外,比如 powerpc 是64K的,hugepage 是 16MB的),大多數都支持更大的頁麵,從2MB的大頁到1GB的超大頁。這些大頁在很多工作負載下對性能有顯著提升,其中最大的收益在於減少了 TLB 的壓力(2MB大頁一次隻要翻一個地址,4KB的頁就得翻512次,顯而易見)。內核的透明大頁(THP, Transparent Huge Page)特性(理論上來說)能解放開發者和用戶的勞力,使大頁的使用透明化,隻不過這玩意兒受到諸多限製,發揮不出理想狀態下的效果。現在 Kirill A. Shutermov 改了一堆複雜的代碼,提交了一係列 patch 來減少這些限製。
THP
THP 的工作原理是,當它判斷一個進程的地址空間 (1)有空頁,(2) 替換為大頁之後進程可收益之時,就會靜悄悄暗落落地把這部分地址空間替換為更大的頁麵。這是 Red Hat 的開發者 Andrea Arcangeli 在 2.6.38 內核引入的特性。結果留下了一個難對付的問題,內核的內存管理代碼裏有一大坨代碼沒法對付隨進分布在進程地址空間裏的大頁。針對此問題,Andrea 的一個解決方法是,整個功能都不用大頁,這就是為什麼 page-cache 的頁麵用不了大頁的緣故。
在另外一些情形下,Andrea 放了一個函數叫split_huge_page()
, 當一段代碼沒法用於大頁場景時,就用這個函數把大頁切回小頁。(原文接下來開始強烈吐槽了:很顯然這樣會有性能開銷了,不過這麼做還是值得的,起碼不會出現一堆奇奇怪怪的內核問題。不過這函數就跟大內核鎖(BKL)一樣就跟個拐杖一樣,不是個健全的解決方法但是至少能 work,把一個難題延後推遲來解決 blah blah。)
然後一直以來,陸陸續續有一些代碼修改完之後可以用於大頁了,這個函數就被替換了。不過在頁麵合並代碼裏,這函數還在。比如在同頁歸並(KSM, Kernel Samepage Merging)的實現中,在bad-memory poisoning代碼中,mprotect()
,mlock()
這倆係統調用中,swap 代碼中,以及其他一些地方都還得用這個函數。有一些估計是改不了的,比如 KSM, 不切割成小頁,估計 KSM 永遠你不可能成功合並重複大頁;其他一些就是比較難改,比方說mprotect()
,怎麼保護半個大頁之類的。針對後麵這種情況,其實是可以優化的,就是使用引用計數。
PMD-level 和 PTE-level 映射
要理解 Kirill 的補丁集,就得記得大頁在內核中是以複合頁的形式存在的,這裏有一些複合頁的閱讀材料。
Kirill 的終極目標是讓 page cache 能用上透明大頁。到目前為止,隻有匿名頁可以用大頁來替換,這隻占內存中的一小部分而已。所以說這個目標很宏大,而目前他的這個補丁集根本就嚐試都沒嚐試一下這個方向(太毒舌了Orz),Kirill 隻是簡化了管理 THP 的方式,讓它們變得更靈活一些。
他的這套補丁消除了正常的4K頁和大頁之間的隔閡。具體來說,當前的內核中,一個4K頁要麼是一個獨立的4K頁,要麼成為一個大頁的一部分,但是不能既是4K頁又是大頁的一部分。而 Kirill 的補丁讓一個4K頁在一個進程空間裏是一個獨立的頁,在另外一個進程空間裏成為一個大頁的一部分。
先來回顧一下 Linux 頁表結構(圖片摘自這篇10年前的 LWN 文章):
從圖上所見,大頁在裏麵處於 PMD 這層,獨立的頁麵在 PTE 這層。不過並非所有進程裏的相同內存都得以相同方式映射,所以在一個進程裏一個 2MB 的空間被映射為一個大頁,另一個進程裏相同的這段內存空間被映射為 512 個 4K 頁,這是完全合法的。如果支持這種不同的映射,那麼一個進程可以調用mprotect()
來保護一個大頁的一部分,其他進程裏可以繼續以大頁方式調用,不受幹擾。
換句話說,如果split_huge_page()
函數可以被替換為諸如split_huge_pmd()
這樣的函數,這個函數就是隻切分一個進程裏大頁的映射,其他進程裏的大頁收益繼續不受影響。可是當前內核不支持不同的映射,所有進程必須以相同方式來映射,這個限製最終可以歸結為大頁裏的引用計數該如何表達的問題。
大頁引用計數
引用計數用於跟蹤一個對象(比如內存裏的一個頁)有多少用戶,內核因此決定這個對象是否空閑,是否能被刪除。一個普通的頁的引用計數有兩種:第一種,放在 struct page
結構體裏的 _count
字段,是這個頁麵被引用的總數,另一種在 _mapcount
字段,是指向這個頁的頁表項的數量。後者從屬於前者,每一個頁表項的映射引用都會在 _count
字段裏同樣增加一次引用,所以 _count
字段永遠大於等於 _mapcount
字段。在 _count
字段而不在 _mapcount
字段的情況包括:映射到 DMA 的頁,通過比如 get_user_pages()
這樣的函數映射到內核地址空間的頁;以及用了 mlock()
鎖住的頁。這兩個值的差值很重要,如果 _count
值和 _mapcount
相等,這個頁可以整個回收,對應的頁表項可以刪掉。如果前者大於後者,那麼多出來那些引用被清理掉之前,這個頁麵不能動,就被“釘住”了。
而這個規則對於複合頁來說完全不一樣了。對複合頁來說,所有 tail page 的 _count
都是0,引用計數放在 head page。不過這就沒辦法統計單獨的小頁了。舉個例子,如果一個大頁中的部分頁麵被用於 I/O 操作,那麼就得用個小技巧:把每個小頁的引用計數放到 _mapcount
裏,然後根據當前頁麵是不是 tail page,來挑選對應的 helper 函數來訪問正確的引用計數。
在這個小技巧裏,因為大頁的 mapping 和 unmapping 都是一起的,所以不需要每個 tail page 單獨拿出來跟蹤其 mapping 狀況。而如果有人想要把一個大頁中的幾個頁單獨拿出來 mapping/unmapping,這方法就不靠譜了,得找一個既能跟蹤整塊大頁的映射又能跟蹤單獨的小頁的映射的方法。
對於跟蹤整個大頁來說,得用另外一個廣為人知的小技巧,把整個大頁的引用計數放到第一個 tail page 的 mapping
字段中。這個字段一般是用來跟蹤一個文件是不是映射到內存的這個頁麵中的,不過既然整個大頁不會用於 page cache,所以這個字段可以放心得挪作他用(僅用於大頁情形),計數是個原子類型,mapping
是指向一個結構體 struct address_space
的指針,所以得強製類型轉換一次。有人說這裏最好用個 union 類型,不過作者還沒這麼幹。
對於那些非映射的引用計數,因為 _count
字段廢掉了,隻有 _mapcount
在用,事情就難辦了,Kirill 的解決方法就是:不記錄某個具體的單獨的小頁的非映射的計數,取而代之的是每當一個小頁被非映射的引用(比如 get_page()
調用)時,隻增加 head page 的引用計數。所以某個頁麵被非映射引用時,這個頁麵會被“釘住”(參見這一節的第一段),但是我們不知道具體是哪個頁麵被釘住了,隻知道有小頁並釘住了。
所以當一個大頁被分割的時候,我們就沒辦法把某個小頁給標記為“釘住”,Kirill 的解決方法是,一旦如此,就直接把 split_huge_page()
函數給 fail 掉。注意不是說讓 split_huge_pmd()
fail 掉,前者函數是對所有進程的地址空間裏的這段內存分割,後者函數隻是分割 pmd. 這麼做的話,調用 split_huge_page()
函數的代碼不用改,隻是調用不成功而已。
移除 tail page 的引用計數的這種行為的好處就是讓 _mapcount
字段回歸其本源:跟蹤頁表中映射到這個頁的計數。這樣,一個進程映射一個單獨的大頁,另一個進程映射這塊地址空間為一堆獨立的 4K 小頁就成為可能。
Kirill 說,這塊代碼的修改帶來了性能提升,雖然他沒提供詳細的 benchmark 數據。允許大頁映射和小頁分割同時存在這種行為也可能讓內存共享更快一些。展望未來,哪天 page cache 也能用 THP 了,生活就更幸福了。當然首先這塊代碼得想辦法進 mainline,Kirill 說可能這段代碼會導致一些意外的狀況,不過他對自己代碼還是有信心的,短期內不會出什麼問題。
Introducing lazytime
引入lazytime
POSIX兼容的文件係統為每個文件維護了3個時間戳,分別對應於文件元數據或內容最後改變的時間(ctime),文件內容的修改(mtime),和文件的訪問(atime).前2個時間戳通常被認為是有用的,但"atime"對於它能提供的好處長期以來是非常昂貴的.在當前係統中,有一個mount選項"relatime",它能減緩atime造成的嚴重問題,但是它也有一些自身的問題.現在一個新的選項"lazytime"也許可以取代"relatime"並且工作得更好.
"atime"的問題是每當文件被訪問,它將被更新.更新"atime"需要將文件inode寫入磁盤,這就意味著"atime"跟蹤本質上會將每個讀操作轉變成一個寫操作.對於許多負載,這對性能的影響是非常嚴重的.在這之上,有很少的程序利用"atime"或者依賴於它的更新.因此,十年前,掛載文件係統的時候都會帶有"noatime"選項,它徹底關閉了訪問時間的跟蹤.
問題是少數程序不是沒有程序;最後確實有一些工具沒有"atime"跟蹤會出問題.一個經典的例子就是郵件客戶端通常會用"atime"值來判斷自從上次郵件被投遞以來郵箱有沒有被讀.經過一些討論之後,內核社區在2.6.20開發周期添加了"relatime"選項."relatime"造成了大多數"atime"更新被抑製,但當當前記錄的"atime"早於"ctime"或者"mtime"才會允許更新.後來,"relatime"被調整為每24小時更新一次.
"relatime"對於大多數係統來說工作得很好,但依然有一些係統需要更好的"atime"跟蹤,不需要為它付出性能的代價.一些用戶也不喜歡"relatime",因為它會造成係統不與POSIX規範完全兼容.對於大部分,人們忍受"relatime"的小缺陷(或者忍受"atime"更新的代價),但現有有一個替代方法.
這個方法就是lazytime選項,它是Ted Ts'o發的一個ext4特定的補丁.當lazytime被使能,一個文件係統將保持"atime"更新在內存inode中.這個inode直到一些原因發生或者需要被從內存中清除的時候才被寫入磁盤.這個好處是"atime"對於運行在係統的所有程序來說都是正確的.保存在磁盤上的"atime"可能是非常老的,但假如係統崩潰,當前的"atime"將會丟失.
Dave Chinner很快指出,這個選項是有用的,但不應該僅是在ext4中.假如它能在VFS實現,所有的文件係統都能用它,不僅僅是ext4,也許更重要的是,在所有的文件係統上能以相同的方式工作.Ted同意一個VFS實現實有意義的,這個補丁的下一版將會如此實現.
Dave也建議, 無規律的"atime"更新寫入也許不是可取的. Ted也接受這個想法,因此下一版很可能將會以每24小時至少寫入一次更新的"atime".沒有這個改變,像在數據庫服務器上,"atime"更新可能呆在內存中幾個月才更新一次.
最後,有一個問題是關於"lazytime"是否成為缺省的mount選項.它滿足POSIX沒有引入正常"atime"更新的開銷,因此看起來是一個比"relatime"更好的選項.Ted似乎想不久改變當前的缺省選項,然而Dave擔心會有回歸問題,想先等等看.同時這將導致這個特性能否得到更多的測試的問題,但正如Dave提醒的,未來會有對這個特性更感興趣的人幫著測試.
這個是否征程需要時間去檢驗,"relatime"對於大多數用戶來說工作得很好,因此沒有必要讓大多數的用戶去試這個新的選項.但最後一些富於嚐試的發行版很可能會采用這個新選項.在這點上來看,經過長時間,任何潛在的問題都很可能會暴露出來.因此也許"lazytime"選項會在2016年變為缺省選項,也許真正被測試得很好,證明沒有問題.
Control group namespaces
LinuxContainer(LXC)使用namespace來做名字空間上的隔離,使用cgroup來做性能或者說資源隔離。目前的這套方案中存在的是一個問題是想操作cgroup就必須得mount cgroupfs,而cgroupfs本身是沒有隔離的,所以一個container裏邊的root用戶隻要mount上cgroupfs,就可以看到整個宿主機上所有container的情況了。在container裏邊cat /proc/self/cgroup,可以看到從根到自己的完整路徑,這顯然會造成明顯的信息泄漏。這件事的影響還不光是泄漏,Linux下的容器方案折騰在線熱遷移已經有一陣子了,OpenVZ其實很早之前就支持這個,它向內核主幹貢獻的CRIU(Checkpoint/Restore In Userspace)方案正趨於成熟,對於容器裏的進程來說,很可能遷到別的地方上之後對端機器的cgroupfs目錄層次與源端是不同的,這個進程再去看/proc/self/cgroup就發現它變了,而一般的應用進程都不會防備發生這種事情,因此這對熱遷移也產生了間接的影響。怎麼辦呢? Google的Aditya Kali提了一個patch,給cgroupfs引入了一個新的namespace,就叫cgroup namespace,通過給unshare()加新一個名為CLONE_NEWCGROUP的新標誌,指示當前線程要進入一個新的cgroup namespace,這個新namespace會讓當前線程把自己所處的那一級cgroupfs目錄做為虛擬的根目錄,從它自己的角度看過去所見的其他目錄都依據這個動作做相應的調整。
例如:
- 原先進程cat /proc/self/cgroup可見自己位於/batchjobs/c_job_1,unshare後就看見自己位於/
- 機器上有/batchjobs/ag和/batchjobs/bg兩個組,一個位於ag組中的進程做了unshare,然後去cat /proc/{bg組中的線程}/cgroup,就會看見它位於/../bg
以此類推
社區對這個patch非常歡迎,namespace的維護者Eric W. Biederman說自打cgroup被合並到主幹的那天起他就想要這個功能了。有個這個功能之後各種cgroup manager也可以跑在子組中,嵌套著加以部署。社區對這個patch主要的討論焦點是權限相關的,即unshare了之後,新的“假”根組中的線程可以被允許移到哪些組中去?(注意這裏討論的移動是指namespace之間的移動,不是指對應的cgroup組的移動,後者需要做這個操作的人自己去決定要不要做)原始的patch第一版是隻允許普通線程移動到自己的子組中去,不能平行移動或者向上移動。特權線程可以任意移動。開發者們覺得這樣的限製有點過於苛刻。
由於總體上沒什麼反對意見,我們有望在後邊的內核中很快見到這個特性。
ACCESS_ONCE() and compiler bugs
ACCESS_ONCE()與編譯器Bug
宏ACCESS_ONCE()在內核中應用廣泛,它確保了相應變量僅被編譯器生成的代碼訪問一次。這篇文章(https://lwn.net/Articles/508991/) 說明了它的工作原理以及應用場景。該文寫於2012年,那時內核中大概有200處地方使用了這個宏。現在大概有700處。像很多用於並發管理的底層技術一樣,ACCESS_ONCE()使用了不太容易理解的小花招。而且與其它技術一樣,當編譯器發生改變時,可能在此處出現Bug。 14年11月份時, Christian Borntraeger報告了這個Bug。為了理解這個問題,我們來仔細分析下這個宏,它在當前內核中的定義很簡單(<linux/kernel.h>):
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
ACCESS_ONCE()將變量聲明為volatile類型。Christian的報告指出GCC4.6與4.7會把非標量變量前的volatile忽略掉。因此,對int是沒有問題的。但是對於其它的複雜結構則存在問題。比如,ACCESS_ONCE()經常用於聲明頁表項: typedef struct { Unsigned long pet; }pte_t; 在此例中,volatile會被上述編譯器忽略,而導致內核Bug。Christian嚐試尋找了解決方案,不過看來隻能避免使用有問題的GCC來編譯內核。但是許多係統中都安裝了4.6或4.7,拉黑他們會讓很多用戶不舒服。而且Linus指出: “雖然有時我們可以避免使用有問題的編譯器。但是編譯器Bug總是無休止地提醒我們:你們在做一件很脆弱的事。或許我們應該找種方法避免脆弱了。 一種方法是對複雜結構體中的標量型變量使用ACCESS_ONCE()而不是該結構體變量本身。例如: Pte_t p = ACCESS_ONCE(pte); 可以被改寫為: Unsigned long p = ACCESS_ONCE(pte->pte); 這種方法需要去檢查每一處ACCESS_ONCE()調用,找到使用非標量類型的地方。這個過程很費時,而且容易出錯。 Christian指出的另一種方法是一些有問題的ACCESS_ONCE()調用。而用barrier()代替。在很多例子中,屏障很有效,但並非總是如此。我們需要更詳細的統計,阻止新的代碼使用ACCESS_ONCE()。 Christian修改了ACCESS_ONCE(),使得它不能作用於非標量變量。在他提交的最近一組Patch中,ACCESS_ONCE()長成了這樣:
#define __ACCESS_ONCE(x) ({ \
__maybe_unused typeof(x) __var = 0; \ (volatile typeof(x) *)&(x); })
#define ACCESS_ONCE(x) (*__ACCESS_ONCE(x))
這個版本在使用於非標量時,會報出編譯錯誤。但一個非標量需要使用這個功能該怎麼辦呢?Christian引入了兩個新的宏,READ_ONCE()/ASSIGN_ONCE(). 前者的定義如下:
static __always_inline void __read_once_size(volatile void *p, void *res, int size)
{
switch (size) {
case 1: *(u8 *)res = *(volatile u8 *)p; break;
case 2: *(u16 *)res = *(volatile u16 *)p; break;
case 4: *(u32 *)res = *(volatile u32 *)p; break;
#ifdef CONFIG_64BIT
case 8: *(u64 *)res = *(volatile u64 *)p; break;
#endif
}
}
#define READ_ONCE(p) \
({ typeof(p) __val; __read_once_size(&p, &__val, sizeof(__val)); __val; })
可以看出,它強製使用標量類型,即使傳入的變量不是此類型。 Christian的補丁集使用了READ_ONCE()與ASSIGN_ONCE()替換了ACCESS_ONCE()。代碼中的評論建議為了將優先使用這些宏,但是大多數已有的ACCESS_ONCE()不會被替換。開發者使用ACCESS_ONCE()來訪問非標量變量時,會收到編譯器給出的警告。 這個版本的Patch收到的評論不多,看來會在不久的將來進入upstream.在此之前,最好避免使用有Bug的編譯器。編譯器的Bug同時也說明內核中的相關代碼可以寫的更好,更加健壯。
Attaching eBPF programs to sockets
最近的內核開發周期中已經看到添加了柏克萊封包過濾器(extend Berkeley Packet Filter,縮寫 eBPF)子係統到內核。但是,對於3.18版本的內核,一個用戶空間的程序可以加載一個eBPF程序,但是不能令其運行在有用的上下文環境中;程序雖然可以加載和驗證,但也僅僅隻是加載和驗證。不用說,eBPF開發者Alexei Starovoitov想讓這個子係統有更加廣泛的作用。3.19版的內核應該包含將會第一次包含一係列新的體現Alexei想法的補丁。 將加入3.19內核的主要特性是將可以把eBPF程序附加到sockets上。操作的順序將是首先在內存中建立eBPF程序,然後使用新的 bpf()係統調用(相對於3.18版內核而言)來將程序加載到內核並獲取一個文件描述符來引用它。這樣,程序便可以將新的SO_ATTACH_BPF選項負載到setsockopt()函數了。
setsockopt(socket, SOL_SOCKET, SO_ATTACH_BPF, &fd, sizeof(fd));
這裏的參數socket表示網絡的socket,fd表示加載eBPF程序的文件描述符。
一旦程序加載後,它將會在每次相應socket捕獲到數據包的時候執行。目前,可用的功能仍然在以下兩個方麵受限: eBPF程序可以訪問到存儲在捕獲到的數據包中的數據,但是不能訪問到任何內核skb數據結構中的數據。未來計劃將使一些元數據可用,但是目前還不清楚哪些數據將可以以及如何訪問。 程序不能對數據包的傳送產生任何影響。因此,盡管這些程序被成為“過濾器”,但是它們目前可以做的僅僅是存儲在eBPF中的信息提供給用戶空間程序使用。
最終的結果是,在3.19中,eBPF程序對於統計收集等功能有用,但用處不是很多。
不過,這是個開始。3.19內核中應該包含一些例子來說明如何使用該功能。它們中的兩個是一個簡單程序從數據包獲取低級別的協議(如UDP,TCP,ICMP等)並在eBPF map中為每種協議維護一個計數器。如果某人想寫這樣一個直接使用eBPF虛擬機語言,他可以這樣寫:
struct bpf_insn prog[] = {
BPF_MOV64_REG(BPF_REG_6, BPF_REG_1),
BPF_LD_ABS(BPF_B, 14 + 9 /* R0 = ip->proto */),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), /* *(u32 *)(fp - 4) = r0 */
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */
BPF_LD_MAP_FD(BPF_REG_1, map_fd),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),
BPF_MOV64_IMM(BPF_REG_1, 1), /* r1 = 1 */
BPF_RAW_INSN(BPF_STX | BPF_XADD | BPF_DW, BPF_REG_0, BPF_REG_1, 0, 0),
BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */
BPF_EXIT_INSN(),
};
不用說,這樣的程序,對於大多數人而言,不是特別難懂。但是,正如例子所述,該程序也可以寫成一個限製性的C語言版本。
int bpf_prog1(struct sk_buff *skb)
{
int index = load_byte(skb, 14 + 9);
long *value;
value = bpf_map_lookup_elem(&my_map, &index);
if (value)
__sync_fetch_and_add(value, 1);
return 0;
}
這個程序可以被送到一個特別版本的LLVM編譯器,產生針對eBPF虛擬機的目標文件。但是現在,還隻能使用Alexei那版的LLVM編譯器,但Alexei表示它正在將這些更新提交到LLVM主線上。一個用戶空間工具可以使用通常的方式從(LLVM編譯器)產生的目標文件中讀取這個程序並將它加載到內核空間。這樣就不需要直接與eBPF語言打交道了。
當讀到最後一個例子時,可使用高級語言的能力使其價值顯現,這個例子編譯了一個300條指令的eBPF程序,這個程序實現了流跟蹤、根據IP地址計算數據包數量。這個程序本身可能隻有這些功能,但是它向我們展示了一些熟悉的複雜的功能可以使用eBPF虛擬機在內核中實現。
未來的計劃還包括將eBPF應用在其他一些地方,包括secure computing(“seccomp”)子係統以及filtering tracepoint hits。鑒於eBPF將成為內核中的一個通用設施,看來,內核開發者們將會在其他它可以使用的地方使用它。希望能在未來幾年中見到一些與eBPF相關的有趣的事情。
The iov_iter interface
內核其中一項很常見的操作是處理用戶空間傳入的一個buffer,有的時候這個buffer會包含很多塊。這個地方的處理是內核代碼裏麵經常容易出錯的點,有時會導致安全問題。為此內核開始考慮將之前用於內存管理和文件係統層的iov_iter擴展到內核的其他部分。
iov_iter主要用於遍曆iovec結構,該結構在<uapi/linux/uio.h>定義為:
struct iovec
{
void __user *iov_base;
__kernel_size_t iov_len;
};
這個結構和用戶態的iovec結構是對應的,被類似readv()這種係統調用使用。iovec用於描述一個在物理地址和虛擬地址可能分散的buffer。
iov_iter結構定義在<linux/uio.h>:
struct iov_iter {
int type;
size_t iov_offset;
size_t count;
const struct iovec *iov; /* SIMPLIFIED - see below */
unsigned long nr_segs;
};
type指明iterator的類型,根據是從interator裏麵讀或者往裏麵寫而被設置為READ或者WRITE。iov_offset記錄的是數據相對於iov裏麵第一個iovec指向地址的偏移。iovec數組包含數據的數目存儲在count裏麵。需要注意的是,這些量大部分都會在遍曆buffer過程中改變。它們描述的並不是整個buffer,而是類似一個指向buffer的標尺。
- 使用iov_iter結構
iov_iter在使用前必須要進行初始化:
void iov_iter_init(struct iov_iter *i, int direction,
const struct iovec *iov, unsigned long nr_segs,
size_t count);
然後數據可以在iterator和用戶態之間通過如下的兩個方式進行傳遞:
size_t copy_to_iter(void *addr, size_t bytes, struct iov_iter *i);
size_t copy_from_iter(void *addr, size_t bytes, struct iov_iter *i);
copy_to_iter()用於將addr地址對應buffer裏麵的數據拷貝到iterator指明的用戶態buffer中。簡單理解,copy_to_iter()看起來比較像是升級版的copy_to_user(),可以支持同時遍曆多個buffer。類似的,copy_from_iter()用於將用戶態 buffer的數據拷貝到地址addr裏麵。和copy_to_user()一樣,返回值是沒有拷貝的字節數。
注意,iovec數組的base地址在遍曆過程中是會隨著遍曆的進程推進而改變,也就是說調用者需要自己記錄iovec數組的base地址。
其他還有一些函數,用於將數據從page結構中移入或者移出iterator:
size_t copy_page_to_iter(struct page *page, size_t offset, size_t bytes,
struct iov_iter *i);
size_t copy_page_from_iter(struct page *page, size_t offset, size_t bytes,
struct iov_iter *i);
由於隻有一個page,需要注意的是,這些函數拷貝的數據需要確保在一個page內。
原子上下文的代碼從用戶態獲取數據的調用是:
size_t iov_iter_copy_from_user_atomic(struct page *page, struct iov_iter *i,
unsigned long offset, size_t bytes); 這個僅當數據都存在於RAM裏麵才會成功,調用者為此需要做更多錯誤處理。
用於將用戶態buffer映射到內核的調用如下:
ssize_t iov_iter_get_pages(struct iov_iter *i, struct page **pages,
size_t maxsize, unsigned maxpages, size_t *start);
ssize_t iov_iter_get_pages_alloc(struct iov_iter *i, struct page ***pages,
size_t maxsize, size_t *start);
iov_iter_get_pages()期望的是page數組調用者已經申請了,而iov_iter_get_pages_alloc()將會自己進行申請。在這種情況,pages數組需要最後通過kvfree()進行釋放。
不移動任何數據,而僅僅往前移動可以使用:
void iov_iter_advance(struct iov_iter *i, size_t size);
清理iterator的buffer使用:
size_t iov_iter_zero(size_t bytes, struct iov_iter *i);
獲取iterator裏麵的信息相關函數:
size_t iov_iter_single_seg_count(const struct iov_iter *i);
int iov_iter_npages(const struct iov_iter *i, int maxpages);
size_t iov_length(const struct iovec *iov, unsigned long nr_segs);
iov_lenght()函數使用時需要注意,由於該函數對iovec結構裏麵的len是完全信任的,如果數據來自於用戶空間,可能會在內核導致整數溢出。
- 不僅僅是iovec裏麵使用
目前在內核裏麵基本看不到iov_iter的使用。目前也還隻是在block層的BIO結果相關裏麵使用,添加了一個iov的數組域:
union {
const struct iovec *iov;
const struct bio_vec *bvec;
};
這類iterator在type裏麵標識為ITER_BVEC。目前在內核裏麵隻有swap和splice()代碼裏麵有相關使用。
- 即將到來的3.19
3.19裏麵對iov_iter進行了一些優化,優化的iterator通過以下函數創建:
void iov_iter_kvec(struct iov_iter *i, int direction,
const struct kvec *iov, unsigned long nr_segs,
size_t count);
之前的union結構裏麵也會為此添加一個新的kevc域。
網絡相關case裏麵也已經開始添加相關幫助函數,比如用於拷貝一個buffer並生成一個checksum。
結果就是,iov_iter接口慢慢的演變為標準的用於隱藏很多處理用戶態buffer相關複雜性的方式。大量內核開發者將會需要了解相關內容。
User namespaces and setgroups()
當前Linux內核,進程調用setgroups()修改自己所屬的group,需要擁有CAP_SETGID權限。而10月份時,Josh Triplett提交了一個patch,允許進程在沒有CAP_SETGID權限時,也能通過setgroups()調用將自己從所屬的group移除。從表麵上看,這種改動似乎是安全的,但是實際上它引入了一個安全漏洞。比如,通過一個group來限製該group中的進程對某個文件的訪問權限,但如果非特權進程能夠通過調用setgroups(),將自己從所屬組的移除,就有可能能夠訪問該文件。
對這個漏洞的進一步思考,發現該漏洞在user namespace機製中也存在。一個非特權進程通過clone創建一個新的user namespace時,該user namespace中的第一個進程擁有所有的權限,這樣本來不擁有特權的進程就能夠調用setgroups()來修改自己所屬的組。
Eric提交了一個patch修複了該漏洞,其基本思想是在沒有完成namespace內部gid與namespace外部gid映射之前,禁止進程調用setgroups()。具體實現方式是/proc/目錄中為每個進程增加一個setgroups文件,如果該文件的內容為deny,則禁止進程調用setgroups()。另外,修改setgroups文件需要CAP_SYS_ADMIN 權限從而防止任意進程能夠禁止setgroups()調用。
CoreOS looks to move from Btrfs to overlayfs
曆經坎坷,overlayfs終於在3.18合並進入了Linux內核主線,最近CoreOS也適時的提出它的根文件係統要用overlayfs+ext4來替換btrfs。Docker用了很多的文件係統feature,最基本的需求就是要將一個讀寫文件係統架到一個隻讀文件係統上,這一塊目前是使用btrfs來實現的,後麵會用overlayfs來實現。這個變化是12月15日Brandon Phillips在coreos-dev的郵件列表裏麵提出的,Phillips認為目前btrfs仍然不成熟,還有很多問題(我的感覺這些問題已經存在很久了)。。。這個提議立刻得到了大家的認可。 Chris Mason(btrfs的作者)認為CoreOS遇到的關於btrfs的問題都是客觀存在的,但是他認為btfs持續在進步,3.19也修了很多的bug,言下之意似乎是說btrfs是靠譜的,不過最後他還是說CoreOS選擇什麼文件係統也是他們自己的權利。
本文的最後吐槽了一下btrfs,盡管經曆了多年的開發,btfs仍然在功能,性能,穩定性上有很多問題,Mason的郵件給大家了一些希望,但是似乎路還很長。
The "too small to fail" memory-allocation rule
對於內核開發者(linux) 來說,比較少見的情況中,係統資源不夠,分配內存失敗,比如:kmalloc, vmalloc, __get_free_pages 等。所以寫代碼的時候,都有錯誤檢查啥的 (比如:分配失敗的某種 exit 路徑)。不過內存子係統在實現上和教科書就有些不一樣了,有時候情況就會比較嚴重,看下麵例子:
一個進程 A 運行情況比較不好,係統內存太少了,這個時候調用 XFS 文件係統的某些接口,事實上,裏麵就可能要先分配內存。 內存管理子係統收到了請求,開始處理,發現內存不夠了,首先可能會先回收(例如:PFRA) ,如果還是不滿足條件(比如:完全沒回收到 page),就走 oom killer 了。 oom killer 就還是有選擇的殺進程了,而且隻能一個一個的殺,這會進程 B 很不幸,被選中了,但是 B 要推出的時候,必須在同一個 XFS 上麵做點操作,而這種操作可能要拿之前 A 已經拿到還沒釋放的鎖(A 也在做 XFS 操作,拿了鎖等內存分配呢),這樣就很可能死鎖了,這種死鎖是 3 者之間的環路等待,oom killer 很可能也會卡住。
係統管理員這會會不會想到要切換到 *bsd 呢?
XFS 的 maintainer Dave Chinner 也問到這麼一個問題,為啥非要走 oom killer 而不是直接 fail 掉這次內存請求,因為 XFS 代碼對請求失敗的處理都是妥妥的呀。
這會內存管理的 maintainer Michal Hocko 也跳出來仍炸彈了:
“好吧,這裏確實有一個隱含的條件,如果低階(小於 3) 連續內存(8 個連續頁)分配的話 (GFP_KERNEL)就不會失敗(其實不是不會失敗啦,隻是大不了就 oom killer 接住)。這個是很久以前的決定了,現在要解決這樣的問題而又不動大量的代碼的話其實會很 tricky, 悲劇啊!“
Dave 這會回複說:
“我們一直被告訴說,啊,內存分配不一定成功啊,除非_GFP_NOFAIL 被指定了,而這個標誌現在也不鼓勵用啊“
”很多代碼都依賴於內存分配失敗啊,比如 page cache, 這也就是說所有的文件係統依賴於這一點。我們不會顯示讓內存分配失敗,而是期待內存子係統在資源不夠的時候分配失敗。大夥都按這種方式寫了 15 年代碼了“
-- 說白了就是,不行就失敗吧,反正我這能處理。
一個 “太小而不能失敗” 原則(覺得中文翻譯好別扭),反正就是,對於一般的內核來說,比如:1/8 個頁(連續),總之,就是很小的連續內存分配就不讓它失敗。誰都不知道這個規則啥時候進的內核,在 git 時代之前估計。Johannes Weiner 說:如果很小的內存分配都滿足不了的話,那基本表明係統已經不可用了,實在沒啥別的辦法了,就幹脆 oom killer 了,意思就是你出錯處理也沒多大意義。但是其實 oom killer 的時候也導致係統不可用。
前麵也提到另外一個選擇就是,_GFP_NORETRY,就是說分配的時候不 retry 了,直接失敗返回,但是會導致小片內存申請也 fail (因為可能不會 reclaim 了呀),但是 Dave 說用這種方式解決死鎖的問題,坑是填不完的,總之不是一勞永逸的辦法了。
然後回到了原始問題上來了,要不要廢棄掉 "too small to fail",這裏還是用英文吧,然後來讓內存分配比較符合大多數開發者的想法,Johannes 回複的消息也帶了一個 patch, 讓回收的無限循環終止掉,當直接回收不成功的時候,但是他也提到,過了這麼久(n 年了) 讓那些小內存(0 階,單頁)分配直接失敗掉是很大風險的。
原因是:很多開發者其實很可能並沒有考慮內存分配失敗的情況,也沒有做失敗後的處理。更大的原因是,即使做了處理,這些出錯代碼路徑基本沒有被走過,因為內存分配失敗本來屬於概率比較低的情況,再加上測試不夠的話(這裏提到 fault injection framework),不知道都有做過沒,反正譯者本人也沒跑過 :
那突然大量的未經測試的出錯路徑可能同時發生的時候,想象一下,mail list 會不會爆啊。反正總之會有很多挑戰。
總之現在,表示,就目前的階段,廢棄掉上麵的規則來解決問題有點接受不了,風險太大,那就還是保持現狀吧,長期來看還是會有更好的內核處理方式,短期我們就先忍忍吧。最簡單的增大內存,或者係統級別的優化吧,這種問題總歸也有很多其他的方法避免。
最後更新:2017-06-07 10:31:24