728x90
반응형

멤버 함수가 멤버 변수들을 수정하지 않는다면 const로 선언하는 것이 자연스럽다. 그런데 스레드를 사용하면 문제가 생길 수 있다.

아래의 예시를 봐보자.

mutex 사용

다음은 다항식의 근을 구하는 함수 roots를 구현하는 내용이다. 성능 향상을 위해 캐싱을 이용하였으며, rootsAreValid의 값을 이용하여 캐싱 여부를 판별한다.

class Polynomial
{
public:
    using RootsType = std::vector<double>;
    
    RootsType roots() const
    {
        if(!rootsAreValid)
        {
            ...
            rootsAreValid = true;
        }
    }
    
private:
    mutable bool rootsAreValid { false };
    mutable RootsType rootVals {};
};

그런데 여기서 두 개의 스레드가 roots 함수를 호출하는 경우를 생각해보면, rootsAreValid와 rootVals는 스레드에 안전하지 않기 때문에 mutex를 써야 한다. 문제는 roots 함수가 const함수이기 때문에 mutex 또한 mutable로 선언해야 한다.

mutable : 멤버 변수의 값을 const함수에서도 바꿀 수 있게 해주는 키워드

class Polynomial
{
public:
    using RootsType = std::vector<double>;
    
    RootsType roots() const
    {
        std::lock_guard<std::mutex> g(m);
        if(!rootsAreValid)
        {
            ...
            rootsAreValid = true;
        }
    }
    
private:
    mutable std::mutex m;
    mutable bool rootsAreValid { false };
    mutable RootsType rootVals {};
};

하지만 mutex는 복사하거나 이동할 수 없기 때문에 mutex를 클래스에 추가하면 해당 클래스의 복사와 이동도 불가능해진다.

 

atomic 사용

멤버 함수의 호출 횟수를 세고 싶다면, atomic 카운터(항목 40 참고)를 사용해서 비용을 줄일 수 있는 경우가 많다. (실제로 비용이 절감되는지는 프로그램이 실행되는 컴퓨터 하드웨어에 혹은 표준 라이브러리의 구체적인 뮤텍스 구현 방식에 따라 다를 수 있다.)

다음은 atomic을 이용하여 멤버 함수의 호출 횟수를 세는 방법을 보여주는 예제 코드이다.

class Point
{                                      
public:
    double distanceFromOrigin(void) const noexcept 
    {                                              
        ++callCount;                               
        return std::hypot(x, y); // std::sqrt(x*x + y*y)
    }

private:
    mutable std::atomic<unsigned> callCount { 0 };
    double x, y;
};

mutex처럼 atomic도 복사와 이동이 불가능하다. 따라서 Point에 이 callCount를 도입하면 Point 역시 복사와 이동이 불가능해진다.

여기서 mutex보다 비용이 싸다고 atomic을 남발하면 문제가 생길 수 있는데 아래의 예제를 봐보자.

class Widget
{
public:
    int magicValue(void) const
    {
        if (cacheValid) return cacheValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cacheValue = val1 + val2;
            cacheValid = true;
            return cacheValue;
        }
    }
    
private:
    mutable std::atomic<bool> cacheValid { false };
    mutable std::atomic<int> cacheValue;
};

이 코드가 작동하긴 하지만 생각보다 비용이 클 수 있다.

1. 한 스레드가 Widget::magicValue를 호출한다. cacheValid가 false라고 관측하고 비용이 큰 두 계산을 수행한 후 둘의 합을 cacheValue에 대입한다.

2. 그 시점에서 둘째 스레드가 Widget::magicValue를 호출하는데 역시 cacheValid가 false라고 관측해서 첫 스레드가 방금 마친 것과 동일한 비싼 계산들을 수행한다.(여러 개의 다른 스레드일 수도 있음.)

 

cacheValue와 cacheValid의 대입 순서를 바꿔도 위와 동일한 문제가 발생할 수 있으며, 추가적으로 아래와 같은 문제도 발생할 수 있다.

1. 한 스레드가 Widget::magicValue를 호출해서 cacheValid가 true로 설정되는 지점까지 나아간다.

2. 그 시점에 둘째 스레드가 Widget::magicValue를 호출해서 cacheValid를 점검한다. 그것이 true임을 관측한 둘째 스레드는 첫 스레드가 cacheValue에 값을 대입하기 전에 cacheValue를 돌려주므로 그 반환 값은 정확하지 않다.

 

즉, 동기화가 필요한 변수 하나 또는 메모리 장소 하나에 대해서는 atomic을 사용하는 것이 적합하지만 둘 이상의 변수나 메모리 장소를 하나의 단위로서 조작해야 할 때는 mutex를 꺼내는 것이 바람직하다.

 

Widget::magicValue를 뮤텍스로 보호한다면 다음과 같은 모습이 될 것이다.

class Widget
{
public:
    int magicValue(void) const
    {
        std::lock_guard<std::mutex> guard(m); // m을 잠근다.
        
        if (cacheValid) return cacheValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cacheValid = true;
            cacheValue = val1 + val2;
            return cacheValue;
        }
    } // m을 푼다.
    
private:
    mutable std::mutex m;
    mutable int cacheValue; // 이제는 atomic이 아님
    mutable bool cacheValid { false }; // 이제는 atomic이 아님
};

 

요약

  • 동시 컨텍스트에서 쓰이지 않을 것이라고 확신하지 않는 다면, const 멤버 함수는 스레드에 안전하게 작성해라.
  • atomic 변수는 mutex에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때만 적합하다.
728x90
반응형