Synchronization2

2022. 1. 21. 21:08게임서버/멀티쓰레드 이론

320x100

안녕하세요 대학생 개발자입니다.

저번 글에서는 동기화에 대한 얘기와 동기화 객체에 대한 얘기를 간단히 했습니다.

오늘할 얘기도 별반 다르지 않습니다.

다만 다른점은 이번에 다룰 WaitOnAddress는 객체는 아니라는점이죠 ㅋㅋㅋㅋㅋ

주소를 가지고 기다리는 거니까... 객체는 아니잖아요?

어째뜬 시작해보도록 하겠습니다

 

WaitOnAddress가 뭔지 간단히 파헤쳐 보겠습니다.

WaitOnAddress 방식의 동기화는 동기화 객체의 방식처럼 뭔가를 획득하고 안되고라는 개념은 아닙니다.

나는 어느 주소를 대상으로 기다리고 있을테니 니가 준비가 되면 나를 깨워줘의 느낌입니다.

네 그리고 WaitOnAddress의 경우에는 이 하나만 가지고는 동기화를 보장할 수 없습니다.

왜냐면... 주소를 기준으로 기다리기만 하는것이고 변수하나의 주소의 값을 가지고 입장하는 것이기 때문에 보장이 되지 않습니다.

그래서 이를 최대한으로 커버하기 위해 InterLockExchange 함수를 사용할 것입니다.

물론 인터락 함수를 썻다고해서 모든게 완벽해지진 않습니다.

그러므로 예전에 설명드렸던 스핀락과 같은 형태의 모습으로 가게 될 것입니다.

동기화 객체를 획득하지 못할 상황에 YieldProcessor를 하고 다시 들어오는 모습과는 다르게 WaitOnAddress계열 함수를 사용해서 블락에 걸리게 될겁니다.

 

그럼 깨어나는건 누가해주냐... Lock을 가장먼저 성공시켰던 스레드가 Unlock을 해주면서 WakeByAddress~~함수를 호출해 줄겁니다.

뭐... 그러면 주소를 키로 가지는 어느 해시테이블상의 하나를 깨우로갑니다.

깨울때는 WakeByThreadID함수를 통해서 지정을해서 깨우게 됩니다.

 

즉 깨워지게 될 thread가 특정되어버립니다.

 

단점이라면 역시... 동기화 객체처럼 경합이 없다면 단일로 들어가는게 확정되겠지만 다른 추가적인 로직없이 WaitOnAddress만을 이용해서는 단 하나의 thread만 진입시키는 것이 불가능합니다.

 

뭐... 그 이유는 유저 동기화 객체를 설명드릴때 말씀드렸던 것 같습니다.

thread를 깨우는것은 커널이 관여해야되는 일입니다.

그럴수 밖에없죠... thread자체가 커널인데 그걸 제어하려고 하는것이니 당연히 커널모드여야 될겁니다.

하지만 어떤 쓰레드를 깨울 것인지에대한 선택과 동기화 객체에 대한 선점권 등을 결정하는 부분은 유저 로직에있는 것처럼 이렇게 분리가 되어있기 때문에... 동시에 여러개에서 접근이 가능할 수 있다는 겁니다.

 

일단 말이 두리뭉실하지만 조금더 내용을 파고들어가다보면 이해가 되실겁니다.

 

우선 WaitOnAddress원리에 대한 정리를 하겠습니다.

호출당시 대상의 변수값만을 가지고 블락 혹은 리턴을합니다.

bool flag = ??;
void Function()
{
	bool tempCompare = ??;
	WaitOnAddress(&flag,&tempCompare,sizeof(bool),INFINITE);
	//flag 변경
    //만약 tempCompare와 flag가 값이 동일하다면 blocking에 걸림.
    //두 값이 다르면 WaitOnAddress가 바로 return해버림. 
}

유감스럽게도 이렇게 변수의 값만 가지고 블로킹을 걸거냐 혹은 리턴을 할거냐를 결정하기 때문에 flag에 대한 atomic함이 보장이 되지 않으면 2개이상의 스레드가 진입할 가능성은 열려있다고 볼 수 있겠습니다.

 

자 뭐... 어찌되었던 간에 반대의 경우도 봐야겠습니다.

그럼 우리는 잠들어있는 thread를 깨워줄 방법도 필요하겠는데요...

WakeByAddressSinlge or WakeByAddressAll 함수가 있습니다.

두 함수 모두 지정된 특정 address로 대기중인 thread에 한정해서 작동합니다.

일단 WakeByAddressAll 함수는 신경쓰지 않겠습니다.

모든 thread들을 깨우는 경우... 없습니다 적어도 우리가 쓰는 경우안에서는 존재하지 않기때문에 그냥 건너뛰고 가겠습니다.

 

위에서 언급한 지정된 특정 address에 대해 블로킹 걸리는 thread들중 하나를 wake하는 함수가 ...Single입니다.

아까 말씀드렸지만 이경우에는 변수에 대한 InterLockExchange함수의 반환값을 사용해서 단 하나의 쓰레드임을 보장하는 로직을 User구간에서 따로 작성해줘야됩니다.

InterLockExchange를 반복검사하지 않는 현상황에서는 값변경과 Wake가 atomic한 연산으로 되어있지 않으므로 blocking 되어있는 thread와 신규 thread혹은 지금 돌고있는 thread와의 경합이 생기게 됩니다.

내 thread와 경합을 하는 상황은 그렇다 치더라도 새로운 thread와 경합을 하게 되는 것에서는 문제가 될 소지가 충분히 있습니다.

일단은 atomic하지 않은 연산 2개에 대한 순서를 바꿔가며 설명드리겠습니다.

 

-> case1 flag를 먼저 수정하고 그다음 wake 호출시

   -> flag가 수정된 상황에서 새로 들어운 thread와 다시 flag를 바꿔놓기 전에 wake된 thread도 진입한다.

   -> 두개가 같이 돌수있는 상황이 발생한다

 

이부분은 아까 언급드렸던 부분입니다.

어찌됬든 절대로 로직은 둘이 같이 돌면 안될겁니다.

왜냐면... 락을건다는 것의 의도 자체가 공유자원을 접근하겠다는 의미로 해석할 수 있는데 이경우 공유자원에 둘이 동시에 접근 할 수 있는 상황이 만들어지기 때문입니다.

이는 치명적인 결함입니다.

 

-> case2 wake를 하고 그 다음 flag를 바꾸게 될시.

   -> 깨운 thread가 다시 WaitOnAddress를 호출해서 값이 다르기를 기대하고 있지만 Wake를 요청한 thread가 context switching이나 뭐 다른 경우에 걸쳐서 바로 다음줄에 있는 flag를 못바꿔 주었다면... 방금 깨어난 thread는 다시 blocking에 걸리게 될겁니다.

이는 기능이 작동하지 않는 결함입니다.

 

어째뜬 이두경우 모두 문제가 될 소지가 있으므로 우리의 소스코드는 InterLockExchange로 내가 값을 바꿀때까지 계속 WaitOnAddress를 호출해주어야 될 것 입니다.

while(InterLockExchange(...) != 이전값)
{
	WaitOnAddress(...);
    //break; 하지 않습니다.
}

와같은 코드가 나오면 좋을 것 같습니다.

아까 말씀드렸듯 스핀락과 상당부분 일치하게 생겼습니다만 저 경우에는 YieldProcessor가 아닌 WaitOnAddress 함수를 호출해서 quantum time을 반납하고 blocking상태에 빠지게 되는겁니다.

 

자 이런식으로 되어있습니다.

 

근데 WakeByAddress를 해서 깨어난거면 동기화 객체를 얻었다는 의미가 되지 않나요?? 라고 질문하실 수 있습니다.

물론 아까전에 말씀드렸습니다만 유저 객체의 경우에는 Wake하는 과정과 동기화 객체에 대한 획득 과정이 따로 분리가 되어있기 때문에 다음과 같은 문제가 생길 수 있습니다.

-> Unlock을 하는순간 새로 들어온 스레드에 의해서 두개의 스레드가 동시에 작동될 위험성이 있습니다.

     이경우는 결함이기 때문에 절대로 있어서는 안되는경우입니다. 즉 방금 깨어난 thread도 다시 조건 검사를 하고 넘어가야됩니다.

 

-> Wake된 thread는 Dispatcher에 의해 ready queue에 들어가게 되고 running 상태로 전환되는데 시간이 걸리게 됩니다.

     운좋게 짧으면 기존 thread의 quantum time을 뺏아버리고 자신이 들어가겠지만 운이 안좋으면 그 quantum time을 모조리 기다렸다가 들어가는 불상사가 생길수도 있겠죠.

      그렇게 된다면 UnLock을 요청했던 thread가 다시 Lock을 요청하는 상황까지 걸리는 quantum time이 짧다고 한다면 UnLock을 했던 thread가 재 진입할 가능성이 높아집니다.

 

이경우는 결함은 아닙니다만... 그래도 싱글스레드로 돌아가는 상황과 비슷한 형태가 되는꼴이니 썩 만족스럽지는 못합니다.

 

하지만 괜찮습니다...

 

우리가 직접 이걸 구현해서 쓰겠다는 것은 아닙니다.

우리는 잘 만들어져있는 SRWLock을 사용할 것이지만...

내부가 어떻게 돌아가는지 정도는 알아야 한다는 것이 목적이기 때문에 일단은 우리는 지금 상황에서 최선은 무엇인가만 판단하면 됩니다.

그리고 실제 유저 동기화 객체들은 UnLock을 했던 thread가 다시 진입하는 상황이 빈번하게 일어납니다.

거의 대부분이라고 봐도 무방하죠 ㅋㅋㅋ...

 

이건 제가 냈던 의견이었는데 만약 유저 동기화 객체를 사용해서 한 thread가 반복적으로 계속 돌게 되는것이 문제이고 dispatcher에 의해서 ready queue에 들어가고 running으로 들어가는 과정의 시간때문이라면 둘이 경합을 동일한 상황에서 시키는게 어떻겠냐는 취지에서 UnLock을 시킨 thread가 SwitchToThread함수나 Sleep(0)를 호출하면 어떻겠느냐고 질문을 드려봤는데요...

 

뭐 이렇게 까지 해서 굳이 한틱씩 맞춰나가는건 큰 의미가 없을 것 같다고 하셨습니다.

 

그리고 어차피 우리는 저런식으로 짜여져있는 동기화 객체는 쓰지 않을것이기 때문에 그냥 이런 문제가 있다는 정도만 알아두면 좋다고 해서... 그냥 넘어가려고 합니다.

 

이전 글에서 설명드렸지만 다시 한번 언급하고 넘어가면 커널 동기화객체의 경우에는 wake와 동시에 그 thread가 동기화 객체를 획득하고 나가는 꼴이 됩니다.

그리고 이전 thread와의 경쟁도 없습니다.

왜냐면 이전 thread가 이미 unlock을 하는 상황에 다른 thread가 획득을 하는 꼴이기 때문에 바로 다시 lock을 한다고 해도 이미 늦습니다.

이미 다른 thread가 동기화 객체를 얻어가버렸습니다.

 

자 그럼 커널 객체에서는 뭘 기다린다는 의미일까요.

분명 동기화 객체를 이용할 때에는 뭔가를 기다리고 있는데 분명합니다.

그리고 그 무언가를 통해서 우리는 획득을 하거나 혹은 제어를 할 수 있을겁니다.

 

그 방법에 대해서 알아보겠습니다.

 

일단 모든 커널 오브젝트는 모두 signal 상태와 non-signal 상태를 가지고 있습니다.

동기화 객체의 경우에는 획득이 곧 non-signal 상태입니다.

반대로 반환이 signal상태인 것이겠죠.

이 signal 상황과 non-signal 상황이 존재하기 때문에 커널 객체를 획득하고 반환하고 컨트롤 할 수있는 것입니다.

다른 경우로 thread와 process의 경우에는 실행중이 non-signal 상태입니다.

끝났으면 signal이 됩니다.

 

뭐 일단 확실한건 두 경우 모두다 일이 없는 경우가 signal인 것 같습니다.

 

 

음... 그러면 커널 동기화 객체인 mutex에 대해서 쪼끔 알아보겠습니다.

 

뭐 생성은 단순합니다 커널 객체이기 때문에 Create로 시작하는 함수를 호출해야될겁니다.

HANDLE CreateMutex(PSECURITY_ATTRIBUTE, initialOwner, name); 요런식으로 생겼는데요...

뭐 일단 security attribute는 나중에 알아보도록 하겠습니다.

뭐 상속을 받을것이냐 뭐 선호도를 어떻게할거냐 우선순위를 어떻게할거냐 이런거 설정하는 내용입니다. 

그리고 가장 중요한건 Create를 했다고 무조건 새것이 만들어지는게 아닙니다.

만약 name이 겹치거나 혹은 다른데서 사용하고 있는 경우라면 기존에 있는 HANDLE값을 duplicate해서 줄겁니다.

그 경우에는 내가 바로 mutex객체를 획득하는게 아니기 때문에... 기다려야됩니다.

 

그럼 여기서 커널 객체를 기다린다는게 무슨의미냐..?

위에서 말씀드렸던 것처럼 non-signal이 되기를 대기한다는 의미입니다.

뭐... initialOwner가 false라면 뭐... 이 thread가 생성한 mutex의 권한을 소유하지 않을 수 도있을겁니다.

하지만 true라면 무조건 획득했어야 되는거죠... 그렇기 때문에 우리는 반환값을 잘 봐야됩니다.

InitOwner가 true라면 HANDLE에 값이 나왔을때 GetLastError를 통해 ERROR_ALREADY_EXISTS 확인을 꼭 하고 넘어가셔야 할겁니다.

name의 경우에는... 커널 오브젝트는 프로세스간 공유가 가능합니다. 그래서 이름을 통해서 외부에서도 접근이 가능한데요...

그 이름을 정해주는 문자열을 넣어달라는 겁니다 우리에게 큰 의미는 없습니다.

 

저는 솔직히 오늘 글중에서 이부분이 제일 공부하는데 힘든부분이 있었던 것 같습니다.

ReleaseMutex함수 때문인데요...

Release를 했을때 만약 누가 기다리는 상황이 아니라면 signal로 바꿔놓고 return을 해버립니다.

이건뭐... 당연한거겠죠 이제 사용하려고 하는 스레드가 따로 없는데 문을 잠궈놓고 나올 필요는 없을겁니다.

 

하지만 누가 기다리는 상황이 되었다면 문서상으로는 분명 signal로 변경후 blocking이 되어있는 thread를 깨우고 다시 non signal로 변경한다고 적혀있습니다.

저는 이부분이 지금도 이해가 되지 않고있습니다.

내부에서 어떻게 구현하는가... 저한테 이걸 알려주신 분께서도 이걸 처음에 봤을땐 이해가 잘 안된다고 하셨습니다.

왜냐면 signal이 되었을때 thread 하나를 선택해서 깨운다고 한다면 signal이 된다는 그 정보를 어디선가 폴링하면서 꾸준히 보고있어야 된다는 의미가 됩니다.

즉 thread를 깨움과 동시에 작업을 시작하는게 안된다는 의미가 되죠...

그게 아니라면 저로서는 생각이 나지 않습니다...

물론 저는 이걸 이렇게 배웠기 때문에 이렇게 생각하는 걸 수도 있습니다만...

저는 이부분에서 저한테 이정보를 주신분과 동일한 사이드에 서있는것 같습니다...

 

이론상은 저렇게 된다고 말을하지만 실제로는 non-signal상태를 유지하면서 하나의 thread를 깨우는것으로 끝낸다 정도로 해석하고 있었습니다.

왜냐면 signal로 바꾸는순간 다른 thread들도 득달같이 달려들게 될텐데 이부분은 동기화 작업도 따로 해줘야되고 문제가 될 부분이 분명히 존재합니다.

 

로직적으로 복잡해지죠...

 

저는 그래서 이부분에서는 non-signal상태를 유지하며 하나를 wake하는게 끝이다 라고 추측하고 있습니다...

그리고 저에게 이 정보를 알려주신분도 이런식으로 추론하고 계셨습니다.(저는 그 영향을 분명 받은게 분명합니다 ㅋㅋ)

 

 

아... 이건 갑자기 생각이난건데 semaphore의 경우에는 Lock을 건 thread에서만 풀수 있는건 아니고 다른 thread에서도 다중 진입이 가능합니다.

그리고 다른 thread에서 unlock을 해줄수도 있습니다.

뭐이건 semaphore의 특징인데 그냥 알고 넘어가면 될 정도입니다.

그외의 커널 동기화 객체들은 lock을 한 thread에서 unlock을 해야지 deadlock에 걸리지 않습니다. 

 

자 그럼 signal과 non-signal에 대한 얘기를 좀 해봤습니다.

아까 말씀드렸듯이 모든 커널 객체들은 signal과 non-signal상황을 가지고 있다고 했습니다.

그리고 우리가 알아가야할 Event라는 녀석도 마찬가지죠.

하지만 특이한건 이녀석은 signal, non-signal상태를 제외한 다른 기능은 따로 없습니다.

그래서 커널 객체들은 Event를 모두 기반으로 간다 라고 생각하셔도 좋을 것 같습니다.

Event의 signal과 non-signal status는 자동으로 처리할 수도있고 수동으로 처리할 수도있습니다.

 

음... auto reset의 경우에는 이런경우가 되겠습니다.

아까 말씀드린 것 처럼 unlock을 했을 때 다른 thread가 대기중이지 않다면 자동으로 signal로 만들어놓고 나가버릴겁니다.

그리고 다른 객체가 대기하고 있다면 signal-> non-signal 2회 변경이 일어나게 될겁니다(문서상으로는요)

 

정리를 좀 해보면

 

AutoReset

-> 깨움과 동시에 signal로 바꿔버린다. 그리고 재진입시 자동으로 non-signal이 되어버린다.

-> 만약 다른 thread가 대기중이라면 실제 flag 변경은 없다! 그냥 wake thread가 끝이다.

ManualReset

-> wake thread이후에도 signal로 변하지 않습니다 -> non-signal 상태를 유지하고있습니다.

 

Event의 signal은 flag 개념이지 counting의 개념은 아닙니다.

 semaphore와 같은 객체는 counting이 되고 심지어 다른 thread에서 해제도 가능하다고 말씀드렸습니다.

하지만 Event는 flag방식이기 때문에 3번씩이나 SetEvent()를 한다고 해서 3개의 thread가 깨어나서 돌거라는 100% 보장은 받을 수 없습니다.

 

커널 객체들 중에서 mutex, semaphore등과 같은 동기화 객체들은 독립적으로 그 객체에 대한 획득을 전제로 그 부분부터 막아버리는 즉 단 하나의 thread에게만 길을 열어주는 그런 문지기와 같은 역할을 하고 있다고 느껴졌습니다.

반대로 동기화 객체가 아닌 다른 객체들은 할일이 있다면 일을 하게 내버려 두고 WaitFor...Object와 같은 함수들로 blocking을 걸어버리는 컨트롤의 역할과 없다면 바로 패스 해버리는 그런 방식으로 객체를 컨트롤 하는 느낌으로의 signal과 non-signal로 느껴집니다 저는... 뭐 여러분들은 어떻게 느끼시는지 모르겠습니다만...

 

 

일단은 오늘은 여기서 글을 맺도록 하겠습니다.

손가락이 아프거든요...ㅋㅋㅠㅠ

다들 공채시즌이 얼마 남지 않았습니다...

이제 곧 1달정도 뒤면 공채시즌일텐데... 다들 좋은 회사에서 동료로 뵙길 희망합니다.

오늘도 긴글 읽어주셔서 정말 감사드립니다.

그럼 다음에 뵙겠습니다 안녕히계세요

 

 

 

 

 

320x100

'게임서버 > 멀티쓰레드 이론' 카테고리의 다른 글

Thread Design  (0) 2022.01.27
Out Of Ordering Excution  (0) 2022.01.24
Synchronization  (0) 2022.01.21
Thread  (0) 2022.01.20
Synchronization object  (0) 2022.01.19