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