Переключение контекста
Под "многозадачностью" большинство пользователей подразумевает возможность параллельного выполнения нескольких приложений: чтобы в фоне играл WinAmp, скачивался mp3 с Интернета, принималась почта, редактировалась электронная таблица и т.д. Минимальной единицей исполнения в Windows является поток. Потоки объединяются в процессы, а процессы – в задания (jobs). Каждый поток обладает собственным стеком и набором регистров, но все потоки одного процесса выполняются в едином адресном пространстве и обладают идентичными квотами.
В любой момент времени на данном процессоре может выполняться только один поток и если количество потоков превышает количество установленных процессоров, потоки вынуждены сражаться за процессорное время. Распределением процессорного времени между потоками занимается ядро. Вытесняющая многозадачность, реализованная в Windows NT, устроена приблизительно так: каждому потоку выдается определенная порция машинного времени, называемая квантом (quantum), по истечении которой планировщик
(dispatcher) принудительно переключает процессор на другой поток. Учет процессорного времени обеспечивается за счет таймера. Периодически (раз в 10 мс или 15 мс) таймер генерирует аппаратное прерывание, приказывающее процессору временно приостановить выполнение текущего потока и передать бразды правления диспетчеру. Диспетчер уменьшает квант потока на некоторую величину (обычно равную двум) и либо возобновляет выполнение потока, либо (если квант обратился в нуль) сохраняет регистры потока в специальной области памяти, называемой контекстом (context), находит поток, больше всего нуждающийся в процессорном времени, восстанавливает его контекст вместе с контекстом процесса (если этот поток принадлежит другому процессу), и передает ему управление.
Потоки обрабатываются по очереди в соответствии с их приоритетом и принятой стратегией планирования. Планировщик сложным образом манипулирует с очередью, повышая приоритеты потоков, которые слишком должно ждут процессорного времени, только что получили фокус управления или дождались завершения операции ввода/вывода. Алгоритм планирования непрерывно совершенствуется, однако, не все усовершенствования оказывают благоприятное влияние на производительность. В общем случае, многопоточные приложения должны исполнятся на тех ядрах, под стратегию планирования которых они оптимизировались, в противном можно нарваться на неожиданное падение производительности.
При небольшом количестве потоков накладные расходы на их переключения довольно невелики и ими можно пренебречь, но по мере насыщения системы они стремительно растут! На что же расходуется процессорное время? Прежде всего на служебные нужны самого планировщика (анализ очереди, ротацию приоритетов и т. д.), затем на сохранение/восстановление контекста потоков и процессов. Посмотрим, как все это устроено изнутри?
Дизассемблирование показывает, что планировщик как бы размазан по всему ядру. Код, прямо или косвенно связанный с планированием, рассредоточен по десяткам функций, большинство из которых не документированы и не экспортируются. Это существенно затрудняет сравнение различных ядер друг с другом, но не делает его невозможным. Чуть позже мы покажем, как можно выделить подпрограммы профилировщика из ядра, пока же сосредоточимся на переключении и сохранении/восстановлении контекста.
Процессоры семейства x86 поддерживают аппаратный механизм управления контекстами, автоматически сохраняя/восстанавливая все регистры при переключении на другую задачу, но Windows не использует его, предпочитая обрабатывать каждый из регистров вручную. Какое-то время автор думал, что I486C-ядро, ничего не знающее о MMX/SSE регистрах современных процессоров, и не включающее их в контекст, будет выигрывать в скорости, однако параллельная работа двух и более мультимедийных приложений окажется невозможной. В действительности же оказалось, что за сохранение/восстановление регистров сопроцессора (если его можно так назвать) отвечают машинные команды FXSAVE/FXSTOR, обрабатывающие и MMX/SSE регистры тоже, но чтобы выяснить это пришлось перерыть все ядро – от HAL'а до исполнительной системы!
Переключение контекста осуществляется служебной функцией SwapContext, реализованной в ntoskrnl.exe. Это чисто внутренняя функция и ядро ее не экспортирует. Тем не менее она присутствует в символьных файлах (symbol file), бесплатно распространяемых фирмой Microsoft. Полный комплект занимает порядка 150 Мб и неподъемно тяжел для модемного скачивания. Ряд утилит, таких, например, как Symbol Retriever, от NuMega, позволяют выборочно скачивать необходимые символьные файлы вручную, значительно сокращая время перекачки, однако, по непонятным причинам они то работают, то нет (Microsoft блокирует доступ?), поэтому, необходимо уметь находить точку входа в SwapContext самостоятельно. Это легко. SwapContext единственная, кто может приводить к синему экрану смерти с надгробной надписью "ATTEMPTED_SWICH_FROM_DPC", которой соответствует BugCheck код B8h. Загрузив ntoskrnl.exe в ИДУ (или любой другой дизассемблер), перечислим все перекрестные ссылки, ведущие к функциям KeBugCheck и KeBugCheckEx. В какой-то из них мы найдем PUSH B8h/CALL KeBugCheck или что-то в этом роде. Она-то и будет функцией SwapContex. Прокручивая экран дизассемблера вверх, мы увидим вызов HalRequestSoftwareInterrupt, которая, собственно, и переключает контекст, а в многопроцессорной версии ядра еще и машинную команду FXSAVE, которая тут совсем ни к чему и которая отсутствует в монопроцессорной версии. К тому же многопроцессорные версии намного щепетильнее относятся к вопросам синхронизации и потому оказывается несколько менее производительными.
Функция HalRequestSoftwareInterrupt, реализованная в HAL, через короткий патрубок соединяется с функциями _HalpDispatchInterrupt/_HalpDispatchInterrupt, cохраняющими/восстанавливающими регистры в своих локальных переменных (не в контексте потока!) и на определенном этапе передающих управление на KiDispatchInterrupt, вновь возвращающую нас в ntoskrnl.exe и рекурсивно вызывающую SwapContext. Кто же тогда сохраняет/восстанавливает контексты? Оказывается – аппаратные обработчики. Список указателей на предустановленные обработчики находится в ntoskrnl.exe и содержится в переменной IDT (не путать с IDT-таблицей процессора!), которая, как и следовало ожидать, не экспортируется ядром, но присутствует в символьных файлах. При их отсутствии найти переменную IDT можно так: просматривая таблицу прерываний любых из ядерных отладчиков (Soft-Ice, Microsoft Kernel Debugger), определите адреса нескольких не переназначенных обработчиков прерываний (т. е. таких, которые указывают на ntoskrnl.exe, а не к драйверу) и, загрузив ntoskrnl.exe в дизассемблер, восстановите перекрестные ссылки, ведущие к ним. Это и будет структурой IDT.
Другие функции так же могут сохранять/восстанавливать текущий контекст (это, в частности, делает Kei386EoiHelper, расположенная в ntoskrnl.exe), поэтому накладные расходы на переключение между потоками оказываются достаточно велики и выливаются в тысячи и тысячи команд машинного кода, причем, каждое ядро имеет свои особенности реализации. Как оценить насколько одно из них производительнее другого?
Логично, если мы уговорим ядро переключать контексты так быстро, как только это возможно, то количество переключений в единицу времени и определит вклад накладных расходов в общее быстродействие ядра. Сказано – сделано. Создаем большое количество потоков (по меньшей мере сто или даже триста) и каждый из них заставляем циклически вызывать функцию Sleep(0), приводящую к отдаче квантов времени и как следствие – немедленному переключению на другой поток. Количество переключений контекста можно определить по содержимому специального счетчика производительности, отображаемого Системным Монитором, утилитой CPUMon Марка Руссиновича, отладчиком Microsoft Kernel Debugger и многими другими программами.
thread()
{
// отдаем процессорное время в бесконечном цикле
while(1) Sleep(0);
}
#define defNthr 300
#define argNthr ((argc > 1)?atol(argv[1]):defNthr)
main(int argc, char **argv)
{
int a, zzz;
printf("creating %d threads...", argNthr);
// создаем argNthr потоков
for (a = 0; a < argNthr; a++) CreateThread(0, 0, (void*)thread, 0,0, &zzz);
printf("OK\n"); thread();
return 0;
}