Devin.KR

atomic 과 메모리 순서 - 잠금 없는 카운터의 조건

개발자KR 조회 10

이 장에서 배우는 것

앞 장에서 만든 작업 큐는 뮤텍스로 주문의 삽입과 꺼내기를 보호했다. 이제 주문을 처리한 횟수와 체결 수량도 기록하려 한다. 이 값들은 단순한 정수지만 여러 작업자가 동시에 바꾸므로 일반 변수로 공유할 수 없다. 그렇다고 모든 통계 갱신에 작업 큐의 뮤텍스를 사용할 필요도 없다.

이 장에서는 원자적 연산(atomic operation)이 보장하는 범위를 확인하고, 주문 통계와 설정 공개에 필요한 메모리 순서를 구분한다. 제목의 ‘잠금 없는 카운터’는 조건이 붙는 표현이다. std::atomic이라는 타입 이름만으로 구현이 잠금을 사용하지 않는다거나 처리 엔진 전체가 잠금 없이 동작한다고 판단할 수는 없다.

  • std::atomic의 읽기, 쓰기, 증가 연산을 용도에 맞게 선택한다.
  • 통계 누적과 데이터 공개에 서로 다른 메모리 순서를 적용한다.
  • 거짓 공유(false sharing)가 정확성과 별개로 성능에 영향을 주는 이유를 설명한다.
  • 여러 값을 함께 보호해야 하는 상황에서 뮤텍스를 선택한다.

문제 상황

작은 주문 처리 엔진에 작업자 두 개가 있다. 각 작업자는 주문을 검증하고, 유효한 주문을 체결 로그에 넣은 뒤 통계를 갱신한다. 운영 화면에는 체결 주문 수, 거절 주문 수, 누적 체결 수량을 표시할 예정이다.

처음에는 공유 정수에 ++filled를 실행했다. 그러나 증가는 현재 값을 읽고, 하나를 더하고, 결과를 저장하는 동작을 포함한다. 동기화 없이 두 스레드가 같은 일반 정수를 수정하면 데이터 경쟁(data race)이 생기며 프로그램의 동작은 정의되지 않는다. 결과가 가끔 작아지는 현상만으로 문제의 범위를 설명해서는 안 된다.

카운터를 원자 타입으로 바꾼 뒤에도 설계 질문은 남는다. 체결 수가 증가했다는 사실만 보고 로그를 읽어도 되는가. 각각 읽은 세 통계는 같은 순간의 상태인가. 시작 신호를 받은 작업자가 초기 설정을 볼 수 있는가. 모두 ‘공유 데이터’에 관한 질문이지만 같은 해법을 적용할 수는 없다.

완성 프로그램에서는 주문을 작업자별로 나누어 처리한다. 큐 구현을 반복하지 않고, 큐에서 주문을 꺼낸 뒤에 해당하는 처리 부분에 집중한다. 시작 설정은 한 번 공개하고, 통계는 개별 원자 연산으로 누적하며, 체결 로그는 뮤텍스로 보호한다.

원자 연산의 단위와 잠금 없는 구현

std::atomic<T>는 지원되는 타입의 값을 원자적으로 읽고 수정하는 인터페이스다. 같은 원자 객체에 대한 수정에는 그 객체만의 일관된 순서가 존재한다. 다만 여러 원자 객체를 묶은 거래나, 주변 일반 변수의 자동 보호까지 제공하지는 않는다.

주문 카운터에 사용하는 원자 연산의 역할
연산하는 일반환값과 주의점
load현재 값을 읽는다읽은 값을 반환한다
store새 값을 저장한다기존 값을 반환하지 않는다
fetch_add읽기와 덧셈과 저장을 하나로 수행한다증가하기 전 값을 반환한다
exchange새 값으로 교체한다교체하기 전 값을 반환한다
compare_exchange_weak기대값과 일치하면 새 값으로 교체한다실패하면 기대값을 실제 읽은 값으로 갱신한다

단순 누적에는 fetch_add가 적합하다. load와 store를 따로 호출하면 각각은 원자적이지만 두 호출을 합친 갱신은 원자적이지 않다. 두 작업자가 모두 10을 읽고 각각 11을 저장할 수 있다. 이 경우 원자 객체 자체에는 데이터 경쟁이 없어도 증가 하나가 사라지는 논리 오류가 생긴다.

std::atomic<unsigned> filled{0};
filled.fetch_add(1, std::memory_order_relaxed);

++filled도 원자적인 증가다. 다만 기본 메모리 순서를 사용하므로, 통계의 목적을 코드에 드러내려면 순서를 명시한 fetch_add가 편리하다. 모든 원자 연산의 기본 순서는 std::memory_order_seq_cst다.

조건부 갱신에는 비교 후 교체를 사용한다. compare_exchange_weak는 값이 같아도 실패할 수 있으므로 보통 재시도 반복문에 넣는다. 실패 시 기대값이 바뀐다는 점도 계산에 반영해야 한다. 단순 증가를 위해 이런 반복문을 직접 만들 필요는 없다.

원자성과 잠금 없는 구현은 다른 속성이다. 구현체는 일부 원자 타입을 내부 잠금으로 구현할 수 있다. 특정 객체의 구현 속성은 is_lock_free()로 확인한다. std::atomic<T>::is_always_lock_free는 해당 구현에서 그 타입이 항상 잠금 없이 제공되는지를 컴파일 시점에 나타낸다. 정수 폭이 작다는 이유만으로 이 속성을 가정하지 않는다.

잠금 없는 구현(lock-free)이라는 말은 모든 호출이 일정한 시간 안에 끝난다는 뜻도 아니다. 경합 상황에서는 특정 스레드의 진행이 늦어질 수 있다. 지원 여부가 요구사항이라면 다음과 같이 빌드 조건으로 둘 수 있다. 이 조건은 이식 가능한 실행 예제에는 넣지 않는다.

static_assert(
    std::atomic<std::uint64_t>::is_always_lock_free,
    "This target requires lock-free 64-bit atomics.");

메모리 순서는 무엇을 연결하는가

메모리 순서는 원자 연산 주변의 접근들이 스레드 사이에서 어떤 순서 관계를 갖는지 정한다. ‘메모리를 즉시 새로 고친다’거나 ‘캐시를 모두 비운다’는 설명으로 이해하면 적용 범위를 잘못 잡기 쉽다. 중요한 질문은 다른 스레드가 어떤 값을 관측했을 때, 그 앞의 어떤 작업까지 관측하도록 보장해야 하는가다.

이 장에서 사용하는 메모리 순서의 보장과 용도
순서핵심 보장주문 엔진의 용도
relaxed해당 연산의 원자성을 보장하되 다른 데이터의 공개를 연결하지 않는다독립적인 통계 누적
release앞선 작업을 획득 연산과 연결할 출발점을 만든다초기 설정을 쓴 뒤 시작 신호 저장
acquire대응하는 공개를 관측하면 그 앞의 작업을 이후 접근과 연결한다시작 신호를 읽은 뒤 설정 사용
seq_cst해당 순서의 연산들에 하나의 전역 순서를 추가한다기본 선택, 여러 원자 연산의 순서 추론

수만 세는 경우에는 relaxed가 충분하다

통계 증가가 다른 데이터의 준비 완료를 알리지 않는다면 relaxed를 고려할 수 있다. 이 순서는 증가를 잃어도 좋다는 뜻이 아니다. 각 fetch_add는 여전히 하나의 원자적인 갱신이다. 다만 카운터가 증가한 것을 보았다는 사실만으로 체결 로그의 내용을 읽을 권한이 생기지는 않는다.

실행 중 통계는 변하고 있으므로 한 번 읽은 값은 곧 과거 값이 될 수 있다. 또 체결 수와 체결 수량을 각각 읽어 만든 조합이 실제 어느 한 시점에 존재했던 조합이라는 보장은 없다. 이것은 더 강한 순서를 붙이는 것만으로 해결되지 않는다.

설정을 공개할 때는 release와 acquire를 연결한다

메인 스레드가 최대 주문 수량을 일반 변수에 쓴 다음, 시작 플래그에 release로 참을 저장한다고 하자. 작업자가 같은 플래그를 acquire로 읽어 그 저장의 참을 관측하면, 설정 쓰기는 작업자의 설정 읽기에 선행한다. 이러한 스레드 사이의 선행 관계(happens-before)가 일반 변수 접근을 안전하게 연결한다.

같은 시작 플래그의 release 저장을 acquire 읽기가 관측하면 앞선 설정 쓰기가 이후 설정 읽기에 연결된다

이 예제에서 플래그에 참을 쓰는 곳은 하나뿐이다. 따라서 참을 읽었다면 어느 저장과 연결되는지 분명하다. acquire라는 이름만 붙였다고 아무 release와 연결되는 것은 아니다. 같은 원자 객체에서 어떤 쓰기의 값을 읽었는지 확인해야 한다.

설정은 공개 후 바꾸지 않는다. 시작 신호가 한 번 전달됐다는 이유로 이후의 설정 수정도 보호되는 것은 아니다. 설정을 반복해서 교체하려면 읽는 동안의 수명과 수정 충돌까지 포함한 별도 설계가 필요하다. 플래그를 거짓과 참으로 되돌리는 것만으로는 충분하지 않다.

load에는 release를, store에는 acquire를 지정하지 않는다. 읽기와 쓰기는 수행하는 역할이 다르다. 읽고 수정하는 연산에는 두 역할이 필요할 수도 있지만, 이 장의 통계 누적에는 필요하지 않다.

seq_cst도 여러 문장을 하나로 묶지 않는다

seq_cst는 가장 강한 기본 선택이다. 해당 순서로 수행한 연산들을 하나의 전역 순서 안에서 생각할 수 있어 추론의 출발점으로 유용하다. 그러나 이 전역 순서는 프로그램의 모든 일반 메모리 접근을 자동으로 보호하는 규칙이 아니다.

체결 수를 늘린 다음 체결 수량을 늘리는 두 연산 사이에는 다른 스레드의 읽기가 들어갈 수 있다. 두 연산을 모두 seq_cst로 바꾸어도 마찬가지다. 필요한 보장이 한 객체의 갱신인지, 여러 객체의 일관된 상태인지를 먼저 구별해야 한다.

표준 라이브러리의 연산별 제약과 메모리 순서 정의는 C++ 표준 작업 초안의 원자 연산 순서 항목에서 확인할 수 있다. 이 문서는 규칙 확인에 사용하고, 실제 설계에서는 보호할 데이터와 동기화 경로를 코드 옆에 적는 편이 도움이 된다.

경합, 거짓 공유, 뮤텍스의 경계

하나의 전역 카운터를 여러 작업자가 계속 갱신하면 그 카운터를 둘러싼 경합이 생긴다. relaxed는 순서 제약을 줄이는 선택이지, 같은 메모리 위치에 대한 갱신 비용을 제거하는 선택이 아니다. 정확한 실시간 합계가 필요하지 않다면 작업자별로 누적한 뒤 합산하는 설계도 가능하다.

그런데 카운터를 나누어도 물리적으로 가까이 놓으면 기대만큼 빨라지지 않을 수 있다. 하드웨어는 보통 개별 정수보다 큰 캐시 라인(cache line) 단위로 일관성을 관리한다. 서로 다른 작업자가 서로 다른 정수만 수정해도 같은 라인에 놓이면 라인의 소유 상태를 주고받는 비용이 생길 수 있다. 이것이 거짓 공유다.

작업자별 카운터가 서로 다른 캐시 라인에 놓이면 같은 라인에 대한 불필요한 경합을 줄일 수 있다

거짓 공유는 서로 다른 객체를 잘못 공유했다는 뜻이 아니다. 올바른 프로그램에도 발생하는 성능 문제다. 반대로 모든 작업자가 같은 카운터를 수정하는 실제 공유는 그 카운터 주변에 여백을 추가한다고 사라지지 않는다.

카운터를 분리할 때는 정렬과 객체 간 간격을 함께 고려한다. 환경이 지원하면 <new>의 std::hardware_destructive_interference_size를 정렬 기준으로 검토할 수 있다. 이 값은 구현이 제공하는 간격 기준이며, 모든 장치에서 같은 숫자가 나온다고 가정하지 않는다. alignas(64)를 쓰는 경우에도 64바이트가 대상 환경에 맞는지 검증해야 한다.

패딩은 메모리 사용량과 순회 비용을 늘릴 수 있다. 전역 카운터의 갱신 빈도, 작업자별 분할, 간격 조정을 실제 부하에서 비교해야 한다. 완성 프로그램의 작은 주문 묶음은 정확성 설명을 위한 것이므로 성능 측정 자료로 쓰지 않는다.

여러 값이 함께 불변식을 이루면 뮤텍스가 더 직접적인 도구다. 예를 들어 잔여 한도를 줄이면서 예약 수량을 늘리고, 그 결과를 일관되게 조회해야 한다면 전체 변경과 조회를 같은 잠금으로 감싼다. 로그 컨테이너의 구조 변경도 별도 보호가 필요하다. 원자 카운터를 옆에 두었다고 컨테이너의 재할당이나 요소 접근까지 안전해지지는 않는다.

완성 코드

다음 프로그램은 주문 여덟 개를 두 작업자에게 나누어 준다. 작업자는 시작 신호를 기다린 뒤 최대 수량을 읽고 주문을 처리한다. 양수이면서 최대 수량 이하인 주문만 체결 로그에 기록한다. 모든 작업자를 합류시킨 뒤 로그를 정렬하므로 출력 순서는 실행마다 같다.

카운터는 독립적인 통계이고, 로그는 뮤텍스로 보호한다. 로그 기록이 예외로 실패한 경우에는 작업자의 예외를 보관하고 합류 후 다시 던진다. 스레드 생성 도중 실패한 경우에도 이미 시작한 작업자가 대기에서 빠져나오도록 시작 신호를 공개한다.

#include <algorithm>
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

struct Order {
    int id;
    int quantity;
};

struct Config {
    int max_quantity = 0;
};

struct Statistics {
    std::atomic<std::uint64_t> filled{0};
    std::atomic<std::uint64_t> rejected{0};
    std::atomic<std::uint64_t> volume{0};
};

int main() {
    try {
        constexpr std::size_t worker_count = 2;
        const std::array<Order, 8> orders{{
            {101, 5}, {102, 0}, {103, 3}, {104, -2},
            {105, 7}, {106, 1}, {107, 0}, {108, 4}
        }};

        Config config;
        std::atomic<bool> ready{false};
        Statistics statistics;
        std::mutex log_mutex;
        std::vector<Order> execution_log;
        execution_log.reserve(orders.size());

        std::array<std::exception_ptr, worker_count> errors{};
        std::vector<std::jthread> workers;
        workers.reserve(worker_count);

        auto process = [&](std::size_t worker_index) {
            try {
                while (!ready.load(std::memory_order_acquire)) {
                    std::this_thread::yield();
                }

                const int limit = config.max_quantity;
                for (std::size_t i = worker_index;
                     i < orders.size(); i += worker_count) {
                    const Order& order = orders[i];

                    if (order.quantity <= 0 || order.quantity > limit) {
                        statistics.rejected.fetch_add(
                            1, std::memory_order_relaxed);
                        continue;
                    }

                    {
                        std::lock_guard<std::mutex> guard(log_mutex);
                        execution_log.push_back(order);
                    }

                    statistics.filled.fetch_add(
                        1, std::memory_order_relaxed);
                    statistics.volume.fetch_add(
                        static_cast<std::uint64_t>(order.quantity),
                        std::memory_order_relaxed);
                }
            } catch (...) {
                errors[worker_index] = std::current_exception();
            }
        };

        try {
            for (std::size_t i = 0; i < worker_count; ++i) {
                workers.emplace_back(process, i);
            }
        } catch (...) {
            ready.store(true, std::memory_order_release);
            throw;
        }

        config.max_quantity = 10;
        ready.store(true, std::memory_order_release);

        for (auto& worker : workers) {
            worker.join();
        }

        for (const auto& error : errors) {
            if (error) {
                std::rethrow_exception(error);
            }
        }

        std::sort(execution_log.begin(), execution_log.end(),
                  [](const Order& left, const Order& right) {
                      return left.id < right.id;
                  });

        std::cout << "filled="
                  << statistics.filled.load(std::memory_order_relaxed)
                  << '\n';
        std::cout << "rejected="
                  << statistics.rejected.load(std::memory_order_relaxed)
                  << '\n';
        std::cout << "volume="
                  << statistics.volume.load(std::memory_order_relaxed)
                  << '\n';

        for (const Order& order : execution_log) {
            std::cout << "fill " << order.id
                      << " quantity=" << order.quantity << '\n';
        }
    } catch (const std::exception& error) {
        std::cerr << "error: " << error.what() << '\n';
        return 1;
    } catch (...) {
        std::cerr << "error: unknown failure\n";
        return 1;
    }
}

줄별 해설

Order는 주문 번호와 수량을 담는다. 입력 배열은 생성 이후 수정하지 않는다. 두 작업자가 같은 배열을 읽더라도 쓰기가 없으므로 배열 자체에 잠금을 붙일 필요는 없다.

Config의 수량 제한은 일반 정수다. 작업자를 만든 뒤 메인 스레드가 값을 설정하므로 스레드 생성만으로는 이 쓰기를 공개할 수 없다. 뒤에 나오는 시작 플래그가 필요한 이유다.

Statistics의 세 원자 객체는 각각 0으로 초기화한다. 카운터의 역할을 통계로 한정한다. 로그의 준비 상태나 다른 카운터와의 동시 변경을 나타내는 신호로 사용하지 않는다.

execution_log.reserve는 작업자를 시작하기 전에 저장 공간을 확보한다. 이것은 할당을 줄이는 선택이며 동기화 수단은 아니다. 예약된 용량이 충분하더라도 여러 스레드의 push_back에는 잠금이 필요하다.

errors는 작업자마다 다른 배열 요소를 사용한다. 각 작업자는 자기 요소만 수정하고 메인 스레드는 합류 이후에만 읽는다. 같은 요소에 대한 동시 접근이 없으므로 이 배열을 원자 타입으로 만들 필요는 없다.

workers는 작업자가 참조하는 공유 객체들보다 나중에 선언한다. 예외로 범위를 떠날 때는 역순으로 파괴되므로 작업자들이 합류한 뒤 공유 객체가 파괴된다. 시작 플래그를 공개하는 예외 처리도 함께 있어야 대기 중인 작업자의 합류가 막히지 않는다.

ready.load(acquire) 반복문은 참을 관측할 때까지 기다린다. yield는 스케줄러에 실행 기회를 양보하라는 힌트일 뿐, 동기화를 만들거나 일정한 대기 시간을 보장하지 않는다. 실제 서비스에서 준비가 오래 걸릴 수 있다면 앞 장에서 다룬 조건 변수 같은 대기 수단을 검토한다.

const int limit = config.max_quantity는 참을 읽은 다음 실행한다. 정상 경로의 release 저장 앞에서 설정한 10을 안전하게 읽는다. 이후 설정을 수정하지 않으므로 반복 처리 중에는 지역 변수만 사용한다.

반복문의 시작 인덱스는 작업자 번호이고 증가량은 작업자 수다. 작업자 0은 짝수 인덱스, 작업자 1은 홀수 인덱스를 처리한다. 같은 주문을 두 번 처리하지 않으며 별도의 공유 인덱스도 필요하지 않다.

검증 실패 시 거절 수만 늘린다. 검증에 성공하면 잠금 범위에서 로그를 추가하고, 잠금을 해제한 뒤 체결 수와 수량을 각각 늘린다. 따라서 실행 중에는 로그와 통계 사이에 짧은 차이가 존재할 수 있다. 이 프로그램은 실행 중 일관된 조회를 제공하지 않는다.

static_cast는 양수 검증을 통과한 수량을 누적용 부호 없는 타입으로 바꾼다. 이 예제의 합계는 20이므로 범위 안에 있다. 장기 운영 통계에서는 원자성 외에 카운터의 범위와 초기화 정책도 정해야 한다.

스레드 생성이 실패하면 설정의 기본값 0을 유지한 채 시작 신호를 공개하고 예외를 다시 던진다. 이미 만들어진 작업자들은 대기를 끝내고 종료하며, jthread의 소멸자가 합류한다. 이 경로에서는 정상 통계를 출력하지 않는다.

정상 경로의 join은 작업자 종료와 메인 스레드를 동기화한다. 합류가 끝난 뒤에는 더 이상 통계나 로그를 쓰는 작업자가 없다. 그러므로 마지막 통계 읽기는 relaxed여도 완료된 누적 결과를 읽는다. 정확한 최종 결과를 보장하는 근거는 단순한 시간 경과가 아니라 합류다.

마지막 정렬과 출력은 메인 스레드만 수행한다. 작업 중 로그 삽입 순서는 달라질 수 있지만 주문 번호로 정렬하여 출력은 고정한다. 이 엔진은 로그에 뮤텍스를 사용하므로 전체가 잠금 없는 알고리즘은 아니다.

실행 결과

코드를 atomic_orders.cpp로 저장한다. C++20의 std::jthread를 제공하는 컴파일러와 표준 라이브러리가 필요하다. 다음은 macOS 또는 Linux에서 사용할 빌드 및 실행 명령이다.

c++ -std=c++20 -Wall -Wextra -pthread atomic_orders.cpp -o atomic_orders
./atomic_orders

자원 할당과 스레드 생성이 성공한 정상 실행의 예상 출력은 다음과 같다. 수량 0인 주문 두 개와 음수 주문 하나가 거절된다.

filled=5
rejected=3
volume=20
fill 101 quantity=5
fill 103 quantity=3
fill 105 quantity=7
fill 106 quantity=1
fill 108 quantity=4

반복 실행에서 출력이 같다는 사실만으로 동기화가 올바르다고 증명할 수는 없다. 경쟁 검사 도구는 일반 변수의 잘못된 동시 접근을 찾는 데 도움이 되지만, 원자 연산의 조합에서 생기는 논리 오류를 모두 찾지는 못한다.

실무에서 자주 틀리는 것

원자적인 읽기와 쓰기를 원자적인 증가로 생각한다

다음 코드는 다른 작업자의 증가를 덮어쓸 수 있다. 순서를 seq_cst로 바꾸어도 읽기와 쓰기 사이가 열려 있다는 문제는 남는다.

// 틀린 코드: 두 호출 사이에 다른 증가가 들어갈 수 있다.
const auto old = filled.load(std::memory_order_relaxed);
filled.store(old + 1, std::memory_order_relaxed);

읽은 값을 바탕으로 증가시켜야 한다면 읽기와 수정이 결합된 연산을 사용한다.

// 고친 코드
filled.fetch_add(1, std::memory_order_relaxed);

relaxed 플래그로 일반 데이터를 공개한다

다음 조각에서는 두 스레드가 이미 실행 중이라고 가정한다. 플래그 자체의 접근은 원자적이지만 설정 쓰기와 읽기 사이의 동기화가 없다.

// 틀린 코드: 작성 스레드
config.max_quantity = 10;
ready.store(true, std::memory_order_relaxed);

// 틀린 코드: 읽는 스레드
if (ready.load(std::memory_order_relaxed)) {
    use(config.max_quantity);
}

한 번 공개하고 이후 수정하지 않는 설정이라면 저장과 읽기를 다음처럼 연결한다. 여기서 use는 설정을 사용하는 처리를 뜻한다.

// 고친 코드: 작성 스레드
config.max_quantity = 10;
ready.store(true, std::memory_order_release);

// 고친 코드: 읽는 스레드
if (ready.load(std::memory_order_acquire)) {
    use(config.max_quantity);
}

각각 읽은 원자 통계를 일관된 묶음으로 취급한다

다음 읽기는 실행 중인 완성 프로그램에 끼워 넣으면 같은 순간의 체결 수와 수량을 얻는다고 보장할 수 없다. 개별 읽기에 문제가 없어도 두 읽기 사이에서 작업이 진행된다.

// 틀린 가정: 두 값이 반드시 같은 순간의 통계다.
const auto count = statistics.filled.load();
const auto volume = statistics.volume.load();

실행 중 일관된 통계 묶음이 요구된다면 갱신과 조회를 같은 뮤텍스로 보호하는 설계로 바꿀 수 있다. 다음은 별도 통계 타입의 예다. 모든 접근이 이 잠금 규칙을 따라야 한다.

struct Totals {
    std::mutex mutex;
    std::uint64_t filled = 0;
    std::uint64_t volume = 0;
};

void record(Totals& totals, std::uint64_t quantity) {
    std::lock_guard<std::mutex> guard(totals.mutex);
    ++totals.filled;
    totals.volume += quantity;
}

std::array<std::uint64_t, 2> snapshot(Totals& totals) {
    std::lock_guard<std::mutex> guard(totals.mutex);
    return {totals.filled, totals.volume};
}

한도 검사와 차감을 따로 수행한다

원자적인 잔여 한도를 읽어 충분한지 확인한 다음 차감하면, 다른 작업자도 같은 한도를 보고 통과할 수 있다. 다음 코드의 remaining이 원자 정수여도 검사와 차감 전체는 보호되지 않는다.

// 틀린 코드: quantity는 양수라고 가정한다.
if (remaining.load() >= quantity) {
    remaining.fetch_sub(quantity);
    reserved.fetch_add(quantity);
}

잔여 한도와 예약 수량을 함께 관리하려면 뮤텍스 안에서 검사와 변경을 수행한다. 다음 설계는 두 값이 음수가 아니고 합계가 처음 한도를 유지한다는 불변식을 보호한다. 조회도 같은 잠금을 사용해야 한다.

struct Capacity {
    std::mutex mutex;
    int remaining = 100;
    int reserved = 0;
};

bool reserve(Capacity& capacity, int quantity) {
    std::lock_guard<std::mutex> guard(capacity.mutex);
    if (quantity <= 0 || quantity > capacity.remaining) {
        return false;
    }
    capacity.remaining -= quantity;
    capacity.reserved += quantity;
    return true;
}

한눈에 보기

필요한 보장에 따른 주문 엔진의 도구 선택
요구사항선택확인할 조건
독립적인 횟수 누적원자 증가와 relaxed다른 데이터의 준비 신호로 쓰지 않는다
한 번 작성한 설정 공개release 저장과 acquire 읽기공개 이후 설정을 수정하지 않는다
모든 처리 이후 최종 통계작업자 합류 후 읽기다른 작성자가 남아 있지 않다
여러 값의 일관된 갱신과 조회같은 뮤텍스모든 접근이 동일한 잠금 규칙을 따른다
잦은 통계 갱신의 경합 감소작업자별 누적과 간격 검토실제 부하에서 비용과 메모리를 측정한다
잠금 없는 원자 구현 요구타입 또는 객체의 속성 검사엔진 전체의 진행 보장과 구별한다

원자 연산을 선택하기 전에 보호하려는 상태의 범위를 적는다. 한 정수의 갱신이면 원자 연산이 잘 맞을 수 있다. 여러 값의 관계라면 뮤텍스가 더 간단한 근거를 제공한다. 다음 장에서는 공유 통계를 직접 읽는 방식에서 나아가, 작업이 만든 결과를 전달받고 완료를 기다리는 방법을 다룬다.

연습 문제

  1. 완성 코드에서 체결 수와 체결 수량의 연산을 모두 seq_cst로 바꾸었다. 실행 중 두 값을 읽으면 항상 일관된 통계가 되는지 설명하라.
  2. 완성 코드에서 시작 플래그는 유지하되, 최대 수량 설정을 작업자를 생성하기 전으로 옮겼다. 생성 이후 설정을 바꾸지 않는다면 설정 공개를 위해 플래그가 필요한지 설명하라.
  3. 최종 통계만 필요하다는 조건으로, 세 전역 원자 카운터를 작업자별 일반 정수 통계로 바꾸는 설계를 제시하라. 합산 시점과 거짓 공유 가능성을 설명하라.
  4. 모든 작업자 합류 이후 체결 수를 읽는 대신 exchange(0, relaxed)로 가져오면 반환값과 객체의 값은 각각 얼마인가. 같은 연산을 처리 도중 수행해도 로그와 수량까지 한꺼번에 초기화되는지 설명하라.

정답과 해설

  1. 항상 일관되지는 않는다. 작업자는 체결 수와 수량을 서로 다른 연산으로 바꾼다. 조회가 그 사이에 들어가면 새 체결 수와 이전 수량을 얻을 수 있다. 전역 순서는 여러 연산을 하나의 묶음으로 합치지 않는다. 실행 중 일관된 조회가 필요하면 갱신과 조회를 같은 잠금으로 보호한다.

  2. 설정 공개만을 위해서는 필요하지 않다. 스레드 생성 이전에 완료한 설정 쓰기는 해당 스레드 함수의 실행에 전달된다. 설정 객체가 작업자보다 오래 살고 이후 수정되지 않는다는 조건도 유지해야 한다. 여러 작업자를 한꺼번에 출발시키려는 별도 목적이 있다면 시작 플래그를 남길 수 있다.

  3. 작업자 수만큼 일반 정수 통계 구조체를 준비하고 각 작업자가 자기 요소만 수정하도록 한다. 메인 스레드는 모든 작업자를 합류시킨 뒤 각 요소를 더한다. 이 조건에서는 통계에 원자 연산이 필요하지 않다. 다만 서로 다른 요소가 같은 캐시 라인에 놓이면 거짓 공유가 생길 수 있다. 더 나아가 작업자 함수의 지역 변수로 누적하고 종료 직전에 자기 요소로 한 번 옮기면 공유 메모리 쓰기 횟수도 줄일 수 있다.

  4. 정상 처리 이후 반환값은 5이고 체결 수 객체의 새 값은 0이다. 처리 중에도 해당 카운터의 교체 자체는 원자적이다. 그러나 체결 수량과 로그는 별개의 상태이므로 함께 초기화되지 않는다. 일정 구간의 통계를 정확히 묶어서 가져와야 한다면 구간 경계와 여러 값의 갱신을 함께 보호하는 설계가 필요하다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.