Thread

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

320x100

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

 

저번 글에서 동기화 객체에 대한 얘기를 했고 조금 짧은 글이었습니다.

왜냐면... 이번글부터 쓰레드 부분인데 사실 쓰레드 부분에 들어가는 순간 어디서 끊어야할지 매우 난감하기 떄문에 중간에 한번 끊고 갈 수 밖에 없었던 부분입니다...

 

그리고 이번글을 시작하기전에 이번글의 표지사진은 이번글과는 관계가 없음을 알려드립니다.

 

멀티쓰레드에 대한 얘기를 해야되는데 아직 쓰레드에 대한 얘기도 못꺼냈습니다 ㅋㅋㅋ... 무려 4번째 글만에 드디어 쓰레드라는 제목을 달고 나왔긴 하지만... 사실 이글에서 쓰레드에 대한 모든부분을 다루지는 못할거에요...

 

그럼 일단 시작해보겠습니다.

윈도우는 쓰레드 스케쥴링을 하는 os입니다. 물론 요새는 거의 대부분os가 쓰레드를 스케쥴링하는것으로 알고있습니다.

예전의 경우에는 리눅스 커널을 사용하는 os들이 프로세스 스케쥴링이었던 적이 있지만... 리눅스 커널을 사용하는 os들도 요새는 다 스레드 스케쥴링인것으로 알고있습니다.

 

네 뭐 위에 말이 중요한것은 아닙니다.

제가 하려고 했던말은 스레드는 os가 관리하고있는 커널 자원이라는겁니다.

그리고 윈도우즈에서 커널에 직접 접근할 수 있는 방법은 전혀없습니다.

커널모드 전환을 통해 cpu접근 레벨을 최상위로 올리고 그리고 이때까지 사용중이던 메모리들도 모조리 갈아치우고... 등 이런 작업이 일어나는데요.

이런 작업을 유저레벨에 맡기지 않습니다.

윈도우는 유저를 불신하는 os인것 같습니다... 째뜬 우리는 API를 통해서만 커널에 간접접근이 가능하게 설계가 되어있습니다...

우리는 쓰레드를 직접 생성, 직접 접근, 직접 파괴할 수 없습니다.

 

우리는 그저 API를 통해 만들어달라는 요청을 할 수 있습니다.

Create가 들어가는 함수들로 말이죠...

스레드를 만들어내는 함수도 동일합니다 CreateThread입니다.

윈도우즈에서는 이방법 외에는 스레드를 생성할 수 있는 방법이 따로 없습니다.

그리고 실제 반환되는 것 또한 실제 스레드에 대한 객체나 뭐 그런게 아닌 스레드에 대한 핸들값이 반환되죠.

우리는 스레드 관련 함수를 호출할때 실제 스레드를 넣지 않습니다.

핸들값을 통해서 간접적으로 접근을 하는거죠

 

아... 가끔 CreateThread를 제외하고 다른 함수들도 스레드를 생성할 수 있는걸로 알고있다고 하시는 분들이 계시던데요...

c runtime library에서 _beginthread 시리즈 함수 혹은 c++의 std::thread클래스가 존재하지만...

이도 결국 CreateThread를 래핑한 클래스와 함수에 불과합니다.

거기에 +로 c runtime library에서 따로 _tiddata라는 구조체를 할당해서 그 구조체에 대한 정보를 저장하고 있는것 뿐입니다.

이것은 나중에 tls(thread local storage)를 공부하게 되면 더 구체적으로 이해할 수 있을겁니다.

 

일단은 스레드의 생성부분은 우리가 어느정도 컨트롤이 가능합니다만...

스레드의 소멸은 우리가 컨트롤 할 수 있는 부분이 아얘 없습니다.

그저 우리는 사용을 마쳤다는걸 커널에 알려주는것 뿐이죠...

아마 std::shared_ptr을 써보신 분이라면 아실겁니다. ref_cnt라는게 존재하죠.
저 참조카운트는 이 객체를 참조하는 카운트 수가 존재하고 이 카운트가 0보다 크다면 절대 이 객체를 소멸시키지 않습니다.

 

이와 정확히 동일합니다.

스레드도 참조 카운트가 있고 우리는 CloseHandle이라는 함수를 호출하면서 스레드 핸들값을 넘겨주면 참조카운트를 1 차감합니다.

실제 스레드가 파괴되는것은 참조카운트가 0이 되었을 때 os가 할일이죠.

 

우선 프로세스가 켜지게 되면 자동으로 주 스레드 하나가 돌게될거구요...

그건 물론 우리의 main함수를 바로 실행시키진 않습니다.

c runtime library의 시작함수를 호출할것이구요...

얘가 invoke_main이라는 함수를 호출하면 그제서야 우리의 main함수가 호출되는겁니다.

그리고 우리의 main함수가 return이 된다면 정리하는 상황에서의 c runtime library에 등록되어있던 모든 함수들을 다 호출해버립니다.

이 호출되는 함수중에서는 c++에서 등록해둔 소멸자와 같은것이 있을수도 있겠구요...

물론 우리가 등록할 수도있습니다.

atexit라는 함수를 통해서 말이죠.

그리고는 ExitThread(), ExitProcess()와 같은 함수로 프로세스 종료를 유도해주죠.

 

만약 다른 스레드1이 돌고있는데 우리의 main함수가 리턴되어 버린다면 이런일이 일어날겁니다.

스레드1은 사실 메인함수가 끝났는지 여부조차도 모르고 계속 돌고있을겁니다.

그럼 메인함수가 끝이났으니 우선 c runtime library는 열심히 프로세스를 종료시키기 위해 객체 소멸자들을 할겁니다.

사용하기 위해서 만들어뒀던 리소스들을 정리한 뒤,  ExitThread로 주 스레드를 종료시키게 될것이고,

ExitProcess함수를 호출해서 프로세스를 종료시킬겁니다.

 

사실 그때까지는 우리 스레드1은 끝난지도 모르고있을거에요...

왜냐면 스레드를 os의 관리영역이고 메인함수에 대한 정리과정은 c runtime library의 관리영역이니까요.

ExitProcess를 호출해서 프로세스가 정리되면서 덩달아 스레드가 정리되는 상황이 아니라면 그 전까지는 알 수 없을 겁니다.

돌고있다가 갑자기 뚝 하고 사라져버리는 상황이 되어버리게 되는거죠. 

만약 위의 상황에서 만약 스레드1이 전역객체에 접근을 한다고하면... 무조건 터질겁니다.

프로세스가 종료되기 전 모든 소멸자는 호출되었고 메모리는 반환되어있는데 접근을 하려고 하니까요...

운이 좋아서 디커밋이 되지 않았다 한다고 해서 좋아할 일은 아닙니다.

문제가 될 수 밖에 없습니다.

그리고 두번째로 스레드1은 여전히 돌고있고 매우 중요한작업 가령 db에 데이터를 쓰는 작업이라던가 그런 작업을 하고있는데 main thread가 죽어버렸기 때문에(ExitProcess가 호출되었기 때문에) os가 지금 돌고있는 thread를 어떻게든 없애버릴겁니다.

물론 DB와 같은 중요한 스레드를 뽑을때는 안전장치를 몇중으로 안전하게 해둬야 할 것입니다.

하지만 최악의 수를 보자는 겁니다.

우리 개발자들은 최악의 한개의 상황 때문에 문제가 생기는 거니까요.

 

스레드가 하던 행동이 별 중요하지 않았던 것이라고 해도 문제가 아니라고만은 할 순 없습니다.

물론 꺼지는 상황에서 그게 뭐가 중요하냐라고 하실수도 있습니다만...

어떤 상황이든 문제를 안만드는게 제일 좋은방법이 아니겠어요?

 

그리고 서버의 경우에는 몇십개의 종료해야될 프로세스들이 존재하는데 그 프로세스들을 일일이 종료시키지 않고 우리는 원격으로 종료시키는 경우가 많을겁니다. 그 경우에는 끝나지도 않았는데 관리하는 서버가 꺼져버린다면... 노답이죠... 다음번에는 켜지지 않을겁니다 왜냐면 이미 ip port를 물고있기 때문에 bind에러가 날수도있고... 그외 다양한 문제가 생길것입니다. 그래서 무조건 기다림을 원칙으로 갑니다. 그런다고 꺼질때까지 무작정 끝까지 기다리진 않을거긴합니다. 그건 나중에가서 얘기를 해보도록하죠.

 

자... 그럼 우리는 쓰레드를 어떤식으로 활용해야 되는가를 먼저 봐야되겠습니다.

 

아까 말했듯 쓰레드에 직접 접근은 절대 불가능입니다.

우리는 핸들값을 얻어서 가는데요...

그 핸들값을 어떻게 얻어내느냐 현재 작동중인 스레드의 핸들값이 저장된 변수를 다른 변수로 복사해버리면 그게 핸들값을 복사한걸까요?

그건 아닐겁니다 그건 그냥 변수에 대입한거구요..

실제로 스레드에 핸들을 하나더 얻는다고 하는것은 커널 전환이 일어나서 참조카운트 하나 증가시키고 그에 맞는 핸들을 하나 더 만들어서 그 값을 주는게 진짜 핸들을 얻는것이겠죠.

근데 지금 현재 작동중인 쓰레드의 핸들을 얻기위해서 그런 작업을 하는 것은 매우 비효율적일겁니다.

 

그러므로 스레드의 핸들은 가상핸들값(pseudo handle)을 반환합니다.

GetCurrentThread라는 함수를 통해서 반환합니다. 이값은 어느 쓰레드든간에 동일한 값이고 이 값을 그냥 스레드 함수에 인자로 전달하게 된다면.. 그냥 지금 돌고있는 스레드를 대상으로 무언가를 합니다.

그리고 가상핸들값을 가져와봐야 자기 스레드에 뭔가를 하는 행위는 잘 생각나지도 않을뿐더러 많이 쓰지도 않는 것 같아요...

네 그래서 만약 지금 돌고있는 thread id를 파라미터로 다른 thread를 열고싶을땐 절대로 GetCurrentThread를 전달해서는 안될겁니다. 이 GetCurrentThread는 결국 그 thread에서는 또 자기 자신의 thread가 되기 때문이죠.

 

실제로 쓰레드의 핸들을 복사해야될 일이있다면 GetCurrentThread보다는 DuplicateThread함수를 이용해서 핸들값을 하나 더 얻어낼 수 있습니다. 이때는 커널에 들어가서 usageCount를 1증가 시키고 나오게 되는겁니다.

 

쓰레드를 정지시키고 싶다면 내부에서 ExitThread함수가 존재합니다.

외부에서 종료하는 함수로는 TerminateThread라는 함수가 존재합니다.

하지만 이 두가지는 ms에서 조차도 매우 비권고되는 사항입니다.

아까 말씀드렸던 이유와 같이 c runtime library에서 정리할 틈을 주지않고 스레드를 닫아버리기 때문입니다.

물론... 사용은 하긴할건데 이런상황에서 사용할겁니다.

1차로 로직이 정상종료되는것을 최우선으로 둘겁니다. (그래야 소멸자의 호출, 정상적인 메모리 정리 과정을 밟을겁니다.)

종료유도를 했음에도 종료가 안된다면 TerminateThread함수를 써서 종료시킬겁니다.

물론 종료유도를 하는 상황이라는 것은 안전하게 종료할 모든 준비가 끝났음에도 불구하고 종료하지 않는다는 전제를 하고있는겁니다.

만약 백업이 안되었는데 갑자기 Terminate를 호출해서 죽여버리면 안되겠죠..?

 

그 외에도 스레드를 block상태로 만드는 SuspendThread, 그 상황을 해제하는 ResumeThread와 같은 함수도 존재합니다.

SuspendThread의 경우에는 스스로 호출하는게 일반적이겠죠...

남이 너 할일없어 보이니까 쉬어라 라고 하는건 말이 안될겁니다.

그리고 지금 이 스레드가 무슨 작업을 하고 있을줄 알구요...

Resume은... 뭐 타 스레드가 해줄 수 밖에없는거구요.

애초에 block이 된 스레드는 돌지 않는데 스스로 resume을 호출한다는거 자체가 말이안되죠.

그래서 이건 타 스레드가 깨워줄 때 쓸 수 있지만... 이것도 사용하지는 않을겁니다.

아... 그리고 Suspend와 Resume은 flag로 block이다 아니다를 구분하는게 아니가 카운팅 방법입니다.

몇번 Suspend되었다..

이런식으로 기억하고있습니다.

 

네 그럼 기본적인 쓰레드에 대한 지식과 간단한 API몇개를 봤습니다.

지금부터는 잠시 다른 내용을 얘기할겁니다.

뭐냐면요... Sleep함수에 대한 얘기입니다.

우리가 Sleep이라는 함수를 호출하면서 파라미터로 DWORD 타입의 값 하나를 집어넣습니다.

이 값으로 넣는 n은 n ms만큼 block에 걸렸다가 와라 라는 의미로 작용합니다.

즉 스스로 quantum time을 포기하고  blocking이 되었다가 특정 시간이후 다시 dispatcher에 의해서 다시 ready queue에 들어가는 작업입니다.

여기서 우리가 중요하게 생각하는 부분은

Sleep(0)Sleep(1이상의 값)의 차이점입니다.

Sleep(0)의 경우에는 뭐... 0ms만큼 쉬어라 라는게 무슨 의미인지 잘 이해가 안가시는 분들이 계실겁니다.

뭐 이의미는 실제로 블로킹에 걸려라는 의미는 아닙니다.

Sleep(0)가 가진 의미는 지금 가진 남은 quantum time을 포기하고 dispatcher에 의해서 ready queue로 들어가라 라는 행위를 만들어 주는겁니다.

 

context switching 유도 문장입니다.

 

그리고 Sleep(0)로 quantum time을 포기했는데 그 스레드의 priority가 가장 높다면 그 즉시 다시  ready queue에서 running 상태가 될겁니다.

이는 약간의 모순이 있습니다...

Sleep(0)로 quantum time까지 포기해가면서 context switching을 유도했는데 본인이 또 들어간다??

 

이상하지 않으신가요??

 

물론 지금까지 했던 Sleep(0)에 대한 이야기는 단지 과거의 얘기일 뿐입니다.

과거에는 Sleep(0)라는 함수를 통해서 context switching을 유도하면 다시 priority가 가장 높은경우 running상태가 되어서 의미없는 코드가 되버렸었습니다.

그리고 그 시절에는 SwitchToThread라는 함수를 이용해서 Sleep(0)의 부족한 기능을 수행했습니다.

SwitchToThread함수는 quantum time을 포기함과 동시에 자기보다 priority가 낮은 스레드를 우선 실행시켜주는 그런 역할을 하는 함수였습니다.

물론 지금은 Sleep(0)도 동일한 역할을 하고있지만...

뭐... 그렇다구요.

 

Sleep(0)의 경우에는 cpu가용율이 100%가 아니면 큰 효율은 딱히 없습니다.

어차피 남는 시간동안 처리하면 끝나는 부분이니까요.

하지만 또 웃긴게 cpu를 100% 사용하는 상황은 좋은 상황은 아닙니다..

로직이 말도안되게 복잡하거나 cpu에게 부담된다는 의미니까요.

 

그리고 이와 비슷한 개념이지만 cpu단에서의 양보 기능이 또 존재합니다.

아마 유니티같은 엔진에서 코루틴 같은걸 사용해보신 분들이 있다면 yield라는 키워드를 자주 보셨을겁니다.

이 yield가 의미하는게 뭐냐..? 유니티에서는 그냥 적혀있는 시간동안 내 루틴이 돌지 않고 다른 루틴이 돌게 하겠다는 그런 의도로 해석되어집니다. (유니티는 싱글스레드니까요...)

 

이 cpu에서의 yield라는 것은 하이퍼 스레딩을 통해 하나의 cpu가 논리 cpu2개로 보여질 때 내 논리 코어가 메모리를 load하거나 store등 이런 행위를 하고있다면 어차피 일을 못하니까 그동안 pause를 걸어서 나말고 다른 논리 코어가 일을 할 수있게 해주는 기능입니다. 즉 하이퍼 스레딩이 지원되지 않는 cpu라면 NOP operation입니다.

asm으로 그대로 직역하면 PAUSE 가 되는것이죠.. .그리고 지원하지 않는다면 그자리에 NOP이 박혀버릴거구요...

 

 

물론 cpu차원에서 이뤄지는 일이기 때문에 os는 알길이 전혀 없습니다.

 

스레드 얘기가 나와서 잠시 스레드 개념에서의 yield와 cpu 차원에서의 yield에 대한 차이를 간단히 적어봤는데요

 

다시 본론으로 돌아와서 우리는 스레드 부분을 얘기하면서 CreateThread와 ExitThread, TerminateThread 등...의 함수를 권고하지 않는다고 했었습니다. 그 이유는 여기에 있습니다.

가장 아래에 Windows API가 존재할겁니다.

그리고 우리는 그걸 래핑해놓은 c runtime library의 함수를 사용하게 되겠죠.

그리고 우리의 컴파일러는 그 위에서 코드를 짜게 될겁니다.

그리고 컴파일러가 짜놓은 코드들은 전부 정적 코드입니다.

 

이런 예제 코드가 있다고 해보겠습니다.

class Test
{
    Test(){printf("Test Create");}
    ~Test(){printf("Test Destroy");}
};

int main()
{
    Test t;
    int a;
    scanf_s("%d",&a);
    if(a == 10)
    {
        endthread(); or ExitThread();
    }
    return 0;
}

이상황에서 a 가 10이라면 endthread()를 통해서 바깥으로 튀어나가게 될겁니다.

뭐 이 경우 c runtime library에서 확보해뒀던 메모리도 말끔히 지울것입니다.

os차원에서도 문제없이 스레드를 끝내고 프로세스를 종료할겁니다.

그럼 여기서 무슨 문제가있느냐...?

endthread(), ExitThread는 동적으로 스레드의 종료를 명령하는 코드입니다.(컴파일러 입장에서요)

반면에 return의 경우에는 정적으로 함수 종료를 명령하는 코드죠.(컴파일러 입장에서요)

 

return의 경우에는 컴파일러와의 약속입니다.

이 함수를 끝내겠다는 그렇기 때문에 return문을 만나게되면 컴파일러는 Test에 대한 소멸자를 코드로 박아넣을 겁니다.

이점은 확실하게 보장되는 점입니다.

하지만 endthread로 스레드를 종료시키는 저순간 스레드가 종료됩니다.

즉 스택에 할당해두었던 클래스에 대한 소멸자가 호출되지 않기때문에 문제가 생길 수 있습니다.

 

당연한것이겠지만 메모리에대한 정리는 os차원이기때문에 아주 잘될겁니다.

 

물론 지금코드에서는 큰 골칫거리는 없습니다.

그냥 printf하나가 실행이 안된거 뿐이니까요.

하지만 만약 어떠한 기능을 하는 클래스였다면요?

소멸자에서 저장 되지 않았던 정보들을 저장 해버리고 종료하는 코드를 포함하고 있었다고 가정해 보겠습니다.

endthread로 나가게 되어버린다면... 저장이 완료되지 않은 상태에서 스레드 종료가 되게 됩니다.

이는 롤백으로 이어지는 웃긴상황이 발생할겁니다.

endthread가 상당히 나쁜녀석 같아보이지만 이정도면 상당히 양반입니다.

 

ExitThread를 사용하게 된다면... 그마저도 없습니다.

우리의 c runtime library에는 _tiddata라는 구조체가 존재하는데요...

아마 rand를 설명드릴때 말씀드렸던걸로 기억하는데요. 내부에 이전에 했던 값을 기억하는 메모리가 존재한다고 했습니다.

예를들면 rand()함수의 시드값이나 strtok에 인자로 nullptr을 넣게될 경우 아까 문장을 이어서 하는등과 같은 처리부분입니다.

그리고 endthread는 이부분을 정리해주는 부분도 담당하고 있습니다.

물론 이것만 하는건 아니긴합니다만 째뜬 이런 부분을 담당합니다.

 

저는 이부분을 보자말자 이생각을 했습니다.

 

그럼 CreateThread로 만들었는데 endthread로 끝내면 nullptr를 참조하는꼴이기 때문에 런타임 에러가 나는지,

혹은 CreateThread로 만들어서 strtok함수를 쓰게되면 nullptr을 참조하는것이기 때문에 런타임 에러가 나는지 이생각을 했었습니다...

근데 이건 책을 보니까 한번이라도 c runtime library를 사용하게 되면 그 순간 만들어주는 방식으로 되어있다고 하더라구요...

근데 CreateThread로 만들어서 endthread하는 사람이 있겠습니까...

 

그렇기 때문에 우리의 경우에는 무조건 컴파일러에게 맞춰줍니다.

그래서 제가 말씀드렸던게 1차로 무조건 정상종료를 목표로 한다고 했었습니다.(return을 의미합니다)

그 이유는 이런 목적을 담고있었던겁니다.

Test객체의 경우 stack에 컴파일 타임에 정적으로 할당되어지는 객체입니다.

그리고 객체는 스코프 내에서만 유효하죠 이건 제가 c언어 기본 글에서도 언급했었습니다.

이는 컴파일러가 보장하는거고, 중간에 스레드를 멈추게 되면 컴파일러의 보장을 받기 힘들어집니다.

그래서 스레드의 종료는 무조건 return으로 간다고 말씀드린겁니다.

 

endthread는 안전하다고 권고한다고 분명 ms에서도 말합니다...

하지만 endthread는 그 자리에서 스레드를 끝내버리는 함수입니다.

그러니까 스레드가 갑자기 종료되어버리는거죠.

그렇다면 그 순간부터 있어야될 소멸자에대한 모든 호출은 존재하지 않게됩니다.

이부분은 주의해서 쓰시면 될 것 같습니다.

 

음... 일단은 여기서 한번 끊어야겠습니다

다음글에서는 다시 동기화로 돌아올겁니다.

완전히 동기화 객체를 다루는 파트는 아니지만... cpu차원에서의 동기화, 그리고 os차원에서의 동기화 이부분을 나눌것이구요...

그리고 동기화 방법론에 대한걸 몇가지 소개하고자 합니다.

그럼 이쯤에서 글을 마치도록 하겠습니다

그럼 오늘도 긴글 읽어주신 여러분들 감사합니다.

그럼 안녕히계세요

320x100

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

Synchronization2  (0) 2022.01.21
Synchronization  (0) 2022.01.21
Synchronization object  (0) 2022.01.19
windows scheduling  (0) 2022.01.19
Windows preemptive priority based round-robin scheduling  (0) 2022.01.18