استفاده از TimeProvider در .NET 8 و یک تغییر کوچک در SynchronizationContext میتواند چندین ثانیه از زمان تستهای واحد (unit tests) مربوط به منطق بازگشتی (retry-logic) بکاهد؛ تستهایی که در غیر این صورت در حالت sleep واقعی منتظر میمانند. تستی که زمانی هفت ثانیه طول میکشید، اکنون در چند ده میلیثانیه تمام میشود و این تغییر، هیچ هزینه اضافی برای اجرا در محیط عملیاتی (production) ندارد.
چرا این تأخیر اهمیت دارد
بسیاری از ابزارهای کمکیِ بازگشتی (retry helpers) از روش «پسروی نمایی» (exponential backoff) استفاده میکنند: ۱ ثانیه صبر کنید، سپس ۲ ثانیه، سپس ۴ ثانیه و به همین ترتیب. در محیط عملیاتی، این کار از سرویسها در برابر فشار بیش از حد به یک نقطه پایانی (endpoint) در حال خرابی محافظت میکند. اما در یک مجموعه تست، همان کد Task.Delay را روی ساعت واقعی فراخوانی میکند، بنابراین رشتهی تست (test thread) واقعاً به خواب میرود. اگر این را در دهها تست ضرب کنید، خط لوله یکپارچهسازی مداوم (CI pipeline) بدون هیچ سود عملکردی، چندین دقیقه طولانیتر میشود. این «مالیاتِ خواب» (sleep tax) صرفاً اتلاف وقت است.
TimeProvider چگونه کار میکند
داتنت ۸ کلاس TimeProvider را معرفی کرد که انتزاعی (abstraction) روی ساعت سیستم است. کلاسی که به زمان فعلی نیاز دارد یا نیاز به تأخیر دارد، میتواند به جای فراخوانی مستقیم DateTime.UtcNow یا Task.Delay ، یک نمونه از TimeProvider را بپذیرد. در محیط عملیاتی، شما TimeProvider.System را پاس میدهید که به ساعت واقعی متصل است. در تست، شما یک FakeTimeProvider ارائه میدهید. این نسخه جعلی (fake) به شما اجازه میدهد زمان را به صورت دستی با Advance(TimeSpan) جلو ببرید. در لایههای داخلی، Task.Delay از این پرووایدر (provider) استفاده میکند، بنابراین جلو بردن ساعت جعلی، بلافاصله هر تأخیرِ در جریان را برطرف میکند.
ایده ساده است: ساعت واقعی را با یک ساعت قابل کنترل جایگزین کنید، سپس به نقطهای بپرید که کدِ تحت تست باید از آنجا از حالت انتظار خارج میشد.
تلهی SynchronizationContext در xUnit
این مفهوم در یک اپلیکیشن کنسول به خوبی کار میکند، اما در xUnit باعث ایجاد بنبست (deadlock) میشود. زمانی که ابزار کمکیِ بازگشتی در انتظار یک تأخیر بود، تست متد Advance() را فراخوانی کرد. xUnit SynchronizationContext مخصوص به خود را نصب میکند که ادامه کارها (continuations) را میگیرد و آنها را در رشتهی تست اجرا میکند. وقتی Advance() ساعت را جلو برد، ادامه کار (continuation) به جای رشتهی تست، در صفِ استخر رشتهها (thread pool) قرار گرفت. رشتهی تست به جلو بردن زمان ادامه داد و ساعت شبیهسازی شده را از لحظهای که بازگشت بعدی باید رخ میداد، عبور داد. تایمر جدید برای نقطهای در آینده تنظیم شد که هرگز فرا نخواهید رسید، زیرا هیچ رشتهای باقی نمانده بود تا دوباره ساعت را حرکت دهد. تست برای همیشه معلق ماند.
راه حل تکخطی
افزودن تنها یک خط در ابتدای تست، رفتار مورد انتظار را بازیابی میکند:
SynchronizationContext.SetSynchronizationContext(null);
پاک کردن کانتکست سفارشی باعث میشود awaitها برای اجرای ادامه کار (continuations) به استخر رشتهها (thread pool) هدایت شوند، جایی که کالبکهای (callbacks) تایمر جعلی میتوانند اجرا شوند. با این تغییر، همان تست از هفت ثانیه به حدود ۳۶ میلیثانیه کاهش مییابد.
مزایای گستردهتر
فراتر از حذف خوابهای بیهوده، یک ساعت جعلی تست کردن منطقهای حساس به منطقه زمانی (time-zone) را آسان میکند. شما میتوانید تأیید کنید که سهمیه روزانه در نیمهشب محلیِ صحیح بازنشانی میشود، بدون اینکه منتظر بمانید ساعت واقعی به دوازده برسد. همین رویکرد برای هر کدی که بر اساس زمان فعلی تصمیمگیری میکند (branching) کاربرد دارد: پرووایدر را تزریق کنید، از فراخوانیهای پنهان Thread.Sleep یا Task.Delay اجتناب کنید، و به تستهای تعیینپذیر (deterministic) و سریع دست خواهید یافت.
نکات احتیاطی
- هر تأخیری باید از طریق
TimeProviderتزریقشده هدایت شود. یکThread.Sleepسرگردان یا یکTask.Delayمستقیم همچنان ساعت واقعی را فراخوانی کرده و دوباره باعث ایجاد تأخیر میشود. - اگر کتابخانهای که از آن استفاده میکنید، در لایههای داخلی خود بدون ارائه یک پرووایدر،
Task.Delayرا فراخوانی کند، نمیتوانید زمانبندی آن را بدون استفاده از یک لایه واسط (shim) پیچیدهتر کنترل کنید. در چنین مواردی، مزیت این روش ممکن است محدود باشد. - پوشهی تست را با دستور
grepبرایTask.DelayوThread.Sleepجستجو کنید تا انتظارهای پنهان را پیدا کنید. بازنویسی (Refactoring) این فراخوانیها برای استفاده از پرووایدر، تنها راه برای موثر نگه داشتن ساعت جعلی است.
جمعبندی
جایگزینی ساعت سیستم با TimeProvider و غیرفعال کردن SynchronizationContext در xUnit، تستهای بازگشتی کُند را به بررسیهای تقریباً آنی تبدیل میکند، منابع CI را آزاد میکند و تأیید کدهای وابسته به زمان را آسانتر میسازد. این تلاش تنها چند خط کد است؛ اما پاداش آن با ثانیههای ذخیره شده در هر بار اجرای تست سنجیده میشود.
