멀티스레드 정책

최적화된 프로그램이란, 유휴 시간없이 하고 싶은 일을 최대한 많이 하는 프로그램을 의미합니다. 여기서 중요한 것은, 하고자 하는 일을 많이 해야 된다는 점이죠.  

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

  1. 입력 받는 작업
  2. 연산 작업
  3. 화면 그리기 
  4. 1번으로 돌아감

시간을 재고, 특정 작업 시간이 오래 걸려 재 속도를 내지 못한다면, 연산량을 감소 시킬 수 있는 처리를 하거나 (초당 프레임 조정 등), 만약 연산량을 줄일 수 없는 경우라면 게임 속도가 느려지게 됩니다.   연산량을 감소시켜서라도 제속도를 낼 수 있는 임계치를 최소 사양이라고 부릅니다.   멀티 스레드 서버 프로그램의 경우는 어떨까요?

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

  • 소켓 이벤트 처리 6개 스레드
  • 패킷 처리 1개 스레드
  • 디비 처리 6개 스레드
  • 디비 처리 결과 반환 1개 스레드
  • 주기적인 로직 1개 스레드

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

네 좋습니다. 그런데, 만약 상호 데이터 교환이 많은 경우는 어떨까요? A라는 데이터를 모든 스레드에서 요구한다면? A라는 데이터 사용중에는 다른 모든 스레드가 멈춰있겠죠? 싱글 스레드와 다를 것이 없게 됩니다.  물론 데이터가 겹치는 상황이 적다면 안정적인 속도로, 안정적으로 돌아가겠지만 글쎄요. 지금 당장은 그렇겠지만 기능이 추가되면서 분명히 지속적으로 성능 저하를 가져올 겁니다.

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

이 방식은 Win32에서의 메시지 프로그래밍 방식과 매우 흡사한 방식이죠.   이렇게 했을 경우, 이벤트간 순서 제어도 직관 적이고, 데드락 위험성도 없으며, 비동기 스레드가 몇개 되지 않고 오래 걸리지 않는다면 처리 속도도 좋습니다.  문제는 싱글 스레드 이상의 효율을 내지 못한다는 것입니다. 로직 스레드에서 해야 될 일이 많다면…? 로직 스레드도 요청을 큐에서 꺼내서 처리하는데, 이 큐에 쌓이는 속도가, 데이터를 처리하는 속도보다 오래 걸린다면 전체적인 처리속도가 계속 늦어져 결국엔 사실상 아무일도 못하는 상태가 될 겁니다.   그래서 결국 로직을 멀티스레드로 분리하는 작업이 필요해집니다.   3. 로직을 멀티스레드로 처리하고 스레드끼리 중복된 데이터를 사용할 일이 있을 때, 동기화 객체를 사용해서 관리하고, 비동기 작업은 별도 스레드에서 처리하는 방식(이하 멀티 스레드 데이터 동기화 방식)

이렇게 했을 때의 맹점은, 첫번째 방식과 같습니다. 로직 스레드끼리 겹치는 데이터가 많을땐 느려지죠.   그래서 로직을 멀티스레드로 하면서 겹치지 않도록 해야 합니다.   4. 로직을 멀티스레드로 처리하고, 이 상황에서 스레드끼리 겹칠만한 일들과 비동기 처리를 별도 스레드에 맡기고, 로직 스레드에서는 다른 스레드와 겹치지 않는 일을 함으로써 Lock-Free 한 상태로 스레드를 관리(이하 멀티 스레드 메시지 프로그래밍 방식)   두번째 방식이랑 비슷하죠? 비슷하지만 다른 점은, 비동기로 해야 될 일 뿐만 아니라, 스레드끼리 겹칠만한 동작 자체도 별도 스레드로 맡긴다는 점입니다. 그리고 로직 스레드에서는 주의 깊게 (자신에게 주어진 데이터에만 접근하도록) 코딩 하는 것이 중요합니다. 상호 스레드간에 데이터 교환이 메시지 방식으로 이루어지도록 기반이 잘 갖추어져 있다면 위에서 설명한 세가지 방식보다 우월한 처리 효율을 보여줄 수 있습니다.   여기서 원스레드던, 멀티스레드던 메시지 프로그래밍 방식을 취했을 때 주의점이 있습니다. 데이터가 쌓이는 속도보다 푸는 속도가 빨라야 지연이 발생하지 않습니다. 만약 푸는 속도가 더 느리다면 스레드를 증가 시켜야 하는데, 여기서 주의 사항이 생깁니다. 스레드간에 데이터 교환을 위한 락이 적게 걸려야만, 스레드 갯수를 증가시켜서 얻는 잇점이 커지는 것이죠.  

스레드간에 데이터가 쌓이는 속도 대비, 풀리는 속도 측정이 되야 이 데이터 처처리 흐름에 문제가 여부를 알 수 있습니다. 물론 쌓이는 속도와, 풀리는 속도의 효율이 좋다 하더라도, 데이터가 모든 처리되기까지의 시간이 오래 걸린다면 그것도 효율이 좋다고 할순 없습니다.

지금까지 일반적으로 많이 사용되고 있는 스레드 사용 방식의 장단점에 대해서 알아보았습니다. 더 좋은 방법에 대한 논의는 지금도 계속 이루어지고 있고, 의견이 분분하지만, 제 의견과 생각에 대해서 정리를 해보고 많은 분들의 의견을 듣고 싶어 글을 올려봅니다. 의견 있으시면 언제든 댓글이나 메일 주세요. 감사합니다.

멀티스레드 프로그래밍시 유의점

  1. 데이터를 동시에 쓰는 상황, 읽는 도중 값이 변경되는 상황, 읽는 도중 delete 되는 상황에 유의하라.
    • 데이터를 동적으로 다뤄야 되는 상황 자체를 줄이는 것이 좋다. NULL 대신 NULL객체 처리를 선호하는 것이 멀티 스레드 프로그래밍에서 크래시를 줄이고 쉽게 예외 핸들링 할 수 있는 방법중 하나다.
  2. 생성자 / 소멸자 호출 도중에 가상 함수를 읽지 않게 하라.
    • 가급적 생성자 / 소멸자에선 로직 처리를 금하라. 실패 할 수 있는 동작은 생성자/소멸자에서 시도하지 않는 것이 좋다.
  3. 동기화에 대해 주의하라. 
    • 어디서부터 어디까지 공유 데이터인지를 명확히하고, 그 이상의 접근을 막아라.
  4. 스레드 마다 별도로 주어지는 공간 (스택), 모든 스레드가 공유하는 공간 (힙, 정적 데이터 영역) 등에 대해 파악하고 코드를 작성하라.
    • 스레드 프로그래밍에서 static 객체는 특히나 자주 말썽을 썩인다. static한 코드를 의심하라.
  5. 어설픈 가정은 하지 말라. 데이터가 겹치지 않을 것이라는 가정을 하고 있다면, 실제로 겹치지 않도록, 겹치게 된다면 미리 알 수 있도록 하라.
    • 특히 코딩을 하는 과정에서, 잘못된 스레드 용법이 쉽게 작성 가능한 구조라면, 그 작업 자체가 불가능하거나 감지 되도록 강제하라.
  6. 락 정책에 주의하라. 
    • 리터럴 변수에만 사용할 것이라면 Interlockedxxx 계열 함수만 사용하면 된다. 
    • 만약 리터럴이 아니라 구조체나 클래스에서 사용하고, 락이 걸린 영역 내에서 메소드를 호출하게 될 경우 데드락 등의 문제로 악몽을 겪을 수 있으니 주의하자.

(서평) C++ 코딩의 정석 - C++ 프로그래머 필독서의 목록에 올라야 할 책

C++을 익히는 데에 주로 사용되는 대부분의 책들은, 문법에 치중되어 있다. 심지어 The C++ Programming Language 마저 그렇다.

우선 이 책은 서점에서 검토해보고 주문한 책이 아니었다. 이 책을 고르며 생각한 이 책의 방향성은 좋은 코딩 좋은 습관처럼 코딩 규칙이나, 가이드 라인에 대한 책인줄 알았다. 실제로 부제목도 코딩 가이드라인에 대해 언급했고.

하지만 막상 읽어보니 코딩 규칙이나, 가이드 라인보다는 표현의 자유가 강하지만 그 만큼 복잡하고 잘못 사용될 여지가 많은 C++을 객체 지향적으로 잘 활용할 수 있는 방법에 대한 느낌이 더 강했다.

자바가 대세가 된지 한참 되었고, C#도 많이 성장했고, 클라이언트-서버 모델에서 웹으로 흐름이 옮겨간지도 꽤 오랜시간이 흘렀다.

하지만 나에게 있어 C++은 모호함이나 메모리를 직접 다루는 데에 있어서 오는 리스크는 인정하지만, 그런 문제의 소지를 줄이고 잘 사용했을 때 얻을 수 있는 강력함이 워낙 매력적이라 생각하기에, C++을 주력으로 사용하는 것을 포기 할 수 없었다.

그래서 C++을 잘사용하는 데에 집중했고, 나 역시 흔히 추천하시는 Effective 시리즈도 읽게 되었었다.

이 책은 아까 거론한 좋은 코딩 좋은 습관 보다는, More Effective C++이나 Effective C++과 (심지어는 Effective STL과도) 겹치는 부분이 훨씬 많다. 

Effective 시리즈가 한 주제를 자세하게 파고드는 편이라면 이 책은 한 주제에 대해 단순 명료하게 설명해주었다. 

설명이 짧다고해서 이해하기 어렵게 쓰여져 있는게 아니라 내 개인적으론매우 만족했다. 

C++의 바른 사용법에 대해서 한번 더 정리하는 시간이 됐다고 해야 할까?

Effective 시리즈를 이미 읽었던, 읽지 않았던 간에 C++을 잘 쓰고 싶은 많은 프로그래머들에게 읽혀야 할 책이 아닌가 싶다.

UDP Hole Punching

홀 펀칭 (Hole Punching)

  • 정확한 명칭은 STUN (Simple Traversal of User Datagram Protocol Through Network Address Translators)

공유기라는 녀석이 라우터의 특성도 함께 가지고 있어 Routing Table 을 작성하기 위해 P2P 통신을 목적으로, 사전에 상대방과 패킷을 주고받고 하여 각자의 공유기에 Routing Table 을 작성하는 것을 홀 펀칭이라고 한다.

  • Full Cone NAT
    • 내부에 있는 호스트들의 모든 요청은, 모두 같은 외부 ip, port 로 맵핑된다.
    • 더군다나 어떤 외부 호스트든 공인 IP가 맵핑된 패킷 보내기에 의해 내부 호스트로 패킷을 보낸다.
  • Restricted Cone
    • 목적지의 주소에 따라 NAT에 맵핑되는 포트가 달라진다.
    • 홀 펀칭을 위해서는 목적지의 IP만 동일시하여 뚫어주면 목적지의 패킷을 받을 수 있다.
  • Port Restricted Cone
    • 목적지의 주소에 따라 NAT에 맵핑되는 포트가 달라진다.
    • 홀 펀칭을 위해서는 목적지의 IP와 포트를 동일시하여 뚫어주어야만 목적지의 패킷을 받을 수 있다.
  • Symmetric Cone
    • 목적지의 주소와 포트에 따라 NAT에 맵핑되는 포트가 달라진다.

P2P로의 1:1 연결에서는 적어도 한 쪽이 Symmetric Cone NAT 가 아니거나 공인 아이피를 소유하고 있는 Peer 여야 한다.

홀펀칭 방식

  1. Full cone
    • PC에서 UDP 데이터를 공유기 밖으로 보낼 때 해당 PC의 IP와 포트 정보를 공유기가 기억하고 공유기의 포트와 맵핑을 해줌.
    • 공유기의 해당 포트로 데이터가 오면 출발지 IP와 포트 정보를 상관하지 않고 해당 PC에 포워딩을 해줌.
  2. Restricted Cone
    • PC에서 UDP 데이터를 공유기 밖으로 보낼 때 해당 PC의 IP와 포트 정보, 목적지 IP를 기억하고 공유기의 포트와 맵핑을 해줌. 공유기의 해당 포트로 데이터가 오면 출발지 IP정보를 비교하여 공유기에 기록된 목적지 IP와 같으면 해당 PC에 포워딩을 해줌.
  3. Port Restricted Cone
    • PC에서 UDP 데이터를 공유기 밖으로 보낼 때 해당 PC의 IP와 포트 정보, 목적지 IP, Port 를 기억하고 공유기의 포트와 맵핑을 해줌. 공유기의 해당 포트로 데이터가 오면 출발지 IP 정보를 비교하여 공유기에 기록된 목적지 IP, Port 가 같으면 해당 PC에 포워딩을 해줌.
  4. Symmetric NAT
    • PC에서 UDP 데이터를 공유기 밖으로 보낼 때 해당 PC의 IP와 포트 정보, 목적지 IP, Port 를 기억하고 공유기의 포트와 맵핑을 해줌.
    • 만약 목적지 IP나 Port 번호가 바뀌면 새로운 포트로 맵핑해줌.
    • 공유기의 해당 포트로 데이터가 오면 출발지 IP 정보를 비교하여 공유기에 기록된 목적지 IP, Port 가 같으면 해당 PC에 포워딩을 해줌.

구현 방법

UDP 서버 (랑데뷰 피어)로 클라이언트가 UDP 패킷을 전송. 서버에 클라이언트의 IP와 Port 정보가 남는다. 이 정보를 바탕으로 현재 서버에 연결된 소켓에 접속할 IP와 포트 정보만 상대방 IP와 포트 정보를 넣고 상호간에 데이터 전송 시도하면, Cone 방식은 UDP 홀펀칭이 성공한다.

간혹 Symmetric NAT 방식의 공유기가 있는데, 이 공유기 같은 경우에는 같은 소켓을 써도 UDP 데이터를 외부로 쏠 때, 목적지 IP나 포트 정보가 변경되면 공유기에서는 새로운 포트를 할당해 준다.

고로 나가는 것은 되나 들어오는 것이 안 됨.

한 쪽이 Symmetric NAT 방식이라면 상관 없는데 양쪽이 Symmetric NAT 방식이라면 낭패다. UDP 릴레이 서버를 거치던지 다른 방법을 써야한다.

한쪽이 Symmetric NAT 방식이라면 반대쪽에서는 Symmetric NAT 쪽의 데이터를 받을 수 있다. 이 데이터를 받을 때 IP 와 포트 번호를 알아낼 수 있다. 알아낸 IP 와 포트 번호로 데이터 전송하면 됨.

Reliable UDP

Reliable UDP (이하 RUDP)는 신뢰성을 갖는 UDP를 의미합니다.

일반적으로 TCP는 신뢰성을 갖는 대신 느리고, UDP는 신뢰성이 없고 빠르다고 알려져있죠.

여기에 또 하나의 특징은, TCP는 서버 (Listener)와, 클라이언트 (Connector) 관계가 성립한다는 점입니다.

즉, 서버건 클라이언트건 연결 관리가 필요하다는 것이죠.

RUDP의 필요성은, 주로 클라이언트 끼리의 통신에서 대두되었습니다.

우선 일반적인 클라이언트/서버 구조에서의 클라이언트 끼리의 통신은 서버를 경유해서 데이터를 전송함으로써 신뢰성을 갖추는데, UDP보다 느리고 서버에 부하를 주기 때문에 클라이언트 끼리의 통신에서도 TCP의 장점은 신뢰성과, UDP의 장점은 속도를 모두 갖춘 Reliable UDP가 등장하게 된 것이죠.

TCP는 한쪽이 서버가 되어서 대기 하고 있어야하지만, UDP는 그럴 필요가 없이 바로 통신이 가능하다는 점도 또 하나의 장점입니다.

보통 RUDP는 Relay Server와 연동되어서 구현이 되는데요, 모든 상황에서 UDP 통신이 가능한 것이 아니기 때문에, UDP 통신이 실패했을 때 신뢰성 갖춘 통신을 위해 Relay Server를 통한 데이터 전송을 해주기 위해서입니다.

우선 UDP가 Reliable 해지기 위한 방법부터 알아봅시다.

TCP가 UDP와 다른 점은 신뢰성이 있다고 얘기했었죠? TCP가 신뢰성을 갖추고 있는 이유를 먼저 얘기해보겠습니다.

  1. 데이터의 순서를 보장해줍니다. 보낸 순서와 받는 순서가 일치하게 해준다는 의미입니다.
  2. 데이터의 도착을 보장해줍니다. 보낸 데이터가 반드시 도착한다는 것을 보장해준다는 의미입니다.
  3. 데이터의 무결성을 보장해줍니다. 즉, 보낸 데이터와 받는 데이터가 일치 하다는 것을 보장한다는 의미입니다.

위의 세가지 조건을 만족하기에 TCP가 신뢰성을 갖추고 있다고 말하는 것이고, RUDP도 마찬가지로 위 세가지 조건을 UDP를 통해 만족하도록 구현함으로써 신뢰성을 갖게 되는 것이죠.

순서를 보장하는 방법은 패킷에 번호를 붙이고, 번호순서대로 패킷이 도착할때까지 기다렸다가 패킷이 모두 모이면 그때 패킷을 풀면됩니다.

도착을 보장하는 방법은 패킷에 번호를 붙이고, 해당 번호의 패킷이 도착할때까지 재전송하면 됩니다.

보내는 입장에서 재전송을 하는 이유는, UDP이기에 받는 입장에서는 자신에게 보내려는 패킷이 있었는지 알 방법이 없기 때문이죠.

무결성을 보장하는 방법은 체크섬을 통해서, 데이터가 손실되지 않았는지 검증합니다.

RUDP사용시 주의사항은, UDP로 연결을 시도하는 중에도 릴레이 서버를 통한 패킷 전달은 지속적으로 이루어 져야 한다는 점입니다.

그리고 UDP연결이 성공했을 때 UDP를 통한 송신을 시작해야 합니다.

UDP 송수신 중에는 연결 유지를 위해 일정 시간 간격으로 HeartBeat 패킷을 보내고, 일정 시간동안 해당 패킷이 도착하지 않는다면 UDP전송이 다시금 불가능해진 것으로 판단하여, 릴레이 서버를 이용하도록 하면서 지금껏 전송 확인이 안된 패킷들을 릴레이를 통해 전달하면 됩니다. 이렇게 해야 패킷의 지연은 있을 수 있으나 패킷의 소실은 발생하지 않습니다.

Reliable UDP (이하 RUDP)는 신뢰성을 갖는 UDP를 의미합니다.

일반적으로 TCP는 신뢰성을 갖는 대신 느리고, UDP는 신뢰성이 없고 빠르다고 알려져있죠.

여기에 또 하나의 특징은, TCP는 서버 (Listener)와, 클라이언트 (Connector) 관계가 성립한다는 점입니다.

즉, 서버건 클라이언트건 연결 관리가 필요하다는 것이죠.

RUDP의 필요성은, 주로 클라이언트 끼리의 통신에서 대두되었습니다.

우선 일반적인 클라이언트/서버 구조에서의 클라이언트 끼리의 통신은 서버를 경유해서 데이터를 전송함으로써 신뢰성을 갖추는데, UDP보다 느리고 서버에 부하를 주기 때문에 클라이언트 끼리의 통신에서도 TCP의 장점은 신뢰성과, UDP의 장점은 속도를 모두 갖춘 Reliable UDP가 등장하게 된 것이죠.

TCP는 한쪽이 서버가 되어서 대기 하고 있어야하지만, UDP는 그럴 필요가 없이 바로 통신이 가능하다는 점도 또 하나의 장점입니다.

보통 RUDP는 Relay Server와 연동되어서 구현이 되는데요, 모든 상황에서 UDP 통신이 가능한 것이 아니기 때문에, UDP 통신이 실패했을 때 신뢰성 갖춘 통신을 위해 Relay Server를 통한 데이터 전송을 해주기 위해서입니다.

우선 UDP가 Reliable 해지기 위한 방법부터 알아봅시다.

TCP가 UDP와 다른 점은 신뢰성이 있다고 얘기했었죠? TCP가 신뢰성을 갖추고 있는 이유를 먼저 얘기해보겠습니다.

  1. 데이터의 순서를 보장해줍니다. 보낸 순서와 받는 순서가 일치하게 해준다는 의미입니다.
  2. 데이터의 도착을 보장해줍니다. 보낸 데이터가 반드시 도착한다는 것을 보장해준다는 의미입니다.
  3. 데이터의 무결성을 보장해줍니다. 즉, 보낸 데이터와 받는 데이터가 일치 하다는 것을 보장한다는 의미입니다.

위의 세가지 조건을 만족하기에 TCP가 신뢰성을 갖추고 있다고 말하는 것이고, RUDP도 마찬가지로 위 세가지 조건을 UDP를 통해 만족하도록 구현함으로써 신뢰성을 갖게 되는 것이죠.

순서를 보장하는 방법은 패킷에 번호를 붙이고, 번호순서대로 패킷이 도착할때까지 기다렸다가 패킷이 모두 모이면 그때 패킷을 풀면됩니다.

도착을 보장하는 방법은 패킷에 번호를 붙이고, 해당 번호의 패킷이 도착할때까지 재전송하면 됩니다.

보내는 입장에서 재전송을 하는 이유는, UDP이기에 받는 입장에서는 자신에게 보내려는 패킷이 있었는지 알 방법이 없기 때문이죠.

무결성을 보장하는 방법은 체크섬을 통해서, 데이터가 손실되지 않았는지 검증합니다.

RUDP사용시 주의사항은, UDP로 연결을 시도하는 중에도 릴레이 서버를 통한 패킷 전달은 지속적으로 이루어 져야 한다는 점입니다.

그리고 UDP연결이 성공했을 때 UDP를 통한 송신을 시작해야 합니다.

UDP 송수신 중에는 연결 유지를 위해 일정 시간 간격으로 HeartBeat 패킷을 보내고, 일정 시간동안 해당 패킷이 도착하지 않는다면 UDP전송이 다시금 불가능해진 것으로 판단하여, 릴레이 서버를 이용하도록 하면서 지금껏 전송 확인이 안된 패킷들을 릴레이를 통해 전달하면 됩니다. 이렇게 해야 패킷의 지연은 있을 수 있으나 패킷의 소실은 발생하지 않습니다.