Server programming

02_07_멀티쓰레드 프로그래밍_SpinLock

devRiripong 2022. 8. 5.
반응형

Q1. Spin Lock 의 개념에 대해 설명해 보세요.

Q2. volatile 키워드의 의미는?

Q3. 스핀락은 어떨 때 쓰는게 좋고 어떨 때 쓰면 안좋은가?

Q4. SpinLock 클래스를 구현하고 이를 이용해서 2개의 각 스레드가 1을 10000번씩 더하고 빼는 코드를 구현해 보아라.

 

이번 시간에는 스핀락을 구현해 보는 실습을 할거야. 많은 것들을 배울 수 있을거야.

면접에 단골로 등장하는 주제야.

멀티 스레드가 중요하면서도 멀티 스레들와 관련해서 스핀락만 물어봐도 멀티스레드에 대해 이해하고 있는지를 간단하게 테스트할 수 있는 질문이기 때문이야.

 

오늘 실습을 해볼거는 일단 지난 시간에 했던 코드를 복원하고 시작한다.

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>
#include <atomic>
#include <mutex>

int32 sum = 0; 
mutex m;

void Add()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<mutex> guard(m); 
		sum++; 
	}
}

void Sub()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<mutex> guard(m);
		sum--;
	}
}

int main()
{
	thread t1(Add); 
	thread t2(Sub);

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

	cout << sum << endl; 
}

실행하면 정확히 0으로 떨어져.

이걸 mutex가 아니라 우리가 자체적으로 구현한 클래스를 이용해 바꿔치기 해서 똑같이 0이 나오면 오늘의 테스트는 성공하는 거다.

일단 간단하게 SpinLock이라는 클래스를 만들어 준다.

class SpinLock
{
public: 
	void lock()
	{

	}

	void unlock()
	{

	}

private: 
};

lock, unlock 함수 만들어 주는데 소문자로 만들어 줘야 한다. lock_guard에서 내부에서 lock을 소문자로 호출해줄 것이기 때문에 시그니쳐를 똑같이 맞춰줘야 한다.

그래서 일반 락과 마찬가지로 lock을 획득하는 함수 lock이란 애랑 풀어주는 함수 unlock을 만들어 준거.

아무 배경 지식 없이 lock이란 애를 만들어 주세요 하면 보통 이렇게 만든다.

private: 
	bool _locked = false;

잠겼냐, 안잠겼냐의 의미.

lock을 할 때는 잠기는 게 풀려있을 때 까지 뺑뺑이를 돌다가 1빠가 되었다고 하면 확 가가지고 문을 잠그면 된다. 그래서 이걸 단순하게 표현해 보면

void lock()
	{
		while (_locked)
		{

		}

		_locked = true; 
	}

무한 루프를 돌면서 차례가 오기까지 기다릴 것이고, 차례가 오면 _locked를 true로 바꿔치기를 하면 되지 않을까 생각이 들어.

_locked가 false 면 무한 루프에서 바로 빠져 나와서 락을 잡아버리는 상황이 된다. unlock을 할 때는

void unlock()
	{
		_locked = false;
	}

이렇게 하면 다음애가 들어 올 수 있지 않을가 생각이 든다.

되는지 테스트를 해보자.

SpinLock spinLock; 

이란 변수를 선언해 주고

void Add()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<**SpinLock**> guard(**spinLock**); 
		sum++; 
	}
}

void Sub()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<**SpinLock**> guard(**spinLock**);
		sum--;
	}
}

lock_guard<mutex>의 mutex를 SpinLock클래스로 바꿔주고, mutex m 대신 SpinLock spinLock을 넣어준다.

빌드 후 ctrl+f5로 실행을 해보면

불행히도 값은 0이 아닌 이상한 값으로 나온다. 이 방법이 뭔가

오동작하고 있다는게 되는 거.

여러 문제가 있는데 일단

private: 
	volatile bool _locked = false;

bool에 volatile 키워드를 붙여준다.

C++에서 말하는 volatile은 C#이나 java에서 말하는 거랑 다른 의미야. 말 그대로 컴파일러에게 최적화를 하지 말라고 부탁하는 거에 불과해.

C#은 추가적으로 메모리 베리어나 가시성이랑 엮여 있어서 많은 기능을 하지만 C++은 그냥 최적화만 하지 말아줘에 불가해. 이 키워드는 거의 활용할 일은 없어.

예제를 들어서 보면

int main()
{
	int32 a = 0; 
	a = 1; 
	a = 2; 
	a = 3; 
	a = 4; 
	cout << a << endl;

여기서 컴파일러가 만든 코드를 곧이 곧대로 번역을 해서 어셈블리 언어로 만든 다음에 바이너리로 만들어서 사용을 할 수도 있겠지만 여기서 만약 Debug모드가 아니라 Release 모드로 바꿔준다고 하면 Release모드의 경우는 온갖 최적화가 들어가게 된다.

cout에 break point를 걸고 살펴 보도록 하자.

Release 모드로 바꿨으니 ServerCore도 한번 빌드를 해주고, GameServer를 실행해보자.

break point 잡히면 디버그→창→디스어셈블리에 가서 살펴 보면

a=1 a=2 a=3 a=4 를 했음에도 그거와 관련된 내용이 보이지 않는다.

사실상 컴파일러 입장에서는 왜 이런 쓸데없는 짓을 하는지 이해가 안가는 거.

1,2,3 넣어도 4로 덮어쓸 것이기에 a는 4f라고 바로 인지를 하면 되겠다 생각을 하는 거.

경우에 따라 프로그래머가 의도한 코드일 수 있어.

int main()
{
	**volatile** int32 a = 0; 
	a = 1; 
	a = 2; 
	a = 3; 
	a = 4; 
	cout << a << endl;

그래서 만약에 volatile 키워드를 붙여주게 되면

이제는 컴파일러에게 최적화를 하지 말고 쓸데 없는 짓을 다 해달라고 부탁을 하는 거

이 상황에서 다시 어셈블리창을 보면 실제로 a라는 변수에 1,2,3,4를 각각 넣어주는 부분이 들어간다고 볼 수 있다.

말 그대로 컴파일러 최적화를 하지 말아 주세요가 되는 거.

이게 volatile bool _locked랑 무슨 상관이 있냐면

int main()
{
	bool flag = true; 

	while (flag)
	{

	}

이렇게 돌고 있다고 가정을 하고 while(flag)에 break point를 잡아 보도록 할거야.

실행해서 어셈블리 코드를 보면 flag를 체크를 하는게 아니라 바로 jump를 해서 이상한데로 돌아가는 걸 볼 수 있다.

실질적으로 이 flag 값을 체크하는 코드가 날아갔다는 얘기가 되는 거.

컴파일러 입장에서 보면 애당초 flag는 true이기 때문에 이 값을 매 프레임 while문을 돌 때 마다 체크할 이유가 전혀 없다고 생각을 하고 있는 거.

class SpinLock
{
public: 
	void lock()
	{
		while (_locked)
		{

		}

		_locked = true; 
	}

	void unlock()
	{
		_locked = false;
	}

private: 
	**volatile** bool _locked = false; 
};

여기 while문에서는 _locked를 매번 체크해야 하는 이유가 있다.

멀티 스레드 환경에서는 이 _locked라는 애를 다른 애가 기존의 값과 다른 값으로 건드릴 수 있기 때문에 얘를 매번마다 체크를 해서 그 값에 따라 뭔가 변화가 일어나야 되는데 그럴 때 우리가 volatile 키워드를 딱 박아주면 컴파일러가 보기엔 쓸데 없어 보이더라도 flag에 대해 최적화를 하지 말아줘 라고 컴파일러에게 요청을 하고 있는 거다.

int main()
{
	**volatile** bool flag = true; 

	while (flag)
	{

	}

이 상태에서 똑같이 break point를 while에 잡고 디스어셈블리 창에 가보면

코드가 바뀌어 있는 거 볼 수 있다. flag라는 걸 가져와서 체크를 한 다음에 그 값이 0이냐 아니냐에 따라 가지고

만약에 통과하지 못했으면 main+20h이라는 07FF79EFA1050 요 부분으로 돌아오는 걸 볼 수 있다. 요 부분을 뺑뺑이를 돌고 있는 거.

우리가 의도한 이 코드를 실행해 준다는 걸 알 수 있다.

그래서 결국에는 volatile 키워드를 안붙여서 혹시라도 최적화를 해 가지고 문제가 발생하지 않았을까 걱정에 bool _locked에 붙여줬는데 사실 지금 이 상황에서는 이게 문제의 원인은 아니다.

volatile 키워드 추가해서 실행해도 0이 안나오는 건 마찬가지다.

class SpinLock
{
public: 
	void lock()
	{
		while (_locked)
		{

		}

		_locked = true; 
	}

	void unlock()
	{
		_locked = false;
	}

private: 
	volatile bool _locked = false; 
};

그럼 이 코드에서 어떤 문제가 있는걸까?

화장실 들어가는 행동과 자물쇠를 잠그는 행동은 한번에 일어나야 말이 되는 거.

지금은 딱히 그런 제한이 없기 때문에 동시에 들어가서 서로 승리자라고 하면서 같이 문을 잠궈 버리는 상황이 발생한 거.

예전에 정수 대상으로 ++, — 할 때도 한번에 이루어지는걸로 보였지만 사실상 3 단계에 걸쳐서 레지스터에 갖고 왔다가 값 1 증가시켜서 다시 레지스터 값을 원래 메모리에 갖다놓는 식으로 3단계에 걸쳐서 일어났던 것과 마찬가지로 지금도 결국에는 락에 대한 상태를 체크 하는 거와 락의 상태를 바꿔주는 코드가 2번에 걸쳐서 따로 따로 분리 되어서 일어났기 때문에 이런 상태가 발생한 것이다.

결국 문제가 되는 상황은 이 아이가 화장실에 들어가는 거랑 비었다는 걸 느끼고 자물쇠를 잠그는 이 행동 자체가 쪼개지면 안되고 한번에 일어나야 하는 거. 그리고 사실 이런 용어를 여러번 살펴본 적이 있는 atomic 하게 일어나야 한다고 설명할 수 있다. 즉 원자적으로 들어갔다가 잠그는 행동은 한번에 모든 애들이 일어나거나 애당초 실패할거면 이 문에 들어가는 거 조차 못해야 한다.

그래서 이 상황에 대한 처리를 하기 위해서 다시 코드로 돌아가보도록 할거야.

void lock()
	{
		while (_locked)
		{

		}

		_locked = true; 
	}

체크하는 것과 잠그는 것과 코드가 2개로 분리되어 있기 때문에 정말로 다수의 스레드가 동시에 lock을 실행할 경우에는 while(_locked) 부분을 간발의 차이로 동시에 통과를 한 다음에 두개의 스레드가 서로 _loacked = true에 와서 내가 승리했구나 오해를 해서 둘다 _locked을 true로 바꿔주는 상황이 발생할 수 있다.

사실은 한번에 하나의 스레드만 통과를 해야 정상적인데 지금은 2개로 분리가 되어 있기 때문에 이상한 상황이 발생한다고 볼 수 있다.

해결하기 위해서 atomic 처럼 이 코드가 한번에 묶여가지고 동작을 하게끔 유도를 해줘야 한다는 얘기가 된다.

atomic으로 묶어주는 일련의 함수들이 있다.

// CAS (Compare-And-Swap) 계열의 함수라고 한다.

이것도 운영체제에 따라서 InterlockedExchange~ 시리즈가 있는데 지금은 이런식으로 사용할 필요 없이 atomic을 이용하면 그 함수가 포함이 되어 있다.

atomic<bool> _locked = false;

참고로 이 atomic을 사용하는 순간 volatile 키워드는 잊어도 된다. 얘가 그 기능까지 포함하고 있다고 보면 된다.

atomic이라는 거 자체가 원자적으로 사용하겠다는 의미로 받아 줄 수 있었어.++이나 —를 할 때 원자적으로 동작한다는 걸 알 수 있었는데

그거 말고도 다른 함수들이 묶여 있다.

나중에 lock free 프로그램에서도 이 개념이 중요하게 등장하게 된다.

함수 원형 보면 bool뱉어주고, bool& _Expexted, const bool _Desired 를 받아주고 있어.

환경, 언어에 따라 조금씩 달라진다. C++ 버전은 의사코드로 보면

		bool expected = false; 
		bool desired = true; 

		_locked.compare_exchange_strong(expected, desired)

요 expected는 우리가 이 _locked라는 값이 무엇인지를 예상을 해주고 있는거, 일단 false로 놓고, false면 desired를 true로 세팅을 해줘 보도록 할거야.

이 코드를 풀어서 써 보도록 할거야.

// CAS 의사 코드
		if (_locked == expected)
		{
			expected = _locked; 
			_locked = desired; 
			return true; 
		}
		else
		{
			expected = _locked;
			return false;
		}

compare_exchange_strong 함수가 이 코드를 한방에 실행을 한다고 보면 된다.

처음에 보면 로직이 복잡해. 헷갈리는데 유심히 보면 expected랑 desired 가 큰 의미를 갖고 있어.

_locked라는 값이 처음에 어떤 값이 되기를 기대를 하고 있는거.

_locked가 false라고 한다면 그러면 _locked를 true로 바꿔주고 싶은 상황이 되는 거.

화장실 문으로 비유를 하면 _locked가 false라는 건 문이 잠기지 않은 상태인 거니까 들어가서 걔를 잠궈 주겠다는 의미가 되는 거니까.

before 상황인 expected는 false고 after인 바뀌길 원하는 상황은 true로 바꿔주는 상황을 얘기를 하는 거.

_locked가 false였으면 true 로 바꿔 주세요 부분이 실행 되는거.

만약에 return true 쪽으로 들어오면 성공적으로 값을 고쳤다는게 되는 거니까 실제로 lock을 획득했다는 의미가 되는 건데

만약에 경합에 실패해서 다른 애가 선수를 쳤다거나 애당초 _locked라는 게 false가 아니라 이미 true로 세팅이 되어가지고 딴 애가 lock을 소유하고 있는 상태라면 무조건 else 문 부분으로 들어오게 될거야. compare_exchange_strong이 실패하게 되는 거.

그래서 사용할 때 이렇게 바꿔주면 된다.

		while (_locked.compare_exchange_strong(expected, desired) == false)
		{

		}

얘가 실패를 했다고 하면 성공할 때 까지 무한적으로 시도를 하겠따는 의미가 되는 거고 그럼

		while (_locked)
		{

		}

		_locked = true;

이 두개의 코드를 한 방에 묶어준 셈이 되는 거다.

한가지 조심해야 하는건 compare_exchange_strong 얘가 성공하든 실패하는 거랑 상관 없이 기본적으로 이 expected 값이 우리가 처음에 넣어줬던 값이 아니라 원래 들어가 있던 _locked 코드가 compare_exchange_strong(expected, desired) 이쪽으로 들어 간다는 문제가 살짝 있어.

얘를 다시 한번 보면 그냥 bool이 아니라 bool의 레퍼런스를 받고 있어. 이 expected 값이 계속 매번마다 바뀌기 때문에 얘를 실행을 할 때 마다 우리 원했던 초창기 값으로 바꿔치기를 다시 해줘야 한다.

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

결국에는

class SpinLock
{
public: 
	void lock()
	{
		// CAS (Compare-And-Swap)

		bool expected = false; 
		bool desired = true; 

		// CAS 의사 코드
	/*	if (_locked == expected)
		{
			expected = _locked; 
			_locked = desired; 
			return true; 
		}
		else
		{
			expected = _locked;
			return false;
		}*/

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

expected, desired를 세팅한 다음에 이런식으로 compared and swap을 무한으로 계속 돌리면 얘가 성공하는 순간 빠져 나오게 될 것이고,

성공했다는 의미는 결국에는 원하던 desired 값을 _locked에다가 넣어준 상황이 되는 거

즉,

		while (_locked)
		{

		}

		_locked = true;

요 코드가 묶여서 한방에 실행이 되고 있다고 보면 된다.

그리고

void unlock()
	{
		_locked = false;
	}

여기서 _lockded = false로 해도 상관이 없긴 한데

_locked가 atomic 계열의 변수인데 false라고 하면 boolean인지 헷갈리니까

void unlock()
	{
		// _locked = false;
		_locked.store(false); 
	}

store같은 별도의 함수로 만들어 주도록 할거야.

결국 논리적으로 보면 아까랑 별로 달라진게 없기는 한데 이 의사코드가 한방에 묶여서 atomic한 함수로 들어 갔다는게 차이가 있는 거.

그래서 compare_exchange_strong을 이용해 이렇게 바꿔치기를 하는 부분을 만들어 봤다.

이게 사실은 간단하게 구현한 spin lock의 개념이라고 할 수 있겠어.

이길 때 까지 뺑뺑이를 돌면서 시도를 하고 있는거.

이 코드를 다시 한 번 실행을 해 보면

이제는 정상적으로 0이 뜬다는 걸 알 수 있다.

얘가 정상적인 락으로, 상호 베타적인 락으로 잘 동작을 한다는 걸 볼 수 있는 거.

코드를 줄여 보면 생각보다 짧다는 걸 알 수 있다.

class SpinLock
{
public: 
	void lock()
	{
		// CAS (Compare-And-Swap)

		bool expected = false; 
		bool desired = true; 

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

	void unlock()
	{
		_locked.store(false); 
	}

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

결국에는 여기서 무한 루프를 돌면서 계속 시도하는 거 자체가 어떻게 보면 효율적으로 돌아가는 것일 수도 있고 아닐 수도 있어.

지금 경합이 붙고 있는데 lock을 점유하는 애가 금방 나갈 거 같은면 굳이 커널모드로 돌아가는 context switching을 하지 않고 유저모드에서 뺑뺑이를 돌면서 계속 시도를 해서 다시 한번 용감하게 시도를 하는게 사실 맞을 것이고

그게 아니라 만약 상대방 쪽에서 몇시간 lock을 놔주지 않는다 가정을 하면 계속 무한정으로 실행하는게 무식한 상황이 될거야.

그래가지고 이 스핀락의 특징은 계속 뺑뺑이를 돌며 락을 시도한다는 특징이 있긴 한데 만약에 경합이 붙어서 스핀락이 서로 무한루프를 돌기 시작하면 CPU 점유율이 높아진다는 특징이 있다.

만약에 context switching이 되어서 실행 소유권을 딴 애한테 넘겨 줬으면 CPU 점유율이 낮아지면서 다른 애가 그걸 적절하게 필요에 따라 잘 활용할 수 있게 되는 거지만 spin lock에서 만약 이 while문을 무한정 돌면서 이 compare_exchange_strong을 체크하면서 나 들어갈 수 있냐, 비었냐를 계속 체크하는 건 CPU를 쓸데없이 낭비하는 행동이기도 하다. 그래서 양날의 검이다.

하지만 스핀락 자체는 어려운 개념이 아니야. 무한 루프를 돌면서 존버메타 화장실 앞에서 나올 때 까지 기다린다 그런 개념을 이해해 주면 되겠고, 혹시라도 면접에서 스핀락 구현해 봤냐고 물어보면, 스핀락은 무엇이냐 관련된 내용이 나오면 이런 내용들을 숙지해서 대답을 하면 된다.

이렇게 락에 대한 첫번째 구현 방법. 락을 획득할 때 누군가 들어가 있으면 어떻게 처리 할 것이냐의 문제에서 가장 먼제 선택한 방법. 계속 기다린다는 방법을 스핀락으로 구현해 봤다.

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>
#include <atomic>
#include <mutex>

class SpinLock
{
public: 
	void lock()
	{
		// CAS (Compare-And-Swap)

		bool expected = false; 
		bool desired = true; 

		// CAS 의사 코드
	/*	if (_locked == expected)
		{
			expected = _locked; 
			_locked = desired; 
			return true; 
		}
		else
		{
			expected = _locked;
			return false;
		}*/

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

		//while (_locked)
		//{

		//}

		//_locked = true; 
	}

	void unlock()
	{
		// _locked = false;
		_locked.store(false); 
	}

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

int32 sum = 0; 
mutex m;
SpinLock spinLock; 

void Add()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<SpinLock> guard(spinLock); 
		sum++; 
	}
}

void Sub()
{
	for (int32 i = 0; i < 10'0000; i++)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum--;
	}
}

int main()
{
	thread t1(Add); 
	thread t2(Sub);

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

	cout << sum << endl; 
}
반응형

댓글