[Modern C++] 항목 16: const 멤버 함수를 스레드에 안전하게 작성하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 3. 현대적 C++에 적응하기

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

🔍 문제 상황: const이지만 스레드에 안전하지 않은 함수

다항식의 근을 계산하는 클래스를 설계한다고 하자. 근의 계산 비용이 클 수 있으므로 캐시를 활용하는 것이 바람직하다.

class Polynomial {
public:
    using RootsType = std::vector<double>;
    
    RootsType roots() const;
    
};
class Polynomial {
public:
    using RootsType = std::vector<double>;

    RootsType roots() const
    {
        if (!rootsAreValid) {   // 캐시가 유효하지 않으면
                              // 근들을 계산해서 rootVals에 저장해 둔다
            rootsAreValid = true;
        }
        return rootVals;
    }

private:
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};
  • roots는 개념적으로는 자신이 속한 Polynomial 객체를 변경하지 않는다. 그러나 캐싱을 위해서는 rootValsrootsAreValid의 변경이 필요할 수 있다.
  • 이는 mutable을 적용해야 하는 전형적인 시나리오다.

그런데 두 스레드가 하나의 Polynomial 객체에 대해 roots를 동시에 호출한다면:

Polynomial p;

/*------ 스레드 1 ------*/     /*-------- 스레드 2 --------*/
auto rootsOfP = p.roots();    auto valsGivingZero = p.roots();
  • 두 스레드 중 하나나 둘 다 rootsAreValidrootVals를 수정하려 들 수 있다.
  • 이는 데이터 경쟁(data race)의 정의와 일치하며, 미정의 행동을 유발할 수 있다.
  • rootsconst로 선언되어 있지만 스레드에 안전하지는 않다.

🔒 해결책 1: std::mutex 사용

class Polynomial {
public:
    using RootsType = std::vector<double>;

    RootsType roots() const
    {
        std::lock_guard<std::mutex> g(m); // 뮤텍스를 잠근다

        if (!rootsAreValid) {             // 캐시가 유효하지 않으면
                                        // 근들을 계산, 저장한다
            rootsAreValid = true;
        }

        return rootVals;
    }                                     // 뮤텍스를 푼다

private:
    mutable std::mutex m;
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};
  • std::mutex 형식의 객체 mmutable로 선언된다. m을 잠그고 푸는 멤버 함수들은 비const이지만, roots(const 멤버 함수) 안에서는 mconst 객체로 간주되므로 이렇게 해야 한다.
  • 주의: std::mutex는 복사와 이동이 불가능하기 때문에, mPolynomial에 추가하면 Polynomial의 복사와 이동 능력도 사라진다.

⚡ 해결책 2: std::atomic 사용 (단순한 경우)

뮤텍스 도입이 너무 과한 경우, 예를 들어 멤버 함수의 호출 횟수만 세고 싶다면 std::atomic 카운터를 사용하는 것이 비용 면에서 유리할 수 있다.

class Point {
public:
    
    double distanceFromOrigin() const noexcept
    {
        ++callCount;           // 원자적 증가
        return std::hypot(x, y);
    }

private:
    mutable std::atomic<unsigned> callCount{ 0 };
    double x, y;
};
  • 주의: 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;
};

cacheValidfalse일 때 다음과 같은 문제가 발생할 수 있다.

  • 한 스레드가 magicValue를 호출해서 cacheValidtrue로 설정되는 지점까지 나아간다.
  • 그 시점에서 둘째 스레드가 magicValue를 호출해서 cacheValid를 점검한다. true임을 관측한 둘째 스레드는, 첫 스레드가 cachedValue에 값을 배정하기도 전에 cachedValue를 돌려준다. 따라서 그 반환값은 정확하지 않다.

결론: 동기화가 필요한 변수나 메모리 장소 하나에 대해서는 std::atomic이 적합하지만, 둘 이상의 변수나 메모리 장소를 하나의 단위로서 조작해야 할 때는 뮤텍스를 사용하는 것이 바람직하다.

class Widget {
public:
    
    int magicValue() const
    {
        std::lock_guard<std::mutex> guard(m); // m을 잠근다

        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expenseComputation2();
            cachedValue = val1 + val2;
            cacheValid = true;
            return cachedValue;
        }
    }                                         // m을 푼다
    

private:
    mutable std::mutex m;
    mutable int cachedValue;           // 이제는 atomic이 아님
    mutable bool cacheValid{ false };  // 이제는 atomic이 아님
};

🧐 정리

  • 동시적 문맥에서 쓰이지 않을 것이 확실한 경우가 아니라면, const 멤버 함수는 스레드에 안전하게 작성하라.
  • std::atomic 변수는 뮤텍스에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합하다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기