Thread design에 대한 이해는, 기본적으로 잠금 정책에 over head를 이해하고 있느냐에서 출발한다고 생각합니다.


잠금 기반 프로그래밍은, 자주 사용하는 코드가 잠기게 될수록 성능이 수직 하향합니다.

대기 하느라, 제대로 된 퍼포먼스를 낼 수 없다는 얘기죠.


그렇게 하지 않기 위해, 객체 간에 잠금에 신경쓰지 않게끔, 객체 간 접점을 줄여주어야 합니다.

 

좋은 Thread design의 목표는 어떻게 잡아야 할까요?

 

접점 최소화

손쉬운 비동기 처리

의도한 대로 순차 처리 (순서가 중요한 동작의 순서 보장)

 

디테일하게 나열하자면 얼마든지 많겠지만, 저는 위 세가지 목표가 보장된 기반 코드는, 컨텐츠 구현 시에 필요한 요구 사항을 다수 충족 시킬 수 있습니다.



이런 문제가 현세대 멀티스레드 프로그래밍의 최선이라고 여겨졌는지, 많은 솔루션이 이런 니즈를 충족시키는 데에 최적화되서 개발이 되었습니다.


node.js는 무거운 작업마다 비동기로 던지고, 그 결과 값을 바탕으로 진행할 다음 작업을 지정함으로써 잠금과 순서를 고민하지 않는 프로그래밍을 유도하고 있습니다.

만약 무거운 작업을 비동기로 처리하지 않는다면, 프로그래머의 실수라고 규정 짓는 가이드라인을 제시했습니다.



erlang도 마찬가지입니다.

기본적으로 메시지로만 통신을 유도하면서, 각 작업 간에 겹치는 상황을 제거함으로써, 잠금을 고민하지 않도록 했죠.



이렇게 할때의 언어에 구애 받지 않는 핵심은, 작업마다 독립적으로 동작할 수 있어야 한다는 점입니다.




로직을 작성하는 데에 있어서, 이 객체마다 잠금을 걸었다 풀어주는 과정은 잠재적 성능 저하 지점을 만드는 과정이라 볼 수 있습니다.


아무리 측정을 자주 하는 팀이라 할지라도, 기존에 (논리적으로도, 성능 적으로도) 잘 동작하던 코드가 병목이 될 수 있는지 여부는 의심을 덜하기 마련이기 때문이죠.


그런 잠재적 우려 지점을 변수가 추가될 때 마다 늘리는 방식은 결코 좋다고 보기 어렵다는 결론에 도달해, 현재의 모델이 최선이라고 느끼는 상황이죠.



애초에 작업들이 모두 작게 쪼개져 있고, 그간에 영향을 받지 않는다고 확신 할 수 있다면 당연하게도 대기 없는 비동기 처리가 가능해집니다.


로직을 작성하는 사람이 고민해야 될 대상중에 순서만이 남은 것이죠.


작업들을 비동기로 분리하고 난 뒤의 로직의 순서 조절은 상대적으로 쉬운 문제가 됩니다. 결합도를 고민하지 않아도 되는 상황이 되어버렸기 때문에, 순서 보장 기능을 라이브러리나 프레임워크 단에서 지원 (혹은 스크립팅으로 조절) 해주기 쉽고, 그렇지 않은 경우에 구현하는 문제도 순서 보장 작업끼리 같은 큐를 사용하게만 해줘도 되는 것이죠.



요약하자면 비동기 프로그래밍에서의 성능을 장점으로 삼는 다수의 언어와 프레임워크가 내린 합리적인 선의 비동기 프로그래밍은, 작업간 결합도를 줄인 후, 작업을 병렬로 수행해 성능 향상을 노리는 쪽으로 가고 있습니다.


제 생각도, 합리적인 선의 선택이라고 보여집니다. 극한의 성능도 중요하지만, 로직 작성의 난이도를 낮추는 것도 중요한 문제거든요. 실수할 여지를 줄이는 장점도 물론 옵션이겠고요.



지금까지 thread-design에서의 잠금 최소화 프로그래밍에 대해 알아보았습니다.


Posted by 엘키 엘키

댓글을 달아 주세요

제가 프로그래밍을 처음 배울 때의 CLI 프로그래밍과 WIN32 프로그래밍으로 넘어왔을 괴리를 느꼈습니다.

이유는 바로 EVENT-DRIVEN(message based)프로그래밍 때문이었죠.

현재는 reactor라는 패턴이란 이름으로 알려진 메시지 기반 프로그래밍은, DOS 시절의 동기 프로그래밍에 익숙한 많은 프로그래머를 괴롭게 했습니다.

 

message라는걸 굳이 만들어 처리하는가…. 대해 저는 당시 이해하기 어려웠습니다.

당시만해도, 윈도우 메시지를 굳이 처리하지 않고도 여러 작업이 가능했기 때문이죠.

 

예를 들어 GetAsyncKeyState (http://msdn.microsoft.com/ko-kr/library/windows/desktop/ms646293(v=vs.85).aspx) 같은 함수로도 얼마든지 입력 처리가 가능했고, 마우스 입력도 마찬가지였죠.

 

굳이 메시지 핸들러 코딩을 해줘야 하는지 이해가 갔어요. 특히나 윈도우 기본 컨트롤들을 사용하지 않았기 때문이기도 겁니다.

 

때가 98 경이었는데, COM 기반의 메소드를 통해 초기화하던 Direct-X가 이해 안던… ( 당시엔 COM 뭔지도, 저렇게 복잡하게 포인터 캐스팅하는지도 이해가 어려웠습니다) 시기였습니다.

 

코어도 개이던 시절이라, multithread  프로그래밍은 n개의 CPU 꽂아 쓰는 서버나 슈퍼 컴퓨터 사용자들에게나 필요한 걸로 치부 되기도 했습니다.

 

헌데…그 어렵디 어렵다던 multithread 프로그래밍이 사실은 WIN32 프로그래밍에선 각종 API 통해 이루어지고 있었고, 메시지 핸들링 코드는 다른 thread (다른 프로세스에서 오는 메시지도 있지만)에서의 처리 결과를 전달하는 코드이기도 했다는 한참이 지나서야 알게 됐습니다.

 

깊게 알아내지 못한 잘못도 있지만, 책과 PC통신을 제외하곤 정보를 얻기 힘든 당시 상황을 감안하면, 친절하게 설명해주지 못한 당시 WIN32 프로그래밍 입문서들의 문제도 있습니다.

 

! 여기서 핵심은, WIN32 어플리케이션을 만들어온 과정이 사실은 multithread 프로그래밍을 해왔다라는 점이에요. 우리가 몰랐을 뿐이죠.

 

모를 있었다는 것은, 사실 WIN32 메시지 핸들러를 통한 thread  모델이 안정적이고, 디자인된 thread design 하나라고 있습니다. WIN32 메시지 핸들링 방식은 메시지 루프를 통해 비동기 작업들을 처리해야 때에만 양보하고 다시 thread 루프로 돌아오게 해주었던 안정적인 thread design API 구성했죠.

 

이로써, multithread 대한 이해 없이, 그리고 thread 동기화에 신경 쓰지 않고도 비동기 프로그래밍을 있었습니다.

 

그렇습니다! 우리는 WIN32 메시지 기반 프로그래밍을 통해 Reactor thread design 경험한 것입니다!

 

정리하자면, thread design이란 다음 요구사항을 충족해야 한다고 보시면 됩니다.

  1. 객체를 lock 신경 써야 부분, 그렇지 않은 부분을 명확히 규정
    -> 프레임워크단에서만 신경쓰도록 구현하는 것을 권장하지만, 개체 단위 lock이 정책인 경우도 있습니다.
    -> 개체 단위 lock으로 구현한 경우에는, 코딩 난이도 상승, 병목 지점 감지도 어려워질 뿐더러, 데드락 위험성도 높아져 좋지 않은 디자인이라고 봅니다.

  2. API 사용법만 준수하면 최대한 많은 코딩 영역에 thread 동기화를 고민하지 않도록 하는
    -> 이 부분이 사실 핵심인데, thread design이 잘 되어 있을 수록, 성능도 만족하면서 유지 보수 난이도가 급격하게 낮아진다고 보시면 됩니다.

  3. Blocking 동작이나, 반복작업, 병렬 수행이 필요한 작업등을 지원하기 위해 비동기 작업을 위임하거나, 분산할 있는 기능
    -> thread pool을 통한 작업 분배, open MP등으로 지원된 병렬 코드 수행등을 떠올리시면 좋습니다.

 

WIN32 메시지 기반 모델에서는 병렬 작업을 위한 분산 기능은 미흡했으나, (시기적인 부분을 감안해 줘야 된다고 생각합니다만) 이외의 요구 사항은 충족합니다.


이렇듯 thread design이란 곳에 있지 않았습니다!

Posted by 엘키 엘키

댓글을 달아 주세요

클럭도 물론 중요하지만, 코어가 몇갠지 부터 보는 일이 자연스러워진지도 몇년.

다들 병렬 프로그래밍 잘 하고 계시나요?

서버 프로그래밍을 시작한 2006년부터 지금까지... 멀티 스레드를 다뤄오며, 느낀 것에 대해 이야기해보고자 한다.

lock
말그대로 잠금. 이 데이터 unlock 될때 까지 쓰지말라는 거다.

- non-blocking
non-blocking이 뭐냐고? 대기하는 상황(blocking) 없이 코드가 수행되는 것을 말한다.

- lock is blocking.
lock이라는 것 자체가 blocking을 위한 녀석이다. 멀티스레드에 적합한 녀석 일리 없다.

- lock-free
container나 데이터에 접근할 때에 lock 객체에 대한 고민없이 사용해도 되는 자료구조나 스레드 모델을 lock-free라고 통칭한다.
좀 더 디테일하게 설명하자면 lock-free container, lock-free design이라 칭해도 되겠다.


atomic operation
- atomic operation (or atomic instruction)
CAS (http://en.wikipedia.org/wiki/Compare-and-swap) 기반의 명령어 집합을 의미한다.
windows 에선 Interlocked 계열 메소드(http://msdn.microsoft.com/en-us/library/windows/desktop/ms684122(v=vs.85).aspx)를 통해서 지원한다. software적으로 직접 구현도 가능하다. (Interlocked 계열 메소드도 software적 구현)

- limit of atomic operation
atomic operation이 해결해 주는 것도 꽤나 많다. 객체단위 lock보다, CAS는 잠금 시간이 짧다.
다만 그로 인해 성능은 좋아질 지언정 논리상 오류가 없을까? 기본적인 전치검사 후치보장 룰을 깨뜨릴 가능성이 높다.
결국엔 논리적 오류까지 해소하기 위해선 스레드 디자인이 필요하다는 얘기다.

parelell
- lock and paralell
애초에 lock이란 pararell과 어울리지 않는 단어.
허나 locking을 minimal 하는 목표를 갖고 pararell 하게 구현해보자.

- reactor, proactor
reactor : dispatching based.

proactor : callback based.

조금 더 자세히 보자면 proactor는 이 작업을 비동기로 수행하고 그 결과를 알려주세요라는 개념이고, reactor는 어떠한 작업이 언제 완료될지 모르니, 작업에 대한 통지가 오길 기다리는 개념이라고 보면 된다.

thread-design
- entry point
진입점이 명확하면 좋다. 데이터를 발생 시키는 지점부터 고민해야 한다.
내 경험상 수 많은 서버가, entry point의 종류로 network receive, cyclic action (internal looping), system thread (api internal thread) callback, packet process thread 정도가 주를 이룬다.
이 위치를 명확히 파악해두어야, 부하에 대한 tracking이 쉬워진다.

- contact point
각 entry point에서 발생된 event들이 겹치는 지점을 말한다. entry point가 종류별로 여러 스레드로 구성 되어 있을 경우 특히나 contact point가 겹치곤 하는데, 이 위치에 lock을 놓는 전략이 가장 쉽지만, 성능 저하를 일으키는 지점이 되곤한다.

- thread count
스레드 갯수는 경합 확률을 높인다. 하지만, 잘 되어있는 스레드 디자인이 이를 우회할 수 있다.
blocking 되는 상황이 있는 thread와, 할 일이 있다면 쉬지 않고 일하는 non-blocking thread를 파악하고, 그 갯수를 비례해서 조절하는 것이 좋다.

sleep 함수도 마찬가지다. 현재 context를 이관하는 선택은, blocking이 되는 상황이 되고 있다는 것을 전제한다.

- tip
contact point는 필연적으로 lock을 강제당한다.  
contact point를 제거할 수 없다면, contact point를 갈라주거나, locking을 minimize 하는 노력을 기울여야 한다.

- lock minimize
-> contact point가 줄어들면 자연스레 lock을 최소화 해서 사용할 수 있게 된다.

-> contact point를 줄이는 쉬운 팁은 thread마다 다른 instance에 접근하도록 하는 것이다. 
-> 예를 들어 MMORPG에서 지역별로 다른 message worker를 이용하도록 하면, 잠금없이 스레드가 동작할 수 있다.
-> 다른 지역간의 interaction이 일어나는 동작을 개체단위 lock이 아닌 message로 수신해서 처리하는 방식으로 한다면, message queue에 밀어넣는 잠깐의 지연만 걸릴 뿐이다.

-> 조금 더 쉽게 정리하자면, 개체의 소유권을 각 스레드에 두고, 입력과 출력은 message로써 다루면 lock을 줄일 수 있다.



warning
- event explosion
어떻게 구현하건간에 처리량은 한계가 있다.
이벤트량 자체가 폭발하지 않게끔제어해야 한다.

예를들면 패킷을 수신하는데, 비정상 적인 수의 패킷을 발생 시킨다면, flooding 처리를 해야 한다.
또한 로직에서 아이템 처리를 위해 여러개 티어를 거친다고 생각해보자. 아이템 획득 처리, 아이템 로그, 퀘스트 달성 여부, 이벤트 진행인지 검사 이벤트 등... 모든 컨텐츠의 티어가 잘게 나누어져 있고, 처리 티어들을 한번씩만 거친다고 해도 이벤트량 자체는 많을 수 밖에 없다.

티어를 너무 잘게 쪼갠다면, 데이터를 완성 시키기 위한 과정에서의 이벤트량 자체가 많아지고, 오류 발생의 여지도 늘어난다.


- hot spot
latency는 두 종류가 있다.
1. 절대적인 처리량이 많아서. (event explosion으로 인한 처리량이 많아진 상황도 포함한다)
2. 병목 지점에서 대기가 이루어져, 전체 대기 시간이 길어지는 상황.

이 중 2번의 주 원인은 hot spot에 있다. 
hot spot이 발생했다는 것 자체가, contact point에서의 지연이 발생하거나, contact point에 동시 접근하는 스레드가 많다는 의미다.
다시 한번 강조하자면, thread design이 그래서 중요하다.

maintain
- profiling
측정하라. entry point부터 processing이 끝난 시점까지 측정하는 것은 너무나도 당연하지만 생각보다 이에 대한 모니터링이 이루어지지 않는 경우도 많다.
이벤트 갯수, 이벤트 처리시간, 오류 발생, 경고 등 지표화 할 수 있는 데이터들을 잘 남겨 두는 것이 중요하다.

프로파일링은 기능의 일부처럼 다뤄져야한다. 

번거롭고, 거추장스러워 문제가 생기고 나서야 확인하는 범행 현장 조사 같은 일이 되어서는 안된다.

- base line
처리 속도와 처리 시간의 목표 지점을 설정하라. 한 패킷이 5ms이하로 동작하리라 목표로 삼는다면, 1스레드당 200개 패킷을 처리할 수 있다는 전제를 세울 수 있다.
처리량을 기준으로, 한 서버당 가용 동접이 설정 가능해진다.

정상, 경고, 임계, 장애등의 수위를 정해놓고 각 수위별로 다른 알람을 받으면 좋다.
또한 runtime에 해당 수치를 낮출 수 있는 대안 (컨텐츠별 block)을 마련해두어 대응할 수 있어야 한다.



스레드 디자인에 대한 내용은 다음 글에서 따로 다뤄보고자 합니다. 꽤나 길어질거 같군요.

제 생각에 대한 이의나 다른 의견, 질문 뭐든지 환영합니다.

이상으로 멀티 스레드 프로그래밍의 퍼포먼스 해결을 위한 팁을 정리해보았습니다.


Posted by 엘키 엘키

댓글을 달아 주세요

1.Critical Section

 - 유저 레벨의 동기화 방법 중, 유일하게 커널 객체를 사용하지 않음.

 - 내부 구조가 단순하여 동기화 처리에 대한 속도가 빠르다.

 - 동일한 프로세스 내에서만 사용.

 - 커널 객체를 사용하지 않기 때문에 핸들을 사용하지 않고, CRITICAL_SECTION 이라는 타입을 정의하여 사용.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 크리티컬 섹션을 초기화한다.
// 파라메터는 여러 개의 쓰레드에 참조되어야 하므로 전역으로 선언하도록 한다.
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
 
// 생성된 크리티컬 섹션을 삭제한다.
void DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
 
// 동기화 방법
void workFunc()
{
    EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection); // 호출. lock.
 
    // 여기서 공유 자원을 안전하게 액세스한다.
 
    LeavCriticalSection(LPCRITICAL_SECTION lpCriticalSection); // 호출. unlock.
}

2. 동기화 대기 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMiliseconds);
// hHandle : 동기화 객체.  dwMiliseconds : 대기 시간
// 대기 시간을 INFINITE로 지정하면 무한대로 대기한다.
// 반환 값의 종류는 세가지다.
//  WAIT_OBJECT_0 : 성공
//  WAIT_TIMER : 동기화 객체가 시그널 상태
//  WAIT_ABANDONED : 대기시간 초과
 
DWORD WaitForMulipleObject(DWORD nCound, CONST HANDLE *lpHandles,
                            BOOL fWaitAll, DWORD dwMiliseconds);
// WaitforSingleObject 함수는 하나의 객체에 대한 동기화를 기다리는데 비해 이 함수는
// 복수 개의 동기화 객체를 대기할 수 있다. 동기화 객체의 핸들 배열을 만든 후 lpHandles 인수로
// 배열의 포인터를 전달해 주고 nCount로 배열의 개수를 넘겨준다.
// fWaitAll이 TRUE이면 모든 동기화 객체가 시그널 상태가 될 때까지 대기하며,
// FALSE이면 그 중 하나라도 시그널 상태가 되면 대기 상태를 종료한다.
// 리턴 값의 의미는 조금 다르다.
//  WAIT_TIMEOUT : 대기시간 초과
//  WAIT_OBJECT_0 : bWaitAll이 TRUE일 때 - 모든 동기화 객체가 시그널 상태.
//      FALSE일 때 - lpHandles 배열에서 시그널 상태가 된 동기화 객체의 인덱스를 반환.
//      이 경우 lpHandles[리턴값 - WAIT_OBJECT_0]의 방법으로 시그널 상태가 된
//      동기화 객체의 핸들을 얻을 수 있다.

 

3. Mutex (MUTual EXclusion)

 - 최초 Signaled 상태로 생성되며, WaitforSingleObject()와 같은 대기 함수를 호출함으로써 NonSignaled 상태가 된다.

 - 크리티컬 섹션에 비해서 느리다. 크리티컬 섹션의 경우 구조체의 값을 통해 잠그기를 허용하는데 비해 뮤텍스는 객체를 생성하기 때문이다.

 - 만약 A라는 쓰레드가 뮤텍스를 소유하고 있고, B라는 쓰레드가 뮤텍스를 사용하기 위하여 대기하고 있을 때, A 쓰레드가 잘못된 연산을 수행하거나 강제 종료되어서 소유하고 있던 뮤텍스를 반환하지 않았을 때에 B라는 쓰레드는 뮤텍스를 얻기 위해 무한정 기다려야 할까?  => 만약 크리티컬 섹션을 사용하였다면 B 쓰레드는 무한정 대기하기 된다.(데드락) 하지만, 뮤텍스의 경우는 자신이 소유한 쓰레드가 누군지 기억하고 있다. 그리고 Windows 운영체제에서 뮤텍스를 반환하지 않는 상태에서 쓰레드가 종료될 경우 그 뮤텍스는 강제적으로 Signaled 상태로 만든다.

 - 만약 같은 쓰레드가 중복으로 뮤텍스를 호출할 경우 데드락이 발생할까? => 발생하지 않는다. 왜냐면 같은 쓰레드가 중복으로 뮤텍스를 호출할 경우는 내부 Count만 증가시키고, 진입은 허용한다. 그리고 나중에 내부 Count가 '0'으로 될 때 Singaled 상태로 해준다. (이 부분은 크리티컬 섹션도 동일한 개념이다.)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName);
// lpMutexAttributes는 보안 속성으로 보통 NULL로 지정한다.
// bInitialOwner는 뮤텍스 생성과 동시에 소유할 것인지 지정하는데 TRUE이면 이 쓰레드가 바로 뮤텍스를
// 소유하면서 다른 쓰레드는 소유할 수 없게 된다.
// lpName은 뮤텍스의 이름이다. NULL 설정 가능.
// 리턴 값은 뮤텍스의 핸들이다.
 
HANDLE OpenMutex(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);
// 뮤텍스를 연다. 프로세스 ID와 마찬가지로 뮤텍스의 이름은 전역적으로 유일하다.
 
BOOL ReleaseMutex(HANDLE hMutex);
// 해당 쓰레드의 뮤텍스 소유를 해제하여 다른 쓰레드가 가질 수 있도록 한다.
 
HRESULT CloseHandle(HANDLE hHandle);
// 모든 커널 객체와 마찬가지로 생성된 뮤텍스를 파괴할 때 사용한다.
// 리턴 값 S_OK : 성공.
//           그외엔 에러.

* 포기된 뮤텍스

 만약 뮤텍스를 소유하고 있는 쓰레드가 ExitThread나 TerminateThread로 비정상적으로 종료시켰을 경우 강제로 뮤텍스를 시그널 상태로 만들어준다. 그러므로 대기 중인 다른 쓰레드에서 뮤텍스를 가지게 되는데, WaitForSingleobject() 의 리턴 값으로 WAIT_ABANDONED 값을 전달 받음으로써 이 뮤텍스가 정상적인 방법으로 신호상태가 된 것이 아니라 포기된 상태임을 알 수 있다. 중복 소유 뮤텍스를 여러 번 겹쳐서 사용했을 경우 데드락과 같은 상태에 빠질 수도 있을 것이다.

 

4. Semaphore

 - 뮤텍스와 유사한 동기화 객체이다. 뮤텍스는 하나의 공유 자원을 보호하는데 비해, 세마포어는 일정 개수를 가지는 자원을 보호할 수 있다. 여기서 자원이라는 것은 윈도우, 프로세서, 쓰레드와 같은 SW적이거나 어떤 권한과 같은 무형적인 것도 포함된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
HANDLE CreateSemaphore(LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, LONG iInitialCount,
                                      LONG iMaximumCount, LPCTSTR lpName);
// iMaximumCount는 최대 사용 개수.
// iInitialCount 에 초기값을 지정.
// 아주 특별한 경우외엔 이 두가지 값은 같다. 세마포어는 뮤텍스와 같이 이름을 가질 수 있고 이름을 알고 있는
// 프로세스는 언제든지 OpenSemaphore로 핸들을 구할 수 있다. 역시 파괴할 때는 CloseHandle 함수를 이용한다.
 
HANDLE OpenSemaphore(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSRT lpName);
// 뮤텍스와 같다.
 
BOOL ReleaseSemaphore(HANDLE hSemaphore, LONG iReleaseCount, LPLONG lpPreviousCount);
// iReleaseCount로 사용한 자원의 개수를 알려준다. lpPreviousCount는 세마포어 이전 카운트를 리턴받기 위한
// 참조 인수이다. NULL 가능.

 

5. Event

 - 위의 동기화 객체들이 공유자원을 보호하기 위해 사용되는데 비해 이벤트는 쓰레드의 작업 순서나 시기를 조정하기 위해 사용된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HANDLE CreateEvent(LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset,
                             BOOL bInitialState, LPCTSTR lpName);
// bManualReset은 이벤트가 수동 리셋(쓰레드가 Non-Signaled로 만들어 줄 때 까지 신호 상태를 유지)인지
// 자동리셋(대기 상태가 종료되면 자동으로 Non-Signaled가 된다.) 인지를 결정한다. TRUE이면 수동이다.
// bInitialState가 TRUE이면 자동으로 Signaled 상태로 들어가 이벤트를 기다리는 쓰레드가 곧바로 실행할 수 있게 된다.
 
HANDLE OpenEvent(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSRT lpName);
// 다른 부분과 같다.
 
BOOL SetEvent(HANDLE hEvent);
// 다른 동기화 객체와는 다르게 사용자 임의로 Signaled 상태와 Non-Signaled 상태를 설정할 수 있다.
// 위의 함수는 Signaled 상태로 만들어 준다.
 
BOOL ResetEvent(HANDLE hEvent);
// Non-Signaled 상태로 만든다. 일반적으로 자동 리셋을 사용하는데, 이벤트를 발생시켜 대기 상태를 풀 때
// 자동으로 Non-Signaled 상태로 만드는 것이다. 하지만 여러 개의 쓰레드를 위해서 이벤트를 사용한다면
// 문제가 될 수도 있다. 그러므로 수동 리셋으로 이벤트를 생성 후 ResetEvent 함수로 수동리셋을 한다.

출처:: http://blog.daum.net/thermidor/5173637

Posted by 엘키 엘키

댓글을 달아 주세요

서버 프로그래머가 되기 이전엔 멀티 스레드 따위 관심도 없었다.

물론 그 시기까지가 클럭 향상 -> 멀티 코어로 변화가 이루어지기 전이기도 했지만... 여하튼 나는 그런 것 보단 다른 것들에 관심이 훨씬 많았다.


서버 프로그래밍을 시작하면서 멀티 스레드를 다루기 시작했고 만 7년이 된 지금까지 여러 프로젝트를 경험해왔고, 여러 사고를 쳐왔다.


그냥 정줄 놓은 누가 봐도 코드를 잘못 짜서 생긴 사고 (...)도 많았지만, 그보다 더 큰 사고는 주로 함정에서 발생했는데, 그 중 최고봉은 역시나 멀티 스레드 버그였다고 할 수 있다.


아니 아주 정확히는 멀티스레드에 맞게 코딩하지 못한 내 버그다.


내가 지금껏 프로그래밍을 공부해오며 생각해온 방식은 구조적 프로그래밍에 대한 이해를 전제로 해왔다.

그러던 중 멀티스레드를 받아들이려니 도무지 그 복잡도를 따라갈 수가 없었다.


지금은 여러가지 상황에 대한 경험으로 데이터 경합/동시 접근에 대한 문제를 찾는 노하우가 생겼고, 스레드 파이프라인 설계 등으로 문제를 회피하는 노하우등이 생겼을 뿐 여전히 멀티 스레드 코드를 작성하는 과정은 그것에 대한 고려가 없을 때 보다 수십배는 더 어렵다.


집중해야만 잘할 수 있는 일은 실수가 더 많아지기 마련이고, 당연히 골치를 썩고 있음은 물론이다.



성능 최적화에 대한 문제도, 새로운 이해를 요구하고 있다.


애초에 내가 당연시 여기던 많은 것들이 변한 것은, 멀티스레드 때문이다.


전치검사/후치보장을 잘 지켜 문제없이 돌아가던 코드가, 멀티스레드라는 환경으로 인해 무너지는 일은 절대 유쾌한 경험이 아니다.


네트워크 엔진/렌더링 엔진등 성능 최적화를 목표로 하는 경우 특히나 멀티스레드 코딩을 자연스레 하게 되고, 그로 인해 수 많은 문제를 내제하는 코드를 만들게 된다.


이를 디버깅하려면 해당 엔진의 스레딩 구조 (나는 이 것을 위에서처럼 스레드 파이프라인이라 부른다. 렌더링 파이프라인처럼 패킷이 처리되는 일련의 과정을 파이프라인이라 칭하는 것이다) 를 이해해야 하는데, 이 과정이 익숙해지기란 절대 쉽지 않다.



잠금 (파일 접근 잠금, 메모리 잠금 등..), 경합과 양보, 병목 등의 문제를 해결해야 하는 데, 이는 기본 설계 자체가 잠금을 용인하거나, 병목을 해결하기 위한 준비가 되어있지 않은 경우라면 매우 리스크 큰 작업을 해야 함을 의미한다.


이런 과정은 테스트 환경을 세팅하고 작업을 시작한다 해도 꽤나 큰 시간을 소비해야 되는 작업이라, 설계부터 Through-put 을 예측하고 보정하며 개발하는 것이 유리하고, 경합이 일어나지 않도록 스레드 디자인을 하려면 높은 이해도와 고찰이 필요한 작업이라고 밖에 할 수 없다.



특히 병목 지점을 profiling하고, 제거하는 과정은 수많은 테스트 케이스와 자동화 테스트 기반이 필요하고, 설계가 이미 병목 지점을 의도하고 있다면 지나치게 큰 작업을 요구하게 되는 경우도 빈번하다.


사실상 Framework은 안정적으로 돌아갔으면 하는 것이 일반적인 기대치이고, Framework에서 Logic의 멀티스레드화를 자연스럽게 매칭 시킬 수 있도록 (물론 전제 조건이야 있겠지만) 지원이 되면 더할 나위 없을테고, 멀티 프로세싱을 통한 Through-put 향상만 이루어져도 대게는 만족스럽다.


지금이 멀티코어 시대고, 단일 프로세스 내에 퍼포먼스 극대화에 대한 시도를 나도 하고 있고, 몇몇 회사가 시도하고 있지만 (GDC2013의 길드워2에 대한 세션 참고 : http://blog.naver.com/PostView.nhn?blogId=spacesun&logNo=140185766419&redirect=Dlog&widgetTypeCall=true) 소규모 팀 및 회사에서 마저 이런 시도를 당연시 여기고 개발해온 근래까지의 C++ 기반 서버는 내가 봐도 납득이 잘 안간다. (특히 네트웍 라이브러리 자체 개발 같은거 말이지)


그렇지만, 로직에서라도 병목 지점을 profiling해야 되는 것은 어느 규모의 서버나 매한가지다.


이 작업 자체는 기본적인 이해도라 할지라도, 멀티스레드에 모든 프로그래머가 익숙해져야 했던 기존의 C++ 서버 개발 방식은 그다지 좋지 않다.


멀티스레드에 신경써야 되는 일은 소수의 시니어 프로그래머가 해야 하며, 대다수의 주니어 프로그래머 내지는 컨텐츠 프로그래머는 적절한 함수 콜만으로 원하는 동작을 구현해낼 수 있어야만 한다.



멀티스레드에 내가 익숙해지기까지의 시행착오는 쉽지 않았다. 멀티스레드에 신경써야 될 요인들은 그렇게 마냥 간단하지 않기 때문이다.


특히 병목지점 여부를 파악하는 것이나, 설계가 갖춰지지 않은 상태에서 자주 내리는 판단인 잠금 객체 사용 (critical section, semaphore, mutex 등)은 결과적으로 성능 저하를 이끈다.


그래서 나도 다음 서버를 만든다면 C++서버와 웹서버 조합으로 짤 예정이다. (ruby on rails 또는 node.js가 될거라 예상한다.)


C++ 만으로 모든 서버 로직을 개발하며 겪에 되는 여러가지 문제로 소비될 시간에 더 많은 테스트 커버리지와 또 다른 방식에서의 (스케일 아웃등의 매커니즘이 웹서버 기반에서 많은 노하우와 솔루션이 많기에) 최적화를 시도 할 수 있기 때문이다.


꽤나 많은 서버에 포함되는 기능이 웹서버로 빠질 수 있다. 그렇게 되면 서버 프로세스내에 through-put은 저절로 상승하게 된다. (packet-flooding 같은 기능이 활성화 되어있어, 인증받지 못한 동작은 guard된다는 전제하에서지만 말이다.



현재 만들어지고 있는 꽤나 많은 게임은 connection을 상시 맺는걸 전제로 하지 않아도 된다. 그리고 RESTful API를 사용함으로써 생기는 DB 접근 비용 (캐시 서버를 쓴다 할지라도, 그 자체도 비용일 수 밖에 없다) 문제도, 해쉬를 통해 변화가 없을땐 갱신하지 않는 방법 등으로 줄일 수 있다.


허나 한편으로는 반응성이 중요한 멀티플레이 게임에서는 C++, C#, JAVA등으로 만들어진 TCP connection 기반 서버가 여전히 필요할 수 밖에 없다.


그렇기에 앞으로의 흐름에 맞추고, 효율성을 높이며, 게임의 한계를 줄이기 위해선 더 많은 연구가 고찰이 필요하지 않나 싶다.

Posted by 엘키 엘키

댓글을 달아 주세요

캐싱의 기본은 지역성에 근거하는데요, 이는 프로그래밍단의 최적화에서도 유명한 80-20법칙과도 일맥상통하는 이야기죠.

지역성(locality)은 아래 추정에 근거합니다.
1. 지금 읽힌 데이터는 이후에도 자주 사용될 가능성이 높다.
2. 지금 읽힌 데이터와 인접한 데이터는 이어서 사용될 가능성이 높다.

이는 코드 실행시 스택 처리를 통해 얻게 되는 장점과 유사합니다.

단일 코어가 아닌 멀티 코어 CPU는 데이터를 읽어올때, 캐시 라인 (cache line)이란 단위로 읽어옵니다. 
캐시 라인이라 함은 지역성에 근거해 인접한 데이터를 미리 읽어옴으로써 속도향상을 노리는 것이지요.

하지만 이는 장점이자 독이 되기도 합니다.

멀티코어에서는 A스레드와 B스레드에서 인접 메모리를 접근할때, 캐시에 있던 내용을 메모리에 반영하려 시도합니다.
인접 메모리를 읽고 있는 상태이기에 병행 수행시 데이터의 유효성을 조금이라도 높이기 위해 메모리에 반영하는 과정에서 속도 저하가 발생하는 것이죠.

실제로 인접메모리일뿐 동시 접근이 일어나지 않는 코드라고 할지언정, 해당 코드가 어떻게 작성되었는지는 중요치 않습니다. 
캐시 라인은 코드의 작성 여부까지 판단하고 동작하지 않기 때문에 (그렇게 할 수 가 없기에), 인접 메모리 접근만으로도 성능 손해를 보면서라도 데이터의 유효성을 높이고자 하는 판단을 내릴 수 밖에 없습니다.

convoying (무분별한 lock의 사용으로 멀티 스레드를 활용하지 못하고, 한개 스레드 동작시 다른 스레드들은 그 스레드가 unlock 할때 까지 대기 해야만 하는 상황) 보다야 낫겠지만, 멀티 코어가 일반화 되면서 이를 얼마나 잘 활용하는가가 화두가 되고 있는 이 시점에 메모리 거짓 공유로 인한 속도 저하는 반드시 염두에 두어야 하는 이슈입니다.

멀티코어 프로그래밍에서는, 자주 읽히는 데이터가 인접해 있다면, cache line 크기만큼의 간격을 두도록 하는 것이 좋습니다. 
패딩(padding)을 이용해서 메모리를 손해보더라도 속도에서 이득을 보라는 얘기죠.

옛말에 "메모리 공간을 팔아 속도를 산다"는 말 처럼, 메모리와 속도는 반비례 그래프와 같다는 생각이 다시 한번 드네요.

참고 자료
art.oriented - false sharing

메모리 거짓 공유

Locality 그리고, 거짓 공유
http://rein.kr/blog/archives/906

cache line bouncing


Posted by 엘키 엘키

댓글을 달아 주세요

좋은 프로그램이란, 유휴 시간없이 하고 싶은 일을 최대한 많이 하는 프로그램을 의미합니다.

여기서 중요한 것은, 하고자 하는 일을 많이 해야 된다는 점이죠.

 

싱글 스레드 클라이언트 프로그램의 경우는 대게 아래와 같습니다. 

1. 입력 받는 작업

2. 연산 작업

3. 화면 그리기 

4. 1번으로 돌아감


시간을 재고, 특정 작업 시간이 오래 걸려 재 속도를 내지 못한다면, 연산량을 감소 시킬 수 있는 처리를 하거나 (초당 프레임 조정 등), 만약 연산량을 줄일 수 없는 경우라면 게임 속도가 느려지게 됩니다.

 

연산량을 감소시켜서라도 제속도를 낼 수 있는 임계치를 최소 사양이라고 부릅니다.

 

멀티 스레드 서버 프로그램의 경우는 어떨까요?


처리 스레드 종류에 대한 가정

- 소켓 이벤트 처리 6개 스레드

- 패킷 처리 1개 스레드

디비 처리 6개 스레드

디비 처리 결과 반환 1개 스레드

주기적인 로직 1개 스레드

 

1. 모든 스레드를 자유롭게 동작하도록 풀어놓고 스레드끼리 중복된 데이터를 사용할 일이 있을 때, 동기화 객체를 사용해서 관리해주는 방식(이하스레드 데이터 동기화 방식)

 

네 좋습니다. 그런데, 만약 상호 데이터 교환이 많은 경우는 어떨까요?

A라는 데이터를 모든 스레드에서 요구한다면? A라는 데이터 사용중에는 다른 모든 스레드가 멈춰있겠죠? 싱글 스레드와 다를 것이 없게 됩니다. 

물론 데이터가 겹치는 상황이 적다면 안정적인 속도로, 안정적으로 돌아가겠지만 글쎄요. 지금 당장은 그렇겠지만 기능이 추가되면서 분명히 지속적으로 성능 저하를 가져올 겁니다.

 

2. 로직은 한 스레드에서만 돌리고, 비동기로 이루어져도 되는 처리에 대해서 요청한 후, 그 처리가 끝난 후에 신호를 받아 다시 처리하는 방식 (이하 원스레드 메시지 프로그래밍 방식)


이 방식은 Win32에서의 메시지 프로그래밍 방식과 매우 흡사한 방식이죠.

 

이렇게 했을 경우, 이벤트간 순서 제어도 직관 적이고, 데드락 위험성도 없으며, 비동기 스레드가 몇개 되지 않고 오래 걸리지 않는다면 처리 속도도 좋습니다. 

문제는 싱글 스레드 이상의 효율을 내지 못한다는 것입니다. 로직 스레드에서 해야 될 일이 많다면...? 로직 스레드도 요청을 큐에서 꺼내서 처리하는데, 이 큐에 쌓이는 속도가, 데이터를 처리하는 속도보다 오래 걸린다면 전체적인 처리속도가 계속 늦어져 결국엔 사실상 아무일도 못하는 상태가 될 겁니다.

 

그래서 결국 로직을 멀티스레드로 분리하는 작업이 필요해집니다.

 

3. 로직을 멀티스레드로 처리하고 스레드끼리 중복된 데이터를 사용할 일이 있을 때, 동기화 객체를 사용해서 관리하고, 비동기 작업은 별도 스레드에서 처리하는 방식(이하 멀티 스레드 데이터 동기화 방식)


이렇게 했을 때의 맹점은, 첫번째 방식과 같습니다. 로직 스레드끼리 겹치는 데이터가 많을땐 느려지죠.

 

그래서 로직을 멀티스레드로 하면서 겹치지 않도록 해야 합니다.

 

4. 로직을 멀티스레드로 처리하고, 이 상황에서 스레드끼리 겹칠만한 일들과 비동기 처리를 별도 스레드에 맡기고, 로직 스레드에서는 다른 스레드와 겹치지 않는 일을 함으로써 Lock-Free 한 상태로 스레드를 관리(이하 멀티 스레드 메시지 프로그래밍 방식)

 

두번째 방식이랑 비슷하죠? 비슷하지만 다른 점은, 비동기로 해야 될 일 뿐만 아니라, 스레드끼리 겹칠만한 동작 자체도 별도 스레드로 맡긴다는 점입니다.

그리고 로직 스레드에서는 주의 깊게 (자신에게 주어진 데이터에만 접근하도록) 코딩 하는 것이 중요합니다.

상호 스레드간에 데이터 교환이 메시지 방식으로 이루어지도록 기반이 잘 갖추어져 있다면 위에서 설명한 세가지 방식보다 우월한 처리 효율을 보여줄 수 있습니다.

 

여기서 원스레드던, 멀티스레드던 메시지 프로그래밍 방식을 취했을 때 주의점이 있습니다. 데이터가 쌓이는 속도보다 푸는 속도가 빨라야 지연이 발생하지 않습니다.

만약 푸는 속도가 더 느리다면 스레드를 증가 시켜야 하는데, 여기서 주의 사항이 생깁니다.

스레드간에 데이터 교환을 위한 락이 적게 걸려야만, 스레드 갯수를 증가시켜서 얻는 잇점이 커지는 것이죠.

 


스레드간에 데이터가 쌓이는 속도 대비, 풀리는 속도 측정이 되야 이 데이터 처처리 흐름에 문제가 여부를 알 수 있습니다.

물론 쌓이는 속도와, 풀리는 속도의 효율이 좋다 하더라도, 데이터가 모든 처리되기까지의 시간이 오래 걸린다면 그것도 효율이 좋다고 할순 없습니다.



지금까지 일반적으로 많이 사용되고 있는 스레드 사용 방식의 장단점에 대해서 알아보았습니다.

더 좋은 방법에 대한 논의는 지금도 계속 이루어지고 있고, 의견이 분분하지만, 제 의견과 생각에 대해서 정리를 해보고 많은 분들의 의견을 듣고 싶어 글을 올려봅니다. 의견 있으시면 언제든 댓글이나 메일 주세요. 감사합니다.


Posted by 엘키 엘키

댓글을 달아 주세요

1. 데이터를 동시에 쓰는 상황, 읽는 도중 값이 변경되는 상황, 읽는 도중 delete 되는 상황에 유의하라.
-> 데이터를 동적으로 다뤄야 되는 상황 자체를 줄이는 것이 좋다. NULL 대신 NULL객체 처리를 선호하는 것이 멀티 스레드 프로그래밍에서 크래시를 줄이고 쉽게 예외 핸들링 할 수 있는 방법중 하나다.

2. 생성자 / 소멸자 호출 도중에 가상 함수를 읽지 않게 하라.
-> 가급적 생성자 / 소멸자에선 로직 처리를 금하라. 실패 할 수 있는 동작은 생성자/소멸자에서 시도하지 않는 것이 좋다.

3. 동기화에 대해 주의하라. 
-> 어디서부터 어디까지 공유 데이터인지를 명확히하고, 그 이상의 접근을 막아라.

4. 스레드 마다 별도로 주어지는 공간 (스택), 모든 스레드가 공유하는 공간 (힙, 정적 데이터 영역) 등에 대해 파악하고 코드를 작성하라.
-> 스레드 프로그래밍에서 static 객체는 특히나 자주 말썽을 썩인다. static한 코드를 의심하라.

5. 어설픈 가정은 하지 말라. 데이터가 겹치지 않을 것이라는 가정을 하고 있다면, 실제로 겹치지 않도록, 겹치게 된다면 미리 알 수 있도록 하라.
-> 특히 코딩을 하는 과정에서, 잘못된 스레드 용법이 쉽게 작성 가능한 구조라면, 그 작업 자체가 불가능하거나 감지 되도록 강제하라.

6. 락 정책에 주의하라. 
-> 리터럴 변수에만 사용할 것이라면 Interlockedxxx 계열 함수만 사용하면 된다. 
-> 만약 리터럴이 아니라 구조체나 클래스에서 사용하고, 락이 걸린 영역 내에서 메소드를 호출하게 될 경우 데드락 등의 문제로 악몽을 겪을 수 있으니 주의하자.



Posted by 엘키 엘키

댓글을 달아 주세요

이전버튼 1 이전버튼

블로그 이미지
Software Engineer
엘키

공지사항

Yesterday31
Today27
Total1,605,481

달력

 « |  » 2020.8
            1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31          

글 보관함