В последнее время не в первый раз сталкиваюсь с тем, что разработчики не до конца понимают как работает один из стандартных таймеров в .NET — System.Threading.Timer.
Т.е. в общем-то они вроде понимают что таймер что-то выполняет, скорее всего в ThreadPool — и если его использовать для периодического выполнения чего-либо, то он вполне подойдет. Но вот если вам надо создать не один таймер, а положим 1000, то тут люди начинают волноваться: а вдруг вот что-то там не так, а вдруг это все-таки 1000 потоков и даже боятся использовать их в таких случаях.
Хотелось бы пролить немного света на этот «таинственный» System.Threading.Timer.
В .NET еще существуют другие таймеры, но они в основном предназначены для решения специфических задач(например, для написания GUI приложений). Нами рассматриваемый предназначен для решения «системных» задач или использования в библиотеках.
Немного о том, как бы мы могли реализовать таймер.
Например, мы могли бы для каждой периодически выполняемой единицы работы создавать отдельный поток который просыпался бы по истечению определенного интервала времени, выполнял работу и засыпал опять. Понятно что это не самый лучший вариант.
Можно было бы пойти другим путем и использовать объект ядра «таймер». Для каждой периодической единицы работы создавать объект ядра и в отдельном потоке ожидать на них в стиле:
WaitHandle.WaitAny(/*timerHandles[]*/)
Но, к сожалению или нет, в .NET нет API для прямой работы с такими объектами(таймерами ядра).
Есть третий вариант реализации таймера(получившийся у разработчиков класса System.Threading.Timer)
При создании первого в домене приложения таймера через механизм P/Invoke создается объект ядра «таймер» это можно увидеть в классе System.Threading.TimerQueue:
[SecurityCritical]
[SuppressUnmanagedCodeSecurity]
[DllImport("QCall", CharSet = CharSet.Unicode)]
private static TimerQueue.AppDomainTimerSafeHandle CreateAppDomainTimer(uint dueTime);
// some code
if (this.m_appDomainTimer == null || this.m_appDomainTimer.IsInvalid)
{
this.m_appDomainTimer = TimerQueue.CreateAppDomainTimer(dueTime);
// some code
Также создается отдельный поток который высчитывает сколько надо подождать до ближайшего срабатывания одного из таймеров, устанавливает соответствующие параметры объекту ядра «таймер» и ждет.
Давайте посмотрим как это выглядит. Создадим консольный проект и подключим SOS Debugging Extension.
Как мы видим, перед созданием таймера у нас всего два потока: «основной» и поток «финализатора». Давайте продвинемся на одну строку ниже.
У нас появились два потока — один, ID 3, это как раз и есть поток который работает с объектом ядра «таймер». А второй, ID 4, это рабочий поток пула, он еще не успел запуститься, в нем будут исполняться наши callback.
Теперь как это все работает если вы последовательно создаете несколько таймеров
Возвращаемся к классу System.Threading.TimerQueue. Он является синглтоном. Каждый раз когда вы пишете код вида:
new Timer(First, null, 0, 250);
Это приводит к добавлению экземпляра класса System.Threading.TimerQueueTimer в его внутреннюю очередь(являющуюся чем-то вроде LinkedList). Т.е. этот класс содержит внутри себя все созданные таймеры(я склоняюсь что в рамках домена).
После того как первый таймер был создан. У TimerQueue будет регулярно вызыватьcя метод FireNextTimers.
Что он делает(код длинный, я не стал приводить исходники, кому интересно может посмотреть сам):
Он быстро пробегается по всем сохраненным в нем таймерам и находит время до ближайшего срабатывания таймера и настраивает объект ядра таймер на посылку нотификации через этот интервал. Как только эта нотификация будет получена, время следующего срабатывания будет пересчитано и объект ядра таймер будет настроен на новый интервал. При добавлении нового таймера время следующей нотификации будет пересчитано.
Давайте попробуем создать 1000 таймеров и посмотрим что из этого получится:
Мы видим, что создание 1000 таймеров не влечет за собой создание 1000 потоков. CLR создало один поток для работы с таймером ядра и несколько рабочих потоков для обработки срабатываний таймера.
Итого:
Когда вы работаете с классом System.Threading.Timer создается один(на домен приложения) объект ядра «таймер» и один поток для работы с ним который работает по принципу схожему с работой структуры данных «куча».
К вопросу о 1000 таймеров — накладно ли создавать такое количество таймеров в приложении, думаю что каждый конкретный случай надо рассматривать отдельно. Но знание того как устроены таймеры изнутри поможет принять правильное решение.
Испытывалось на Windows 7 64, .Net 4.5, VS2012.
Используемая литература: Duffy «Concurrent Programming on Windows», MSDN
Автор: f0bos