Item16은 'const 멤버 함수는 스레드 안전하게 작성해라' 라는 이야기다.
const는 값을 바꾸지 않는다는 생각으로 여러 스레드에서 동시에 접근하도록 하면 안된다는 내용이다.
---
수학적 영역에서 작업한다면 다항식(polynomial)을 나타내는 클래스를 갖는 것이 판리할 것이다.
그리고 이 클래스 내에 다항식의 해(root), 즉 다항식의 값이 0이 되는 값들을 계산하는 함수를 두는 것 또한 아마도 유용할거다.
그리고 이 함수는 다항식을 수정하지 않으므로 const로 선언하는게 올바른 방향일 것이다.
이런 방향으로 코드를 작성한다면 아래와 같을 것이다.
class Polynomial {
public:
using RootsType = // 해의 값들을 보유하는 데이터 구조
std::vector<double>;
…
RootsType roots() const; // const로 선언됨
…
};
그리고 다항식의 해를 계산하는 것은 비용이 많이 들 수 있으므로, 계산된 다항식의 해는 캐싱해두고 꼭 필요할 때만, 계산되지 않을때만 로직이 수행하고 그 외에는 캐시된 값을 반환하도록 root 함수를 구현할 것이다. 이를 위한 코드는 아래와 같을 것이다.
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
if (!rootsAreValid) { // 만약 캐시가 유효하지 않다면
… // 해를 계산하여,
// rootVals에 저장함
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false }; // mutable 키워드 사용
mutable RootsType rootVals{};
};
개념적으로 roots 함수는 그것이 작동하는 Polynomial 객체를 변경하진 않지만 캐싱 동작의 일환으로 rootVals와 rootsAreValid를 수정해야 할 수도 있다.
이것은 mutable의 전형적인 사용 예시이며, 이것이 이 데이터 멤버들의 선언에 mutable이 포함된 이유이다.
이제 두 개의 스레드가 하나의 polynomial 객체에 대해 동시에 roots 함수를 호출한다고 해보자.
Polynomial p;
…
/*----- 스레드 1 ----- */ /*------- 스레드 2 ------- */
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
클라이언트의 입장에서 roots 함수는 const 멤버함수이며, 이는 곧 읽는 작업임을 나타내기 때문에 합리적인 사용이다.
동기화 없이 여러 스레드가 읽기 작업을 수행하는건 안전해야 하지만, 지금의 경우엔 안전하지 않다.
roots 내부에서 두 스레드 중 하나 또는 모두가 데이터 멤버인 rootsAreValid와 rootVals를 수정하려 시도할 수 있기 때문이다.
이는 서로 다른 스레드가 동기화 없이 동일한 메모리를 읽고 쓰는 상황이 발생할 수 있음을 의미하며, 이것이 데이터 레이스(data race)이다. 이 코드는 미정의 동작(undefined behavior)을 유발한다.
여기서 중요한 문제는 roots 함수가 const로 선언되었음에도 불구하고 thread safe하지 않다는 점이다.
const 선언 자체는 C++98에서와 마찬가지로 C++11에서도 여전히 동일한 의미를 가진다. 다항식의 해를 구한다고 해서 다항식의 값 자체가 변하는 것은 아니기 때문이다.
따라서 바로 잡아야 하는 것은 스레드 안정성의 결여, 즉 스레드 안전하지 못한 부분을 수정해야 한다.
이 문제를 해결하는 가장 쉬운 방법은 일반적인 방식인 뮤텍스(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로 선언되어 있다. 왜냐하면 뮤텍스를 잠그고 해제하는 것은 const 멤버 함수가 아니며, roots(const 멤버함수)함수의 내부에서 mutable이 아니라면 m은 const 객체로 간주되어 조작할 수 없기 때문이다.
std::mutex는 이동 전용 타입(move-only-type, 이동은 가능하지만 복사는 불가능한 타입)이기 때문에, Polynomial 에 m을 추가함으로서 발생하는 부수효과는 Polynomial 이 복사능력을 읽게 된다는 것이다. 하지만 여전히 이동은 가능하다.
어떤 상황에서는 뮤텍스가 과할 수 있다.
예를 들어, 멤버 함수가 몇 번 호출되었는지 계산되는게 전부라면 std::atomic 카운터(다른 스레드가 해당 연산을 분할 불가능하게 일어나는 것으로 보장 받는 카운터 - Item40)를 사용하는 것이 종종 더 저렴한 방법이 된다. (실제로 더 저렴한지는 사용중인 하드웨어와 표준 라이브러리의 뮤텍스 구현 방식에 따라 다르다.)
아래는 std::atomic을 사용하는 방법이다.
class Point { // 2D 좌표
public:
…
double distanceFromOrigin() const noexcept // noexcept에 대해서는 Item 14 참조
{
++callCount; // 원자적 증가(atomic increment)
return std::sqrt((x * x) + (y * y));
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};
std::mutex와 마찬가지로 std::atomic 또한 이동 전용 타입이다. 따라서 Point 클래스 안에 callCount가 존재한다는 것은 Point 역시 이동 전용 타입이 된다는 것을 의미한다.
std::atomic 변수에 대한 연산은 종종 뮤텍스를 획득하고 해제하는 것보다 비용이 적게 들기 때문에, 생각보다 더 std::atomic엥 의존하려는 유혹을 느낄 수 있다.
예를 들어, 계산 비용이 많이 드는 int 값을 캐싱하는 클래스에서 뮤텍스 대신 한 쌍의 std::atomic 변수를 사용하려 시도할수도 있다.
class Widget {
public:
…
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; // uh oh, part 1
cacheValid = true; // uh oh, part 2
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};
std::atomic 변수 두 개를 사용한 Widget 클래스는 동작은 하겠지만, 설계했던 것 보다 비효율적으로 동작한다.
아래 상황을 고려해보자.
- 한 스레드가 Widget::magicValue를 호출하고, cacheValid가 false 인 것을 확인한다. 그리고 두 개의 비용이 큰 계산을 수행한 뒤, 그 합을 cachedValue에 할당한다.
- 바로 그 시점에, 두 번째 스레드가 Widget::magicValue를 호출한다. 이 스레드 역시 cacheValid 를 false로 보게 되고, 따라서 첫 번째 스레드가 방금 막 끝낸 것과 똑같은, 비용이 큰 계산을 다시 수행한다. (이 두 번째 스레드는 사실 여러개의 다른 스레드가 될 수 있다.)
이런 동작은 캐싱(caching)의 목적에 정면으로 위배된다. cachedValue와 cacheValid에 값을 할당하는 순서를 바꾸면 이 문제는 해결되겠지만, 그 결과는 훨씬 더 나빠진다.
아래 코드를 보자.
class Widget {
public:
…
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheValid = true; // 어이쿠, 파트 1 (먼저 플래그 설정)
return cachedValue = val1 + val2; // 어이쿠, 파트 2 (값 할당)
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};
cacheValid가 false 인 상태에서 다음과 같은 상황이 발생한다 해보자.
- 한 스레드가 Widget::magicValue를 호출하고 cacheValid를 true로 설정하는 지점까지 실행했고
- 바로 그 순간에 두 번째 스레드가 Widget::magicValue를 호출해서 cahceValid를 체크한다. true인 것을 확인한 이 스레드는, 첫 번째 스레드가 아직 값을 할당하지 않았음에도 불구하고 cachedValue를 즉시 반환해버린다. 따라서 반환된 값은 올바르지 않은 값이 된다.
이걸 이용해 얻을 수 있는 교훈은,
동기화가 필요한 단일 변수나 메모리 위치에 대해서는 std::atomic을 사용하는 것이 적절하지만, 하나의 단위로 조작해야 하는 두 개 이상의 변수나 메모리 위치가 있다면 뮤텍스(mutex)를 사용해야 한다는 점이다.
mutex를 사용하면 Widget클래스의 코드는 아래와 같이 변경될 것이다.
class Widget {
public:
…
int magicValue() const
{
std::lock_guard<std::mutex> guard(m); // 뮤텍스 m을 잠금
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // 뮤텍스 m 잠금 해제
…
private:
mutable std::mutex m;
mutable int cachedValue; // 더 이상 atomic일 필요 없음
mutable bool cacheValid{ false }; // 더 이상 atomic일 필요 없음
};
Item16은 여러 스레드가 하나의 객체에 대해 동시에 const 멤버 함수를 실행할 수 있다는 가정을 전제로 하고 있다.
한 객체에 대해 해당 멤버 함수를 실행하는 스레드가 절대로 둘 이상이 되지 않음을 보장할 수 있는, 위와 같은 상황이 절대 발생하지 않는 const 멤버 함수를 작성하고 있다면 함수의 스레드 안전성은 중요하지 않을 것이다.
예를 들어, 오직 싱글스레드에서만 사용하도록 설계된 클래스의 멤버 함수가 스레드 안전(thread safe)한지는 중요하지 않다. 오히려 이런 경우에는 뮤텍스나 std::atomic과 관련된 비용을 피할 수 있고, 해당 클래스들이 '이동 전용(move-only)'이 되는 부수 효과도 피할 수 있다.
하지만 이러한 스레드 프리(threading-free) 시나리오는 점점 드물어지고 있고, 앞으로 더 많이 드물어질 가능성이 높다.
가장 안전한 도박은 const 멤버 함수가 동시에 실행될 수 있다고 가정하는 것이며, 이것이 const 멤버함수를 스레드 안전하게 만들어야 하는 이유이다.
기억해야 할 사항들
- 동시성 환경에서 절대로 사용되지 않을 것이라고 확신하지 않는 한, const 멤버 함수는 스레드 안전하게 만들어라
- std::atomic 변수를 사용하는 것이 뮤텍스를 사용하는 것보다 더 좋은 성능을 제공할 수 있지만, 이는 단일 변수나 단일 메모리 위치를 조작할 때만 적합하다.
즉,
1. 논리적 불변성 vs 물리적 불변성
const 함수가 내부적으로 mutable을 통해 상태를 바꾸더라도(캐싱 등), 외부에서 보기에는 값이 변하지 않는 것 처럼 느껴져야 하며, 여러 스레드가 동시에 봐도 문제 없어야 한다.
2. 도구 선택
std::atomic : 하나의 변수 제어
std::mutex : 둘 이상의 변수가 얽힌 로직 제어
'읽은 기록 > Effective Modern C++' 카테고리의 다른 글
| [CH.4] Smart Pointers (0) | 2026.04.05 |
|---|---|
| [CH3] Item17. Understand special member function generation. (0) | 2026.03.30 |
| [CH3] Item15. Use constexpr whenever possible. (0) | 2026.03.22 |
| [CH3] Item14. Declare functions noexcept if they won’t emit exceptions. (0) | 2026.03.16 |
| [CH3] Item13. Prefer const_iterators to iterators. (0) | 2026.03.15 |