C++

멀티 쓰레드 Lock 구현

춤추는수달 2022. 1. 26. 00:43
Lock 상태일 때 다른 쓰레드는 뭘 할까?

어떤 쓰레드가 lock을 하고 메모리를 차지했다면 다른 쓰레드들은 unlock될 때 까지 기다려야 한다. 그런데 기다린다는 것은 말하자면 시간 낭비이다. 어떻게 시간 낭비를 줄일 수 있을지 몇 가지 정책을 알아보자.

  • Spin Lock : unlock 될 때까지 무작정 기다리기
  • Sleep : 일단 물러나고 나중에 다시 돌아오기.(랜덤)
  • Event : 다른 사람한테 unlock 되면 알려달라고 하고 나는 다른일 하고 있기.

대표적으로 위와 같은 정책들이 있다. 아니면 일정 시간 무작정 기다리다가 물러나기 와 같이 위 정책들을 적절히 섞어 써도 된다. 

그렇다면 위 정책을 사용하려면  Lock 함수를 어떻게 구현해야 할지 알아보자.


Spin Lock

  • lock이 풀릴 때 까지 무작정 기다린다.
  • lock 하고 있는 쓰레드가 금방 놔줄 것 같으면 효과적인 방법이다.
  • 근데 한 쓰레드가 locke을 오랫동안 안놔주고 다른 여러 쓰레드가 lock을 얻기 위해 경합하면 계속 while문을 돌게되므로 cpu점유율이 높아짐.

잘못된 코드

먼저 직관적인 생각만으로 코드를 짜보면 아래와 같이 될 수 있겠다.

class SpinLock
{
public :
	void lock()
    {
    	while(_locked) // _locked가 false가 될 때까지 무한 대기
        {
        }
        _locked = true; // _locked가 false면 true 로 바꾸고 탈출.
    }
    void unlock()
    {
    	_locked = false; 
    }
private :
	_locked = false;
}

문제점

그런데 위와 같은 코드는 쓰레드가 제대로 동작하지 않는다. 왜냐하면 locked가 false인지 확인하는 부분과 _locked를 true로 바꿔주는 부분이 atomic 하지 않기 때문이다. 즉 여러 쓰레드가 동시에 lock() 함수를 실행해서 동시에 _locked = true;  문장에 도착하면 결국 대기하는 사람은 없고 둘다 통과해버리는 상황이 발생한다. 그렇다면 이 문제를 해결하기 위해서는 어떻게 해주면 좋을까?

해결방법

이 문제를 해결하기 위해서는 locked가 false인지 확인하는 부분과 _locked를 true로 바꿔주는 부분을 atomic 하게 바꿔주어야 한다.  

이를 위해서는 CAS 함수를 사용해주면 좋다.

CAS(Compare-And-Swap)함수란

비교, 스왑을 atomic 하게 수행해주는 함수들이다. 

이번엔 _locked.compare_exchange_strong(expected , desired) 함수를 써보자. 인자는 두 가지가 있다.

  • expected : _locked의 값이 뭔지 예상한 값
  • desired : _locked의 값이 expected 와 같다면 _locked에 들어가길 바라는 값.

그리고 이 함수의 동작은 다음과 같다.

  • _locked와 expected 가 같다면 expected 에 _locked 를 넣어주고, _locked 에는 desired 를 넣어준고 true를 반환한다.
  • _locked와 expected 가 다르다면 expected 에 _locked를 넣어주고 false를 반환한다.
atomic

또한 _locked 변수도 atomic 하게 바꿔주자. (atomic<boo>  _locked = false;)

[참고] volatile키워드 : 
컴파일러야 얘는 최적화 하지 말아줘 키워드(c++기준).
release 모드일 때 컴파일러는 각종 최적화를 해준다.
예를 들어 만약 while(b){} 문이 있다고 할 때 b가 계속 true일 것 같으면 컴파일러는 그냥 while문에서 b를 검사하는 것을 스킵해버린다. 
그런데 Spin Lock 에서 _locked 변수가 이 최적화 때문에 검사를 스킵해버리면 쓰레드 동작이 잘못될 수도 있지 않을까??  그런 걱정이 들 때 volatile 키워드를 사용해보자. 
e.g) volatile bool _locked; 
참고로 이 키워드는 atomic 에 기본적으로 포함된 기능이기 떄문에 쓸 일은 사실 별로 없다.

잘 되는 코드

class SpinLock
{
public :
	void lock()
    {
    	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;
}

Sleep

Sleep 기법은 현재 unlock을 기다리고 있는 쓰레드의 실행권(time slice)을 포기하고 다른 쓰레드에게 양보하는 것이다. 그리고 다음에 다시 자신에게 실행권이 왔을 때 unlock 상태인지 확인하는 것을 반복한다.

하지만 실행권을 양보한 사이에 다른 쓰레드가 새치기하면 또 그 쓰레드를 기다려야하는, 운에 맡기는 방법이라고 할 수 있다.

실행권(time slice) : 실행권이란 운영체제가 각 쓰레드에게 할당한 CPU를 점유할 기회, 시간을 말한다. 운영체제는 여러 쓰레드가 최대한 공평하게 CPU를 점유할 수 있도록 쓰레드들에게 CPU 시간을 나눠준다. 참고로 이것을 '운영체제 스케쥴링' 이라고 한다.

그렇다면 어떻게 쓰레드의 실행권을 포기시킬까? 쓰레드의 실행권이 포기되는 이유는 여러 가지이다.

  • 쓰레드에게 주어진 time slice를 모두 소진한 경우.
  • 쓰레드가 자진해서 실행권을 포기한 경우.
  • 쓰레드가 System Call을 한 경우.
시스템 콜(System Call) : 시스템 콜이란 운영체제에게 작업을 요청하는 것이다. 유저 모드에서 동작하는 프로그램들은 커널모드에 접근할 수 없기 때문에 입출력, 하드웨어 사용 등 커널모드에서만 할 수 있는 작업을 하기 위해 운영체제에게 시스템 콜을 한다. 이 시스템 콜을 하게되면 쓰레드의 실행권을 운영체제에게 넘기고 요청한 시스템 콜이 완료될 때까지 대기 큐에 들어가서 대기하게 된다. 

우리가 사용할 방법은 '쓰레드가 자진해서 실행권을 포기한 경우.' 에 해당한다. 쓰레드가 자진해서 실행권을 포기하게 하는 방법은 사실 sleep 함수를 호출해주면 끝이다. 

sleep 함수는 아래 와 같은 문장으로 쓸 수 있다.

  • this_thread::sleep_for(std::chrono::milliseconds(100)); // 100ms 동안 실행권을 쥐고 있다가 양보하기
  • this_thread::sleep_for(100ms); // 위 문장과 똑같음.
  • this_thread::yield(); // 사실상 this_thread::sleep_for(0ms); 과 같음

0ms 동안 sleep은 사실상 sleep을 안해주는 것과 같다고 생각할 수 있지만, 실행권을 다른 쓰레드에게 양도한다는 의미를 가지기 때문에 아무 의미도 없는 코드가 아니라는 점을 유념하자.

그럼 sleep함수를 사용해 Lock을 구현해보자. 

class SpinLock
{
public :
	void lock()
    {
    	bool expected = false;
        bool desired = true;
    	while(_locked.compare_exchange_strong(expected,desired) == false)
        {
        	expected = false;
            this_thread::yield(); // 이 한 문장만 추가하면 됨.
        }
        
    }
    void unlock()
    {
    	_locked.store(false); 
    }
private :
	atomic<bool> _locked = false;
}

위의 Spin Lock 방법의 코드에서 this_thread::yield(); 한 문장만 추가됐다. 

 


Event

이벤트를 활용해 Lock을 구현해보자.

기본적인 컨셉은 lock을 잡고있는 쓰레드 t1이 작업을 끝내고 unlock을 하면 운영체제가 이를 알아채고 unlock을 기다리던 쓰레드 t2에게 알려주는 것이다. 이 때 작업이 끝났는지 알아채고 t2에게 알려주는데 필요한 것이 Event이다.

Event란

여기서 이벤트는 운영체제에서 관리하는 커널 오브젝트 이다. signal속성의 상태를 바꾸고 상태가 바뀌면 상태가 바뀌길 기다리는 쓰레드를 깨워주는 등의 작업을 할 수 있다. 

커널 오브젝트 
커널에서 사용하는 오브젝트. 말 그대로 커널에서 관리되는 객체라고 할 수 있다. 이벤트, 프로세스, 쓰레드 기타등등이 여기에 속한다. 이 커널 오브젝트들은 관리에 필요한 정보들을 가지고있다.
이벤트는 auto/manual 속성, signal 속성 등을 가지고 있다. 커널 오브젝트를 사용하는 것은 무거운 작업이므로 주의할 필요가 있다. 이벤트는 좀 가벼운 커널 오브젝트인 편.

이벤트의 주요 속성들을 알아보자. 

  • auto / manual reset event : signal이 돼서 통과되면 자동으로 다시 non-signal 상태로 바꿔줄지, 아니면 수동으로 바꿔줄지에 대한 속성.
  • signal / non-signal : 이벤트의 상태 속성. 이벤트가 켜져있는지, 꺼져있는지를 나타낸다. 운영체제가 이 속성의 값을 확인해서 쓰레드에게 신호를 준다. 

이제 이벤트를 사용하는 코드 및 함수를 알아보자.

  • HANDLE handle = CreateEvent(보안속성, 자동/수동, 초기상태, 이름); // 이벤트(커널 오브젝트)를 만들어준다. 보안속성 인자는 일단 FALSE로 주자. 신경쓰기 귀찮음.
  • CloseHandle(handle); // 이벤트를 닫는다. 
  • WaitForSingleObject(handle, 대기시간); // handle에 해당하는 이벤트가 signal 상태가 될 때까지 [대기시간] 만큼 대기한다. 무한히 대기하게 하려면 [대기시간]에 INFINITE 를 주면 된다. 
  • SetEvent(handle); // handle 이벤트를 signal 상태로 만든다.
  • ResetEvent(handle); // handle 이벤트를 non-signal 상태로 만든다.

그렇다면 이제 이벤트 Lock이 필요한 상황을 알아보자. 

queue<int> que;
mutex m;
void pushThread()
{
	while (true)
	{
		{
			lock_guard<mutex> lg(m);
			que.push(100); 
		}
		
		this_thread::sleep_for(10000ms);
	}

}
void popThread()
{
	while (true)
	{
		lock_guard<mutex> lg(m);
		if (que.empty() == false)
		{
			que.pop();
			cout << que.size() << endl;
		}
			
	}
}
int main()
{
	thread t1(pushThread);
	thread t2(popThread);
	t1.join();
	t2.join();
    return 0;
}​

위 상황에서 que는 10000ms 라는 어마어마한 시간에 한 번 꼴로 채워지고 있다.. 그러나 t2쓰레드는 그 시간동안 while 루프를 돌며 que가 비어있는지 계속 체크하고 있다. 이는 엄청난 자원 낭비를 초래한다. 실제로 내 컴퓨터에서 확인해보니 상시 CPU 6%정도를 쓰고있었다. 

이럴 때 이벤트를 사용해서 t1의 push 작업이 일어났을 때에만 t2가 일어나서 pop 작업을 해주면 정말 좋을 것 같다. 구현해보자. 

queue<int> que;
mutex m;
HANDLE handle;
void pushThread()
{
	while (true)
	{
		{
			lock_guard<mutex> lg(m);
			que.push(100); 
		}
		SetEvent(handle);
		this_thread::sleep_for(10000ms);
	}

}
void popThread()
{
	while (true)
	{
		WaitForSingleObject(handle, INFINITE);
		lock_guard<mutex> lg(m);
		if (que.empty() == false)
		{
			que.pop();
			cout << que.size() << endl;
		}
		ResetEvent(handle);
	}
}
int main()
{
	handle = CreateEvent(FALSE, FALSE, FALSE, NULL);
	thread t1(pushThread);
	thread t2(popThread);
	t1.join();
	t2.join();

	CloseHandle(handle);
    return 0;
}

이렇게 하면 동작에는 별 차이가 없어 보이지만, CPU 점유율을 보면 상시 6%정도였던 아까와는 다르게 상시 0%정도를 유지하고 있는 것을 확인할 수 있다. 

 

그러나 이벤트를 사용할 때 주의할 점이 있다. 너무 빈번하게 일어나는 상황일 경우 커널 오브젝트를 사용하는 것은 좋지 않다. 커널 오브젝트를 만들고 관리하는 것은 생각보다 무거운 작업이기 때문이다. 위 상황처럼 어쩌다 한 번 일어나는 경우에 사용하는 것이 효과적이다. 

 

그런데 만약 위 상황에서 sleep 하는 부분 없이 마구 push된다면 쓰레드가 제대로 동작하지 않는 문제가 발생할 수 있다. 그 이유와 해결법은 다음 글 [조건변수] 에서 알아보자. 

https://ddukddaksudal.tistory.com/63

'C++' 카테고리의 다른 글

메모리 모델  (0) 2022.02.01
Futrue  (0) 2022.01.27
조건 변수(Condition Variable)  (0) 2022.01.27
멀티 쓰레드  (0) 2022.01.24
생성자  (0) 2021.12.27