2.2 Директивы OpenMP

Значительная часть функциональности OpenMP реализуется при помощи директив компилятору. Они должны быть явно вставлены пользователем, что позволит выполнять программу в параллельном режиме. В C/C++ директивы OpenMP определяются конструкциями #pragma, предусматривающимися стандартами C и C++, и используемых для задания дополнительных указаний компилятору. Использование специальной ключевой директивы “omp” указывает на то, что команды относятся к OpenMP и для того, чтобы исключить случайные совпадения имён директив OpenMP с другими именами. Таким образом директивы #pragma для работы с OpenMP имеют следующий формат:

#pragma omp директива<> опция[ [ [,] опция]...]

Объектом действия большинства директив является один оператор или блок, перед которым расположена директива в исходном тексте программы. В OpenMP такие операторы или блоки называются ассоциированными с директивой. Ассоциированный блок должен иметь одну точку входа в начале и одну точку выхода в конце. Директивы – регистрозависимы, однако порядок опций в описании директивы несущественен, в одной директиве большинство опций может встречаться несколько раз. После некоторых опций может следовать список переменных, разделяемых запятыми. Каждая директива может иметь несколько дополнительных атрибутов – опций (clause). Отдельно специфицируются опции для назначения классов переменных, которые могут быть атрибутами различных директив.

Опция (clause) – это необязательный модификатор директивы, влияющий на ее поведение. Списки опций, поддерживаемые каждой директивой, различаются, а пять директив (master, critical, flush, ordered и atomic) вообще не поддерживают опции.

OpenMP поддерживает директивы parallel, for, parallelfor, section, sections, single, master, critical, flush, ordered и atomic, и ряд других, которые определяют механизмы разделения работы или конструкции синхронизации.

Прежде чем начать разбор директив OpenMP, хотелось бы отметить что некоторые директивы OpenMP имеют определенный набор опций, которые могут быть как включены так и не включены в эти директивы.

Опция schedule

Управляет распределением работы между нитями в конструкции распределения работы цикла.

schedule тип([, chunk])

Опция задаёт, каким образом итерации цикла распределяются между нитями; Задается вид алгоритма планирования и, если необходимо, числовой параметр алгоритма (обычно размер блока пространства итераций).

В опции schedule параметр type задаёт следующий тип распределения итераций:

Директива parallel

Директива parallel создает параллельную область для следующего за ней структурированного блока, параллельная область задаётся при помощи записи:

#pragma omp parallel опция[[[,] опция]...]структурированный 
 блок

Возможные опции:

Эта директива сообщает компилятору, что структурированный блок кода должен быть выполнен параллельно, в нескольких потоках. Каждый поток будет выполнять один и тот же поток команд, но не один и тот же набор команд – все зависит от операторов, управляющих логикой программы, таких как if-else.

При входе в параллельную область порождаются новые OMP_NUM_THREADS-1 нитей, каждая нить получает свой уникальный номер, причём порождающая нить получает номер 0 и становится основной нитью группы (“мастером”). Остальные нити получают в качестве номера целые числа с 1 до OMP_NUM_THREADS-1. Количество нитей, выполняющих данную параллельную область, остаётся неизменным до момента выхода из области. При выходе из параллельной области производится неявная синхронизация и уничтожаются все нити, кроме породившей.

Если один из потоков параллельной области встречает другую директиву parallel, то он создает новую группу потоков, согласно правилам, и становится основной нитью новой группы.

Если выполнение потока аварийно прерывается внутри параллельной области, то также прерывается выполнение всех потоков во всех группах. Порядок прерывания работы потоков не определен. Вся работа, проделанная группой до последней барьерной синхронизации, гарантированно будет выполнена. Объем выполненной работы, проделанной каждым потоком после последней барьерной синхронизации, до аварийного завершения работы потоков не определен.

Все порождённые нити исполняют один и тот же код, соответствующий параллельной области. Предполагается, что в SMP-системе нити будут распределены по различным процессорам (однако это, как правило, находится в ведении операционной системы).

Во время исполнения любой поток может приостоновить выполнение своей неявной задачи в точке планирования задач (task scheduling point) и переключиться на выполнение любой явно-сгенерированной задачи прежде чем возобновить выполнение неявной задачи.

Нить может узнать свой номер с помощью вызова библиотечной функции omp_get_thread_num.

Очень часто параллельная область не содержит ничего, кроме конструкции разделения работы (т.е. конструкция разделения работы тесно вложена в параллельную область). В этом случае можно указывать не две директивы, а указать одну комбинированную.

Например, если внутри параллельной области содержится только один параллельный цикл или одна конструкция sections, то можно использовать укороченную запись: parallel for или parallel sections. При этом допустимо указание всех опций этих директив, за исключением опции nowait.

Ограничения для директивы parallel следующие:

Директива for

Если в параллельной области встретился оператор цикла, то, согласно общему правилу, он будет выполнен всеми нитями текущей группы, то есть каждая нить выполнит все итерации данного цикла. Для распределения итераций цикла между различными нитями можно использовать директиву for.

#pragma omp for опция[[[,] опция] ... ]цикл 
 for

Эта директива относится к идущему следом за данной директивой блоку, включающему оператор for.

Возможные опции:

На вид параллельных циклов накладываются достаточно жёсткие ограничения. В частности, предполагается, что корректная программа не должна зависеть от того, какая именно нить какую итерацию параллельного цикла выполнит. Нельзя использовать побочный выход из параллельного цикла. Размер блока итераций, указанный в опции schedule, не должен изменяться в рамках цикла.

Эти требования введены для того, чтобы OpenMP мог при входе в цикл точно определить число итераций. Если директива параллельного выполнения стоит перед гнездом циклов, завершающихся одним оператором, то директива действует только на самый внешний цикл. Итеративная переменная распределяемого цикла по смыслу должна быть локальной, поэтому в случае, если она специфицирована общей, то она неявно делается локальной при входе в цикл. После завершения цикла значение итеративной переменной цикла не определено, если она не указана в опции lastprivate.

Следующий пример демонстрирует использование директивы for. В последовательной области инициализируются три исходных массива A, B, C. В параллельной области данные массивы объявлены общими. Вспомогательные переменные i и n объявлены локальными. Каждая нить присвоит переменной n свой порядковый номер. Далее с помощью директивы for определяется цикл, итерации которого будут распределены между существующими нитями. На каждой i-ой итерации данный цикл сложит i-ые элементы массивов A и B и результат запишет в i-ый элемент массива C. Также на каждой итерации будет напечатан номер нити, выполнившей данную итерацию.

#include <stdio.h> 
#include <omp.h> 
 
int main(int argc, char *argv[]) 
{ 
    int A[10], B[10], C[10], i, n; 
// Заполним исходные массивы 
    for (i = 0; i < 10; i++) 
    { 
        A[i] = i; 
        B[i] = 2 * i; 
        C[i] = 0; 
    } 
 
    #pragma omp parallel shared(A, B, C) private(i, n) 
    { 
// Получим номер текущей нити 
        n = omp_get_thread_num(); 
 
        #pragma omp for 
        for (i = 0; i < 10; i++) 
        { 
            C[i] = A[i] + B[i]; 
            printf("Нить \%d сложила элементы с номером %d\n", n, i); 
        } 
    } 
}

Директива single

Если в параллельной области какой-либо участок кода должен быть выполнен лишь один раз, то его нужно выделить директивой single.

#pragma omp single опция[ [[,] опция]...]структурированный 
 блок

Возможные опции:

В параллельных регионах часто встречаются блоки кода, доступ к которым желательно предоставлять только одному потоку, – например, блоки кода, отвечающие за запись данных в файл. Во многих таких ситуациях не имеет значения, какой поток выполнит код, важно лишь, чтобы этот поток был единственным. Для этого в OpenMP служит директива single.

Какая именно нить будет выполнять выделенный участок программы, не специфицируется. Одна нить будет выполнять данный фрагмент, а все остальные нити будут ожидать завершения её работы, если только не указана опция nowait.

Следующий пример иллюстрирует применение опции copyprivate. В данном примере переменная n объявлена в параллельной области как локальная. Каждая нить присвоит переменной n значение, равное своему порядковому номеру, и напечатает данное значение. В области single одна из нитей присвоит переменной n значение 100, и на выходе из области это значение будет присвоено переменной n на всех нитях. В конце параллельной области значение n печатается ещё раз и на всех нитях оно равно 100.

#include <stdio.h> 
#include <omp.h> 
 
int main(int argc, char *argv[]) 
{ 
    int n; 
 
    #pragma omp parallel private(n) 
    { 
        n = omp_get_thread_num(); 
        printf("Значение n начало(): %d\n", n); 
 
        #pragma omp single copyprivate(n) 
        { 
            n = 100; 
        } 
 
    printf("Значение n конец(): %d\n", n); 
    } 
}

Ограничения для директивы single следующие:

Директива sections

Директива sections используется для задания конечного (неитеративного) параллелизма. Директива sections содержит набор структурированных блоков, которые распределяются по потокам в группе. Каждый структурированный блок исполняется один раз одним из потоков в группе.

#pragma omp sections опция[[[,] опция] ...] 
{ 
#pragma omp sectionструктурированный 
 блок 
#pragma omp sectionструктурированный 
 блок 
... 
}

При выполнении этого кода OpenMP сначала создает группу потоков, а затем распределяет между ними обработку итераций цикла, после выполнения которого потоки начинают параллельную обработку оставшихся разделов кода. Если количество разделов программного кода будет больше числа потоков, обработка нескольких разделов будет отложена до тех пор, пока не появятся свободные потоки. В отличие от планирования циклов, распределение нагрузки между потоками при обработке параллельных разделов кода осуществляется и контролируется OpenMP. Программисту остается только выбрать, какие переменные будут общими, а какие – индивидуальными, и предусмотреть выражения уменьшения аналогично сегменту с организацией циклов.

Возможные опции:

Директива section задаёт участок кода внутри секции sections для выполнения одной нитью.

#pragma omp section

Перед первым участком кода в блоке sections директива section не обязательна. Какие именно нити будут задействованы для выполнения какой секции, не специфицируется. Если количество нитей больше количества секций, то часть нитей для выполнения данного блока секций не будет задействована. Если количество нитей меньше количества секций, то некоторым (или всем) нитям достанется более одной секции.

Следующий пример демонстрирует использование опции lastprivate. В данном примере опция lastprivate используется вместе с директивой sections. Переменная n объявлена как lastprivate переменная. Три нити, выполняющие секции section, присваивают своей локальной копии n разные значения. По выходе из области sections значение n из последней секции присваивается локальным копиям во всех нитях, поэтому все нити напечатают число 3. Это же значение сохранится для переменной n и в последовательной области.

#include <stdio.h> 
#include <omp.h> 
 
int main(int argc, char *argv[]) 
{ 
    int n = 0; 
 
    #pragma omp parallel 
    { 
        #pragma omp sections lastprivate(n) 
        { 
            #pragma omp section 
            { 
                n = 1; 
            } 
 
            #pragma omp section 
            { 
                n = 2; 
            } 
 
            #pragma omp section 
            { 
                n = 3; 
            } 
        } 
 
        printf("Значение n на нити %d: %d\n", 
        omp_get_thread_num(), n); 
    } 
 
    printf("Значение n в последовательной области: %d\n", n); 
}

Директива master

Директивы master выделяют участок кода, который будет выполнен только нитью-мастером. Остальные нити просто пропускают данный участок и продолжают работу с оператора, расположенного следом за ним. Неявной синхронизации данная директива не предполагает.

#pragma omp master

Следующий пример демонстрирует применение директивы master. Переменная n является локальной, то есть каждая нить работает со своим экземпляром. Сначала все нити присвоят переменной n значение 1. Потом нить-мастер присвоит переменной n значение 2, и все нити напечатают значение n. Затем нитьмастер присвоит переменной n значение 3, и снова все нити напечатают значение n. Видно, что директиву master всегда выполняет одна и та же нить. В данном примере все нити выведут значение 1, а нить-мастер сначала выведет значение 2, а потом - значение 3.

#include <stdio.h> 
 
int main(int argc, char *argv[]) 
{ 
    int n; 
 
    #pragma omp parallel private(n) 
    { 
        n = 1; 
 
        #pragma omp master 
        { 
            n = 2; 
        } 
 
        printf("Первое значение n: %d\n", n); 
 
        #pragma omp barrier 
 
        #pragma omp master 
        { 
            n = 3; 
        } 
 
        printf("Второе значение n: %d\n", n); 
    } 
}

Директива critical

С помощью директив critical оформляется критическая секция программы. Критическая секция запрещает одновременное исполнение структурированного блока более чем одним потоком.

#pragma omp critical имя[()]структурированный 
 блок

В каждый момент времени в критической секции может находиться не более одной нити. Если критическая секция уже выполняется какой-либо нитью, то все другие нити, выполнившие директиву для секции с данным именем, будут заблокированы, пока вошедшая нить не закончит выполнение данной критической секции. Как только работавшая нить выйдет из критической секции, одна из заблокированных на входе нитей войдет в неё. Если на входе в критическую секцию стояло несколько нитей, то случайным образом выбирается одна из них, а остальные заблокированные нити продолжают ожидание.

Все неименованные критические секции условно ассоциируются с одним и тем же именем. Все критические секции, имеющие одно и тоже имя, рассматриваются единой секцией, даже если находятся в разных параллельных областях. Побочные входы и выходы из критической секции запрещены.

Следующий пример иллюстрирует применение директивы critical. Переменная n объявлена вне параллельной области, поэтому по умолчанию является общей. Критическая секция позволяет разграничить доступ к переменной n. Каждая нить по очереди присвоит n свой номер и затем напечатает полученное значение.

#include <stdio.h> 
#include <omp.h> 
int main(int argc, char *argv[]) 
{ 
    int n; 
 
    #pragma omp parallel 
    { 
        #pragma omp critical 
        { 
            n = omp_get_thread_num(); 
            printf("Нить %d\n", n); 
        } 
    } 
}

Если бы в примере не была указана директива critical, результат выполнения программы был бы непредсказуем. С директивой critical порядок вывода результатов может быть произвольным, но это всегда будет набор одних и тех же чисел от 0 до OMP_NUM_THREADS-1. Конечно, подобного же результата можно было бы добиться другими способами, например, объявив переменную n локальной, тогда каждая нить работала бы со своей копией этой переменной. Однако в исполнении этих фрагментов разница существенная.

Если есть критическая секция, то в каждый момент времени фрагмент будет обрабатываться лишь какой-либо одной нитью. Остальные нити, даже если они уже подошли к данной точке программы и готовы к работе, будут ожидать своей очереди. Если критической секции нет, то все нити могут одновременно выполнить данный участок кода. С одной стороны, критические секции предоставляют удобный механизм для работы с общими переменными. Но с другой стороны, пользоваться им нужно осмотрительно, поскольку критические секции добавляют последовательные участки кода в параллельную программу, что может снизить её эффективность.

Директива barrier

Самый распространенный способ синхронизации в OpenMP – барьер. Он оформляется с помощью директивы barrier. Директива barrier дает всем потокам указание ожидать друг друга перед тем, как они продолжат выполнение за барьером.

#pragma omp barrier

Нити, выполняющие текущую параллельную область, дойдя до этой директивы, останавливаются и ждут, пока все нити не дойдут до этой точки программы, после чего разблокируются и продолжают работать дальше. Кроме того, для разблокировки необходимо, чтобы все синхронизируемые нити завершили все порождённые ими задачи (task).

Следующий пример демонстрирует применение директивы barrier. Директива barrier используется для упорядочивания вывода от работающих нитей. Выдачи с разных нитей "Сообщение 1"и "Сообщение 2"могут перемежаться в произвольном порядке, а выдача "Сообщение 3"со всех нитей придёт строго после двух предыдущих выдач.

#include <stdio.h> 
#include <omp.h> 
int main(int argc, char *argv[]) 
{ 
    #pragma omp parallel 
    { 
        printf("Сообщение 1\n"); 
 
        printf("Сообщение 2\n"); 
 
        #pragma omp barrier 
 
        printf("Сообщение 3\n"); 
    } 
}

Директива atomic

Частым случаем использования критических секций на практике является обновление общих переменных. Например, если переменная sum является общей и оператор вида sum = sum + expr находится в параллельной области программы, то при одновременном выполнении данного оператора несколькими нитями можно получить некорректный результат. Чтобы избежать такой ситуации можно воспользоваться механизмом критических секций или специально предусмотренной для таких случаев директивой atomic.

#pragma omp atomic [ read | write | update | capture ]оператор

или

#pragma omp atomic captureструктурированный 
 блок

Данная директива относится к идущему непосредственно за ней оператору присваивания (на используемые в котором конструкции накладываются достаточно понятные ограничения), гарантируя корректную работу с общей переменной, стоящей в его левой части. На время выполнения оператора блокируется доступ к данной переменной всем запущенным в данный момент нитям, кроме нити, выполняющей операцию. Атомарной является только работа с переменной в левой части оператора присваивания, при этом вычисления в правой части не обязаны быть атомарными.

Следующий пример иллюстрирует применение директивы atomic. В данном примере производится подсчет общего количества порожденных нитей. Для этого каждая нить увеличивает на единицу значение переменной count. Для того, чтобы предотвратить одновременное изменение несколькими нитями значения переменной, стоящей в левой части оператора присваивания, используется директива atomic.

#include <stdio.h> 
#include <omp.h> 
int main(int argc, char *argv[]) 
{ 
    int count = 0; 
 
    #pragma omp parallel 
    { 
        #pragma omp atomic 
        count++; 
    } 
 
    printf("Число нитей: %d\n", count); 
}

Директива ordered

Директивы ordered определяют блок внутри тела цикла, который должен выполняться в том порядке, в котором итерации идут в последовательном цикле.

#pragma omp orderedструктурированный 
 блок

Блок операторов относится к самому внутреннему из объемлющих циклов, а в параллельном цикле должна быть задана опция ordered. Нить, выполняющая первую итерацию цикла, выполняет операции данного блока. Нить, выполняющая любую следующую итерацию, должна сначала дождаться выполнения всех операций блока всеми нитями, выполняющими предыдущие итерации. Может использоваться, например, для упорядочения вывода от параллельных нитей.

Следующий пример иллюстрирует применение директивы ordered и опции ordered. Цикл for помечен как ordered. Внутри тела цикла идут две выдачи – одна вне блока ordered, а вторая – внутри него. В результате первая выдача получается неупорядоченной, а вторая идёт в строгом порядке по возрастанию номера итерации.

#include <stdio.h> 
#include <omp.h> 
int main(int argc, char *argv[]) 
{ 
    int i, n; 
 
    #pragma omp parallel private (i, n) 
    { 
        n = omp_get_thread_num(); 
 
        #pragma omp for ordered 
        for (i = 0; i < 5; i++) 
        { 
            printf("Нить %d, итерация %d\n", n, i); 
 
            #pragma omp ordered 
            { 
                printf("ordered: Нить %d, итерация %d\n", n, i); 
            } 
        } 
    } 
}

Директива task

Директива task применяется для выделения отдельной независимой задачи.

#pragma omp task опция[[[,] опция] ...]структурированный 
 блок

Текущая нить выделяет в качестве задачи ассоциированный с директивой блок операторов. Задача может выполняться немедленно после создания или быть отложенной на неопределённое время и выполняться по частям. Размер таких частей, а также порядок выполнения частей разных отложенных задач определяется реализацией.

Возможные опции:

Следующий пример показывает как пройти древовидную структуру используя директиву task. Отметим, что функция траверс (traverse) должна быть вызвана из параллельной области для различных указанных задач, которые будут выполняться параллельно. Также отметим, что задачи выполняются не в указанном порядке, поскольку здесь не используются директивы синхронизации. Таким образом, предположение, что обход будет сделан в том же порядке, что и в последовательном коде, является неверным.

struct node 
{ 
    struct node *left; 
    struct node *right; 
}; 
 
extern void process(struct node *); 
 
void traverse( struct node *p ) 
{ 
    if (p->left) 
        #pragma omp task // p является firstprivate-переменной по умолчанию 
            traverse(p->left); 
 
    if (p->right) 
        #pragma omp task // p является firstprivate-переменной по умолчанию 
            traverse(p->right); 
 
    process(p); 
}

Директива taskwait

Для гарантированного завершения в точке вызова всех запущенных задач используется директива taskwait.

#pragma omp taskwait

Нить, выполнившая данную директиву, приостанавливается до тех пор, пока не будут завершены все ранее запущенные данной нитью независимые задачи.

В следующем примере мы заставляем программу совершить обход дерева в обратном порядке с помощью добавления директивы taskwait. Теперь мы можем смело предположить, что левая и правая ветки будут выполнены, прежде чем мы обработаем текущий узел.

struct node 
{ 
    struct node *left; 
    struct node *right; 
}; 
 
extern void process(struct node *); 
 
void postorder_traverse( struct node *p ) 
{ 
    if (p->left) 
        #pragma omp task // p является firstprivate-переменной по умолчанию 
            postorder_traverse(p->left); 
 
    if (p->right) 
        #pragma omp task // p является firstprivate-переменной по умолчанию 
            postorder_traverse(p->right); 
 
    #pragma omp taskwait 
    process(p); 
}

Директива taskyield

Директива taskyield указывает, что текущая задача может быть приостановлена в пользу выполнения других задач.

#pragma omp taskyield

Следующий пример иллюстрирует использование директивы taskyield. Задача в примере - вычислить что-то полезное (something useful), а затем сделать некоторые вычисления, которые необходимо выполнить в критической области (something critical). Когда задача не может получить доступ к критической области, директива taskyield может приостановить выполнение текущей задачи и поставить другие задачи на выполнение something useful.

#include <omp.h> 
 
void something_useful ( void ); 
void something_critical ( void ); 
 
void foo ( omp_lock_t * lock, int n ) 
{ 
    int i; 
 
    for ( i = 0; i < n; i++ ) 
 
    #pragma omp task 
    { 
        something_useful(); 
 
        while ( !omp_test_lock(lock) ) 
        { 
            #pragma omp taskyield 
        } 
 
        something_critical(); 
 
        omp_unset_lock(lock); 
    } 
}