C# - .NET Core에서 바뀐 DateTime.Ticks의 정밀도
오랜만에 이전 글의 코드를,
C# - DateTime.Ticks의 정밀도
; https://www.sysnet.pe.kr/2/0/11082
using System;
class Program
{
static void Main(string[] args)
{
int count = 100000;
long[] tickBuf = new long[count];
for (int i = 0; i < count; i++)
{
// tickBuf[i] = DateTime.Now.Ticks;
tickBuf[i] = DateTime.UtcNow.Ticks; // Now/UtcNow 모두 결과는 동일
}
long oldTime = tickBuf[0];
long elapsed;
for (int i = 1; i < count; i++)
{
elapsed = tickBuf[i] - oldTime;
oldTime = tickBuf[i];
if (elapsed != 0)
{
Console.WriteLine(elapsed);
}
}
}
}
/* 출력 결과:
...[생략]...
1
1
1
...[생략]...
1
1
.NET 8에서 실행했더니 결과가 예상치 않은 값이 나왔습니다. 즉, 당시 .NET Framework로 실행했을 때는 "
Current timer interval"의 변화에 따라, 예를 들어 15.6ms인 경우에는 대략 156,000의 변화가, 1ms인 경우에는 10,000의 변화가 나타났었는데요, .NET 8에서는 1 값이 연속으로 출력됐습니다.
뭔가 분명히 변화가 있다는 것이겠죠? ^^
자, 그럼 UtcNow의 소스코드를 살펴볼까요?
namespace System
{
public readonly partial struct DateTime
{
internal static bool SystemSupportsLeapSeconds => LeapSecondCache.s_systemSupportsLeapSeconds;
// ...[생략]...
public static unsafe DateTime UtcNow
{
get
{
ulong fileTimeTmp; // mark only the temp local as address-taken
LeapSecondCache.s_pfnGetSystemTimeAsFileTime(&fileTimeTmp);
ulong fileTime = fileTimeTmp;
if (LeapSecondCache.s_systemSupportsLeapSeconds)
{
// Query the leap second cache first, which avoids expensive calls to GetFileTimeAsSystemTime.
LeapSecondCache cacheValue = LeapSecondCache.s_leapSecondCache;
ulong ticksSinceStartOfCacheValidityWindow = fileTime - cacheValue.OSFileTimeTicksAtStartOfValidityWindow;
if (ticksSinceStartOfCacheValidityWindow < LeapSecondCache.ValidityPeriodInTicks)
{
return new DateTime(dateData: cacheValue.DotnetDateDataAtStartOfValidityWindow + ticksSinceStartOfCacheValidityWindow);
}
return UpdateLeapSecondCacheAndReturnUtcNow(); // couldn't use the cache, go down the slow path
}
else
{
return new DateTime(dateData: fileTime + (FileTimeOffset | KindUtc));
}
}
}
}
}
코드상으로는, 핵심 작업은 LeapSecondCache.s_pfnGetSystemTimeAsFileTime 함수 포인터로 GetSystemTimeAsFileTime 함수를 호출하는 것에서 끝납니다. 이후 윤초 지원 여부를 LeapSecondCache.s_systemSupportsLeapSeconds 속성으로 결정하는데요, 그 2개의 정의를 모두 LeapSecondCache 클래스에서 찾을 수 있습니다.
namespace System
{
public readonly partial struct DateTime
{
internal static bool SystemSupportsLeapSeconds => LeapSecondCache.s_systemSupportsLeapSeconds;
// ...[생략]...
private sealed class LeapSecondCache
{
// The length of the validity window. Must be less than 23:59:59.
internal const ulong ValidityPeriodInTicks = TicksPerMinute * 5;
// The FILETIME value at the beginning of the validity window.
internal ulong OSFileTimeTicksAtStartOfValidityWindow;
// The DateTime._dateData value at the beginning of the validity window.
internal ulong DotnetDateDataAtStartOfValidityWindow;
// The leap second cache. May be accessed by multiple threads simultaneously.
// Writers must not mutate the object's fields after the reference is published.
// Readers are not required to use volatile semantics.
internal static LeapSecondCache s_leapSecondCache = new LeapSecondCache();
// The configuration of system leap seconds support is intentionally here to avoid blocking
// AOT pre-initialization of public readonly DateTime statics.
internal static readonly bool s_systemSupportsLeapSeconds = GetSystemSupportsLeapSeconds();
internal static readonly unsafe delegate* unmanaged[SuppressGCTransition]<ulong*, void>
s_pfnGetSystemTimeAsFileTime = GetGetSystemTimeAsFileTimeFnPtr();
}
}
}
우선 GetSystemSupportsLeapSeconds() 함수로 윤초 지원 여부를 결정하는데요, 이건 Win32 API로 제공하는 NtQuerySystemInformation 함수를 호출해 결정하는 식으로 구현돼 있습니다.
private static unsafe bool GetSystemSupportsLeapSeconds()
{
Interop.NtDll.SYSTEM_LEAP_SECOND_INFORMATION slsi;
return Interop.NtDll.NtQuerySystemInformation(
Interop.NtDll.SystemLeapSecondInformation,
&slsi,
(uint)sizeof(Interop.NtDll.SYSTEM_LEAP_SECOND_INFORMATION),
null) == 0 && slsi.Enabled != Interop.BOOLEAN.FALSE;
}
그러니까, 결국 윤초 지원에 따른 결과로는 interrupt timer 주기를 따르지 않는 이유가 되지 못합니다.
자, 그럼 이제 남은 문제는 s_pfnGetSystemTimeAsFileTime 함수 포인터인데요, 이것을 결정하는 GetGetSystemTimeAsFileTimeFnPtr 함수를 보면,
private static unsafe delegate* unmanaged[SuppressGCTransition]<ulong*, void> GetGetSystemTimeAsFileTimeFnPtr()
{
IntPtr kernel32Lib = Interop.Kernel32.LoadLibraryEx(Interop.Libraries.Kernel32, IntPtr.Zero, Interop.Kernel32.LOAD_LIBRARY_SEARCH_SYSTEM32);
Debug.Assert(kernel32Lib != IntPtr.Zero);
IntPtr pfnGetSystemTime = NativeLibrary.GetExport(kernel32Lib, "GetSystemTimeAsFileTime");
if (NativeLibrary.TryGetExport(kernel32Lib, "GetSystemTimePreciseAsFileTime", out IntPtr pfnGetSystemTimePrecise))
{
// GetSystemTimePreciseAsFileTime exists and we'd like to use it. However, on
// misconfigured systems, it's possible for the "precise" time to be inaccurate:
// https://github.com/dotnet/runtime/issues/9014
// If it's inaccurate, though, we expect it to be wildly inaccurate, so as a
// workaround/heuristic, we get both the "normal" and "precise" times, and as
// long as they're close, we use the precise one. This workaround can be removed
// when we better understand what's causing the drift and the issue is no longer
// a problem or can be better worked around on all targeted OSes.
// Retry this check several times to reduce chance of false negatives due to thread being rescheduled
// at wrong time.
for (int i = 0; i < 10; i++)
{
long systemTimeResult, preciseSystemTimeResult;
((delegate* unmanaged[SuppressGCTransition]<long*, void>)pfnGetSystemTime)(&systemTimeResult);
((delegate* unmanaged[SuppressGCTransition]<long*, void>)pfnGetSystemTimePrecise)(&preciseSystemTimeResult);
if (Math.Abs(preciseSystemTimeResult - systemTimeResult) <= 100 * TicksPerMillisecond)
{
pfnGetSystemTime = pfnGetSystemTimePrecise; // use the precise version
break;
}
}
}
return (delegate* unmanaged[SuppressGCTransition]<ulong*, void>)pfnGetSystemTime;
}
아하~~~ 저렇게 되어 있었군요. ^^ 우선 GetSystemTimeAsFileTime 함수를 호출한 다음, (Windows 8/Server 2012 이후부터 지원하는) GetSystemTimePreciseAsFileTime 함수가 있다면 (
9014 이슈에 걸리지 않는 경우) 그것을 사용하기로 돼 있는 것입니다.
결국, .NET Core부터는 GetSystemTimePreciseAsFileTime 함수로 호출이 바뀐 탓에,
GetSystemTimeAsFileTime과 GetSystemTimePreciseAsFileTime의 차이점
; https://www.sysnet.pe.kr/2/0/13802
.NET Framework의 실행 결과와 차이가 발생한 것입니다.
참고로, 지난 글에 설명한 QueryPerformanceFrequency 함수의 경우 닷넷에서는 Stopwatch.
Frequency 필드로 구할 수 있습니다.
또한, Stopwatch.
IsHighResolution 필드는 C++에서는 QueryPerformanceFrequency 함수의 반환값에 해당합니다.
마지막으로, 윤초 지원이 아닌 경우 곧바로 DateTime에 FileTimeOffset과 KindUtc를 더하는데요,
if (LeapSecondCache.s_systemSupportsLeapSeconds)
{
// ...[생략]...
}
else
{
return new DateTime(dateData: fileTime + (FileTimeOffset | KindUtc));
}
우선 KindUtc는 "private const ulong KindUtc = 0x4000000000000000;"에 해당하는 상수이고, FileTimeOffset 역시 내부적으로 윤초가 지원되지 않는 경우 고정으로 계산된 상수입니다.
// Number of days in a non-leap year
private const int DaysPerYear = 365;
// Number of days in 4 years
private const int DaysPer4Years = DaysPerYear * 4 + 1; // 1461
// Number of days in 100 years
private const int DaysPer100Years = DaysPer4Years * 25 - 1; // 36524
// Number of days in 400 years
private const int DaysPer400Years = DaysPer100Years * 4 + 1; // 146097
// Number of days from 1/1/0001 to 12/31/1600
private const int DaysTo1601 = DaysPer400Years * 4; // 584388
private const long FileTimeOffset = DaysTo1601 * TicksPerDay;
// Number of 100ns ticks per time unit
internal const int MicrosecondsPerMillisecond = 1000;
private const long TicksPerMicrosecond = 10;
private const long TicksPerMillisecond = TicksPerMicrosecond * MicrosecondsPerMillisecond;
private const int HoursPerDay = 24;
private const long TicksPerSecond = TicksPerMillisecond * 1000;
private const long TicksPerMinute = TicksPerSecond * 60;
private const long TicksPerHour = TicksPerMinute * 60;
private const long TicksPerDay = TicksPerHour * HoursPerDay;
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]