스토리텔링 개발자

[Effective Modern C++] 16. thread safety한 const 멤버 함수 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 16. thread safety한 const 멤버 함수

김디트 2025. 2. 26. 12:27
728x90

항목 16. const 멤버 함수를 스레드에 안전하게 작성하라

 

 

 

const 함수를 가지는 다항식(polynomial) 예제
class Polynomial
{
public:
    // 다항식의 근들
    // 즉, 다항식이 0으로 평가되는 값들
    using RootsType = std::vector<double>;
    
    RootsType roots() const // 캐시된 값을 그저 리턴
    {
        if(!rootsAreValid)
        {
            ... // 근들을 계산해서 rootVals에 캐싱
            
            rootsAreValid = true;
        }
        
        return rootVals;
    }

private:
    mutable bool rootsAreValid { false }; // 중괄호 초기지(항목 7 참조)
    mutable RootsType rootVals{};  // 중괄호 초기지(항목 7 참조)
};
  • roots 함수는 기본적으로 개체를 수정하지 않는다.
    • 헌데 캐싱을 위해선 rootVals나 rootsAreValid의 변경이 필요하다.
    • 그러므로 변수들을 mutable을 적용하였다.(const 함수 내에서도 변경 가능)
  • 근데 두 쓰레드에서 한 객체에 대해 동시에 roots를 호출한다면 어떻게 될까?

  • 이 코드는 합법하다.
    • roots는 const 멤버 함수이므로 읽기 전용이라는 뜻이다.
    • 여러 스레드가 동기화 없이 읽기를 수행하는 건 안전하니까.
  • 허나 위의 예에서는 코드가 안전하지 않다.
    • mutable 내부 변수를 수정하려 들기 때문이다.
    • data race 상태에 이를 수 있다.
    • 즉, 미정의 행동을 유발하는 코드이다.

 

 

 

뮤텍스를 사용해서 해결?
class Polymial
{
public:
    using RootsType = std::vector<double>;
    
    RootsType roots() const
    {
        std::lock_guard<std::mutex> g(m);
        
        if(!rootsAreVaild)
        {
            ...
            rootsAreValid = true;
        }
        
        return rootsVals;
    }
private:
    mutable std::mutex m ;
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};
  • Polynomial의 복사와 이동이 불가해진다.
    • 멤버 변수 m 때문이다.
    • 뮤텍스는 복사하거나 이동할 수 없다.
  • 뮤텍스를 도입하는 건 너무 과한 일이다.
    • 비용이 너무 커진다.

 

 

 

std::atomic을 사용하는 예제
  • 뮤텍스는 비용이 크니까 비용이 작은 atomic을 사용해보자는 아이디어.
  • 그 아이디어를 적용한 또 다른 예제
class Point
{
public:
    ...
    double distanceFromOrigin() const noexcept
    {
        ++callCount;
        
        return std::hypot(x, y); // C++11에 추가된 함수
    }
private:
    mutable std::atomic<unsigned> callCount{ 0 }; // 아토믹 사용
    double x, y;
};
  • Point 역시 복사와 이동이 불가능해진다.
    • std::atomic도 복사와 이동이 불가능하기 때문이다.
  • 허나 std::atomic이 뮤텍스보다 비용이 싸다는 점에 현혹되어 함정에 빠질 수 있다.

 

 

 

std::atomic을 잘못 사용한 예제
class Widget
{
public:
    ...
    
    int magicValue() const
    {
        if(cacheValid) return cachedValue;
        else
        {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cachedValue = val1 + val2; // ??
            cacheValid = true; // ????
            return cachedValue;
        }
    }
    
private:
    mutable std::atomic<bool> cacheValid{ false };
    mutable std::atomic<int> cachedValue;
};
  • 아래와 같은 시나리오를 생각해보자. 
    • 한 스레드가 Widget::magicValue를 호출한다.
    • cacheValid가 false임을 확인한다.
    • 비용이 큰 두 계산을 수행한 후 둘의 합을 cachedValue에 할당한다.
    • 이 때 다른 스레드가 Widget::magicValue를 호출한다.
    • cacheValid가 false임을 확인한다.
    • 비용이 큰 두 계산을 재수행한다.
    • 근데 다른 스레드는 한개가 아닐 수 있다.
  • cacheValid를 먼저 수행하면 해결이 될까?
    • cacheValid가 true로 설정되기 전에 비용이 큰 계산을 수행하게 되므로 문제 해결에 도움이 되지 않는다.
    • 게다가 더 안 좋은 시나리오가 발생할 수도 있다!!
  • cacheValid를 먼저 수행하는 시나리오를 생각해보자.
    • 한 스레드가 Widget::magicValue를 호출한다.
    • cacheValid에 true가 할당된다.
    • 그 시점에 둘째 스레드가 Widget::magicValue를 호출한다.
    • cacheValid를 점검해서 true임을 확인한다.
    • 할당되지 않은 cachedValue를 리턴한다!!
  • 결국 이런 상황이 되면 std::atomic보단 std::mutex를 사용하는 게 바람직하다.

 

 

 

결론
  • 스레드에 자유로운 상황이 점점 드물어지고 있으므로 const 멤버 함수는 늘 스레드 안전성을 고려하자.
  • 그리고 그런 구현을 할 때는,
    • 동기화가 필요한 변수가 하나일 때는 std::atomic
    • 둘 이상일 때는 std::mutex를 사용하자.
728x90
Comments