[Modern C++] 항목 16: const 멤버 함수를 스레드에 안전하게 작성하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 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객체를 변경하지 않는다. 그러나 캐싱을 위해서는rootVals와rootsAreValid의 변경이 필요할 수 있다.- 이는
mutable을 적용해야 하는 전형적인 시나리오다.
그런데 두 스레드가 하나의 Polynomial 객체에 대해 roots를 동시에 호출한다면:
Polynomial p;
…
/*------ 스레드 1 ------*/ /*-------- 스레드 2 --------*/
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
- 두 스레드 중 하나나 둘 다
rootsAreValid와rootVals를 수정하려 들 수 있다. - 이는 데이터 경쟁(data race)의 정의와 일치하며, 미정의 행동을 유발할 수 있다.
roots가const로 선언되어 있지만 스레드에 안전하지는 않다.
🔒 해결책 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형식의 객체m은mutable로 선언된다.m을 잠그고 푸는 멤버 함수들은 비const이지만,roots(const멤버 함수) 안에서는m이const객체로 간주되므로 이렇게 해야 한다.- 주의:
std::mutex는 복사와 이동이 불가능하기 때문에,m을Polynomial에 추가하면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;
};
cacheValid가 false일 때 다음과 같은 문제가 발생할 수 있다.
- 한 스레드가
magicValue를 호출해서cacheValid가true로 설정되는 지점까지 나아간다. - 그 시점에서 둘째 스레드가
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변수는 뮤텍스에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합하다.
댓글남기기