Programming Language/C#

[C#] DateTime 구조체 파헤치기

  • -
using System

 

DateTime 구조체는 날짜와 시간으로 표현된 시각을 나타낸다.

 

현재 시각을 가져오거나, 날짜간의 연산이 필요하거나, 원하는 날짜 및 시각을 저장하는 등으로 사용할 수 있다.

 

DateTime dt = new DateTime(2024, 10, 12, 23, 20, 42, 21); Console.WriteLine($"DateTime: {dt}"); Console.WriteLine($"Year: {dt.Year}"); Console.WriteLine($"Month: {dt.Month}"); Console.WriteLine($"Day: {dt.Day}"); Console.WriteLine($"DayOfWeek: {dt.DayOfWeek}"); Console.WriteLine($"Hour: {dt.Hour}"); Console.WriteLine($"Minute: {dt.Minute}"); Console.WriteLine($"Second: {dt.Second}"); Console.WriteLine($"Millisceond: {dt.Millisecond}");

 

 

 

private readonly ulong _dateData; public DateTime(int year, int month, int day, int hour, int minute, int second) { if (second != 60 || !s_systemSupportsLeapSeconds) { _dateData = DateToTicks(year, month, day) + TimeToTicks(hour, minute, second); } else { // if we have a leap second, then we adjust it to 59 so that DateTime will consider it the last in the specified minute. this = new(year, month, day, hour, minute, 59); ValidateLeapSecond(); } }

 

인자로 들어온 년, 월, 일 등의 int 데이터들을 Ticks로 변환하여 _dateData라는 내부 필드에 저장한다.

 

private static ulong TimeToTicks(int hour, int minute, int second) { if ((uint)hour >= 24 || (uint)minute >= 60 || (uint)second >= 60) { ThrowHelper.ThrowArgumentOutOfRange_BadHourMinuteSecond(); } int totalSeconds = hour * 3600 + minute * 60 + second; return (uint)totalSeconds * (ulong)TicksPerSecond; }

 

위에서 호출된 TimeToTicks 메소드를 살펴보겠다.

 

인자에 대한 유효성 검사 이후 모두 초로 변환하고, TicksPerSecond를 곱하여 return한다.

 

// Number of 100ns ticks per time unit private const long TicksPerMillisecond = 10000; private const long TicksPerSecond = TicksPerMillisecond * 1000; private const long TicksPerMinute = TicksPerSecond * 60; private const long TicksPerHour = TicksPerMinute * 60; private const long TicksPerDay = TicksPerHour * 24;

 

운영체제와 관련 없이 TicksPerMillisecond는 10,000으로 고정되어있고, 나머지는 해당 필드를 기준으로 곱하여 사용한다.

 

즉, 1밀리초에는 10,000개의 Tick이 있고 1Tick은 100나노초를 나타낸다.

 

private readonly ulong _dateData;

 

저장되는 내부 필드는 unsigned long (64bit) 정수다.

 

주석에 따르면 62번째 비트까지는 Ticks를 담으며,

63-64번째 비트에는 날짜 시간의 DateTimeKind 값을 설명하는 상태 값으로, 날짜 시간이 현지 시간이지만 서머타임과 겹치는 시간대이고 서머타임인 드문 경우를 위한 2번째 값이 있다.

이로 인해 애매한 현지 시간을 구분할 수 있고 현지 시간에서 UTC 시간으로 왕복할 때 데이터 손실을 방지할 수 있다고 한다.

 

public int Year => GetDatePart(DatePartYear); public int Month => GetDatePart(DatePartMonth); public int Day => GetDatePart(DatePartDay);
private int GetDatePart(int part) { // n = number of days since 1/1/0001 uint n = (uint)(UTicks / TicksPerDay); // y400 = number of whole 400-year periods since 1/1/0001 uint y400 = n / DaysPer400Years; // n = day number within 400-year period n -= y400 * DaysPer400Years; // y100 = number of whole 100-year periods within 400-year period uint y100 = n / DaysPer100Years; // Last 100-year period has an extra day, so decrement result if 4 if (y100 == 4) y100 = 3; // n = day number within 100-year period n -= y100 * DaysPer100Years; // y4 = number of whole 4-year periods within 100-year period uint y4 = n / DaysPer4Years; // n = day number within 4-year period n -= y4 * DaysPer4Years; // y1 = number of whole years within 4-year period uint y1 = n / DaysPerYear; // Last year has an extra day, so decrement result if 4 if (y1 == 4) y1 = 3; // If year was requested, compute and return it if (part == DatePartYear) { return (int)(y400 * 400 + y100 * 100 + y4 * 4 + y1 + 1); } // n = day number within year n -= y1 * DaysPerYear; // If day-of-year was requested, return it if (part == DatePartDayOfYear) return (int)n + 1; // Leap year calculation looks different from IsLeapYear since y1, y4, // and y100 are relative to year 1, not year 0 uint[] days = y1 == 3 && (y4 != 24 || y100 == 3) ? s_daysToMonth366 : s_daysToMonth365; // All months have less than 32 days, so n >> 5 is a good conservative // estimate for the month uint m = (n >> 5) + 1; // m = 1-based month number while (n >= days[m]) m++; // If month was requested, return it if (part == DatePartMonth) return (int)m; // Return 1-based day-of-month return (int)(n - days[m - 1] + 1); }

 

DateTime에서 년, 월, 일을 가져오려고 하면 호출되는 메소드다.

 

이것저것 주석도 많고 복잡하지만, 결국 저장된 Tick을 가지고 날짜 계산을 하는 것이다.

 

private ulong UTicks => _dateData & TicksMask;

 

Ticks는 0이 될 수 있지만, 년, 월, 일은 0이 될 수 없다. 따라서 중간중간 +1을 해주고 반환하는 모습을 볼 수 있다.

 

날짜는 최소 1/1/0001 00:00부터 최대 12/31/9999 23:59:59.9999999까지다.

 

이는 각각 DateTime.MinValue DateTime.MaxValue로 미리 static 객체로 명명되어 있다.

 

결론은 DateTime 구조체는 내부 필드에 그레고리력으로 0001년 1월 1일 자정 12:00:00 이후로 경과한 100나노초 간격의 수인 Tick을 저장해두고, 필요할 때 이를 가공해서 반환해 주는 것이다.

 

 

public static bool operator ==(DateTime d1, DateTime d2) => d1.Ticks == d2.Ticks; public static bool operator !=(DateTime d1, DateTime d2) => d1.Ticks != d2.Ticks; public static bool operator <(DateTime t1, DateTime t2) => t1.Ticks < t2.Ticks; public static bool operator <=(DateTime t1, DateTime t2) => t1.Ticks <= t2.Ticks; public static bool operator >(DateTime t1, DateTime t2) => t1.Ticks > t2.Ticks; public static bool operator >=(DateTime t1, DateTime t2) => t1.Ticks >= t2.Ticks;

 

같은 기준을 가지고 있는 Ticks를 저장해두고 있는 덕분에, 날짜 연산도 모두 간편하게 Ticks 비교로 오버로딩해 둔 모습이다.

 

정수간의 비교기 때문에 오버헤드도 거의 없다.

 

public DateTime AddDays(double value) { return Add(value, MillisPerDay); } public DateTime AddHours(double value) { return Add(value, MillisPerHour); } public DateTime AddMinutes(double value) { return Add(value, MillisPerMinute); }
private DateTime Add(double value, int scale) { double millis_double = value * scale + (value >= 0 ? 0.5 : -0.5); if (millis_double <= -MaxMillis || millis_double >= MaxMillis) ThrowOutOfRange(); return AddTicks((long)millis_double * TicksPerMillisecond); static void ThrowOutOfRange() => throw new ArgumentOutOfRangeException(nameof(value), SR.ArgumentOutOfRange_AddValue); }

 

또한 특정 시각으로부터 value(음수 가능) 만큼 떨어져있는 날짜 혹은 시간을 간편하게 입력하여 연산된 새로운 DateTime을 받아볼 수도 있다.

 

 

Console.WriteLine($"Now: {DateTime.Now}"); Console.WriteLine($"UtcNow: {DateTime.UtcNow}");

 

 

DateTime.Now는 이 컴퓨터의 현재 날짜와 시간(현지 시간으로 표시)으로 설정된 DateTime 객체를 가져온다.

DateTime.UtcNow는 이 컴퓨터의 현재 날짜와 시간(협정 세계시(UTC)로 표시)으로 설정된 DateTime 객체를 가져온다.

 

public static unsafe DateTime UtcNow { get { ulong fileTimeTmp; // mark only the temp local as address-taken s_pfnGetSystemTimeAsFileTime(&fileTimeTmp); ulong fileTime = fileTimeTmp; if (s_systemSupportsLeapSeconds) { // Query the leap second cache first, which avoids expensive calls to GetFileTimeAsSystemTime. LeapSecondCache cacheValue = 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)); } } }

 

DateTiem.UtcNow는 운영체제에 따라 다르게 구현되어있고, 필자는 Windows 에 맞게 구현된 코드를 가져왔다.

 

시스템 시간을 가져온 뒤, 윤초를 지원하는 시스템이라면 이를 더해주고, UTC 시간대로 변환하여 반환한다.

 

private static unsafe readonly delegate* unmanaged[SuppressGCTransition]<ulong*, void> 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)) { 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; }

 

시스템 시간을 가져오기 위해 Windows API의 GetSystemTimeAsFileTime()을 호출하는 모습이다.

 

public static DateTime Now { get { DateTime utc = UtcNow; long offset = TimeZoneInfo.GetDateTimeNowUtcOffsetFromUtc(utc, out bool isAmbiguousLocalDst).Ticks; long tick = utc.Ticks + offset; if ((ulong)tick <= MaxTicks) { if (!isAmbiguousLocalDst) { return new DateTime((ulong)tick | KindLocal); } return new DateTime((ulong)tick | KindLocalAmbiguousDst); } return new DateTime(tick < 0 ? KindLocal : MaxTicks | KindLocal); } }

 

DateTiem.NowDateTiem.UtcNow를 먼저 호출하여 현재 시스템에 설정되어 있는 지역으로 시간을 조정하여 반환한다.

 

TimeZoneInfo.GetDateTimeNowUtcOffsetFromUtc() 또한 Windows API를 통해 OS에서 TimeZone 데이터를 받아온다.

 

DateTiem.NowDateTiem.UtcNow 모두 운영체제에서 데이터를 가져오기 때문에 성능 차이가 궁금하여 실험해보았다.

 

1회 호출에 걸리는 ticks

 

0.5초 간격으로 100회 호출한 평균 ticks

 

첫 호출 시에만 비용이 크고, 그 뒤로는 내부적으로 캐싱하여 오버헤드를 많이 줄이는 듯 하다.

 

테스트 코드

더보기
using System.Diagnostics; public class Program { private static void Main(string[] args) { var garbage = Stopwatch.StartNew(); var a = DateTime.MinValue; garbage.Stop(); var ticks = GetAvgElapsedTicks(() => new DateTime(2021, 3, 21, 3, 21, 12)); Console.WriteLine("new ticks avg: " + ticks); ticks = GetAvgElapsedTicks(() => DateTime.UtcNow); Console.WriteLine("UtcNow ticks avg: " + ticks); ticks = GetAvgElapsedTicks(() => DateTime.Now); Console.WriteLine("Now ticks avg: " + ticks); } static float GetAvgElapsedTicks(Func<DateTime> action) { const int count = 100; long sum = 0; for (int i = 0; i < count; ++i) { var stopwatch = Stopwatch.StartNew(); _ = action.Invoke(); stopwatch.Stop(); sum += stopwatch.ElapsedTicks; Thread.Sleep(500); } return sum / (float)count; } }

 

DateTime.NowDateTiem.UtcNow 프로퍼티의 resolution(정밀도?)는 기본 운영 체제에 따라 달라지는 시스템 타이머에 따라 달라지며, 보통 0.5~15밀리초 사이라고 한다.

 

따라서 루프와 같이 짧은 시간 간격으로 Now 프로퍼티를 반복적으로 호출하면 동일한 값이 반환될 수 있다.

resolution이 낮기 때문에 벤치마킹 도구로 사용하기에는 적합하지 않고, Stopwatch 클래스를 사용하는 것이 낫다고 한다.

https://sikpang.tistory.com/40

 

[C#] Stopwatch 클래스 이해하기

using System.Diagnostics Stopwatch 클래스로 어느 간격에 대한 경과 시간을 측정할 수 있다. 일반적으로 Start 메서드를 호출한 다음 Stop 메서드를 호출하고,이후 Elapsed 속성을 사용하여 경과 시간을 확

sikpang.tistory.com

 

 

https://learn.microsoft.com/ko-kr/dotnet/api/system.datetime?view=net-8.0

 

DateTime 구조체 (System)

일반적으로 날짜와 시간으로 표현된 시간의 한 순간을 나타냅니다.

learn.microsoft.com

https://github.com/dotnet/runtime/blob/5535e31a712343a63f5d7d796cd874e563e5ac14/src/libraries/System.Private.CoreLib/src/System/DateTime.cs

 

runtime/src/libraries/System.Private.CoreLib/src/System/DateTime.cs at 5535e31a712343a63f5d7d796cd874e563e5ac14 · dotnet/runtim

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps. - dotnet/runtime

github.com

 

개인 공부용 포스팅인 점을 참고하시고, 잘못된 부분이 있다면 댓글로 남겨주시면 감사드리겠습니다.

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.