C++17 실행 정책 seq·par·par_unseq: 병렬 알고리즘과 데이터 레이스 함정

이 글의 핵심

C++17 실행 정책(seq/par/par_unseq)의 실제 동작 방식과, par를 붙였다고 항상 빨라지지 않는 이유, par_unseq가 요구하는 벡터화 안전 조건, 예외가 std::terminate로 이어지는 규칙을 정리합니다.

Execution Policy란?

C++17 이전에는 표준 라이브러리 알고리즘을 병렬로 돌리려면 OpenMP, Intel TBB, 또는 직접 std::thread를 관리하는 코드를 작성해야 했습니다. 알고리즘 로직 자체를 병렬 버전으로 다시 작성하거나, 최소한 별도 라이브러리에 의존해야 했다는 뜻입니다. C++17은 이 문제를 실행 정책(execution policy)이라는 개념으로 해결합니다. std::sort, std::transform, std::for_each 같은 기존 알고리즘의 첫 번째 인자로 정책 객체를 하나 추가하면, 알고리즘의 나머지 인터페이스는 그대로 두고 실행 방식만 순차/병렬/벡터화 중에서 고를 수 있습니다.

여기서 중요한 점은, 정책은 “이렇게 실행해도 안전하다는 허가”이지 “반드시 이렇게 실행하라는 명령”이 아니라는 것입니다. 표준은 par를 지정해도 구현체가 내부 판단에 따라 순차 실행으로 처리하는 것을 허용합니다. 즉 실행 정책은 컴파일러/표준 라이브러리에게 병렬화할 수 있는 여지를 열어주는 것이지, 특정 스레드 수나 SIMD 폭을 보장하지 않습니다.

#include <algorithm>
#include <execution>
#include <vector>

std::vector<int> v = {3, 1, 4, 1, 5};

// 순차
std::sort(std::execution::seq, v.begin(), v.end());

// 병렬
std::sort(std::execution::par, v.begin(), v.end());

// 병렬 + 벡터화
std::sort(std::execution::par_unseq, v.begin(), v.end());

세 줄 모두 정렬 결과는 동일하지만 내부 실행 방식이 다릅니다. seq는 지금까지 알던 std::sort와 동일하게 단일 스레드에서 순서대로 실행됩니다. par는 표준 라이브러리가 내부적으로 관리하는 스레드(또는 스레드 풀)에 작업을 나누어 맡길 수 있습니다. par_unseq는 여기에 더해 각 스레드 내부에서도 SIMD 명령어로 여러 원소를 동시에 처리할 수 있도록 허용합니다.

실무에서 자주 놓치는 함정 하나는 표준 라이브러리별 지원 차이입니다. libstdc++(GCC)의 병렬 알고리즘은 Intel oneTBB를 백엔드로 사용하므로 -ltbb 링크가 필요하고, 컴파일 시 TBB 헤더를 찾지 못하면 직렬 백엔드로 빌드되어 par를 지정해도 경고 없이 순차 실행됩니다. MSVC STL은 자체 병렬 구현(Windows 스레드 풀 기반)을 내장하고 있어 별도 링크가 필요 없습니다. libc++(Clang)는 오랫동안 병렬 알고리즘을 제공하지 않다가 LLVM 17 무렵부터 실험적 구현을 추가했으므로, 사용하는 버전의 문서를 확인해야 합니다. “정책을 지정했는데 왜 안 빨라지지?”라는 질문의 상당수는 로직 문제가 아니라 이 백엔드/링크 문제입니다. 실제로 성능이 개선되는지는 대상 환경에서 실행 시간을 재 보고 확인해야 합니다.

실행 정책 종류

#include <execution>

// sequenced_policy: 순차
std::execution::seq

// parallel_policy: 병렬
std::execution::par

// parallel_unsequenced_policy: 병렬 + 벡터화
std::execution::par_unseq

// unsequenced_policy: 벡터화 (C++20)
std::execution::unseq

각 정책이 코드에 부과하는 제약 조건이 다르다는 점이 핵심입니다.

  • seq: 지금까지의 STL 알고리즘과 동일합니다. 실행 순서가 보장되므로 부작용(side effect)이 있는 람다를 넘겨도 안전합니다.
  • par: 여러 스레드에서 동시에 실행될 수 있습니다. 공유 상태에 접근한다면 std::mutex나 std::atomic 같은 일반적인 동기화 수단을 사용할 수 있습니다. 다만 각 반복이 서로 다른 스레드에서 실행된다고 가정해야 하므로, 스레드 안전하지 않은 라이브러리 호출(예: 스레드 안전성이 보장되지 않는 로거, 전역 상태를 수정하는 C 라이브러리 함수)에는 주의가 필요합니다.
  • par_unseq: 여기서부터가 진짜 함정입니다. 표준은 이 정책으로 호출된 요소 접근 함수 안에서 “벡터화 안전하지 않은(vectorization-unsafe)” 표준 라이브러리 함수를 호출하는 것을 금지합니다. 표준의 정의로는 다른 함수 호출과 동기화하도록 규정된 함수가 여기에 해당하며, 대표적으로 뮤텍스 잠금·해제나 조건 변수 대기가 있습니다. 이유는 여러 요소의 처리가 한 스레드 안에서 인터리브될 수 있기 때문입니다. 한 요소의 처리가 락을 잡은 채로 다른 요소의 처리로 넘어가고, 그 요소가 같은 락을 기다리면 사실상 자기 자신을 기다리는 구조가 되어 데드락이 생길 수 있습니다. 흔히 오해하는 부분이 힙 할당인데, 표준은 메모리 할당·해제 함수를 이 정의에서 명시적으로 제외하므로 par_unseq 안의 new/delete 자체는 규칙 위반이 아닙니다. 다만 할당은 대개 벡터화를 막고 할당자 내부 경합을 일으키므로 성능상으로는 피하는 편이 좋습니다.
  • unseq (C++20): 멀티스레드 없이 호출 스레드 안에서의 벡터화(인터리브)만 허용합니다. 벡터화 안전 조건은 par_unseq와 같지만, 요소 접근 함수가 여러 스레드에서 동시에 실행되지는 않으므로 스레드 안전성까지 신경 쓸 필요는 없습니다.

정리하면 par는 “여러 스레드에서 실행될 수 있다”는 익숙한 동시성 문제이고, par_unseq는 여기에 “같은 명령어 스트림 안에서 여러 원소가 인터리브될 수 있다”는, 일반적인 멀티스레드 프로그래밍 경험만으로는 직관적으로 와닿지 않는 제약이 추가됩니다. 이 차이를 모르고 par에서 쓰던 락 코드를 그대로 par_unseq로 바꾸면 컴파일은 되지만 런타임에 문제가 생깁니다.

실전 예시

예시 1: 병렬 정렬

#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <execution>
#include <iostream>
#include <vector>

void benchmark() {
    std::vector<int> data(10000000);
    std::generate(data.begin(), data.end(), std::rand);

    // 순차
    auto v1 = data;
    auto start1 = std::chrono::steady_clock::now();
    std::sort(std::execution::seq, v1.begin(), v1.end());
    auto end1 = std::chrono::steady_clock::now();

    // 병렬
    auto v2 = data;
    auto start2 = std::chrono::steady_clock::now();
    std::sort(std::execution::par, v2.begin(), v2.end());
    auto end2 = std::chrono::steady_clock::now();

    auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1);
    auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2);

    std::cout << "순차: " << time1.count() << "ms" << std::endl;
    std::cout << "병렬: " << time2.count() << "ms" << std::endl;
}

이 코드는 1천만 개의 정수를 정렬하면서 seq와 par의 소요 시간을 비교합니다. 정렬은 약 n log n ≈ 2억 3천만 번 수준의 비교를 하므로 스레드 분배 오버헤드보다 실제 작업량이 훨씬 크고, 병렬화 이득을 볼 여지가 있습니다. 다만 병렬 정렬은 분할·병합 단계와 메모리 대역폭 때문에 코어 수에 정비례하는 속도 향상은 나오지 않는 것이 보통이고, 결과는 하드웨어와 표준 라이브러리 구현에 따라 크게 달라집니다. 이런 측정 코드를 실전에 그대로 옮기기 전에 주의할 점이 몇 가지 있습니다. 먼저 std::rand는 스레드 안전성이 보장되지 않으므로 벤치마크용 데이터 생성 단계에서만 쓰고 병렬 알고리즘 내부 콜백에서는 절대 호출하면 안 됩니다. 또한 단 한 번의 측정값만으로 판단하지 말고, 워밍업 실행을 한두 번 거친 뒤 여러 번 반복 측정해 평균과 분산을 함께 봐야 노이즈에 속지 않습니다.

예시 2: 병렬 변환

#include <algorithm>
#include <execution>
#include <numeric>
#include <vector>

int main() {
    std::vector<int> v(1000000);
    std::iota(v.begin(), v.end(), 1);

    // 병렬 변환
    std::transform(std::execution::par, v.begin(), v.end(), v.begin(),
        [](int x) { return static_cast<long long>(x) * x % 1000003; });
}

(원소 값이 100만까지 올라가므로 x * x를 그대로 int로 계산하면 부호 있는 정수 오버플로, 즉 정의되지 않은 동작이 됩니다. 예제에서는 64비트로 곱한 뒤 나머지를 취했습니다.) std::transform에 par를 적용할 수 있는 이유는 각 원소의 변환이 다른 원소의 결과와 완전히 독립적이기 때문입니다. 람다가 캡처하는 외부 상태도 없고, 입력 원소를 읽어 같은 위치에 다시 쓰는 구조라 원소 간 데이터 의존성이 없습니다. 이런 “순수하고 상태가 없는” 연산은 par_unseq로 바꿔도 안전한 대표적인 사례입니다. 반대로 람다 안에서 외부 변수에 누적하거나, 이전 원소의 결과를 참조하는 순간 이 조건이 깨지고 병렬화는 물론 벡터화도 위험해집니다. 실무에서 transform을 병렬화할 때 가장 흔한 실수는 “일단 람다를 만들고 나중에 로깅이나 카운터를 추가”하는 패턴인데, 이렇게 되면 처음에는 안전했던 코드가 조용히 데이터 레이스를 갖게 됩니다.

예시 3: 병렬 집계

#include <execution>
#include <iostream>
#include <numeric>
#include <vector>

int main() {
    std::vector<int> v(10000000, 1);

    // 병렬 reduce
    int sum = std::reduce(std::execution::par, v.begin(), v.end(), 0);

    std::cout << "합: " << sum << std::endl;
}

std::accumulate 대신 std::reduce를 쓰는 이유가 바로 여기 있습니다. accumulate는 왼쪽에서 오른쪽 순서로 연산을 적용한다고 표준이 못박아두기 때문에 병렬화할 수 없습니다. reduce는 대신 결합 연산이 결합법칙과 교환법칙을 만족한다고 가정하고, 그 대가로 원소를 어떤 순서로 묶어 계산해도 되는 자유를 얻습니다. 정수 덧셈처럼 실제로 결합법칙이 성립하는 연산이라면 문제없지만, 부동소수점 덧셈은 수학적으로는 결합법칙이 성립해도 실제 부동소수점 연산에서는 반올림 오차 때문에 성립하지 않습니다. float/double 벡터를 std::reduce(par, ...)로 합산하면 실행할 때마다 스레드 분배 방식이 달라질 수 있고, 그에 따라 마지막 몇 비트 단위에서 결과가 미세하게 다르게 나올 수 있습니다. 그래서 부동소수점 결과를 비트 단위로 비교하는 테스트는 병렬 reduce로 바꾼 뒤 간헐적으로 실패할 수 있고, 허용 오차를 둔 비교로 바꿔야 합니다. 재현성이 중요한 코드(회계, 시뮬레이션 검증, 골든 테스트)에서는 이 트레이드오프를 반드시 인지하고 있어야 합니다.

예시 4: 조건부 병렬

#include <algorithm>
#include <execution>

template<typename T>
void conditionalSort(std::vector<T>& v, bool parallel = true) {
    if (parallel && v.size() > 10000) {
        std::sort(std::execution::par, v.begin(), v.end());
    } else {
        std::sort(v.begin(), v.end());
    }
}

실무 코드에서는 이렇게 크기 기반 임계값으로 병렬 실행 여부를 분기하는 패턴을 자주 씁니다. 다만 여기서 10000이라는 숫자는 특정 하드웨어와 데이터 타입, 비교 연산 비용을 기준으로 정해진 값이라 다른 환경에 그대로 옮기면 최적이 아닐 수 있습니다. 원소 타입의 비교 비용이 큰 경우(문자열 비교, 사용자 정의 비교자)라면 임계값을 낮춰도 병렬화 이득이 나지만, 단순 정수 비교라면 임계값을 훨씬 높여야 손해를 보지 않습니다. 이 값은 감으로 정하지 말고 실제 배포 환경과 유사한 조건에서 프로파일링한 뒤 결정하는 것이 안전합니다.

정책 선택 기준

정책을 고를 때는 세 가지 축을 함께 봐야 합니다. 데이터 크기가 스레드 생성/작업 분배 오버헤드를 상쇄할 만큼 큰지, 각 반복이 서로 독립적인지(공유 상태 접근 여부), 그리고 par_unseq로 갈 경우 함수 본문이 락·힙 할당·동기화 대기를 전혀 포함하지 않는지입니다.

// seq: 순차 (기본)
// - 단일 스레드
// - 예측 가능, 디버깅 용이

// par: 병렬
// - 멀티 스레드로 작업 분배
// - 공유 상태는 mutex/atomic으로 보호 가능

// par_unseq: 병렬 + 벡터화
// - SIMD + 멀티 스레드 인터리빙
// - 락/힙 할당/동기화 대기 절대 금지

처음부터 par_unseq를 목표로 삼기보다, 먼저 seq로 정확성을 검증하고 par로 바꿔 스레드 안전성 문제(레이스, 데드락)를 해결한 뒤, 함수 본문이 정말로 벡터화 안전 조건을 만족하는지 재검토하고 나서 par_unseq로 승격하는 단계적 접근이 사고를 줄여줍니다.

자주 발생하는 문제

문제 1: 데이터 레이스

int counter = 0;

std::vector<int> v(1000);

// ❌ 데이터 레이스
std::for_each(std::execution::par, v.begin(), v.end(), [&](int x) {
    ++counter;  // 레이스
});

// ✅ atomic
std::atomic<int> counter{0};
std::for_each(std::execution::par, v.begin(), v.end(), [&](int x) {
    ++counter;
});

일반 int& 캡처는 여러 스레드가 동시에 읽고-증가시키고-쓰는 read-modify-write 연산을 동기화 없이 수행하게 만듭니다. par로 넘어가는 순간 순차 실행을 전제로 짜여진 상태 공유 코드는 전부 의심해야 할 대상이 됩니다. std::atomic으로 바꾸는 것이 가장 간단한 해법이지만, 카운터 하나 정도가 아니라 값을 여러 번 갱신해야 하는 경우라면 매번 원자적 연산을 거는 비용이 누적되어 오히려 병렬화 이득을 깎아먹을 수 있습니다. 이럴 때는 스레드(또는 청크)별로 지역 변수에 누적한 뒤 마지막에 한 번만 합치는 방식이 원자적 연산 오버헤드를 크게 줄여줍니다.

문제 2: 동기화 프리미티브의 정책별 제약

std::mutex mtx;

// ❌ par_unseq에서 뮤텍스
std::for_each(std::execution::par_unseq, v.begin(), v.end(), [&](int x) {
    std::lock_guard lock{mtx};  // 정의되지 않은 동작
    // ...
});

// ✅ par에서 뮤텍스
std::for_each(std::execution::par, v.begin(), v.end(), [&](int x) {
    std::lock_guard lock{mtx};
    // ...
});

이 코드의 위험한 점은 컴파일러가 아무것도 알려주지 않는다는 것입니다. par에서 잘 동작하던 호출을 성능 때문에 par_unseq로 바꾸면 컴파일은 그대로 되고, 구현이 실제로 요소들을 인터리브하지 않는 환경에서는 테스트도 통과합니다. 하지만 정의되지 않은 동작이므로 다른 컴파일러 버전이나 다른 하드웨어에서 데드락이나 잘못된 결과로 나타날 수 있고, 증상이 매번 달라 원인을 좁히기 어렵습니다. 실행 정책을 par_unseq로 바꿀 때는 요소 접근 함수와 그 함수가 호출하는 코드 안에 락이나 조건 변수 대기가 없는지 먼저 확인하고, 공유 카운터가 필요하다면 락 대신 std::atomic(락 없이 구현되는 타입인지 확인)이나 std::reduce/std::transform_reduce로 구조를 바꾸는 편이 맞습니다.

문제 3: 오버헤드가 이득을 넘어서는 경우

std::vector<int> small(100);

// ❌ 작은 데이터에 병렬
std::sort(std::execution::par, small.begin(), small.end());
// 오버헤드 > 이득

// ✅ 큰 데이터에 병렬
std::vector<int> large(10000000);
std::sort(std::execution::par, large.begin(), large.end());

원소 100개를 정렬하는 작업은 비교 수백 번 수준이라 한 코어에서도 순식간에 끝납니다. 여기에 par를 붙이면 표준 라이브러리 구현이 작업을 스레드 풀에 나누고, 다른 스레드를 깨우고, 결과를 다시 모으는 비용이 더해지는데, 이 고정 비용이 실제 정렬 작업보다 커지기 쉽습니다. 구현에 따라서는 입력이 작으면 내부적으로 순차 실행으로 돌리기도 하지만, 표준이 그런 최적화를 보장하지는 않습니다. 그래서 정책을 붙이기 전에 대표적인 입력 크기로 seq와 비교해 보고, 필요하면 예시 4처럼 크기 임계값으로 분기하는 방식이 안전합니다.

문제 4: 예외 처리

// 병렬 실행 중 예외
try {
    std::for_each(std::execution::par, v.begin(), v.end(), [](int x) {
        if (x < 0) {
            throw std::runtime_error("음수");
        }
    });
} catch (...) {
    // 여기에 도달하지 않음: 요소 접근 함수에서 빠져나온 예외는
    // std::terminate()를 호출한다
}

병렬 알고리즘의 예외 처리는 순차 알고리즘과 근본적으로 다르게 접근해야 합니다. 표준은 병렬 알고리즘에 전달된 함수 객체가 던진 예외가 std::terminate()를 호출한다고 규정합니다. 이 규칙은 seq를 포함한 모든 표준 실행 정책에 적용되므로, 위 코드의 catch (...)는 요소 접근 함수가 던진 예외를 잡지 못하고 프로그램이 종료됩니다. 호출부로 전파되는 예외는 알고리즘이 내부 임시 메모리를 할당하지 못했을 때의 std::bad_alloc뿐입니다. 이는 실행 정책 없이 호출한 알고리즘에서 예외가 정상적으로 호출 스택을 타고 전파되는 것과는 전혀 다른 동작입니다. 여러 스레드에서 동시에 예외가 발생했을 때 “어느 스레드의 예외를 먼저 전파할지”를 정의하기 어렵고, 이미 다른 스레드에서 실행 중인 작업을 안전하게 취소하는 매커니즘도 표준화되어 있지 않기 때문에 이런 보수적인 설계를 택한 것으로 이해하면 됩니다. 실전에서는 람다 내부에서 예외를 던지는 대신, 오류 상태를 std::atomic<bool>이나 에러 코드 벡터에 기록해두고 알고리즘 호출이 끝난 뒤 바깥에서 검사하는 방식이 훨씬 안전합니다.

지원 알고리즘

// 대부분의 STL 알고리즘 지원
std::sort(policy, begin, end)
std::transform(policy, begin, end, out, func)
std::for_each(policy, begin, end, func)
std::reduce(policy, begin, end, init)
std::find(policy, begin, end, value)
// ...

C++17은 <algorithm>과 <numeric>에 있는 대부분의 알고리즘에 실행 정책을 받는 오버로드를 추가했습니다. 다만 모든 알고리즘이 똑같이 병렬화 이득을 보는 것은 아닙니다. sort, transform, reduce처럼 원소 단위 작업량이 많고 서로 독립적인 알고리즘은 병렬화 효과가 큰 편이지만, find처럼 조건을 만족하는 첫 원소를 찾으면 바로 끝나는 알고리즘은 상황에 따라 병렬 탐색이 오히려 불필요한 작업을 더 많이 수행하게 될 수도 있습니다(전체 범위를 나눠 동시에 탐색하다 보니, 이미 앞쪽에서 답을 찾았어도 뒤쪽 스레드가 하던 작업을 마저 끝내야 하는 경우가 있습니다). 새로운 알고리즘을 병렬화할 때는 “이 알고리즘의 조기 종료 특성이 병렬 실행과 잘 맞는가”도 함께 고려하는 것이 좋습니다.

같이 보면 좋은 글