Microsoft MVP성태의 닷넷 이야기
닷넷: 2340. C# - Win32 Multimedia Timer 주기 [링크 복사], [링크+제목 복사],
조회: 448
글쓴 사람
정성태 (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

비밀번호

댓글 작성자
 




... 76  77  78  79  80  81  82  83  84  [85]  86  87  88  89  90  ...
NoWriterDateCnt.TitleFile(s)
11853정성태3/24/201922636개발 환경 구성: 435. 존재하지 않는 IP 주소에 대한 Dns.GetHostByAddress/gethostbyaddr/GetNameInfoW 실행이 느리다면? - 두 번째 이야기 [1]
11852정성태3/20/201920795개발 환경 구성: 434. 존재하지 않는 IP 주소에 대한 Dns.GetHostByAddress/gethostbyaddr/GetNameInfoW 실행이 느리다면?파일 다운로드1
11851정성태3/19/201924665Linux: 8. C# - 리눅스 환경에서 DllImport 대신 라이브러리 동적 로드 처리 [2]
11850정성태3/18/201924629.NET Framework: 813. C# async 메서드에서 out/ref/in 유형의 인자를 사용하지 못하는 이유
11849정성태3/18/201923517.NET Framework: 812. pscp.exe 기능을 C#으로 제어하는 방법파일 다운로드1
11848정성태3/17/201920665스크립트: 14. 윈도우 CMD - 파일이 변경된 경우 파일명을 변경해 복사하고 싶다면?
11847정성태3/17/201925002Linux: 7. 리눅스 C/C++ - 공유 라이브러리 동적 로딩 후 export 함수 사용 방법파일 다운로드1
11846정성태3/15/201923839Linux: 6. getenv, setenv가 언어/운영체제마다 호환이 안 되는 문제
11845정성태3/15/201923220Linux: 5. Linux 응용 프로그램의 (C++) so 의존성 줄이기(ReleaseMinDependency) [3]
11844정성태3/14/201925266개발 환경 구성: 434. Visual Studio 2019 - 리눅스 프로젝트를 이용한 공유/실행(so/out) 프로그램 개발 환경 설정 [1]파일 다운로드1
11843정성태3/14/201919990기타: 75. MSDN 웹 사이트를 기본으로 영문 페이지로 열고 싶다면?
11842정성태3/13/201917475개발 환경 구성: 433. 마이크로소프트의 CoreCLR 프로파일러 예제를 Visual Studio CMake로 빌드하는 방법 [1]파일 다운로드1
11841정성태3/13/201918098VS.NET IDE: 132. Visual Studio 2019 - CMake의 컴파일러를 기본 g++에서 clang++로 변경
11840정성태3/13/201920289오류 유형: 526. 윈도우 10 Ubuntu App 환경에서는 USB 외장 하드 접근 불가
11839정성태3/12/201924543디버깅 기술: 124. .NET Core 웹 앱을 호스팅하는 Azure App Services의 프로세스 메모리 덤프 및 windbg 분석 개요 [3]
11838정성태3/7/201928351.NET Framework: 811. (번역글) .NET Internals Cookbook Part 1 - Exceptions, filters and corrupted processes [1]파일 다운로드1
11837정성태3/6/201941778기타: 74. 도서: 시작하세요! C# 7.3 프로그래밍 [10]
11836정성태3/5/201925709오류 유형: 525. Visual Studio 2019 Preview 4/RC - C# 8.0 Missing compiler required member 'System.Range..ctor' [1]
11835정성태3/5/201923418.NET Framework: 810. C# 8.0의 Index/Range 연산자를 .NET Framework에서 사용하는 방법 및 비동기 스트림의 컴파일 방법 [3]파일 다운로드1
11834정성태3/4/201922113개발 환경 구성: 432. Visual Studio 없이 최신 C# (8.0) 컴파일러를 사용하는 방법
11833정성태3/4/201923419개발 환경 구성: 431. Visual Studio 2019 - CMake를 이용한 공유/실행(so/out) 리눅스 프로젝트 설정파일 다운로드1
11832정성태3/4/201918096오류 유형: 524. Visual Studio CMake - rsync: connection unexpectedly closed
11831정성태3/4/201919044오류 유형: 523. Visual Studio 2019 - 새 창으로 뜬 윈도우를 닫을 때 비정상 종료
11830정성태2/26/201918085오류 유형: 522. 이벤트 로그 - Error opening event log file State. Log will not be processed. Return code from OpenEventLog is 87.
11829정성태2/26/201919403개발 환경 구성: 430. 마이크로소프트의 CoreCLR 프로파일러 예제 빌드 방법 - 리눅스 환경 [1]
11828정성태2/26/201928126개발 환경 구성: 429. Component Services 관리자의 RuntimeBroker 설정이 2개 있는 경우 [8]
... 76  77  78  79  80  81  82  83  84  [85]  86  87  88  89  90  ...