Windows 소프트 리얼타임 실전 가이드 - 지연을 줄이기 위한 체크리스트
· 업데이트: · 小村 豪 · Windows 개발, 소프트 리얼타임, 설계, 계측
수정 이력(1건, 최종 수정 2026년 09월 01일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635093)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635092)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
小村 豪 (2026). 「Windows 소프트 리얼타임 실전 가이드 - 지연을 줄이기 위한 체크리스트」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635092 https://comcomponent.com/ko/blog/2026/03/09/000-windows-soft-realtime-practical-guide-natural/
- DOI(최신 버전)
- 10.5281/zenodo.21635092
- DOI(이 버전)
- 10.5281/zenodo.22217412
Windows에서 주기 처리, 음성 처리, 영상 처리, 계측, 장치 제어처럼 「늦으면 곤란한」 처리를 만들면 「Windows로는 어렵지 않을까」라는 인상을 받기 쉽습니다. 이 인상은 절반은 맞고 절반은 틀린데, Windows는 hard real-time OS는 아니지만 설계, 구현, 계측, 운영을 제대로 다듬으면 soft real-time으로서 꽤 실용적인 상태까지 끌어올릴 수 있습니다.
이 글에서 다루는 것은 특별한 RTOS 확장이나 독자적인 커널 드라이버, 전용 컨트롤러를 전제로 하지 않는, 평범한 Windows 10 / 11입니다. 평소 쓰는 데스크톱 / 노트북 PC 위의 user-mode 앱에서 지연과 지터를 어디까지 줄일 수 있는가라는 실무 중심의 이야기입니다. 음성, 영상, 주기 제어, 데이터 취득에서는 세부 사항은 다르지만 문제가 되기 쉬운 지점은 상당히 공통되어 있으므로, 이번에는 그 공통 부분을 체크리스트 형태로 정리했습니다.
대상 독자와 코드 예시 언어
Windows에서 「늦으면 곤란한」 처리(주기 제어, 음성·영상, 계측, 장치 제어)를 만드는 개발자를 대상으로 씁니다. 상정하고 있는 것은 user-mode 앱 개발이며, 커널 모드 드라이버 구현은 범위 밖입니다.
코드 예시의 언어는 다음과 같이 나뉘어 있습니다.
| 내용 | 언어 | 위치 |
|---|---|---|
| 주기 루프, MMCSS, 전원 QoS 등 Win32 API를 직접 호출하는 부분 | C++(Win32) | 4.1, 4.3, 4.5 |
| 같은 Win32 API를 C#에서 호출하는 방식 | C#(P/Invoke) | 4.5 |
| 시각 계측, GC, 할당 관련 주의점 | .NET(C#) | 4.4의 「.NET 측 체크」, 5.2 |
3장까지의 원인 이야기와 4장의 체크리스트 본체는 언어에 의존하지 않습니다. C#만 사용하는 분은 C++ 코드를 「어떤 API를 어떤 순서로 호출하는가」에 대한 설명으로 읽어 주시면 충분합니다.
목차
- 먼저 결론(한마디로)
- 1.1. 주기 범위별 조견표(어디부터 읽을지)
- 평범한 Windows에서 「소프트 리얼타임」이란 무엇인가
- 2.1. 이 글에서 말하는 「평범한 Windows」
- 2.2. 무엇이 가능하고, 어디서부터 어려워지는가
- 2.3. 용어를 먼저 한마디로
- 지연과 지터의 주된 원인
- 3.1. 스케줄러와 우선순위
- 3.2. DPC / ISR과 드라이버
- 3.3. 페이지 폴트와 메모리
- 3.4. 타이머 분해능과 전원 관리
- 3.5. 코어 이동과 열
- 평범한 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.1. 무엇을 기록할까
- 5.2. p99 / p99.9 / max 보는 법
- 5.3. 무엇으로 볼까
- 5.4. 테스트 방식
- 대략적인 구분
- 정리
- 참고 자료
이 글의 지식 맵
평범한 Windows에서 소프트 리얼타임을 노리려면, 기한 위반 제로를 보증하는 하드 리얼타임을 요구하지 않고 지연과 지터를 작게 해서 기한을 벗어나도 무너지지 않는 설계로 만듭니다. 주기 대기는 QueryPerformanceCounter로의 계측과 고정밀 waitable timer(대기 가능 타이머)를 토대로 하며, 필요한 동안만 timeBeginPeriod로 타이머 분해능을 올립니다. 전원 스로틀링으로 EcoQoS로의 격하와 타이머 분해능 무시를 명시적으로 해제하지 않으면, DPC/ISR, page fault, 서멀 스로틀링과 함께 지터가 늘어나는 한 원인이 됩니다. 음성·영상 같은 연속 처리에서는 MMCSS가 우선적인 CPU 배분으로 기한 초과를 막고, CPU 배치는 CPU Sets 같은 느슨한 지정을 우선순위 높은 고정보다 먼저 시도해야 하며, 원인 분리에는 WPR/WPA와 LatencyMon을 사용합니다.
flowchart LR
accTitle: 평범한 Windows의 소프트 리얼타임 지식 맵
accDescr: 소프트 리얼타임을 뒷받침하는 waitable timer(대기 가능 타이머)나 MMCSS, 전원 스로틀링과 EcoQoS의 관계, DPC/ISR나 page fault가 지터를 낳는 경로, CPU Sets에서 CPU 고정으로 가는 단계, WPR/WPA와 LatencyMon에 의한 계측 수단의 관계를 보여주는 그림.
soft_realtime["소프트 리얼타임"]
qpc["QPC(QueryPerformanceCounter)"]
waitable_timer["대기 가능 타이머(waitable timer)"]
timebeginperiod["timeBeginPeriod(타이머 분해능 요청)"]
power_throttling["Power Throttling(전력 조절)"]
ecoqos["EcoQoS"]
jitter["지터"]
mmcss["MMCSS(Multimedia Class Scheduler Service)"]
deadline_miss["데드라인 미스(기한 위반)"]
thread_priority_class["스레드 우선순위/우선순위 클래스"]
cpu_sets["CPU Sets"]
cpu_affinity["CPU 선호도"]
thermal_throttling["서멀 스로틀링"]
dpc_isr["DPC/ISR"]
page_fault["페이지 폴트"]
wpr_wpa["WPR/WPA(Windows Performance Recorder/Analyzer)"]
latencymon["LatencyMon"]
soft_realtime -.->|"이용한다"| qpc
soft_realtime -.->|"이용한다"| waitable_timer
soft_realtime -.->|"이용한다"| timebeginperiod
power_throttling -.->|"양립하지 않는다"| timebeginperiod
soft_realtime -.->|"이용한다"| power_throttling
power_throttling -.->|"방지한다"| ecoqos
ecoqos -.->|"원인이 될 수 있다"| jitter
soft_realtime -.->|"이용한다"| mmcss
mmcss -->|"완화한다"| jitter
mmcss -->|"완화한다"| deadline_miss
thread_priority_class -.->|"원인이 될 수 있다"| jitter
thread_priority_class -->|"사용은 비권장"| soft_realtime
soft_realtime -.->|"이용한다"| cpu_sets
cpu_sets -->|"보다 먼저 해야 한다"| cpu_affinity
thermal_throttling -.->|"원인이 될 수 있다"| jitter
dpc_isr -.->|"원인이 될 수 있다"| jitter
page_fault -.->|"원인이 될 수 있다"| jitter
dpc_isr -->|"에서 확인할 수 있다"| wpr_wpa
dpc_isr -->|"에서 확인할 수 있다"| latencymon
page_fault -->|"에서 확인할 수 있다"| wpr_wpa
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 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에서는 우선순위를 올리는 것보다, 늦어지는 이유를 설계로 줄이는 것이 더 효과적입니다. 우선순위나 전원 설정도 중요하지만 그것만으로 안정성이 만들어지지는 않습니다.
flowchart TB
accTitle: 안정성을 만드는 것은 우선순위보다 설계
accDescr: 핫 패스를 짧고 고정 길이로 논블로킹으로 만들어 지연 원인을 설계로 줄이는 것이 가장 효과적이며, 우선순위나 전원 설정은 중요하지만 그것만으로는 안정성을 만들 수 없다는 결론을 보여준다.
hot["핫 패스를 짧고 고정 길이로"] --> less["지연 원인이 설계에서 줄어든다"]
less --> goal["지연이 적고 늦어도 무너지지 않는 구성"]
prio["우선순위나 전원 설정 조정"] -.-> goal
prio -.-> note["중요하지만 그것만으로는 부족하다"]
그림 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 / 로그 / 통신 / DB와 전부 동거시키고 싶음 | 「전부 1프로세스 1루프」로 끌어안지 말고 책무를 분리. 후단의 사정이 전단의 기한을 깨뜨리기 쉬움 | 4.2, 4.3, 6 |
원인 분리와 계측 방식은 어떤 범위에서도 공통입니다(3장과 5장).
2. 평범한 Windows에서 「소프트 리얼타임」이란 무엇인가
2.1. 이 글에서 말하는 「평범한 Windows」
여기서 말하는 평범한 Windows는 대략 다음을 전제로 합니다.
- Windows 10 / 11의 일반적인 데스크톱 / 노트북 PC
- 독자적인 RTOS 확장 없음
- 독자적인 커널 모드 드라이버 개발 없음
- 평범한 user-mode 앱
- 일반적인 Windows API와 설정으로 조정
즉, 「전용기 한 대를 통째로 리얼타임 제어용으로 만들어 넣는」 이야기가 아니라, 「평범한 Windows PC 위에서 어디까지 현실적으로 줄일 수 있는가」라는 이야기입니다.
flowchart LR
A["평범한 Windows 10 / 11 PC"] --> B["user-mode 앱"]
B --> C["soft real-time을 목표로 함"]
C --> D["지연을 낮춘다"]
C --> E["지터를 작게 한다"]
C --> F["deadline miss를 관측해 무너지지 않게 한다"]
G["기한 위반 제로를 보증하고 싶다"] -.-> H["RTOS / 전용 컨트롤러 / FPGA / 디바이스 측 처리"]
그림 2: 평범한 Windows의 user-mode 앱에서 노리는 것은 soft real-time이고, 기한 위반 제로의 보증은 RTOS 등의 영역.
2.2. 무엇이 가능하고, 어디서부터 어려워지는가
평범한 Windows에서도 예컨대 이런 처리라면 상당히 현실적으로 「덜 늦는」 상태를 만들 수 있습니다.
- 수 밀리초 ~ 수십 밀리초 단위의 주기 처리
- 음성 / 영상의 버퍼 구동
- 센서 취득과 제어 루프
- 소프트 PLC 방식의 일정 주기 처리
- UI와는 별도 스레드로 동작하는 저지연 파이프라인
다만 여기서 말하는 「가능하다」는 가끔의 지연 스파이크를 완전히 제로로 할 수 있다는 의미가 아니라, 노리는 것은 어디까지나 다음과 같은 상태입니다.
- 평상시의 지연을 낮게 유지
- 지터를 작게
- 가끔 기한을 놓쳐도 무너지지 않음
- 놓친 사실을 관측할 수 있음
반대로 다음과 같은 요구가 되면 평범한 Windows의 user-mode만으로 충족하기는 상당히 어려워집니다.
- 기한 위반 제로를 보증하고 싶다
- 수백 마이크로초 이하를 장시간 안정적으로 지키고 싶다
- 무거운 GUI, 네트워크, 스토리지와 동거하고 싶다
- 배터리 구동이나 절전 우선인 채로 하고 싶다
- 드라이버나 디바이스 유래의 스파이크도 용납되지 않는다
이 정도가 되면 정말로 시간에 엄격한 부분만 디바이스 측 펌웨어, 전용 컨트롤러, FPGA, RTOS로 옮기는 것도 고려하는 편이 안전합니다.
flowchart TB
accTitle: soft real-time에서 노리는 상태와 떼어내는 판단
accDescr: 평범한 Windows에서 노리는 것은 평상시 지연을 낮추고 지터를 작게 하며, 가끔 기한을 어겨도 무너지지 않고 어긴 사실을 관측할 수 있는 상태이며, 기한 위반 제로 같은 엄격한 요구는 시간에 엄격한 부분만 밖으로 떼어낸다는 것을 보여준다.
aim["soft real-time에서 노리는 상태"] --> s1["평상시 지연을 낮게"]
aim --> s2["지터를 작게"]
aim --> s3["어겨도 무너지지 않고 관측 가능"]
strict["기한 위반 제로 요구"] -.-> out["디바이스나 RTOS로 옮긴다"]
그림 3: 「가능하다」의 내용은 스파이크 제로가 아니라, 늦기 어렵고·무너지기 어렵고·관측 가능한 상태라는 것.
2.3. 용어를 먼저 한마디로
이 글에 나오는 용어의 의미를 먼저 짚어 둡니다.
| 용어 | 한마디로 | 실무에서의 관점 |
|---|---|---|
| soft real-time | 가끔의 지연은 있을 수 있지만, 지연을 작게 하고 늦어도 무너지지 않게 하는 사고방식 | 평범한 Windows에서 먼저 노리는 것이 이것 |
| hard real-time | 기한 위반 제로를 보증하고 싶은 세계 | 평범한 Windows의 user-mode 단독으로 노릴 대상이 아님 |
| 지터 | 주기나 응답 시간의 흔들림 | 평균이 좋아도 지터가 크면 실운영에서는 불안정 |
| deadline miss | 예정 시각까지 처리가 끝나지 않는 것 | 숨기지 않고 세어서 로그에 남긴다 |
| p99 / p99.9 | 느린 쪽의 꼬리를 보는 지표 | p99는 「100회 중 느린 쪽 1회가 보이기 시작하는 경계」 |
| DPC / ISR | 드라이버나 인터럽트 주변의 커널 측 처리 | 길면 user-mode 스레드는 기다려야 함 |
| MMCSS | 음성 / 영상 등 시간에 민감한 처리에 CPU를 배분하는 Windows의 구조 | 버퍼가 끊기지 않게 하고 싶은 처리에서 유력 |
| 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에서 주기 처리가 늦어지는 이유는 대체로 이 그림 중 하나로 귀결됩니다.
flowchart TD
Late["주기 처리가 늦어진다"] --> S["스케줄러 / 우선순위"]
Late --> D["DPC / ISR / 드라이버"]
Late --> M["페이지 폴트 / 메모리"]
Late --> T["타이머 분해능 / 전원 관리"]
Late --> C["코어 이동 / 열"]
그림 4: 주기 처리가 늦어지는 원인은 스케줄러, DPC/ISR, 메모리, 타이머와 전원, 코어 이동과 열의 5계통으로 귀결된다.
3.1. 스케줄러와 우선순위
Windows의 스레드는 우선순위로 실행 순서가 정해집니다. 같은 우선순위라면 라운드로빈으로 돌고, 더 높은 우선순위의 스레드가 실행 가능해지면 낮은 우선순위의 스레드는 밀려납니다.
즉, 주기 스레드를 성실하게 작성해도
- 다른 스레드
- 다른 프로세스
- OS의 내부 처리
- 보안 제품
- 디바이스 보조 처리
- 백그라운드 동기화
가 먼저 실행되는 일은 흔히 있습니다.
flowchart TB
accTitle: 우선순위 스케줄링에 의한 밀려남
accDescr: 같은 우선순위의 스레드는 라운드로빈으로 순서대로 돌아가고, 더 높은 우선순위의 스레드가 실행 가능해지면 낮은 우선순위의 스레드는 밀려나기 때문에, 주기 스레드 앞에 다른 프로세스나 OS의 내부 처리가 끼어드는 것은 흔한 일임을 보여준다.
same["같은 우선순위끼리"] --> rr["라운드로빈으로 순서대로 돌아간다"]
high["더 높은 우선순위가 실행 가능해진다"] --> push["낮은 우선순위는 밀려난다"]
push -.-> who["다른 프로세스나 OS 내부 처리 등"]
그림 5: 주기 스레드를 성실하게 작성해도, 더 높은 우선순위의 작업이 먼저 실행되는 것은 흔한 일.
3.2. DPC / ISR과 드라이버
여기는 꽤 중요합니다. 앱 측의 우선순위를 정돈해도, DPC(Deferred Procedure Call)나 ISR(Interrupt Service Routine)이 길면 그동안 user-mode의 스레드는 실행할 수 없습니다.
원인이 되기 쉬운 것은 이런 디바이스나 드라이버입니다.
- USB
- Wi-Fi / Bluetooth
- 스토리지
- 오디오
- GPU
- ACPI / 전원 관련
앱의 코드가 나쁘지 않아도 드라이버나 하드웨어 사정으로 멈춰지는 경우가 있습니다. 여기는 「앱의 우선순위를 더 올리면 이길 것」이라고 생각하면 대체로 큰코다칩니다.
flowchart TB
accTitle: DPC와 ISR이 user-mode를 멈추는 구조
accDescr: USB나 Wi-Fi나 GPU 등의 디바이스와 드라이버의 인터럽트 처리인 ISR이나 DPC가 길면 그동안 user-mode의 스레드는 실행할 수 없고, 앱의 우선순위를 올려도 이길 수 없음을 보여준다.
dev["USB·Wi-Fi·GPU 등의 디바이스"] --> kern["ISR이나 DPC가 커널 측에서 실행됨"]
kern --> block["그동안 user-mode는 실행할 수 없음"]
block -.-> note["우선순위를 올려도 이길 수 없음"]
그림 6: 앱의 코드가 나쁘지 않아도, 드라이버나 하드웨어 사정으로 user-mode는 멈춰질 수 있다.
3.3. 페이지 폴트와 메모리
핫 패스에서 page fault(필요한 페이지가 메모리에 없어 가지러 가는 것)가 일어나면 지연이 단번에 커집니다.
특히 피하고 싶은 패턴을 들어 둡니다.
- 최초 접근에서의 페이지 커밋
- 지연 로드
- 메모리 맵 파일의 페이지 인
- 필요 이상의 동적 확보
- 큰 객체나 단편화된 힙
주기 처리의 본체에서는 필요한 메모리를 먼저 확보하고, 기동 시 한 번 접근해 두는 정도가 딱 좋습니다.
flowchart TB
accTitle: 핫 패스에서 page fault로 인한 지연
accDescr: 핫 패스에서 접근한 페이지가 메모리에 없으면 page fault로 가져오게 되어 지연이 단번에 커지므로, 필요한 메모리를 미리 확보해 기동 시 한 번 접근해 두는 것이 좋음을 보여준다.
touch["핫 패스에서 메모리에 접근"] --> q{"페이지가 메모리에 있는가"}
q -->|"있음"| ok["그대로 진행"]
q -->|"없음"| pf["page fault로 가져옴"]
pf --> spike["지연이 단번에 커짐"]
pf -.-> fix["미리 확보해 기동 시 한 번 접근"]
그림 7: page fault가 일어나는지 여부로 지연이 튄다. 대책은 사전 확보와 기동 시 워밍업.
3.4. 타이머 분해능과 전원 관리
「1ms마다 돌리고 싶으니 Sleep(1)」은 대부분의 경우 잘 되지 않습니다.
Windows의 대기 정밀도는 타이머 분해능, 스케줄링, 전원 상태의 영향을 받습니다.
게다가 타이머 분해능을 올리는 설정은, 대기 정밀도를 조금 개선할 수 있는 한편, 소비 전력이나 시스템 전체의 동작에 부작용이 있다는 점도 간과할 수 없습니다.
3.5. 코어 이동과 열
스레드가 코어 간을 이동하면 캐시가 다시 데워져야 합니다. 이 자체는 OS가 잘 처리하는 경우도 많지만, 부하가 높은 환경에서는 흔들림의 원인이 됩니다.
게다가 장시간 돌리면 열도 무시할 수 없습니다. 서멀 스로틀링이 걸리면 그때까지 안정적이던 주기가 무너지는 경우가 있습니다.
flowchart TB
accTitle: 코어 이동과 열이 주기를 무너뜨리는 경로
accDescr: 스레드가 코어 간을 이동하면 캐시가 다시 데워져야 해서 고부하 환경에서는 흔들림의 원인이 되고, 장시간 운전으로 열이 쌓이면 서멀 스로틀링으로 그때까지 안정적이던 주기가 무너짐을 보여준다.
move["스레드가 코어 간 이동"] --> cache["캐시가 다시 데워짐"]
cache --> jitter["고부하 환경에서는 흔들림의 원인"]
heat["장시간 운전으로 열이 쌓임"] --> thr["서멀 스로틀링"]
thr --> broke["안정적이던 주기가 무너짐"]
그림 8: 코어 이동은 지터로서, 열은 스로틀링으로서, 장시간 운전의 안정성을 깎아 먹는다.
4. 평범한 Windows에서 지연을 줄이는 실전 체크리스트
여기서부터가 실무 파트입니다. 앞 절에서 본 원인에 대해, 평범한 Windows에서 무엇을 확인하고, 무엇을 피하며, 무엇을 먼저 정해야 하는지를 체크리스트 형식으로 정리합니다.
4.1. 주기 루프와 대기 방법
먼저 전형적인 안티패턴은 이것입니다.
while (running)
{
Sleep(1);
Step();
}
이것은 「1ms 주기」가 아니라, 대략 1ms 이상 기다린 뒤에, 거기에 Step()의 실행 시간을 더하는 루프입니다.
게다가 대기 오버슈트가 그대로 누적됩니다.
flowchart LR
subgraph Bad["상대 시간 기반"]
B1["Sleep(1)"] --> B2["Step()"]
B2 --> B1
end
B2 --> B3["대기 오차와 실행 시간이 조금씩 쌓임"]
subgraph Good["절대 기한 기반"]
G1["next += period"] --> G2["WaitUntil(next - margin)"]
G2 --> G3["필요하면 짧은 spin"]
G3 --> G4["FastStep()"]
G4 --> G1
end
G4 --> G5["드리프트가 쌓이기 어려움"]
그림 9: Sleep(1)에 의존하는 상대 시간 루프는 오차가 쌓이고, 절대 기한으로 돌리는 루프는 드리프트가 쌓이기 어렵다.
체크리스트
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로 내보내는 것입니다.
flowchart LR
Input["디바이스 / 취득 이벤트"] --> Fast["fast path: 취득·제어·최소한의 복사"]
Fast --> Queue["고정 길이 큐"]
Queue --> Slow["slow path: 저장·송신·UI·집계"]
Fast --> Metrics["lateness / miss / queue depth 기록"]
Metrics --> Slow
그림 10: fast path에는 기한에 민감한 작업만 두고, 고정 길이 큐를 거쳐 slow path로 내보내는 기본 구성.
fast path에서 할 일은 이 정도로 좁힙니다.
- 데이터 취득
- 제어값 계산
- 필요 최소한의 복사
- 타임스탬프
- 큐 투입
- miss / overrun 기록
그 외는 slow path로 내립니다.
체크리스트
- 핫 패스에서 파일 쓰기, 네트워크 송신, DB 쓰기를 하지 않는다
- 핫 패스에서 무거운 로그 출력,
Flush, 동기 RPC를 하지 않는다 - fast path / slow path를 스레드나 책무로 명확히 나눈다
- 큐는 고정 길이로 한다
- 큐가 넘쳤을 때의 방침을 먼저 정한다
- miss 횟수, drop 수, queue depth를 관측한다
- UI 갱신이나 로그 집약은 낮은 주기로 분리한다
큐가 가득 찼을 때는 방침을 애매하게 하지 않는 편이 안전합니다.
flowchart TD
Overflow["큐가 가득 참"] --> Policy{"무엇을 지킬까?"}
Policy -->|최신값이 중요| Latest["오래된 요소를 버리고 최신을 남김"]
Policy -->|전건이 중요| All["알림 / 정지 / 상류 제어"]
Policy -->|로그 용도| Log["오래된 요소를 버리고 drop 수만 기록"]
그림 11: 큐가 가득 찼을 때 무엇을 지킬지는 용도별로 먼저 정해 둔다.
4.3. 우선순위 / MMCSS / background mode
우선순위의 기본은 전부를 올리지 않는 것입니다. 평범한 Windows에서는 「중요한 스레드만 올리고, 후방 작업은 확실히 낮춘다」쪽이 잘 됩니다. background mode는 CPU뿐 아니라 I/O 같은 자원도 낮은 우선순위 쪽으로 다루기 위한 구조입니다.
flowchart TD
Work["작업을 나눈다"] --> Critical["기한에 민감한 스레드"]
Work --> Worker["저장 / 송신 / 압축 / 집계"]
Work --> UI["UI"]
Critical --> P1["필요하면 높은 우선순위 또는 MMCSS"]
Worker --> P2["background mode / 낮은 우선순위"]
UI --> P3["일반 우선순위"]
P1 --> Warn["처음부터 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");
}
flowchart TB
accTitle: MMCSS를 사용하는 스레드의 등록과 해제
accDescr: 시간에 민감한 루프 앞에서 AvSetMmThreadCharacteristicsW로 MMCSS에 등록하고, 루프가 끝나면 AvRevertMmThreadCharacteristics로 되돌리는 흐름과, MMCSS가 시간에 민감한 처리에 CPU를 배분함을 보여준다.
reg["AvSetMmThreadCharacteristicsW"] --> loop["시간에 민감한 루프를 실행"]
loop --> rev["AvRevertMmThreadCharacteristics"]
reg -.-> mm["MMCSS가 CPU를 우선적으로 배분"]
그림 13: MMCSS는 등록과 해제를 짝으로 사용한다. 연속 버퍼 처리라면 상시 고우선순위로 돌리는 것보다 Windows의 설계에 맞는다.
4.4. 메모리 / GC / 초기 비용
핫 패스에서 매번 new / malloc / List<T>.Add / 문자열 연결 / LINQ를 사용하면 언젠가 회수나 재배치의 사정이 겉으로 드러납니다. GC(가비지 컬렉션) 자체가 나쁜 것은 아니지만, 할당이 많은 코드를 작성하면 그 영향은 지터로 표면화됩니다.
flowchart LR
Start["기동"] --> Alloc["필요한 버퍼를 확보"]
Alloc --> Touch["한 번 접근해 페이지를 데움"]
Touch --> Warm["JIT / DLL 로드 / 최초 I/O를 끝냄"]
Warm --> Measure["그 후 본계측 / 본운전"]
그림 14: 기동 시에 버퍼 확보·페이지 데우기·초기 비용을 끝낸 뒤 본계측·본운전에 들어간다.
체크리스트
- 핫 패스에서 매번 메모리 확보 / 해제를 하지 않는다
- 필요한 버퍼를 기동 시에 사전 확보한다
- 기동 시에 한 번 접근해 페이지를 데운다
- 최초 JIT, 최초 DLL 로드, 최초 I/O를 본계측에 섞지 않는다
- 루프 중에 거대한 구조나 가변 길이 로그를 키우지 않는다
VirtualLock을 사용하더라도 아주 작은 크리티컬 영역만으로 한정한다
.NET 측 체크
- 시각 계측에
Stopwatch/Stopwatch.GetTimestamp()를 사용한다 - 핫 패스에서 LINQ, 문자열 연결,
ToString(), 거대 로그 생성을 하지 않는다 async/await를 hot path에 들이지 않는다- 워밍업 전과 후를 나누어 평가한다
4.5. 전원 설정 / EcoQoS / timer resolution
여기는 수수하지만 효과가 있습니다. 코드를 다듬어도 상위의 전원 제어가 강하게 작용하면 결과는 안정되지 않습니다.
flowchart TD
Power["평범한 Windows의 전원 관련"] --> AC["AC 급전으로 실행"]
Power --> Mode["전원 모드: 최상의 성능 쪽"]
Power --> Plan["필요하면 실전용 전용 전원 플랜"]
Power --> QoS["시간에 민감한 프로세스는 EcoQoS를 피한다"]
Power --> Timer["타이머 분해능 요청의 취급을 확인"]
그림 15: 전원 관련에서 확인할 점. 급전, 전원 모드, EcoQoS, 타이머 분해능 요청의 취급.
체크리스트
- 실전 평가는 먼저 AC 급전으로 한다
[설정] > [시스템] > [전원 및 배터리] > [전원 모드]를 최상의 성능 쪽으로 한다- battery saver / 절전 우선 모드를 실행 중에 사용하지 않는다
- 벤더 고유 유틸리티의 정음 / eco / battery 우선 모드를 확인한다
- 시간에 민감한 프로세스를 부주의하게 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에 맡기는(기본 동작) 상태로 돌아갑니다.
flowchart TB
accTitle: power throttling 설정의 두 마스크의 역할
accDescr: ControlMask로 자신이 제어할 구조를 고르고, StateMask로 그 구조를 on으로 할지 off로 할지 정함으로써, EcoQoS로 떨어뜨리지 않는다는 것과 타이머 분해능 요청을 무시시키지 않는다는 것을 선언할 수 있음을 보여준다.
cm["ControlMask로 제어 대상을 고른다"] --> sm["StateMask로 on과 off를 정한다"]
sm --> r1["EcoQoS로 떨어뜨리지 않는다는 선언"]
sm --> r2["타이머 분해능 요청을 무시시키지 않는다"]
cm -.-> zero["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();을 1회 실행합니다. 프로세스 핸들에는 PROCESS_SET_INFORMATION 접근 권한이 필요하지만, GetCurrentProcess()가 반환하는 자기 프로세스의 의사 핸들이라면 문제없습니다.
4.6. CPU 배치 / 코어 이동 / 열
CPU 배치는 처음부터 특정 코어에 고정(hard affinity / CPU pinning)하기보다, 「가급적 이 코어 그룹에서 동작해 주었으면」이라는 soft affinity에 가까운 지정부터 시작하는 편이 잘 되는 경우가 많습니다.
flowchart LR
Measure["먼저 계측"] --> Ideal["SetThreadIdealProcessor / CPU Sets"]
Ideal --> Check{"충분히 개선됐는가?"}
Check -->|예| Keep["거기서 멈춘다"]
Check -->|아니오| Hard["마지막으로 SetThreadAffinityMask 검토"]
Measure --> Therm["동시에 온도 / 클록 / 장시간 운전도 확인"]
그림 17: CPU 배치는 계측부터 시작하고, 느슨한 지정으로 충분하면 거기서 멈추며, 특정 코어 고정은 최후의 수단으로 한다.
체크리스트
- 먼저 계측하고 나서 CPU 배치를 손댄다
- 처음부터 특정 코어에 고정하지 않는다
- 먼저
SetThreadIdealProcessor나 CPU Sets를 시도한다 SetThreadAffinityMask는 최후의 수단으로 다룬다- 장시간 운전에서 온도, 클록, 서멀 스로틀링을 확인한다
- 노트북의 정음 모드나 저소음 모드를 확인한다
순서로는 이 흐름이 안전합니다.
- 먼저 계측한다
- 필요하면 ideal processor / CPU Sets
- 그래도 개선이 필요하면 특정 코어 고정
특정 코어 고정은 효과가 있어 보이지만, OS가 빠져나갈 여지을 줄이므로 안이하게 사용하면 오히려 융통성이 없어지는 경우가 있습니다.
4.7. 드라이버 / DPC / ISR / 외란 분리
「가끔 max만 폭발한다」「평균은 좋은데 p99.9가 나쁘다」는 경우에는 앱 코드 이외의 외란도 의심하는 편이 좋습니다.
flowchart TD
Spike["late / miss / max spike가 발생"] --> Q1{"자신의 처리 시간도 긴가?"}
Q1 -->|예| App["핫 패스 단축 / 할당 삭감 / I/O 제거"]
Q1 -->|아니오| Q2{"DPC / ISR 스파이크가 있는가?"}
Q2 -->|예| Driver["USB / Wi-Fi / Bluetooth / GPU / Audio / Storage / ACPI / 드라이버 업데이트 확인"]
Q2 -->|아니오| Q3{"page fault / GC / 초기 비용이 있는가?"}
Q3 -->|예| Mem["사전 확보 / 워밍업 / 힙 부하 삭감"]
Q3 -->|아니오| Q4{"배터리 / 절전 / 열의 영향이 있는가?"}
Q4 -->|예| Pow["AC 급전 / 전원 설정 / 냉각 / 장시간 테스트"]
Q4 -->|아니오| ETW["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에 이르는 꼬리에 진짜 문제가 나타납니다.
flowchart TB
accTitle: 평균과 꼬리 쪽 지표의 차이
accDescr: 평균만 보면 가끔 나오는 큰 지연이 숨겨지는 반면, p99나 p99.9나 max를 보면 느린 쪽의 꼬리가 보이고, 평범한 Windows에서는 진짜 문제가 거기에 나타남을 보여준다.
avg["평균만 본다"] --> hide["가끔의 큰 지연이 숨겨짐"]
tail["p99·p99.9·max를 본다"] --> see["느린 쪽 꼬리가 보임"]
see --> real["진짜 문제는 p99에서 max 사이에 나타남"]
그림 19: 평균이 좋아도 max가 튀고 있다면 「평소는 빠르지만 가끔 튄다」는 상태. 꼬리 쪽 지표로 파악한다.
이 글에서는 권장 구성의 효과를 수치로 제시하지 않습니다. 지연과 지터는 CPU, 드라이버, 상주 소프트웨어, 전원 설정, 부하를 거는 방식에 따라 쉽게 달라지므로, 타인의 환경에서 나온 수치는 그대로 자신의 환경의 근거가 되지 않습니다. 대신 자신의 환경에서 같은 형태의 표를 만드는 절차를 두겠습니다.
자신의 환경에서 p99를 산출하는 최소 절차
- 핫 패스에서는 기록만 합니다.
Stopwatch.GetTimestamp()(C++라면QueryPerformanceCounter)로 lateness와 실행 시간을 얻어, 사전 확보한 배열에 씁니다. 여기서 평균이나 정렬을 계산해서는 안 됩니다 - 계측을 멈춘 뒤에 집계합니다. 정렬해서 백분위 위치의 값을 꺼냅니다
- 같은 절차를 조건을 바꿔 반복합니다. 워밍업 전후, AC / 배터리, UI 전면 / 최소화, 다른 프로세스의 부하 유무(5.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는 1초당 카운트 수이므로, 나눈 뒤 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 드라이버 기인 흔들림을 짐작함
- 온도 / 클록 감시 열의 영향을 봄
flowchart LR
App["앱 내 계측"] --> Dist["p50 / p95 / p99 / p99.9 / max"]
App --> Miss["miss / drop / queue depth"]
ETW["ETW / WPR / WPA"] --> Root["context switch / DPC / ISR / page fault"]
Temp["온도 / 클록 감시"] --> Root
Dist --> Decide["개선 우선순위 결정"]
Miss --> Decide
Root --> Decide
그림 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 "주기 지연 조사"로 저장. 이후 trace.etl을 WPA로 엽니다. 사용 가능한 프로필 이름은 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는 「사용되는 방식」에 동작이 끌려가기 쉬우므로 실제로 사용하는 조건에 맞춰 확인하는 것이 중요합니다.
flowchart TB
accTitle: 조건을 나눈 테스트의 사고방식
accDescr: 조용한 벤치 환경만으로 평가하면 실운영 문제를 놓치므로, 워밍업 전후나 장시간 운전, UI 전면과 최소화, AC 급전과 배터리 구동, 부하 유무 같은 조건을 나누어 확인함을 보여준다.
bench["조용한 벤치 환경만의 평가"] -.-> lose["실운영 문제를 놓침"]
lose -.-> split["조건을 나누어 평가"]
split --> c1["워밍업·장시간"]
split --> c2["UI 전면과 최소화"]
split --> c3["급전과 부하 조건"]
그림 21: 평범한 Windows는 사용되는 방식에 동작이 끌려간다. 실제로 사용하는 조건에 맞춰 평가한다.
6. 대략적인 구분
주기 범위별 조견표는 읽으면서 되돌아볼 수 있도록 1.1로 앞당겨 두었습니다. 여기서는 그 표만으로는 정해지지 않는 두 가지 판단을 보충합니다.
어디서 「평범한 Windows로는 무리」라고 판단할까
1ms 미만을 장시간·고부하로 지켜야 할 필요가 생기면, 튜닝을 계속하기보다 시간에 엄격한 부분만 밖으로 빼내는 판단이 더 빠른 경우가 많습니다. 옮길 곳의 후보는 디바이스 측 펌웨어, 전용 컨트롤러, FPGA, RTOS입니다. 판단 근거는 주관이 아니라 5장의 수치로 삼습니다.
- 핫 패스를 더 이상 단축할 수 없는데도 p99.9와 max가 요구를 계속 넘고 있다
- 외란(DPC / ISR, 드라이버, 다른 프로세스)이 원인이어서 앱 측 대책으로는 미치지 못한다(4.7의 분리로 확인)
- 요구가 「가끔 놓쳐도 무너지지 않는다」가 아니라 「단 한 번도 놓칠 수 없다」로 되어 있다
전부를 1프로세스에 동거시키고 싶을 때
GUI, 로그, 통신, DB를 같은 프로세스의 같은 루프에서 끌어안으면 후단의 사정이 전단의 기한을 깨뜨립니다. 파일 플러시 대기, DB 재접속, UI 다시 그리기는 어느 것이든 수십 밀리초 단위로 늘어날 수 있기 때문입니다. fast path / slow path의 분리(4.2)를, 스레드가 아니라 프로세스 단위까지 넓히는 것도 선택지가 됩니다. 프로세스를 나누면 후단이 멈춰도 전단의 주기는 계속 돌아갑니다.
flowchart TB
accTitle: 동거 구성과 프로세스 분리의 대비
accDescr: GUI나 로그나 통신이나 DB를 같은 프로세스의 같은 루프에서 끌어안으면 후단의 사정이 전단의 기한을 깨뜨리므로, fast path와 slow path의 분리를 프로세스 단위까지 넓히면 후단이 멈춰도 전단의 주기는 계속 돌아감을 보여준다.
one["전부를 1프로세스 1루프로 끌어안음"] --> broke["후단의 사정이 전단의 기한을 깨뜨림"]
broke -.-> ex["플러시 대기·재접속·다시 그리기"]
sep["전단과 후단을 프로세스로 나눔"] --> keep["후단이 멈춰도 전단은 계속 돌아감"]
그림 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. 참고 자료
- 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
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
업무 시스템의 코드 설계 ── 상품 코드・고객 코드 정하는 방법과 체크 디지트
상품 코드・고객 코드 등 업무 시스템의 코드 체계를 정하는 실전 가이드. 유의미 코드와 무의미 일련번호의 판단표, JAN・Luhn 등 체크 디지트 산식과 C# 구현, Excel의 앞자리 0 소실 대책, 자릿수 초과와 이관까지 정리합니다.
ADR(Architecture Decision Record) 입문 ── 소규모 개발에서 '왜 이런 설계로 했는가'를 남기는 최소한의 방법
코드는 '왜 그렇게 했는지'를 말해주지 않습니다. ADR(Architecture Decision Record)로 설계 판단의 이유를 1결정=1파일의 Markdown으로 남기는 방법을, 템플릿과 작성 여부 판단표, 실제 사례와 함께 해설합니다.
Windows 앱의 웹 전환, 하지 않는 편이 나은 경우 ── 판단표와 '분할'이라는 현실적 해법
'사내 Windows 앱을 웹으로 만들고 싶다'는 요청이 늘고 있지만, 장치 연동·로컬 파일 처리·오프라인 운용·고속 입력 UI를 갖춘 앱에서는 웹 전환이 오히려 비용 증가와 기능 저하를 부를 수 있습니다. 웹 전환에 적합한 경우와 부적합한 경우...
Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 분해능의 폴링을 피한다
Windows의 짧은 timer wait는 system clock 분해능과 스케줄러 지연에 묶여 의도한 정밀도가 나오지 않습니다. 작업 도착·I/O 완료·정지 요청은 event 대기로, 시각 자체는 waitable timer로 나누는 설계 지침을...
상정하지 않은 예외가 발생했을 때의 체크리스트 - 앱을 종료시킬지 계속할지, 먼저 보는 판단표
예상하지 못한 예외가 발생했을 때 앱을 종료해야 할지 계속해야 할지를 상태 파괴·외부 부작용·스레드·네이티브 경계의 관점에서 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 평범한 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, queue 깊이, 온도·클록 변동도 기록하고, 워밍업 전후, 장시간 운전, 최소화 상태, 배터리 구동 등의 조건을 나누어 평가합니다.