ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] const와 Thread safety
    C++/Effective Modern C++ 2022. 5. 3. 15:02

    const로 선언된 함수는 내부에서 값의 수정이 일어나지 않는다고 기대할 수 있습니다.

    그리고 이러한 함수는 멀티스레드 프로그램에서 스레드에 안전할 것 입니다.

    하지만, 예외되는 경우가 존재하는데, 이번 글에서는 이에 대해서 살펴보도록 하겠습니다.

     


     

    생각할 수 있는 가장 간단한 예제는 아래와 같습니다.

    class MyClass{
    public:
        int calc() const {
            if (!data_valid){
                ...
                data_valid = true;
            }
            
            return data;
        }
    private:
        mutable bool data_valid { false };
        mutable int data { 0 };
    };

    함수의 값을 계산하고 캐싱하는 예제입니다.

    값에 대한 계산 비용이 큰 값들의 경우 위와 같이 캐싱하는 방식을 사용할 수 있습니다.

    계산 과정에서 값의 수정이 일어나기 때문에 멤버 변수에 mutable 선언이 되어 있습니다.

    MyClass c;
    ...
    // Thread 1
    auto val_1 = c.calc();
    
    // Thread 2
    auto val_2 = c.calc();

    const함수는 읽기 연산이기 때문에, 여러 스레드에서 동기화 없이 호출하는 것이 정상입니다.

    개념적으로 보면 합당한 일이지만, 실제로는 안전하지 않을 수 있습니다.

    위 두 함수의 호출에서 동시에 값을 수정하게 될 수 있고, 이 경우 데이터 레이스가 발생할 수 있습니다.

     

    1. std::mutex

     

    더보기

    위 문제를 해결하는 가장 간단한 방법은 동기화를 사용하는 것 입니다.

    class MyClass{
    public:
        int calc() const {
            std::lock_guard<std::mutex> g(m);
            
            if (!data_valid){
                ...
                data_valid = true;
            }
            
            return data;
        }
    private:
        mutable std::mutex m;
        mutable bool data_valid { false };
        mutable int data { 0 };
    };

    위와 같이 mutex로 객체를 이용할 경우 데이터 레이스 문제를 해결할 수 있습니다.

    mutex가 mutable로 선언된 이유는 const멤버 함수 내부에서는 mutex가 const객체로 간주되기 때문입니다.

    또한 위와 같은 경우 mutex는 복사, 이동이 불가하기 때문에 MyClass 객체가 복사, 이동이 불가능한 클래스가 되는 점을 유의해야 합니다.

     

    2. std::atomic

     

    더보기

    동기화에 사용할 수 있는 수단이 뮤택스만 존재하는 것은 아닙니다.

    때때로는 뮤택스를 도입하는 것이 과하다는 생각이 들 수 있습니다.

    예를 들어, 함수가 호출된 횟수를 세는 일에는 뮤택스를 도입하는 것 보다는 std::atomic을 이용하는 것이 더 저렴할 수 있습니다.

    class Myclass{
    public:
        ...
        void function() const {
            ...
            ++call_count;
            ...
        }
    private:
        mutable std::atomic<unsigned> call_count { 0 };
    };

    std::atmoic<>은 쓰기 연산 시 한 스레드만 접근이 가능한 객체입니다.

    따라서 데이터 레이스 문제에서 자유로우며, 상대적으로 뮤택스를 사용해 스코프를 잠그고, 푸는 연산보다 저렴합니다.

     

    std::atmoic<>객체에 대한 쓰기 연산 자체는 저렴하지만, 이것이 뮤택스보다 비효율적으로 되는 상황 또한 존재합니다.

    코드를 먼저 살펴보도록 하겠습니다.

    #include <iostream>
    #include <atomic>
    using namespace std;
    
    class MyClass {
    public:
        ...
        int calc() const {
            if (!cache_valid)return cache_val;
            else {
                auto val_1 = expensive_calc_1();
                auto val_2 = expensive_calc_2();
                cache_val = val_1 + val_2;
                cache_valid = true;
                return cache_val;
            }
        }
    private:
        mutable std::atomic<bool> cache_valid{ false };
        mutable std::atomic<int> cache_val{ 0 };
    };

     위 코드에서, 다음과 같은 상황을 가정해보도록 하겠습니다.

    • 한 스레드가 MyClass::calc를 호출합니다. cache_valid가 false일 때, 비용이 큰 expensive_calc_1,2를 수행하고, 두 값의 합을 cache_val에 배정합니다.
    • cache_valid가 true가 되기 전에, 다른 스레드가 MyClass::calc를 호출합니다. 비용이 큰 연산이 또 다시 수행됩니다. (이 때, MyClass::calc를 호출하는 스레드가 여러개일 수 있습니다.)

    위 문제를 해결하기 위해, 아래와 같이 합연산과 valid 수정을 바꾸는 경우를 생각할 수 있습니다.

        ...
        int calc() const {
            if (!cache_valid)return cache_val;
            else {
                auto val_1 = expensive_calc_1();
                auto val_2 = expensive_calc_2();
                cache_valid = true;
                return cache_val = val_1 + val_2;
            }
        }
        ...

    하지만 이 코드에서는 아래와 같은 상황이 발생할 수 있습니다.

    • 한 스레드가 MyClass::calc를 호출합니다. cahce_valid가 false일 때, 비용이 큰 expensive_calc_1,2를 수행하고, cache_valid를 true로 변경합니다.
    • 이 때 다른 스레드가 MyClass::calc를 호출합니다. cache_valid가 true이므로, cache_val을 반환합니다. 첫 스레드에서 합연산이 수행되지 않았으므로, cache_val의 값은 정확하지 않습니다.

     

    3. mutex VS atomic

     

    더보기

    위 문단에서, atomic은 mutex보다 비용이 저렴하지만, 특정 상황에서 큰 비용이 발생할 수 있습니다.

    예를 들어, 두번째 문단의 atomic을 살펴볼 때와 같은 코드는 뮤택스를 이용하는 것이 저렴할 수 있습니다.

    #include <iostream>
    #include <atomic>
    using namespace std;
    
    class MyClass {
    public:
        ...
        int calc() const {
            std::lock_guard<std::mutex> guard(m)
        
            if (!cache_valid)return cache_val;
            else {
                auto val_1 = expensive_calc_1();
                auto val_2 = expensive_calc_2();
                cache_val = val_1 + val_2;
                cache_valid = true;
                return cache_val;
            }
        }
    private:
        mutable std::mutex m;
        mutable bool cache_valid{ false };
        mutable int cache_val{ 0 };
    };

    동기화가 필요한 변수, 메모리가 하나일때에는 atomic을 사용하는 것이 좋지만, 두 개 이상의 변수, 메모리를 묶어서 관리할 필요가 있을 경우에는 mutex를 사용하는 것이 좋습니다.

     


     

    이번 글은 여러 스레드가 하나의 const멤버 함수를 동시에 실행한다는 가정이 전제되어 있습니다.

    한정적인 상황인 만큼 사용되는 범위는 적지만, Thread safety는 매우 중요한 문제이므로 유의해야 합니다.

    사용자의 입장에서는 const멤버 함수를 이용할 때 Thread safety에 대해 일반적으로 안전하다고 생각할 것이기 때문에, 이러한 함수를 개발할 때에는 위와 같은 상황이 일어나지 않도록 유의해야 합니다.

     

    감사합니다.

    댓글

Designed by Tistory.