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

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

서버 프로그래밍을 시작한 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 엘키 엘키

댓글을 달아 주세요

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

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


서버 프로그래밍을 시작하면서 멀티 스레드를 다루기 시작했고 만 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 엘키 엘키

댓글을 달아 주세요

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

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

 

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

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 엘키 엘키

댓글을 달아 주세요


C 프로그래머가 알아야 할 것들 - Chapter 8 스레드

성훈 (sunghun84@nate.com) 

(1) 프로세스와 스레드
스레드를 이해하려면 프로세스에 대한 이해가 선행되어야 합니다. 프로세스란 프로그램이 실행되는 단위를 말합니다. 지금 제가 이 문서를 작성하고 있는 OpenOffice도 프로세스고, 음악을 듣고 있는 aimp2도 프로세스, 메신져인 pidgin 모두 프로세스입니다. 일반적으로 프로그램의 실행 단위가 프로세스라고 보시면 됩니다. (한 프로그램 내에 여러 프로세스를 묶어 하나처럼 보이게 하는 경우도 있지만, 이런 경우는 예외로 생각하겠습니다.)
 
저는 지금 메신져를 켜놓고, 음악을 들으며 문서 작성을 하고 있는데요, 이렇게 세가지 작업을 한꺼번에 할 수 있는 것은, 윈도우즈가 멀티 프로세스를 지원하기 때문입니다
.

반면 윈도우즈 자체는 싱글 프로세스입니다. 메모리에 대한 소유권, CPU 제어권을 혼자 독점합니다. 그렇게 독점한 자원을 바탕으로, 프로세스들을 실행/관리 해주는 것이죠
.

그렇다면 스레드는 무엇일까요? 프로세스 내의 실행 단위를 말합니다. C언어 계열의 프로그램은 기본적으로 main() 함수에서 시작합니다. 그리고, main()함수 에서부터 순차적으로 실행되죠. main()부터 시작되는 스레드를 메인 스레드라 부릅니다
.

메인 스레드 외에 다른 스레드와 교차되어 실행 되는 것이 멀티 스레드 입니다. 여기서 키 포인트는 '교차' 입니다. 스레드가 교차되어 실행 되기에 파생되는 문제점 들에 대해서 자세히 얘기해보죠
.

(2)
멀티 스레드에서 교차 실행에 따른 문제점
교차된다는 게 무엇일까요? 직렬이 아니라 병렬이라는 뜻이겠죠? 스레드를 여러개 만든 다는 것은 코드의 실행 흐름을 여러개 만든다는 의미가 됩니다. main()에서 시작되서, main()내의 코드 흐름만을 따르는게 아니라, 스레드를 만드는 수 만큼 코드 흐름이 늘어나는 것이 되죠.

흐름을 여러개 만들어놓고, 한 흐름이 끝날 때까지 기다려야 한다면 직렬. 즉 단일 스레드와 전혀 다를바 없어집니다
.

그래서, 각 스레드가 실행 되던중에, 다른 스레드로 실행 흐름이 옮겨가게 되고, 이 것을 교차라고 표현한 것입니다.

#include <Windows.h>

 

int g_nTest = 0;

 

DWORD WINAPI IdleThread(void *lpVoid)

{

             while(g_nTest < 30)

             {

                           printf("%s %d\n", __FUNCTION__, ++g_nTest);

             }

             return 0;

}

 

int _tmain(int argc, _TCHAR* argv[])

{

             HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

             while(g_nTest < 30)

             {

                           printf("%s %d\n", __FUNCTION__, ++g_nTest);

             }

 

             if(hThread != INVALID_HANDLE_VALUE)

             {

                           WaitForSingleObject(hThread, INFINITE);

                           CloseHandle(hThread);

             }

 

             return 0;

}


위 코드는 스레드를 하나 추가로 생성하고, 전역 변수를 0에서 시작해서 100이 될 때까지 실행 되도록 한 코드입니다.

실행 결과는 어떨까요?



어떤 스레드에서 g_nTest값을 썼던간에, g_nTest에 해당 하는 값은 1~30까지 순차적으로 찍혀야 된다고 생각하시죠? 하지만 실제 수행 결과는 차이가 있습니다.

wmain() (= main().
이하 main()) while문이 실행 되던중 g_nTest 변수가 13이었을 때, IdleThread printf 구문이 실행되었고, g_nTest변수를 ++g_nTest 시킨 상태에서 printf()에 값을 전달했으나 아직 printf()가 실행되지 않은 상태에서 코드 흐름이 main()으로 다시 넘어가 30까지 실행 되었습니다
.

그리고 다시 코드 흐름이 IdleThread()로 넘어오게 오게 되어, printf()함수에 넘겼던 %d에 해당하는 값인 14가 출력되고 프로그램이 종료 되었죠
.

여기서
핵심 g_nTest 변수가 13이었을 때 IdleThread ++g_nTest 구문이 실행되었다는 점입니다. main스레드의 while 루프를 돌던 중에, 코드 수행이 IdleThread로 옮겨 간 것이죠.
정확히 하자면 스레드는 병행 수행이 아니라, 스레드의 수만큼 실행 흐름을 만들고스레드의 동작 도중 다른 스레드로 실행 흐름이 바뀐다는 것입니다
.

그런데 이 스레드의 교차 수행으로 인해 장점이자 단점이 생깁니다. 뭘까요?

(3)
교차와 메모리 공유에서 오는 문제들

멀티 스레드라고 해도, 메모리 할당은 프로세스 단위로 이루어지기 때문에 같은 메모리를 공유하게 되어있습니다.

게다가 교차되서 실행 되기에 같은 메모리에 덮어 썼을 때 실행 결과가 예상과 달라질 수 있게 됩니다
.

예제 코드를 보시죠.

#include <Windows.h>

 

int *g_pTest = new int(0);

 

DWORD WINAPI IdleThread(void *lpVoid)

{

             while(g_pTest && *g_pTest < 20)

             {

                           printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);

                           (*g_pTest)++;

             }

             delete g_pTest;

             g_pTest = NULL;

             printf("%s %p delete g_pTest\n", __FUNCTION__, g_pTest);

             return 0;

}

 

int _tmain(int argc, _TCHAR* argv[])

{

             HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

             while(g_pTest && *g_pTest < 20)

             {

                           printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);

                           (*g_pTest)++;

             }

 

             if(hThread != INVALID_HANDLE_VALUE)

             {

                           WaitForSingleObject(hThread, INFINITE);

                           CloseHandle(hThread);

             }

 

             getchar();

             return 0;

}


이 코드는 메모리 공유로 인한 문제를 보여줍니다. g_pTest IdleThread에서, 20까지 증가 시킨 후, g_pTest를 초기화 합니다. main()함수에는 g_pTest가 유효할때만 로그를 찍고, 값을 1 증가 시켜주죠.

하지만, IdleThread가 먼저 종료되고 난 후, 메인 스레드로 복귀 했을때, 수행중이었던 첫번째 printf은 무난하게 넘어가지만 (스택에 이미 값을 넘긴 이후기에), 두번째 printf에서, (*g_pTest)에 ++를 하려다 NULL포인터 접근 오류가 발생했습니다.

 



이상하죠? 메모리 공유나 교차 문제가 아니라면, while(g_pTest)에서 널포인터 검사를 했기에 유효한 포인터여야 할텐데 라고요.

하지만, g_pTest가 전역 데이터이기에 쓰레드간에 공유됩니다. 그래서 main()쓰레드가 첫번째 printf함수에 값을 전달해놓은 상태에서 멈췄고, IdleThread가 수행되며 g_pTest가 NULL이 되고 난 후에야 main()로 코드 흐름이 돌아왔고, 그래서 NULL포인터 접근 오류가 발생 한 것입니다.
(이 예제 코드 수행 시 항상 NULL포인터 접근 오류가 발생하진 않다는 점에 유의하십시오.)


현재 두가지 경우 모두다 전역 변수/포인터로 설명했지만, 함수에 포인터로 전달된 값들도 같은 문제를 안고 있습니다.

그러면 이 문제를 해결하기 위해 어떻게 해야 할까요? 이 문제의 해결책은 바로 스레드 동기화입니다.

(4) 스레드 동기화
우선 우리의 문제부터 다시 정리해보면 공유된 메모리를 사용하는데, 다른 스레드가 언제 끼어들지 몰라 어떤 값이 언제 변할지 모른다는 점이다.

이 문제를 해결하기 위해 일반적으로 어떤 값에 대한 사용이 끝나기 전까지는, 그 값을 사용하려는 다른 스레드가 멈춰있게 함으로써 그 값에 대한 신빙성을 유지해주는 방법을 쓴다.

이 방식으로 데이터 신빙성을 유지해주는 방법을 크리티컬 섹션 동기화 방식 (이하 CRITICAL_SECTION또는 크리티컬 섹션)라고 합니다.
다음 코드를 보시죠.

#include <Windows.h>

 

CRITICAL_SECTION cs;

int *g_pTest = new int(0);

 

DWORD WINAPI IdleThread(void *lpVoid)

{
            
printf("%s Start\n", __FUNCTION__, g_pTest);

             EnterCriticalSection(&cs);

             while(g_pTest && *g_pTest < 20)

             {

                           printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);

                           (*g_pTest)++;

             }

             delete g_pTest;

             g_pTest = NULL;

             printf("%s %p delete g_pTest\n", __FUNCTION__, g_pTest);

             LeaveCriticalSection(&cs);

             return 0;

}

 

int _tmain(int argc, _TCHAR* argv[])

{

             InitializeCriticalSection(&cs);

             HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

             EnterCriticalSection(&cs);

             while(g_pTest && *g_pTest < 20)

             {

                           printf("%s %p %d\n", __FUNCTION__, g_pTest, *g_pTest);

                           (*g_pTest)++;

             }

             LeaveCriticalSection(&cs);

 

             if(hThread != INVALID_HANDLE_VALUE)

             {

                           WaitForSingleObject(hThread, INFINITE);

                           CloseHandle(hThread);

             }

 

             DeleteCriticalSection(&cs);

             getchar();

             return 0;

}


이 코드는 크리티컬 섹션으로 데이터 사용중임을 다른 스레드에 알린다는 점을 제외하고는 위 예제와 동일합니다.

그리고 이렇게 수정함으로써 메모리 접근 오류도 발생하지 않게 되죠
.

코드 수행 결과부터 보시죠.

























EnterCriticalSection() 은 같은 CRITICAL_SECTION 객체를 사용하지 않는 상태에서만 수행 됩니다. 어딘가에서 사용할려는 CRITICAL_SECTION
객체가 사용중이라면, 그 사용이 끝날때까지 대기 합니다.

그래서 IdleThread가 수행되려 했음에도 불구하고, main스레드의 while문이 종료 될때까지 대기한 후, 종료 되고 나서야 IdleThread가 실행 되었죠.

여기서 중요한 것은
g_pTest에 변경을 시도하는 코드들이 모두 EnterCriticalSection()과,  LeaveCriticalSection()로 감싸져 있다는 점입니다.

만약 지금의 수행결과와 다르게 IdleThread가 main스레드보다 먼저 실행되어, IdleThread에서 g_pTest를 NULL로 만든 후에 main스레드로 돌아온다고 해도, while문 실행 도중에 main스레드로 돌아오는 것이 아니라, while문의 시작부분 g_pTest의 포인터 유효성 검사 코드 부터 수행 되어, NULL포인터 접근 하는 일은 없게 됩니다.

정리하자면, CRITICAL_SECTION 방식의 동기화는 “내가 데이터 사용 끝낼 때 까지, 다른 녀석들은 쓰지마!”로 요약할 수 있는 것이죠.

이 방식 이외에도 이벤트, 세마포어, 뮤텍스 등을 이용한 동기화 방법들이 존재하지만, 커널 모드/ 유저 모드에 대한 설명도 필요하고, 이 강좌에서 집중하려는 내용에서 벗어나기에 해당 내용은 차 후에 다루도록 하겠습니다.

그렇다면...이렇게 여러 문제가 있어, 동기화도 해주어야 하는 번거로운 녀석인 멀티 스레드가 필요할 때와, 필요하지 않을 때는 과연 언제 일까요?

(5) 멀티 스레드를 사용해야 할 때
몇년전만해도 멀티 스레드 프로그램은 지금보다 훨씬 적은 수 였습니다.

멀티 스레드가 다루기 어렵기 때문에 포기한 경우도 적지 않았는데요, 위에서 말한  문제를 비롯해서 데드락, 스레드 동기화를 위한 대기로 인한 성능 저하, 스레드 동기화 실패 등의 문제등이 발생할 수 있죠.

많은 문제 발생의 소지가 있음에도 불구하고 현재의 많은 프로그램들은 멀티 스레드를 선택하고 사용하고 있는데 그 이유는 멀티 스레드가 가진 장점들 때문이죠.

그 장점중 하나로는 블럭 함수들은 스레드 단위로 블럭에 걸린다는 것입니다.

다음 코드를 보시죠.

#include <Windows.h>

 

#pragma comment(lib, "winmm.lib")

 

int _tmain(int argc, _TCHAR* argv[])

{

             printf("Are you want to quit? You type 'Q' or 'q'\nIf you want Replay? You type other key.\n");

             while(1)

             {

                           sndPlaySound(_T("Play.wav"), SND_SYNC);

                           char key =            getchar();

                           if(key == 'q' || key == 'Q')

                                        break;

             }

             return 0;

}

별도로 스레드를 만들어주지 않았기에 싱글 스레드 코드입니다. 이 코드의 경우 sndPlaySound()의 음악 재생이 끝날 때 까지 리턴되지 않습니다. 음악 재생이 끝난 이후라도 getchar()가 블럭 함수이기 때문에, 키 들어오기전는 멈춰있게 되죠. 둘다 블럭되는 동작이기에 서로 동시에 이루어질 수 없죠.

그럼 멀티 스레드는 어떻게 될까요?

#include <Windows.h>

 

#pragma comment(lib, "winmm.lib")

 

DWORD WINAPI IdleThread(void *lpVoid)

{

             while(1)

             {

                           sndPlaySound(_T("Play.wav"), SND_SYNC);

             }

             return 0;

}

 

int _tmain(int argc, _TCHAR* argv[])

{

             HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

             printf("Are you want to quit? You type 'Q' or 'q'. If you type other key, Sound Plays is Loop.\n");

             while(1)

             {

                           char key =            getchar();

                           if(key == 'q' || key == 'Q')

                                        break;

             }

 

             if(hThread != INVALID_HANDLE_VALUE)

             {

                           WaitForSingleObject(hThread, 1000);

                           CloseHandle(hThread);

             }

             return 0;

}

IdleThread는 계속 음악 재생 역할을 하고 있고, 메인 스레드는 루프가 돌면서 q또는 Q가 입력 되길 기다리고 있습니다. 멀티 스레드에서는 음악 재생중임에도 불구하고 키 입력이 가능해지는 것을 아실 수 있습니다.

이처럼 멀티 스레드를 쓴다는 것 자체가 한 스레드가 블럭 상태에 놓여있어도 다른 스레드는 영향을 받지 않는다는 큰 장점을 가지고 있습니다.

현재는 음악 재생이었지만, 큰 용량의 파일을 읽어서 분석한다고 해봅시다. 그 파일을 완전히 읽고 분석을 끝내기 전까지 그 데이터는 의미를 갖지 못합니다.

그런데 굳이 프로그램 전체가 멈춰가며 기다려야만 할까요? 아니죠?

그럴때 스레드를 생성한 후 생성한 스레드는 파일 로딩에만 집중하도록 한 후, 다른 스레드는 기존에 하던일을 처리하도록 만들면 로딩이 끝날 때까지 다른일을 할 수 있게 됩니다.

그런데...이렇게 좋은 스레드가 뭐가 문제라는 걸까요??

(6) 멀티 스레드를 쓰는 것이 독이 될 때

스레드가 실행 흐름이 여러개로 나뉘어진다고 했죠? 그 여러개의 실행 흐름을 갖기 위해서, 또 동시에 실행되기 위해선 스택과 스레드 제어 블록(어떤 스레드가 먼저 실행 되어야 하는지 등의 다른 스레드와의 관계 등에서 쓰이는 데이터)을 스레드마다 가져야 합니다.

스레드가 하나 추가 될 때 마다 스택과, 스레드 제어 블록도 같이 생성 되는데, 이로 인한 부하가 사실 만만치않습니다. 또한, 스레드 간 스위칭 (다른 스레드로 실행 흐름이 옮겨지는 것을 스위칭이라 합니다)에도 적지 않은 비용이 듭니다.

게다가 동기화를 잘못 했을 경우, 다른 스레드를 기다리는 시간이 길어져 속도가 저하되는 문제도 발생할 수 있죠.

크리티컬 섹션이 데이터 사용 끝낼 때 까지 다른 사람은 건드리지말라는 의미라 말씀드렸죠? 크리티컬 섹션을 쓴다는 것은, 같은 크리티컬 섹션 객체를 사용하는 다른 스레드가 대기 상태에 빠지게 합니다. 즉 해당 스레드가 그 시간만큼 기다려야 한다는 것이죠.

#include <Windows.h>

 

#pragma comment(lib, "winmm.lib")

 

CRITICAL_SECTION cs;

 

DWORD WINAPI IdleThread(void *lpVoid)

{

             while(1)

             {

                           EnterCriticalSection(&cs);

                           sndPlaySound(_T("Play.wav"), SND_SYNC);

                           LeaveCriticalSection(&cs);

             }

             return 0;

}

 

int _tmain(int argc, _TCHAR* argv[])

{

             InitializeCriticalSection(&cs);

             HANDLE hThread = CreateThread(NULL, 0, ::IdleThread, NULL, 0, NULL);

             printf("Are you want to quit? You type 'Q' or 'q'. If you type other key, Sound Plays is Loop.\n");

             while(1)

             {

                           EnterCriticalSection(&cs);

                           char key =            getchar();

                           if(key == 'q' || key == 'Q')

                           {

                                        LeaveCriticalSection(&cs);

                                        break;

                           }

                           LeaveCriticalSection(&cs);

             }

 

             if(hThread != INVALID_HANDLE_VALUE)

             {

                           WaitForSingleObject(hThread, 1000);

                           CloseHandle(hThread);

             }

             DeleteCriticalSection(&cs);

             return 0;

}

 

main스레드의 getchar()가 리턴되기 전까지, IdleThread의 sndPlaySound()가 실행 될 수 없고, IdleThread의 sndPlaySound()가 리턴 될 때 까지 main스레드의 getchar()가 실행 될 수 없습니다.

동기화 객체의 사용이 끝났다고 알려주기 전까지는 다른 스레드는 대기 상태에 빠질 수 밖에 없습니다. 현재는 두개의 스레드이기 때문에 '아...상호 연관 데이터가 없네? 그럼 크리티컬 섹션을 쓰지 말자'라거나, '블럭함수니까 크리티컬 섹션으로 감싸면 안되겠구나' 라는 판단을 바로 내릴 수 있지만, 스레드가 늘어나고 로직이 복잡해지면 이런 판단을 쉽게 내리기 어려워집니다.

그래서 멀티 스레드에 대한 고민은 코드 작성에서 중요한 부분을 차지하고, 동기화에 실수가 생겼을 때 파장이 크기 때문에 더 신중 해야 합니다.

하지만 이보다
더 큰 문제는 데드락 입니다. 수행 속도 저하는 어쨋거나 코드가 정상적으로 수행은 됩니다. 하지만, 데드락의 경우는 프로그램이 아예 정지해 버리죠.
아! 데드락이 뭐냐고요? 데드락이란, 한글로는 교착 상태라고 표현하곤 하는 상태를 말하는데요, 크리티컬 섹션 객체를 사용하는 동기화를 설명 드릴 때, ‘내가 이 데이터 사용을 끝내기 전에 다른 스레드는 사용하지마’라는 의미를 가진다고 말씀 드렸죠? 반대로 자신의 관점에서도 어떤 스레드가 내가 사용하려는 데이터를 사용 중이라면, 사용을 끝낼 때까지 기다려야 합니다.

 

그런데 만일 대기 중인 이유가, 내가 사용중인 데이터를 기다리기 때문이라면 어떻게 될까요? 이럴 경우 서로 기다리기만 할 뿐 (상호 대기 상태라 합니다) 더 이상 아무 일도 할 수 없는 상태가 되어버리죠. 이 상태가 바로 데드락(DeadLock: 교착) 상태입니다.

그래서 데드락에 빠지지 않기 위해, 큰 작업 분류 별로 스레드를 나눈 후 블럭이 걸리는 일은 별도의 스레드를 만들어 돌리고, 각 스레드 별로 처리한 결과를 다른 스레드에 전달 하거나 받아야 할 때에만 메시지 큐 등을 통해서 전달 받아 처리하는 방법이 사용되곤 합니다.

이는 윈도우즈 API에서도 내부적으로 사용하는 방식이기도 한데요, 타이머 처리나 윈도우 메시지, 예제로 사용했던
sndPlaySound()함수의 두 번째 인수로 SND_ASYNC가 사용될 경우 백그라운드 작업을 통해 처리하고 있다가, 그 결과를 어플리케이션에 알려줘야 할 경우 메시지 큐 등을 통해 전달하는 것과 같은 방식입니다.

 

이런 처리 방식에서 핵심은 동기화가 필요한 상황 자체를 줄이는 데에 있습니다. 동기화가 필요한 상황자체가 적다면 다른 스레드를 기다려야 하는 상황/시간도 줄어들고 그로 인한 효율성 낭비가 줄어든다는 것에 기반하고 있습니다.

세상 어떤 일에도 절대, 최고의 방법은 존재하지 않습니다. 제가 설명 드린 방식보다 더 좋은 멀티 스레드 활용법에 대해 한번쯤 고민 해보시는 건 어떨까요?

'C++ > General' 카테고리의 다른 글

32bit Windows 메모리 관리  (0) 2009.03.27
참조자와 포인터  (2) 2009.03.19
C 프로그래머가 알아야 할 것들 - Chapter 8 스레드  (4) 2009.01.18
유용한 매크로  (0) 2008.11.19
C, C++ 코드 작성시 좋은 습관  (0) 2008.08.04
IEEE Standard 754 Floating Point Numbers  (2) 2008.04.01
Posted by 엘키 엘키

댓글을 달아 주세요

  1. JLee 2009.06.02 11:21  댓글주소  수정/삭제  댓글쓰기

    필요한 글 잘 봤습니다. 퍼가도 될까요?

  2. Ssano 2009.12.16 06:49  댓글주소  수정/삭제  댓글쓰기

    잘 읽었습니다. 감사합니다. ^^

  3. Favicon of https://94sulli.tistory.com BlogIcon 진리의IT 2015.08.18 05:36 신고  댓글주소  수정/삭제  댓글쓰기

    쉽게설명되어있네요 감사합니다.

이전버튼 1 이전버튼

블로그 이미지
Software Engineer
엘키

공지사항

Yesterday31
Today29
Total1,605,483

달력

 « |  » 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          

글 보관함