Timer chạy code sau một khoảng trễ hoặc lặp lại theo chu kỳ: làm mới cache mỗi phút, lưu bản nháp mỗi 30 giây, hỏi trạng thái một dịch vụ, cập nhật đồng hồ. .NET có nhiều lớp timer trông giống nhau nhưng khác nhau ở chỗ callback chạy ở đâu và bạn điều khiển chúng thế nào. Trang này nói về từng lớp, cùng với Stopwatch để đo một việc mất bao lâu.
Nên dùng timer nào
| Timer | Callback chạy trên | Kiểu dùng | Dùng cho |
|---|---|---|---|
System.Threading.Timer | Thread pool | Delegate callback | Việc chạy nền trong dịch vụ và thư viện |
System.Timers.Timer | Thread pool (mặc định) | Sự kiện Elapsed, Start/Stop | Code kiểu component, thích dùng sự kiện |
PeriodicTimer (.NET 6+) | Nơi vòng lặp async của bạn chạy | await WaitForNextTickAsync() | Code async; các tick không bao giờ chồng nhau |
System.Windows.Forms.Timer, DispatcherTimer của WPF | Luồng giao diện | Sự kiện Tick | Cập nhật control trong ứng dụng desktop |
Hai lớp đầu chạy trên các luồng của thread pool, nên callback của chúng có thể chạy cùng lúc với phần còn lại của chương trình, thậm chí cùng lúc với nhau. Các timer giao diện chạy trên luồng giao diện, vì vậy chúng được chạm trực tiếp vào control, và cũng vì vậy một trình xử lý chậm sẽ làm cửa sổ bị treo.
System.Threading.Timer
Hàm khởi tạo nhận callback, một đối tượng trạng thái, khoảng trễ trước tick đầu tiên và chu kỳ. Timer bắt đầu chạy ngay.
Đầu ra mẫu:
tick 1 at ~200 ms
tick 2 at ~500 ms
tick 3 at ~800 ms
tick 4 at ~1100 ms
stopped after 4 ticks
Có hai chi tiết quan trọng ở đây. Main phải chờ (done.WaitOne()); chương trình console kết thúc khi Main trả về, và callback của timer chạy trên các luồng nền không giữ được tiến trình sống. Ngoài ra timer nằm trong một field tĩnh: một System.Threading.Timer không còn gì tham chiếu tới có thể bị bộ thu gom rác thu hồi, và khi đó nó lặng lẽ ngừng chạy. Lỗi này hay gặp nhất khi timer được tạo dưới dạng biến cục bộ trong một phương thức đã trả về.
Change(dueTime, period) lên lịch lại cho timer; truyền Timeout.Infinite cho cả hai sẽ tạm dừng nó, và một cặp giá trị mới sẽ chạy lại. Dispose() dừng hẳn timer.
System.Timers.Timer
System.Timers.Timer bọc cùng cơ chế đó trong một API dựa trên sự kiện: đặt Interval, đăng ký Elapsed, rồi gọi Start() và Stop().
Đầu ra mẫu:
autosave #1
autosave #2
autosave #3
autosave stopped
AutoReset = false tạo một timer chạy một lần: nó kích hoạt một lần rồi dừng, và gọi lại Start() sẽ lên lịch cho tick đơn tiếp theo. Enabled = true và false tương đương Start() và Stop(). Ở đây phải viết đầy đủ tên System.Timers.Timer vì System.Threading cũng có một lớp Timer; khi import cả hai namespace, Timer viết trơn sẽ bị mơ hồ và không biên dịch được.
System.Timers.Timer còn có thuộc tính SynchronizingObject để chuyển Elapsed sang luồng giao diện trong WinForms. Trên thực tế, dùng timer của chính framework giao diện sẽ đơn giản hơn.
Callback chồng nhau và chạy trên luồng khác
Cả hai lớp trên đều kích hoạt trên thread pool, đúng lịch, bất kể callback trước đã xong hay chưa. Nếu một callback mất 3 giây mà chu kỳ là 1 giây, sẽ có ba callback chạy cùng lúc. Điều đó kéo theo hai yêu cầu:
- Mọi thứ callback chạm vào phải an toàn đa luồng. Dùng
Interlockedcho bộ đếm (như các ví dụ) và lock cho những gì lớn hơn. - Chặn việc chồng nhau khi điều đó quan trọng. Hoặc bỏ qua một tick khi tick trước vẫn đang chạy, hoặc dùng một cách viết không thể chồng nhau.
Một cách bỏ qua tick bằng Interlocked:
private int running = 0;
private void OnTick(object state)
{
if (Interlocked.Exchange(ref running, 1) == 1) return; // previous tick still busy
try
{
SyncOrders(); // slow work
}
finally
{
Volatile.Write(ref running, 0);
}
}
Ngoại lệ là cái bẫy còn lại. Một ngoại lệ thoát ra khỏi callback của System.Threading.Timer sẽ làm sập tiến trình, còn System.Timers.Timer thì nuốt mất ngoại lệ từ trình xử lý Elapsed, nên lỗi trôi qua mà không ai biết. Hãy bọc thân callback của timer trong try/catch và ghi log.
PeriodicTimer và vòng lặp async
Trong code async, một vòng lặp chờ giữa các lần lặp là kiểu timer rõ ràng nhất: lần chờ tiếp theo chỉ bắt đầu sau khi công việc xong, nên các tick không bao giờ chồng nhau, và ngoại lệ hiện ra ở chỗ bạn bắt được. .NET 6 thêm PeriodicTimer chính cho việc này:
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await SaveDraftAsync();
}
WaitForNextTickAsync trả về false khi timer đã bị dispose, khiến vòng lặp kết thúc, và ném OperationCanceledException khi token bị hủy. Khác với vòng lặp quanh Task.Delay, PeriodicTimer giữ nhịp cố định: nếu công việc mất 2 giây, tick tiếp theo vẫn đến 30 giây sau tick trước, không phải 32 giây.
Trên mọi phiên bản, cùng cách viết đó với Task.Delay vẫn dùng được khi độ lệch nhịp không quan trọng:
Đầu ra mẫu:
poll 1
poll 2
poll 3
poll 4
polling cancelled after 4 rounds
Việc hủy thay cho Stop(): truyền một token vào vòng lặp và hủy nó từ bên ngoài. Xem task để biết thêm về cancellation token.
Đo thời gian bằng Stopwatch
Timer lên lịch cho code; Stopwatch đo code chạy mất bao lâu. Nó đọc một đồng hồ độ phân giải cao chỉ chạy về phía trước, nên đây là công cụ đúng để đo thời gian:
Đầu ra mẫu:
string += 64 ms
StringBuilder 0 ms
Same length: True
High resolution clock: True
Start, Stop, Reset và Restart điều khiển nó; Elapsed là một TimeSpan, còn ElapsedMilliseconds và ElapsedTicks cho con số thô. Tránh đo thời gian bằng DateTime.Now: độ phân giải của nó có thể thô tới 10 đến 15 ms, và nó bị nhảy khi đồng hồ hệ thống được chỉnh. Với benchmark nghiêm túc, khi quá trình khởi động JIT và việc thu gom rác làm sai lệch một lần chạy đơn lẻ, hãy dùng thư viện BenchmarkDotNet.
Độ chính xác của timer
Khoảng thời gian của timer là mức tối thiểu, không phải lời hứa. Trên Windows, độ phân giải mặc định của timer hệ thống là khoảng 15,6 ms, nên một khoảng 10 ms thường kích hoạt mỗi 15 hoặc 16 ms, và trên một máy đang bận, callback trên thread pool còn có thể bắt đầu muộn hơn nữa. Timer phù hợp cho kiểu "khoảng mỗi N giây". Với việc canh thời gian khung hình hoặc media cần chính xác, hãy dùng các API được làm riêng cho việc đó.
Lỗi thường gặp
System.Threading.Timerchỉ được lưu trong biến cục bộ. Nó có thể bị thu gom rác và ngừng chạy; hãy giữ nó trong một field.- Để
Maintrả về. Tiến trình kết thúc và kéo theo cả timer. Timerbị mơ hồ. Khi import cảSystem.ThreadingvàSystem.Timers, hãy viết tên đầy đủ.- Cho rằng các tick không bao giờ chồng nhau. Timer trên thread pool sẽ chồng nhau khi công việc chậm hơn chu kỳ.
- Chạm vào control giao diện từ timer trên thread pool. Dùng timer của framework giao diện hoặc chuyển về luồng giao diện.
- Ngoại lệ không được xử lý trong callback. Chúng làm sập tiến trình (
Threading.Timer) hoặc biến mất (Timers.Timer); hãy bắt và ghi log. - Quên
Dispose. Timer giữ tài nguyên hệ thống và tiếp tục chạy cho đến khi bị dispose.
Câu hỏi thường gặp
Nên dùng timer nào trong C#?
Trong code async trên .NET 6 trở lên, dùng PeriodicTimer với await timer.WaitForNextTickAsync() trong một vòng lặp. Để chạy callback trên thread pool, dùng System.Threading.Timer. Để có một timer dựa trên sự kiện với Start, Stop và sự kiện Elapsed, dùng System.Timers.Timer. Trong WinForms hoặc WPF, hãy dùng timer của framework giao diện để trình xử lý chạy trên luồng giao diện.
Làm sao chạy code mỗi vài giây trong C#?
Tạo một timer với khoảng thời gian mong muốn, ví dụ new System.Threading.Timer(_ => Refresh(), null, 0, 5000) để gọi Refresh ngay bây giờ và sau đó mỗi 5 giây, rồi giữ một tham chiếu tới nó. Trong code async, hãy lặp với PeriodicTimer (.NET 6+) hoặc await Task.Delay(5000); cách dùng vòng lặp không bao giờ chạy hai tick cùng lúc.
Làm sao dừng timer trong C#?
Với System.Timers.Timer, gọi Stop() (hoặc đặt Enabled = false), và gọi Dispose() khi không cần nó nữa. Với System.Threading.Timer, gọi Change(Timeout.Infinite, Timeout.Infinite) để tạm dừng, hoặc Dispose() để dừng hẳn. Một callback đã bắt đầu chạy vẫn có thể chạy xong sau khi bạn dừng timer.
Vì sao System.Threading.Timer của tôi ngừng chạy?
Không còn gì tham chiếu tới timer, nên bộ thu gom rác (garbage collector) đã thu hồi nó và các callback ngừng chạy. Chuyện này xảy ra khi timer được tạo dưới dạng biến cục bộ trong một phương thức đã trả về. Hãy lưu nó trong một field suốt thời gian nó cần chạy.
Làm sao đo thời gian đã trôi qua trong C#?
Dùng System.Diagnostics.Stopwatch: var sw = Stopwatch.StartNew(); ... sw.Stop(); rồi đọc sw.ElapsedMilliseconds hoặc sw.Elapsed. Nó dùng một đồng hồ độ phân giải cao chỉ tăng về phía trước, khác với việc trừ hai giá trị DateTime.Now, vốn có độ phân giải thô hơn và bị nhảy khi đồng hồ hệ thống được chỉnh.