A Practical Guide to Getting as Close to Soft Real-Time as Possible on Ordinary Windows
· Updated: · Go Komura · Windows Development, Soft Real-Time, Design, Measurement
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614459)
- First published
Cite this article(DOI: 10.5281/zenodo.21614458)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). A Practical Guide to Getting as Close to Soft Real-Time as Possible on Ordinary Windows. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614458 https://comcomponent.com/en/blog/2026/03/09/000-windows-soft-realtime-practical-guide-natural/
- DOI (latest version)
- 10.5281/zenodo.21614458
- DOI (this version)
- 10.5281/zenodo.22217121
When you build processing on Windows where being late is a problem - periodic processing, audio, video, measurement, equipment control - the impression that Windows is not really up to it tends to come up. That impression is half right and half wrong: Windows is not a hard real-time OS, but if you properly nail down design, implementation, measurement, and operations, you can get it to a genuinely practical state as soft real-time.
What this article covers is ordinary Windows 10 / 11, without special RTOS extensions, custom kernel drivers, or dedicated controllers. It is a practice-oriented discussion of how far you can push down latency and jitter with a user-mode app on an everyday desktop or laptop PC. Audio, video, periodic control, and data acquisition differ in their details, but the trouble spots are largely shared, so this time we have collected that common ground in the form of a checklist.
Who This Is For, and the Languages Used in the Code Examples
This is written for developers building processing on Windows where being late is a problem: periodic control, audio and video, measurement, and equipment control. The assumption is user-mode application development; implementing kernel-mode drivers is out of scope.
The code examples split across languages as follows.
| Content | Language | Where |
|---|---|---|
| Periodic loops, MMCSS, power QoS, and other places that call Win32 APIs directly | C++ (Win32) | 4.1, 4.3, 4.5 |
| How to call the same Win32 APIs from C# | C# (P/Invoke) | 4.5 |
| Time measurement, GC, and allocation caveats | .NET (C#) | The .NET-side checks in 4.4, and 5.2 |
The discussion of causes up through chapter 3, and the checklist itself in chapter 4, do not depend on the language. If you work only in C#, reading the C++ code as an explanation of which APIs to call in which order is enough.
Table of Contents
- The Conclusion First (In One Line)
- 1.1. Quick Reference by Period Range (Where to Start Reading)
- What “Soft Real-Time” Means on Ordinary Windows
- 2.1. What This Article Means by “Ordinary Windows”
- 2.2. What Is Achievable, and Where It Gets Hard
- 2.3. A Quick Word on Terminology
- The Main Causes of Latency and Jitter
- 3.1. The Scheduler and Priorities
- 3.2. DPCs / ISRs and Drivers
- 3.3. Page Faults and Memory
- 3.4. Timer Resolution and Power Management
- 3.5. Core Migration and Heat
- A Practical Checklist for Reducing Lateness on Ordinary Windows
- 4.1. Periodic Loops and How to Wait
- 4.2. Fast Path / Slow Path and Fixed-Length Queues
- 4.3. Priorities / MMCSS / Background Mode
- 4.4. Memory / GC / First-Run Costs
- 4.5. Power Settings / EcoQoS / Timer Resolution
- 4.6. CPU Placement / Core Migration / Heat
- 4.7. Isolating Drivers / DPCs / ISRs / External Disturbances
- Measurement and Evaluation
- 5.1. What to Record
- 5.2. How to Read p99 / p99.9 / max
- 5.3. What to Measure With
- 5.4. Testing Discipline
- A Rough Guide to Choosing
- Conclusion
- References
Knowledge map for this article
Aiming for soft real-time on ordinary Windows means not asking for the hard real-time that guarantees zero deadline misses, but instead keeping latency and jitter small and designing the system so that nothing breaks when a deadline is missed. Periodic waiting rests on measurement with QueryPerformanceCounter and a high-precision waitable timer, and timer resolution is raised with timeBeginPeriod only for as long as it is actually needed. Unless the demotion to EcoQoS and the disregard for timer resolution that Power Throttling brings are explicitly turned off, they become one more cause of growing jitter alongside DPC/ISR, page faults, and thermal throttling. For continuous processing such as audio and video, MMCSS prevents deadline overruns by giving the work preferential CPU allocation, CPU placement should start with a loose specification such as CPU Sets before the stronger option of pinning to specific CPUs, and WPR/WPA and LatencyMon are the tools for isolating the cause.
flowchart LR
accTitle: Soft real-time on ordinary Windows
accDescr: Diagram showing the waitable timers and MMCSS that underpin soft real-time, the relationship between Power Throttling and EcoQoS, the paths by which DPC/ISR and page faults create jitter, the progression from CPU Sets to hard CPU pinning, and how WPR/WPA and LatencyMon relate as measurement tools.
soft_realtime["Soft Real-Time"]
qpc["QPC (QueryPerformanceCounter)"]
waitable_timer["Waitable Timer"]
timebeginperiod["timeBeginPeriod (Timer Resolution Request)"]
power_throttling["Power Throttling"]
ecoqos["EcoQoS"]
jitter["Jitter"]
mmcss["MMCSS (Multimedia Class Scheduler Service)"]
deadline_miss["Deadline miss"]
thread_priority_class["Thread Priority / Priority Class"]
cpu_sets["CPU Sets"]
cpu_affinity["CPU affinity"]
thermal_throttling["Thermal Throttling"]
dpc_isr["DPC / ISR"]
page_fault["Page Fault"]
wpr_wpa["WPR / WPA Toolset"]
latencymon["LatencyMon"]
soft_realtime -.->|"uses"| qpc
soft_realtime -.->|"uses"| waitable_timer
soft_realtime -.->|"uses"| timebeginperiod
power_throttling -.->|"incompatible with"| timebeginperiod
soft_realtime -.->|"uses"| power_throttling
power_throttling -.->|"prevents"| ecoqos
ecoqos -.->|"may cause"| jitter
soft_realtime -.->|"uses"| mmcss
mmcss -->|"mitigates"| jitter
mmcss -->|"mitigates"| deadline_miss
thread_priority_class -.->|"may cause"| jitter
thread_priority_class -->|"not recommended for"| soft_realtime
soft_realtime -.->|"uses"| cpu_sets
cpu_sets -->|"should come before"| cpu_affinity
thermal_throttling -.->|"may cause"| jitter
dpc_isr -.->|"may cause"| jitter
page_fault -.->|"may cause"| jitter
dpc_isr -->|"verified by"| wpr_wpa
dpc_isr -->|"verified by"| latencymon
page_fault -->|"verified by"| wpr_wpa
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (20 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
1. The Conclusion First (In One Line)
- What you aim for on ordinary Windows is not a hard real-time guarantee, but a soft real-time configuration that is unlikely to be late and does not break when it is.
- The biggest single win is making the hot path short, fixed-length, and non-blocking.
- Separate the fast path (acquisition / control) from the slow path (storage / communication / UI) and connect them with a fixed-length queue.
- Drive the periodic loop on absolute deadlines, not by leaning on
Sleep(1). - For continuous streams like audio and video, consider MMCSS first.
- For time measurement, use QueryPerformanceCounter (QPC) - in .NET,
Stopwatch. - For waiting, prefer device events or high-resolution waitable timers.
- Use
timeBeginPeriodonly for as long as needed. Do not design on the assumption that it is always on. - In real operation, AC power, the power mode, how EcoQoS is handled, and pruning background load all pay off.
- Evaluate not just averages, but p99 (the threshold where the slowest 1 in 100 starts to show) / p99.9 / max / miss count / DPC / ISR / page faults / queue depth.
In short, on ordinary Windows, reducing the reasons for lateness through design beats raising priorities. Priorities and power settings matter, but they alone cannot create stability.
flowchart TB
accTitle: Design beats priority tuning at creating stability
accDescr: Shows the conclusion that making the hot path short and fixed-length and non-blocking so design removes the reasons for lateness has the biggest effect, and that priority and power settings matter but cannot create stability on their own.
hot["Keep the hot path short and fixed-length"] --> less["Design removes the reasons for lateness"]
less --> goal["Rarely late, and unbroken when it is"]
prio["Tuning priorities and power settings"] -.-> goal
prio -.-> note["Important but not enough on its own"]
Figure 1: The biggest win is not priority tuning but removing the reasons for lateness in the design itself.
1.1. Quick Reference by Period Range (Where to Start Reading)
This is a long article, so the quick reference comes first, so that you can read only the row that matches your case. It used to sit at the end, in chapter 6, and has been moved up to here.
| Period / requirement | The setup to build first | Sections to focus on |
|---|---|---|
| 10-20 ms class, where occasional wobble can be absorbed | Fast path / slow path separation, a fixed-length queue, normal to slightly elevated priority, event-driven design. This is often enough | 4.1, 4.2 |
| 1-5 ms class, where you have to keep up continuously | All of the above, plus an allocation-free hot path, a dedicated thread, MMCSS or careful priority tuning, a high-resolution waitable timer, AC power, and a review of the power settings | 4.1 - 4.5 |
| Approaching sub-1 ms, and you cannot miss even over long runs under heavy load | Very hard with user-mode alone on ordinary Windows. Consider first a design that moves the critical part somewhere else: device-side firmware, a dedicated controller, an FPGA, an RTOS | 2.2, 6 |
| GUI / logging / communication / DB all living together | Do not cram it all into one process and one loop; separate the responsibilities. Downstream concerns easily break upstream deadlines | 4.2, 4.3, 6 |
Isolating causes and measurement discipline are the same across every range (chapters 3 and 5).
2. What “Soft Real-Time” Means on Ordinary Windows
2.1. What This Article Means by “Ordinary Windows”
By ordinary Windows we roughly assume the following.
- A typical Windows 10 / 11 desktop or laptop PC
- No custom RTOS extensions
- No custom kernel-mode driver development
- A normal user-mode app
- Tuning with standard Windows APIs and settings
In other words, this is not about building out a whole dedicated machine for real-time control - it is about how far you can realistically push things on an ordinary Windows PC.
flowchart LR
A["Ordinary Windows 10 / 11 PC"] --> B["User-mode app"]
B --> C["Aim for soft real-time"]
C --> D["Keep latency low"]
C --> E["Keep jitter small"]
C --> F["Observe deadline misses and avoid breaking"]
G["Need to guarantee zero deadline violations"] -.-> H["RTOS / dedicated controller / FPGA / device-side processing"]
Figure 2: What a user-mode app on ordinary Windows aims for is soft real-time; guaranteeing zero deadline violations belongs to RTOSes and similar territory.
2.2. What Is Achievable, and Where It Gets Hard
Even on ordinary Windows, you can build a genuinely rarely-late setup for processing like the following.
- Periodic processing from a few milliseconds to tens of milliseconds
- Buffer-driven audio / video
- Sensor acquisition and control loops
- Soft-PLC-style fixed-period processing
- A low-latency pipeline running on a thread separate from the UI
That said, achievable here does not mean the occasional latency spike can be reduced to absolute zero. The state we aim for is this.
- Keep normal-case latency low
- Keep jitter small
- Do not break when a deadline is occasionally missed
- Be able to observe the fact that it was missed
Conversely, requirements like the following become very hard to satisfy with user-mode alone on ordinary Windows.
- Guaranteeing zero deadline violations
- Holding under a few hundred microseconds stably over long periods
- Coexisting with a heavy GUI, network, and storage
- Doing it on battery power or with power-saving priorities intact
- Not tolerating even spikes caused by drivers or devices
For these, it is safer to also consider moving only the truly time-critical part to device-side firmware, a dedicated controller, an FPGA, or an RTOS.
flowchart TB
accTitle: What soft real-time aims for and when to move work out
accDescr: Shows that on ordinary Windows the aim is to keep normal-case latency low and jitter small and to survive an occasional missed deadline while still observing it, and that a requirement of zero deadline violations means moving only the time-critical part elsewhere.
aim["What soft real-time aims for"] --> s1["Low normal-case latency"]
aim --> s2["Small jitter"]
aim --> s3["Survives a miss and can observe it"]
strict["Requirement of zero deadline violations"] -.-> out["Move it to the device or an RTOS"]
Figure 3: What achievable means here is not zero spikes, but a system that is rarely late, does not break, and can observe when it is.
2.3. A Quick Word on Terminology
Let us pin down the terms used in this article first.
| Term | In one line | Practical view |
|---|---|---|
| soft real-time | Occasional lateness is possible; the approach is to keep it small and to survive it | This is what to aim for first on ordinary Windows |
| hard real-time | The world where zero deadline violations must be guaranteed | Not a target for user-mode alone on ordinary Windows |
| Jitter | Variation in period or response time | Even with a good average, large jitter means instability in real operation |
| deadline miss | Processing not finishing by its scheduled time | Do not hide it - count it and log it |
| p99 / p99.9 | Metrics for looking at the slow tail | p99 is the threshold where the slowest 1 in 100 starts to show |
| DPC / ISR | Kernel-side processing around drivers and interrupts | When long, user-mode threads are made to wait |
| MMCSS | The Windows mechanism that allocates CPU to time-sensitive work like audio / video | A strong option for processing that must never starve its buffers |
| QPC | QueryPerformanceCounter |
The basis of elapsed-time measurement. A high-resolution counter, not the wall clock |
| waitable timer | A kernel object that becomes signaled at a specified time. Passing CREATE_WAITABLE_TIMER_HIGH_RESOLUTION to CreateWaitableTimerExW gives you the high-resolution version |
A better foundation for periodic waiting than Sleep (4.1) |
| EcoQoS | A classification meaning power efficiency may take priority. The CPU frequency may be lowered, or the work moved onto efficiency cores | Avoid it for time-sensitive work. If you do not state your intent, the OS infers it automatically (4.5) |
IGNORE_TIMER_RESOLUTION |
A setting that says the process’s timer-resolution request may be ignored (PROCESS_POWER_THROTTLING_IGNORE_TIMER_RESOLUTION) |
While it is in effect, timeBeginPeriod has no effect. On Windows 11 it can be applied automatically once the process is hidden from the screen (4.5) |
| CPU Sets | A mechanism for asking softly that a thread or process run on a given set of cores | Try it before pinning to specific cores (4.6) |
| ETW / WPR / WPA | The standard Windows tracing infrastructure (ETW) plus its recording tool (WPR) and analysis GUI (WPA) | Use them to dig into context switches, DPC / ISR, and page faults (5.3) |
| LatencyMon | A third-party tool for looking at driver-induced latency | Scan DPC / ISR execution times per driver to get a bearing (5.3) |
3. The Main Causes of Latency and Jitter
The reasons periodic processing falls behind on ordinary Windows almost always trace back to one of the boxes in this diagram.
flowchart TD
Late["Periodic processing falls behind"] --> S["Scheduler / priorities"]
Late --> D["DPC / ISR / drivers"]
Late --> M["Page faults / memory"]
Late --> T["Timer resolution / power management"]
Late --> C["Core migration / heat"]
Figure 4: The reasons periodic processing falls behind trace back to five families: the scheduler, DPC/ISR, memory, timers and power, and core migration and heat.
3.1. The Scheduler and Priorities
Windows threads run in priority order. At equal priority they take turns round-robin, and when a higher-priority thread becomes runnable, lower-priority threads get pushed aside.
So even if you write your periodic thread diligently, it is entirely normal for the following to run first:
- Other threads
- Other processes
- OS-internal work
- Security products
- Device helper processing
- Background synchronization
flowchart TB
accTitle: Preemption under priority scheduling
accDescr: Shows that threads at the same priority take turns round-robin and that when a higher-priority thread becomes runnable the lower-priority ones are pushed aside, so it is normal for another process or OS-internal work to run ahead of the periodic thread.
same["Threads at the same priority"] --> rr["Take turns round-robin"]
high["A higher-priority thread becomes runnable"] --> push["Lower-priority threads are pushed aside"]
push -.-> who["Another process, OS-internal work, and so on"]
Figure 5: However diligently you write the periodic thread, higher-priority work running first is entirely normal.
3.2. DPCs / ISRs and Drivers
This part is quite important. Even with your app-side priorities in order, if DPCs (Deferred Procedure Calls) or ISRs (Interrupt Service Routines) run long, user-mode threads cannot execute during that time.
The devices and drivers that commonly cause this include:
- USB
- Wi-Fi / Bluetooth
- Storage
- Audio
- GPU
- ACPI / power management
Even when your application code is fine, you can get stalled by driver or hardware circumstances. Thinking you will just raise your app’s priority higher and win here usually ends in pain.
flowchart TB
accTitle: How DPCs and ISRs stall user mode
accDescr: Shows that when the ISRs and DPCs of devices and drivers such as USB and Wi-Fi and the GPU run long, user-mode threads cannot execute during that time, and that raising the application priority does not win against them.
dev["Devices such as USB, Wi-Fi, and the GPU"] --> kern["ISRs and DPCs run on the kernel side"]
kern --> block["User mode cannot execute during that time"]
block -.-> note["Raising the priority does not win"]
Figure 6: Even when your application code is fine, driver and hardware circumstances can stall user mode.
3.3. Page Faults and Memory
If a page fault (a needed page not being in memory and having to be fetched) occurs on the hot path, latency balloons instantly.
Patterns particularly worth avoiding:
- Page commit on first access
- Lazy loading
- Page-in of memory-mapped files
- More dynamic allocation than necessary
- Large objects or a fragmented heap
For the body of periodic processing, the right posture is roughly: allocate the memory you need up front, and touch it once at startup.
flowchart TB
accTitle: Latency from a page fault on the hot path
accDescr: Shows that if a page touched on the hot path is not in memory a page fault has to go fetch it and latency balloons, so allocating the memory you need up front and touching it once at startup is the right move.
touch["Touch memory on the hot path"] --> q{"Is the page in memory?"}
q -->|"Yes"| ok["Continue as-is"]
q -->|"No"| pf["A page fault fetches it"]
pf --> spike["Latency balloons"]
pf -.-> fix["Allocate up front and touch once at startup"]
Figure 7: Whether a page fault occurs is what makes latency jump. The countermeasures are preallocation and a startup warm-up.
3.4. Timer Resolution and Power Management
“I want to run every 1 ms, so Sleep(1)” almost never works out.
Windows wait precision is affected by timer resolution, scheduling, and power state.
Furthermore, do not overlook that raising the timer resolution slightly improves wait precision but has side effects on power consumption and overall system behavior.
3.5. Core Migration and Heat
When a thread migrates between cores, the caches have to warm up again. The OS often handles this well by itself, but under heavy load it becomes a source of wobble.
Heat also becomes non-negligible over long runs. When thermal throttling kicks in, a previously stable period can fall apart.
flowchart TB
accTitle: How core migration and heat break the period
accDescr: Shows that a thread migrating between cores forces the caches to warm up again which becomes a source of wobble under heavy load, and that heat building up over a long run brings thermal throttling that breaks a previously stable period.
move["Thread migrates between cores"] --> cache["Caches have to warm up again"]
cache --> jitter["A source of wobble under heavy load"]
heat["Heat builds up over a long run"] --> thr["Thermal throttling"]
thr --> broke["A previously stable period falls apart"]
Figure 8: Core migration eats away at long-run stability as jitter, and heat does the same as throttling.
4. A Practical Checklist for Reducing Lateness on Ordinary Windows
Here begins the practical part. For the causes seen in the previous section, we summarize what to check, what to avoid, and what to decide first on ordinary Windows, in checklist form.
4.1. Periodic Loops and How to Wait
First, the classic anti-pattern is this.
while (running)
{
Sleep(1);
Step();
}
This is not a 1 ms period - it is a loop that waits roughly 1 ms or more, then adds the execution time of Step() on top.
Worse, the wait overshoot accumulates directly.
flowchart LR
subgraph Bad["Relative-time based"]
B1["Sleep(1)"] --> B2["Step()"]
B2 --> B1
end
B2 --> B3["Wait error and execution time pile up bit by bit"]
subgraph Good["Absolute-deadline based"]
G1["next += period"] --> G2["WaitUntil(next - margin)"]
G2 --> G3["Short spin if needed"]
G3 --> G4["FastStep()"]
G4 --> G1
end
G4 --> G5["Resists accumulating drift"]
Figure 9: A relative-time loop that leans on Sleep(1) piles up error, while a loop driven by absolute deadlines resists accumulating drift.
Checklist
- The periodic loop is not built on
Sleep(1) - The period is driven by absolute deadlines via
next += period - Waiting prefers device events or waitable timers
- Only the final fine adjustment uses a very short busy-spin
timeBeginPeriodis used only while needed and reverted afterwards- Behavior has been verified while minimized / hidden / not visible
A periodic loop is more stable when driven by absolute deadlines rather than relative time.
int64_t next = QpcNow() + periodTicks;
while (running)
{
WaitUntil(next - wakeMarginTicks);
while (QpcNow() < next)
{
CpuRelax(); // Spin briefly only at the very end
}
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 and Fixed-Length Queues
The architectural basis is to put only deadline-sensitive work on the fast path, and push everything else to the slow path.
flowchart LR
Input["Device / acquisition events"] --> Fast["fast path: acquire, control, minimal copying"]
Fast --> Queue["Fixed-length queue"]
Queue --> Slow["slow path: store, send, UI, aggregation"]
Fast --> Metrics["Record lateness / misses / queue depth"]
Metrics --> Slow
Figure 10: The basic layout: only deadline-sensitive work on the fast path, pushed out to the slow path through a fixed-length queue.
Limit the fast path to roughly this much.
- Data acquisition
- Control-value computation
- The minimum necessary copying
- Timestamping
- Enqueueing
- Recording misses / overruns
Everything else drops to the slow path.
Checklist
- No file writes, network sends, or DB writes on the hot path
- No heavy logging,
Flush, or synchronous RPC on the hot path - Fast path / slow path clearly separated by thread or responsibility
- The queue is fixed-length
- The policy for queue overflow is decided in advance
- Miss counts, drop counts, and queue depth are being observed
- UI updates and log aggregation are separated to a lower frequency
When the queue fills up, it is safer not to leave the policy vague.
flowchart TD
Overflow["Queue is full"] --> Policy{"What do we protect?"}
Policy -->|Latest value matters| Latest["Drop old entries, keep the latest"]
Policy -->|Every record matters| All["Alert / stop / upstream throttling"]
Policy -->|Logging use| Log["Drop old entries, record only the drop count"]
Figure 11: What you protect when the queue fills up is a decision to make in advance, and it differs by use case.
4.3. Priorities / MMCSS / Background Mode
The basic rule of priorities is: do not raise everything. On ordinary Windows, raising only the important threads and properly lowering the housekeeping work works better. Background mode is a mechanism that treats not just CPU but also resources like I/O at lower priority.
flowchart TD
Work["Split the work"] --> Critical["Deadline-sensitive threads"]
Work --> Worker["Store / send / compress / aggregate"]
Work --> UI["UI"]
Critical --> P1["Higher priority or MMCSS if needed"]
Worker --> P2["Background mode / lower priority"]
UI --> P3["Normal priority"]
P1 --> Warn["Do not start with REALTIME_PRIORITY_CLASS"]
Figure 12: How to assign priorities: raise only the deadline-sensitive threads, and lower the housekeeping work properly.
Checklist
- Not all threads are set to high priority
- Only genuinely time-critical threads are raised
- Housekeeping work like storing, sending, compressing, and syncing is dropped to background mode
- MMCSS is considered for continuous buffer processing such as audio, video, capture, and playback
- Thinking per-thread first, before the whole process
REALTIME_PRIORITY_CLASSis not used until the need is clearly established
MMCSS (Multimedia Class Scheduler Service) is especially effective for processing that has to fill a buffer within a fixed time, like audio / video. It aligns with Windows’ design better than simply spinning a high-priority thread at all times.
The code looks roughly like this.
DWORD taskIndex = 0;
HANDLE avrt = AvSetMmThreadCharacteristicsW(L"Pro Audio", &taskIndex);
if (!avrt)
{
throw std::runtime_error("AvSetMmThreadCharacteristicsW failed");
}
// Run the time-sensitive loop
if (!AvRevertMmThreadCharacteristics(avrt))
{
throw std::runtime_error("AvRevertMmThreadCharacteristics failed");
}
flowchart TB
accTitle: Registering and reverting a thread that uses MMCSS
accDescr: Shows the flow of registering with MMCSS through AvSetMmThreadCharacteristicsW before the time-sensitive loop and reverting with AvRevertMmThreadCharacteristics once the loop is done, and that MMCSS gives the registered thread preferential CPU allocation.
reg["AvSetMmThreadCharacteristicsW"] --> loop["Run the time-sensitive loop"]
loop --> rev["AvRevertMmThreadCharacteristics"]
reg -.-> mm["MMCSS allocates CPU preferentially"]
Figure 13: MMCSS is used as a register-and-revert pair. For continuous buffer processing it fits the Windows design better than running at high priority all the time.
4.4. Memory / GC / First-Run Costs
If you use new / malloc / List<T>.Add / string concatenation / LINQ on every pass through the hot path, the costs of collection and relocation will eventually surface. GC (garbage collection) itself is not the villain, but if you write allocation-heavy code, its impact will surface as jitter.
flowchart LR
Start["Startup"] --> Alloc["Allocate the needed buffers"]
Alloc --> Touch["Touch them once to warm the pages"]
Touch --> Warm["Get JIT / DLL loading / first I/O out of the way"]
Warm --> Measure["Then do the real measurement / real operation"]
Figure 14: Get buffer allocation, page warming, and first-run costs out of the way at startup, and only then move to real measurement and real operation.
Checklist
- No per-iteration memory allocation / deallocation on the hot path
- Required buffers are preallocated at startup
- Pages are warmed by touching them once at startup
- First JIT, first DLL load, and first I/O are not mixed into the real measurement
- No huge structures or variable-length logs growing inside the loop
- If
VirtualLockis used at all, it is limited to a very small critical region
.NET-side checks
- Time measurement uses
Stopwatch/Stopwatch.GetTimestamp() - No LINQ, string concatenation,
ToString(), or large log generation on the hot path async/awaitis not brought into the hot path- Pre-warm-up and post-warm-up are evaluated separately
4.5. Power Settings / EcoQoS / Timer Resolution
This part is unglamorous but effective. However tight your code is, results will not stabilize if higher-level power control is bearing down hard.
flowchart TD
Power["Power management on ordinary Windows"] --> AC["Run on AC power"]
Power --> Mode["Power mode: leaning toward Best performance"]
Power --> Plan["A dedicated production power plan if needed"]
Power --> QoS["Keep time-sensitive processes away from EcoQoS"]
Power --> Timer["Check how timer-resolution requests are handled"]
Figure 15: What to check around power: the supply, the power mode, EcoQoS, and how timer-resolution requests are handled.
Checklist
- Production evaluation is done on AC power first
Settings > System > Power & battery > Power modeis set toward Best performance- Battery saver / power-saving-first modes are not active during runs
- Vendor-specific utilities’ quiet / eco / battery-first modes have been checked
- Time-sensitive processes are not carelessly placed under EcoQoS (power-efficiency-leaning QoS)
IGNORE_TIMER_RESOLUTIONis not enabled on the time-sensitive process- Verified whether timer-resolution requests lose effect when minimized / hidden
- Power settings for everyday use and for production / measurement / demos are kept separate
timeBeginPeriod is useful when used in an organized way, but it is not a cure-all.
- Call it just before it is needed
- Revert with
timeEndPeriodwhen done - From Windows 10 version 2004 onward, it no longer has the fully global behavior of the past
- On Windows 11, a process with windows that is fully hidden / minimized / not visible / not audible may not be guaranteed high resolution
- Raising the resolution does not increase QPC’s precision
If power or QoS effects are suspected, check the power throttling state with SetProcessInformation.
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 (performance-leaning) + honor timer-resolution requests
if (!SetProcessInformation(
GetCurrentProcess(),
ProcessPowerThrottling,
&state,
sizeof(state)))
{
throw std::runtime_error("SetProcessInformation failed");
}
ControlMask says which mechanisms you take control of yourself, and StateMask says whether each of those mechanisms is on or off. The example above selects two mechanisms as controlled and turns both of them off. That is a declaration of two things: do not drop this process into EcoQoS (keep it leaning toward HighQoS) and do not ignore its timer-resolution request. Setting ControlMask to 0 instead hands both back to the OS and its default behavior.
flowchart TB
accTitle: What the two masks in a power throttling setting do
accDescr: Shows that ControlMask selects which mechanisms you control yourself and StateMask decides whether each of them is on or off, which lets you declare that the process must not drop into EcoQoS and that its timer-resolution request must not be ignored.
cm["ControlMask selects what you control"] --> sm["StateMask decides on or off"]
sm --> r1["Declare: do not drop into EcoQoS"]
sm --> r2["Declare: do not ignore the timer-resolution request"]
cm -.-> zero["A ControlMask of 0 hands it back to the OS"]
Figure 16: What the two masks of SetProcessInformation do: select what you control, then declare on or off.
Calling it from C#
To do the same thing from C#, the P/Invoke declarations look like this.
// 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;
// The fifth member of PROCESS_INFORMATION_CLASS (4 when counting from 0) is 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>Opts out of both power-efficiency-leaning treatment and having the timer-resolution request ignored.</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());
}
}
}
Call it once, as PowerQos.OptOutOfPowerThrottling();, before starting the time-sensitive loop. The process handle needs PROCESS_SET_INFORMATION access rights, but the pseudo-handle for the current process returned by GetCurrentProcess() has no problem there.
4.6. CPU Placement / Core Migration / Heat
For CPU placement, rather than jumping straight to pinning to specific cores (hard affinity / CPU pinning), it usually works better to start with something closer to soft affinity: please run mostly on these cores.
flowchart LR
Measure["Measure first"] --> Ideal["SetThreadIdealProcessor / CPU Sets"]
Ideal --> Check{"Improved enough?"}
Check -->|Yes| Keep["Stop there"]
Check -->|No| Hard["Consider SetThreadAffinityMask last"]
Measure --> Therm["Also check temperature / clocks / long runs"]
Figure 17: Start CPU placement from measurement, stop there if a soft hint is enough, and keep pinning to specific cores as the last resort.
Checklist
- CPU placement is only touched after measuring
- Not pinning to specific cores right away
SetThreadIdealProcessoror CPU Sets tried firstSetThreadAffinityMasktreated as a last resort- Temperature, clocks, and thermal throttling checked over long runs
- Laptop quiet / low-noise modes checked
As an order of operations, this flow is safe.
- Measure first
- If needed, ideal processor / CPU Sets
- If improvement is still needed, pin to specific cores
Pinning to specific cores looks like it should help, but it removes the OS’s escape routes, so used casually it can actually leave you with less room to maneuver.
4.7. Isolating Drivers / DPCs / ISRs / External Disturbances
When only max occasionally explodes, or the average is good but p99.9 is bad, it pays to suspect external disturbances beyond your own code.
flowchart TD
Spike["Late / miss / max spike occurred"] --> Q1{"Is your own processing time also long?"}
Q1 -->|Yes| App["Shorten the hot path / reduce allocation / remove I/O"]
Q1 -->|No| Q2{"Are there DPC / ISR spikes?"}
Q2 -->|Yes| Driver["Check USB / Wi-Fi / Bluetooth / GPU / audio / storage / ACPI / driver updates"]
Q2 -->|No| Q3{"Page faults / GC / first-run costs?"}
Q3 -->|Yes| Mem["Preallocate / warm up / reduce heap pressure"]
Q3 -->|No| Q4{"Battery / power saving / thermal effects?"}
Q4 -->|Yes| Pow["AC power / power settings / cooling / long-duration tests"]
Q4 -->|No| ETW["Dig deeper with ETW / WPA / LatencyMon"]
Figure 18: The order to work through when a spike appears: your own processing, then DPC/ISR, then memory, then power and heat.
Checklist
- Drivers around Wi-Fi / Bluetooth / USB / storage / GPU / audio have been checked
- Have compared runs with unnecessary cloud sync, indexing, and auto-updates stopped
- Also tested whether things degrade when minimized or with the display off
- DPC / ISR trends inspected with LatencyMon or ETW
- Whether your own processing is heavy and whether you are being stalled from outside are examined separately
5. Measurement and Evaluation
5.1. What to Record
At minimum, you want to capture these.
- Scheduled period time
- Actual start time
- Actual finish time
- Lateness (how late the start was relative to schedule)
- Execution time
- Missed deadline count
- Consecutive missed deadline count
- Queue depth
- Drop count
- CPU utilization
- Per-core skew
- DPC / ISR spikes
- Page faults
- Temperature / clock variation
Looking only at averages makes the essence hard to grasp. What hurts in production is the occasional large latency spike.
5.2. How to Read p99 / p99.9 / max
Metrics like p99 exist to look at the slow tail. Averages alone hide the occasional large delay.
| Metric | Meaning | Intuition over 10,000 measurements |
|---|---|---|
| Average | The smoothed overall value | Spikes get buried easily |
| p50 | The middle value | Close to everyday feel |
| p95 | The threshold where the slowest 5% starts to show | The boundary excluding the slowest 500 |
| p99 | The threshold where the slowest 1% starts to show | The boundary excluding the slowest 100 |
| p99.9 | The threshold where the slowest 0.1% starts to show | The boundary excluding the slowest 10 |
| max | The worst case | The single slowest run |
Suppose the numbers line up like this (an example for explaining how to read the metrics, not measurements from any specific hardware).
- Average: 0.8 ms
- p99: 1.2 ms
- p99.9: 3.5 ms
- max: 28 ms
The story is: usually fast, but with occasional large spikes. On ordinary Windows, the real problems almost always live in this tail from p99 to max.
flowchart TB
accTitle: The difference between the average and the tail metrics
accDescr: Shows that looking only at the average hides the occasional large delay while looking at p99 and p99.9 and max reveals the slow tail, and that on ordinary Windows the real problems appear there.
avg["Look only at the average"] --> hide["The occasional large delay is hidden"]
tail["Look at p99, p99.9, and max"] --> see["The slow tail becomes visible"]
see --> real["The real problems appear between p99 and max"]
Figure 19: A good average with a spiking max means usually fast but occasionally spiking. Catch it with the tail metrics.
Note that this article does not put numbers on the effect of the recommended setup. Latency and jitter shift easily with the CPU, the drivers, resident software, power settings, and how load is applied, so numbers from someone else’s machine are not evidence about yours. Instead, here is the procedure for producing the same shape of table on your own machine.
The minimum procedure for getting p99 on your own machine
- On the hot path, only record. Take lateness and execution time with
Stopwatch.GetTimestamp()(orQueryPerformanceCounterin C++) and write them into a preallocated array. Do not compute averages or sort here - Aggregate after you stop measuring. Sort, then pull out the value at each percentile position
- Repeat the same procedure under different conditions: before and after warm-up, AC versus battery, UI in the foreground versus minimized, with and without load from other processes (5.4)
- Every time you introduce one change, take the numbers again under the same conditions and compare
// C# / .NET 8. Aggregate after measurement has stopped
using System.Diagnostics;
// On the hot path, only write into the array (zero allocation)
long[] latenessTicks = new long[100_000];
int count = 0;
// Example: inside the periodic loop
// 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;
}
// Usage
// 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 is the number of counts per second, so dividing by it and multiplying by 1000 gives milliseconds. With too few samples, p99.9 is meaningless. If you want to talk about p99.9, collect at least 10,000 samples, and preferably 100,000.
5.3. What to Measure With
The toolkit is fairly standard.
- In-app measurement
First capture
period / lateness / execution time / queue depth / dropyourself - ETW / WPR / WPA Dig into CPU, context switches, DPC / ISR, page faults
- LatencyMon Get a bearing on driver-induced wobble
- Temperature / clock monitoring Watch for thermal effects
flowchart LR
App["In-app measurement"] --> Dist["p50 / p95 / p99 / p99.9 / max"]
App --> Miss["misses / drops / queue depth"]
ETW["ETW / WPR / WPA"] --> Root["context switches / DPC / ISR / page faults"]
Temp["Temperature / clock monitoring"] --> Root
Dist --> Decide["Prioritize the improvements"]
Miss --> Decide
Root --> Decide
Figure 20: Line up the results from in-app measurement, ETW, and thermal monitoring to decide which improvements come first.
Going all the way to WPA takes some effort, but it is highly effective for separating whether DPCs / ISRs are the cause, or your own processing is simply heavy.
How to get them, and the minimum usage
| Tool | How to get it | Minimum procedure |
|---|---|---|
| WPR / WPA (Windows Performance Toolkit) | Select Windows Performance Toolkit when installing the Windows ADK (Windows Assessment and Deployment Kit). The default location is C:\Program Files (x86)\Windows Kits\10\Windows Performance Toolkit |
Open a command prompt as administrator and (1) start recording with wpr -start CPU, (2) run the problem workload for a few tens of seconds, (3) save with wpr -stop trace.etl "periodic latency investigation". Then open trace.etl in WPA. wpr -profiles lists the profile names you can use |
| LatencyMon | Download it from the Resplendence Software site. The Home Edition for personal use is the free version | Start it, begin measuring, and leave it running for a few minutes while your workload runs. It aggregates the maximum kernel timer latency plus the ISR / DPC execution times and hard pagefaults per driver, so note down any driver whose execution time stands out |
The first things to look at in WPA are, among the CPU-related graphs, DPC / ISR execution time and context switches. They line up which driver held the CPU during the stretches when your thread could not run, so cross-reference them against the times of the delays you recorded in 5.1.
Note that this article includes no screenshots. Their appearance changes easily between versions, so follow the procedure above and check on the actual screen instead.
5.4. Testing Discipline
A quiet bench environment alone is not enough for testing. At minimum, you want to examine these conditions separately.
- Right after startup, before warm-up
- After warm-up
- Long continuous runs
- UI in the foreground
- UI minimized / close to hidden
- AC power
- Battery power
- With load on the network or disk
Evaluating only on the bench makes it easy to miss problems that appear in real operation. Ordinary Windows behavior is easily pulled around by how the machine is used, so it is important to verify under conditions close to actual use.
flowchart TB
accTitle: How to think about testing under separated conditions
accDescr: Shows that evaluating only in a quiet bench environment misses problems that appear in real operation, so conditions such as before and after warm-up, long runs, the UI in the foreground versus minimized, AC versus battery power, and the presence or absence of load should be checked separately.
bench["Evaluating only on a quiet bench"] -.-> lose["Misses the problems of real operation"]
lose -.-> split["Evaluate conditions separately"]
split --> c1["Warm-up and long runs"]
split --> c2["UI in the foreground and minimized"]
split --> c3["Power supply and load conditions"]
Figure 21: Ordinary Windows behavior is pulled around by how the machine is used. Evaluate under conditions close to actual use.
6. A Rough Guide to Choosing
The quick reference by period range has been moved up to 1.1 so that you can jump back to it while reading. This section adds the two judgment calls that the table alone does not settle.
Where to decide that ordinary Windows will not do
Once you have to hold under 1 ms over long runs under heavy load, deciding to move only the time-critical part outside is often faster than continuing to tune. The candidates to move it to are device-side firmware, a dedicated controller, an FPGA, and an RTOS. Base the decision on the numbers from chapter 5, not on impressions.
- The hot path cannot be shortened any further, yet p99.9 and max keep exceeding the requirement
- External disturbances (DPCs / ISRs, drivers, other processes) are the cause, and app-side measures cannot reach them (confirmed with the isolation steps in 4.7)
- The requirement has shifted from surviving an occasional miss to never missing once
When you want everything to live in one process
Holding the GUI, logging, communication, and the database in the same loop of the same process means downstream concerns break upstream deadlines. Waiting on a file flush, reconnecting to a database, and repainting the UI can each stretch to tens of milliseconds. Widening the fast path / slow path separation (4.2) from threads to whole processes is also an option. Split the processes and the upstream period keeps running even when the downstream side seizes up.
flowchart TB
accTitle: One combined process versus separated processes
accDescr: Shows that holding the GUI and logging and communication and the database in the same loop of the same process lets downstream concerns break upstream deadlines, and that widening the fast path and slow path separation to whole processes keeps the upstream period running even when the downstream side seizes up.
one["Everything in one process and one loop"] --> broke["Downstream concerns break upstream deadlines"]
broke -.-> ex["Flush waits, reconnects, repaints"]
sep["Split upstream and downstream into processes"] --> keep["Upstream keeps running when downstream seizes up"]
Figure 22: If everything really must live together, widening the fast path / slow path separation to whole processes is an option.
7. Conclusion
There are two premises worth holding on to.
- What to aim for on ordinary Windows is not a hard real-time guarantee, but a soft real-time configuration: small latency and jitter, and no breakage when a deadline violation occurs
- The biggest win is tidying the hot path, more than tuning priorities
On the implementation side, these are what pay off.
- Separate the fast path and the slow path
- Use fixed-length queues, and decide the overflow policy in advance
- Measure with QPC, and wait with events or waitable timers
- Avoid allocation, blocking I/O, and heavy locks on the hot path
On the operations side, these are what pay off.
- Run on AC power
- Keep a separate power configuration for production
- Reduce unnecessary background load
- Evaluate with p99 / p99.9 / max and miss counts
Soft real-time on ordinary Windows is not decided by priority settings alone. Work through design, implementation, power settings, measurement, and operations as separate concerns, and you can build a remarkably stable system.
8. References
- Multimedia Class Scheduler Service
- AvSetMmThreadCharacteristicsW function
- SetThreadPriority function
- SetPriorityClass function
- timeBeginPeriod function
- CreateWaitableTimerExW function
- Acquiring high-resolution time stamps
- QueryPerformanceCounter function
- GetSystemTimePreciseAsFileTime function
- SetProcessInformation function
- VirtualLock function
- CPU Sets
- SetThreadIdealProcessor function
- SetThreadAffinityMask function
- Processor power management options
- Change the power mode for your Windows PC
- Power settings in Windows 11
- CPU Analysis (WPA / WPT)
- WPR Command-Line Options
- Download and install the Windows ADK
- Quality of Service (EcoQoS / HighQoS)
- PROCESS_POWER_THROTTLING_STATE structure
- LatencyMon - Resplendence Software
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
An Introduction to ADRs (Architecture Decision Records) — The Minimal Way to Record 'Why We Designed It This Way' on a Small Team
Code never explains why it was written that way. We cover how to use an ADR (Architecture Decision Record) — one decision, one Markdown f...
When Not to Move a Windows App to the Web: A Decision Table and the Practical Answer of Splitting
Requests to move in-house Windows apps to the web are increasing, but for apps built around device integration, local file processing, of...
Why You Should Prefer Event Waits over Sleep(1) on Windows
On Windows, the accuracy of short timed waits is bounded by the system clock granularity and scheduling. If you are waiting for work to a...
A Decision Table for Whether to Exit or Continue After an Unexpected Exception
When an unexpected exception occurs, should the app exit or keep running? We organize the decision from the perspectives of state corrupt...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Technical Consulting & Design Review
We help clarify design direction, architectural boundaries, lifetime ownership, and how to handle legacy Windows assets.
Frequently Asked Questions
Common questions about the topic of this article.
- Can Windows do real-time processing?
- You cannot get a hard real-time guarantee of zero deadline violations, but if you work carefully through design, implementation, measurement, and operations, you can reach a genuinely practical state as soft real-time. Periodic processing from a few milliseconds to tens of milliseconds, buffer-driven audio and video, and sensor acquisition with a control loop are all realistic on ordinary Windows 10/11. Conversely, if you need a guarantee of zero deadline violations, or stability below a few hundred microseconds over long runs, you should consider moving the work to an RTOS, a dedicated controller, an FPGA, or the device side.
- Why should I not use Sleep(1) for a periodic loop?
- Sleep(1) does not give you a 1 ms period. It waits roughly 1 ms or more and then adds your processing time on top, so the wait overshoot accumulates directly. A periodic loop is stable when it runs on the absolute deadlines produced by next += period, when it waits on a device event or a high-resolution waitable timer, and when only the final fine adjustment is left to a very short busy-spin. Use timeBeginPeriod only while you need it, and revert it when you are done.
- What has the biggest effect on reducing latency and jitter?
- Making the hot path short, fixed-length, and non-blocking, rather than raising priorities. Split the fast path of acquisition and control from the slow path of storage, communication, and UI, connect them with a fixed-length queue, and keep file writes, network sends, heavy logging, and per-iteration allocation off the hot path. On the operations side, AC power, reviewing the power mode, checking EcoQoS, and pruning background load are what pay off.
- Which metrics should I use to evaluate the stability of a periodic loop?
- Look at p99, p99.9, max, and the missed deadline count, not just the average. An average of 0.8 ms with a max of 28 ms, for example, means the loop is usually fast but occasionally spikes badly, and on ordinary Windows the real problems show up in that tail from p99 to max. Record DPC/ISR spikes, page faults, queue depth, and temperature and clock variation alongside them, and evaluate separate conditions such as before and after warm-up, long continuous runs, a minimized window, and battery power.