예를 들어 계산 비용이 큰 int 값을 캐시에 저장하는 클래스라면 뮤텍스 대신 한쌍의 std::atomic 변수들을 사용해볼 만합니다.
class Widget {
public:
...
int magicValue() const
{
if (cacheValid) return cacheValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheValue = val1 + val2; // !?
cacheValid = true // !?!?
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false; };
mutable std::atomic<int> cacheValue;
};
이 코드가 작동하긴 하지만, 생각보다 비용이 클 수 있습니다. 다음과 같은 시나리오를 생각해 봅시다.
- 한 스레드가 Widget::magicValue를 호출합니다. cacheValid가 false라고 관측하고, 비용이 큰 두 계산을 수행한 후 둘의 합을 cacheValue에 배정합니다.
- 그 시점에서 둘째 스레드가 Widget::magicValue를 호출하는데, 역시 cacheValid가 false라고 관측해서 첫 스레드가 방금 마친 것과 동일한 비싼 계산들을 수행합니다. (이 '둘째 스레드'가 실제로는 여러 개의 다른 스레드 들일 수도 있습니다.)
cachedValue와 cacheValid의 배정 순서를 반대로 바꾸면 이 문제가 해결될 것으로 생각하는 독자도 있겠지만, 잘 생각해보면 (1) 그래도 cacheValid가 true로 설정되기 전에 여러 스레드가 val1과 val2를 계산하게 되는, 그래서 배정 순서를 바꾸는 것이 의미가 없어지는 경우가 발생할 수 있으며, (2) 사실 상황이 더 나빠질 뿐이라는 점을 깨닫게 될 것입니다. 다음 예를 봅시다.
class Widget {
public:
...
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheValid = true; // !?
return cachedValue = val1 + val2; //!?!?
}
}
...
};
cacheValid가 false라고 할 때,
- 한 스레드가 Widget::magicValue를 호출해서, cacheValid가 true로 설정되는 지점까지 나아갑니다.
- 그 시점에서 둘째 스레드가 Widget::magicValue를 호출해서 cacheValid를 점검합니다. 그것이 true임을 관측한 둘째 스레드는, 첫 스레드가 cachedValue에 값을 배정하기도 전에 cachedValue를 돌려줍니다. 따라서 그 반환 값은 정확하지 않습니다.
이상의 예에서 배워야 할 점은 이런 것들입니다. 동기화가 필요한 변수 하나 또는 메모리 장소 하나에 대해서는 std::atomic을 사용하는 것이 적합하지만, 둘 이상의 변수나 메모리 장소를 하나의 단위(unit)로서 조작해야 할 때에는 뮤텍스를 꺼내는 것이 바람직합니다. Widget::magicValue를 뮤텍스로 보호한다면 다음과 같은 모습이 될 것입니다.
class Widget {
public:
...
int magicValue() const
{
std::look_guard<std::mutex> guard(m); // m을 잠근다
if (cachValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cachedVlid = true;
return cachedValue;
}
}
...
private:
mutable int cachedValue; // 이제는 atomic이 아님
mutable bool cachedValid{ false }; // 이제는 atomic이 아님
};
이제는 눈치챘겠지만 이 항목의 조언에는 여러 스레드가 하나의 객체에 대해 어떤 const 멤버 함수를 동시에 실행하려 한다는 가정이 깔려 있습니다. 독자가 작성하려는 const 멤버 함수에 그런 가정이 적용되지 않느다면, 이를테면 하나의 객체에 대해 그 멤버 함수를 호출하려는 스레드가 많아야 하나임을 보장할 수 있다면, 그 멤버 함수의 스레드 안전성은 대수롭지 않은 문제입니다. 예를 들어 전적으로 단일 스레드 환경에서만 쓰이는 클래스의 멤버 함수들을 스레드에 안전하게 만드는 것은 중요하지 않습니다. 그런 경우에는 뮤텍스와 std::atomic에 관련된 비용을 피할 수 있으며, 그런 것들을 도입했기 때문에 클래스의 복사와 이동이 불가능해지는 부수 효과도 피할 수 있습니다. 그러나 그런 스레드에 자유로운 상황은 점점 드물어지고 있으며, 앞으로는 아주 희귀한 경우가 될 가능성이 큽니다. const 멤버 함수가 언제라도 동시적 실행 상황에 처할 것이라고 가정하는 것이 안전하며, 따라서 const 멤버 함수는 항상 스레드에 안전하게 만드든 것이 바람직합니다.
*** Key Point ***
- 동시적 문맥에서 쓰이지 않을 것이 확실한 경우가 아니라면, const 멤버 함수는 스레드에 안전하게 작성해야 합니다.
- std::atomic 변수는 뮤텍스에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합합니다.
'컴퓨터과학' 카테고리의 다른 글
[Effective C++] (17-2) 특수 멤버 함수들의 자동 작성 조건을 숙지할 것 (0) | 2020.06.10 |
---|---|
[Effective C++] (17-1) 특수 멤버 함수들의 자동 작성 조건을 숙지할 것 (0) | 2020.06.09 |
[Effective C++] (16-2) const 멤버 함수를 스레드에 안전하게 작성할 것 (0) | 2020.06.07 |
[Effective C++] (15-4) 가능하면 항상 constexpr을 사용할 것 / (16-1) const 멤버 함수를 스레드에 안전하게 작성할 것 (0) | 2020.06.06 |
[Effective C++] (15-3) 가능하면 항상 constexpr을 사용할 것 (0) | 2020.06.05 |