我在 Linux閱碼場 微信公衆號發表的文章,由於版權原因,通過轉載分享到博客,原文鏈接爲:
《打通IO棧:一次編譯服務器性能優化實戰》:https://mp.weixin.qq.com/s/cX1ciAsYZ0WZm6baFDl-NA
背景
隨着企業SDK在多條產品線的廣泛使用,隨着SDK開發人員的增長,每日往SDK提交的補丁量與日俱增,自動化提交代碼檢查的壓力已經明顯超過了通用服務器的負載。於是向公司申請了一臺專用服務器,用於SDK構建檢查。
$ cat /proc/cpuinfo | grep ^proccessor | wc -l
48
$ free -h
total used free shared buffers cached
Mem: 47G 45G 1.6G 20M 7.7G 25G
-/+ buffers/cache: 12G 35G
Swap: 0B 0B 0B
$ df
文件系統 容量 已用 可用 已用% 掛載點
......
/dev/sda1 98G 14G 81G 15% /
/dev/vda1 2.9T 1.8T 986G 65% /home
這是KVM虛擬的服務器,提供了CPU 48線程,實際可用47G內存,磁盤空間約達到3TB。
由於獨享服務器所有資源,設置了十來個worker並行編譯,從提交補丁到發送編譯結果的速度槓槓的。但是在補丁提交非常多的時候,速度瞬間就慢了下去,一次提交觸發的編譯甚至要1個多小時。通過top看到CPU負載並不高,難道是IO瓶頸?找IT要到了root權限,幹起來!
由於認知的侷限性,如有考慮不周的地方,希望一起交流學習
整體認識IO棧
如果有完整的IO棧的認識,無疑有助於更細膩的優化IO。循着IO棧從上往下的順序,我們逐層分析可優化的地方。
在網上有Linux完整的IO棧結構圖,但太過完整反而不容易理解。按我的認識,簡化過後的IO棧應該是下圖的模樣。
-
用戶空間:除了用戶自己的APP之外,也隱含了所有的庫,例如常見的C庫。我們常用的IO函數,例如
open()/read()/write()
是系統調用,由內核直接提供功能實現,而fopen()/fread()/fwrite()
則是C庫實現的函數,通過封裝系統調用實現更高級的功能。 -
虛擬文件系統:屏蔽具體文件系統的差異,向用戶空間提供統一的入口。具體的文件系統通過
register_filesystem()
向虛擬文件系統註冊掛載鉤子,在用戶掛載具體的文件系統時,通過回調掛載鉤子實現文件系統的初始化。虛擬文件系統提供了inode
來記錄文件的元數據,dentry
記錄了目錄項。對用戶空間,虛擬文件系統註冊了系統調用,例如SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
註冊了open()
的系統調用。 -
具體的文件系統:文件系統要實現存儲空間的管理,換句話說,其規劃了哪些空間存儲了哪些文件的數據,就像一個個收納盒,A文件保存在這個塊,B文件則放在哪個塊。不同的管理策略以及其提供的不同功能,造就了各式各樣的文件系統。除了類似於vfat、ext4、btrfs等常見的塊設備文件系統之外,還有sysfs、procfs、pstorefs、tempfs等構建在內存上的文件系統,也有yaffs,ubifs等構建在Flash上的文件系統。
-
頁緩存:可以簡單理解爲一片存儲着磁盤數據的內存,不過其內部是以頁爲管理單元,常見的頁大小是4K。這片內存的大小不是固定的,每有一筆新的數據,則申請一個新的內存頁。由於內存的性能遠大於磁盤,爲了提高IO性能,我們就可以把IO數據緩存在內存,這樣就可以在內存中獲取要的數據,不需要經過磁盤讀寫的漫長的等待。申請內存來緩存數據簡單,如何管理所有的頁緩存以及如何及時回收緩存頁纔是精髓。
-
通用塊層:通用塊層也可以細分爲
bio層
和request層
。頁緩存
以頁爲管理單位,而bio
則記錄了磁盤塊與頁之間的關係,一個磁盤塊可以關聯到多個不同的內存頁中,通過submit_bio()
提交bio到request層
。一個request可以理解爲多個bio的集合,把多個地址連續的bio合併成一個request。多個request經過IO調度算法的合併和排序,有序地往下層提交IO請求。 -
設備驅動與塊設備:不同塊設備有不同的使用協議,而特定的設備驅動則是實現了特定設備需要的協議以正常驅使設備。對塊設備而言,塊設備驅動需要把request解析成一個個
設備操作指令
,在協議的規範下與塊設備通信來交換數據。
形象點來說,發起一次IO讀請求的過程是怎麼樣的呢?
用戶空間
通過虛擬文件系統
提供的統一的IO系統調用,從用戶態切到內核態。虛擬文件系統
通過調用具體文件系統
註冊的回調,把需求傳遞到 具體的文件系統
中。緊接着 具體的文件系統
根據自己的管理邏輯,換算到具體的磁盤塊地址,從頁緩存
尋找塊設備的緩存數據。讀操作一般是同步的,如果在 頁緩存
沒有緩存數據,則向通用塊層
發起一次磁盤讀。 通用塊層
合併和排序所有進程產生的的IO請求,經過 設備驅動
從 塊設備
讀取真正的數據。最後是逐層返回。讀取的數據既拷貝到用戶空間的buffer中,也會在頁緩存中保留一份副本,以便下次快速訪問。
如果 頁緩存
沒命中,同步讀會一路通到 塊設備
,而對於 異步寫,則是把數據放到 頁緩存
後返回,由內核回刷進程在合適時候回刷到 塊設備
。
根據這個流程,考慮到我沒要到KVM host的權限,我只能着手從Guest端的IO棧做優化,具體包括以下幾個方面:
- 交換分區(swap)
- 文件系統(ext4)
- 頁緩存(Page Cache)
- Request層(IO調度算法)
由於源碼以及編譯的臨時文件都不大但數量極其多,對隨機IO的要求非常高。要提高隨機IO的性能,在不改變硬件的情況下,需要緩存更多數據,以實現合併更多的IO請求。
諮詢ITer得知,服務器都有備用電源,能確保不會掉電停機。出於這樣的情況,我們可以儘可能優化速度,而不用擔心掉電導致數據丟失問題。
總的來說,優化的核心思路是儘可能多的使用內存緩存數據,儘可能減小不必要的開銷,例如文件系統爲了保證數據一致性使用日誌造成的開銷。
交換分區
交換分區的存在,可以讓內核在內存壓力大時,把內核認爲一些不常用的內存置換到交換分區,以此騰出更多的內存給系統。在物理內存容量不足且運行喫內存的應用時,交換分區的作用效果是非常明顯的。
然而本次優化的服務器反而不應該使用交換分區。爲什麼呢?服務器總內存達到47G,且服務器除了Jenkins slave進程外沒有大量喫內存的進程。從內存的使用情況來看,絕大部分內存都是被cache/buffer
佔用,是可丟棄的文件緩存,因此內存是充足的,不需要通過交換分區擴大虛擬內存。
# free -h
total used free shared buffers cached
Mem: 47G 45G 1.6G 21M 18G 16G
-/+ buffers/cache: 10G 36G
交換分區也是磁盤的空間,從交換分區置入置出數據可也是要佔用IO資源的,與本次IO優化目的相悖,因此在此服務器中,需要取消swap分區。
查看系統狀態發現,此服務器並沒使能swap。
# cat /proc/swaps
Filename Type Size Used Priority
#
文件系統
用戶發起一次讀寫,經過了虛擬文件系統(VFS)
後,交給了實際的文件系統。
首先查詢分區掛載情況:
# mount
...
/dev/sda1 on on / type ext4 (rw)
/dev/vda1 on /home type ext4 (rw)
...
此服務器主要有兩個塊設備,分別是 sda和 vda。sda 是常見的 SCSI/IDE 設備,我們個人PC上如果使用的機械硬盤,往往就會是 sda 設備節點。vda 是 virtio 磁盤設備。由於本服務器是 KVM 提供的虛擬機,不管是 sda 還是 vda,其實都是虛擬設備,差別在於前者是完全虛擬化的塊設備,後者是半虛擬化的塊設備。從網上找到的資料來看,使用半虛擬化的設備,可以實現Host與Guest更高效的協作,從而實現更高的性能。在此例子中,sda 作爲根文件系統使用,vda 則是用於存儲用戶數據,在編譯時,主要看得是 vda 分區的IO情況。
vda 使用 ext4 文件系統。ext4 是目前常見的Linux上使用的穩定的文件系統,查看其超級塊信息:
# dumpe2fs /dev/vda1
...
Filesystem features: has_journal dir_index ...
...
Inode count: 196608000
Block count: 786431991
Free inodes: 145220571
Block size: 4096
...
我猜測ITer使用的默認參數格式化的分區,爲其分配了塊大小爲4K,inode數量達到19660萬個且使能了日誌。
塊大小設爲4K無可厚非,適用於當前源文件偏小的情況,也沒必要爲了更緊湊的空間降低塊大小。空閒 inode 達到 14522萬,空閒佔比達到 73.86%。當前 74% 的空間使用率,inode只使用了26.14%。一個inode佔256B,那麼10000萬個inode佔用23.84G。inode 實在太多了,造成大量的空間浪費。可惜,inode數量在格式化時指定,後期無法修改,當前也不能簡單粗暴地重新格式化。
我們能做什麼呢?我們可以從日誌和掛載參數着手優化
日誌是爲了保證掉電時文件系統的一致性,(ordered日誌模式下)通過把元數據寫入到日誌塊,在寫入數據後再修改元數據。如果此時掉電,通過日誌記錄可以回滾文件系統到上一個一致性的狀態,即保證元數據與數據是匹配的。然而上文有說,此服務器有備用電源,不需要擔心掉電,因此完全可以把日誌取消掉。
# tune2fs -O ^has_journal /dev/vda1
tune2fs 1.42.9 (4-Feb-2014)
The has_journal feature may only be cleared when the filesystem is
unmounted or mounted read-only.
可惜失敗了。由於時刻有任務在執行,不太好直接umount
或者-o remount,ro
,無法在掛載時取消日誌。既然取消不了,咱們就讓日誌最少損耗,就需要修改掛載參數了。
ext4掛載參數: data
ext4有3種日誌模式,分別是ordered
,writeback
,journal
。他們的差別網上有很多資料,我簡單介紹下:
- jorunal:把元數據與數據一併寫入到日誌塊。性能差不多折半,因爲數據寫了兩次,但最安全
- writeback: 把元數據寫入日誌塊,數據不寫入日誌塊,但不保證數據先落盤。性能最高,但由於不保證元數據與數據的順序,也是掉電最不安全的
- ordered:與writeback相似,但會保證數據先落盤,再是元數據。折中性能以保證足夠的安全,這是大多數PC上推薦的默認的模式
在不需要擔心掉電的服務器環境,我們完全可以使用writeback的日誌模式,以獲取最高的性能。
# mount -o remount,rw,data=writeback /home
mount: /home not mounted or bad option
# dmesg
[235737.532630] EXT4-fs (vda1): Cannot change data mode on remount
沮喪,又是不能動態改,乾脆寫入到/etc/config
,只能寄希望於下次重啓了。
# cat /etc/fstab
UUID=... /home ext4 defaults,rw,data=writeback...
ext4掛載參數:noatime
Linux上對每個文件都記錄了3個時間戳
時間戳 | 全稱 | 含義 |
---|---|---|
atime | access time | 訪問時間,就是最近一次讀的時間 |
mtime | data modified time | 數據修改時間,就是內容最後一次改動時間 |
ctime | status change time | 文件狀態(元數據)的改變時間,比如權限,所有者等 |
我們編譯執行的Make
可以根據修改時間來判斷是否要重新編譯,而atime
記錄的訪問時間其實在很多場景下都是多餘的。所以,noatime
應運而生。不記錄atime
可以大量減少讀造成的元數據寫入量,而元數據的寫入往往產生大量的隨機IO。
# mount -o ...noatime... /home
ext4掛載參數:nobarrier
這主要是決定在日誌代碼中是否使用寫屏障(write barrier),對日誌提交進行正確的磁盤排序,使易失性磁盤寫緩存可以安全使用,但會帶來一些性能損失。從功能來看,跟writeback
和ordered
日誌模式非常相似。沒研究過這方面的源碼,說不定就是一回事。不管怎麼樣,禁用寫屏障毫無疑問能提高寫性能。
# mount -o ...nobarrier... /home
ext4掛載參數:delalloc
delalloc
是 delayed allocation 的縮寫,如果使能,則ext4會延緩申請數據塊直至超時。爲什麼要延緩申請呢?在inode中採用多級索引的方式記錄了文件數據所在的數據塊編號,如果出現大文件,則會採用 extent 區段的形式,分配一片連續的塊,inode中只需要記錄開始塊號與長度即可,不需要索引記錄所有的塊。這除了減輕inode的壓力之外,連續的塊可以把隨機寫改爲順序寫,加快寫性能。連續的塊也符合 局部性原理,在預讀時可以加大命中概率,進而加快讀性能。
# mount -o ...delalloc... /home
ext4掛載參數:inode_readahead_blks
ext4從inode表中預讀的indoe block最大數量。訪問文件必須經過inode獲取文件信息、數據塊地址。如果需要訪問的inode都在內存中命中,就不需要從磁盤中讀取,毫無疑問能提高讀性能。其默認值是32,表示最大預讀 32 × block_size 即 64K 的inode數據,在內存充足的情況下,我們毫無疑問可以進一步擴大,讓其預讀更多。
# mount -o ...inode_readahead_blks=4096... /home
ext4掛載參數:journal_async_commit
commit塊可以不等待descriptor塊,直接往磁盤寫。這會加快日誌的速度。
# mount -o ...journal_async_commit... /home
ext4掛載參數:commit
ext4一次緩存多少秒的數據。默認值是5,表示如果此時掉電,你最多丟失5s的數據量。設置更大的數據,就可以緩存更多的數據,相對的掉電也有可能丟失更多的數據。在此服務器不怕掉電的情況,把數值加大可以提高性能。
# mount -o ...commit=1000... /home
ext4掛載參數彙總
最終在不能umount情況下,我執行的調整掛載參數的命令爲:
mount -o remount,rw,noatime,nobarrier,delalloc,inode_readahead_blks=4096,journal_async_commit,commit=1800 /home
此外,在/etc/fstab
中也對應修改過來,避免重啓後優化丟失
# cat /etc/fstab
UUID=... /home ext4 defaults,rw,noatime,nobarrier,delalloc,inode_readahead_blks=4096,journal_async_commit,commit=1800,data=writeback 0 0
...
頁緩存
頁緩存在FS與通用塊層之間,其實也可以歸到通用塊層中。爲了提高IO性能,減少真實的從磁盤讀寫的次數,Linux內核設計了一層內存緩存,把磁盤數據緩存到內存中。由於內存以4K大小的 頁 爲單位管理,磁盤數據也以頁爲單位緩存,因此也稱爲頁緩存。在每個緩存頁中,都包含了部分磁盤信息的副本。
如果因爲之前讀寫過或者被預讀加載進來,要讀取數據剛好在緩存中命中,就可以直接從緩存中讀取,不需要深入到磁盤。不管是同步寫還是異步寫,都會把數據copy到緩存,差別在於異步寫只是copy且把頁標識髒後直接返回,而同步寫還會調用類似fsync()
的操作等待回寫,詳細可以看內核函數generic_file_write_iter()
。異步寫產生的髒數據會在“合適”的時候被內核工作隊列writeback
進程回刷。
那麼,什麼時候是合適的時候呢?最多能緩存多少數據呢?對此次優化的服務器而言,毫無疑問延遲迴刷可以在頻繁的刪改文件中減少寫磁盤次數,緩存更多的數據可以更容易合併隨機IO請求,有助於提升性能。
在/proc/sys/vm
中有以下文件與回刷髒數據密切相關:
配置文件 | 功能 | 默認值 |
---|---|---|
dirty_background_ratio | 觸發回刷的髒數據佔可用內存的百分比 | 0 |
dirty_background_bytes | 觸發回刷的髒數據量 | 10 |
dirty_bytes | 觸發同步寫的髒數據量 | 0 |
dirty_ratio | 觸發同步寫的髒數據佔可用內存的百分比 | 20 |
dirty_expire_centisecs | 髒數據超時回刷時間(單位:1/100s) | 3000 |
dirty_writeback_centisecs | 回刷進程定時喚醒時間(單位:1/100s) | 500 |
對上述的配置文件,有幾點要補充的:
- XXX_ratio 和 XXX_bytes 是同一個配置屬性的不同計算方法,優先級 XXX_bytes > XXX_ratio
- 可用內存並不是系統所有內存,而是free pages + reclaimable pages
- 髒數據超時表示內存中數據標識髒一定時間後,下次回刷進程工作時就必須回刷
- 回刷進程既會定時喚醒,也會在髒數據過多時被動喚醒。
- dirty_background_XXX與dirty_XXX的差別在於前者只是喚醒回刷進程,此時應用依然可以異步寫數據到Cache,當髒數據比例繼續增加,觸發dirty_XXX的條件,不再支持應用異步寫。
更完整的功能介紹,可以看內核文檔Documentation/sysctl/vm.txt
,也可看我寫的一篇總結博客《Linux 髒數據回刷參數與調優》
對當前的案例而言,我的配置如下:
dirty_background_ratio = 60
dirty_ratio = 80
dirty_writeback_centisecs = 6000
dirty_expire_centisecs = 12000
這樣的配置有以下特點:
- 當髒數據達到可用內存的60%時喚醒回刷進程
- 當髒數據達到可用內存的80%時,應用每一筆數據都必須同步等待
- 每隔60s喚醒一次回刷進程
- 內存中髒數據存在時間超過120s則在下一次喚醒時回刷
當然,爲了避免重啓後丟失優化結果,我們在/etc/sysctl.conf
中寫入:
# cat /etc/sysctl.conf
...
vm.dirty_background_ratio = 60
vm.dirty_ratio = 80
vm.dirty_expire_centisecs = 12000
vm.dirty_writeback_centisecs = 6000
Request層
在異步寫的場景中,當髒頁達到一定比例,就需要通過通用塊層把頁緩存裏的數據回刷到磁盤中。bio層記錄了磁盤塊與內存頁之間的關係,在request層把多個物理塊連續的bio合併成一個request,然後根據特定的IO調度算法對系統內所有進程產生的IO請求進行合併、排序。那麼都有什麼IO調度算法呢?
網上檢索IO調度算法
,大量的資料都在描述Deadline
,CFQ
,NOOP
這3種調度算法,卻沒有備註這只是單隊列上適用的調度算法。在最新的代碼上(我分析的代碼版本爲 5.7.0),已經完全切換到multi-queue的新架構上了,支持的IO調度算法就成了mq-deadline
,BFQ
,Kyber
,none
。
關於不同IO調度算法的優劣,網上有非常多的資料,本文不再累述。
在《Linux-storage-stack-diagram_v4.10》 對 Block Layer 的描述可以形象闡述單隊列與多隊列的差異。
單隊列的架構,一個塊設備只有一個全局隊列,所有請求都要往這個隊列裏面塞,這在多核高併發的情況下,尤其像服務器動則32個核的情況下,爲了保證互斥而加的鎖就導致了非常大的開銷。此外,如果磁盤支持多隊列並行處理,單隊列的模型不能充分發揮其優越的性能。
多隊列的架構下,創建了Software queues
和Hardware dispatch queues
兩級隊列。Software queues是每個CPU core一個隊列,且在其中實現IO調度。由於每個CPU一個單獨隊列,因此不存在鎖競爭問題。Hardware Dispatch Queues的數量跟硬件情況有關,每個磁盤一個隊列,如果磁盤支持並行N個隊列,則也會創建N個隊列。在IO請求從Software queues提交到Hardware Dispatch Queues的過程中是需要加鎖的。理論上,多隊列的架構的效率最差也只是跟單隊列架構持平。
咱們回到當前待優化的服務器,當前使用的是什麼IO調度器呢?
# cat /sys/block/vda/queue/scheduler
none
# cat /sys/block/sda/queue/scheduler
noop [deadline] cfq
這服務器的內核版本是
# uname -r
3.13.0-170-generic
查看Linux內核git提交記錄,發現在 3.13.0 的內核版本上還沒有實現適用於多隊列的IO調度算法,且此時還沒完全切到多隊列架構,因此使用單隊列的 sda 設備依然存在傳統的noop
,deadline
和cfq
調度算法,而使用多隊列的 vda 設備(virtio)的IO調度算法只有none
。爲了使用mq-deadline
調度算法把內核升級的風險似乎很大。因此IO調度算法方面沒太多可優化的。
但Request層優化只能這樣了?既然IO調度算法無法優化,我們是否可以修改queue相關的參數?例如加大Request隊列的長度,加大預讀的數據量。
在/sys/block/vda/queue
中有兩個可寫的文件nr_requests
和read_ahead_kb
,前者是配置塊層最大可以申請的request數量,後者是預讀最大的數據量。默認情況下,
nr_request = 128
read_ahead_kb = 128
我擴大爲
nr_request = 1024
read_ahead_kb = 512
優化效果
優化後,在滿負荷的情況下,查看內存使用情況:
# cat /proc/meminfo
MemTotal: 49459060 kB
MemFree: 1233512 kB
Buffers: 12643752 kB
Cached: 21447280 kB
Active: 19860928 kB
Inactive: 16930904 kB
Active(anon): 2704008 kB
Inactive(anon): 19004 kB
Active(file): 17156920 kB
Inactive(file): 16911900 kB
...
Dirty: 7437540 kB
Writeback: 1456 kB
可以看到,文件相關內存(Active(file) + Inactive(file) )達到了32.49GB,髒數據達到7.09GB。髒數據量比預期要少,遠沒達到dirty_background_ratio
和dirty_ratio
設置的閾值。因此,如果需要緩存更多的寫數據,只能延長定時喚醒回刷的時間dirty_writeback_centisecs
。這個服務器主要用於編譯SDK,讀的需求遠大於寫,因此緩存更多的髒數據沒太大意義。
我還發現Buffers達到了12G,應該是ext4的inode佔用了大量的緩存。如上分析的,此服務器的ext4有大量富餘的inode,在緩存的元數據裏,無效的inode不知道佔比多少。減少inode數量,提高inode利用率,說不定可以提高inode預讀的命中率。
優化後,一次使能8個SDK並行編譯,走完一次完整的編譯流程(包括更新代碼,抓取提交,編譯內核,編譯SDK等),在沒有進入錯誤處理流程的情況下,用時大概13分鐘。
這次的優化就到這裏結束了,等後期使用過程如果還有問題再做調整。