우선 저 글을 정리하기 이전에 저번 글에서 마져 다 소개하지 못했던 내용들을 정리해놓고 시작하도록 하겠습니다.
저 그럼 시작하겠습니다.
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입니다