فصل ۲۱: Barrier، Lazy Initialization، Thread-Local Storage و Timerها
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
جایگزینهای WaitAll و SignalAndWait
WaitAll و SignalAndWait در یک آپارتمان تکنخی (Single-Threaded Apartment) اجرا نمیشوند. خوشبختانه جایگزینهایی وجود دارد. در مورد SignalAndWait، بهندرت به semantics مربوط به جلو زدن در صف نیاز دارید: برای مثال، در نمونهٔ rendezvous ما، اگر Wait Handleها فقط برای همان rendezvous استفاده شوند، کاملاً معتبر است که روی Wait Handle اول Set را صدا بزنید و سپس روی دیگری WaitOne را فراخوانی کنید. در بخش بعد، گزینهٔ دیگری را برای پیادهسازی rendezvous نخها بررسی میکنیم.
در مورد WaitAny و WaitAll، اگر به atomicity نیاز ندارید، میتوانید از کدی که در بخش قبلی نوشتیم برای تبدیل Wait Handleها به Task استفاده کنید و سپس Task.WhenAny و Task.WhenAll را بهکار ببرید (فصل ۱۴).
اگر به atomicity نیاز دارید، میتوانید پایینترین سطح signaling را انتخاب کنید و منطق را خودتان با متدهای Wait و Pulse از Monitor بنویسید. Wait و Pulse در مکمل آنلاین Threading با جزئیات توضیح داده شدهاند.
کلاس Barrier
کلاس Barrier یک مانع اجرای نخ (Thread Execution Barrier) را پیادهسازی میکند و اجازه میدهد تعداد زیادی نخ در یک نقطهٔ زمانی به یکدیگر برسند؛ این مفهوم را نباید با Thread.MemoryBarrier اشتباه گرفت. این کلاس بسیار سریع و کارآمد است و بر پایهٔ Wait، Pulse و Spinlockها ساخته شده است.
برای استفاده از این کلاس:
- آن را ایجاد کنید و مشخص کنید چند نخ باید در rendezvous شرکت کنند. بعداً میتوانید این تعداد را با
AddParticipants و RemoveParticipants تغییر دهید.
- هر نخ هر زمان که بخواهد به نقطهٔ rendezvous برسد،
SignalAndWait را فراخوانی کند.
ساخت یک Barrier با مقدار ۳ باعث میشود SignalAndWait تا زمانی که این متد سه بار فراخوانی نشده است Block شود. سپس چرخه از نو آغاز میشود: فراخوانی دوبارهٔ SignalAndWait تا سه فراخوانی بعدی Block میماند. به این ترتیب هر نخ با نخهای دیگر «همقدم» باقی میماند.
در مثال زیر، هر یک از سه نخ اعداد ۰ تا ۴ را مینویسد و همزمان با دو نخ دیگر پیش میرود:
var barrier = new Barrier (3);
new Thread (Speak).Start();
new Thread (Speak).Start();
new Thread (Speak).Start();
void Speak()
{
for (int i = 0; i < 5; i++)
{
Console.Write (i + " ");
barrier.SignalAndWait();
}
}
خروجی:
0 0 0 1 1 1 2 2 2 3 3 3 4 4 4
یکی از قابلیتهای واقعاً مفید Barrier این است که هنگام ساخت آن میتوانید یک post-phase action نیز مشخص کنید. این Delegate پس از آنکه SignalAndWait به تعداد n بار فراخوانی شد، اما پیش از Unblock شدن نخها اجرا میشود؛ بخش سایهخورده در شکل ۲۱-۳ همین مرحله را نشان میدهد. در مثال ما، اگر Barrier را اینگونه بسازیم:
static Barrier _barrier = new Barrier (3, barrier => Console.WriteLine());
خروجی به این صورت خواهد بود:
0 0 0
1 1 1
2 2 2
3 3 3
4 4 4
شکل ۲۱-۳. Barrier
یک post-phase action میتواند برای یکپارچهکردن دادههای هر Worker Thread مفید باشد. این کد لازم نیست نگران Preemption باشد، چون هنگام اجرای آن همهٔ Workerها Block هستند.
راهاندازی تنبل (Lazy Initialization)
یکی از مسائل متداول در Threading این است که چگونه یک Field مشترک را بهصورت Lazy و Thread-Safe مقداردهی اولیه کنیم. این نیاز زمانی ایجاد میشود که Fieldی از نوعی داشته باشید که ساختنش پرهزینه است:
class Foo
{
public readonly Expensive Expensive = new Expensive();
...
}
class Expensive { /* Suppose this is expensive to construct */ }
مشکل این کد این است که ساخت Foo همیشه هزینهٔ ساخت Expensive را تحمیل میکند، حتی اگر Field مربوط به Expensive هیچوقت خوانده نشود.
پاسخ واضح این است که Instance را هنگام نیاز بسازیم:
class Foo
{
Expensive _expensive;
public Expensive Expensive // Lazily instantiate Expensive
{
get
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
...
}
اکنون سؤال این است که آیا این کد Thread-Safe است؟ جدا از این واقعیت که خارج از Lock و بدون Memory Barrier به _expensive دسترسی داریم، فرض کنید دو نخ همزمان این Property را بخوانند. هر دو ممکن است شرط if را برقرار ببینند و در نهایت هر نخ Instance متفاوتی از Expensive بسازد. چون این موضوع میتواند به خطاهای ظریف منجر شود، بهطور کلی این کد را Thread-Safe نمیدانیم.
راهحل این است که بررسی و مقداردهی اولیهٔ شیء را داخل Lock قرار دهیم:
Expensive _expensive;
readonly object _expenseLock = new object();
public Expensive Expensive
{
get
{
lock (_expenseLock)
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
}
Lazy<T>
کلاس Lazy<T> برای کمک به Lazy Initialization در دسترس است. اگر آن را با آرگومان true ایجاد کنید، الگوی مقداردهی اولیهٔ Thread-Safe که همین حالا توضیح دادیم را پیادهسازی میکند.
برای استفاده از Lazy<T>، کلاس را با یک Value Factory Delegate که نحوهٔ ساخت مقدار جدید را مشخص میکند و آرگومان true ایجاد کنید. سپس از Property به نام Value به مقدار دسترسی پیدا کنید:
Lazy<Expensive> _expensive = new Lazy<Expensive>
(() => new Expensive(), true);
public Expensive Expensive { get { return _expensive.Value; } }
اگر false را به سازندهٔ Lazy<T> بدهید، الگوی Lazy Initialization غیر Thread-Safe که در ابتدای این بخش توضیح داده شد پیادهسازی میشود؛ این حالت زمانی منطقی است که بخواهید Lazy<T> را در یک Context تکنخی استفاده کنید.
LazyInitializer
LazyInitializer یک کلاس Static است که تقریباً همان کاری را انجام میدهد که Lazy<T> انجام میدهد، با این تفاوتها:
- قابلیت آن از طریق یک متد Static ارائه میشود که مستقیماً روی Field موجود در Type خودتان کار میکند. این کار یک سطح Indirection را حذف میکند و در مواردی که به بهینهسازی بسیار شدید نیاز دارید Performance را بهتر میکند.
- حالت دیگری از مقداردهی اولیه ارائه میکند که در آن چند نخ میتوانند برای Initialize کردن با یکدیگر Race کنند.
برای استفاده از LazyInitializer، پیش از دسترسی به Field متد EnsureInitialized را صدا بزنید و Reference خود Field و Factory Delegate را به آن بدهید:
Expensive _expensive;
public Expensive Expensive
{
get // Implement double-checked locking
{
LazyInitializer.EnsureInitialized (ref _expensive,
() => new Expensive());
return _expensive;
}
}
میتوانید آرگومان دیگری هم بدهید تا نخهای رقیب برای Initialize کردن Race کنند. این وضعیت شبیه مثال اولیهٔ Thread-Unsafe به نظر میرسد، با این تفاوت که همیشه اولین نخی که تمام میکند برنده میشود و در نتیجه فقط یک Instance باقی میماند. مزیت این تکنیک این است که روی سیستمهای چندهستهای حتی از Double-Checked Locking سریعتر است، چون میتوان آن را کاملاً بدون Lock و با تکنیکهای پیشرفته پیادهسازی کرد. این یک بهینهسازی افراطی و بهندرت لازم است و هزینههایی دارد:
- اگر تعداد نخهای در حال Race از تعداد Coreها بیشتر باشد، کندتر میشود.
- ممکن است CPU را با انجام Initialization تکراری هدر دهد.
- منطق Initialization باید خودش Thread-Safe باشد؛ مثلاً اگر سازندهٔ
Expensive در Static Fieldها بنویسد ممکن است Thread-Unsafe شود.
- اگر Initializer شیئی بسازد که به Dispose نیاز دارد، شیء «هدررفته» بدون منطق اضافی Dispose نخواهد شد.
ذخیرهسازی محلی نخ (Thread-Local Storage)
بخش بزرگی از این فصل روی Synchronization Constructها و مشکلاتی متمرکز بود که از دسترسی همزمان نخها به یک دادهٔ مشترک ایجاد میشود. اما گاهی میخواهید داده را ایزوله نگه دارید تا هر نخ Copy جداگانهٔ خودش را داشته باشد. Local Variableها دقیقاً همین کار را انجام میدهند، ولی فقط برای دادههای موقتی مفیدند.
راهحل، Thread-Local Storage است. شاید پیدا کردن یک نیاز واضح برای آن دشوار باشد، چون دادهای که میخواهید به یک نخ محدود بماند معمولاً ذاتاً موقتی است. کاربرد اصلی آن نگهداری دادههای «خارج از باند» (Out-of-Band) است؛ دادههایی که زیرساخت Execution Path را پشتیبانی میکنند، مانند Messaging، Transaction و Security Tokenها. عبور دادن چنین دادهای در پارامترهای متد میتواند دستوپاگیر باشد و روشهایی را که تحت کنترل شما نیستند از این داده محروم کند؛ و ذخیرهسازی آن در Static Field عادی هم یعنی اشتراک بین همهٔ نخها.
Thread-Local Storage همچنین برای بهینهسازی کد Parallel مفید است. هر نخ میتواند Version خودش از یک شیء Thread-Unsafe را بدون Lock و بدون نیاز به ساخت دوبارهٔ آن شیء بین فراخوانیهای متد، بهصورت انحصاری استفاده کند.
چهار روش برای پیادهسازی Thread-Local Storage وجود دارد که در زیربخشهای بعد بررسی میکنیم.
[ThreadStatic]
سادهترین روش این است که یک Static Field را با Attribute به نام ThreadStatic علامتگذاری کنید:
[ThreadStatic] static int _x;
پس از آن هر نخ یک Copy جداگانه از _x میبیند.
متأسفانه [ThreadStatic] با Instance Fieldها کار نمیکند و عملاً هیچ اثری روی آنها ندارد. همچنین با Field Initializerها خوب کار نمیکند، چون Initializer فقط یک بار و روی نخی اجرا میشود که هنگام اجرای Static Constructor فعال است. اگر به Instance Field نیاز دارید یا میخواهید مقدار اولیهٔ غیرپیشفرض داشته باشید، ThreadLocal<T> گزینهٔ بهتری است.
ThreadLocal<T>
ThreadLocal<T> برای Static Fieldها و Instance Fieldها Thread-Local Storage فراهم میکند و اجازه میدهد مقدار پیشفرض تعیین کنید.
در اینجا یک ThreadLocal<int> میسازیم که مقدار پیشفرض آن برای هر نخ ۳ است:
static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);
سپس برای دریافت یا تنظیم مقدار Thread-Local از Property به نام Value روی _x استفاده میکنید. مزیت دیگر ThreadLocal این است که مقدارها بهصورت Lazy ارزیابی میشوند؛ Factory Function در اولین فراخوانی برای هر نخ اجرا میشود.
ThreadLocal<T> و Instance Fieldها
ThreadLocal<T> برای Instance Fieldها و Captured Local Variableها نیز مفید است. برای نمونه، مسئلهٔ تولید عدد تصادفی در محیط چندنخی را در نظر بگیرید. کلاس Random Thread-Safe نیست، بنابراین یا باید هنگام استفاده از آن Lock بگیریم ـ که Concurrency را محدود میکند ـ یا برای هر نخ یک Random جدا بسازیم. ThreadLocal<T> راه دوم را آسان میکند:
var localRandom = new ThreadLocal<Random>(() => new Random());
Console.WriteLine (localRandom.Value.Next());
Factory Function ما برای ساخت Random کمی سادهانگارانه است، چون سازندهٔ بدون پارامتر Random برای Seed به ساعت سیستم تکیه میکند. این مقدار ممکن است برای دو Random که در فاصلهای حدود ۱۰ میلیثانیه ساخته میشوند یکسان باشد. یکی از راههای اصلاح:
var localRandom = new ThreadLocal<Random>
( () => new Random (Guid.NewGuid().GetHashCode()) );
در فصل ۲۲، در مثال Parallel Spellchecking مربوط به PLINQ، از این تکنیک استفاده میشود.
GetData و SetData
روش سوم استفاده از دو متد GetData و SetData در کلاس Thread است. این متدها داده را در «Slot»های مخصوص هر نخ نگه میدارند. Thread.GetData از Data Store ایزولهٔ نخ میخواند و Thread.SetData در آن مینویسد. هر دو متد برای شناسایی Slot به یک LocalDataStoreSlot نیاز دارند. میتوانید همان Slot را در تمام نخها استفاده کنید و با این حال هر نخ مقدار جداگانهٔ خودش را خواهد داشت:
class Test
{
// The same LocalDataStoreSlot object can be used across all threads.
LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot ("securityLevel");
// This property has a separate value on each thread.
int SecurityLevel
{
get
{
object data = Thread.GetData (_secSlot);
return data == null ? 0 : (int) data; // null == uninitialized
}
set { Thread.SetData (_secSlot, value); }
}
...
}
در این نمونه، Thread.GetNamedDataSlot را صدا زدیم که یک Slot نامدار میسازد؛ در نتیجه Slot میتواند در کل Application به اشتراک گذاشته شود. روش دیگر این است که Scope یک Slot را خودتان با Slot بینامی که از Thread.AllocateDataSlot میگیرید کنترل کنید:
class Test
{
LocalDataStoreSlot _secSlot = Thread.AllocateDataSlot();
...
Thread.FreeNamedDataSlot یک Named Data Slot را در همهٔ نخها آزاد میکند، اما فقط زمانی که تمام Referenceها به آن LocalDataStoreSlot از Scope خارج شده و Garbage-Collected شده باشند. این تضمین میکند تا وقتی نخها Reference مربوط به Slot موردنیاز را نگه داشتهاند، Slot ناگهان از زیر پایشان خارج نشود.
AsyncLocal<T>
روشهای Thread-Local Storage که تا اینجا توضیح دادیم با Asynchronous Functionها سازگار نیستند، چون پس از await ممکن است Execution روی نخ دیگری Resume شود. کلاس AsyncLocal<T> این مسئله را با حفظ مقدار در طول await حل میکند:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
async void Main()
{
_asyncLocalTest.Value = "test";
await Task.Delay (1000);
// The following works even if we come back on another thread:
Console.WriteLine (_asyncLocalTest.Value); // test
}
AsyncLocal<T> همچنان میتواند Operationهایی را که روی نخهای جدا آغاز شدهاند از یکدیگر تفکیک کند، چه با Thread.Start شروع شده باشند و چه با Task.Run. کد زیر «one one» و «two two» را مینویسد:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
// Call Test twice on two concurrent threads:
new Thread (() => Test ("one")).Start();
new Thread (() => Test ("two")).Start();
}
async void Test (string value)
{
_asyncLocalTest.Value = value;
await Task.Delay (1000);
Console.WriteLine (value + " " + _asyncLocalTest.Value);
}
AsyncLocal<T> یک ظرافت جالب و منحصربهفرد دارد: اگر یک شیء AsyncLocal<T> هنگام شروع یک نخ از قبل مقدار داشته باشد، نخ جدید آن مقدار را «به ارث» میبرد:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
_asyncLocalTest.Value = "test";
new Thread (AnotherMethod).Start();
}
void AnotherMethod() => Console.WriteLine (_asyncLocalTest.Value); // test
اما نخ جدید یک Copy از مقدار دریافت میکند، بنابراین تغییراتی که خودش اعمال کند روی Original اثر نمیگذارد:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
_asyncLocalTest.Value = "test";
var t = new Thread (AnotherMethod);
t.Start(); t.Join();
Console.WriteLine (_asyncLocalTest.Value); // test (not ha-ha!)
}
void AnotherMethod() => _asyncLocalTest.Value = "ha-ha!";
به خاطر داشته باشید که نخ جدید یک Shallow Copy از مقدار دریافت میکند. بنابراین اگر AsyncLocal<string> را با AsyncLocal<StringBuilder> یا AsyncLocal<List<string>> جایگزین کنید، نخ جدید میتواند StringBuilder را پاک کند یا به List<string> آیتم اضافه/حذف کند و این تغییر روی Original هم اثر خواهد گذاشت.
Timerها
اگر لازم است متدی را در فاصلههای منظم بارها اجرا کنید، سادهترین راه استفاده از Timer است. Timerها در مصرف حافظه و منابع راحت و کارآمدند، بهویژه در مقایسه با تکنیکهایی مانند کد زیر:
new Thread (delegate() {
while (enabled)
{
DoSomeAction();
Thread.Sleep (TimeSpan.FromHours (24));
}
}).Start();
این روش نهتنها یک Thread Resource را دائماً اشغال میکند، بلکه بدون کدنویسی اضافی، DoSomeAction هر روز کمی دیرتر اجرا خواهد شد. Timerها این مشکلات را حل میکنند.
.NET پنج Timer ارائه میکند. دو مورد Timer چندنخی General-Purpose هستند:
System.Threading.Timer
System.Timers.Timer
دو مورد دیگر Timer تکنخی Special-Purpose هستند:
System.Windows.Forms.Timer ـ Timer مربوط به Windows Forms
System.Windows.Threading.DispatcherTimer ـ Timer مربوط به WPF
Timerهای چندنخی قدرتمندتر، دقیقتر و انعطافپذیرترند؛ Timerهای تکنخی برای اجرای کارهای سادهای که Controlهای Windows Forms یا Elementهای WPF را بهروزرسانی میکنند امنتر و راحتتر هستند.
در نهایت، از .NET 6 کلاس PeriodicTimer نیز وجود دارد که ابتدا آن را بررسی میکنیم.
PeriodicTimer
PeriodicTimer در واقع Timer به معنای سنتی نیست؛ کلاسی است برای کمک به Loopهای Asynchronous. مهم است بدانیم از زمان معرفی async و await، Timerهای سنتی معمولاً ضروری نیستند. الگوی زیر بهخوبی کار میکند:
StartPeriodicOperation();
async void StartPeriodicOperation()
{
while (true)
{
await Task.Delay (1000);
Console.WriteLine ("Tick"); // Do some action
}
}
PeriodicTimer این الگو را ساده میکند:
var timer = new PeriodicTimer (TimeSpan.FromSeconds (1));
StartPeriodicOperation();
// Optionally dispose timer when you want to stop looping.
async void StartPeriodicOperation()
{
while (await timer.WaitForNextTickAsync())
Console.WriteLine ("Tick"); // Do some action
}
PeriodicTimer همچنین اجازه میدهد با Dispose کردن Instance Timer آن را متوقف کنید. در این حالت WaitForNextTickAsync مقدار false برمیگرداند و Loop پایان مییابد.
Timerهای چندنخی (Multithreaded Timers)
System.Threading.Timer سادهترین Timer چندنخی است: فقط یک Constructor و دو Method دارد. در مثال زیر، Timer پس از پنج ثانیه متد Tick را فراخوانی میکند که «tick...» را مینویسد، و سپس هر یک ثانیه این کار را تکرار میکند تا کاربر Enter بزند:
using System;
using System.Threading;
// First interval = 5000ms; subsequent intervals = 1000ms
Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
Console.ReadLine();
tmr.Dispose(); // This both stops the timer and cleans up.
void Tick (object data)
{
// This runs on a pooled thread
Console.WriteLine (data); // Writes "tick..."
}
میتوانید بعداً Interval یک Timer را با متد Change تغییر دهید. اگر میخواهید Timer فقط یک بار Fire شود، در آخرین آرگومان Constructor مقدار Timeout.Infinite بدهید.
.NET کلاس Timer دیگری با همین نام در Namespace به نام System.Timers دارد. این کلاس در واقع System.Threading.Timer را Wrap میکند و در حالی که از همان Engine زیرین استفاده میکند امکانات راحتتری ارائه میدهد:
- پیادهسازی
IComponent، تا بتوان آن را در Component Tray مربوط به Designer ویژوال استودیو قرار داد.
- Property به نام
Interval بهجای متد Change.
- Event به نام
Elapsed بهجای Callback Delegate.
- Property به نام
Enabled برای Start و Stop کردن Timer؛ مقدار پیشفرض آن false است.
- متدهای
Start و Stop در صورتی که استفاده از Enabled گیجکننده باشد.
- Flag به نام
AutoReset برای مشخصکردن Event تکرارشونده؛ مقدار پیشفرض آن true است.
- Property به نام
SynchronizingObject با متدهای Invoke و BeginInvoke برای فراخوانی امن متدها روی Elementهای WPF و Controlهای Windows Forms.
مثال:
using System;
using System.Timers; // Timers namespace rather than Threading
var tmr = new Timer(); // Doesn't require any args
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed; // Uses an event instead of a delegate
tmr.Start(); // Start the timer
Console.ReadLine();
tmr.Stop(); // Stop the timer
Console.ReadLine();
tmr.Start(); // Restart the timer
Console.ReadLine();
tmr.Dispose(); // Permanently stop the timer
void tmr_Elapsed (object sender, EventArgs e)
=> Console.WriteLine ("Tick");
Timerهای چندنخی از Thread Pool استفاده میکنند تا تعداد کمی نخ بتواند Timerهای زیادی را سرویس دهد. بنابراین Callback Method یا Event به نام Elapsed ممکن است هر بار روی نخ متفاوتی Fire شود. افزون بر این، Event به نام Elapsed همیشه تقریباً سر وقت Fire میشود، فارغ از اینکه اجرای Event قبلی تمام شده باشد یا نه. بنابراین Callbackها و Event Handlerها باید Thread-Safe باشند.
دقت Timerهای چندنخی به سیستمعامل بستگی دارد و معمولاً در محدودهٔ ۱۰ تا ۲۰ میلیثانیه است. اگر به دقت بالاتری نیاز دارید، میتوانید از Native Interop استفاده کنید و Windows Multimedia Timer را فراخوانی کنید که تا یک میلیثانیه دقت دارد و در winmm.dll تعریف شده است. ابتدا timeBeginPeriod را صدا میزنید تا به سیستمعامل بگویید به Timing دقیق نیاز دارید، سپس timeSetEvent را برای Start کردن Multimedia Timer فراخوانی میکنید. در پایان، timeKillEvent Timer را متوقف میکند و timeEndPeriod اعلام میکند که دیگر Timing با دقت بالا لازم نیست. فصل ۲۴ فراخوانی External Methodها با P/Invoke را نشان میدهد. مثالهای کامل Multimedia Timer را میتوان با جستوجوی کلیدواژههای dllimport winmm.dll timesetevent پیدا کرد.
Timerهای تکنخی (Single-Threaded Timers)
.NET Timerهایی ارائه میکند که برای حذف مسائل Thread-Safety در برنامههای WPF و Windows Forms طراحی شدهاند:
System.Windows.Threading.DispatcherTimer برای WPF
System.Windows.Forms.Timer برای Windows Forms
هر دو Timer از نظر Memberهایی که ارائه میدهند شبیه System.Timers.Timer هستند: Interval، Start و Stop، و Event به نام Tick که معادل Elapsed است. شیوهٔ استفاده نیز مشابه است؛ اما نحوهٔ کار داخلی آنها تفاوت دارد. بهجای Fire کردن Eventهای Timer روی Pooled Threadها، Event را به Message Loop مربوط به WPF یا Windows Forms Post میکنند.
در نتیجه Event به نام Tick همیشه روی همان نخی Fire میشود که در ابتدا Timer را ساخته است؛ در یک Application عادی همان نخی که تمام Elementها و Controlهای UI را مدیریت میکند. این رفتار چند مزیت دارد:
- میتوانید نگرانی دربارهٔ Thread Safety را کنار بگذارید.
- یک
Tick جدید هرگز پیش از پایان پردازش Tick قبلی Fire نمیشود.
- میتوانید Elementها و Controlهای UI را مستقیماً از کد Event Handler مربوط به
Tick بهروزرسانی کنید، بدون فراخوانی Control.BeginInvoke یا Dispatcher.BeginInvoke.
بنابراین برنامهای که از این Timerها استفاده میکند در واقع Multithreaded نیست؛ نوعی Pseudo-Concurrency مشابه چیزی خواهید داشت که در فصل ۱۴ برای Asynchronous Functionهای اجراشونده روی UI Thread توضیح داده شد. یک نخ همهٔ Timerها و همچنین پردازش Eventهای UI را سرویس میدهد؛ در نتیجه Event Handler مربوط به Tick باید سریع اجرا شود، وگرنه UI غیرپاسخگو خواهد شد.
این ویژگی Timerهای WPF و Windows Forms را برای کارهای کوچک ـ معمولاً بهروزرسانی بخشی از UI مانند ساعت یا Countdown ـ مناسب میکند.
از نظر دقت، Timerهای تکنخی شبیه Timerهای چندنخیاند و دقتشان در حد دهها میلیثانیه است، هرچند معمولاً کمی کمدقتترند، چون ممکن است در زمان پردازش درخواستهای دیگر UI یا Eventهای Timer دیگر به تأخیر بیفتند.