[멀티 쓰레드 실습] Lock 구현: Spin Lock, Sleep Lock, Event 방식

Jin Hur·2022년 11월 26일
0

1. Spin Lock 구현

SpinLock이라는 클래스를 정의하여 Lock을 구현한다.

// 정의한 lock 클래스
SpinLock slock;

// 주소 공간의 Data 영역의 데이터
// 쓰레드끼리 해당 데이터를 공유할 수 있음
int32 SharedData = 0;

void Add() {
	for (int32 i = 0; i < 100'0000; i++) {
		// lock_guard 객체 생성을 통해서도 구현 가능
		// lock_guard<SpinLock> guard(slock);

		slock.lock();
		SharedData++;
		slock.unlock();
	}
}
void Sub() {
	for (int32 i = 0; i < 100'0000; i++) {
		// lock_guard 객체 생성을 통해서도 구현 가능
		// lock_guard<SpinLock> guard(slock);

		slock.lock();
		SharedData--;
		slock.unlock();
	}
}


int main() {

	thread t1;
	thread t2;

	/* 원자성이 보장되지 않아서 발생하는 문제 */
	t1 = thread(Add);
	t2 = thread(Sub);

	if (t1.joinable())
		t1.join();
	if (t2.joinable())
		t2.join();

	cout << SharedData << endl;
	// 의도한 결과 확인
}

잘못된 Spin Lock 구현

class SpinLock {
public:
	void lock() {
		/* 잘못된 Spin-Lock 구현 */
		while (flag) {	// ...(1) 
        
			// 두 개의 쓰레드가 이 조건 while 루프를 동시에 통과할 수 있다!!!
            
		}	
		flag = true;	// ...(2) 

	}
	void unlock() {
		flag = false;
	}

private:
	bool flag = false;
};

위 코드는 문제점이 있다. 여러 쓰레드들이 while 루프(1)를 동시적으로 벗어날 수 있다.

루프를 돌며 조건과 비교하는 과정(1)과 flag 변수에 값을 대입하는 과정(2)가 원자적으로 수행해야 됨을 알 수 있다.

이를 위해 먼저 flag 변수를 atomic 타입으로 선언하고, compare_exchange 함수를 호출하여 (1),(2) 과정을 내부적으로 원자성을 갖고 수행하도록 한다.

/* Spin-Lock 구현 */
class SpinLock {
public:
	void lock() {
		/*
		// (1) 
		while (flag) {

		}	// 두 개의 쓰레드가 이 조건 while 루프를 동시에 통과할 수 있다!!!
		// (2) 
		flag = true;

			// => 따라서 (1), (2)가 원자적으로 수행되어야 한다. 
			// => CAS (Compare-And-Swap) 
		*/

		/* 올바른 Spin-Lock 구현 */
		bool expected = false;
		bool desired = true;
		while (flag.compare_exchange_strong(expected, desired) == false) {
			expected = false;
		}
	}
	void unlock() {
		flag = false;
	}

private:
	atomic<bool> flag = false;
};

compare_exchange_strong(expected, desired) 함수의 내부동작은 pseudo 코드로 다음과 같다.

bool compare_exchange_strong(bool expected, bool desired) {
	bool expected = false;
	bool desired = true;
	if(flag == expected) {
		expected = flag;
		flag = desired;
		return true;
	}
	else {
		expected = flag;
		return false;
	}
}

이 코드들이 원자적으로 일어나는 것이다.


2. Sleep Lock

Spin Lock 처럼 락을 획득하지 못할 시 본인 쓰레드가 할당받은 타임퀀텀을 모두 소비하며 루프를 돌며 조건을 체크하는 것이 아니라, 일단 CPU 자원을 다른 쓰레드에 양보하는 방식이다.

Spin Lock 구현에서 쓰레드를 선점시키는 API를 호출하면 된다.

class SleepLock {
public:
	void lock() {
		bool expected = false;
		bool desired = true;

		// Spin-Lock의 경우 락을 점유하지 못할 시 계속해서 루프를 돈다.
		//while (flag.compare_exchange_strong(expected, desired) == false) {
		//	expected = false;
		//}

		while (flag.compare_exchange_strong(expected, desired) == false) {
			expected = false;

			// 곧바로 루프를 돌지 않도록 sleep 시킨다.
			// => 컨텍스트 스위칭 유발!
			this_thread::sleep_for(1ms);	// 1ms 동안 스케줄되지 않음

			// 또는 시간 지정없이 
			// this_thread::yield();	// == this_thread::sleep_for(0ms);
										// 일단 선점되어 양보한다는 의미
		}
		
	}
	void unlock() {
		flag = false;
	}

private:
	atomic<bool> flag = false;
};

3. Event

생산자(Producer)과 소비자(Consumer) 쓰레드가 각각 있다. 공통으로 공유하는 큐가 있는데, 생산자가 큐에 데이터를 삽입하는 생산 과정을 하면, 소비자는 이를 꺼내어 출력하는 소비 과정을 보인다.

만약 생산자가 생산을 아주 드믈게 한다면 소비자 polling이나 빈번한 스케줄은 오히려 낭비가 될 것이다. 이럴 때 이벤트 방식을 사용한다.

이벤트는 커널 오브젝트를 활용한다.
커널 오브젝트란 커널이 관리하는 객체로 프로세스, 쓰레드, 이벤트 등이 있다.
커널 오브젝트는 공통적으로 다음과 같은 속성들을 가지고 있다.

  • Usage Count: 오브젝트를 몇명이 사용하고 있는지
  • Signal / Non-Signal 플래그
  • 자동(auto) / 수동(manual) 플래그

윈도우의 경우 #include <Windows.h> 헤더 필요

#include <Windows.h>
...
...


mutex m;
queue<int32> q;
HANDLE handle;

void GUI() {
	char c = 0;
	while (true) {
		cout << "char: ";
		cin >> c;

		if (c != 'a') {
		}
		else {
			cout << "SetEvent!" << endl;
			::SetEvent(handle);
		}

		this_thread::sleep_for(100ms);
	}
}
void PROCESS() {
	::WaitForSingleObject(handle, INFINITE);
	cout << "확인" << endl;
}

int main() {
	// 이벤트 생성
	handle = ::CreateEvent(NULL /*보안속성*/, FALSE/*매뉴얼 리셋 관련*/, FALSE/*초기상태*/, NULL/*이벤트 이름*/);

	thread t1(PROCESS);
	thread t2(GUI);
	t1.join();
	t2.join();

	// 이벤트 소멸
	::CloseHandle(handle);
}

위 코드는 콘솔창에 'a' 문자를 입력할 때 PROCESS 작업 쓰레드가 깨어나 "확인" 문자열을 출력하는 것이다. 'a' 문자를 입력하지 않을 때는 깨어나지 않고, 'a' 문자를 입력할 때 깨어난다.

내부적으로 보면, 'a'를 입력할 때 ::SetEvent(handle)이 호출되는데 이는 할당받은 handle과 맵핑된 커널 오브젝트의 Sinal flag를 바꾸고, 이에 따라 대기중인 쓰레드가 깨어나는 것이다.

0개의 댓글