본문 바로가기
SWE/C++ OOP

[C++] 멀티스레드에서 안전한 자원 관리 방법 | 병목현상 예방, mutex, lock_guard

by S나라라2 2023. 5. 26.
반응형

 

 

Thread-safety 변수 구현 방법

 

multi thread 환경에서 안전성을 갖춘 변수를 구현해보자

 

int val 변수를 thread-safey 하게 작성하고 싶으면, 보통 int val를 감싸는 커스텀 클래스를 구현하게 된다.

해당 클래스에서는 우리가 사용할 int val 변수를 가지고 있고, 해당 변수의 유일 사용을 보장해줄 mutex 변수도 추가한다.

코드는 아래와 같다.

class MyClass {
 public:
 	MyClass() : val(0), mtx_() {};
    ~MyClass() {};
 private:
 	int val_;
    std::mutex mtx_;
 public:
 	void SetVal(int val) {
    	std::lock_guard<std::mutex> lock(this->mtx_);
        this->val_ = val;
    }
    int GetVal() {
    	std::lock_guard<std::mutex> lock(this->mtx_);
    	return val;
    }
};

 

 

 

내부에 mutex가 있는 클래스의 복사

 

보통의 해결법은 위와 같은데, 이렇게 사용시 주의할 사항이 있다.

멤버 변수로 mutex를 가지고 있을 경우, 복사 문제가 발생할 수 있다.

 

예를 들어, 복사 생성자를 호출하여 객체를 복사하게 되면, 복사된 객체와 원본 객체가 같은 mutex를 가리키게 되므로, 복사된 객체와 원본 객체에서 동시에 mutex에 접근하게 되는 문제가 발생하게 된다.

 

 

 

내부에 mutex가 있는 클래스의 복사 안정성 구현

 

위의 문제를 해결하기 위해, 컴파일러가 디폴트로 생성하는 대입 연산자 함수를 사용하지 않고, 직접 대입 연산자 함수를 재정의해줘야 한다.

class MyClass {
 public:
 	MyClass() : val(0), mtx_() {};
    ~MyClass() {};
 private:
 	int val_;
    std::mutex mtx_;
 public:
 	void SetVal(int val) {
    	std::lock_guard<std::mutex> lock(this->mtx_);
        this->val_ = val;
    }
    int GetVal() {
    	std::lock_guard<std::mutex> lock(this->mtx_);
    	return val;
    }
    // 대입 연산자 함수 재정의
    MyClass& operator=(const MyClass& rhs) {
    	if ( this != &rhs ) {
        	this->val_ = rhs.val_;
        }
        return *this;
    }
};

여기서 대입 연산자 함수를 오버로딩할 때도 주의할 점이 많다.

위처럼 할 경우, 빌드에러는 나오지 않지만, 실제 복사 함수가 실행될 때, val_ 변수는 thread-safety 하지 않다. (lock되지 않은채 읽히고, 쓰이고 있기 때문)

 

따라서 mutex lock 과정이 필요하다.

 

 

 

잘못된 방법

// 대입 연산자 함수 재정의
MyClass& operator=(const MyClass& rhs) {
    if ( this != &rhs ) {
    	// WRONG! DO NOT DO THIS!!
        std::lock_guard<std::mutex> lhs_lock(this->mtx_);  // (1)
        std::lock_guard<std::mutex> rhs_lock(rhs.mtx_);  // (2)
        this->val_ = rhs.val_;
    }
    return *this;
}

위와 같이 코드를 작성할 경우 deadlock이 발생할 수 있다.

 

그 자세한 이유를 살펴보자.

예를 들어 스레드가 A, B 두 개 있다. A 스레드에서 x = y 를 수행하고, 동시에 B스레드에서 y = x 를 수행한다. 

그러면 A 스레드에서 (1) 라인을 수행하며, x의 mutex를 lock한다.

B 스레드도 (1) 라인을 수행하면서, y의 mutex를 lock한다.

그리고 다시 A스레드에서 (2) 라인을 수행하며, y의 mutex를 lock하려고 하였으나, B스레드에서 lock하였기 때문에 무한히 기다리게 된다. 즉, 병목 현상이 발생하게 되는 것이다.

(A, B 스레드 모두 그들의 rhs.mutex를 lock 하려고 기다리지만, 다른 스레드가 release하지 않는다.)

 

아주 위험한 코드이다!

 

 

 

[결론] 옳은 방법

 

mutex가 있는 올바른 대입 연산자의 공식화는 아래와 같다.

mutable std::mutex mtx_;

// 대입 연산자 함수 재정의
MyClass& operator=(const MyClass& rhs) {
    if ( this != &rhs ) {
    	std::lock(this->mtx_, rhs.mtx_);  // (1)
        std::lock_guard<std::mutex> lhs_lock(this->mtx_, std::adopt_lock);  // (2)
        std::lock_guard<std::mutex> rhs_lock(rhs.mtx_, std::adopt_lock);
        this->val_ = rhs.val_;
    }
    return *this;
}

위의 코드를 자세히 살펴보자.

먼저, (1) 라인에서 this의 mutex와 rhs의 mutex를 동시에 lock 해준다.

( std::lock 에서 복수개의 뮤텍스에 대해 동시 lock을 지원해주고, deadlock을 발생시키지 않는다. std::lock의 내부 구현법에 대한 자세한 설명은 해당 링크를 참조한다. http://howardhinnant.github.io/dining_philosophers.html )

 

그러면 이제 mtx와 rhs.mtx가 모두 lock되어 있는 상태이다.

(2) 라인을 보면, std::lock_guard( ..., adopt) 를 통해서 뮤텍스의 소유권을 전달받으면서 예외 안전한 unlock을 할수 있다. 이제 이 뮤텍스는 std::lock_guard() 생성자에서 더 이상 lock을 하려고 시도하지 않는다. 그러나 std::lock_gurad() 소멸자에서는 자동으로 unlock 될 것이다. 

(참고로, lock_guard는 예외를 던지지 않기 때문에 항상 예외에 안전한다.)

 

정리하자면, 위와 같이 대입 연산자 함수를 오버로딩하면,

스레드 A와 스레드 B에서 동시 접근하더라도, mutex 자원을 기다리며 무한히 대기하게 되는 병목현상을 피할 수 있다.

 

그리고 코드 작성 시 주의할 점은, mutex를 mutable한 데이터 멤버로 저장해야 한다. 그렇지 않으면 rhs의 lock과 unlock이 불가능해진다.

 

 

위의 코드를 기억해두자!

 

 

 

참고) 멤버 변수 mutex앞에 mutable 키워드를 붙여야하는 이유

C++에서 const멤버 함수 내에서는 해당 변수의 값을 변경할 수 없다. 따라서 멤버함수가 뮤텍스 mtx의 잠금 또는 해제를 처리하지 못하는 문제가 발생할 수 있다.

이 문제를 해결하기 위해서 mutex변수에 mutable 키워드를 사용하여 const 멤버 함수 내에서도 변수의 값을 변경할 수 있게끔 해준다. 

 

 


 

 

 

추가 공부

 

Q. std:;lock_guard와 std::lock의 차이점은?

std::lock_guard std::lock은 모두 C++에서 다중 스레드 환경에서의 동시성 문제를 해결하기 위한 기술 중 하나인 뮤텍스(mutex)와 관련된 클래스와 함수이다. 하지만 각각의 역할 및 사용 방법이 다르기 때문에 차이가 있다.

 

std::lock_guard 뮤텍스 잠금과 잠금 해제를 자동으로 처리하는 RAII 기반의 클래스이다. , std::lock_guard 객체가 생성될 때 뮤텍스를 자동으로 잠금 처리하고, 객체가 소멸되면 자동으로 뮤텍스를 잠금 해제한다. 이를 통해 잠금과 해제의 균일함과 또 다른 스레드에서 예외가 발생하더라도 뮤텍스가 자동으로 잠금 해제되는 안전한 구현을 할 수 있다.

 

std::lock 두 개 이상의 뮤텍스를 안전하게 동시에 잠금 처리하는 함수이다. std::lock을 사용하면 여러 개의 뮤텍스를 동시에 안전하게 잠금 처리할 수 있다. 또한, std::lock 함수는 뮤텍스 객체의 어느 시점에서든지 인자로 전달된 모든 뮤텍스를 동시에 잠금 처리하며, 이 과정에서 뮤텍스가 서로 교차하는(deadlock) 상황을 방지할 수 있다.

 

따라서, std::lock_guard는 단일 뮤텍스에 대해 쉽게 사용할 수 있으며, std::lock는 둘 이상의 뮤텍스를 안전하게 처리하는 데에 사용된다.

 

참고로, 하나의 뮤텍스일 때도 std::lock을 사용하여 잠그는 것이 가능하지만, std::lock_guard를 권장한다.

 

 

 

Q. mutable키워드란?

 

C++에서 정의된 클래스 멤버 변수는 기본적으로 그 값을 변경할 수 없는 const 변수로 선언된다. 그러나 때로는 클래스 내부에서 특정 함수에 의해 값이 변경되는 변수가 필요한 경우가 있다. 이 때 사용하는 키워드가 mutable이.

 

mutable 키워드는 해당 변수가 const로 선언된 상황에서도 값의 변경이 가능하도록 허용한다. 예를 들면, 클래스의 멤버 변수 중에서 시간에 따라 변경되는 값을 저장하는 변수가 있다고 가정하면, 이 변수를 mutable로 선언해야 해당 값을 변경할 수 있다.

 

mutable 변수는 일반적인 변수와 동일한 방식으로 사용할 수 있지만 const 함수에서만 변경할 수 있다. , mutable 변수는 클래스 내부적으로 상태를 변경하는 함수에서만 값의 변경이 가능하다.

 

mutable 변수의 사용 예시

class Example {
public:
    void setVal(int newVal) const {
        mutableVal = newVal;
    }
    int getVal() const {
        return mutableVal;
    }
private:
    mutable int mutableVal;
};

 

코드에서 클래스 Example의 멤버 변수인 mutableVal mutable로 선언되었기 때문에 getVal 함수에서도 값을 변경할 수 있다. 하지만 setVal 함수에서도 값을 변경할 수 있으며 const 함수임에도 불구하고 값을 변경할 수 있다는 것이 특징이다.

 

 

 

반응형