在一般 Windows 上盡可能實現軟即時的實戰指南

· 更新日期: · · Windows 開發, 軟即時, 設計, 量測

更新紀錄(1 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.21616242)
初次發布
引用本文(DOI: 10.5281/zenodo.21616241)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈在一般 Windows 上盡可能實現軟即時的實戰指南〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616241 https://comcomponent.com/zh-TW/blog/2026/03/09/000-windows-soft-realtime-practical-guide-natural/

DOI(最新版本)
10.5281/zenodo.21616241
DOI(此版本)
10.5281/zenodo.22297075

在 Windows 上開發週期處理、音訊處理、影片處理、量測、設備控制這類「遲到就麻煩」的處理時,常會有人覺得「Windows 大概撐不住吧」。這個印象一半對、一半不對:Windows 確實不是 hard real-time OS,但只要把 設計、實作、量測、維運 都確實敲定,就能把 soft real-time 做到相當實用的狀態。

本文要談的是 不以特殊 RTOS 擴充、自製核心驅動程式或專用控制器為前提的一般 Windows 10 / 11,內容偏向實務:在日常使用的桌機或筆電上,用 user-mode 應用程式能把延遲與抖動壓到什麼程度。音訊、影片、週期控制、資料擷取在細節上各有不同,但容易出問題的地方相當一致,所以這次把共通的部分整理成 檢查清單 的形式。

目標讀者與程式碼範例使用的語言

本文寫給 在 Windows 上開發「遲到就麻煩」的處理(週期控制、音訊與影片、量測、設備控制)的開發者。設想的情境是 user-mode 的應用程式開發,核心模式驅動程式的實作不在範圍內。

程式碼範例使用的語言分成以下幾類。

內容 語言 位置
週期迴圈、MMCSS、電源 QoS 等直接呼叫 Win32 API 的部分 C++(Win32) 4.1、4.3、4.5
從 C# 呼叫同一組 Win32 API 的寫法 C#(P/Invoke) 4.5
時間量測、GC、配置記憶體的注意事項 .NET(C#) 4.4 的「.NET 端的檢查」、5.2

第 3 章之前談原因的部分,以及第 4 章的檢查清單本體,都不依賴特定語言。只使用 C# 的讀者,把 C++ 的程式碼當成「該按什麼順序呼叫哪些 API」的說明來讀就足夠了。

目錄

  1. 先說結論(一句話)
    • 1.1. 依週期範圍分的速查表(該從哪裡開始讀)
  2. 在一般 Windows 上「soft real-time」是什麼
    • 2.1. 本文所說的「一般 Windows」
    • 2.2. 能做到什麼,從哪裡開始變難
    • 2.3. 先把用語講清楚
  3. 延遲與抖動的主要原因
    • 3.1. 排程器與優先權
    • 3.2. DPC / ISR 與驅動程式
    • 3.3. 分頁錯誤與記憶體
    • 3.4. 計時器解析度與電源管理
    • 3.5. 核心遷移與發熱
  4. 在一般 Windows 上減少遲到的實戰檢查清單
    • 4.1. 週期迴圈與等待方式
    • 4.2. fast path / slow path 與固定長度佇列
    • 4.3. 優先權 / MMCSS / background mode
    • 4.4. 記憶體 / GC / 首次執行成本
    • 4.5. 電源設定 / EcoQoS / timer resolution
    • 4.6. CPU 配置 / 核心遷移 / 發熱
    • 4.7. 驅動程式 / DPC / ISR / 外部干擾的釐清
  5. 量測與評估
    • 5.1. 要記錄什麼
    • 5.2. p99 / p99.9 / max 的看法
    • 5.3. 用什麼工具看
    • 5.4. 測試的作法
  6. 大致的取捨
  7. 總結
  8. 參考資料

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 20 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

1. 先說結論(一句話)

  • 在一般 Windows 上要追求的不是 hard real-time 的保證,而是以 soft real-time 做出「不容易遲到,就算遲到也不容易壞掉」的架構。
  • 效果最大的是 把熱路徑縮短、固定長度、非阻塞。
  • 把 fast path(取值 / 控制)與 slow path(儲存 / 通訊 / UI)分開,中間用 固定長度佇列 連接。
  • 週期迴圈不交給 Sleep(1),而是以 絕對期限 來跑。
  • 音訊或影片這類連續串流,先考慮 MMCSS。
  • 時間量測使用 QueryPerformanceCounter(QPC),.NET 則使用 Stopwatch。
  • 等待優先使用 設備事件 或 高精度 waitable timer(可等待計時器)。
  • timeBeginPeriod 只在需要的期間使用,不要以「一直開著」為前提來設計。
  • 實際維運中,AC 供電 / 電源模式 / EcoQoS 的處理方式 / 背景負載的整理 很有效。
  • 評估不要只看平均值,要看 p99(量 100 次時,較慢的那 1 次開始顯現的分界) / p99.9 / max / miss 次數 / DPC / ISR / page fault / queue 深度。

簡單說,在一般 Windows 上,比起提高優先權,用設計去減少遲到的理由 更有效。 優先權與電源設定很重要,但光靠它們做不出穩定性。

穩定性來自設計而不是優先權把熱路徑縮短、固定長度、非阻塞,用設計減少遲到的理由最有效,優先權與電源設定雖然重要但光靠它們做不出穩定性的圖。把熱路徑縮短、固定長度遲到的理由從設計上減少不容易遲到、遲到也不會壞的架構調整優先權與電源設定重要但光靠它們並不夠

圖 1: 效果最大的不是調整優先權,而是用設計減少遲到的理由本身。

1.1. 依週期範圍分的速查表(該從哪裡開始讀)

文章很長,所以先放一張速查表,讓讀者只讀對應到自己情況的那一列。這份內容原本放在文章末尾(第 6 章),現在提前到這裡。

週期與需求 先搭起來的架構 要重點閱讀的小節
10〜20ms 等級,偶爾的浮動可以吸收 分離 fast path / slow path、固定長度佇列、一般到稍高的優先權、事件驅動。這樣通常就夠了 4.1、4.2
1〜5ms 等級,希望持續趕得上 在上面之外再加:熱路徑不配置記憶體、專用執行緒、MMCSS 或謹慎的優先權調整、高精度 waitable timer、AC 供電與重新檢視電源設定 4.1 〜 4.5
接近 1ms 以下,長時間、高負載下也不想錯過期限 只靠一般 Windows 的 user-mode 相當吃力。先考慮把關鍵部分移到別的地方(設備端韌體、專用控制器、FPGA、RTOS)的設計 2.2、6
想讓 GUI / 日誌 / 通訊 / 資料庫 全部擠在一起 不要用「全部塞進 1 個處理程序、1 個迴圈」的方式扛下來,要劃分職責。後段的狀況很容易破壞前段的期限 4.2、4.3、6

釐清原因與量測的作法,在任何週期範圍都是共通的(第 3 章與第 5 章)。

2. 在一般 Windows 上「soft real-time」是什麼

2.1. 本文所說的「一般 Windows」

這裡說的 一般 Windows,大致以下列條件為前提。

  • Windows 10 / 11 的一般桌機或筆電
  • 沒有自製的 RTOS 擴充
  • 沒有自行開發核心模式驅動程式
  • 一般的 user-mode 應用程式
  • 用一般的 Windows API 與設定來調整

換句話說,這不是 「把一整台專用機做成即時控制專用」 的討論,而是 「在一般的 Windows PC 上,實際能推進到什麼程度」 的討論。

一般的 Windows 10 / 11 PCuser-mode 應用程式以 soft real-time 為目標降低延遲縮小抖動觀測 deadline miss 並確保不會壞掉想保證零期限違反RTOS / 專用控制器 / FPGA / 設備端處理

圖 2: 一般 Windows 的 user-mode 應用程式要瞄準的是 soft real-time,保證零期限違反屬於 RTOS 之類的領域。

2.2. 能做到什麼,從哪裡開始變難

即使是一般 Windows,像下面這些處理,也相當實際地能做到「不容易遲到」的狀態。

  • 數毫秒到數十毫秒的週期處理
  • 音訊 / 影片的緩衝區驅動
  • 感測器取值與控制迴圈
  • 類似軟體 PLC 的固定週期處理
  • 與 UI 分開執行緒運作的低延遲管線

不過,這裡說的「做得到」並不是指 能把偶爾的延遲尖峰完全歸零,要瞄準的終究是這樣的狀態。

  • 讓平時的延遲夠低
  • 讓抖動夠小
  • 偶爾錯過期限也不會壞掉
  • 能觀測到錯過期限這件事

反過來說,如果需求變成下面這些,只用一般 Windows 的 user-mode 要滿足就相當吃力。

  • 想保證零期限違反
  • 想長時間穩定守住數百微秒以下
  • 想和笨重的 GUI、網路、儲存裝置擠在同一個處理程序裡
  • 想在電池供電或省電優先的狀態下執行
  • 連驅動程式或設備造成的尖峰都不允許

遇到這些狀況,比較安全的做法是考慮 只把真正對時間嚴格的部分,集中到設備端韌體、專用控制器、FPGA 或 RTOS。

soft real-time 要瞄準的狀態與移出去的判斷一般 Windows 要瞄準的是讓平時的延遲夠低、抖動夠小,偶爾錯過期限也不會壞而且看得到,至於零期限違反這種嚴格需求則只把對時間嚴格的部分移到外面的圖。soft real-time 要瞄準的狀態讓平時的延遲夠低讓抖動夠小錯過期限也不會壞且看得到零期限違反的需求移到設備或 RTOS

圖 3: 「做得到」的內容不是尖峰歸零,而是不容易遲到、不容易壞掉、而且看得到的狀態。

2.3. 先把用語講清楚

先掌握本文會出現的用語的意思。

用語 一句話說明 實務上的看法
soft real-time 承認偶爾會遲到,但要把遲到壓小,而且遲到了也不會壞的思路 在一般 Windows 上首先要瞄準的就是這個
hard real-time 想保證零期限違反的世界 不是一般 Windows 光靠 user-mode 就該瞄準的目標
抖動(jitter) 週期或回應時間的浮動 平均值再好,抖動一大,實際維運上就不穩
deadline miss 處理沒能在預定時刻前完成 不要藏起來,要計數並寫進日誌
p99 / p99.9 用來看較慢那一側尾巴的指標 p99 是「100 次裡,較慢的那 1 次開始顯現的分界」
DPC / ISR 驅動程式與中斷周邊的核心端處理 一旦拖長,user-mode 執行緒就得等
MMCSS Windows 用來把 CPU 配給音訊、影片等對時間敏感處理的機制 對不想讓緩衝區斷掉的處理很有用
QPC 就是 QueryPerformanceCounter 量測經過時間的基本手段。不是牆上時鐘,而是高精度計數器
waitable timer(可等待計時器) 在指定時刻變成 signaled 的核心物件。對 CreateWaitableTimerExW 加上 CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 就會變成高精度版本 當成週期等待的基礎,比 Sleep 更合適(4.1)
EcoQoS 被分類為「可以優先省電」的狀態。CPU 頻率會被調低,工作也會被移到電力效率較好的核心上 對時間敏感的處理要避免。不明確指定的話,OS 會自行推測(4.5)
IGNORE_TIMER_RESOLUTION 表示「可以忽略這個處理程序的計時器解析度要求」的指定(PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION) 一旦生效,timeBeginPeriod 的效果就消失。在 Windows 11 上,視窗從畫面上隱藏時可能會被自動套用(4.5)
CPU Sets 柔性地 指定「希望執行緒或處理程序在這群核心上執行」的機制 比固定到特定核心更該先試(4.6)
ETW / WPR / WPA Windows 標準的追蹤基礎架構(ETW),以及它的記錄工具(WPR)與分析 GUI(WPA) 要深入挖 context switch、DPC / ISR、page fault 時使用(5.3)
LatencyMon 用來查看驅動程式造成的延遲的第三方工具 依驅動程式分別查看 DPC / ISR 的執行時間,先鎖定可疑範圍(5.3)

3. 延遲與抖動的主要原因

在一般 Windows 上,週期處理會遲到的理由,大致都會歸結到這張圖裡的其中一項。

週期處理遲到排程器 / 優先權DPC / ISR / 驅動程式分頁錯誤 / 記憶體計時器解析度 / 電源管理核心遷移 / 發熱

圖 4: 週期處理遲到的原因,會歸結到排程器、DPC/ISR、記憶體、計時器與電源、核心遷移與發熱這 5 個系統。

3.1. 排程器與優先權

Windows 的執行緒是依優先權決定執行順序。 同樣優先權的話會以 round robin 的方式輪流執行,一旦有更高優先權的執行緒變成可執行,優先權較低的執行緒就會被擠開。

也就是說,就算週期執行緒寫得很認真,

  • 其他執行緒
  • 其他處理程序
  • OS 的內部處理
  • 資安產品
  • 設備的輔助處理
  • 背景同步

先跑起來也是很常見的事。

優先權排程造成的擠開同樣優先權的執行緒以 round robin 輪流執行,一旦有更高優先權的執行緒變成可執行,優先權較低的執行緒就會被擠開,因此週期執行緒之前跑起其他處理程序或 OS 內部處理是很常見的事的圖。同樣優先權之間以 round robin 輪流執行更高優先權變成可執行優先權較低的被擠開其他處理程序或 OS 內部處理等

圖 5: 就算週期執行緒寫得很認真,更高優先權的工作先跑也是很正常的事。

3.2. DPC / ISR 與驅動程式

這一節相當重要。 就算把應用程式端的優先權調好,只要 DPC(Deferred Procedure Call)或 ISR(Interrupt Service Routine) 拖得久,那段期間 user-mode 的執行緒就無法執行。

容易變成原因的,是下面這些設備與驅動程式。

  • USB
  • Wi-Fi / Bluetooth
  • 儲存裝置
  • 音訊
  • GPU
  • ACPI 與電源相關

就算應用程式的程式碼沒問題,也可能因為驅動程式或硬體的狀況被擋下來。 在這裡如果想著「把應用程式的優先權再拉高就贏得了吧」,大多會出事。

DPC 與 ISR 讓 user-mode 停下來的構圖USB、Wi-Fi、GPU 等設備與驅動程式的中斷處理也就是 ISR 與 DPC 一旦拖長,那段期間 user-mode 執行緒就無法執行,即使提高應用程式的優先權也贏不了的圖。USB、Wi-Fi、GPU 等設備ISR 與 DPC 在核心端執行那段期間 user-mode 無法執行提高優先權也贏不了

圖 6: 就算應用程式的程式碼沒問題,user-mode 還是會因為驅動程式或硬體的狀況被擋下來。

3.3. 分頁錯誤與記憶體

在熱路徑上發生 page fault(需要的頁面不在記憶體裡,必須去取回來)時,延遲會一口氣變大。

先列出特別想避免的模式。

  • 首次存取時才 commit 頁面
  • 延遲載入
  • 記憶體對應檔案的 page in
  • 超出需要的動態配置
  • 大型物件或碎片化的堆積

在週期處理的本體裡,先把需要的記憶體配置好,並在啟動時先摸過一遍,這樣的程度剛剛好。

熱路徑上的 page fault 造成的延遲熱路徑上碰到的頁面若不在記憶體裡就得靠 page fault 去取回來,延遲會一口氣變大,所以先把需要的記憶體配置好並在啟動時摸過一遍比較好的圖。在不在在熱路徑上碰記憶體頁面在記憶體裡嗎直接繼續執行靠 page fault 去取回來延遲一口氣變大先配置好並在啟動時摸過一遍

圖 7: 會不會發生 page fault,決定了延遲會不會跳起來。對策是事先配置與啟動時的暖機。

3.4. 計時器解析度與電源管理

「想每 1ms 動一次,所以用 Sleep(1)」在大多數情況下都行不通。 Windows 的等待精度會受到計時器解析度、排程與電源狀態的影響。

此外還有一點不能忽略:提高計時器解析度的設定 雖然能稍微改善等待精度,卻會對耗電量與整個系統的行為造成副作用。

3.5. 核心遷移與發熱

執行緒在核心之間移動時,快取必須重新暖起來。 這件事本身通常 OS 會處理得不錯,但在負載高的環境裡就會變成浮動的原因。

而且長時間運轉的話,發熱也不能忽略。 一旦進入熱節流(thermal throttling),原本穩定的週期就可能亂掉。

核心遷移與發熱破壞週期的路徑執行緒在核心之間移動會導致快取重新暖起來,在高負載環境中成為浮動的原因,而長時間運轉累積的熱會透過熱節流讓原本穩定的週期亂掉的圖。執行緒在核心之間移動快取重新暖起來在高負載環境中成為浮動的原因長時間運轉累積熱熱節流原本穩定的週期亂掉

圖 8: 核心遷移以抖動的形式、發熱以節流的形式,一點一點削掉長時間運轉的穩定性。

4. 在一般 Windows 上減少遲到的實戰檢查清單

從這裡開始是實務的部分。 針對前一節看到的原因,用檢查清單的形式整理 在一般 Windows 上該檢查什麼、該避免什麼、該先決定什麼。

4.1. 週期迴圈與等待方式

首先,典型的反面模式是這個。

while (running)
{
    Sleep(1);
    Step();
}

這不是「1ms 週期」,而是 大約等 1ms 以上之後,再把 Step() 的執行時間加上去 的迴圈。 而且等待的超額時間會直接累積。

以絕對期限為基礎以相對時間為基礎WaitUntil(next - margin)next += period必要時做短暫的 spinFastStep()Step()Sleep(1)等待誤差與執行時間一點一點堆積不容易累積 drift

圖 9: 依賴 Sleep(1) 的相對時間迴圈會累積誤差,以絕對期限來跑的迴圈則不容易累積 drift。

檢查清單

  • 沒有把 Sleep(1) 當成週期迴圈的基礎
  • 週期是以 next += period 的 絕對期限 來跑
  • 等待優先使用 設備事件 或 waitable timer(可等待計時器)
  • 只在最後的微調上限定使用極短的 busy-spin(空轉等待)
  • timeBeginPeriod 只在需要的期間使用,結束後就還原
  • 在最小化 / 隱藏 / 看不見的狀態下也確認過行為

週期迴圈以 絕對期限 而不是相對時間來跑,會比較穩定。

int64_t next = QpcNow() + periodTicks;

while (running)
{
    WaitUntil(next - wakeMarginTicks);

    while (QpcNow() < next)
    {
        CpuRelax(); // 只在最後短暫 spin
    }

    int64_t started = QpcNow();
    FastStep();
    int64_t finished = QpcNow();

    RecordTiming(next, started, finished);

    next += periodTicks;

    while (finished > next)
    {
        ++missedDeadlines;
        next += periodTicks;
    }
}

4.2. fast path / slow path 與固定長度佇列

架構的基本原則是 fast path 只放「對期限敏感的工作」,其餘全部趕到 slow path。

設備 / 取得事件fast path: 取值、控制、最小限度的複製固定長度佇列slow path: 儲存、傳送、UI、彙總記錄 lateness / miss / queue depth

圖 10: fast path 只放對期限敏感的工作,中間夾一個固定長度佇列,把其餘工作趕到 slow path 的基本架構。

fast path 要做的事,縮到這個程度就好。

  • 取得資料
  • 計算控制值
  • 最小限度的複製
  • 加上時間戳記
  • 排入佇列
  • 記錄 miss / overrun

其餘的都丟到 slow path。

檢查清單

  • 熱路徑上沒有寫檔案、送網路、寫資料庫
  • 熱路徑上沒有輸出笨重的日誌、Flush、同步 RPC
  • fast path / slow path 以執行緒和職責明確分開
  • 佇列是 固定長度
  • 先決定好佇列滿溢時的方針
  • 有觀測 miss 次數、drop 數、queue depth
  • UI 更新與日誌彙整分離到較低的週期

佇列滿了的時候,方針不要含糊比較安全。

最新的值最重要每一筆都重要日誌用途佇列滿了要守住什麼?丟掉舊的元素,留下最新的警報 / 停止 / 控制上游丟掉舊的元素,只記錄 drop 數

圖 11: 佇列滿了的時候要守住什麼,依用途先決定好。

4.3. 優先權 / MMCSS / background mode

優先權的基本原則是 不要全部往上調。 在一般 Windows 上,「只調高重要的執行緒,後段的工作確實調低」會比較順利。 background mode 則是把 CPU 以外的 I/O 等資源也一併當成低優先權處理的機制。

把工作分開對期限敏感的執行緒儲存 / 傳送 / 壓縮 / 彙總UI必要時提高優先權或用 MMCSSbackground mode / 較低的優先權一般優先權不要一開始就用 REALTIME_PRIORITY_CLASS

圖 12: 只調高對期限敏感的執行緒,後段工作確實調低的優先權分配方式。

檢查清單

  • 沒有把所有執行緒都設成高優先權
  • 只調高真正時間吃緊的執行緒
  • 儲存、傳送、壓縮、同步等後段工作都降到 background mode
  • 音訊、影片、擷取、播放等連續緩衝區處理有考慮使用 MMCSS
  • 比起整個處理程序,先從 執行緒單位 思考
  • REALTIME_PRIORITY_CLASS 在必要性明確之前都不使用

MMCSS(Multimedia Class Scheduler Service)在 音訊、影片這類「想在一定時間內把緩衝區填滿」的處理 上特別有效。 比起單純讓高優先權執行緒一直跑,這種做法更貼近 Windows 的設計。

程式碼大致長這樣。

DWORD taskIndex = 0;
HANDLE avrt = AvSetMmThreadCharacteristicsW(L"Pro Audio", &taskIndex);
if (!avrt)
{
    throw std::runtime_error("AvSetMmThreadCharacteristicsW failed");
}

// 跑對時間敏感的迴圈

if (!AvRevertMmThreadCharacteristics(avrt))
{
    throw std::runtime_error("AvRevertMmThreadCharacteristics failed");
}
使用 MMCSS 的執行緒的註冊與解除在對時間敏感的迴圈之前用 AvSetMmThreadCharacteristicsW 註冊到 MMCSS,迴圈結束後用 AvRevertMmThreadCharacteristics 還原的流程,以及 MMCSS 會把 CPU 優先配給對時間敏感的處理的圖。AvSetMmThreadCharacteristicsW跑對時間敏感的迴圈AvRevertMmThreadCharacteristicsMMCSS 優先配置 CPU

圖 13: MMCSS 要成對地註冊與解除。連續緩衝區處理用它,比一直維持高優先權更貼近 Windows 的設計。

4.4. 記憶體 / GC / 首次執行成本

在熱路徑上每次都用 new / malloc / List<T>.Add / 字串串接 / LINQ,總有一天回收或重新配置的狀況會浮上檯面。GC(記憶體回收)本身並沒有錯,但 只要寫出大量配置記憶體的程式碼,影響就會以抖動的形式顯現出來。

啟動配置需要的緩衝區摸過一次讓頁面暖起來完成 JIT / DLL 載入 / 首次 I/O之後才進入正式量測 / 正式運轉

圖 14: 在啟動時把緩衝區配置、頁面暖機、首次執行成本都處理完,再進入正式量測與正式運轉。

檢查清單

  • 熱路徑上沒有每次都配置 / 釋放記憶體
  • 需要的緩衝區在啟動時就事先配置好
  • 啟動時已經摸過一次讓頁面暖起來
  • 首次 JIT、首次 DLL 載入、首次 I/O 沒有混進正式量測
  • 迴圈中沒有讓龐大的結構或可變長度的日誌一直長大
  • 就算要用 VirtualLock,也只用在極小的關鍵區域

.NET 端的檢查

  • 時間量測使用 Stopwatch / Stopwatch.GetTimestamp()
  • 熱路徑上沒有 LINQ、字串串接、ToString()、產生龐大日誌
  • 沒有把 async/await 帶進 hot path
  • 暖機前與暖機後分開評估

4.5. 電源設定 / EcoQoS / timer resolution

這一節不起眼卻很有用。 就算把程式碼壓得再緊,只要上層的電源控制作用太強,結果還是不會穩定。

一般 Windows 的電源相關設定以 AC 供電執行電源模式: 偏向最佳效能必要時準備正式環境專用的電源計劃對時間敏感的處理程序避開 EcoQoS確認計時器解析度要求的處理方式

圖 15: 電源相關要檢查的項目。供電方式、電源模式、EcoQoS、計時器解析度要求的處理方式。

檢查清單

  • 正式評估先在 AC 供電 下進行
  • [設定] > [系統] > [電源與電池] > [電源模式] 已調到偏向 最佳效能
  • 執行期間沒有使用 battery saver 或省電優先模式
  • 有查看廠商自家工具的靜音 / eco / 電池優先模式
  • 沒有隨手把對時間敏感的處理程序設成 EcoQoS(偏省電的 QoS)
  • IGNORE_TIMER_RESOLUTION 沒有在對時間敏感的處理程序上生效
  • 有確認最小化 / 隱藏時,計時器解析度要求的效果會不會改變
  • 日常使用與正式 / 量測 / 展示用的電源設定分開

timeBeginPeriod 只要梳理清楚再使用是有幫助的,但 它不是萬靈丹。

  • 在真正需要之前才呼叫
  • 結束後用 timeEndPeriod 還原
  • 從 Windows 10 version 2004 起,不再是以前那種完全全域的行為
  • 在 Windows 11 上,帶有視窗的處理程序若處於完全隱藏 / 最小化 / 看不見 / 聽不到的狀態,就不一定保證維持高解析度
  • 提高解析度 並不會提高 QPC 的精度

如果懷疑是電源或 QoS 的影響,就用 SetProcessInformation 查看 power throttling 的狀態。

PROCESS_POWER_THROTTLING_STATE state{};
state.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
state.ControlMask =
    PROCESS_POWER_THROTTLING_EXECUTION_SPEED |
    PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION;
state.StateMask = 0; // HighQoS(偏效能優先)+ 尊重計時器解析度要求

if (!SetProcessInformation(
        GetCurrentProcess(),
        ProcessPowerThrottling,
        &state,
        sizeof(state)))
{
    throw std::runtime_error("SetProcessInformation failed");
}

ControlMask 是「哪些機制要由自己控制」,StateMask 是「要把那個機制設成 on 還是 off」。上面的例子把兩個機制都選為控制對象,然後兩個都設成 off。也就是宣告 不要降到 EcoQoS(偏向 HighQoS),以及 不要讓計時器解析度要求被忽略。反過來把 ControlMask 設成 0,兩者就都回到交給 OS(預設行為)。

power throttling 設定中兩個遮罩的角色用 ControlMask 選出要由自己控制的機制,再用 StateMask 決定該機制是 on 還是 off,藉此宣告不要降到 EcoQoS,也不要讓計時器解析度要求被忽略的圖。用 ControlMask 選出控制對象用 StateMask 決定 on 與 off宣告不要降到 EcoQoS不要讓計時器解析度要求被忽略ControlMask 為 0 就回到交給 OS

圖 16: SetProcessInformation 兩個遮罩的角色。先選出控制對象,再宣告 on / off。

從 C# 呼叫的情況

同樣的事情要從 C# 做的話,P/Invoke 宣告會長這樣。

// C# / .NET 8
using System.Runtime.InteropServices;

internal static class PowerQos
{
    [StructLayout(LayoutKind.Sequential)]
    private struct PROCESS_POWER_THROTTLING_STATE
    {
        public uint Version;
        public uint ControlMask;
        public uint StateMask;
    }

    private const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
    private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
    private const uint PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION = 0x4;

    // PROCESS_INFORMATION_CLASS 的第 5 個(從 0 起算是 4)就是 ProcessPowerThrottling
    private const int ProcessPowerThrottling = 4;

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool SetProcessInformation(
        IntPtr hProcess,
        int processInformationClass,
        ref PROCESS_POWER_THROTTLING_STATE processInformation,
        uint processInformationSize);

    [DllImport("kernel32.dll")]
    private static extern IntPtr GetCurrentProcess();

    /// <summary>同時解除偏省電的處理方式,以及對計時器解析度要求的忽略。</summary>
    public static void OptOutOfPowerThrottling()
    {
        var state = new PROCESS_POWER_THROTTLING_STATE
        {
            Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION,
            ControlMask =
                PROCESS_POWER_THROTTLING_EXECUTION_SPEED |
                PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION,
            StateMask = 0,
        };

        if (!SetProcessInformation(
                GetCurrentProcess(),
                ProcessPowerThrottling,
                ref state,
                (uint)Marshal.SizeOf<PROCESS_POWER_THROTTLING_STATE>()))
        {
            throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
        }
    }
}

呼叫方式是在開始對時間敏感的迴圈之前,執行一次 PowerQos.OptOutOfPowerThrottling();。處理程序控制代碼需要 PROCESS_SET_INFORMATION 的存取權限,不過 GetCurrentProcess() 傳回的自身處理程序虛擬控制代碼不會有問題。

4.6. CPU 配置 / 核心遷移 / 發熱

CPU 配置與其一開始就 固定到特定核心(hard affinity / CPU pinning),不如從 「盡量在這群核心上執行就好」這種接近 soft affinity 的指定 開始,通常結果會比較好。

是否先量測SetThreadIdealProcessor / CPU Sets改善得夠了嗎?就停在這裡最後才考慮 SetThreadAffinityMask同時確認溫度 / 時脈 / 長時間運轉

圖 17: CPU 配置從量測開始,柔性指定就夠的話就停在那裡,固定到特定核心留作最後手段。

檢查清單

  • 先量測之後才動 CPU 配置
  • 沒有一開始就固定到特定核心
  • 先嘗試 SetThreadIdealProcessor 或 CPU Sets
  • SetThreadAffinityMask 當成最後手段
  • 長時間運轉時有查看溫度、時脈與熱節流
  • 有查看筆電的靜音模式或低噪音模式

順序上,這個流程比較安全。

  1. 先量測
  2. 必要時使用 ideal processor 或 CPU Sets
  3. 還需要改善的話才固定到特定核心

固定到特定核心看起來很有效,但因為 減少了 OS 的迴旋空間,隨便使用反而可能讓系統失去彈性調度的能力。

4.7. 驅動程式 / DPC / ISR / 外部干擾的釐清

遇到「偶爾只有 max 爆掉」「平均很好但 p99.9 很差」的情況, 最好也懷疑應用程式程式碼以外的外部干擾。

是否是否是否是否出現 late / miss / max spike自己的處理時間也長嗎?縮短熱路徑 / 減少配置 / 移除 I/O有 DPC / ISR 尖峰嗎?查看 USB / Wi-Fi / Bluetooth / GPU / Audio / Storage / ACPI 與驅動程式更新有 page fault / GC / 首次執行成本嗎?事先配置 / 暖機 / 降低堆積負載有電池 / 省電 / 發熱的影響嗎?AC 供電 / 電源設定 / 散熱 / 長時間測試用 ETW / WPA / LatencyMon 深入挖

圖 18: 出現尖峰時的釐清順序。依序懷疑自己的處理、DPC/ISR、記憶體、電源與發熱。

檢查清單

  • 有查看 Wi-Fi / Bluetooth / USB / 儲存裝置 / GPU / 音訊相關的驅動程式
  • 停掉不需要的雲端同步、索引建立、自動更新後做比較
  • 也試過最小化後會不會亂掉、關掉螢幕後會不會亂掉
  • 有用 LatencyMon 或 ETW 觀察 DPC / ISR 的趨勢
  • 分開來看是「自己的處理太重」還是「被外面擋住」

5. 量測與評估

5.1. 要記錄什麼

至少這些是想先記錄下來的。

  • 週期的預定時刻
  • 實際開始時刻
  • 實際結束時刻
  • lateness(相對於預定的開始時刻,實際晚了多少才開始)
  • 執行時間
  • missed deadline 次數
  • 連續 missed deadline 次數
  • queue depth
  • drop 數
  • CPU 使用率
  • 各核心之間的偏斜
  • DPC / ISR 尖峰
  • page fault
  • 溫度 / 時脈變動

只看平均值,很難抓到本質。 正式環境裡會造成困擾的,是偶爾出現的大延遲尖峰。

5.2. p99 / p99.9 / max 的看法

p99 這類指標是 用來看較慢那一側尾巴的東西。 只看平均值的話,偶爾出現的大延遲就會被藏起來。

指標 意義 量 10,000 次時的概念
平均 整體的平均值 尖峰容易被埋掉
p50 正中間的值 接近平時的體感
p95 較慢的 5% 開始顯現的分界 去掉最慢 500 次之後的分界
p99 較慢的 1% 開始顯現的分界 去掉最慢 100 次之後的分界
p99.9 較慢的 0.1% 開始顯現的分界 去掉最慢 10 次之後的分界
max 最差值 最慢的那 1 次

假設量到下面這樣的一組數字(這是為了說明指標怎麼讀的例子,不是特定機器上的實測值)。

  • 平均: 0.8ms
  • p99: 1.2ms
  • p99.9: 3.5ms
  • max: 28ms

這代表 平時很快,但偶爾出現大尖峰 的狀態。 在一般 Windows 上,真正的問題大多就出在 p99 到 max 這條尾巴 上。

平均值與尾端指標的差別只看平均值會把偶爾出現的大延遲藏起來,而看 p99、p99.9、max 就能看見較慢那一側的尾巴,在一般 Windows 上真正的問題就出在那裡的圖。只看平均值偶爾的大延遲被藏起來看 p99、p99.9、max看得見較慢那一側的尾巴真正的問題出在 p99 到 max

圖 19: 平均值不錯但 max 跳起來,就是「平時很快但偶爾跳一下」的狀態。要用尾端的指標去掌握。

另外,本文沒有用數字呈現建議架構的效果。延遲與抖動會因為 CPU、驅動程式、常駐軟體、電源設定、施加負載的方式而輕易改變,所以 別人環境的數字不能直接當成自己環境的依據。取而代之,這裡放上在自己環境做出同樣格式表格的步驟。

在自己的環境算出 p99 的最小步驟

  1. 熱路徑上 只做記錄。用 Stopwatch.GetTimestamp()(C++ 就用 QueryPerformanceCounter)取得 lateness 與執行時間,寫進事先配置好的陣列。這裡不可以計算平均值或做排序
  2. 停止量測之後才做彙總。排序之後取出百分位數位置上的值
  3. 改變條件 重複同樣的步驟。暖機前後、AC / 電池、UI 在前景 / 最小化、其他處理程序有負載 / 無負載(5.4)
  4. 每加入一項變更,就在同樣的條件下重新量一次再比較
// C# / .NET 8。彙總在停止量測之後才做
using System.Diagnostics;

// 熱路徑上只寫進陣列(零配置)
long[] latenessTicks = new long[100_000];
int count = 0;

// 例: 週期迴圈中
// latenessTicks[count++] = Stopwatch.GetTimestamp() - scheduledTimestamp;

static double PercentileMs(long[] ticks, int count, double percentile)
{
    long[] sorted = ticks.AsSpan(0, count).ToArray();
    Array.Sort(sorted);

    int index = (int)Math.Ceiling(percentile / 100.0 * count) - 1;
    index = Math.Clamp(index, 0, count - 1);

    return sorted[index] * 1000.0 / Stopwatch.Frequency;
}

// 用法
// Console.WriteLine($"p50={PercentileMs(latenessTicks, count, 50):F3}ms");
// Console.WriteLine($"p99={PercentileMs(latenessTicks, count, 99):F3}ms");
// Console.WriteLine($"p99.9={PercentileMs(latenessTicks, count, 99.9):F3}ms");
// Console.WriteLine($"max={PercentileMs(latenessTicks, count, 100):F3}ms");

Stopwatch.Frequency 是每秒的計數次數,所以先相除再乘上 1000 就會變成毫秒。樣本數太少的話,p99.9 沒有意義。要談 p99.9,至少要收集 10,000 個樣本(可以的話 10 萬個)。

5.3. 用什麼工具看

工具的組合大致是固定的。

  • 應用程式內量測 先自己取得 period / lateness / execution time / queue depth / drop
  • ETW / WPR / WPA 深入挖 CPU、context switch、DPC / ISR、page fault
  • LatencyMon 先鎖定驅動程式造成的浮動範圍
  • 溫度 / 時脈監控 查看發熱的影響
應用程式內量測p50 / p95 / p99 / p99.9 / maxmiss / drop / queue depthETW / WPR / WPAcontext switch / DPC / ISR / page fault溫度 / 時脈監控決定改善的優先順序

圖 20: 把應用程式內量測、ETW、溫度監控的結果對照起來,決定改善的優先順序。

做到 WPA 這一步是有點費工夫, 不過要分辨 是 DPC / ISR 造成的,還是單純自己的處理太重,它相當有效。

取得方式與最小的用法

工具 取得方式 最小步驟
WPR / WPA(Windows Performance Toolkit) 安裝 Windows ADK(Windows Assessment and Deployment Kit)時勾選「Windows Performance Toolkit」就會裝進去。預設位置是 C:\Program Files (x86)\Windows Kits\10\Windows Performance Toolkit 以系統管理員身分開啟命令提示字元,(1) 用 wpr -start CPU 開始記錄,(2) 讓有問題的處理跑數十秒,(3) 用 wpr -stop trace.etl "週期延遲的調查" 儲存。之後用 WPA 開啟 trace.etl。可用的設定檔名稱可以用 wpr -profiles 查看
LatencyMon 從 Resplendence Software 的網站下載。個人用的 Home Edition 是免費版 啟動後開始量測,讓目標的處理持續執行並放置數分鐘。它會彙總核心計時器的最大延遲,以及 各驅動程式的 ISR / DPC 執行時間與 hard pagefault;執行時間特別突出的驅動程式要自己記下來

在 WPA 裡最先要看的,是 CPU 相關圖表中的 DPC / ISR 執行時間 與 context switch。畫面上會列出「自己的執行緒動不了的那段時間裡,是哪個驅動程式佔用了 CPU」,把它和 5.1 記錄的延遲發生時刻對照起來。

另外,本文沒有放畫面截圖。外觀容易隨版本改變,請照上面的步驟操作,在實際畫面上確認。

5.4. 測試的作法

測試只在安靜的測試機環境做是不夠的。至少下面這些條件想分開來看。

  • 剛啟動、暖機之前
  • 暖機之後
  • 長時間連續運轉
  • UI 在前景
  • UI 最小化 / 接近隱藏的狀態
  • AC 供電
  • 電池供電
  • 網路或磁碟有負載的狀態

只在測試機環境評估,很容易漏掉實際維運才會出現的問題。 一般 Windows 的行為很容易被「使用方式」牽著走,所以貼近實際使用的條件來確認很重要。

分開條件測試的想法只在安靜的測試機環境評估會漏掉實際維運的問題,所以要分開暖機前後與長時間運轉、UI 在前景與最小化、AC 供電與電池供電、有無負載等條件來確認的圖。只在安靜的測試機環境評估漏掉實際維運的問題分開條件來評估暖機與長時間UI 在前景與最小化供電與負載的條件

圖 21: 一般 Windows 的行為會被使用方式牽著走。要貼近實際使用的條件來評估。

6. 大致的取捨

依週期範圍分的速查表已經 提前到 1.1,方便邊讀邊回頭查。這裡補充兩個光靠那張表決定不了的判斷。

在哪裡判定「一般 Windows 做不到」

當需求變成要在長時間、高負載下守住 1ms 以下時,比起繼續調校,只把對時間嚴格的部分移到外面 的判斷通常更快。移出去的候選是設備端韌體、專用控制器、FPGA、RTOS。判斷依據不用主觀感覺,而是用第 5 章的數字。

  • 熱路徑已經無法再縮短,p99.9 與 max 卻持續超出需求
  • 原因是外部干擾(DPC / ISR、驅動程式、其他處理程序),應用程式端的對策構不到(用 4.7 的釐清方式確認)
  • 需求已經從「偶爾錯過也不會壞」變成「一次都不能錯過」

想把全部都放進同一個處理程序時

把 GUI、日誌、通訊、資料庫全部放進同一個處理程序的同一個迴圈,後段的狀況就會破壞前段的期限。因為等待檔案排清、資料庫重新連線、UI 重繪,都可能一口氣拉長到數十毫秒。把 fast path / slow path 的分離(4.2)從執行緒層級擴大到處理程序層級,也是一個選項。分成不同處理程序之後,就算後段卡住,前段的週期還是會繼續跑。

全部擠在一個處理程序與分開處理程序的對比把 GUI、日誌、通訊、資料庫放進同一個處理程序的同一個迴圈,後段的狀況就會破壞前段的期限,而把 fast path 與 slow path 的分離擴大到處理程序層級之後,就算後段卡住前段的週期還是會繼續跑的圖。全部塞進 1 個處理程序 1 個迴圈後段的狀況破壞前段的期限等待排清、重新連線、重繪把前段與後段分成不同處理程序後段卡住前段仍繼續跑

圖 22: 如果非得全部擠在一起不可,可以把 fast path / slow path 的分離擴大到處理程序層級。

7. 總結

有兩個前提要先掌握。

  • 在一般 Windows 上要瞄準的不是 hard real-time 的保證,而是以 soft real-time 縮小延遲與抖動,並做成即使發生期限違反也不會壞掉的架構
  • 效果最大的不是調整優先權,而是梳理熱路徑

在實作上有效的是這些。

  • 把 fast path / slow path 分開
  • 先決定固定長度佇列與滿溢時的方針
  • 用 QPC 量測,用 event 或 waitable timer(可等待計時器)等待
  • 熱路徑上避開配置記憶體、阻塞式 I/O 與笨重的鎖

在維運面上有效的是這些。

  • 用 AC 供電執行
  • 把正式環境用的電源設定分開
  • 減少不需要的背景負載
  • 用 p99 / p99.9 / max 與 miss 次數評估

一般 Windows 上的軟即時不是靠優先權設定就能決定的,只要把設計、實作、電源設定、量測、維運分開逐項敲定,就能做出相當穩定的系統。

8. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

Windows 能做即時處理嗎?
無法保證 hard real-time(零期限違反),但只要把設計、實作、量測、維運都確實敲定,就能把 soft real-time 做到相當實用的狀態。數毫秒到數十毫秒的週期處理、音訊與影片的緩衝區驅動、感測器取值與控制迴圈,在一般的 Windows 10/11 上都是可行的。反過來說,如果需要保證零期限違反,或需要在數百微秒以下長時間維持穩定,就應該考慮把工作移到 RTOS、專用控制器、FPGA 或設備端處理。
週期處理為什麼不能用 Sleep(1)?
因為 Sleep(1) 不是「1ms 週期」,而是「大約等 1ms 以上,再加上處理時間」的行為,等待的超額時間會直接累積。週期迴圈要以 next += period 的絕對期限來跑,等待優先使用設備事件或高精度 waitable timer,最後的微調才限定在極短的 busy-spin,這樣才穩定。timeBeginPeriod 只在需要的期間使用,用完就還原。
要減少延遲與抖動,最有效的做法是什麼?
比起提高優先權,更有效的是把熱路徑縮短、固定長度、非阻塞。把 fast path(取值與控制)和 slow path(儲存、通訊、UI)分開,中間用固定長度佇列連接,熱路徑上避免寫檔案、送網路、輸出笨重的日誌,以及每次都配置記憶體。維運面上,AC 供電、重新檢視電源模式、查看 EcoQoS、整理背景負載都很有效。
週期處理的穩定性該用哪些指標評估?
不能只看平均值,要看 p99、p99.9、max 與 missed deadline 次數。例如平均 0.8ms 但 max 有 28ms,就是平時很快、偶爾出現大尖峰的狀態,而在一般 Windows 上,真正的問題就出在 p99 到 max 這條尾巴。同時還要記錄 DPC/ISR 尖峰、page fault、佇列深度、溫度與時脈變動,並分開評估暖機前後、長時間運轉、最小化狀態、電池供電等條件。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽