KoreanFoodie's Study

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

Tutorials/C++ : Advanced

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

GoldGiver 2022. 10. 26. 09:54

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

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

핵심 :

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


다음과 같은 다항식 클래스가 있다고 하자.

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{}
};

다항식이 0으로 평가되는 값들을 계산하는 멤버 함수는 다항식을 수정하지 않음으로, 위와 같이 const 로 선언하는 것이 바람직하다. 또한, 다항식 근의 계산 비용은 클 수 있으므로, 꼭 필요할 때만 계산하는 것이 바람직하다.
roots 는 개념적으로는 자신이 속한 Polynomial 객체를 변경하지 않는다. 그러나 캐싱을 위해서는 rootVal 과 rootsAreValid 의 변경이 필요할 수 있다(그래서 mutable 을 사용함). 즉, 여러 스레드가 roots() 를 동시에 호출하면 경쟁 상황(race condition) 이 발생할 수 있다.
이를 해결하는 가장 쉬운 방법은 통상적인 동기화 해결책인 뮤텍스를 사용하는 것이다.

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

  RootsType roots() const {
    // 뮤텍스를 잠근다
    std::lock_guard<std::mutex> g(m);
    
    ... // 기존과 동일

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

private:
  mutable std::mutex m;  
  ...
};

std::mutex 는 복사하거나 이동할 수 없으므로, m 을 Polynomial 에 추가하면 Polynomial 의 복사와 이동 능력도 사라진다.

그리고 이런 목적으로 뮤텍스를 도입하는 것이 과한 일일 수 있다. 함수 호출 횟수를 세는 간단한 경우에는 std::atomic 을 사용하는 것이 효율적일 수도 있다.

class Point {
public:
  void f() {
    ++callNums;
    // Do something...
  }
private:
  mutable std::atomic<int> callCount{ 0 };
};

std::atomic 도 복사와 이동이 불가능하다. 또한, std::atomic 의 남용은 자제해야 한다. 다음과 같은 예시를 보자.

class Widget {
public:
  int magicValue() const
  {
    if (cacheValid) {
      return cachedValue;
    } else {
      auto val1 = expensiveComputation();
      auto val2 = expensiveComputation();
      cachedValue = val1 + val2;
      cacheValid = true;
      return cachedValue;
    }
  }

  int expensiveComputation() { /* ...expensive! */}

private:
  mutable std::atomic<bool> cacheValid{ false };
  mutable std::atomic<int> cachedValue;
};



여러 개의 스레드가 magicValue 를 호출한다고 가정해 보자. 만약 cacheValid 를 갱신하기 이전에, 여러 스레드가 cachedValue 를 계산하는 부분까지만 실행을 하면, 불필요한 계산이 매우 많이 일어나게 될 것이다.
심지어 cachedValue 와 cacheValid 를 건드리는 코드의 순서를 바꾸면, 문제가 더 심각해질 수 있다. cacheValid 를 true 로 바꾼 상태에서 한 스레드가 멈춘 후, 다른 스레드가 실행되면 정확하지 않은 cachedValue 값을 읽을 수 있기 때문이다.
즉, 동기화가 필요한 변수 하나 또는 메모리 장소 하나에 대해서는 std::atomic 을 쓰는 것이 적합하지만, 둘 이상일 경우, 뮤텍스를 사용하는 것이 바람직하다. 이 항목에서 강조한 것처럼, const 멤버 함수가 언제라도 동시적 실행 상황에 처할 것이라고 가정하는 것이 안전하며, const 멤버 함수는 항상 스레드에 안전하게 만들어야 한다.

Comments