Microsoft MVP성태의 닷넷 이야기
닷넷: 2340. C# - Win32 Multimedia Timer 주기 [링크 복사], [링크+제목 복사],
조회: 633
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

C# - Win32 Multimedia Timer 주기

아래와 같은 질문이 있군요. ^^

멀티미디어 타이머의 일정하지 않은 호출 주기에 관한 질문
; https://www.sysnet.pe.kr/3/0/5974

질문을 위한 테스트 코드의 구성을 보면,

using System.Diagnostics;
using System.Runtime.InteropServices;

class MultimediaTimerTest
{
    const int TIME_PERIODIC = 0x0001;
    const int TIME_KILL_SYNCHRONOUS = 0x0100;

    private delegate void TimerCallback(uint uTimerID, uint uMsg, IntPtr dwUser, IntPtr dw1, IntPtr dw2);

    [DllImport("winmm.dll", SetLastError = true)]
    static extern uint timeSetEvent(uint msDelay, uint msResolution, TimerCallback callback, IntPtr userCtx, uint eventType);

    [DllImport("winmm.dll", SetLastError = true)]
    static extern uint timeKillEvent(uint timerId);

    [DllImport("winmm.dll", SetLastError = true)]
    static extern uint timeBeginPeriod(uint uMilliseconds);

    [DllImport("winmm.dll", SetLastError = true)]
    static extern uint timeEndPeriod(uint uMilliseconds);

    static TimerCallback? _callback;
    static uint _timerId;

    static Stopwatch _stopwatch = Stopwatch.StartNew();
    static long _lastTicks;
    static double _tickFrequencyMs = 1000.0 / Stopwatch.Frequency;

    static double _minInterval = double.MaxValue;
    static double _maxInterval = double.MinValue;

    static Stopwatch _statReset = Stopwatch.StartNew();

    static void Main()
    {
        uint msDelay = 1; // 1ms 주기

        Console.WriteLine($"▶ Multimedia Timer Test 시작 ({msDelay}ms 주기)");
        timeBeginPeriod(1);

        _callback = TimerTick;
        _timerId = timeSetEvent(msDelay, 0, _callback, IntPtr.Zero, TIME_PERIODIC | TIME_KILL_SYNCHRONOUS);

        Console.WriteLine("Press CTRL+C key to exit...");

        Console.CancelKeyPress += (s, e) =>
        {
            Console.WriteLine("\n종료 중...");
            timeKillEvent(_timerId);
            timeEndPeriod(1);
            Thread.Sleep(100);
        };

        Thread.Sleep(Timeout.Infinite);
    }

    static void TimerTick(uint id, uint msg, IntPtr user, IntPtr param1, IntPtr param2)
    {
        long now = _stopwatch.ElapsedTicks;
        double interval = (now - _lastTicks) * _tickFrequencyMs;
        _lastTicks = now;

        if (interval > 0.01)
        {
            if (interval < _minInterval) _minInterval = interval;
            if (interval > _maxInterval) _maxInterval = interval;
        }

        if (_statReset.ElapsedMilliseconds >= 3000)
        {
            Console.WriteLine($"[3초 통계] 최소: {_minInterval:F4} ms / 최대: {_maxInterval:F4} ms");
            _minInterval = double.MaxValue;
            _maxInterval = double.MinValue;
            _statReset.Restart();
        }
    }
}

3초마다 지난 TimerTick 시간부터 얼마나 빨리/느리게 호출되었는지를 재고 있는데요, 제 컴퓨터에서 실행해 보면 대충 이런 결과가 나옵니다.

▶ Multimedia Timer Test 시작 (1ms 주기)
Press any key to exit...
[3초 통계] 최소: 0.0116 ms / 최대: 2.9326 ms
[3초 통계] 최소: 0.2317 ms / 최대: 11.6624 ms
[3초 통계] 최소: 0.4911 ms / 최대: 2.0578 ms
[3초 통계] 최소: 0.4918 ms / 최대: 2.0510 ms
[3초 통계] 최소: 0.4048 ms / 최대: 2.1614 ms
[3초 통계] 최소: 0.3551 ms / 최대: 2.2452 ms
[3초 통계] 최소: 0.4750 ms / 최대: 2.6287 ms
[3초 통계] 최소: 0.5099 ms / 최대: 2.0470 ms
[3초 통계] 최소: 0.4998 ms / 최대: 2.1595 ms
[3초 통계] 최소: 0.4815 ms / 최대: 2.0719 ms
[3초 통계] 최소: 0.4523 ms / 최대: 2.0096 ms
[3초 통계] 최소: 0.4480 ms / 최대: 2.0150 ms
...[생략]...

대체로 0.4ms ~ 2.0ms 사이에서 호출이 되는데요, 다시 말해 Timer를 1ms 주기로 설정했어도 실제 호출 주기는 경우에 따라 0.4ms 후에 빠르게 호출될 수도, 혹은 반대로 2.0ms 후에 느리게 호출될 수도 있다는 것입니다.

사실 이건 Windows의 Multimedia Timer가 결국에는 TimerTick이라는 콜백 함수를 운영체제의 스케줄링에 영향을 받아 호출하게 되므로 어쩔 수 없는 현상입니다. (만약 저걸 정확하게 지켜야 한다면 Windows가 아닌 다른 RTOS를 선택해야 합니다.)

게다가 timeBeginPeriod도 Timer Interrupt의 주기를 조정하는 함수이기 때문에,

윈도우 운영체제의 시간 함수 (1) - GetTickCount와 timeGetTime의 차이점
; https://www.sysnet.pe.kr/2/0/11063

전에 설명했던 현상이 그대로 재현되는 것이나 마찬가지입니다.




그렇긴 한데, RTOS도 아닌 Windows가 제공하는 멀티미디어 타이머의 의미는 시간 주기를 보정해 준다는 점입니다. 이에 대해서는 전에 아래의 Q&A로 의견이 오간 적이 있었습니다. ^^

WaitHandle.WaitOne 과 Stopwatch에 관한 질문
; https://www.sysnet.pe.kr/3/0/956

예를 들어, 1초 단위로 주기를 설정했어도 어느 순간 1.001초 후에 호출되었다면, 그다음의 호출은 0.999초 후에 호출되도록 보정된다는 것이 Multimedia Timer의 특징입니다. 단지, 질문자의 예제에서는 스케줄러에게는 너무 벅찬 1ms 주기로 timer를 설정했기 때문에 오차가 너무 발생한 것입니다.

실제로 주기 자체를 1000으로 바꿔서 테스트하면,

uint msDelay = 1000; 

실행 시 이런 결과가 나오는데,

[3초 통계] 최소: 999.5577 ms / 최대: 1000.3119 ms
[3초 통계] 최소: 999.4849 ms / 최대: 1000.2293 ms
[3초 통계] 최소: 999.8198 ms / 최대: 1000.2886 ms
[3초 통계] 최소: 999.1548 ms / 최대: 1000.4207 ms
[3초 통계] 최소: 999.5312 ms / 최대: 1000.7725 ms
[3초 통계] 최소: 999.8065 ms / 최대: 1000.0304 ms
[3초 통계] 최소: 999.5189 ms / 최대: 1000.7229 ms
[3초 통계] 최소: 999.2757 ms / 최대: 1000.8761 ms
[3초 통계] 최소: 999.0815 ms / 최대: 1000.8589 ms
...[생략]...

보는 바와 같이 이번엔 주기에 비해 발생하는 오차가 상대적으로 크지 않은 수준으로 내려왔습니다.




테스트하는 김에 좀 더 알아볼까요? 위의 출력 결과에 Thread.CurrentThread.ManagedThreadId를 함께 나오도록 하면,

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] [3초 통계] 최소: {_minInterval:F4} ms / 최대: {_maxInterval:F4} ms");

스레드 ID가 동일하게 출력이 되는데요, 달리 말하면 TimerTick 콜백을 호출하는 스레드가 항상 정해져 있다는 것을 의미합니다. 이 상태에서 1ms 주기로 설정해 1초마다 몇 번 실행되는지 테스트를 다음과 같이 할 수 있습니다.

static void Main()
{
    timeBeginPeriod(1);

    Thread t = new Thread(threadPrint);
    t.IsBackground = true;
    t.Start(); // 별도 스레드에서 수행 횟수 출력

    // ...[생략]...

    Thread.Sleep(Timeout.Infinite);
}

static long _count = 0;

private static void threadPrint(object? obj)
{
    Stopwatch st = new Stopwatch();
    st.Start();
    long oldTime = st.ElapsedTicks;

    while (true)
    {
        long now = st.ElapsedTicks;
        long timeDiff = now - oldTime;

        if (timeDiff >= 10_000_000)
        {
            long count = Interlocked.Exchange(ref _count, 0);
            oldTime = now;

            // 1초마다 몇 번 호출되었는지 출력
            Console.WriteLine($"1000 ms ==> {count}");
        }
    }
}

static void TimerTick(uint id, uint msg, IntPtr user, IntPtr param1, IntPtr param2)
{
    Interlocked.Increment(ref _count); // 실행 횟수 누적
}

실행해 보면,

1000 ms ==> 999
1000 ms ==> 1001
1000 ms ==> 999
1000 ms ==> 1001
1000 ms ==> 1000
1000 ms ==> 999
1000 ms ==> 1001
1000 ms ==> 1000
1000 ms ==> 1000
1000 ms ==> 1000
1000 ms ==> 999
1000 ms ==> 1001
1000 ms ==> 1000
1000 ms ==> 1000
1000 ms ==> 1000
1000 ms ==> 1000
1000 ms ==> 1000
1000 ms ==> 999
1000 ms ==> 1000
1000 ms ==> 1001
1000 ms ==> 1000
1000 ms ==> 1000
...[생략]...

보는 바와 같이, 1초에 (간혹 밀려서) 999번이 호출되었다고 해도 이후에 (밀린 시간 대비 빠르게 호출한 경우로 인해) 1001번이 호출되고 있습니다. 하지만 그렇다고 이것이 반드시 (평균적으로) 1,000번 호출된다는 보장은 없습니다. 왜냐하면, TimerTick의 내부 수행 시간이 1ms를 넘게 되면, 그다음 호출이 밀리게 되기 때문입니다. (위의 예제 코드는 _count를 증가하는 간단한 코드만 있기 때문에 그런 현상이 발생하지 않는 것입니다.)

이것을 CPU 부하를 이용한 uSleep을 호출하도록 수정해 확인할 수 있습니다.

// 이 핸들러의 주기를 1ms로 설정했지만,
static void TimerTick(uint id, uint msg, IntPtr user, IntPtr param1, IntPtr param2)
{
    Interlocked.Increment(ref _count);

    NativeMethods.uSleep(5000); // 일부러 5000us, 즉 5ms 동안 소비
                                // Thread.Sleep(5)로 사용하지 않은 이유가 뭘까요? ^^
}

실행하면, 거의 정확히 저 비율대로 호출 횟수가 줄어드는데,

1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 199
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
1000 ms ==> 200
...[생략]...

그래도 저렇게 199번이 한 번씩 나오면서 이후 201번으로 상쇄되는 현상은 없습니다.

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




정리해 보면, 멀티미디어 타이머는 1) 주어진 시간 주기로 반드시 호출하는 것은 아니고, 주어진 시간 주기로 호출된 것처럼 보정하는 효과가 있으며, 2) 만약 콜백 메서드의 수행 시간이 주기보다 길어지면 그다음 호출이 밀리게 돼 주기에 비해 호출 횟수가 줄어들 수 있습니다.




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/11/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... 121  122  [123]  124  125  126  127  128  129  130  131  132  133  134  135  ...
NoWriterDateCnt.TitleFile(s)
10892정성태2/16/201622401오류 유형: 318. 탐색기에서 폴더 생성/삭제 시 몇 초 동안 멈추는 현상
10891정성태2/16/201625927VC++: 95. 내 CPU가 MPX/SGX를 지원할까요? [1]
10890정성태2/15/201625046.NET Framework: 544. C# 5의 Caller Info를 .NET 4.5 미만의 응용 프로그램에 적용하는 방법 [5]
10889정성태2/14/201621849.NET Framework: 543. C++의 inline asm 사용을 .NET으로 포팅하는 방법 - 두 번째 이야기파일 다운로드1
10888정성태2/14/201619763.NET Framework: 542. 닷넷 - 특정 클래스가 로드되었는지 여부를 알 수 있을까?
10887정성태2/3/201621299VC++: 94. MPX(Memory Protection Extensions) 테스트파일 다운로드1
10886정성태2/3/201622771개발 환경 구성: 281. Intel MPX Runtime Driver 수동 설치
10885정성태2/2/201621683오류 유형: 317. Sybase.Data.AseClient.AseException: The command has timed out.
10884정성태1/11/201622579개발 환경 구성: 280. 닷넷에서 SAP Adaptive Server Enterprise 데이터베이스 사용파일 다운로드1
10882정성태1/6/201622956Windows: 113. 윈도우의 2179, 26143, 47001 TCP 포트 사용 [1]
10881정성태1/3/201623858오류 유형: 316. 윈도우 10 - 바탕/돋음 체가 사라져 한글이 깨지는 현상 [2]
10880정성태12/16/201522153오류 유형: 315. 닷넷 프로파일러의 오류 코드 정보
10879정성태12/16/201523809오류 유형: 314. Error : DEP0700 : Registration of the app failed. error 0x80070005
10878정성태12/9/201527207디버깅 기술: 75. UWP(유니버설 윈도우 플랫폼) 앱에서 global::System.Diagnostics.Debugger.Break 예외 발생 시 대응 방법
10877정성태12/9/201531296VC++: 93. std::thread 사용 시 R6010 오류 [2]
10876정성태11/26/201527118.NET Framework: 541. SignedXml을 이용한 ds:Signature만드는 방법 [3]파일 다운로드1
10875정성태11/26/201532390개발 환경 구성: 279. signtool.exe의 다중 서명 기능 [2]
10874정성태11/26/201528089개발 환경 구성: 278. 인증서와 인증서를 이용한 코드 사인의 해시 구분
10873정성태11/25/201526618.NET Framework: 540. C# - 부동 소수 계산 왜 이렇게 나오죠? (2) [3]파일 다운로드1
10872정성태11/24/201534910.NET Framework: 539. C# - 부동 소수 계산 왜 이렇게 나오죠? (1) [1]
10871정성태11/23/201529056오류 유형: 313. SignTool Error: No certificates were found that met all the given criteria.
10870정성태11/23/201530386오류 유형: 312. 윈도우 10 TH2 (버전 1511) 업데이트가 안되는 경우 [1]
10869정성태11/23/201526549오류 유형: 311. certutil 실행 오류 - 0x80070057 [1]
10868정성태11/20/201525790제니퍼 .NET: 25. 제니퍼 닷넷 적용 사례 (5) - RestSharp 라이브러리의 CPU High 현상파일 다운로드1
10867정성태10/18/201529280.NET Framework: 538. Thread.Abort로 인해 프로세스가 종료되는 현상
10866정성태10/14/201524226.NET Framework: 537. C# - Reflection의 박싱 없이 값 형식을 다루는 방법파일 다운로드1
... 121  122  [123]  124  125  126  127  128  129  130  131  132  133  134  135  ...