본문 바로가기
C++/Effective Modern C++

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

by COCO1337 2020. 7. 9.

다항식의 근을 구하는 등 계산 비용이 큰 함수들은 꼭 필요할 때에만 계산하는 것이 바람직하다. 또한 중복해서 계산하는 것은 피해야 하며, 필요한 때만 근을 계산해서 캐시에 저장하고 그렇지 않을 때에는 캐시에 있는 값을 돌려주도록 함수를 구현하는 것이 좋다.

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는 개념적으로 자신이 속한 Ploynomial의 객체를 변경하지 않지만 캐싱을 위해 rootVals와 rootsAreValid의 변경이 필요할 수 있다. 그렇기 때문에 mutable로 선언했다.

 

하지만 만약 두 스레드가 동시에 하나의 Polynomial 객체에 대해 roots를 호출한다고 가정해보자.

개념적으로 여러 스레드에서 동기화 없이 읽기 연산을 수행하는 것은 안전하다. 하지만 roots는 const의 멤버함수이긴 하지만 함수 내에서 자료멤버 수정을 시도 할 수 있다. 이러한 경우 data race 상황이 일어나며, 이 코드는 미정의 행동을 유발할 수 있다.

 

통상적으로 mutex를 사용해 이를 해결 할 수 있다. 하지만 이 경우, std::mutex를 복사하거나 이동할 수 없기 때문에 std::mutex를 추가 하면, Polynomial의 복사, 이동능력이 사라지게 된다.

class Polynomial{
public:
  using RootsType = std::vector<double>;
  
  RootsType roots() const
  {
    std::lock_guard<std::mutex> g(m);	// 뮤텍스를 잠근다
  
    if (!rootsAreValid){		// 캐시가 유효하지 않으면 근들을 계산해서 rootVals에 저장
      ...
      rootsAreValid = true;
    }
    
    return rootVals;		
  }					// 뮤텍스를 푼다
  
private:
  mutable std::mutex m;
  mutable bool rootsAreValid{ false };
  mutable RootsType rootVals{};
};

std::mutex의 형식은 mutable로 선언되었는데, m을 잠그고 푸는 멤버함수들은 const 형식이 아니지만 const 멤버 함수인 roots 안에서는 m이 const 객체로 간주되기 때문에 이렇게 해야 한다.

 

또 하나의 예로 계산 비용이 큰 int 값을 캐시에 저장하는 클래스에서는 한 쌍의 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;
};

만약 한 스레드가 비용이 큰 계산을 끝내고 둘의 합을 cachedValue에 배정할 때 두번째 스레드가 magicValue()를 호출하는 경우 앞의 스레드와 동일한 비용이 큰 계산을 한다.

cachedValue와 cacheValid의 순서를 바꾼 경우에, 한 스레드가 cacheValid를 true로 바꿔준 뒤 cachedValue를 배정하는 시점에서 두 번째 스레드가 magicValue()를 호출하는 경우 계산되지 않은 cachedValue를 갖게 된다.

이처럼 동기화에 필요한 변수가 둘 이상의 변수나 메모리 장소를 하나의 단위로 조작해야 할 때는 뮤텍스를 사용하는 것이 바람직하다.

 


Conclusion

- 동시적 문맥에서 쓰이지 않을 것이 확실한 경우가 아니라면, const 멤버함수는 항상 thread-safe하게 작성하라

- std::atomic 변수는 뮤텍스에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합하다.


Reference

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

반응형

댓글