-
C# [펌] TCP/IP환경에서 메시지 교환 원리닷넷/C# 2018. 8. 30. 18:22반응형
TCP/IP환경에서 메시지 교환 원리
2016.04.28 06:03 from 프로그래밍 일반/개발 도구 사용법
http://foranie0.tistory.com/199
1. 서두
질문글
답변글
서버 플머가 아니라서 이런 것까지 정리할 생각은 없었는데 어제 술 마시다가 조상현 교수님이 만든 엔진에는 프로토콜 넘버를 기준으로 일일이 switch case 해주는 부분 없이 상속으로 처리 했다는 이야기를 듣고 소스를 까보는 김에 이것까지 작성 하게 되었다. (와중에 내 코드 일부가 틀린 것이 생각 나기도 했다.) 문서 작성시 직전에 설명한 내용까지만 가지고 설명 하고 아래에서 왜 틀렸는가 이야기 하는 방식으로 서술 하였는데 그래도 틀린 부분이 있다면 이야기 해줬으면 한다.
2. 바쁜 현대를 살아가는 F자 리더(reader)를 위한 8줄 요약
1) Send()와 Recv()를 호출 하면 블러킹이 걸려버린다.
2) wsaasyncselect 같은 비동기 함수나 thread를 이용하자.
3) thread를 쓰는 경우 수신/송신/워커 세개로 나눈다.
4) 각 thread는 송신/수신 버퍼를 중간에 둔다.
i. 수신 thread는 수신 버퍼에 데이터를 적재
ii. 워커 thread는 수신 버퍼에 접근하여 메시지를 하적
iii. 워커 thread는 송신 버퍼에 메시지를 적재
iv. 송신 thread는 송신 버퍼에 데이터를 송신
3. 스트림? 메시지? 프로토콜? 헤더?
1) 스트림
파일 입출력을 써본 기억을 되살려 보면 iostream을 include 하고 fstream을 이용해 파일입력을 받을 때 100정도의 버퍼를 미리 잡아 100 단위로 읽었던 기억이 있을 것이다. 대상이 포매팅된 텍스트 파일 이었다면 strtok()으로 토큰을 찾으면서 유의미한 구문을 찾아 파싱을 했을 것이고 바이너리라면 따로 특정 라이브러리에 집어넣어 처리를 했다. 네트워크의 스트림도 동일하다. 유의미한 하나의 구문을 구성하는 길이나 한번에 읽는 버퍼의 길이가 전혀 관계가 없이 일렬로 늘어선 것을 스트림(Stream)이라고 한다.
2) 메시지
위에서 ‘유의미한 구문’ 이라는 단어를 썻는데 ‘유의미한’의 의미는 반대쪽 스트림에서 보낸 것과 동일하고 사용자의 프로그램이 해석할 수 있는 명령 하나를 뜻한다.
예를 들어 특정 사용자의 정보를 조회 하고 싶다는 요청 메시지를 보내기 위해 ’사용자 정보가 필요합니다’, ’사용자의 이름은 우리집 고양이’ 라고 스트림을 통해 보냈다면 송신측 에서 보낸 메시지와 동일하게 ‘사용자 정보가 필요합니다’, ’사용자의 이름은 우리집 고양이’ 라는 메시지를 받을 수 있어야 한다.3) 프로토콜
프로토콜을 사전에 검색해보면 통신의 규칙과 약속이라고 나올 것이다. TCP와 IP의 P와 같은 의미다. 위에서 ‘사용자 정보가 필요합니다’ 라는 메시지를 보낸다고 했는데 이걸 어떻게 보내면 빠르게 실수 없이 보낼 수 있을까? CHAR로 문자열 좀 다뤄 본 사람이라면 C에서 문자 다루는게 정말 괴팍하고 잡아먹는 메모리도 큼을 알고 있을 것이다. (잘 모르겠다면 오래 전에 작성한 유니코드 대충 정복 이라는 글에서 ‘그렇다면 실제 메모리에는 어떻게?’ 문단을 읽어 보자. http://foranie0.tistory.com/131 )
그래서 쉽게 처리 하기 위하여 보내는 측과 받는 측에서 공통의 규칙을 새우는데 그것이 양측이 같은 상수 숫자로 이루어진 프로토콜이다. 당연하지만 서로 다르면 프로토콜이라는 단어가 성립하지 않으니 프로토콜 넘버는 상수이다. ‘사용자 정보가 필요합니다’라는 의미를 가진 프로토콜을 상수 50000으로 정의 하여 전송한다면 이런 형태가 될 것이다. ‘50000우리집고양이\0’4) 헤더
첫 문단에서 네트워크에서 하나의 메세지와 읽은 버퍼의 길이는 전혀 1:1관계가 아니라는 이야기를 했다. 26길이의 메세지를 한번에 보낼 수도, 메세지 네 개를 한번에 보내거나 메시지 100개를 보낼 수도 있고 전송 중 인터럽트가 발생해 뒷부분만 끊길 수도 있다. (내가 실수 한 것으로 추측 되는 부분이 일부 전송 후 죽어버린 상황에 대한 처리다.) 전송 자체는 모두 성공 했다는 가정 하에 ‘50000우리집고양이\0’를 예로 들어보면
(1) ‘500|00우리집고|양이\0’ (|앞 까지가 1회에 전송된 부분.총 3회 전송이 일어남)
(2) ‘50000우리집고양이\0’ (한번에 하나의 구문이 들어온 상태)
(3) ‘50000 우리집고양이\0’ ‘50002우리집강아지\0’ (한번에 두 개의 구문, 혹은 하나 이상의 구문이 들어온 경우)
세가지 혹은 이 이상의 case가 존재할 수 있다. 이를 고려해서 전송하는 메시지의 정보를 묶어서 본문 앞에 실어 보내는데 이것을 헤더라고 한다. 엔진 구현마다 다르지만 헤더에는 보통 전체 패킷 크기, 프로토콜넘버가 들어간다. 밑에서 몇 가지 엔진의 패킷 헤더를 살펴 보겠지만 CGSF의 헤더는 다음과 같이 정의 되어 있다.
typedef struct tag_SFPacketHeader
{
USHORT packetID;
DWORD packetOption;
DWORD dataCRC;
USHORT dataSize;
}
2 + 4 + 4 +2 = 12byte
우리집 고양이를 여기 대입 해보자.
50000 0 0 14 우리집 고양이\0
4. 메시지를 전송하는 과정
전송을 위해 헤더까지 사용했지만 여전히 의문은 남는다. 패킷의 크기는 과연 전송하는 구조체의 크기와 100% 일치할까?
버그가 아니라면 거의 일치한다. 전송시 헤더 뒤 4바이트가 int이면 받았을때의 헤더 뒤 4바이트는 int 값이다.1) 그러나 문자열이 들어가기 시작하면 다시 힘들어진다. 책에서는 중요하게 언급되지만 게임이 고정되고 안정된 플랫폼 위에서 돌아가는 이상 범용 모듈들과 달리 빅 인디안/리틀 인디안 문제나 문자셋이 UTF8냐 ASCII냐 같은 문제가 일어나지는 않는다. 틀려도 합의 하에 고정하면 소프트웨어의 생명이 다할 때까지 좀처럼 바꿀 이유가 없다. 그럼에도 문자 부분 처리가 힘든 이유는 길이가 가변적 이라는 점이다. 그래서 길이가 한정 되어있는 아이디 같은 경우나 엔진에서 길이를 고정하고 있지 않은, 가변적인 문자열에 한해서 문자열 앞에 길이를 전송한다. 다시 위에 메시지를 전송 해보자.
50000 0 0 14 14 우리집 고양이\0
2) 아이디 이므로 고정된 길이20을 가지고 있다면
50000 0 0 14 우리집 고양이\0\0\0\0\0\0\0
3) 문자열 길이를 전송하지 않는 대신 약속된 공간만큼 빈 데이터를 보낸다.
4) 이걸 winsock의 socket로 전송을 하면 이렇게 코딩을 할 것이다.
String message= “50000 0 0 14 14 우리집 고양이\0“
Socket.Send(message.c_str());
5. 이러면 끝일까?
아직은 멀었다. 송수신 할 때 정확한 길이를 보내고 받는 방식은 알아봤지만 여전히 해결되지 않은 문제가 있다. Send() 에서 블러킹 되는 경우 서버가 멈춘다. 당연히 스루풋-CPU가 시간당 처리하는 데이터량- 에 영향을 준다. 물론 수신 단에서 Recv()를 기다리고 있으면 같은 상황이 벌어진다. 작성한 코드 밖에서 일어나는 블러킹의 이유는 여러 가지가 있는데 다 설명하는건 힘들고 네이글 알고리즘, 세그먼테이션등을 찾아보자.
외부글 링크: 네이글 알고리즘
6. 그러므로 패킷 송수신은 thread를 사용.
비동기 입출력을 지원하는 BOOST::ASIO같은 라이브러리나 wsaacyncselect() 같은 함수도 있다. 이쪽을 사용했다면 Send()의 블러킹을 무시해도 되겠지만 (애초에 멈춰 있지를 않으니) 그게 아니라면 thread와 뮤텍스등을 이용해 송수신을 처리 해야 한다.
먼저 send()와 recv()를 기다리는 각각의 스레드를 만들고 메시지를 처리하는 로직 스레드(또는 메인 스레드)가 존재 해야 한다. 그리고 각 스레드간에 메시지 교환을 위한 송신버퍼와 수신 버퍼를 만든다.
여기서는 수신이 일어나는 thread, 송신이 일어나는 thread, 게임 로직 thread 3가지가 존재하고 입출력 메시지 큐를 만들었다고 가정하자.대기중에 Recv가 발생한 경우 수신 버퍼에 저장한다.
로직 thread는 수신 버퍼에 접근하여 메시지가 있는지 확인하고 있는 경우 락을 걸고 복사를 한 다음 수신버퍼에서 해당 내용을 삭제한다.
로직 thread는 작업을 끝내고 응답할 메시지를 송신 버퍼에 보낸다.
송신 thread는 송신 버퍼에 메시지가 있는지 감시하여 있는 경우 Send()한다
7. 비동기 송신에 대하여
비동기의 경우는 조금 간단하다. 신뢰성을 가진 라이브러리를 쓴다면 거의 send(char*, int len) 정도로 처리가 끝나며 덤으로 성능상 tcp버퍼로의 복사도 일어나지 않아 좋은 편이다. 사실 CPP에 Overlapedio는 써본적이 없어서 […] 길게 쓰기가 곤란하다. Mono C#에서는 AsyncCallback로 무난하게 구현 했다.
8. 버퍼에 대하여
여기까지 볼 때 근거는 없지만 thread 사용은 TCP/IP 예제를 잘만 래핑 하면 문제가 모두 해결이 될 것 같은 자신감을 준다. thread를 썻을 뿐 수신에선 recv()에서 대기 하다가 받은 내용을 버퍼에 밀어 넣고 Send()에선 송신 버퍼를 감시하다가 변화가 있으면 Send()해주면 된다. 버퍼를 복사 할 때 lock, 끝나면 free 해주면 thread 경합이 어쩌다 일어나도 큰일이 나지는 않을 것이다. 아직 써본 적이 없지만 버퍼의 구성에 따라 R-W Lock도 좋은 성능을 보여준다고 한다. 이제 버퍼에 대해 고민해보자.
버퍼에 대해서 글을 쓰면서 제일 많이 썻다 지웠다를 반복한 것 같다. 여러 가지 기준으로 버퍼의 종류를 갈라 보았는데 실제로는 좀 더 많은 설명이 필요한 것 같다. 자세한 것은 링크를 참조 해주기 바란다. 설명하고 있는 구조 자체가 OS단에서 처리되는 TCP/IP의 원리와 거의 동일 해서 편리성과 성능을 적절히 판단해야 한다. (개인적으론 불행하게도 이런 판단 기준을 가지고 있지는 못해서..)
외부글 링크 : 패킷 송수긴간 버퍼 관련 질문
외부글 링크 추가 : 패킷 파싱의 방법헤더는 위에서 보았던 CGSF의 헤더 12바이트를 쓰는 걸로 하자. 메시지 처리를 어디서 하느냐에 따라 수신 버퍼의 종류를 두 가지로 나눠 볼 수 있겠다.
1) recv하는 족족 배열에 밀어 넣고 work thread가 헤더를 읽어 바디를 메시지로 만들기
2) recv할 때 무조건 헤더 12바이트는 읽어서(모자라면 대기) 뒤로 오는 바디의 길이를 알아내어 메시지를 수신 버퍼에 큐잉 하고 work thread에서는 메시지만 빼가기
구현도 간단하고 헤더를 즉시 열어볼 수 있다는 점에서 두 번째 형태를 선호한다.
9. 마지막으로 프로토콜
헤더에 프로토콜이 들어있었으니 이제 처리하는 부분으로 넘겨주기만 하면 된다. 다시 우리집 고양이를 인용해보자. (의사 코드 비슷 한거라 소소한건 넘어가자)
Packet* pPacket = Receive.popMsg();//1. 수신 메시지를 받는다.
if (pPacket == nullptr)// 2. 메시지가 없으면 pass.
return 0;
switch (pPacket->protocol)
{
case Mazemaker::REQ_UserInfo://프로토콜 넘버 const static int REQ_UserInfo = 50000 이다.
Packet* pResult = new Packet(Mazemaker::RET_UserInfo);//유저 정보 응답 패킷을 만든다. 쓰고 보니 풀을 만들던지 팩토리 패턴을 쓰던지 해야겠다.
pResult->name = "백수"; //송신할 답변의 내용을 채워 넣는다.
Send.pushMsg(pResult);//송신 큐에 집어 넣는다.
break;
}
함수 포인터나 델리게이트를 쓰면 더 깔끔 해질 것 같다. 이 구조는 메시지가 많지 않은 이상 클라 서버 동일하게 쓰이는 편이다.
10. 정리: 책에선 안알랴쥼
서버 개발자가 아니라서 실무 레벨까지는 설명할 능력이 없지만 프로토 타이핑 할 때나 공부 할때 대부분의 사람이 한번은 막히는 부분에 대해서 정리를 해보았다. 윤성우의TCP/IP를 읽어 보면 기초지식과 기본이 되는 Select 부터 실제 성능이 좋아 널리 쓰이는 iocp나 epoll 그리고 위에서 이야기한 입출력 thread에 대한 부분. 위에서 언급만 했던 비동기 입출력 모델 이나 프토토콜을 정의해서 계산기 서버와 클라를 구현 하는법 등 자세하고 세세하게 코드까지 첨부하여 설명이 되어있다.
그러나 한 발짝만 더 나가면 되는 정도의 내용, 이것과 이것을 조합해서 이렇게 구현하면 팔지는 못해도 놀기에는 충분한 수준의 구현이 됩니다 라고 까지 되어있지는 않은 것이 안타까울 따름이다.11. 각 프레임 워크 별 헤더의 구성
프레임 워크 수준에서 제공 되는 서버 프레임 워크의 헤더는 사용자가 원하는 대로 정의 할 수 있다. 그래서 의미 없을 수도 있지만 대충 이렇게 쓴다는걸 알아두면 자신의 서버에 기능을 추가하는데 도움이 될 것 같다.
1.) OCF (2015에서 컴파일이 안되어 코드를 복사 했다. ㅠㅠ 내 기억으로는 OCF의 헤더가 4바이트로 1바이트는 서비스 타입, 3바이트는 데이터 길이로 구성되고 OS의 버퍼로 넘어가기 전에 마지막으로 헤더를 포함한 전체 길이가 앞에 붙었던 것 같은데… 커스텀인지는 모르겠다.
공식 문서상은 4바이트이다 (서비스 타입1 + 길이 3))
return m_pcNc->Send(
_Cnt_6489E36B_FE72_4444_A8F1_25651607C29D_,
_aul_Bind_2EE5D0E9_E447_4b0a_A4A4_E3651F6B6465_,
_aul_Identity_36A0FC71_7800_4156_B52D_A299FDEB5A25_,
byte _by_ServiceType_9A356872_9AD9_4769_8A6A_F8A4CE471567_,
cTmpOut_F74FAFA4_1595_44e4_A14C_6181269690D4_.GetDataLength())2) CGCII
- CCGBuffer tempBuffer = MEM_POOL_ALLOC(8);
- tempBuffer.append<uint32_t>(8);
- tempBuffer.front<uint32_t>() = tempBuffer.len+SIZE_OF_CRC;
- tempBuffer.append(CRC());
- 4 + 4 = 8byte
3) CGSF- typedef struct tag_SFPacketHeader
- {
- USHORT packetID;
- DWORD packetOption;
- DWORD dataCRC;
- USHORT dataSize;
- }2 + 4 + 4 +2 = 12byte
계속
반응형'닷넷 > C#' 카테고리의 다른 글
C# (Windows 작업스케줄러를 사용하지 않기 위한...) 특정시간마다 이벤트를 실행시키는 방법 (6) 2018.10.19 C# [펌] Parallel.For 병렬 For문 (0) 2018.09.27 C# [펌] Thread 파라미터 전달 (0) 2018.08.24 C# 16진수를 10진수로 변환하기 (0) 2018.08.14 C# 숫자 세자리마다 콤마(쉼표)찍기 (2) 2018.08.02