본문 바로가기

컴퓨터과학

[Effective C++] (16-3) const 멤버 함수를 스레드에 안전하게 작성할 것

C++ const

예를 들어 계산 비용이 큰 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 변수는 뮤텍스에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합합니다.