Out Of Ordering Excution

2022. 1. 24. 23:42게임서버/멀티쓰레드 이론

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

 

저번 글에서는 동기화와 관련된 얘기를 계속 했었습니다.

이번글에서는 out of ordering excution에 대한 정리를 할겁니다.

우선 저 글을 정리하기 이전에 저번 글에서 마져 다 소개하지 못했던 내용들을 정리해놓고 시작하도록 하겠습니다.

 

저 그럼 시작하겠습니다.

Event에 대한 얘기를 하고서는 마감을 했었던 것으로 기억합니다.

어떤 함수를 동기로 처리한다 라고 하면 이런 구조로 처리가 됩니다.

----|A:(return)|----

저 사이구간은 다른 작업을 하지 못하고 A라는 함수에만 매달려 있어야 하므로 동기 함수라고 할 수 있죠.

근데 이걸 비동기화 시키고 싶다면 예전 같은 경우에는

----(AThread생성)-----

     ---(A)---(return)

와 같이 스레드가 생성되고 스레드가 할일이 끝나면 리턴해서 소멸하는 그런 형식으로 사용을 했었었던 시절이 있었습니다.

 

물론 지금은 저렇게 만들지 않습니다.

쓰레드는 너무나도 당연하게 풀로 사용한다는 개념이 지금 개발자들에게 박혀있습니다.

그래서 쓰레드를 한번 생성하면 바로 리턴시키지 않고 같은일을 반복해서 시키되 사용하지 않으면 블로킹 되어있어라(Suspend상태가 되어라) 하고 합니다.

이게 너무나도 당연시 되는 이유는 쓰레드는 만들었다가 지우는게 아주 큰 작업이기 때문이죠...

 

그에 비해 메모리풀은요?

메모리풀같은 경우에는 일반적으로 잘 사용하지 않습니다.

클라이언트에서 메모리풀을 사용한다면... 메모리가 꾸준히 높은상태로 유지가 될것입니다.

물론 엔진등을 사용하다보면 어느정도 메모리풀이 들어가있고 효과를 볼 수 있겠지만...

 

메모리풀이라는게 일반적인 모든상황에서 들어가는건 아니다보니...

메모리풀의 경우에는 오히려 특이한 케이스로 보고있습니다만... 쓰레드풀과는 반대되는 상황에 있는거죠.

 

자 그럼 서론은 여기까지만 하고 본론으로 넘어가겠습니다.
HANDLE g_Event;
//g_Event는 초기화가 되었다는 가정하에 시작
void Post()
{
    while(true)
    {
        if(비동기키보드입력...)
        {
             SetEvent(g_Event);
        }
    }
}

void WorkerThread()
{
    while(true)
    {
        WaitForSingleObject(...g_Event...);
        //어떤 작업
    }
}

와 같은 로직이 있다고 하겠습니다.

위 로직에서 문제점이 무엇인가요??

 

당연히 이 코드는 결함은 없습니다.

하지만 사소한 버그가 발생할 수 있는 가능성이 열려있습니다.

 

만약 어떤 작업이 오래걸리고 그 오래걸리는 동안 2번의 키입력을 했다면 어떻게 될까요?

뭐... 이건 문제가 없습니다.

왜냐면 key입력을 받은 직후 WaitForSingleObject 함수를 리턴을 하게되고 g_Event는 signal로 되었다가 바로 다시 non-signal 상태가 되어버릴것이기 때문입니다.(물론 실제 작동이 이렇게 되지 않습니다만.)

그리고 작업을 수행하는 도중 또 키가 입력이 된 상황이니 그 상황에서 다시 signal상태로 바뀌게 될겁니다.

그러면 두번째 바퀴를 돌게될겁니다.

 

하지만 만약 한번의 작업도중 3개의 key입력이 들어온다면?? 어떻게될까요.

저번글에서 말씀드렸던 것 처럼 Event 객체는 signal과 non-signal이 counting이 아닌 flag 방식입니다.

켜졌으면 켜진거고 꺼졌으면 꺼진겁니다.

그렇기 때문에 한 루프에 3번을 누르던 33번을 누르던 상관없이 그냥 1번더 실행되고 끝나버릴겁니다.

우리는 이상황을 원하지 않습니다.

 

예전의 서버를 만들었던 과정도 동일합니다.

한번의 recv를 한뒤 모든 패킷을 한큐에 모조리다 처리해버렸던 것처럼 만약 한루프에 2개이상의 키가 입력이 되었다면 그 모든걸 다 처리해야될겁니다. ring buffer속의 모든 내용을 처리했던 것 처럼 말이죠.

 

그래서 나오는게 Job queue입니다.

Post라는 녀석은 키가 입력될때마다 Job queue에 일거리를 하나씩 넣을거고, Post는 JobQueue가 안비었다면 계속 SetEvent를 해주는겁니다... 

 

그리고 Event는 두가지의 케이스가 있다고 말씀드렸는데 manual reset과 auto reset의 케이스가 있다고 말씀드렸습니다.

여기서는 당연히 auto reset을 사용할 것이구요...

manual reset의 경우에는 일반적으로 모든 스레드를 깨우고 싶을때 사용합니다..

그래서 종료할때나 특정... 무슨 상황 모두가 깨워야하는 상황이 왔을때 쓰면 적절하겠죠...

 

그리고 이벤트의 경우에는 ms에서는 따로 순서를 보장한다라고 명시하고 있지 않습니다.

하지만 이는 매 업데이트때마다 최적의 코드로 변경의 가능성이 있기 때문입니다.

제가 포스팅하고있는 오늘을 기준으로 최근을 말씀드린다면 FIFO 방식입니다.

즉 큐로 이뤄져있습니다.

간단히 코드를 한번 짜봤습니다.

Post를 하는쪽에서는 a키가 입력되면 SetEvent를 하고 10개의 WorkerThread들은 WaitForSingleObject에서 리턴되는 순간 자신의 threadid를 출력하고 다시 들어가는 코드로 되어있습니다.

이상황에서 본다면 처음에 나왔던 12972번 쓰레드는 정확히 10번뒤인 11번에서 출력되고있고 또 10번뒤인 21번에서 또 출력되고 있습니다... 뭐 2번도 마찬가지입니다. 12번에서 또출력 22번에서 또출력...

다음과같이 큐의 형태로 되어있다는걸 확인하실 수 있습니다.

 

물론 이를 기반으로 코드를 짜시면 안됩니다.

 

ms에서는 항상 더 효율적으로 변화하는 방향으로 개발을 하고 있고 효율적이기 위해서는 언제바뀔지 모르기 때문에 이를 보장 못한다라고 명시하고 있기 때문입니다. 

 

그러므로 이게 큐다라고 신뢰하고 그에 기반한 코드를 짜는건 위험 부담이 매우 크니까... 하지마시라고 하는겁니다.

 

네 뭐 어째뜬 간에 이벤트에 대한 얘기는 여기까지만 하겠습니다.

 

물론 워커쓰레드를 관리하는 방법에는 큐방식만 있는건 아니고 다른 방식도 있습니다만...

일단 이벤트로 구현할 수 있는 쓰레드풀에대한 얘기를 잠시 해봤습니다.

 

잠깐 쉬어가겠습니다 여러분

자... 그럼 일단 이벤트는 여기까지 정리를 하고 다음으로 넘어가도록 하겠습니다.

아마 예전에 제가 C언어에 대한 정리를 하면서 캐시라인에 관한 얘기를 했었을겁니다.

당연히 멀티스레드로 오게되면 이 캐시라는게 매우 중요해집니다...

우선 이런 질문을 해보고 싶습니다.

struct Test1
{
    int read;
    int write;
}
struct Test2
{
    int read;
    alignas(64) write;
}

 다음과 같은 클래스가 두개있습니다.

두개의 클래스는 동일한 멤버변수를 가지는데요... 다른점은 Test2구조체는 alignas(64) 키워드로 중간에 패딩을 통해 캐시라인을 벌려놓는 효과를 냈다는 것입니다.

제가 질문하려는것은 다음과 같습니다.

멀티스레드에서는 당연히 두개의 성능차이가 나는게 맞습니다만...

싱글스레드에서도 이게 차이가 나는것일까에 대한 질문입니다...

 

그리고 해답은 바로 아랫줄에서 나오네요 ^_^... 고민을좀 해보셨길 바랍니다.

드래그하면 나옵니다.

이거 끄면 드래그가능합니다. 개발자라면 이정도는 찾으실수있습니다.

정답은 연관이 매우있다입니다.

엥... 이게 무슨소리냐 두개의 스레드에서 동시에 접근할때 cache line무효화가 발생하는것 아니냐 라고 하실수도 있겠습니다만 아닙니다.

캐시 무효화는 싱글스레드에서도 나는거고 cpu선호도를 따로 세팅하지 않는이상 하나의 스레드는 여기저기 코어를 돌아다니게 될겁니다.

그리고 당연히 2개의 코어를 반복해서 왔다갔하는 경우도 존재할 수 있구요.

그렇다면 여기서 캐시 무효화를 날리게 된다면 왔다갔다 왔다하는 스레드의 캐시에서 그 라인이 무효화 될것이고 다시 그 코어로 갔을때는 그 캐시라인이 날라가서 캐시 미스가 나오게 됩니다.

싱글스레드의 경우에도 확률이 매우 높다곤 할 수 없지만 캐시 미스의 케이스가 존재한다고 할수있는겁니다.

.

 

잠깐 장난을 쳐봤습니다.
다음으로 우리가 논해볼 얘기는 이런겁니다.

인터락이나 동기화 객체가 없이 쓰레드 동기화를 시키는 방법이 없는가... 하면 아뇨 있습니다.(사실은 없는것 같습니다)

코드상으로 그리고 논리상으로는 가능합니다만 cpu 파이프라인상 불가능하다고 말씀드리는거에요.

저는 언어적으로는 분명 가능해 보입니다.

잠시 피터슨이라는 교수님이 제안하신 알고리즘을 하나 보고갈건데요.

이는 2개의 스레드가 같은 공유자원에 접근하는 경우에만 성립하는 케이스입니다.

 

일단 flag0, flag1 그리고 turn이라는 변수 3개를 둘겁니다.

unsigned __stdcall Thread1(void* param)
{
    unsigned __int64 i = g_Count;
    while (i > 0)
    {
        flag0 = true;
        turn = 0;
        while (flag1 == true && turn == 0)
            YieldProcessor();
        i--;
        g_RetCount++;
        flag0 = false;
    }
    return 0;
}

unsigned __stdcall Thread2(void* param)
{
    unsigned __int64 i = g_Count;
    while (i > 0)
    {
        flag1 = true;
        turn = 1;
        while (flag0 == true && turn == 1)
            YieldProcessor();
        i--;
        g_RetCount++;
        flag1 = false;
    }
    return 0;
}

 

네 뭐 의사코드로 쓰려고 했으나... 이미 코드로 짜놓은게 있어서 그냥 바로 가져왔습니다.

Thread1 의 입장에서먼저 써보겠습니다

flag0 = true입니다 즉 자신의 flag는 true가 된것이죠.

그리고 turn을 0 즉 자신의 turn으로 만들어버렸습니다.

flag1이 true이다 그럼 즉 Thread2도 flag1 = true 코드를 실행했다는 의미가 됩니다.

그리고 turn은 누가더 빨리 바꿧을지 모르겠지만 먼저 바꾼쪽이 먼저 while문을 통과할겁니다.

그리고 자신의 flag를 false로 만드는 순간 다음 스레드도 while문을 통과해서 나오게 될겁니다. 물론 이게 반복이 되도 큰 문제는 없습니다.

 

하지만 이코드를 실행해보면 다음과같은 문제가 있습니다.

풀소스를 올리진 못했지만 두개의 스레드가 500만번씩 저 코드를 돌면서 g_RetCount를 ++ 하고 주 스레드는 WaitForMultipleObjects(...)함수를 이용해서 기다리고 있다가 모든 쓰레드가 작업이 끝나면 g_RetCount를 출력해주고 마무리하는 프로그램입니다. 당장 봐도 문제가 있어보입니다..

순차적으로 진행이 된다고 하면 논리적으로 완벽한 경우이지만 이 경우는 작동하지 않는데요...

이제부터 정리해볼 out of ordering excution이라는 이유 때문입니다.

 

 

Out Of Oredering Excution이요..? 처음들어보는건데...

out of ordering이라는 것은 언어나 os 등의 문제가 아닙니다.

cpu 자체의 파이프라인 문제에서 비롯되는 것인데요...

대부분의 cpu의 파이프라인은 오더 버퍼에 한번에 왕창 많은 명령어들을 읽어옵니다.

물론 오더버퍼에 들어가기 까지는 순차적으로 정렬이 잘 되어 있습니다만...

처리 효율을 높히기 위해서 만약 오래걸리는 작업이 존재한다면 그 작업보다 우선 다른작업을 먼저 처리해버리는 그런 경우가 발생합니다.

즉 명령어 처리를 할때는 순서가 완벽히 보장되지 않게됩니다.

그리고 다시 이 과정을 합하는 과정 그니까...

저는 이 경우를 커밋이라고 말하는데 다시 그 데이터를 커밋 시켜놓는 상황에서는 다시 오더링을 맞춰줍니다.

그니까 중간에 데이터가 삐끗해서 잘못들어가게 됬는데 그 데이터로 잘못된 연산을 하게되었고 그걸 순서에 맞게 넣어주게 된다면 문제가 생길 수 있다는 겁니다.

 

그럼 문제가 어디서 난것이냐?

 

이건 메모리 오더링에 대한 테이블이구요 위키에서 가져왔습니다.

아 우선... x86아키텍쳐에서는 load와 store라는 어셈블리 명령어가 존재하지 않습니다.

그냥 MOV라는 명령어 하나로 통합되어있는데요...

레지스터에서 다른 메모리로 가는 경우를 store, 메모리에서 레지스터오 올라오는 경우는 load라고 생각하시면 되겠습니다.

그리고 다시 돌아가서 여기 있는 표에 의거해서 문제가 발생합니다...

대부분의 사람들은 x86아키텍쳐가 탑재된 cpu를 사용하고 계실텐데요.

물론 저는 지금 arm 아키텍쳐가 탑재된 맥으로 글을 쓰고있긴 합니다만...

개발은 어차피 x86아키텍쳐 cpu가 부착된 데스크톱으로 하니까요...

째뜬 이게 중요한게 아닙니다.

 

x86부분만 조금 집중해서 보도록하겠습니다.

 

다른것들은 문제가없어보입니다.

1. Loads can be reordered after loads -> no           load가 load뒤로 리오더 될수있느냐? no입니다.

2. Load can be reordered after stores -> no           load가 store뒤에 리오더 될수있느냐? no입니다.

3. store can be reoredered after store -> no          store가 store뒤에 리오더 될수있느냐? no입니다.

4. Store can be reordered after load -> yes            store가 load뒤에 리오더 될수있느냐? yes입니다

 

우리에게 지금 문제가 되는 상황은 4번때문입니다.

왜냐면 

        flag0 = true;
        turn = 0;
        while (flag1 == true && turn == 0)

 

이문장중 flag1을 읽어오는 데서 문제가 되는데요

flag0 = true <- 의 store 보다 flag1 == true의 load가 더 앞서서 될 수 있기 때문에 우선 처리되어버립니다.

그리고 flag0 = true~while문 앞까지는 이미 load가 되어버린 flag1을 가지고 갈것입니다.

그러면 우리는 이미 과거의 데이터를 가지고 가는 꼴이 되는겁니다.

즉 내가 flag1을 true로 만들었다고 해서 그 문장이 그 라인을 읽는 즉시 적용이 된다는 믿음을 가지면 안된다는 겁니다.

왜냐면 out of ordering때문에 이 순서는 100% 보장받지 못하는 상황이 되어버렸고...

보장을 받지 못한다면 이런 문제가 발생할 수 밖에 없기때문입니다.

물론 나름 x86은 다른 아키텍쳐에 비해 방어가 잘되고 있습니다만...

 

이미 문제가 나고있음을 확인했잖아요??

 

그래서 우리는 이렇게 cpu에게 당하고 있을겁니까 라고 물어본다면... 당연히 방어를 해야됩니다.

out of ordering을 코드로 막으려면 명령어를 실행하는 순서자체를 보장해줘야될것 같습니다.

 

그래서 지금부터 할 얘기는 MemoryBarrier, MemoryFence에 대한 얘기를 해보려고 합니다.

 

먼저 memory fence의 경우 먼저 이 함수들을 보고 가겠습니다.

_mm_mfence, _mm_lfence, _mm_sfence

이름이 상당히 직관적입니다. mfence는 당연히 메모리 펜스일거 같은 느낌이들고 lfence는 로드 펜스일거같구요, 마지막으로 sfence는 스토어 펜스일것 같네요. 이들의 각각 기능을 설명해보면요

lfence는 lfence함수를 기준선으로 위와 아래의 load어셈블리 명령어 처리를 섞이지 않게 하겠다는 명시입니다.

그럼 똑같이 sfence는 sfence함수를 기준으로 위와 아래의 store어셈블리 명령어 처리를 섞이지 않게 하겠다는 명시입니다.

 

근데 우리 x86에서는 의미가없습니다.

이미 cpu에서 store는 store보다 앞설 수 없다고 명시하고있고, load는 load보다 앞설수 없다고 명시하고있습니다.

 

즉 우리가 사용할 함수는 mfence입니다.

그위아래로는 어떤 어셈블리 명령어든 mfence함수를 기준으로 섞이지 않고 구분이 되게됩니다.

그럼 우리가 주의해야될 사항은 하나입니다. load가 store보다 앞서는 상황 이상황만 막으면 되는거 아니겠나요?

 

제가 이 구간에서 하려는 말은 sfence + lfence는 mfence가 아니라는 의미입니다.

sfence함수는 store끼리의 섞임을 막는거고 lfence함수는 load끼리 섞임을 막는거니 store와 load가 섞이는걸 막으려면 mfence를 사용해야 된다는 것입니다.

 

그리고 현재는 ms에서도 __faststorefence() 라는 함수를 지원하고 있습니다.

그리고 이 함수가 ms에서도 _mm_mfence함수를 대신해서 권고되고 있습니다. 

그리고 __fastorderfence함수의 경우에는 lock or dword ptr[rsp] 0 이라는 인라인 어셈블리로 박히게됩니다.

다음과 같이 들어가게 됩니다.

이게 무슨의미냐구요??

 

dword ptr [rsp] 는 rsp가 참조하고 있는값 즉 스택의 최상단이겠죠 이거랑 0이랑 or을 한다??

 

rsp가 뭔지는 모르겠으나 0을 or하게되면 아무런 값변화가 없겠죠.

rsp가 참조하는 값이 그대로 떨어질겁니다.

이게 뭐하는 코드인가요? 라고 물으면 저는 대답할 수 있습니다.

"아무것도 안하는 코드라구요."

그럼 의도는 둘째치고 rsp를 사용한 이유는 무엇인가 입니다.

 

아마 interlock의 원리를 저번글인가 저저번 글에서 소개했었을겁니다.

캐시라인을 잠그는거라고 했습니다.

물론 제가 책에서 다시 읽어본결과 메모리 버스에 시그널을 탑재해서 메모리 참조를 못한다고만 적혀있습니다.

그리고 이는 곧 캐시라인을 의미하고있을겁니다.

그리고 만약 다른 자원을 락을 걸어버리게 된다면 그것과 동일한 캐시라인에 존재하는 메모리는 cpu차원에서 락이걸려서 접근이 불가능할겁니다.

그렇기때문에 스택의 최상단 안쓰는 부분을 락을 건겁니다.

 

이건 나름 똑똑했죠?

 

그럼 이제 rsp를 사용하는 이유에 대해 알았으니 이 코드의 의도를 파악해봅시다.

인터락이라는 것은 cpu가 가진 고유 기능이라고 했습니다.

그리고 atomic한 연산이라고 했습니다.

atomic이라는게 무슨의미인가요?

원자적으로 보장을 받는다는 의미입니다.

저 행동은 저 라인에서 무조건 한번에 실행을 보장받는다는 의미입니다.

그러니까 인터락보다 아래에있는 load들은 절대로 인터락보다는 위로 갈 수 없고 인터락보다 위에있는 store또한 인터락보다 아래로 내려갈 수 없습니다.

왜냐면 인터락은 그 자리에서 그 행동을 함이 보장받아야 되기 때문이죠.

그래서 본다면 인터락은 위에있는 모든 연산이 완료가 되고나면 그재서야 자신을 실행할겁니다.

당연히 퍼포먼스는 떨어질겁니다 왜냐면 out of ordering의 목적자체가 스토어에 오래걸리는 거라면 로드를 우선적으로 해서 그 만큼 성능을 앞당기겠다는 의미니까요.

하지만 로드가 스토어를 앞서면서 문제가 생기고 있기 때문에 이부분을 정정해 주기 위한 인터락으로써의 역할인겁니다.

 

그리고 그외 다른 함수들도 존재합니다만...

_ReadWriteBarrier이라는 함수는 컴파일러 베리어입니다.

즉 컴파일러가 최적화를 하는 상황에서 저 _ReadWriteBarrier이라는 코드를 두고는 위와 아래가 섞이지 않습니다.

그렇기 때문에 컴파일러 베리어라고 불리고있고 그리고 컴파일러 베리어는 어셈블리로 보게되면 아무것도 적혀있지 않습니다.

그냥 따로 어셈이 뽑히지 않고 컴파일러가 그걸보고 그냥 아 그렇구나 하고 따로 코드를 안뽑고 넘어간다는 의미입니다.

즉... 컴파일러의 최적화 기능또한 컴파일러 선에서의 out of ordering 상황이라는 겁니다.

cpu가 더 빨리 작동하기 위해서 그랬던것 처럼요...

 

마지막으로 std::atomic_thread_fence() 함수인데요...

이 함수또한 크게 뭐가 없습니다 결국에는 _InterlockedIncrement 함수를 호출합니다.

그리고 std::atomic_thread_fence함수는 오더링 등급이 존재하는데 x86의 경우에는 relaxed의 경우에는 아무 작업을 하지 않고 seq_cst의 경우에만 메모리 베리어를 치게됩니다.

그외의 케이스는 그냥 컴파일 베리어만 해버리고 리턴하게 됩니다.

 

pragma warning 처리는... 뭐 초기화 안된 변수를 사용했기 그런겁니다.

딱히 큰 의미가 있는 부분은 아니니 넘어가시면 됩니다.

왜 초기화를 안해줬냐 하는것은... 초기화에 낭비할 클럭이 없다는 것입니다.

 

그리고 과거에는 저 _Guard라는 변수를 static으로 사용했었습니다.

static으로 사용하게되면 문제점이 있습니다.

static 변수를 사용하게되면 또 공유자원이 생기고 이걸 컴파일러 차원에서 동기화를 잡아줘야됩니다.(지금은 static변수에 대한 멀티스레드 동기화 작업은 컴파일러 선에서 보장됩니다)

그러므로 성능 낭비가 생기겠죠... 지역변수로 바꾼것은 상당히 잘 한것같습니다.

 

일단은 피터슨 알고리즘을 인터락으로 잡게되면 당연히 문제는 사라집니다.

왜냐면 out of ordering으로 인한 load가 store보다 더 먼저 진행되는 현상이 사라지기 때문입니다.

물론 저렇게 된다고해서 인터락을 도배하거나 난장판을 쳐놓으면 실무에서는 난리가 날겁니다.(제가 해본건 아니지만 들은게있어서...)

실무에서는 안정적이고 튼튼한 동기화 객체 쓰시고... 공부하실때는 인터락을 도배를 하시든... 뭐 락을 쓰시든 마음껏 공부하시면 됩니다...

 

와.. 오늘은 정말 긴글이었던 것 같습니다.

이 주제는 진짜 너무나도 중요해서 한글에 적을 수 밖에 없었습니다.

내용이 상당히 중요해 보임이 여러분도 느껴지실 겁니다...

다들 일단 긴 글 읽어주셔서 정말 감사드립니다.

다음글에서 더 좋은 정보로 뵙겠습니다. 그럼 안녕히계세요

320x100

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

Thread Design2  (0) 2022.02.04
Thread Design  (0) 2022.01.27
Synchronization2  (0) 2022.01.21
Synchronization  (0) 2022.01.21
Thread  (0) 2022.01.20