본문 바로가기

- Programming/- C#

★ 2. c# 네트워크 개발 p2

반응형

==================================================================

모든 출처는

- 유니티 개발자를 위한 C#으로 온라인 게임 서버 만들기 - 저자 이석현, 출판사 한빛미디어

그리고 URL :

http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=61

==================================================================


C#으로 게임 서버 만들기 - 2. 접속 처리 및 버퍼 풀링 기법


1-4-1. 클라이언트 접속 이후의 처리


CNetworkService 세부 구현 내용

Accept처리가 완료되었을 때 on_new_client델리게이트를 호출해주는 부분까지가 CListener 클래스의

역할이었습니다. 이제 CNetworkService 클래스에 대해 좀 더 자세히 들어가보도록 하겠습니다.

앞의 CNetworkService 클래스에는 네트워크 기반이 되는 코드가 들어갑니다.

대표적인 기능은 다음과 같습니다


CListener를 생성하여 클라이언트의 접속 처리

SocketAsyncEventArgs 객체를 풀링하여 재사용 가능하도록 구현

메시지 송수신 버퍼를 풀링하는 매니저 클래스 구현


listener 생성 부분입니다.


1
2
3
4
5
6
public void listen(string host, int port, int backlog)
{
    this.client_listener = new CListener();
    this.client_listener.callback_on_newclient += on_new_client;
    this.client_listener.start(host, port, backlog);
}
cs


listener 생성 부분입니다.

서버의 host, port, backlog 값을 전달 받아 listener를 생성한 뒤 start 메소드로 접속을 기다립니다.


SocketAsyncEventArgs 풀링 구현

소켓별로 두 개의 SocketAsyncEventArgs가 필요합니다.

하나는 전송용, 다른 하나는 수신용입니다.

SocketAsyncEventArgs마다 버퍼를 필요로 하는데 결국 하나의 소켓에 전송용 버퍼 한개,

수신용 버퍼 한개 총 두 개의 버퍼가 필요하게 됩니다.

먼저 SocketAsyncEventArgs를 어떻게 풀링하여 사용하는지 알아보겠습니다.


1
2
3
4
5
6
7
public class CNetworkService
{
    // 메시지 수신용 풀
    SocketAsyncEventArgsPool receive_event_args_pool;
    // 메시지 
    SocketAsyncEventArgsPool send_event_args_pool;
}
cs


전송용, 수신용 풀 객체를 각각 선언합니다.

풀링에 사용되는 클래스는 SocketAsyncEventArgsPool입니다.

이 코드는 msdn에 있는 샘플 코드 그대로입니다.


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
32
33
34
35
36
37
38
39
40
41
42
43
// Represents a collection of reusable SocketAsyncEventArgs objects.
class SocketAsyncEventArgsPool
{
    Stack<SocketAsyncEventArgs> m_pool;
 
    // Initializes the object pool to the specified size
        //
    // The "capacity" parameter is the maximum number of
    // SocketAsyncEventArgs objects the pool can hold
    public SocketAsyncEventArgsPool(int capacity)
    {
        m_pool = new Stack<SocketAsyncEventArgs>(capacity);
    }
 
    // Add a SocketAsyncEventArg instance to the pool
    //
    // The "item" parameter is the SocketAsyncEventArgs instance
    // to add to the pool
    public void Push(SocketAsyncEventArgs item)
    {
        if (item == null) { throw new ArgumentNullException("Items added to a SocketAsyncEventArgsPool cannot be null"); }
        lock (m_pool)
        {
            m_pool.Push(item);
        }
    }
 
    // Removes a SocketAsyncEventArgs instance from the pool
        // and returns the object removed from the pool
    public SocketAsyncEventArgs Pop()
    {
        lock (m_pool)
        {
            return m_pool.Pop();
        }
    }
 
    // The number of SocketAsyncEventArgs instnaces in the pool
    public int Count
    {
        get { return m_pool.Count; }
    }
}
cs


코드는 굉장히 간단합니다.

객체를 담을 수 있는 Stack을 생성해 Pop / Push 를 통해 꺼내거나 반환하는 작업을 수행합니다.


1
2
this.receive_event_args_pool = new SocketAsyncEventArgsPool(this.max_connections);
this.send_event_args_pool = new SocketAsyncEventArgsPool(this.max_connections);
cs


최대 동접 수치만큼 생성합니다.


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
for (int i = 0; i < this.max_connections; i++)
{
    CUserToken token = new CUserToken();
 
    // receive pool
    {
        // Pre-allocate a set of reusable SocketAsyncEventArgs
        arg = new SocketAsyncEventArgs();
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(receive_completed);
        arg.UserToken = token;
 
        // add SocketAsyncEventArg to the pool
        this.receive_event_args_pool.Push(arg);
    }
 
    // send pool
    {
        // Pre-allocate a set of reusable SocketAsyncEventArgs
        arg = new SocketAsyncEventArgs();
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(send_completed);
        arg.UserToken = token;
 
        // add SocketAsyncEventArg to the pool
        this.send_event_args_pool.Push(arg);
    }
}
cs


설정해놓은 최대 동접 수 만큼 SocketAsyncEventArgs객체를 미리 생성하여 풀에 넣어 놓는 코드입니다.


1-4-2. 송, 수신 버퍼 풀링 기법


BufferManager

다음으로 버퍼 관리에 대해 구현해보겠습니다.

SocketAsyncEventArgs마다 버퍼가 하나씩 필요합니다.

이 버퍼라는 것은 바이트 배열로 이루어진 메모리 덩어리입니다.


1
2
BufferManager buffer_manager;
this.buffer_manager = new BufferManager(this.max_connections * this.buffer_size * this.pre_alloc_count, this.buffer_size);
cs

버퍼 매니저를 선언하고 생성하는 코드입니다.


버퍼의 전체 크기는 아래 공식으로 계산됩니다.

버퍼의 전체 크기 = 최대 동접 수치 * 버퍼 하나의 크기 * (전송용, 수신용)


전송용 한개, 수신용 한개 총 두개가 필요하기 때문에 pre_alloc_count = 2로 설정해 두었습니다.


버퍼 매니저의 코드를 살펴보겠습니다.


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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/// <summary>
/// This class creates a single large buffer which can be divided up and assigned to SocketAsyncEventArgs objects for use
/// With each socket I/O operation. This enables buffers to be easily reused and gaurds against fragmenting heap memory.
/// 
/// The operations exposed on the BufferManager class are not thread safe.
/// </summary>
internal class BufferManager
{
    int m_numBytes;                // the total number of bytes controlled by the buffer pool
    byte[] m_buffer;            // the underlying byte array maintained by the buffer Manager
    Stack<int> m_freeIndexPool;
    int m_currentIndex;
    int m_bufferSize;
 
    public BufferManager(int totalBytes, int bufferSize)
    {
        m_numBytes = totalBytes;
        m_currentIndex = 0;
        m_bufferSize = bufferSize;
        m_freeIndexPool = new Stack<int>();
    }
 
    /// <summary>
    /// Allocates buffer space used by the buffer pool
    /// </summary>
    public void InitBuffer()
    {
        // create one big large buffer and divide that out to each SocketAsyncEventArg object
        m_buffer = new byte[m_numBytes];
    }
 
    /// <summary>
    /// Assigns a buffer from the buffer pool to the specified SocketAsyncEventArgs object
    /// </summary>
    /// <returns>true if the buffer was successfully set, else false</returns>
    public bool SetBuffer(SocketAsyncEventArgs args)
    {
        if (m_freeIndexPool.Count > 0)
        {
            args.SetBuffer(m_buffer, m_freeIndexPool.Pop(), m_bufferSize);
        }
        else
        {
            if ((m_numBytes - m_bufferSize) < m_currentIndex)
            {
                return false;
            }
            args.SetBuffer(m_buffer, m_currentIndex, m_bufferSize);
            m_currentIndex += m_bufferSize;
        }
        return true;
    }
 
    /// <summary>
    /// Removes the buffer from a SocketAsyncEventArg object. This frees the buffer back to the
    /// buffer pool
    /// </summary>
    /// <param name="args"></param>
    public void FreeBuffer(SocketAsyncEventArgs args)
    {
        m_freeIndexPool.Push(args.Offset);
        args.SetBuffer(null00);
    }
}
cs


이 코드도 msdn에 있는 샘플입니다.

InitBuffer 메소드에서 하나의 거대한 바이트 배열을 생성합니다.

그리고 SetBuffer(SocketAsyncEventArgs args) 메소드에서 SocketAsyncEventArgs 객체에

버퍼를 설정해줍니다.

하나의 버퍼를 설정한 다음에는 index 값을 증가시켜 다음 버퍼 위치를 가리킬 수 있도록 처리합니다.

넓은 땅에 금을 그어 이건 내꺼, 저건 니꺼 하는 식으로 나눈다고 생각하시면 될 것 같습니다.


마지막에는 사용하지 않는 버퍼를 반환시키기 위한 FreeBuffer 메소드입니다.

이 메소드는 아마 쓰이지 않게 될 것 같습니다.

왜냐하면 프로그램 시작시 최대 동접 수치만큼 버퍼를 할당한 뒤 중간에 해제하지 않고 계속 물고 있기 때문입니다. SocketAsyncEventArgs만 풀링하여 재사용할 수 있도록 처리해 놓으면 이 객체에 할당된 버퍼도 같이 따라가게 되기 때문입니다.


이전에 SocketAsyncEventArgs를 생성하여 풀링처리 햇던 부분을 다시 보겠습니다.


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
32
33
34
for (int i = 0; i < this.max_connections; i++)
{
    CUserToken token = new CUserToken();
 
    // receive pool
    {
        // Pre-allocate a set of reusable SocketAsyncEventArgs
        arg = new SocketAsyncEventArgs();
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(receive_completed);
        arg.UserToken = token;
 
        // 추가된 부분
        // assign a byte buffer from the buffer pool to the SocketAsyncEventArg object
        this.buffer_manager.SetBuffer(arg);
 
        // add SocketAsyncEventArg to the pool
        this.receive_event_args_pool.Push(arg);
    }
 
    // send pool
    {
        // Pre-allocate a set of reusable SocketAsyncEventArgs
        arg = new SocketAsyncEventArgs();
        arg.Completed += new EventHandler<SocketAsyncEventArgs>(send_completed);
        arg.UserToken = token;
 
        // 추가된 부분
        // assign a byte buffer from the buffer pool to the SocketAsyncEventArg object
        this.buffer_manager.SetBuffer(arg);
 
        // add SocketAsyncEventArg to the pool
        this.send_event_args_pool.Push(arg);
    }
}
cs



this.buffer_manager.SetBuffer(arg); 라는 코드가 보이시나요?

이 부분이 SocketAsyncEventArgs 객체에 버퍼를 설정하는 코드입니다.

SetBuffer 내부를 보면 범위를 잡아서 arg.SetBuffer를 호출해주게끔 되어있죠.

m_currentIndex += m_bufferSize; 이런식으로 버퍼 크기만큼 인덱스 값을 늘려서

서로 겹치지 않게 해주고 있는겁니다.


다시 정리해보면 하나의 거대한 버퍼를 만들고 버퍼 사이즈만큼 범위를 잡아 SocketAsyncEventArgs 객체에 하나씩 설정해줍니다. 이런 구현 방식으로 SocketAsyncEventArgs 객체와 버퍼 메모리를 풀링하여 사용하게 됩니다.

첫번째 강좌에서도 말씀 드렸지만 .Net에는 가비지 컬렉터가 작동되므로 풀링하지 않아도 객체 참조 관계만 잘 끊어주면 알아서 메모리를 정리해줍니다.
저도 시험삼아 풀링하지 않고 필요할 때 new 하는 방식으로 돌려봤는데 메모리 사용량이 엄청 늘어나더군요. 가비지 컬렉터가 작동되는 시점이 우리가 만들 서버에 잘 들어 맞는다는 보장은 없기에 저는 그냥 마음 편하게 풀링하는 쪽을 선택했습니다.
서버가 살아 있는 동안 거의 매순간 쓰게 될 메모리라는 것도 풀링을 선택하는데 기준이 되었습니다.


1-4-3. 유저의 접속 처리하기


SocketAsyncEventArgs와 버퍼 풀링까지 준비되었다면 접속된 클라이언트를 처리할 준비가 완료된 것입니다.

단순한 에코 서버라면 이런 준비 과정이 필요없을 수 있지만 수 천의 동접을 처리하는 게임서버라면 조금 번거롭더라도 이런 작업들을 잘 만들어 두어야 합니다.

또한 만들어 놓았다고 끝난 것이 아니라 그때부터 시작일지도 모릅니다.

처음 설계한대로 이쁘게 코딩 해놨어도 실제 테스트해보면 부족한 부분이 보일 때가 있기 때문이죠.

디버깅 하면서 뭔가 개운하지 못한 느낌이 든다면 다시 설계를 할 각오도 해야합니다.


클라이언트의 접속이 이루어진 후 호출되는 콜백 메소드인 on_new_client를 살펴보겠습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void on_new_client(Socket client_socket, object token)
{
    // 풀에서 하나 꺼내와 사용한다.
    SocketAsyncEventArgs receive_args = this.receive_event_args_pool.Pop();
    SocketAsyncEventArgs send_args = this.send_event_args_pool.Pop();
 
    // SocketAsyncEventArgs를 생성할 때 만들어 두었던 CUserToken을 꺼내와서
    // 콜백 매소드의 파라미터로 넘김
    if (this.session_created_callback != null)
    {
        CUserToken user_token = receive_args.UserToken as CUserToken;
        this.session_created_callback(user_token);
    }
 
    // 이제 클라이언트로부터 데이터를 수신할 준비를 
    begin_receive(client_socket, receive_args, send_args);
}
cs


accept 처리가 완료된 후 생성된 새로운 소켓을 파라미터로 넘겨주게 했습니다.

앞으로 이 소켓으로 클라이언트와 메시지를 주고받으면 됩니다.

풀링해 두었던 SocketAsyncEventArgs 객체도 드디어 사용할 때가 왔습니다.


1
2
SocketAsyncEventArgs receive_args = this.receive_event_args_pool.Pop();
SocketAsyncEventArgs send_args = this.send_event_args_pool.Pop();
cs

메시지 수신용, 전송용 각각 하나씩 꺼냅니다.

1
2
CUserToken user_token = receive_args.UserToken as CUserToken;
this.session_created_callback(user_token);
cs

위에서 설명하지는 않았지만 SocketAsyncEventArgs를 생성할 때 CUserToken이라는 클래스도 같이 생성하였습니다. 원격지 클라이언트 하나당 한 개씩 매칭되는 유저 객체라고 생각하시면 됩니다.
SocketAsyncEventArgs의 UserToken 변수에 참조하게끔 설정해놨었죠.
begin_receive(client_socket, receive_args, send_args);
클라이언트가 접속한 이유는 무언가를 주고 받기 위함이니 이제 메시지 수신을 위한 작업을 시작합니다. 전송을 위한 작업은 서버에서 메시지를 보낼 시점에 일어나므로 아직 준비할 필요는 없습니다.

begin_receive의 코드를 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void begin_receive(Socket socket, SocketAsyncEventArgs receive_args, SocketAsyncEventArgs send_args)
{
    // receive_args, send_args 아무곳에서나 꺼내와도 됨. 둘 다 동일한 CUserToken을 물고 있음
    CUserToken token = receive_args.UserToken as CUserToken;
    token.set_event_args(receive_args, send_args);
 
    // 생성된 클라이언트 소켓을 보관해 놓고 통신할 때 사용함
    token.socket = socket;
 
    // 데이터를 받을 수 있도록 소켓 메소드를 호출해준다.
    // 비동기로 수신할 경우 워커 스레드에서 대기중으로 있다가 Completed에 설정해놓은 메소드가 호출된다.
    // 동기로 완료될 경우에는 직접 완료 메소드를 호출해줘야 한다.
    bool pending = socket.ReceiveAsync(receive_args);
    if (!pending)
    {
        process_receive(receive_args);
    }
}
cs

비동기 소켓 메소드는 사용법이 다 비슷합니다. xxxAsync 메소드 호출 뒤 리턴 값을 확인하여
콜백 메소드를 기다릴지, 직접 완료 처리를 할지 결정해주는 부분은 이전에 accept 처리 부분과 흡사합니다.

먼저 CUserToken 객체에 수신용, 전송용 SocketAsyncEventArgs를 설정해줍니다.
메시지 송, 수신시 항상 이 SocketAsyncEventArgs가 따라다니게 되기 때문에
작업 편의를 위해서 CUserToken 객체에도 설정해주도록 하였습니다.

마지막으로 ReceiveAsync 메소드를 호출해주면 해당 소켓으로부터 메시지를 수신 받을 수 있게 됩니다. 비동기 메소드이니 리턴 값을 확인해야겠죠?

강좌를 보시면서 이해가 잘 안되는 부분도 많을겁니다.
특히 CListener부터 SocketAsyncEventArgsPool, BufferManager, UserToken 등등
많은 클래스들이 서로 관계를 맺도록 구조가 잡혀 있는데 이런 시스템이 한번에 이해되기는 힘듭니다.
저도 처음 라이브러리를 설계할 때 모두 완성해놓고 코딩한 것은 아닙니다.
하나의 클래스에 단순하게 구현해본 뒤 점점 확장/분리할 필요성을 느끼게 되면서
다시 설계하여 리팩토링 하는 작업을 수십번 반복한 결과입니다.
따라서 처음에는 그냥 대충 이런 흐름이구나 하고 넘어가신 뒤 직접 코딩해 보면서 다시 참고하는
방법을 권해드립니다.

다음은 ReceiveAsync 이후에 콜백으로 호출되는 메소드입니다.

1
2
3
4
5
6
7
8
9
10
void receive_completed(object sender, SocketAsyncEventArgs e)
{
    if (e.LastOperation == SocketAsyncOperation.Receive)
    {
        process_receive(e);
        return;
    }
 
    throw new ArgumentException("The last operation completed on the socket was not a receive.");
}
cs

오류가 발생했을 때 예외를 던져주는 부분만 빼면 그냥 process_receive 메소드를 호출해주는 것 밖에 없습니다. 사실 e.LastOperation이 Receive가 아닌 경우는 발생하지 않을 것 같습니다.
이 코드는 메시지 송, 수신 완료 처리를 하나의 콜백 메소드에서 분기하여 처리하려고 할 때 들어갔던 코드입니다.

이제 process_receive 메소드를 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// This method is invoked when an asynchronous receive operation completes.
// If the remote host closed the connection, then the socket is closed.
private void process_receive(SocketAsyncEventArgs e)
{
    // check if the remote host closed the connection
    CUserToken token = e.UserToken as CUserToken;
    if (e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
    {
        // 이후의 작업은 CUserToekn에 맡긴다.
        token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);
 
        // 다음 메시지 수신을 위해서 다시 ReceiveAsync 메소드를 호출한다.
        bool pending = token.socket.ReceiveAsync(e);
        if (!pending)
        {
            process_receive(e);
        }
    }
    else
    {
        Console.WriteLine(string.Format("error {0}, transferred {1}", e.SocketError, e.BytesTransferred));
        close_clientsocket(token);
    }
}
cs

이제 SocketAsyncEventArgs라는 객체가 어느정도 눈에 익었을 것입니다.
이번에도 역시 이 객체를 사용하게 되는데요.
버퍼도 물고 있고, 유저 객체도 하나 갖고 있고 클라이언트 하나와 통신하는데 필요한 최소한의 재료들을 담고있는 녀석이라고 생각하기로 합시다.

CUserToken을 얻어와서 on_receive 메소드를 호출해줍니다.

token.on_receive(e.Buffer, e.Offset, e.BytesTransferred);
이 부분이 정말 중요합니다.
나중에 패킷 처리할 때 더 자세히 알아보겠지만 지금 보이는 저 세 개의 파라미터가 패킷 수신 처리의 핵심입니다.
e.Buffer에는 클라이언트로부터 수신된 데이터들이 들어있습니다.
e.Offset은 수신된 버퍼의 포지션입니다.
e.BytesTransferred는 이번에 수신된 데이터의 바이트 수를 나타냅니다.
대충 감은 오시겠지만 더 자세한 내용은 다음 강좌 때 설명하겠습니다.

1
2
3
4
5
6
// 다음 메시지 수신을 위해서 다시 ReceiveAsync 메소드를 호출한다.
bool pending = token.socket.ReceiveAsync(e);
if (!pending)
{
    process_receive(e);
}
cs

데이터를 한번 수신한 뒤에는 ReceiveAsync를 다시 호출해야 합니다.
그래야 계속해서 데이터를 받을 수 있게 됩니다. 한번의 ReceiveAsync로 모든 데이터를 다 받지는 못하기 때문입니다.
클라이언트가 잠시 쉬었다 보낼 수도 있고, 한번에 보낸 것도 네트워크 환경에 따라서 여러번에 걸쳐 받을 수도 있거든요.
스트림 기반 프로토콜인 TCP의 특징이죠.

비동기 메소드를 호출한 뒤에는 항상 리턴 값을 확인했었습니다. ReceiveAsync 메소드도 동일합니다. pending 상태가 아니면 메시지 수신 처리를 진행할 수 있도록 직접 process_receive를 호출해줍시다.
저는 여기서 한가지 의문이 들었는데요.
굉장히 자주 pending = false 상태가 된다면
ReceiveAsync -> process -> ReceiveAsync -> process_receive 이렇게 끊임 없이 호출하다가 스택 오버플로우가 나지 않을까 하는 의문이었습니다.
code project에 있는 어떤 외국 사람이 만든 소스코드를 봐도 위와 같은 구조로 되어 있더군요.
그곳 게시판에도 이러너 의문을 가진 사람이 있었는데 자세히 읽어보진 못했습니다.
다른 설계를 사용하여 저런 무한 호출 구조를 회피한 것 같더군요.

대부분 pending 상태가 될 것이기 때문에 별 문제 없겠지 라고 넘어가기엔 약간 개운하지가 못하네요. 좀 더 테스트해보고 연구해서 개선된 방향을 찾으면 다시 알려드리겠습니다.

네트워크 기반 코드 구현은 여기까지입니다.
c#에서 비동기 소켓 프로그래밍을 어떻게 구현하는지에 대한 부분이 주를 이루었는데
눈에 보이는게 없어서 다소 지루할 수 있는 부분이었던 것 같습니다.
이론적으로 알고 있던 부분이 c#에서 어떻게 코드로 표현되는지 이해하는 정도로 받아들이시면 될 것 같습니다. 실제 코딩하고 디버깅 해보며 배우는게 더 재밌겠죠.
다음 강좌에서는 tcp에서 메시지를 처리하는 방법과 함께 패킷 구조를 설계하여
서버와 클라이언트 사이에 어떻게 메시지 처리가 이루어지는지에 대한 내용을 다루겠습니다.


반응형

'- Programming > - C#' 카테고리의 다른 글

★ 6. c# 네트워크 개발 p5  (0) 2017.02.10
★ 5. c# 네트워크 개발 p4  (0) 2017.02.09
★ 4. c# 네트워크 개발 p3  (0) 2017.02.09
★ 3. c# 박싱과 언박싱  (0) 2017.02.08
★ 1. c# 네트워크 개발 p1  (2) 2017.02.06