[EMC++] Item 16. const 멤버 함수를 스레드에 안전하게 작성해라
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
멤버 함수는 다항식의 내용을 변경하지 않지만 캐싱을 위해 rootVals
와 rootsAreValid
멤버 변수를 변경할 수 있어야 한다.
const
로 선언한 멤버 함수 안에서 멤버 변수들의 값을 변경하기 위해서는 멤버 변수에 mutable
키워드가 필요하다.
mutable
키워드는const
함수 안에서 값을 바꿀 수 있게 해주는 키워드이다.
멀티스레드 환경을 고려한 함수 구현
위에서 구현한 roots
함수는 문제없이 동작하지만 멀티스레드 환경에서 사용한다면 문제가 발생할 수 있다.
Polynomial p;
/*------- 스레드 1-------*/ /*------- 스레드 2-------*/
auto RootsOfP = p.roots(); auto valsGivingZero = p.roots();
이처럼 두 스레드가 동시에 roots
함수에 접근하여 멤버 변수를 변경하다보면 예상치 못한 행동을 유발할 수 있기 때문이다.
이러한 동시성 문제를 해결하기 위해 std::mutex
와 std::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::atomic
은 std::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
값을 계산하고 cacheValid
를 true
로 변경하기 전에 두 번째 스레드에서 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;
};
첫 번째 스레드가 cacheValid
를 true
로 변경하고 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
를 쓰는 것이 바람직하다.