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

수학에서 한 개 이상의 항의 합을 표현할 때 사용하는 다항식(polynomial)을 클래스로 구현한다고 생각해보자.

$a+bx+cx^2+…+dx^{n-1}+ex^n$

모름지기 다항식 클래스라면 다항식의 근(root)들을 구하는 멤버 함수가 빠질 수 없다.

다항식의 근들을 구하는 함수는 다항식을 수정할 이유가 없으니 const로 구현하는 것이 바람직하다.

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

  RootsType roots() const;
};

또한, 다항식의 근들을 구하는 계산 비용이 클 수 있으므로 한 번 계산하면 그 값을 캐싱하여 두 번 계산할 필요가 없게 만들 필요가 있다.

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

  RootsType roots() const {
    // 캐싱 여부 확인
    if (!rootsAreValid) {
      // 근을 계산하고 결과값을 rootVals에 캐싱함
      rootsAreValid = true;
    }

    return rootsVals;
  }
private:
  mutable bool rootsAreValid{false};
  mutable RootsType rootVals{};
};

이때 roots 멤버 함수는 다항식의 내용을 변경하지 않지만 캐싱을 위해 rootValsrootsAreValid 멤버 변수를 변경할 수 있어야 한다.

const로 선언한 멤버 함수 안에서 멤버 변수들의 값을 변경하기 위해서는 멤버 변수에 mutable 키워드가 필요하다.

mutable 키워드는 const 함수 안에서 값을 바꿀 수 있게 해주는 키워드이다.


멀티스레드 환경을 고려한 함수 구현

위에서 구현한 roots 함수는 문제없이 동작하지만 멀티스레드 환경에서 사용한다면 문제가 발생할 수 있다.

Polynomial p;

/*------- 스레드 1-------*/       /*------- 스레드 2-------*/ 
auto RootsOfP = p.roots();        auto valsGivingZero = p.roots();

이처럼 두 스레드가 동시에 roots 함수에 접근하여 멤버 변수를 변경하다보면 예상치 못한 행동을 유발할 수 있기 때문이다.

이러한 동시성 문제를 해결하기 위해 std::mutexstd::atomic을 사용한다.


std::mutex

뮤텍스(mutex)는 여러 스레드들이 동시에 같은 코드에 접근하는 것을 막는 상호 배제(mutual exclusion) 기술이다.

C++에서는 한 번에 한 스레드만 자원에 접근할 수 있게 std::mutex 객체를 지원한다.

std::mutex를 사용하여 roots 함수에서 발생한 동시성 문제를 해결하고 스레드 안정성 있는 코드를 작성할 수 있다.

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

  RootsType roots() const {
    std::lock_guard<std::mutex> g(m); // 뮤텍스 lock
    
    // 캐싱 여부 확인
    if (!rootsAreValid) {
      // 근을 계산하고 결과값을 rootVals에 캐싱함
      rootsAreValid = true;
    }

    return rootsVals;
  } // 뮤텍스 unlock
private:
  mutable std::mutex m;
  mutable bool rootsAreValid{false};
  mutable RootsType rootVals{};
};

다만 std::mutex를 멤버 변수로 가지고 있으면 해당 객체의 복사, 이동이 불가능하다.

만약 std::mutex보다 적은 비용으로 스레드 안정성 있는 코드를 구현하고 싶다면 std::atomic을 사용하는 방법이 있다.


std::atomic

std::atomic은 연산을 원자적(atomic)으로 할 수 있도록 지원하는 템플릿 클래스이다.

여기서 원자적이라는 뜻은 CPU가 메모리에 있는 값을 읽고, 계산(증감/가감)하고, 쓰는 작업을 쪼갤 수 없는 하나의 작업처럼 실행하는 것을 의미한다.

멀티스레드 환경에서 멤버 함수의 호출 횟수를 세고 싶으면 다음과 같이 std::atomic 객체를 사용할 수 있다.

class Point {
public:
  double distanceFromOrigin() const noexcept {
    ++callCount;
    
    return std::hypot(x, y); // C++11에서 지원하는 2차원 거리 계산 함수
  }
private:
  mutable std::atomic<unsigned> callCount{0};
  double x, y;
};

std::atomic 또한 멤버 변수로 가지고 있으면 해당 객체의 복사, 이동이 불가능하다.


std::mutex와 std::atomic의 적절한 사용법

std::atomicstd::mutex에서 발생할 수 있는 교착 상태(dead lock) 문제를 피할 수 있고 비용이 더 싸다는 장점이 있다.

하지만 std::atomic을 남용하면 다음과 같은 문제가 발생할 수 있다.

비용이 큰 계산 값을 캐싱하기 위해 std::atomic을 사용한다고 가정해보자.

class Widget {
public:
  int magicValue() const {
    if (cacheValid) return cachedValue;
    else {
      auto val1 = expensiveComputation1();
      auto val2 = expensiveComputation2();
      cachedValue = val1 + val2;   // 1번 코드
      cacheValid = true;           // 2번 코드
      return cachedValue;
    }
  }
  
private:
  mutable std::atomic<bool> cacheValid{false};
  mutable std::atomic<int> cachedValue;
};

첫 번째 스레드가 magicValue 함수를 호출하여 cachedValue 값을 계산하고 cacheValidtrue로 변경하기 전에 두 번째 스레드에서 magicValue 함수를 호출할 경우, 두 번째 스레드도 cachedValue 값을 또 계산하는 문제가 발생할 수 있다.

이 문제를 해결하기 위해 1번 코드와 2번 코드의 위치를 변경하면 더 큰 문제가 발생한다.

class Widget {
public:
  int magicValue() const {
    if (cacheValid) return cachedValue;
    else {
      auto val1 = expensiveComputation1();
      auto val2 = expensiveComputation2();
      cacheValid = true;           // 2번 코드
      cachedValue = val1 + val2;   // 1번 코드
      return cachedValue;
    }
  }
  
private:
  mutable std::atomic<bool> cacheValid{false};
  mutable std::atomic<int> cachedValue;
};

첫 번째 스레드가 cacheValidtrue로 변경하고 cachedValue 값을 계산하기 전에 두 번째 스레드에서 계산 값이 입력되지 않은 cachedValue를 리턴하는 문제가 발생할 수 있다.

첫 번째 경우는 불필요한 연산이 추가되어 성능이 안 좋아지는 정도지만 두 번째 경우는 아예 잘못된 계산 값을 반환해버리는 문제가 발생한다.

이러한 상황에서는 std::mutex를 사용하면 문제를 해결할 수 있다.

class Widget {
public:
  int magicValue() const {
    std::lock_guard<std::mutex> gurad(m); // 뮤텍스 lock
    if (cacheValid) return cachedValue;
    else {
      auto val1 = expensiveComputation1();
      auto val2 = expensiveComputation2();
      cacheValid = true;
      cachedValue = val1 + val2;
      return cachedValue;
    }
  } // 뮤텍스 unlock
  
private:
  mutable std::mutex m;
  mutable bool cacheValid{false};
  mutable int cachedValue;
};

즉, 하나의 메모리 변수 또는 주소를 다룰 때에는 std::atomic이 적합하지만 둘 이상의 메모리 변수 또는 주소를 다룰 경우 std::mutex를 쓰는 것이 바람직하다.


카테고리:

업데이트: