Synchronization

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

320x100

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

오늘은 시간이 많이 남아서 두번째글을 연달아서 쓰고있습니다.

이런날이 언제쯤 또 올수있을지... 한동안 해야할 일이 너무 많았기 때문에 바빳는데...

 

좋습니다 그럼 시작하겠습니다

 

저번 글에서는 쓰레드에 대한 얘기를 했었습니다.

쓰레드를 사용할때 어떤점을 주의해야되고 어떤점이 있으니 참고해야된다고 말씀드렸었는데요...

이번시간에는 쓰레드들간에 같은 공유자원을 사용할 때 어떤식으로 공유자원의 동기화를 걸 수있는지 몇가지 방법에 대해서 소개를 할겁니다.

 

그리고 동기화를 하는 알고리즘 몇가지를 소개할 예정입니다.

자 그러면 시작하겠습니다.

 

먼저 cpu차원에서의 동기화인 인터락 함수에 대해서 알아보겠습니다.

인터락이라는 것은 먼저 아주 간단하게 보자면 최소단위의 연산을 cpu가 보장해준다는 겁니다.

그럼 어떤식으로 최소단위의 연산을 cpu가 보장해준다는 걸까요...

 

우선 그것보다 인터락을 통해서 어떤 보장을 받을 수있는지 확인해보겠습니다.

 

a++이라는 코드가 있습니다.

이걸 asm으로 가져오면... 이건 너무 많이 적긴했는데

MOV eax dword ptr[a]
INC eax
MOV dword ptr[a] eax

와 같은 어셈블리가 뽑히게 될겁니다.

이경우 연산은 하나이지만 cpu입장에서는 연산이 하나가 아닙니다. 즉 저 최소단위 연산 사이사이에 context switching의 가능성이 열려있는겁니다. 인터락의 경우에는 이 a++이라는 연산을 최소단위로 보장해주는 역할을 합니다.

물론 실제 a++ 이라고 쓰진 않고 함수호출을 통해서 이뤄지긴 합니다만..ㅋㅋ

 

우선 최근의 x86계열 cpu들의 경우에는 메모리에 락을 걸어버리는 방식으로 보장을 해줍니다.

그럼 어떤 메모리에 락을거느냐 '캐시라인'입니다.

하나의 캐시라인에 들어오려고하는 다른 코어가 존재한다면 그 입장을 막아버리는 방식으로 동기화를 지켜줍니다.

 

그럼 여기서 헛점 하나가 나왔습니다.

변수가 자기 사이즈 경계에 서있지 않은 변수 즉(16바이트인데 16이 아닌 8의 경계에 서있는 경우) 에는 보장을 받지 못하게될겁니다.

뭐... 그이유는 두개의 캐시라인으로 쪼개질 확률이 존재하기 때문입니다.

이건 InterLockExchange든 InterLockIncreament라던지 ... 뭐 어떤함수든 동일합니다.

 

즉 InterLock함수의 기본조건은 그 변수의 경계에 세우는것입니다.

 

ms에서는 인터락 함수는 리턴값이 정확하지 않다고 리턴값에 의존한 코드를 짜지 말라고 권고하고 있습니다...

하지만 이걸 안믿으면 우리는 믿을게 없습니다. 그리고 지금 상황에서는 정확하다고 볼 수 있습니다. 

아마 이 리턴값을 믿지 말라고 하는 이유는 제생각에는 지금당장 정확하지 않을 가능성이 있다기 보다는 언제 API가 수정될지 모르니 주의하라는 의미에서 말하는 것이라고 생각합니다.

그리고 두번째 경우로는 변수가 그 변수의 경계에 서지 않아서 올바른 작동을 하지 못하였을 경우가 존재하기 때문에라고 생각했습니다.

우리는 후자의 경우는 절대로 만들지 않을겁니다. 후자는 결함이죠 그냥 그러므로 우리는 무조건 인터락함수의 리턴값을 100%신뢰하고 갈 수 밖에 없습니다..

 

간단한 코드의 예시를 하나 보고 가겠습니다.

int r = InterLockIncreament(&a);
case 1 -> if(a == 10) DoSomething(); 
case 2 -> if(r == 10); DoSomething();

 

1번의 경우에는 잘못된 코드입니다.

InterLock코드와 if문 사이에 컨텍스트 스위칭의 가능성 존재합니다.

컨텍스트 스위칭이 일어나면 a값은 11이 될수도 9가될수도 있는거니까요...

인터락 코드까지는 atomic연산임이 보장이 되지만 그 이후에 if문까지는 보장이 되지 않기 때문에 내가 증가시킨 값을 가지고(즉 리턴값을 가지고) 판단을 해야 올바른 결과가 나올것입니다.

 

2번의 경우에는 괜찮습니다.

InterLock으로 잠금을 걸고 공유자원인 a를 변경했지만 if문으로 들어갈 시점에 a값이 변경될 수 도있지만 r의 경우에는 지역변수이므로 이 스레드에서만 유효한 값입니다.

즉 r만을 기준으로 본다면 싱글스레드에서 변수의 값이 타 스레드에 의해 변할 수 있는가에 대한 질문을 해봐야되는 것이고 그것은 정상적인 상황이라면 당연히 불가능하다 라고 볼 수 있겠습니다.

 

 

대충 이런데요 희안한게 하나있습니다.

InterLockExchange라는 함수가 있는데요...

a = 10; 이코드는 무조건 단한번에 실행되는 코드가 아닌가요?

 

네... 무조건 한번에 실행됨을 보장합니다.

당연히 단 한번의 MOV연산으로 a에 10을 넣는 것을 보장합니다.

하지만 이함수가 쓰이는 이유는 값이 바뀌지 않을까봐 때문은 아닙니다.

내가 넣은 값이 무시당할까봐의 우려 때문입니다.

 

만약 내가 어떤 값을 true로 만들었는데 다른 thread에서도 똑같은 작업을 한다고 가정해보겠습니다.

나는 true로 만들었고 저 thread도 true로 만들었습니다 두 코드가 완벽하게 동일한 타이밍에 동일코드를 수행하는 상황은 배제하고 보겠습니다.

당연히 하나의 thread는 false인 상황에서 true로 만들었을 것이고 나머지 하나는 true를 true로 만든 케이스가 될것입니다.

우리는 이상황에서 false->true로 된 스레드의 입장을 허용할 겁니다.

왜냐면 자신이 바꾼거니까요... 이경우를 방어하는겁니다.

InterLockExchange함수는 내가 바꿨는지 안바꿧는지를 확인할 수 있습니다.

g_Flag = false;

void Thread()
{
    bool temp = g_Flag;
    g_Flag = true;
    if(temp != g_Flag)
    {
        //조건 수행
    }

}

다음과 같은 코드로 확인을 해 볼수 있을 것 같습니다.

싱글스레드로 돌린다면 g_Flag를 변경했다면 그 이후로는 temp와 g_Flag의 값은 항상 동일해질겁니다.

하지만 이코드에는 문제점이 있죠.

멀티스레드 상에서 이 로직을 수행하는 스레드가 2개이상일 경우,

temp = g_Flag   를 하는 라인과

g_Flag = true     를 하는 라인사이에 Context Swtiching이 일어날 수 있기 때문입니다.

저 연산은 atomic하지 않은 연산입니다.

하지만 InterLockedExchange 함수는 이전 값과 지금 변경된 값을 모두 보장 받기 때문에 괜찮습니다.

 

그러므로 지금 이 스레드가 값을 바꿧다면 진입 아니면 대기 이런 로직이 가능할 수 있는거죠.

뭐... false, true와 int는 사실상 동일합니다. 함수이름 뒤만 8이냐 32냐 차이입니다.

이전값이 9였는데 10으로 바꾼것인지 아니면 다른스레드에서 10으로 바꾼걸 또 10으로 바꾼것인지 알수가 없습니다.

그래서 InterLockExchange함수에서는 내가 이 값을 바꿨는지에 대한 정보가 나옵니다.

만약 a가 10인데 리턴값도 10이라면 내가 변경한게 아니겠죠...

반대로 a가 10일때 리턴값이 9라면... 이건 지금 스레드에 의해서 값이 변경됬다는게 확실해졌죠...

 

아까 그리고 경계에 대해서 말씀드렸는데 InterLock8,16,32,64와 같은 함수들은 경계에 서지 않으면 되는것 처럼 보입니다.

하지만 함수 작동을 보장을 해주지는 않습니다.

그니까 추후에 문제가 생길 여지가 있는거죠

 

하지만 128의 경우에는 경계에 맞지 않으면 그냥 에러가 나와버립니다.

이게 차라리 더 좋죠 문제가 될 상황을 미리 파악할 수 있으니까요...

 

이까지 하면 cpu에서 최소 연산을 보장해주는 atomic연산에 대한 기본적인 지식을 담아봤습니다.

 

매번 말씀드리지만 필기를 기반으로 하기 때문에 내용이 왔다갔다하고 다소 부정확한 경우도 존재합니다.

틀린게 있다면 언제든 지적해주십쇼...

그러면 cpu에서 보장을 해주는 동기화에 대한 내용을 알아봤습니다.

 

그럼 이제 커널이나 유저 차원에서의 동기화에 대한 내용을 좀 끄적여 볼겁니다...

먼저 예전엔 많이 쓰였지만 현재는 단독으로는 절대 사용되지 않는다고 하는 스핀락에 대해서 알아볼건데요.

스핀락이 뭐냐... 이름이 상당히 직관적입니다.

말그대로 뺑뻉돌면서 락이 풀릴때까지 기다리겠다는거에요.

 

일반적으로 커널이 개입되는 락이라면 blocking 상태에 빠져버리게 됩니다.

왜냐면 cpu를 물고있는건 낭비니까요...

자신의 quantum time을 양보함으로써 다른 thread가 일을할 수 있습니다.

하지만 스핀락의 경우는 다릅니다. cpu를 계속 물고있습니다.

내 quantum time을 모조리 다 스핀락을 위해서 쓴다고 할지라도 그냥 하나의 코어를 물고있습니다.

단 1클럭이라도 더 빨리 내 로직을 수행할 수있다면 다른 스레드에게 배려란 없는 동기화 방식이죠.

 

우선 커널의 경우에는 lock을 걸면 그자리에서 quantum time을 모조리 버리게되고 dispatcher에 의해서 ready queue에 다시 들어가게 될때까지 Suspend된 상태로 스탑해버립니다. 그리고 깨어나게될 상태를 기다리겠죠.

만약 동기화 객체를 얻을때 까지 걸리는 시간이 아주 짧다면 이 과정을 이 과정이 너무 아깝기때문에 나온 로직이고 스핀락은 로직이 매우 짧아서 금방 락이 해제될것이라는 확신이 있을때 사용하면 효율적으로 사용할 수 있습니다.

 

제 추측상 os는 스핀락 도배일 것 같습니다.

왜냐면 scheduling의 시작인 시스템 타이머부터 인터럽트로 작동을 시작하는 일인데도 불구하고...

일을 당장 처리할 수 없다는 이유만으로 블락에 뻗을거 같기때문인데요... ㄷㄷ 어떻게 되있을지는 정확히 모르겠습니다.

스레드야 일을 수행할 수 없다면 당장 멈추는게 맞지만 인터럽트는 수행불가능한다고 잠시 스탑 시켜놓을 수 있는게 아니니까요...

스핀락의 장점이라고 하면 quantum time내에 lock이 풀려버리면 context switching 없이 바로 접근을 할 수 있다는 장점이 있을겁니다.

당연히 매우 이기적인 동기화 방식입니다.

cpu를 다 갉아먹어서라도 내 로직만 빨라지면 되는 동기화 방식이기때문입니다.

요새는 단독으로 사용하는 경우는 거의 없긴하다만... 유저 동기화 객체에서 스핀락을 일부 사용하고 있습니다.

 

자 그러면 이정보를 가지고 그대로 동기화 객체로 이어가보겠습니다.

먼저 Critical Section에 대한 얘기를 해볼건데요

Critical Section은 유저 동기화 객체입니다. 왜냐구요? mutex, semaphore등과 같은 커널 동기화 객체는 Create라는 API를 거쳐서 만들어지게 되지만 CriticalSection은 그냥 변수하나 선언하고 InitializeCriticalSection함수만 호출해주면 끝이거든요. Create라는 과정이 존재하지 않습니다.

동기화 객체에 대한 접근이 유저레벨에서 일어날 수 있기 때문에 유저 동기화 객체입니다.

 

유저 객체의 장점은 경합이 발생하지 않으면 유저 레벨에서 바로 동기화 객체를 획득 가능하게 됩니다.

그러므로 커널로의 전환이 안생기고 매우 빠르게 획득이 가능합니다.

하지만 경합이 존재한다면 아주 짧은 시간동안 스핀락 수행을 하게 됩니다.

그 뒤에는 어차피 블로킹 모드로 빠지게 됩니다 위에서 말씀드렸듯 바로 블로킹으로 가버리진 않습니다.

아주 짧은시간동안 스핀락을 수행한다음 그 안에 동기화객체를 획득하면 바로 튕겨져 나가버리고 그게 아니라면 커널로 전환되어서 block에 빠집니다.

 

그리고 CriticalSection의 장점중 하나는 flag방식이 아니라 counting 방식이기 때문에 동일 스레드가 lock을 두번 걸수있고 unlock을 짝 맞춰서 해주면 풀 수 있다는 점입니다.

Critical Section과 함께 많이 쓰이는 유저 동기화 객체로 SRWLock같은 경우에는 한번이상 잠금을 걸게되면 데드락에 걸리게 되는 점이 있기때문에 이부분은 좋습니다.

뭐... 여러번 락을 걸수있다는건 쓰레드에 대한 정보를 기억하고 있다는 의미가 되는거겠죠.

 

그리고 이건 논외거리입니다만 예전의 경우에는 DeleteCriticalSection이라는 함수를 꼭 썼어야 됬습니다.

예전의 경우에는 경합이 발생하지 않는 상황이라면 바로 획득하고 들어가면 되지만 경합이 발생했을때 커널 객체가 아닌이상 더이상 동기화를 잡을 방법이 존재하지 않았기 때문입니다.

뭐 windows8 이후로부터는 정식으로 Wait On Address 방식을 지원하고 있기 때문에 커널 객체에 대한 생성을 하지 않지만 예전의 경우에는 CreateEvent 함수를 이용해서 semaphore를 생성하기 때문에 그것에 대한 정리를 해줬어야 됬으므로 DeleteCriticalSection이라는 함수를 꼭 썼어야됬습니다.

 

Wait On Address의 경우에는 윈도우는 windows8부터 정식으로 도입된것으로 알고있긴한데 메모리 주소를 이용해서 동기화를 걸어주는 방식입니다. 뭐... 이건 차차 나중에 구체적인 설명을 하도록 하겠습니다.

 

논외거리 2도있습니다.

과거에는 CriticalSection은 스핀락으로 구현되어있었습니다.

그래서 InitializeCriticalSectionAndSpinCount라는 함수로 몇번 스핀을 돌릴건지 정하는 부분이 있었는데 최근에는 저걸 사용하지 않죠...

요새는 스핀락과 블로킹 방식을 두개를 혼합해서 쓰고있습니다.

 

두개의 동기화 방법을 혼합해서 쓰는 케이스는 SRWLock이 최초입니다만...

요새는 뭐 거의 모든 유저 동기화 객체가 두개를 혼합해서 쓰고있습니다.

 

아... 그리고 이건 진짜 중요한건데요...

스핀락을 구현하는 코드가 구글에 많이 풀려있습니다.

그중에서 Sleep(0)나 SwitchToThread함수를 이용하는 케이스가 많습니다.

이건... 스핀락이라고 부를수가 없습니다.

스핀락이라는 것은 quantum time을 모조리 갉아먹어서라도 이번 quantum time안에 lock을 걸고 들어가겠다는 취지로 하는겁니다.

Sleep(0),SwitchToThread()의 경우에는... quantum time을 버리는 행위기 때문에 스핀락의 의미가 소멸됩니다.

즉 모순입니다.

 

스핀락의 최악의 케이스라고 하면 이런경우겠죠

하이퍼 스레딩을 지원하는 cpu라면 하나의 cpu가 2개의 논리 cpu를 돌리고 있는건데...

이건 하나씩 번갈아가면서 실행하는겁니다 누가 더 우선적으로 처리되고 이런게 없습니다.

하지만 이런경우 하나에서 스핀락을 들어가기 위해서 돌고있는데 하나는 스핀락을 해제할 수 있는 키를 가지고있는녀석이다.

이런 상황이라면... 조금 곤란합니다. 그래서 스핀락을 할때는 YieldProcessor를 이용해서 cpu사용권을 다른 논리 코어에게 주는 방법으로 성능 향상을 어느정도 기대해볼 수 있을겁니다.

만약 전혀 관계없는 다른 스레드였다고 해도 뭐... 그닥 나쁜 상황은 아닙니다.

어차피 os는 이게 바뀐지 안바뀐지도 모를것이구요...

컨텍스트 스위칭이 발생하는 상황도 아니기 때문에 문제 없습니다.

 

자... 뭐 스핀락의 경우에는 우리가 따로 구현할 일은 절대 없긴합니다만...

예전에 많이 쓰던 동기화 방식이었고 그리고 최근에도 혼합해서 사용하기 때문에...

이해정도는 하고 넘어가야 될 것 같다고 판단했습니다.

 

그럼 아까부터 계속 CriticalSection, SRWLock과 같은 유저동기화 객체와 그리고 mutex, semaphore와 같은 커널 동기화 객체 라는 얘기를 했습니다.

 

그럼 커널 동기화 객체와 유저 동기화 객체에 대한 간단한 차이점을 얘기해보고 그다음 글을 마무리 하려고합니다.

 

우선 커널 동기화 객체의 경우에는 커널모드로 전환후에 동기화 객체 소유권 획득에 관헌 건을 확인하게 될겁니다.

우선 바로 획득이 가능한 상황이어도 커널로 접근했다가 획득을 하고 나가기때문에 이미 한번 늦어지는 부분이 생기는거죠.

커널모드로 들어가는순간 os에서 해줘야 하는 부분이 한두개가 아니니까요 ^_^...

Blocking에 빠지게 될때 커널 동기화 객체에 등록하고 그 객체의 시그널을 기다리는 상황으로 가게 되는겁니다.

그리고 가장 중요한것은 스레드를 깨움과 동시에 그 스레드가 커널 동기화 객체를 획득을 한다는 점인데요...

그렇기 때문에 커널 동기화 객체의 경우에는 번갈아 가면서 획득을 한다는게 보장이 되어있습니다.

그렇게 구현이 될 수 밖에 없는 이유도 있습니다.

이 내용은 다음글이나 나중에 정리해놓도록 하겠습니다.

 

유저 객체의 경우에는 커널모드로의 전환은 실제 블로킹이 걸릴때만 일어나게 됩니다.

바로 획득이 가능한 상황이면 커널로 전환이 되지 않기 때문에 매우 빠르게 일어나게되죠.

매우 빠른 이유는 아마 전글에서 정리를 했었던것 같습니다.

그리고 유저 동기화 객체에 대한 소유권을 획득하는 과정과 실제 스레드가 깨어나는 상황이 동시에 발생하지 않습니다.

그렇기 때문에 이미 획득을해서 사용하던 객체가 unlock을 한다고해서 새로 깨어난 스레드가 획득을 하는게 아니라 새로 깨어난 스레드와 기존에 있던 녀석이 한번더 경쟁을하게됩니다.

물론 이과정에서 깨우는 코드와 기존의 스레드가 lock을 거는 로직이 동시에 수행되므로 원래 돌던애가 높은확률로 빠르게 되니까 사실상 그저 싱글스레드 반복문을 도는 느낌과 비슷하게 되는겁니다...

즉 바톤터치의 개념이 아니라 자다가 일어난 스레드랑 방금까지 깨어있던 스레드가 달리기를 하는 꼴이 되는거죠.ㅋㅋ

심지어 동일한 조건의 달리기도 아니고 깨어있던 스레드가 더 먼저 달려가기 시작할 확률이 더 높은상황에서 시작하니까요...

이부분도 나중에 다음글이나 뭐... 뒷쪽에 정리해놓겠습니다.

 

추가로 잠시 SWRLock에 대해서 얘기를 해보고 가겠습니다.

SWRLock의 경우에는 가장 획기적인것이 shared modeexclusive mode가 존재한다는 것입니다.

shared 는 shared끼리 같이 동기화 객체 획득을 할 수 있고 로직에 들어갈 수 있습니다.

그동안 당연히 exclusive의 경우에는 blocking에 걸리게 되겠지만요...

그럼 exclusive의 경우에는 exclusive끼리만 되느냐?

그건 아닙니다.

exclusive는 온전히 나혼자서 이 동기화 객체의 소유권을 가지고 로직을 나혼자만 수행해야된다 이런 겁니다.

기존의 다른 동기화 방식이랑 동일하게요.

shared 같은 경우는 대체적으로 읽기 모드에 적합한 경우겠죠 동시에 10개가 읽든 100개가 읽든 메모리 변화가 없으니 상관없을겁니다.

반대로 exclusive의 경우에는 메모리 수정에 관여하는 코드가 있을경우 그렇게 써야되겠죠 exclusive는 2개이상 들어가게 되는순간 문제가 날 수 있는 부분이 있기때문에 단 하나만 들어갈 수 있게하는겁니다.

그래서 SWR에 WR은 write와 read입니다 ㅋㅋㅋ..

 

뭐 째뜬 알면 알수록 어렵고 머리가 뽀가질것 같습니다...

정리하면서도 제가 알고있는게 맞는지 100% 확신이 안서는군요...ㅠㅠ

이런걸 적을때는 뇌피셜이 들어가면 안되는데 필기 기반으로 적다보니 내용이 부실하고 하다보니 일부 뇌피셜이 많이 섞여들어갑니다.

그리고 정확한 정보 전달보다는 제 필기를 정리하는 용도로 블로그를 작성하다보니 타 블로그나 검색을 거의 하지 않고 있습니다.

그래서 보시는 여러분들께 틀린정보가 있으면 지적부탁드리는거구요.

 

일단은 오늘글은 여기서 마무리를 해야될거 같습니다.

다음번에 더 좋은 내용으로 가지고 돌아오도록 하겠습니다.

그럼 안녕히계세요

320x100

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

Out Of Ordering Excution  (0) 2022.01.24
Synchronization2  (0) 2022.01.21
Thread  (0) 2022.01.20
Synchronization object  (0) 2022.01.19
windows scheduling  (0) 2022.01.19