프로그래밍 지식/C++

C++문법 / Race condition, 경쟁상태 해결 - 뮤텍스, Mutex

게임이 더 좋아 2021. 12. 3. 20:00
반응형
728x170

 

여기까지 오면서 한 번쯤 생각해봤을 법한 

"동시에 접근하면 어떡하지?"

 

예를 들고와보자

10만을 더하려고한다.

10개의 스레드를 통해서 하려고 한다.

#include <iostream>
#include <thread>
#include <chrono>
#include <vector>

using namespace std;
using namespace chrono;


//해당 변수를 1 증가시키는 함수
void worker(int &result) {
    for (auto i = 0; i <10000; i++) {
        result += 1;
    }
}

int main() {


    //thread 넣을 벡터
    vector<thread> workers;

    //스레드가 접근할 변수
    int cnt = 0;

    steady_clock::time_point start = steady_clock::now();
    for (int i = 0; i < 10; i++) {
        workers.push_back(thread(worker,
                                  ref(cnt)));    // 레퍼런스로 인자를 전달하려면 ref 함수를 써야함
    }

    for (int i = 0; i < 10; i++) {
        workers[i].join();
    }

    steady_clock::time_point end = steady_clock::now();

    cout << cnt << '\n';
    duration<double> sec = end - start;
    cout << "elapsed time " << sec.count();

}

 

10만을 세려고 한다.

 

결과는???

 

 

??????? 뭐여 왜 10만이 아닌겨???

 

역시나 예상대로다.

동시에 접근하면 분명 정상적으로 작동하지 않을 것이 분명하다.

(다만 여기서 오류는 발생하지 않았다.)

-> 디버깅이 어려운 이유

 

다만 동시에 접근해서 비정상적으로 작동하는 것은 

우리가 보고 있는 코드가 아니라

CPU의 레지스터 단이다.

CPU는 한 번에 하나의 Instruction을 수행한다.

레지스터가 해당 Instruction을 수행하기 위한 파이프라인을 돌며 연산을 수행한다.

하지만 중간에 다른 코드에서도 레지스터에 접근하는 경우

1로 저장되어있던 레지스터가 0으로 바뀌어 연산이 진행되는 비정상적인 사태가 발생한다.

하지만 이에 대해서 CPU가 인지하지를 못한다...라기 보다는 stall하지 않는다.

같은 CPU 레지스터에서 일어났다면 stall 했겠지만

다른 코어에서 돌게 된다면.. 인지를 당연히 못하니까..?

 

다시 말해서 버그가 있는데도 버그를 잡지 못한다.

 

그렇다면 우리는 스레드를 사용할 때마다 맨날 버그를 키워야 하느냐??

그것이 아니다.

우린 해결방법을 찾았다.

 


 

가장 대표적인 해결방법이 뮤텍스, Mutex이다.

Mutual Exclusion이라고 상호 배재라는 뜻을 가진다.

서로 배재, 즉, 제외한다는 말이다.

그렇다면 뭘 제외하느냐? 이 자원을 사용할 권한을 제외하겠다는 말이다.

 

즉, 해당 변수에 접근하는 것은 권한을 가진 사람만이 가능하게끔 하겠다.

그래서 중복으로 접근하는 것을 막겠다. 라는 말이다.

 

C++에선 다행히 해당 mutex를 라이브러리로 제공한다.

우리는 먹기만 하면 된다.

 

#include <iostream>
#include <thread>
#include <chrono>
#include <vector>
#include <mutex>  


using namespace std;
using namespace chrono;


//해당 변수를 1 증가시키는 함수
void worker(int &result, mutex &m) {
    for (auto i = 0; i <10000; i++) {
        m.lock(); //해당 스레드가 권한을 가짐
        result += 1;
        m.unlock(); //해당 스레드가 권한을 뱉음.
    }
}

int main() {


    //thread 넣을 벡터
    vector<thread> workers;

    //스레드가 접근할 변수
    int cnt = 0;

    //mutext 객체 생성
    mutex m;

    steady_clock::time_point start = steady_clock::now();
    for (int i = 0; i < 10; i++) {
        workers.push_back(thread(worker,
                                  ref(cnt),
                                  ref(m)));    // 레퍼런스로 인자를 전달하려면 ref 함수를 써야함
    }

    for (int i = 0; i < 10; i++) {
        workers[i].join();
    }

    steady_clock::time_point end = steady_clock::now();

    cout << cnt << '\n';
    duration<double> sec = end - start;
    cout << "elapsed time " << sec.count();

}

 

 

결과는???

 

 

 

역시 정상적으로 나왔다!!

그런데 시간이 2배나 걸렸네..?

음..?

1만 2천 밖에 차이 안났는데.. 어떻게 시간은 2배나 걸린 것이지..?

 

Mutex는 우선 자원 하나에 대한 lock으로 

해당 스레드가 작업 권한을 가지고 있으면 나머지 스레드에서는 해당 권한을 반납하기를 무한대기한다.

즉, 어차피 한 번에 하나의 스레드만 실행된다.

 

??? 그러면 Mutex 걸거면 왜 스레드 만드냐???

하나로 그냥 돌리면 되는 것 아녀..?

 

해볼까??

한 스레드로 100000을 더해보자 그리고 mutex 걸 필요도 없다.

 

//해당 변수를 1 증가시키는 함수
void worker(int &result) {
    for (auto i = 0; i <100000; i++) {
        result += 1;
    }
}
...
int main() {

	...


    steady_clock::time_point start = steady_clock::now();

    thread t1(worker, ref(cnt));

    t1.join();
    
    steady_clock::time_point end = steady_clock::now();

    cout << cnt << '\n';
    duration<double> sec = end - start;
    cout << "elapsed time " << sec.count();

}

 

결과는..?

 

 

 

아니 뭐여... 하나가 더 빠르잖아? 그렇다면 Mutex를 왜쓰냐??

 

송구하오나 그것이 아니옵니다.

멀티스레딩이 쓸모가 없는 것이 아니라

원래부터가 race condition을 피하는 대상이자 일어나도록 설계하지도 않는다.

다만 race condition일 때도 정상적으로 작동할 수 있게 하는 것이 mutex인 것이다.

 

아하 그렇구나..

근데.. 만약 권한을 반납하지 않으면 어떻게 되나..?

내 스레드가 작업이 끝나도 반환하지 않으면..?

 

그게 바로 deadlock, 교착상태다.

모두가 자원 반납을 기다리지만 해당 스레드는 이미 종료되어 반납할 수도 없어.. 무한 대기다.

따라서 mutex를 설정할 때는 꼭 반납하는 과정도 필요하다.

 

뮤텍스도 생성자 소멸자 비슷하게 사용 후 해제 패턴을 따르기 때문에

소멸자에서 처리할 수 있다.

void worker(int& result, mutex& m) {
  for (int i = 0; i < 10000; i++) {
    // lock 생성 시에 m.lock() 을 실행한다고 보면 된다.
    lock_guard<mutex> lock(m);
    result += 1;

    // scope 를 빠져 나가면 lock 이 소멸되면서
    // m 을 알아서 unlock 한다.
  }
}
 

lock_guard 객체는 뮤텍스를 인자로 받아서 생성하게 되는데

이 때 생성자에서 뮤텍스를 lock 하게 되어 권한을 가진다.

그리고 lock_guard 가 소멸될 때 알아서 lock 했던 뮤텍스를 unlock 한다.

권한을 반환한다.

생성만해도 알아서 소멸할 때 반납해주니까 너무 편리해졌다

처음부터 이렇게만 만들면 되겠네?

 


 

하지만 이렇게 멀티스레딩이 쉬웠다면

사람들이 이렇게 힘들 필요가 없었다.

예외를 알아보자

 

....

void worker1(mutex& m1, mutex& m2) {
    for (int i = 0; i < 10000; i++) {
        lock_guard<mutex> lock1(m1);
        lock_guard<mutex> lock2(m2);
        // Do something
    }
}

void worker2(mutex& m1, mutex& m2) {
    for (int i = 0; i < 10000; i++) {
        lock_guard<mutex> lock1(m2);
        lock_guard<mutex> lock2(m1);
        // Do something
    }
}

int main() {


    //thread 넣을 벡터
    vector<thread> workers;

    //스레드가 접근할 변수
    int cnt = 0;

    //mutext 객체 생성
    mutex m1;
    mutex m2;

    steady_clock::time_point start = steady_clock::now();


    thread t1(worker1, ref(m1), ref(m2));
    thread t2(worker2, ref(m1), ref(m2));


    t1.join();
    t2.join();
    

    steady_clock::time_point end = steady_clock::now();

    cout << cnt << '\n';
    duration<double> sec = end - start;
    cout << "elapsed time " << sec.count();

}

 

결과는...?

 

?안나오네

강제종료해줬다.

 

그렇다면 왜 안되느냐??

 

t1을 만들어 해당 스레드를 m1 잠그고 m2 잠그고

끝나서 m1,m2가 반환되었고

t2에서 m2, m1을 잠그고

끝나서 m2, m1이 반환되었지만

 

어쩌다가 서로 

t1은 m1을 잠갔고 t2는 m2를 잠갔다.

t1은 m2가 반환되어야 잠군다음 실행가능하고

t2는 m1이 반환되어야 잠군다음 실행이가능하다.

 

하지만 해당 자원은 t1이나 t2가 먼저 한쪽이 놓아야할 것 같지만

자원을 뺏기지 않고 Hold and Wait를 한다.

 

결국 해당 스레드들은 자원이 반환되기를 기다리면서 무한 대기 상태에 빠진다.

 

?? lock_guards만 있으면 될 줄 알았지만

결국 deadlock이 또 나와버렸다.

 

그렇다면 어떻게 해결해야할까..?

권한을 들고 기다리지말고

둘다 사용가능 할 때만 점유하게끔 하기로 하였다.

 

함수를 변형시켜보자

void worker1(mutex& m1, mutex& m2) {
    for (int i = 0; i < 10; i++) {
        lock_guard<mutex> lock1(m1);
        lock_guard<mutex> lock2(m2);
        cout << "1 running " << i << '\n';
    }
}

void worker2(mutex& m1, mutex& m2) {
    for (int i = 0; i < 10; i++) {
        while (true) {
            m2.lock();

            // m1 이 이미 lock 되어 있다면 내가 들고있는 m2도 반환한다.
            //★★★★★★
            if (!m1.try_lock()) {
                m2.unlock();
                continue;
            }

            cout << "2 running " << i << '\n';
            m1.unlock();
            m2.unlock();
            break;
        }
    }
}

 

핵심은 worker2에 있다.

 

결과는??

 

오 잘나왔다.

 

사실 이렇게 deadlock이 자주 나오면

그 프로그램은 처음부터 다시 설계하는 것이 낫다고 생각할 정도로..

deadlock은 잘 나오지 않고 나오게 설계하면 안된다.

 

그래서 설계 전에 몇 가지를 지향해야 하는 포인트가 있는데 살짝 보자

 

중첩된 Lock 을 사용하는 것을 피해라

-> 정말로 여러 개의 락이 정말 필요한가?

모든 쓰레드들이 최대 1 개의 Lock 만을 소유한다면 데드락 상황이 발생하는 것을 피할 수 있다.

만일 여러개의 Lock 을 필요로 한다면 다시 생각해보자.. 정말??

 

Lock 을 소유하고 있을 때 유저 코드를 호출하는 것을 피해라

유저 코드에서 Lock 을 소유할 수 도 있기에 중첩된 Lock 을 얻는 것을 피하려면 Lock 소유시 유저 코드를 호출하는 것을 지양하자.

**유저 코드란 Thread에서 실행되는 함수가 아니라 Main에서 실행되는 것을 말한다.

 

Lock 들을 언제나 정해진 순서로 획득해라

만일 여러개의 Lock 들을 획득해야 할 상황이 온다면, 반드시 이 Lock 들을 정해진 순서로 획득해야 한다.

즉, 우선순위가 있어야 한다는 말과 같다.

앞에서 데드락이 발생했던 이유 역시, worker1 에서는 m1, m2 순으로 lock 을 하였지만 worker2 에서는 m2, m1 순으로 lock 을 하였기 때문이다.

만약 worker2 에서 역시 m1, m2 순으로 lock 을 하였다면 데드락은 없었을 것이다.

 

 

728x90
반응형
그리드형