Таймер выполняет код после задержки или с повторяющимся интервалом: обновлять кэш каждую минуту, сохранять черновик каждые 30 секунд, опрашивать сервис, обновлять часы. В .NET несколько классов таймеров, которые похожи, но различаются тем, где выполняется обратный вызов и как ими управлять. На этой странице описан каждый из них, а также Stopwatch для измерения того, сколько что-то заняло времени.
Какой таймер использовать
| Таймер | Где выполняется обратный вызов | Стиль | Для чего |
|---|---|---|---|
System.Threading.Timer | Пул потоков | Делегат обратного вызова | Фоновая работа в сервисах и библиотеках |
System.Timers.Timer | Пул потоков (по умолчанию) | Событие Elapsed, Start/Stop | Код в стиле компонентов, где любят события |
PeriodicTimer (.NET 6+) | Там, где выполняется ваш асинхронный цикл | await WaitForNextTickAsync() | Асинхронный код; такты никогда не перекрываются |
System.Windows.Forms.Timer, DispatcherTimer в WPF | Поток интерфейса | Событие Tick | Обновление элементов управления в настольных приложениях |
Первые два срабатывают в потоках из пула, поэтому их обратные вызовы могут выполняться одновременно с остальной программой и даже друг с другом. Таймеры интерфейса выполняются в потоке интерфейса, поэтому могут напрямую трогать элементы управления, и поэтому медленный обработчик замораживает окно.
System.Threading.Timer
Конструктор принимает обратный вызов, объект состояния, задержку перед первым тактом и период. Таймер запускается сразу.
Пример вывода:
tick 1 at ~200 ms
tick 2 at ~500 ms
tick 3 at ~800 ms
tick 4 at ~1100 ms
stopped after 4 ticks
Здесь важны две детали. Main должен ждать (done.WaitOne()); консольная программа завершается, когда Main возвращает управление, а обратные вызовы таймера выполняются в фоновых потоках, которые не удерживают процесс. И таймер хранится в статическом поле: System.Threading.Timer, на который ничто не ссылается, может быть собран сборщиком мусора и молча перестать срабатывать. Чаще всего на этом попадаются, когда таймер создан как локальная переменная внутри метода, который возвращает управление.
Change(dueTime, period) перепланирует таймер; Timeout.Infinite для обоих значений приостанавливает его, а новая пара значений запускает снова. Dispose() останавливает его навсегда.
System.Timers.Timer
System.Timers.Timer оборачивает тот же механизм в API на событиях: задайте Interval, подпишитесь на Elapsed, затем Start() и Stop().
Пример вывода:
autosave #1
autosave #2
autosave #3
autosave stopped
AutoReset = false делает одноразовый таймер: он срабатывает один раз и останавливается, а повторный вызов Start() планирует следующий одиночный такт. Enabled = true и false это то же самое, что Start() и Stop(). Полное имя System.Timers.Timer здесь выписано потому, что в System.Threading тоже есть Timer; если импортированы оба пространства имён, голый Timer неоднозначен и не компилируется.
У System.Timers.Timer также есть свойство SynchronizingObject, которое переносит Elapsed в поток интерфейса в WinForms. На практике для этого проще собственный таймер фреймворка интерфейса.
Обратные вызовы перекрываются и выполняются в других потоках
Оба класса выше срабатывают в пуле потоков по расписанию, независимо от того, завершился ли предыдущий обратный вызов. Если обратный вызов занимает 3 секунды, а период 1 секунда, одновременно выполняются три вызова. Из этого следуют две обязанности:
- Всё, что трогает обратный вызов, должно быть потокобезопасным. Используйте
Interlockedдля счётчиков (как в примерах) и lock для всего крупнее. - Защищайтесь от перекрытия, когда это важно. Либо пропускайте такт, пока предыдущий ещё выполняется, либо используйте шаблон, который не может перекрываться.
Защита с пропуском через 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);
}
}
Исключения это другая ловушка. Исключение, вырвавшееся из обратного вызова System.Threading.Timer, обрушивает процесс, а System.Timers.Timer глотает исключения из обработчиков Elapsed, так что сбой остаётся незамеченным. Оборачивайте тело обратного вызова таймера в try/catch и пишите в лог.
PeriodicTimer и асинхронные циклы
В асинхронном коде самый понятный таймер это цикл, который ждёт между итерациями: следующее ожидание начинается только после завершения работы, поэтому такты никогда не перекрываются, а исключения появляются там, где их можно перехватить. Именно для этого .NET 6 добавил PeriodicTimer:
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await SaveDraftAsync();
}
WaitForNextTickAsync возвращает false, когда таймер освобождён, что завершает цикл, и выбрасывает OperationCanceledException, когда токен отменён. В отличие от цикла вокруг Task.Delay, PeriodicTimer держит фиксированный ритм: если работа занимает 2 секунды, следующий такт всё равно наступит через 30 секунд после предыдущего такта, а не через 32.
В любой версии та же форма с Task.Delay подходит, когда дрейф не важен:
Пример вывода:
poll 1
poll 2
poll 3
poll 4
polling cancelled after 4 rounds
Отмена заменяет Stop(): передайте токен в цикл и отмените его снаружи. Подробнее о токенах отмены на странице задачи.
Измерение прошедшего времени через Stopwatch
Таймер планирует код; Stopwatch измеряет, сколько код выполнялся. Он читает часы высокого разрешения, которые идут только вперёд, и поэтому это правильный инструмент для замеров:
Пример вывода:
string += 64 ms
StringBuilder 0 ms
Same length: True
High resolution clock: True
Им управляют Start, Stop, Reset и Restart; Elapsed это TimeSpan, а ElapsedMilliseconds и ElapsedTicks дают сырые числа. Не замеряйте время через DateTime.Now: его разрешение может быть грубым, от 10 до 15 мс, и оно прыгает при подстройке системных часов. Для серьёзных бенчмарков, где прогрев JIT и сборка мусора искажают единичный запуск, используйте библиотеку BenchmarkDotNet.
Точность таймеров
Интервал таймера это минимум, а не обещание. В Windows разрешение системного таймера по умолчанию около 15,6 мс, поэтому интервал в 10 мс обычно срабатывает каждые 15 или 16 мс, а на загруженной машине обратный вызов из пула потоков может начаться ещё позже. Таймеры подходят для «примерно каждые N секунд». Для точной синхронизации кадров или медиа используйте API, созданные для этого.
Частые ошибки
System.Threading.Timer, хранящийся только в локальной переменной. Его может собрать сборщик мусора, и он перестанет срабатывать; храните его в поле.- Возврат из
Main. Процесс завершается и забирает таймер с собой. - Неоднозначный
Timer. Когда импортированы иSystem.Threading, иSystem.Timers, пишите полное имя. - Предположение, что такты никогда не перекрываются. Таймеры пула потоков перекрываются, когда работа медленнее интервала.
- Обращение к элементам интерфейса из таймера пула потоков. Используйте таймер фреймворка интерфейса или переносите вызов обратно в поток интерфейса.
- Необработанные исключения в обратных вызовах. Они обрушивают процесс (
Threading.Timer) или пропадают (Timers.Timer); перехватывайте и пишите в лог. - Забытый
Dispose. Таймеры держат системные ресурсы и продолжают срабатывать, пока их не освободят.
Часто задаваемые вопросы
Какой таймер использовать в C#?
В асинхронном коде на .NET 6 или новее PeriodicTimer с await timer.WaitForNextTickAsync() в цикле. Для обратного вызова в пуле потоков System.Threading.Timer. Для таймера на событиях со Start, Stop и событием Elapsed System.Timers.Timer. В WinForms или WPF используйте таймер фреймворка интерфейса, чтобы обработчик выполнялся в потоке интерфейса.
Как выполнять код каждые несколько секунд в C#?
Создайте таймер с интервалом, например new System.Threading.Timer(_ => Refresh(), null, 0, 5000), чтобы вызвать Refresh сейчас и затем каждые 5 секунд, и сохраните ссылку на него. В асинхронном коде крутите цикл с PeriodicTimer (.NET 6+) или await Task.Delay(5000); вариант с циклом никогда не выполняет два такта одновременно.
Как остановить таймер в C#?
Для System.Timers.Timer вызовите Stop() (или установите Enabled = false), а когда он больше не нужен, Dispose(). Для System.Threading.Timer вызовите Change(Timeout.Infinite, Timeout.Infinite), чтобы приостановить его, или Dispose(), чтобы остановить навсегда. Уже начавшийся обратный вызов может завершиться и после остановки таймера.
Почему мой System.Threading.Timer перестаёт срабатывать?
На таймер никто не ссылается, поэтому сборщик мусора его собрал, и обратные вызовы прекратились. Так бывает, когда таймер создан как локальная переменная в методе, который возвращает управление. Храните его в поле, пока он должен работать.
Как измерить прошедшее время в C#?
Используйте System.Diagnostics.Stopwatch: var sw = Stopwatch.StartNew(); ... sw.Stop();, затем читайте sw.ElapsedMilliseconds или sw.Elapsed. Он использует монотонные часы высокого разрешения, в отличие от вычитания двух значений DateTime.Now, у которого разрешение грубее и которое прыгает при подстройке системных часов.