[轉載] 打通IO棧:一次編譯服務器性能優化

我在 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棧應該是下圖的模樣。

  1. 用戶空間:除了用戶自己的APP之外,也隱含了所有的庫,例如常見的C庫。我們常用的IO函數,例如open()/read()/write()是系統調用,由內核直接提供功能實現,而fopen()/fread()/fwrite()則是C庫實現的函數,通過封裝系統調用實現更高級的功能。

  2. 虛擬文件系統:屏蔽具體文件系統的差異,向用戶空間提供統一的入口。具體的文件系統通過register_filesystem()向虛擬文件系統註冊掛載鉤子,在用戶掛載具體的文件系統時,通過回調掛載鉤子實現文件系統的初始化。虛擬文件系統提供了inode來記錄文件的元數據,dentry記錄了目錄項。對用戶空間,虛擬文件系統註冊了系統調用,例如SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)註冊了open()的系統調用。

  3. 具體的文件系統:文件系統要實現存儲空間的管理,換句話說,其規劃了哪些空間存儲了哪些文件的數據,就像一個個收納盒,A文件保存在這個塊,B文件則放在哪個塊。不同的管理策略以及其提供的不同功能,造就了各式各樣的文件系統。除了類似於vfat、ext4、btrfs等常見的塊設備文件系統之外,還有sysfs、procfs、pstorefs、tempfs等構建在內存上的文件系統,也有yaffs,ubifs等構建在Flash上的文件系統。

  4. 頁緩存:可以簡單理解爲一片存儲着磁盤數據的內存,不過其內部是以頁爲管理單元,常見的頁大小是4K。這片內存的大小不是固定的,每有一筆新的數據,則申請一個新的內存頁。由於內存的性能遠大於磁盤,爲了提高IO性能,我們就可以把IO數據緩存在內存,這樣就可以在內存中獲取要的數據,不需要經過磁盤讀寫的漫長的等待。申請內存來緩存數據簡單,如何管理所有的頁緩存以及如何及時回收緩存頁纔是精髓。

  5. 通用塊層:通用塊層也可以細分爲bio層request層頁緩存以頁爲管理單位,而bio則記錄了磁盤塊與頁之間的關係,一個磁盤塊可以關聯到多個不同的內存頁中,通過submit_bio()提交bio到request層。一個request可以理解爲多個bio的集合,把多個地址連續的bio合併成一個request。多個request經過IO調度算法的合併和排序,有序地往下層提交IO請求。

  6. 設備驅動與塊設備:不同塊設備有不同的使用協議,而特定的設備驅動則是實現了特定設備需要的協議以正常驅使設備。對塊設備而言,塊設備驅動需要把request解析成一個個設備操作指令,在協議的規範下與塊設備通信來交換數據。

形象點來說,發起一次IO讀請求的過程是怎麼樣的呢?

用戶空間 通過虛擬文件系統 提供的統一的IO系統調用,從用戶態切到內核態。虛擬文件系統通過調用具體文件系統註冊的回調,把需求傳遞到 具體的文件系統 中。緊接着 具體的文件系統 根據自己的管理邏輯,換算到具體的磁盤塊地址,從頁緩存 尋找塊設備的緩存數據。讀操作一般是同步的,如果在 頁緩存 沒有緩存數據,則向通用塊層發起一次磁盤讀。 通用塊層合併和排序所有進程產生的的IO請求,經過 設備驅動塊設備 讀取真正的數據。最後是逐層返回。讀取的數據既拷貝到用戶空間的buffer中,也會在頁緩存中保留一份副本,以便下次快速訪問。

如果 頁緩存 沒命中,同步讀會一路通到 塊設備 ,而對於 異步寫,則是把數據放到 頁緩存 後返回,由內核回刷進程在合適時候回刷到 塊設備

根據這個流程,考慮到我沒要到KVM host的權限,我只能着手從Guest端的IO棧做優化,具體包括以下幾個方面:

  1. 交換分區(swap)
  2. 文件系統(ext4)
  3. 頁緩存(Page Cache)
  4. 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)
...

此服務器主要有兩個塊設備,分別是 sdavdasda 是常見的 SCSI/IDE 設備,我們個人PC上如果使用的機械硬盤,往往就會是 sda 設備節點。vdavirtio 磁盤設備。由於本服務器是 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種日誌模式,分別是orderedwritebackjournal。他們的差別網上有很多資料,我簡單介紹下:

  1. jorunal:把元數據與數據一併寫入到日誌塊。性能差不多折半,因爲數據寫了兩次,但最安全
  2. writeback: 把元數據寫入日誌塊,數據不寫入日誌塊,但不保證數據先落盤。性能最高,但由於不保證元數據與數據的順序,也是掉電最不安全的
  3. 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),對日誌提交進行正確的磁盤排序,使易失性磁盤寫緩存可以安全使用,但會帶來一些性能損失。從功能來看,跟writebackordered日誌模式非常相似。沒研究過這方面的源碼,說不定就是一回事。不管怎麼樣,禁用寫屏障毫無疑問能提高寫性能。

# mount -o ...nobarrier... /home

ext4掛載參數:delalloc

delallocdelayed 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

對上述的配置文件,有幾點要補充的:

  1. XXX_ratio 和 XXX_bytes 是同一個配置屬性的不同計算方法,優先級 XXX_bytes > XXX_ratio
  2. 可用內存並不是系統所有內存,而是free pages + reclaimable pages
  3. 髒數據超時表示內存中數據標識髒一定時間後,下次回刷進程工作時就必須回刷
  4. 回刷進程既會定時喚醒,也會在髒數據過多時被動喚醒。
  5. 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

這樣的配置有以下特點:

  1. 當髒數據達到可用內存的60%時喚醒回刷進程
  2. 當髒數據達到可用內存的80%時,應用每一筆數據都必須同步等待
  3. 每隔60s喚醒一次回刷進程
  4. 內存中髒數據存在時間超過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調度算法,大量的資料都在描述DeadlineCFQNOOP這3種調度算法,卻沒有備註這只是單隊列上適用的調度算法。在最新的代碼上(我分析的代碼版本爲 5.7.0),已經完全切換到multi-queue的新架構上了,支持的IO調度算法就成了mq-deadlineBFQKybernone

關於不同IO調度算法的優劣,網上有非常多的資料,本文不再累述。

《Linux-storage-stack-diagram_v4.10》 對 Block Layer 的描述可以形象闡述單隊列與多隊列的差異。

單隊列的架構,一個塊設備只有一個全局隊列,所有請求都要往這個隊列裏面塞,這在多核高併發的情況下,尤其像服務器動則32個核的情況下,爲了保證互斥而加的鎖就導致了非常大的開銷。此外,如果磁盤支持多隊列並行處理,單隊列的模型不能充分發揮其優越的性能。

多隊列的架構下,創建了Software queuesHardware 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 設備依然存在傳統的noopdeadlinecfq調度算法,而使用多隊列的 vda 設備(virtio)的IO調度算法只有none。爲了使用mq-deadline調度算法把內核升級的風險似乎很大。因此IO調度算法方面沒太多可優化的。

但Request層優化只能這樣了?既然IO調度算法無法優化,我們是否可以修改queue相關的參數?例如加大Request隊列的長度,加大預讀的數據量。

/sys/block/vda/queue中有兩個可寫的文件nr_requestsread_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_ratiodirty_ratio設置的閾值。因此,如果需要緩存更多的寫數據,只能延長定時喚醒回刷的時間dirty_writeback_centisecs。這個服務器主要用於編譯SDK,讀的需求遠大於寫,因此緩存更多的髒數據沒太大意義。

我還發現Buffers達到了12G,應該是ext4的inode佔用了大量的緩存。如上分析的,此服務器的ext4有大量富餘的inode,在緩存的元數據裏,無效的inode不知道佔比多少。減少inode數量,提高inode利用率,說不定可以提高inode預讀的命中率。

優化後,一次使能8個SDK並行編譯,走完一次完整的編譯流程(包括更新代碼,抓取提交,編譯內核,編譯SDK等),在沒有進入錯誤處理流程的情況下,用時大概13分鐘。

這次的優化就到這裏結束了,等後期使用過程如果還有問題再做調整。

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