Потоки и OpenMP: разделяемая память на директивах

Урок знакомит с самым простым способом распараллелить вычисления на многоядерном CPU.

OpenMP — стандарт для параллелизма с разделяемой памятью: программист расставляет директивы #pragma omp, а компилятор сам создаёт и координирует потоки.

Зачем OpenMP

Писать потоки вручную (создавать, синхронизировать, делить данные) утомительно и ошибочно. OpenMP даёт декларативный подход: вы помечаете цикл директивой, говоря «итерации независимы, распараллель», и компилятор разбивает диапазон между потоками. Модель — разделяемая память: все потоки видят одни и те же переменные, поэтому коммуникация бесплатна, но нужна аккуратность с общими данными.

Ниже — типичный пример: параллельное суммирование с редукцией. Это C++ с OpenMP, исполняется на сервере с компилятором — в браузере он не запускается, поэтому помечен как нечитаемый для раннера блок (только подсветка).

#include <omp.h>
#include <vector>
#include <iostream>

int main() {
    std::vector<double> a(1000000, 1.0);
    double sum = 0.0;

    // распараллеливаем цикл; reduction безопасно складывает частичные суммы
    #pragma omp parallel for reduction(+:sum)
    for (int i = 0; i < (int)a.size(); ++i) {
        sum += a[i];
    }

    std::cout << "sum = " << sum << std::endl;
    return 0;
}

Директива #pragma omp parallel for раздаёт итерации цикла потокам. Конструкция reduction(+:sum) критична: без неё потоки одновременно писали бы в общую sum — гонка данных. С reduction каждый поток копит свою частичную сумму, а в конце они складываются (это та самая параллельная редукция из раздела 4).

Ключевые директивы

ДирективаЧто делает
parallelсоздаёт команду потоков
forделит итерации цикла между потоками
reduction(op:var)безопасно сворачивает переменную (сумма, макс...)
criticalсекция, которую выполняет лишь один поток за раз
barrierвсе потоки ждут друг друга в точке
schedule(dynamic)динамическая балансировка итераций

Иллюстрация: что делает reduction (на Python)

Чтобы понять логику reduction без C++, эмулируем её последовательно: каждый «поток» суммирует свой кусок, потом частичные суммы складываются.

a = [1.0] * 1_000_000
threads = 4
chunk = len(a) // threads

partial = []
for t in range(threads):
    partial.append(sum(a[t*chunk:(t+1)*chunk]))  # частичная сумма потока

print("частичные суммы:", partial)
print("reduction(+):", sum(partial))

Вывод:

частичные суммы: [250000.0, 250000.0, 250000.0, 250000.0]
reduction(+): 1000000.0

schedule: статика против динамики

OpenMP позволяет выбрать, как делить итерации. schedule(static) — поровну заранее (дёшево, хорошо при равной стоимости итераций). schedule(dynamic) — потоки забирают порции по мере освобождения (это work stealing из раздела 6, дороже, но выравнивает нагрузку при неравных итерациях). Выбор schedule — главный рычаг балансировки в OpenMP.

Как работает под капотом

OpenMP не магия: компилятор разворачивает директивы в вызовы рантайма, который держит пул потоков. parallel «будит» потоки пула, for вычисляет диапазон итераций каждого потока, барьер в конце ждёт всех. Поток-мастер продолжает после параллельной области. Поскольку память общая, переменные по умолчанию разделяемые — отсюда главный источник ошибок: случайная запись в общую переменную без reduction или critical.

Главная ценность OpenMP — низкий порог входа: можно взять готовый последовательный цикл и распараллелить его одной строкой, не переписывая программу. Это делает его идеальной отправной точкой для распараллеливания существующего кода. Но та же простота таит ловушку: добавить директиву легко, а вот убедиться, что итерации действительно независимы, — на совести программиста. Компилятор не проверяет независимость; он слепо доверяет вашему #pragma. Поэтому работа с OpenMP — это в основном работа с зависимостями: понять, какие переменные общие, какие приватные, где накопление требует reduction, а где нужен critical. Сама директива — мелочь; всё мышление в анализе данных.

Частые ошибки

  • Распараллелить цикл с зависимостями между итерациями — получите неверный результат (итерации обязаны быть независимы).
  • Забыть reduction для накапливаемой переменной — гонка данных, мусорная сумма.
  • Злоупотреблять critical — он сериализует код и убивает параллелизм.

Итоги

  • OpenMP распараллеливает циклы директивами #pragma в модели разделяемой памяти.
  • reduction безопасно сворачивает накапливаемую переменную (избегает гонки).
  • schedule(static/dynamic) управляет балансировкой; критично, чтобы итерации были независимы.
Проверьте себя
1. Какова модель памяти у OpenMP?
AРаспределённая память с сообщениями
BРазделяемая память — все потоки видят общие переменные
CБез памяти
DТолько GPU-память
2. Зачем нужна конструкция reduction(+:sum)?
AЧтобы ускорить ввод-вывод
BЧтобы потоки безопасно складывали частичные суммы без гонки за общую переменную
CЧтобы создать больше потоков
DЧтобы отсортировать массив
3. Когда нельзя распараллеливать цикл через #pragma omp parallel for?
AКогда итераций мало
BКогда итерации зависят друг от друга
CКогда используется double
DНикогда нельзя