Создание программного обеспечения для многоядерных систем
В этой главе:
Если вы уже знакомы с построением загрузочного образа для однопроцессорной системы, вы можете внести изменения в файл сборки и создать образ многоядерной системы. Подробнее см. в разделе Создание загрузочного образа.
Как было отмечено ранее, для этого достаточно указать многоядерную версию модуля procnto-* ( procnto-smp-*) в файле построения загрузочного образа:
[virtual=x86,bios] .bootstrap = { startup-bios PATH=/proc/boot procnto-smp-ksz } [+script] .script = { devc-con -e & reopen /dev/con1 [+session] PATH=/proc/boot esh & } libc.so [type=link] /usr/lib/ldqnx.so.2=/proc/boot/libc.so [data=copy] devc-con esh ls
После сборки образа дальнейшие действия по созданию многоядерной и одноядерной систем не отличаются друг от друга.
Несмотря на то, что для запуска системы в многоядерном режиме требуется минимум действий по настройке процессора, симметричная многопроцессорная обработка оказывает огромное влияние на работу программ.
Главный аспект заключается в том, что параллельное выполнение потоков является абстракцией в системе с одним процессором, но физически реализовано в многоядерной системе! Кроме того, ограничение использования вычислительных ресурсов позволяет выполнять потоки на конкретных процессорах.
В этом разделе мы рассмотрим влияние многоядерной платформы на проектирование целевой системы.
Можно использовать одноядерный вариант ЗОСРВ «Нейтрино» на многоядерной платформе. В этом случае все программы выполняются на процессоре с номером 0
. Разумеется, при этом другие процессоры простаивают, однако однопроцессорные образы вполне способны работать на многоядерном оборудовании.
Также можно запускать многоядерную версию ЗОСРВ «Нейтрино» в однопроцессорной системе. При этом также будет доступно только одно ядро процессора.
Часто возникает вопрос: «Можно ли настроить многоядерную среду так, чтобы на одном процессоре работал GUI, на другом — база данных, а на третьем и четвертом — приложения реального времени?»
Безусловно, да.
Механизм привязки потоков (thread affinity) позволяет связывать определенные программы (или их потоки) с одним или несколькими процессорами.
Он работает следующим образом: в момент запуска потока его процессорная маска (runmask) разрешает его выполнение на всех процессорах. Процессорная маска потока не наследуется, а задается потоком путем вызова функции ThreadCtl() с управляющим флагом _NTO_TCTL_RUNMASK:
if ( ThreadCtl( _NTO_TCTL_RUNMASK, (void *)my_runmask ) == -1 ){/* произошла ошибка */}
Процессорная маска представляет собой обычную битовую карту, где каждый бит соответствует процессору. Например, процессорная маска 0x05
(00000101
в двоичном виде) разрешает выполнение потока на процессорах 0
(0x01
в двоичном виде) и 2
(0x04
в двоичном виде).
![]() | При использовании флага _NTO_TCTL_RUNMASK процессорная маска имеет тип int , и ее максимальный размер на текущий момент составляет 32 бита. Дочерние потоки не наследуют процессорную маску родителя.
Чтобы создать процессорную маску, которая не умещается в значение типа |
В заголовочном файле <sys/neutrino.h>
определены несколько макросов для работы с процессорной маской:
Процессоры нумеруются с нуля. Перечисленные макросы работают с процессорными масками любого размера.
Для этой цели используется маска наследования (inherit mask) потока, которая задается при помощи вызова ThreadCtl() с управляющим флагом _NTO_TCTL_RUNMASK_GET_AND_SET_INHERIT. Структура, которая передается этой команде, имеет вид:
struct _thread_runmask {int size;unsigned runmask[size];unsigned inherit_mask[size];};
Если поле runmask имеет ненулевое значение, функция ThreadCtl() присваивает его процессорной маске вызывающего потока. Если поле runmask равно нулю, процессорная маска вызывающего потока не изменяется.
Если поле inherit_mask имеет ненулевое значение, функция ThreadCtl() присваивает его маске наследования вызывающего потока; если вызывающий поток порождает потомков с помощью функций pthread_create(), fork(), spawn(), vfork() и exec(), потомки наследуют эту маску. Если поле inherit_mask равно нулю, маска наследования вызывающего потока не изменяется.
Определение структуры struct _thread_runmask в заголовочном файле <sys/neutrino.h>
имеет вид:
struct _thread_runmask {int size;/* unsigned runmask[size]; *//* unsigned inherit_mask[size]; */};
Поскольку количество элементов в массивах runmask и inherit_mask зависит от количества процессоров в многоядерной системе. Макрос RMSK_SIZE() позволяет определять количество беззнаковых целых чисел, которые необходимы для назначения масок; следует передавать этому макросу количество процессоров, указанное на системной странице.
В следующем фрагменте кода показана настройка процессорной маски и маски наследования:
unsigned num_elements = 0;int *rsizep, masksize_bytes, size;unsigned *rmaskp, *imaskp;void *my_data;/* определение необходимого количества элементов массива для хранения* процессорных масок в зависимости от количества процессоров в системе */num_elements = RMSK_SIZE( _syspage_ptr->num_cpu );/* определение размера процессорной маски в байтах */masksize_bytes = num_elements * sizeof( unsigned );/* выделение памяти для структуры данных, которую мы передадим в функцию ThreadCtl().* Нам необходимо место для целого числа (количества элементов в каждом массиве масок)* и двух масок (процессорной маски и маски наследования). */size = sizeof( int ) + 2 * masksize_bytes;if ( (my_data = malloc( size )) == NULL ){/* недостаточно памяти */...} else {memset( my_data, 0x00, size );/* присвоение значений указателям на «поля» структуры */rsizep = (int *)my_data;rmaskp = rsizep + 1;imaskp = rmaskp + num_elements;/* задание размера */*rsizep = num_elements;/* задание процессорной маски. Этот макрос однократно вызывается для каждого процессора,* на котором разрешается выполнение потока */RMSK_SET( cpu1, rmaskp );/* задание маски наследования. Этот макрос однократно вызывается для каждого процессора,* на котором разрешается выполнение потомков потока */RMSK_SET( cpu1, imaskp );if ( ThreadCtl( _NTO_TCTL_RUNMASK_GET_AND_SET_INHERIT, my_data ) == -1 ){/* произошла ошибка */...}}
Можно задавать процессорную маску при запуске процесса с помощью параметров -C и -R команды on (предполагая, что процесс не задает ее программно); например, чтобы запустить стек io-pkt-ksz и привязать все его потоки к процессору 1
, следует выполнить команду on -C 1 io-pkt-ksz. Эта команда задает как процессорную маску, так и маску наследования.
Можно изменять процессорную маску выполняемого процесса или потока с помощью одноименных параметров команды slay. Например, команда slay -C 0 io-pkt-ksz переносит все потоки процесса io-pkt-ksz на процессор 0
. При использовании параметров -C и -R команда slay задает процессорную маску, а если дополнительно указан параметр -i – присваивает это же значение маске наследования.
Стандартные объекты синхронизации (барьеры, мьютексы, условные переменные, семафоры и все их производные, такие как ждущие блокировки) корректно работают в многоядерных системах без дополнительных мер со стороны разработчика.
Для управления доступом двух потоков с одинаковым приоритетом к общей памяти часто используется FIFO планирование. Один поток использует общую память и вызывает функцию SchedYield(), чтобы передать управление другому потоку. Другой поток запускается, использует общую память и также вызывает функцию SchedYield(), чтобы передать управление первому потоку. В однопроцессорной системе оба потока используют процессор поочередно.
Такое применение FIFO-планирования невозможно в многопроцессорной системе, поскольку оба потока могут одновременно выполняться на разных процессорах. Для корректной синхронизации потоков необходимо использовать «стандартные» объекты (такие как мьютекс) или привязывать потоки к конкретным процессорам.
Ниже описан метод, который очень напоминает синхронизацию с помощью FIFO планирования. В однопроцессорной системе поток и функция обработки прерывания не могут выполняться одновременно, поскольку приоритет функции обработки прерывания выше, чем у потока. По этой причине функция обработки прерывания может вытеснять поток, но не наоборот. Единственным способом «защиты» потока является запрет прерываний на время выполнения критической секции кода.
Очевидно, что в многоядерной системе этот метод не работает, поскольку поток и функция обработки прерывания могут выполняться на разных процессорах.
Решением этой проблемы является использование функций InterruptLock() и InterruptUnlock(), которые запрещают функции обработки прерывания вытеснять поток в непредсказуемые моменты времени. А что делать, если поток вытесняет обработчика прерывания? Решение аналогично: вызывать в обработчике функции InterruptLock() и InterruptUnlock().
![]() | Мы рекомендуем всегда вызывать функции InterruptLock() и InterruptUnlock() как в потоке, так и в обработчике прерывания. Потери времени, которые возникают при этом в однопроцессорной системе, пренебрежимо малы. |
Для выполнения простых атомарных операций, таких как сложение содержимого памяти с числом, не обязательно отключать прерывания. В заголовочном файле <atomic.h>
определены C-функции, которые выполняют нижеперечисленные операции над памятью без вытеснения:
![]() | В некоторых системах функции *_value() работают медленнее, поэтому следует использовать их только при необходимости возвращать исходное значение памяти. |
Адаптивное партиционирование можно использовать в многоядерной системе с учетом ряда нюансов. Дополнительную информацию см. в разделе Использование партиционирования в многоядерных системах.
Даже если на сегодняшний день вы не используете многоядерную платформу, желательно, чтобы после ее обновления ваши программы работали быстрее без внесения каких-либо изменений.
Несмотря на то, что разработка программ, которые масштабируются в зависимости от количества процессоров в системе, является нетривиальной задачей, в настоящем разделе приведен ряд общих рекомендаций по ее решению.
Не следует рассчитывать на то, что ваша программа всегда будет выполняться на единственном процессоре — не используйте вышеупомянутый метод синхронизации с помощью FIFO планирования. Рекомендуется использовать функции InterruptLock() и InterruptUnlock(), которые подходят для многоядерных систем и почти не замедляют работу программ в однопроцессорной системе.
Как было отмечено ранее, параллельное выполнение потоков не является лишь «абстракцией программирования»; следует разрабатывать программы так, как будто их потоки действительно выполняются параллельно. Это позволяет избегать неприятных сюрпризов при переходе на многоядерную платформу; также можно использовать ограничение использования вычислительных ресурсов, если возникают проблемы при выполнении программ, но вы не хотите изменять их код.
Большинство задач можно разделять на независимые параллельные подзадачи. Иногда это легко, иногда — сложно, иногда — невозможно. Как правило, разработчик анализирует потоки данных задачи. Если они независимы друг от друга (потоки не использует результаты друг друга и не имеют взаимных блокировок), их часто можно реализовывать в виде отдельных потоков процесса. Рассмотрим следующий фрагмент кода:
do_graphics(){int x;for ( x = 0; x < XRESOLUTION; x++ ){do_one_line( x );}}
В этом примере мы выполняем трассировку лучей. Проанализировав задачу, мы пришли к выводу, что функция do_one_line() только генерирует данные, которые отображаются на экране, и не использует результаты функции do_one_line().
Для оптимального использования многоядерной системы следует запускать несколько потоков, каждый из который выполняется на одном процессоре.
Возникает вопрос о том, сколько потоков запускать. Очевидно, что запускать XRESOLUTION
потоков, где XRESOLUTION
значительно превышает количество процессоров (например, 1024 в четырехъядерной системе) — не лучшая идея: в процессе конкуренции друг с другом многочисленные потоки потребляют ресурсы стека и ядра.
Простое и эффективное решение — определить количество процессоров в системе (с помощью указателя на системную страницу) и структурировать работу следующим образом:
#include <sys/syspage.h>int num_x_per_cpu;do_graphics(){int num_cpus;int i;pthread_t *tids;/* определение количества процессоров в системе */num_cpus = _syspage_ptr->num_cpu;/* выделение памяти для идентификаторов потоков */tids = malloc( num_cpus * sizeof( pthread_t ) );/* количество вертикальных линий, которые может обрабатывать один процессор */num_x_per_cpu = XRESOLUTION / num_cpus;/* запуск одного потока для процессора с передачей идентификатора */for ( i = 0; i < num_cpus; i++ ){pthread_create( &tids[i], NULL, do_lines, (void *)i );}/* теперь все потоки do_lines выполняются на процессорах *//* мы ждем их завершения */for ( i = 0; i < num_cpus; i++ )pthread_join( tids[i], NULL );/* все потоки завершили работу */}void * do_lines( void *arg ){int cpunum = (int)arg; /* преобразование void * в int */int x;for ( x = cpunum * num_x_per_cpu; x < (cpunum + 1) * num_x_per_cpu; x++ ){do_line( x );}}
Вышеописанный подход обеспечивает одновременное выполнение максимального количества потоков в многоядерной системе. Нет смысла создавать потоки в количестве, превышающем количество ЦП в системе, поскольку это приводит к конкуренции между потоками за процессорное время.
Обратите внимание, что мы не указывали, на каком процессоре должен выполняться каждый поток. В рассматриваемом примере это не требуется, поскольку поток READY
с максимальным приоритетом всегда выполняется на следующем доступном процессоре. Обычно потоки выполняются на различных процессорах (в зависимости от других программ системы). Как правило, рабочие потоки, которые выполняют схожие действия, имеют одинаковые приоритеты.
В качестве альтернативного подхода можно использовать семафор, счетчик которого равен количеству доступных процессоров. Потоки создаются, когда семафор указывает, что в системе имеются свободные процессоры. Этот подход проще, но сопряжен с накладными расходами на создание и уничтожение потоков в каждой итерации.
Предыдущий раздел: перейти