То, что вам никто не говорил о многозадачности в Windows
www.dtf.ru
Роман Лут
Многозадачность - это просто?
Когда я только начинал разрабатывать многопоточные приложения, мои мысли были просты и прямолинейны: нужно просто создать второй поток и выполнять в нем какую-либо работу. Потоки будут выполняться одновременно, все сложности на себя берет операционная система, мне только остается воспользоваться несколькими функциями WinAPI.
К сожалению, реально дела обстоят не так просто, и особенно когда это касается real-time приложений (которыми являются компьютерные игры). Чтобы это понять, мне пришлось пройти долгий путь от недоумения (почему все работает нет так, как должно?) к более-менее ясному представлению о том, как на самом деле реализована многозадачность в Windows.
В этой статье я готов поделиться добытой информацией, разоблачить некоторые распространенные заблуждения, наглядно показать изложенные принципы и, в тоже время, не претендуя на полную достоверность, услышать другие мнения.
Основная цель исследования фокусируется на возможности использования многопоточности в играх, и, в частности, в DirectX приложениях. Второй поток предполагается использовать для фоновой загрузки уровней с диска, но забирая не более 10% процессорного времени у основного потока (при запуске фоновых задач fps не должен значительно падать).
О чем НЕ будет идти речь в этой статье
Я не буду рассказывать, почему важна синхронизация между потоками. Я не буду описывать функции и объекты WinAPI, используемые в многопоточных программах. Я даже не буду описывать, какие алгоритмы и шаблоны следует применять в многопоточных приложениях. На эти темы есть масса статей, а я хочу дать ответы на те вопросы, на которые найти ответ очень трудно - нюансы, которые часто сводят на нет всю архитектуру приложения.
Примечание. Термины. Поток - thread. Многопоточность - multithreading. Многозадачность - multitasking.
Часть первая. Принципы работы многозадачности
Начнем с заблуждений. Заблуждение первое - потоки выполняются одновременно
Если вы хоть чуть-чуть знакомы с аппаратной частью и языком ассемблера, то знаете, что процессор изначально рассчитан на исполнение только одного потока команд. У него есть только один регистр-указатель выборки команд и один набор регистров общего назначения.
Упрощенная диаграмма процессора
Процессор не может физически выполнять несколько потоков одновременно (о HyperThreading и DualCore мы поговорим в третьей части статьи. Начнем с простого - с обычного процессора). На самом деле, иллюзию одновременного исполнения для нас создает операционная система.
Есть два основных способа организовать многозадачную ОС:
1. Приложения строятся на основе потока сообщений окна. Каждое приложение обязано в цикле вызывать функцию операционной системы PeekMessage(). Операционная система делает возврат из функции - выдает сообщение (в том числе idle-сообщения) - последовательно всем приложениям по очереди. Пока приложение не завершит обработку сообщения и не вызовет PeekMessage(), ни операционная система, ни другие приложения не выполняются. Таким образом, "переключение" между потоками реально осуществляется функцией PeekMessage() операционной системы. Очевидным недостатком является то, что операционной системой никак не контролируется количество процессорного времени, использованного приложением. Как только одно из приложений "зависает" и не вызывает функцию PeekMessage(), перестают работать все остальные приложения, включая операционную систему. Остается только сделать Reset. Понятно, что о "одновременном исполнении потоков" здесь говорить не приходится.
Несмотря на существенные недостатки, благодаря простоте реализации такая система часто применяется в операционных системах для портативных устройств, например - PalmOS.
2. В серьезных ОС используется так называемая "вытесняющая многозадачность". При инициализации ядро операционной системы настраивает таймер для вызова аппаратного прерывания через определенные промежутки времени (quantum). В обработчике аппаратного прерывания ОС может сохранить полное состояние процессора (регистры), восстановить состояние для другого потока, и передать ему управление. Поток не обязан вызывать какие-либо функции, и даже может исполнять бесконечный цикл - операционная система все равно прервет исполнение, и выделит другому потоку некоторое количество процессорного времени (называемое "time slice").
Налицо преимущества: зависание одного потока не приводит к зависанию других потоков и операционной системы. Операционная система даже может в любой момент уничтожить поток, который посчитает "зависшим".
Нюансом здесь является период времени вызова аппаратного прерывания (quantum) и количество времени, выделяемого каждому потоку (time slice). Этой информации вы не найдете ни в одной документации, и тому есть причина: Microsoft Windows не претендует на звание "Real-time OS". Просто гарантируется, что все будет нормально работать для обычных (читай: офисных) приложений.
Эти временные промежутки отличаются в разных версиях Windows, и для Windows XP составляют quantum=10ms, time slice = 130ms (!). Здесь разработчик игр уже должен насторожиться, т.к. при 50 FPS длина кадра составляет 20ms.
Примечание. Вопреки распространенному заблуждению, Windows 95/98 построены именно по второй схеме. Просто в этих системах есть масса объектов, требующих эксклюзивного доступа (наследие ДОС), и если поток "завиcает", захватив и не освободив один такой объект, то никакая вытесняющая многозадачность не поможет предотвратить зависание других потоков и всей системы в целом.
Приложение ThreadTest
Для экспериментов я написал специальное приложение (исходные коды прилагаются к статье [1]). Оно содержит набор тестов, иллюстрирующих приведенные утверждения.
Завершите все лишние приложения и запустите Тест №1.
Тест №1. Один поток (основной поток приложения) в цикле вычисляет обратную матрицу 4x4, при этом в специальный массив записывая отсчеты времени (но не чаще 10 микросекунд). Выводя отсчеты на timeline, мы можем отчетливо видеть, когда поток выполнялся, а когда простаивал, прерванный операционной системой. В результате также выводится общее количество вычисленных обратных матриц, и скорость матриц/сек (нас интересует именно последнее)
У вас должно получиться как показано на рисунке. Поток выполнялся практически непрерывно, изредка прерываясь операционной системой на выполнение служебных задач и других потоков (здесь мы также можем заметить, что промежутки между перерывами равны примерно 10ms, то есть quantum).
Примечание. Для выполнения тестов следует использовать компьютер с одним процессором, без технологии HT, или выключить HT в BIOS.
Здесь у неопытного программиста сразу может возникнуть вопрос: но ведь наш поток - не единственный, в системе их множество. Судя по графику, наш поток выполнялся практически непрерывно. Получается, остальные потоки не работают?
Дело в том, что у остальных потоков нет работы, и они, быстро проверив несколько флагов, отдают свое процессорное время, используя функции Sleep(), WaitForObject() и т.д. Соответственно, перебрав все потоки, ОС переключается обратно на наш поток. Этим и объясняются небольшие пробелы в выполнении нашего потока. Эта концепция является основополагающей в Windows, что позволяет запускать большое количество фоновых потоков без видимого влияния на производительность приложений.
Для сравнения, запустите архиватор WinRar запаковывать большой файл, и повторите Тест №1.
Работа потока, если на фоне запущен WinRar
На этот раз мы можем наблюдать значительные пробелы в работе потока. Он периодически прерывается на 130ms (time slice) - очевидно, свое процессорное время использует WinRar. Соответственно, наше приложение снизило скорость выполнения "полезной" работы (вычисление матриц) на 25%.
Наше приложение получает в 3 раза больше времени, т.к. его окно является активным. Если запустить тест, и быстро переключиться на WinRar, картина меняется на противоположную:
Работа потока фонового приложения
Теперь очевидна потребность закрывать другие приложения при запуске 3D-игр - многие из них могут быть не настолько любезны, и могут не всегда быстро отдавать свое процессорное время (как известно, ICQ - враг плавного FPS).
Мы можем наглядно увидеть работу потоков, запустив тесты 2 (два потока с нормальным приоритетом) и 3 (4 потока с нормальным приоритетом).
Тест №2. Два потока с нормальным приоритетом
Тест №3. Четыре потока с нормальным приоритетом
Здесь следует отметить, что сумма скоростей выполнения "полезной" работы равна скорости работы одного потока. Тут все понятно - как ни крути - процессор то у нас один!
|