Довольно давно я прочитал статью, автор которой объединил две концепции - многозадачность и объектно-ориентированное программирование. В результате получились так называемые "живые объекты". Идея крайне проста - при инициализации объекта создается отдельный поток и объект в нем живет своей жизнью, а создатель объекта по мере необходимости получает информацию о состоянии объекта из его свойств. Код для такого объекта на С++ выглядит примерно так:
class living_object { ... static DWORD threadHelper(LPVOID); void run(); public: bool animate(); ... }; bool living_object::animate() { ... CreateThread(NULL, 0, ThreadHelper, (LPVOID)this, 0, &threadID); ... } DWORD living_object::threadHelper(LPVOID instance) { ((living_object*)instance)->run(); } void living_object::run() { while(true) { ... Sleep(...); } } |
"Живые объекты" позволяют отказаться от необходимости делить внутреннюю логику объектов, работающих параллельно, на куски, которые создатель объектов должен сам вызывать в определенной последовательности, фактически беря на себя функции диспетчера потоков.
Конечно же нельзя забывать о том, что "живые объекты" привносят в программу проблемы синхронизации доступа к свойствам объекта. Особенно это относится к сложным типам данных наподобие std::vector. Однако было бы ошибкой думать, что базовые типы данных не нуждаются в синхронизации доступа к ним. Хотя на массово распространенных сейчас однопроцессорных системах подобное пренебрежение синхронизацией может не вызывать проблем, но на многопроцессорных системах последствия могут быть самыми неожиданными. Так что лучше не уподобляться тем программистам, которые были уверены, что их программы к 2000-му году уже не будут использоваться.
Одновременное выполнение нескольких потоков только кажется параллельным. На самом деле количество потоков, работающих параллельно, прямо зависит от количества процессоров. Само собой, что на однопроцессорной системе в каждый момент времени работает только один поток. Процессорное время распределяется между потоками диспетчером потоков операционной системы. Конечно же переключение между потоками, так же называемое переключением контекста процессора, не бесплатно с точки зрения процессорного времени. Это связано с тем, что контекст процессора включает в себя некоторое количество информации, например состояние регистров процессора, а перемещение любого количества информации отнимает процессорное время.
Что же вызывает переключение контекста? Вариантов всего два - или поток отдает управление операционной системе добровольно, посредством вызова одной из соответствующих функций, или операционная система сама отбирает управление у потока по истечении минимального разумного времени, называемого time slice.
Теперь перечислю собственно вопросы, побудившие меня провести ряд экспериментов, и, в конечном итоге, написать эту заметку.
Сразу хочу оговориться, что тестовая программа имитирует систему, активно использующую "живые объекты", описанные в начале заметки. Это связано с тем, что меня интересовали вышеперечисленные вопросы применительно именно к "живым объектам". Для многопоточных программ с другой логикой организации работы потоков результаты испытаний могут быть другие. Так же прошу принять во внимание, что замеры не проводились с лабораторной тщательностью, и поэтому нужно сделать скидку на определенную погрешность в цифрах. Для компиляции использовался Visual C++ 6 SP5.
Тестовая программа создает заданное количество "живых объектов", которые все вместе выполняют фиксированный объем вычислений, и замеряет общее время выполнения. Вычисления выглядят следующим образом - в цикле вызывается библиотечная функция rand(), результат которой делится по модулю на некоторое число. Каждый "живой объект" выполняет количество итераций цикла равное общему количеству итераций, заданному для всей программы, поделенному на количество "живых объектов".
Каждые сто итераций цикла "живой объект" вызывает функцию Sleep(0), которая фактически форсирует переключение контекста и передачу управления другому потоку.
ПРЕДУПРЕЖДЕНИЕ Без вызова функции Sleep тестовая программа не отражала бы изменение реальных затрат времени на переключение контекста в зависимости от количества "живых объектов". В этом случае количество переключений контекста примерно равнялось бы продолжительности выполнения программы деленному на размер time slice независимо от количества потоков. И, следовательно, из-за того, что количество переключений контекстов фиксировано, увеличение времени исполнения очень слабо зависит от количества потоков (разница продолжительности выполнения между 2 и 4096 потоками составляет менее 300мс на 2xPIII-1000 под Windows 2000 Professional при общей продолжительности работы программы около 3200мс). |
Тестовая программа была запущена на нескольких конфигурациях, оказавшихся под рукой. Для удобства сравнения результатов выбирались конфигурации с одинаковыми процессорами. Результаты сведены в следующую таблицу:
Кол-во потоков | 2xPIII-1000 Windows NT4 Server (мс|издержки) |
2xPIII-1000 Windows 2000 Professional (мс|издержки) |
PIII-1000 Windows 2000 Professional (мс|издержки) |
PIII-1000 Windows XP Professional (мс|издержки) |
PIII-1000 Windows 98 SE (мс|издержки) |
|||||
---|---|---|---|---|---|---|---|---|---|---|
8192 | 8343 | 53% | 8391 | 57% | 16323 | 58% | 15913 | 50% | ||
4096 | 8500 | 56% | 8328 | 56% | 15172 | 47% | 14961 | 41% | ||
2048 | 8203 | 50% | 7937 | 49% | 14942 | 45% | 14792 | 40% | ||
1024 | 7843 | 44% | 7796 | 46% | 14731 | 43% | 14611 | 38% | 36776 | 208% |
512 | 7562 | 39% | 7593 | 42% | 14431 | 40% | 14411 | 36% | 30632 | 156% |
256 | 7547 | 38% | 7281 | 36% | 13620 | 32% | 14081 | 33% | 25273 | 112% |
128 | 7328 | 34% | 7281 | 36% | 13619 | 32% | 13940 | 32% | 22971 | 92% |
64 | 6671 | 22% | 6609 | 24% | 11917 | 16% | 12348 | 17% | 21254 | 78% |
32 | 6547 | 20% | 6016 | 13% | 10616 | 3% | 10926 | 3% | 19911 | 67% |
16 | 6000 | 10% | 5922 | 11% | 10515 | 2% | 10825 | 2% | 19323 | 62% |
8 | 5984 | 10% | 5875 | 10% | 10515 | 2% | 10805 | 2% | 19184 | 61% |
4 | 5968 | 9% | 5906 | 11% | 10515 | 2% | 10775 | 2% | 19124 | 60% |
2 | 5453 | 0% | 5344 | 0% | 10415 | 1% | 10746 | 1% | 19087 | 60% |
1 | 10703 | 10563 | 10315 | 0% | 10595 | 0% | 11943 | 0% |
Проанализируем таблицу, чтобы получить ответы на наши вопросы.
Для двухпроцессорной системы два потока в программе дают оптимальную и максимально возможную производительность программы. Однако увеличение количества потоков приводит к постоянному увеличению времени выполнения программы на десять и более процентов. С другой стороны, на однопроцессорной системе увеличение количества потоков до 32 практически не сказывается на времени выполнения программы, после чего происходит резкий скачок на промежутке между 32 и 128 потоками, после чего рост продолжается более-менее плавно.
Сильно упрощая и обобщая результаты, можно сказать, что общие потери на переключение контекста процессора не будут превышать шестидесяти процентов на разумном количестве потоков (до 8192). Но даже такой упрощенный вывод необходимо уточнить:
Для более наглядного представления данных упростим вышеприведенную таблицу, объединив данные для двух- и однопроцессорных систем и отбросив результаты, полученные под Windows 98SE:
Кол-во потоков | Двухпроцессорная система (мс) | Однопроцессорная система | Прирост производительности |
---|---|---|---|
8192 | 8367 | 16118 | 93% |
4096 | 8414 | 15067 | 79% |
2048 | 8070 | 14867 | 84% |
1024 | 7820 | 14671 | 88% |
512 | 7578 | 14421 | 90% |
256 | 7414 | 13851 | 87% |
128 | 7305 | 13780 | 89% |
64 | 6640 | 12133 | 83% |
32 | 6282 | 10771 | 71% |
16 | 5961 | 10670 | 79% |
8 | 5930 | 10660 | 80% |
4 | 5937 | 10645 | 79% |
2 | 5399 | 10581 | 96% |
1 | 10633 | 10455 | -2% |
То есть прирост производительности всегда меньше, чем в два раза, независимо от количества потоков. В среднем прирост производительности составляет порядка 85 процентов. Возможно (и даже наверняка) эта цифра будет отличаться для других процессоров, в особенности для линейки Intel Xeon, которая славится улучшенной поддержкой многопоточности, а так же для систем с количеством процессоров больше двух.
Так как мы говорим о Win32, выбор операционных систем не очень велик - линейки Windows 9x/ME и Windows NT/2000/XP. Исходя из имеющихся данных, для Windows NT/2000/XP принципиальной разницы в производительности между всеми комбинациями NT/2000/XP и Workstation/Server нет, хотя, возможно, это будет опровергнуто дальнейшими испытаниями на других конфигурациях.
Результаты для Windows 98 SE говорят сами за себя. Ввиду того, что принципиальных изменений в ядро Windows 95 до сих пор внесено не было, можно смело утверждать, что эти результаты показательны для любой версии Windows 9x/ME.
Разумно предположить, что количество потоков в процессе ограничено ресурсами самого процесса, а так же ресурсами ядра операционной системы.
ПРИМЕЧАНИЕ Все сказанное ниже справедливо для линейки Windows NT/2000/XP. |
Один из основных ресурсов ядра операционной системы, потребляемый при создании потока, это невыгружаемая памяти (non-paged memory) ядра. Создание одного потока требует около 12 килобайт невыгружаемой памяти. Ограничения на размер пула невыгружаемой памяти устанавливается в следующем ключе системного реестра:
HKLM\System\CurrentControlSet\Control\Session Manager\Memory Management |
параметрами NonPagedPoolQuota и NonPagedPoolSize. Их значение по умолчанию равно нулю, что отдает управление этими значениями в руки операционной системы.
То есть при современных объемах памяти крайне сложно выбрать все ресурсы ядра операционной системы, создавая большое количество потоков. Остаются только ресурсы процесса, о чем мы и поговорим подробнее.
Как известно, каждому процессу выделяется адресное пространство в четыре гигабайта, но под свои нужды процесс может употребить только первые два гигабайта. Собственно из этих двух гигабайт и выделяется память под стек для вновь создаваемого потока. Размер стека определяется двумя факторами - параметром /STACK линковщика и параметром dwStackSize функции CreateThread.
Размер стека, заданный параметром dwStackSize, не может быть меньше, чем указано в параметре /STACK линковщика и по умолчанию равен ему. Размер стека, используемый линковщиком по умолчанию равен одному мегабайту. Таким образом максимальное количество потоков, которые можно создать при всех параметрах заданных по умолчанию, равняется примерно 2035. По достижении этого предела функция CreateThread начинает возвращать ошибку ERROR_NOT_ENOUGH_MEMORY, что является истинной правдой - если умножить количество потоков на размер стека по умолчанию, то как раз получается примерно два гигабайта - размер адресного пространства отданный процессу на карманные расходы.
Обойти это ограничение можно указав меньший размер стека параметром /STACK линковщика или в Project Settings (Link/Output/Stack Allocations/Reserve) в Microsoft Visual C++. Размер стека указывается в байтах. Меняя это значение надо быть осторожным ввиду того, что стек используется не только для хранения адресов возврата функций и передачи параметров, но и для хранения локальных переменных. Однако это тема отдельного разговора.
"Живые объекты" предоставляют очень интересные возможности для построения сложных систем. И проведенные тесты дают нам возможность трезво и со значительной степенью точности оценить влияние этой технологии на производительность конечной программы. Потому что лично меня, как программиста, очень нервирует манипулирование категориями "быстро/медленно" или "будет тормозить/не будет тормозить" ;)
Отдельное спасибо хочется сказать Дэну Парновскому, который сделал ряд ценных замечаний в процессе разработки тестовой программы, без которых результаты измерений были бы некорректны.
Так же хочу поблагодарить Константина Князева, чьи комментарии помогли более четко сформулировать некоторые ключевые моменты заметки.