x86 Linux 下實(shí)現(xiàn) 10us 誤差的高精度延時(shí)
在 Linux 下實(shí)現(xiàn)高精度延時(shí),網(wǎng)上所能找到的大部分方法只能實(shí)現(xiàn) 50us 左右的延時(shí)精度。今天讓我們來看下
是如何解決這個(gè)問題的,將延時(shí)精度提升到 10us。
問題描述
最近在開發(fā)一個(gè)項(xiàng)目,需要用到高精度的延時(shí)機(jī)制,設(shè)計(jì)需求是 1000us 周期下,誤差不能超過 1%(10us)。
由于項(xiàng)目硬件方案是用英特爾的 x86 處理器,熟悉 Linux 硬件的人都知道這個(gè)很難實(shí)現(xiàn)。當(dāng)時(shí)評估方案時(shí)候有些草率,直接采用了 “PREEMPT_RT 補(bǔ)丁+內(nèi)核 hrtimer+信號(hào)通知” 的方式來評估。當(dāng)時(shí)驗(yàn)證的結(jié)果也很滿意,于是興沖沖的告訴領(lǐng)導(dǎo)說方案可行,殊不知自己挖了一個(gè)巨大的坑……
實(shí)際項(xiàng)目開始的時(shí)候,發(fā)現(xiàn)這個(gè)方案根本行不通,有兩個(gè)原因:
- 信號(hào)通知只能通知到進(jìn)程,而目前移植的方案無法做到被通知的進(jìn)程中無其他線程。這樣高頻的信號(hào)發(fā)過來,其他線程基本上都會(huì)被干掉。(補(bǔ)充說明:這里特指的是內(nèi)核驅(qū)動(dòng)通知到應(yīng)用層,在用戶層中是有專門的函數(shù)可以通知不同線程的。并且這個(gè)問題經(jīng)過研究,可以通過設(shè)置線程的 sigmask 來解決,但是依舊無法改變方案行不通的結(jié)論)
- 這也是主要原因,項(xiàng)目中需要用的 Ethercat 的同步周期雖然可以在程序開始時(shí)固定,但是實(shí)際運(yùn)行時(shí)的運(yùn)行周期是需要?jiǎng)討B(tài)調(diào)整的,調(diào)整范圍在 5us 以內(nèi)。這樣一來,動(dòng)態(tài)調(diào)整 hrtimer 的開銷就變得無法忽略了,換句話說,我們需要的是一個(gè)延時(shí)機(jī)制,而不是定時(shí)器。
所以這個(gè)方案被否定了。
解決思路
既然信號(hào)方式不行,那只能通過其他手段來分析。總結(jié)下來我大致進(jìn)行了如下的嘗試:
1、sleep方案的確定
嘗試過 usleep
、nanosleep
、clock_nanosleep
、cond_timedwait
、select
等,最終確定用 clock_nanosleep
,選它的原因并不是因?yàn)樗С?ns 級(jí)別的精度。因?yàn)榻?jīng)過測試發(fā)現(xiàn),上述幾個(gè)調(diào)用在周期小于 10000us 的情況下,精度都差不多,誤差主要都來自于上下文切換的開銷。選它的主要原因是因?yàn)樗С?TIME_ABSTIME
選項(xiàng),即支持絕對時(shí)間。這里舉個(gè)簡單的例子,解釋一下為什么要用絕對時(shí)間:
while(1){
do_work();
sleep(1);
do_post();
}
假設(shè)上面這個(gè)循環(huán),我們目的是讓 do_post
的執(zhí)行以 1s 的周期執(zhí)行一次,但是實(shí)際上,不可能是絕對的 1s,因?yàn)?sleep()
只能延時(shí)相對時(shí)間,而目前這個(gè)循環(huán)的實(shí)際周期是 do_work
的開銷 + sleep(1)
的時(shí)間。所以這種開銷放在我們需求的場景中,就變得無法忽視了。而用 clock_nanosleep
的好處就是一方面它可以選擇時(shí)鐘源,其次就是它支持絕對時(shí)間喚醒,這樣我在每次 do_work
之前都設(shè)置一下 clock_nanosleep
下一次喚醒時(shí)的絕對時(shí)間,那么 clock_nanosleep
實(shí)際執(zhí)行的時(shí)間其實(shí)就會(huì)減去 do_work
的開銷,相當(dāng)于是鬧鐘的概念。
2、改用實(shí)時(shí)線程
將重要任務(wù)的線程改成實(shí)時(shí)線程,調(diào)度策略改成 FIFO,優(yōu)先級(jí)設(shè)到最高,減少被搶占的可能性。
3、設(shè)置線程的親和性
對應(yīng)用下所有的線程進(jìn)行規(guī)劃,根據(jù)負(fù)載情況將幾個(gè)負(fù)載比較重的任務(wù)線程分別綁定到不同的 CPU 核上,這樣減少切換 CPU 帶來的開銷。
4、減少不必要的sleep調(diào)用
由于很多任務(wù)都存在 sleep
調(diào)用,我用 strace
命令分析了整個(gè)系統(tǒng)中應(yīng)用 sleep
調(diào)用的比例,高達(dá) 98%,這種高頻次休眠+喚醒帶來的開銷勢必是不可忽略的。所以我將 main
循環(huán)中的 sleep
改成了循環(huán)等待信號(hào)量的方式,因?yàn)?pthread 庫中信號(hào)量的等待使用了 futex
,它使得喚醒線程的開銷會(huì)小很多。其他地方的 sleep
也盡可能的優(yōu)化掉。這個(gè)效果其實(shí)比較明顯,能差不多減少 20us 的誤差。
5、絕招
從現(xiàn)有應(yīng)用中剝離出最小任務(wù),減少所有外界任務(wù)的影響。
經(jīng)過上述五點(diǎn),1000us 的誤差從一開始的 ±100us,控制到了 ±40us。但是這還遠(yuǎn)遠(yuǎn)不夠……
黔驢技窮的我開始漫長的搜索研究中……
這期間也發(fā)現(xiàn)了一些奇怪的現(xiàn)象,比如下面這張圖。
圖片是用 Python 對抓包工具的數(shù)據(jù)進(jìn)行分析生成的,參考性不用質(zhì)疑。縱軸代表實(shí)際這個(gè)周期所耗費(fèi)的時(shí)間。可以發(fā)現(xiàn)很有意思的現(xiàn)象:
- 每隔一定周期,會(huì)集中出現(xiàn)規(guī)模的誤差抖動(dòng)
- 誤差不是正態(tài)分布,而是頻繁出現(xiàn)在 ±30us 左右的地方
- 每次產(chǎn)生較大的誤差時(shí),下個(gè)周期一定會(huì)出現(xiàn)一次反向的誤差,而且幅度大致相同(這點(diǎn)從圖上看不出來,通過其他手段分析的)。
簡單描述一下就是假設(shè)這個(gè)周期的執(zhí)行時(shí)間是 980us,那下個(gè)周期的執(zhí)行時(shí)間一定會(huì)在 1020us 左右。
第 1 點(diǎn)和第 2 點(diǎn)可以經(jīng)過上面的 4 條優(yōu)化措施消除,第 3 點(diǎn)沒有找到非常有效的手段,我的理解可能內(nèi)核對這種誤差是知曉的并且有意在彌補(bǔ),如果有知道相關(guān)背后原理的大神歡迎分享一下。
針對這個(gè)第三點(diǎn)奇怪的現(xiàn)象我也嘗試做了手動(dòng)的干預(yù),比如設(shè)一個(gè)閾值,當(dāng)實(shí)際程序執(zhí)行的誤差大于這個(gè)閾值時(shí),我就在設(shè)置下一個(gè)周期的喚醒時(shí)間時(shí),手動(dòng)減去這個(gè)誤差,但是運(yùn)行效果卻大跌眼鏡,更差了……
柳暗花明
在嘗試了 200 多次參數(shù)調(diào)整,被這個(gè)問題卡了一個(gè)多禮拜之后,偶然發(fā)現(xiàn)了一篇戴爾的技術(shù)文檔《Controlling Processor C-State Usage in Linux》,受到這篇文章的啟發(fā),終于解決了這個(gè)難題。
隨后經(jīng)過一番針對性的查找終于摸清了來龍去脈:
原來英特爾的 CPU 為了節(jié)能,有很多功耗模式,簡稱 C-state。
模式 名字 作用 CPU C0 操作狀態(tài) CPU完全打開 所有CPU C1 停止 通過軟件停止 CPU 內(nèi)部主時(shí)鐘;總線接口單元和 APIC 仍然保持全速運(yùn)行 486DX4及以上 C1E 增強(qiáng)型停止 通過軟件停止 CPU 內(nèi)部主時(shí)鐘并降低 CPU 電壓;總線接口單元和 APIC 仍然保持全速運(yùn)行 所有socket 775 CPU C1E — 停止所有CPU內(nèi)部時(shí)鐘 Turion 64、65-nm Athlon X2和Phenom CPU C2 停止授予 通過硬件停止 CPU 內(nèi)部主時(shí)鐘;總線接口單元和 APIC 仍然保持全速運(yùn)行 486DX4及以上 C2 停止時(shí)鐘 通過硬件停止CPU內(nèi)部和外部時(shí)鐘 僅限486DX4、Pentium、Pentium MMX、K5、K6、K6-2、K6-III C2E 擴(kuò)展的停止授予 通過硬件停止 CPU 內(nèi)部主時(shí)鐘并降低 CPU 電壓;總線接口單元和 APIC 仍然保持全速運(yùn)行 Core 2 Duo和更高版本(僅限Intel) C3 睡眠 停止所有CPU內(nèi)部時(shí)鐘 Pentium II、Athlon以上支持,但Core 2 Duo E4000和E6000上不支持 C3 深度睡眠 停止所有CPU內(nèi)部和外部時(shí)鐘 Pentium II以上支持,但Core 2 Duo E4000、E6000和Turion 64上不支持 C3 AltVID 停止所有CPU內(nèi)部時(shí)鐘和降低CPU電壓 AMD Turion 64 C4 更深入的睡眠 降低CPU電壓 Pentium M以上支持,但Core 2 Duo E4000、E6000和Turion 64上不支持 C4E/C5 增強(qiáng)的更深入的睡眠 大幅降低CPU電壓并關(guān)閉內(nèi)存高速緩存 Core Solo、Core Duo和45-nm移動(dòng)版Core 2 Duo支持 C6 深度電源關(guān)閉 將 CPU 內(nèi)部電壓降低至任何值,包括 0 V 僅45-nm移動(dòng)版Core 2 Duo支持
圖表來自 DELL
當(dāng)程序運(yùn)行的時(shí)候,CPU 是在 C0 狀態(tài),但是一旦操作系統(tǒng)進(jìn)入休眠,CPU 就會(huì)用 Halt 指令切換到 C1 或者 C1E 模式,這個(gè)模式下操作系統(tǒng)如果進(jìn)行喚醒,那么上下文切換的開銷就會(huì)變大!
這個(gè)選項(xiàng)按道理 BIOS 是可以關(guān)掉的,但是坑的地方就在于版本相對較新的 Linux 內(nèi)核版本,默認(rèn)是開啟這個(gè)狀態(tài)的,并且是無視 BIOS 設(shè)置的!這就很坑了!
針對性查找之后,發(fā)現(xiàn)網(wǎng)上也有網(wǎng)友測試,2.6 版本的內(nèi)核不會(huì)默認(rèn)開啟這個(gè),但是 3.2 版本的內(nèi)核就會(huì)開啟,而且對比測試發(fā)現(xiàn),這兩個(gè)版本內(nèi)核在相同硬件的情況下,上下文切換開銷可以相差 10 倍,前者是 4us,后者是 40-60us。
解決辦法
1、永久修改
可以修改 Linux 的引導(dǎo)參數(shù),修改 /etc/default/grub
文件中的 GRUB_CMDLINE_LINUX_DEFAULT
選項(xiàng),改成下面的內(nèi)容:
intel_idle.max_cstate=0 processor.max_cstate=0 idle=poll
然后使用 update-grub
命令使參數(shù)生效,重啟即可。
2、動(dòng)態(tài)修改
可以通過向 /dev/cpu_dma_latency
這個(gè)文件中寫值,來調(diào)整 C1/C1E 模式下上下文切換的開銷。我選擇寫 0
直接關(guān)閉。當(dāng)然你也可以選擇寫一個(gè)數(shù)值,這個(gè)數(shù)值就代表上下文切換的開銷,單位是 us。比如你寫 1
,那么就是設(shè)置開銷為 1us。當(dāng)然這個(gè)值是有范圍的,這個(gè)范圍在 /sys/devices/system/cpu/cpuX/cpuidle/stateY/latency
文件中可以查到,X 代表具體哪個(gè)核,Y 代表對應(yīng)的 idle_state。
至此,這個(gè)性能問題就得到了完美的解決,目前穩(wěn)定測試的性能如下圖所示:
實(shí)現(xiàn)了 x86 Linux 下高精度延時(shí) 1000us 精確延時(shí),精度 10us。