Computer Science 기본 지식/운영체제

[C++ Thread] 공유 변수와 경쟁 조건

로파이 2021. 4. 24. 16:01

멀티 쓰레드를 이용한 프로그래밍을 할 경우 공유 자원을 병행적으로 읽고 쓰기 때문에 경쟁 조건 (race condition)이 발생할 수 있다.

 

Worker Thread 예제

#include <iostream>
#include <mutex>
#include <vector>
#include <thread>
#include <chrono>
#include <time.h>
using std::thread;

class Worker
{
public:
	int _total = 0;
	Worker() {}
	void DoWork(int* out)
	{
		for (int i = 0; i < _total; ++i)
			*out += 1;
	}
};

int main()
{
	Worker* workers = new Worker[5];
	for (int i = 0; i < 5; ++i)
		workers[i]._total = 100000000;

	int result = 0;
	thread worker_threads[5];
	for (int i = 0; i < 5; ++i)
	{
		worker_threads[i] = thread(&Worker::DoWork, &workers[i], &result);
	}

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

	printf("result: %d\n", result);

	delete[] workers;
	return 0;
}

위를 실행하면 각 worker가 100000000씩 더하여 총 worker 수 x 100000000의 결과를 산출해야하지만 실제 결과는 그렇지 않다.

 

이는 메모리-레지스터 단위 혹은 레지스터-레지스터 단위에서 발생하는 문제인데, out 값을 보관하는 메모리가 여러 쓰레드에서 순차적으로 읽히는 와중에 계산되는 결과가 여러 쓰레드에서 동기화가 되지 않아 발생한다.

 

mutex

공유 변수에 대해 경쟁 조건이 발생하는 영역을 임계영역이라 하고 이를 보호하기 위한 방법은 mutex 라는 객체를 이용하는 것이다.

관련 연산

  • lock() : 진입하는 쓰레드가 소유권을 가지면서 영역을 잠그고 다음 실행 영역은 오직 쓰레드 하나만 접근 가능하다. 
  • unlock() : 영역을 나오는 쓰레드는 소유권을 반환하여 다른 쓰레드가 접근 가능해진다.
  • try_lock() : 영역을 잠그기전 소유권을 가질 수 있는 지 알아낸다.
#include <mutex>
mutex mtx;

class Worker
{
public:
	int _total = 0;
	Worker() {}
	void DoWork(int* out)
	{
		mtx.lock();
		// critical section
		for (int i = 0; i < _total; ++i)
			*out += 1;
		mtx.unlock();
	}
};

혹은 RAII 기법의 std::lock_guard 객체를 이용하여 생성할 때 mutex 소유를 가지고 소멸할 때 반납할 수 있다.

template 인수로 mutex 객체를 설정한다.

class Worker
{
public:
	int _total = 0;
	Worker() {}
	void DoWork(int* out)
	{
		std::lock_guard<mutex> lock(mtx);
		// critical section
		for (int i = 0; i < _total; ++i)
			*out += 1;
	}
};

 

데드락 Deadlock

둘 이상의 쓰레드가 서로 임계 영역의 진입을 무한정 기다리고 있는 상태를 의미한다. 둘 이상의 mutex로 deadlock을 볼 수 있다.

using std::thread;
using std::mutex;
mutex mtx1;
mutex mtx2;

class Worker
{
public:
	int _total = 0;
	Worker() {}
	void DoWork(int* out)
	{
		for (int i = 0; i < _total; ++i)
		{
			mtx1.lock();
			mtx2.lock();
			// critical section
			*out += 1;
			mtx2.unlock();
			mtx1.unlock();
		}
	}
	void DoRest(int* out)
	{
		for (int i = 0; i < _total; ++i)
		{
			mtx2.lock();
			mtx1.lock();
			// critical section
			*out -= 1;
			mtx1.unlock();
			mtx2.unlock();
		}
	}
};

Worker 쓰레드가 DoWork와 DoRest를 하는 두 가지 경우가 있다. 만약 DoWork 쓰레드가 mtx1를 잠그고 문맥교환이 일어나고 DoRest 실행 쓰레드가 mtx2를 잠그고 mtx1을 잠그려하면 mtx1의 소유는 DoWork에 있으므로 대기하게 된다. 반대로 DoWork도 mtx2를 잠그려하지만 소유권은 DoRest에 있기 때문에 대기하게 된다. 이렇게 서로 누가 하나 반납할 때까지 기다리게 되는데, 반납 상황은 일어나지 않으므로 데드락 상황에 놓인 것이다. 실제 코드를 실행하면 실행 종료가 되지 않는다.

 

간단한 해결 방법은 순서만 바꿔주면 서로 엊갈려서 소유권을 가지는 상황이 일어나지 않게 되므로 데드락 상황은 발생하지 않는다.

class Worker
{
public:
	int _total = 0;
	Worker() {}
	void DoWork(int* out)
	{
		for (int i = 0; i < _total; ++i)
		{
			mtx1.lock();
			mtx2.lock();
			// critical section
			*out += 1;
			mtx2.unlock();
			mtx1.unlock();
		}
	}
	void DoRest(int* out)
	{
		for (int i = 0; i < _total; ++i)
		{
			mtx1.lock();
			mtx2.lock();
			// critical section
			*out -= 1;
			mtx2.unlock();
			mtx1.unlock();
		}
	}
};

일반적으로 2개 이상의 중첩 lock을 사용하는 것은 좋지 않고 lock이 2개 이상 사용되는 범위인 경우 순서대로 획득하고 반납하도록 해야한다.