C#/네트워크

[c#][서버] Session

goliot 2024. 6. 6. 19:01
반응형

Listener 복습

using System.Net;
using System.Net.Sockets;

class Listener
{
    Socket _listenSocket;
    Action<Socket> _onAcceptHandler;

    public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
    {
        _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
        _onAcceptHandler += onAcceptHandler; //연결 수락시 실행할 콜백 함수

        //문지기 교육
        _listenSocket.Bind(endPoint);

        //영업 시작
        _listenSocket.Listen(10);

        SocketAsyncEventArgs args = new SocketAsyncEventArgs(); //한번 만들면 계속 재사용
        args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); //완료되면, OnAcceptCompleted 실행
        RegisterAccept(args);
    }

    //비동기에선 Accept 요청과 완료가 분리되어야 함
    //Register 와 Completed가 뺑뺑이를 돌면서 계속 실행됨
    void RegisterAccept(SocketAsyncEventArgs args)
    {
        args.AcceptSocket = null; //재사용이므로 초기화 시키고 사용

        bool pending = _listenSocket.AcceptAsync(args); //비동기로 예약
        if(pending == false) //완료가 된 상태
        {
            OnAcceptCompleted(null, args);
        }
    }

    void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
    {
        if(args.SocketError == SocketError.Success)
        {
            //TODO
            _onAcceptHandler.Invoke(args.AcceptSocket);
            //완료됐으므로, 동작 처리
        }
        else
        {
            Console.WriteLine(args.SocketError.ToString());
        }

        RegisterAccept(args);
        //이번 꺼는 완료가 됐으므로, 다음 아이를 위해 재등록
    }
}
  • pending이 계속 false로 걸린다면?
    • Completed와 Register가 계속 뺑뻉이를 돌면서 stack overflow가 나지 않을까?
      • 그러나 10개의 대기열 제한을 걸어 두었기 때문에 이 상황에선 일어나지 않음
  • 문지기가 하나밖에 없는데, 유저가 몰린다면?
    • 다음과 같이 코드 수정 -> 문지기 늘리기
SocketAsyncEventArgs args = new SocketAsyncEventArgs(); //한번 만들면 계속 재사용
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); //완료되면, OnAcceptCompleted 실행
RegisterAccept(args);

//이 부분을 아래처럼

for(int i=0; i<10; i++) {
    SocketAsyncEventArgs args = new SocketAsyncEventArgs(); //한번 만들면 계속 재사용
    args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); //완료되면, OnAcceptCompleted 실행
    RegisterAccept(args);
}
  • Main에서는 while(true)를 계속 돌고 있는데, 도대체 콜백함수 실행 될 때 어떻게 알고 껴드는가?
    • 우리가 쓰레드를 따로 만들지는 않았지만, Async를 썼으므로 쓰레드가 생기는 것
      • 그렇기 때문에, 만약 Main과 OnAcceptCompleted에서 같은 데이터를 건드린다면?
        • Race Condition 문제가 발생함 -> Red Zone이다
      • 현재는 Accept만 해주기 때문에 문제가 없지만, Recv, Send를 한다면 문제가 생긴다.

 

1) Recv 분리, 비동기화 -> Session

Session은 항상 멀티쓰레드라고 염두에 두고 코딩할 것

- Send 부분은 일단은 임시로 블로킹 코드로 구현, 이후에 수정

//Recv 담당
class Session
{
    Socket _socket;

    public void Start(Socket socket)
    {
        _socket = socket;
        SocketAsyncEventArgs recvArgs = new SocketAsyncEventArgs();
        recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
        recvArgs.SetBuffer(new byte[1024], 0, 1024); //버퍼, 시작점, 사이즈

        RegisterRecv(recvArgs);
    }

    public void Send(byte[] sendBuff)
    {
        _socket.Send(sendBuff);
    }

    public void Disconnect()
    {
        _socket.Shutdown(SocketShutdown.Both);
        _socket.Close();
    }

    #region 네트워크 통신
    void RegisterRecv(SocketAsyncEventArgs args)
    {
        bool pending = _socket.ReceiveAsync(args);
        if (pending == false) //바로 성공
            OnRecvCompleted(null, args);
    }

    void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
    {
        //0개의 바이트가 오거나 Success가 아닌 경우, 모두 실패한 경우임
        if(args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
        {
            try
            {
                //TODO
                string recvData = Encoding.UTF8.GetString(args.Buffer, args.Offset, args.BytesTransferred);
                Console.WriteLine($"[From Client] {recvData}");
                RegisterRecv(args);
            }
            catch(Exception e)
            {
                Console.WriteLine($"OnRecvCompleted Failed {e}");
            }
        }
        else
        {
            Disconnect();
        }
    }
    #endregion
}

class Program //Main
{
    static Listener _listener = new Listener();

    static void OnAcceptHandler(Socket clientSocket)
    {
        try
        {
            Session session = new Session();
            session.Start(clientSocket);

            byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
            session.Send(sendBuff);

            Thread.Sleep(1000);

            session.Disconnect();
        }
        catch (Exception e)
        {
            Console.WriteLine(e.ToString());
        }
    }

    static void Main(string[] args)
    {
        //DNS 사용
        string host = Dns.GetHostName();
        IPHostEntry ipHost = Dns.GetHostEntry(host);
        IPAddress ipAddr = ipHost.AddressList[0];
        IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

        _listener.Init(endPoint, OnAcceptHandler);
        Console.WriteLine("Listening...");

        while (true)
        {

        }
    }
}
  • 만약 멀티쓰레드 환경에서 돌다가 Disconnect 한 세션이 한번 더 Disconnect 한다면?
    • 당연히 문제가 발생함 -> 해결하자 -> Lock을 써보자!
int _disconnected = 0;

public void Disconnect()
{
    if (Interlocked.Exchange(ref _disconnected, 1) == 1)
        return;
    //이미 Disconnect가 되었다면 1이 반환되므로 바로 리턴

    _socket.Shutdown(SocketShutdown.Both);
    _socket.Close();
}
  • 이걸 추가해보면, 한 세션당 한 번의 disconnect만 발생할 것이다.

2) Send 분리 -> Session에 이어서

  • Send의 경우 Recv와 얘기가 다르다.
    • Recv와 다르게 Send의 시점이 정해져 있지 않음! -> 예약이 안된다.
    • Recv는 상대가 보내면, 그때 예약걸린 일을 처리하면 되지만
    • Send는 내가 보내야 하니까 예약이란게 없음
public void Send(byte[] sendBuff)
{
    SocketAsyncEventArgs sendArgs = new SocketAsyncEventArgs();
    sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);
    sendArgs.SetBuffer(sendBuff, 0, sendBuff.Length); //버퍼, 시작점, 사이즈

    RegisterSend(sendArgs); //얘는 보낼 내용물이 전부 들어있는 것
    //보낼 때마다, 새로운 통을 준비해서 내용물을 담는 것
    //Recv의 경우에는 무언가 오면 담을 통만 준비해뒀음
}

void RegisterSend(SocketAsyncEventArgs args)
{
    bool pending = _socket.SendAsync(args);
    if (pending == false)
        OnSendCompleted(null, args);
}

void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
    //성공, 실패 판단은 똑같음
    if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
    {
        try
        {
            //Send는 재사용이 불가능
            //여기에서 args에는 전에 보냈던 버퍼가 그대로 남아있음 -> 그대로 재사용하면 안되지
            //사실 Send는 성공하고 나면 다음에 할 게 없음
        }
        catch (Exception e)
        {
            Console.WriteLine($"OnSendCompleted Failed {e}");
        }
    }
    else
    {
        Disconnect();
    }
}

 

3) Send 개선 -> 재사용성, 보낼 버퍼 모아서 처리하기

  • 하지만 많은 유저가 움직이고 있고, 다른 모든 유저에게 그 움직임의 정보를 전달해야 함
    • 그럴 때마다 새로 만들어서 SendAsync를 하나하나 한다는건 말도 안됨
    • Send를 좀 모아서 한번에 보내는게 좋지 않을까?
    • SendAsync도 재사용하면 좋고
  • SendAsync를 재사용하면서, Send - Completed가 계속 순환하게
    • Completed가 완료될때까지는 Send Queue에 쌓아두다가, 완료 되면 비우고 다시 쌓기
 object _lock = new object();
 Queue<byte[]> _sendQueue = new Queue<byte[]>();
 bool _pending = false; //한번이라도 send를 했으면 true, 전부 완료되면 false
 SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
 
 public void Start(Socket socket)
{
    _socket = socket;
    SocketAsyncEventArgs recvArgs = new SocketAsyncEventArgs();
    recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
    recvArgs.SetBuffer(new byte[1024], 0, 1024); //버퍼, 시작점, 사이즈

	//이 부분 추가, 최초 1회만 만들기
    _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);

    RegisterRecv(recvArgs); //얘는 받을 통만 준비한 것
}

public void Send(byte[] sendBuff)
{
    lock (_lock) //Send가 여러 쓰레드에서 호출되므로 락 필수
    {
        _sendQueue.Enqueue(sendBuff); //큐에다가 집어넣고
        if (_pending == false) //1빠로 Send요청을하여 등록도 안된 경우
            RegisterSend();
        //true라면, Send작업이 진행중인 것이고, 지금 진행중인게 완료될때까지 큐에 넣기만 하고 대기
    }
}

void RegisterSend() //얘는 이미 락이 걸려있는 부분에서 호출한게 아니면 호출될 여지가 없음 
{
    _pending = true; //작업중이라고 해놓고
    byte[] buff = _sendQueue.Dequeue(); //큐에서 뽑기
    _sendArgs.SetBuffer(buff, 0, buff.Length);

    bool pending = _socket.SendAsync(_sendArgs);
    if (pending == false)
        OnSendCompleted(null, _sendArgs);
}

void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
    lock (_lock) //얘는 콜백에 의해 예상치 못하게 수행 될 여지가 있음
    {
        if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
        {
            try
            {
                if(_sendQueue.Count > 0) //보내는 동안 또 누군가 전송 요청을 한 경우
                    RegisterSend(); //큐가 남아있다면 다시 전송
                else
                    _pending = false; //성공적으로 끝났으므로 초기화
            }
            catch (Exception e)
            {
                Console.WriteLine($"OnSendCompleted Failed {e}");
            }
        }
        else
        {
            Disconnect();
        }
    }
}

 

 

4) Send 개선 -> Queue에 있는 모든 버퍼를 한번에 처리하기

  • 그런데 한번에 하나씩 Register - Complete하는것도 비효율적이지 않나?
    • 아래처럼 수정하자
List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>();

void RegisterSend() //얘는 이미 락이 걸려있는 부분에서 호출한게 아니면 호출될 여지가 없음 
{
    while(_sendQueue.Count > 0)
    {
        byte[] buff = _sendQueue.Dequeue();
        _pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length));
    }
    _sendArgs.BufferList = _pendingList;

    bool pending = _socket.SendAsync(_sendArgs);
    if (pending == false)
        OnSendCompleted(null, _sendArgs);
}

void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
    lock (_lock) //얘는 콜백에 의해 예상치 못하게 수행 될 여지가 있음
    {
        if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
        {
            try
            {
                _sendArgs.BufferList = null; //초기화
                _pendingList.Clear();

                Console.WriteLine($"Transferrd bytes: {_sendArgs.BytesTransferred}");

                if(_sendQueue.Count > 0) //보내는 동안 또군가 전송 요청을 한 경우
                    RegisterSend();
            }
            catch (Exception e)
            {
                Console.WriteLine($"OnSendCompleted Failed {e}");
            }
        }
        else
        {
            Disconnect();
        }
    }
}
  • 여기서 주의할 점은 반드시 리스트를 따로 만들고, BufferList = list 를 해줘야 정상 작동한다는 것이다.
    • 이렇게 수정하면 _pending 플래그도 필요 없어진다.
    • 큐에 있는 버퍼들을 한번에 List로 모아서 처리를 하는 것

 

5) Send 개선 -> 트래픽 몰릴 경우, 비정상적 요청 처리

  • 하지만 문제가 또 있다.
    • 한번에 과도하게 많은 양을 보내게 되면 오류가 발생
      • 특히 디도스로 의미 없는 정보를 계속 보내는 경우
    • 그러므로, 몰렸을때 조금 쉬는 등의 동작이 필요
  • 또한, 수천명의 유저가 움직이고 스킬쓰고 한 모든 동작들을 하나의 큰 버퍼로 모아서
    • 순회하면서 각 유저에게 뿌린다면 더 효율적이지 않을까?

 

Session 개선 -> 받고 보내고 끝이 아닌, 사후 처리 + 내부, 외부 동작 분리

  • 싹 뜯어고치자.
  • 내부동작부분과 외부 동작부분을 분리
    • 내부: Register, Completed 전부
    • 외부: 동작 완료된 후 사후 처리
//Accept 담당
class Listener
{
    Socket _listenSocket;
    Func<Session> _sessionFactory; //세션을 만들어 주는 것

    public void Init(IPEndPoint endPoint, Func<Session> sessionFactory)
    {
        _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
        _sessionFactory += sessionFactory;

        //문지기 교육
        _listenSocket.Bind(endPoint);

        //영업 시작
        _listenSocket.Listen(10);

        SocketAsyncEventArgs args = new SocketAsyncEventArgs(); //한번 만들면 계속 재사용
        args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); //완료되면, OnAcceptCompleted 실행
        RegisterAccept(args);
    }

    void RegisterAccept(SocketAsyncEventArgs args)
    {
        args.AcceptSocket = null; 

        bool pending = _listenSocket.AcceptAsync(args); 
        if(pending == false) 
        {
            OnAcceptCompleted(null, args);
        }
    }

    void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
    {
        if(args.SocketError == SocketError.Success)
        {
            Session session = _sessionFactory.Invoke();
            session.Start(args.AcceptSocket);
            session.OnConnected(args.AcceptSocket.RemoteEndPoint);
        }
        else
        {
            Console.WriteLine(args.SocketError.ToString());
        }

        RegisterAccept(args);
    }
}

//Recv, Send 담당
abstract class Session
{
    Socket _socket;
    int _disconnected = 0;

    object _lock = new object();
    Queue<byte[]> _sendQueue = new Queue<byte[]>();
    List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>();
    SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
    SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs();

    public abstract void OnConnected(EndPoint endPoint); //연결 성공 이후
    public abstract void OnRecv(ArraySegment<byte> buffer); //수신 성공 이후
    public abstract void OnSend(int numOfBytes); //송신 성공 이후
    public abstract void OnDisConnected(EndPoint endPoint); //연결 끊긴 이후

    public void Start(Socket socket)
    {
        _socket = socket;
        _recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
        _recvArgs.SetBuffer(new byte[1024], 0, 1024); //버퍼, 시작점, 사이즈

        _sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);

        RegisterRecv();
    }

    public void Send(byte[] sendBuff)
    {
        lock (_lock) //Send가 여러 쓰레드에서 호출되므로 락 필수
        {
            _sendQueue.Enqueue(sendBuff);
            if (_pendingList.Count == 0) //1빠로 Send요청을하여 등록도 안된 경우
                RegisterSend();
        }
    }

    public void Disconnect()
    {
        if (Interlocked.Exchange(ref _disconnected, 1) == 1)
            return;

        OnDisConnected(_socket.RemoteEndPoint);
        _socket.Shutdown(SocketShutdown.Both);
        _socket.Close();
    }

    #region 네트워크 통신
    void RegisterSend() //얘는 이미 락이 걸려있는 부분에서 호출한게 아니면 호출될 여지가 없음 
    {
        while(_sendQueue.Count > 0)
        {
            byte[] buff = _sendQueue.Dequeue();
            _pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length));
        }
        _sendArgs.BufferList = _pendingList;

        bool pending = _socket.SendAsync(_sendArgs);
        if (pending == false)
            OnSendCompleted(null, _sendArgs);
    }

    void OnSendCompleted(object sender, SocketAsyncEventArgs args)
    {
        lock (_lock) //얘는 콜백에 의해 예상치 못하게 수행 될 여지가 있음
        {
            if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
            {
                try
                {
                    _sendArgs.BufferList = null; //초기화
                    _pendingList.Clear();

                    OnSend(_sendArgs.BytesTransferred);

                    if(_sendQueue.Count > 0) //보내는 동안 또군가 전송 요청을 한 경우
                        RegisterSend();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"OnSendCompleted Failed {e}");
                }
            }
            else
            {
                Disconnect();
            }
        }
    }

    void RegisterRecv()
    {
        bool pending = _socket.ReceiveAsync(_recvArgs);
        if (pending == false) //바로 성공
            OnRecvCompleted(null, _recvArgs);
    }

    void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
    {
        //0개의 바이트가 오거나 Success가 아닌 경우, 모두 실패한 경우임
        if(args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
        {
            try
            {
                OnRecv(new ArraySegment<byte>(args.Buffer, args.Offset, args.BytesTransferred));
                RegisterRecv();
            }
            catch(Exception e)
            {
                Console.WriteLine($"OnRecvCompleted Failed {e}");
            }
        }
        else
        {
            Disconnect();
        }
    }
    #endregion
}

//Session의 사후 처리 담당
class GameSession : Session
{
    public override void OnConnected(EndPoint endPoint)
    {
        Console.WriteLine($"OnConnected : {endPoint}");

        byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
        Send(sendBuff);
        Thread.Sleep(1000);
        Disconnect();
    }

    public override void OnDisConnected(EndPoint endPoint)
    {
        Console.WriteLine($"OnDisConnected : {endPoint}");
    }

    public override void OnRecv(ArraySegment<byte> buffer)
    {
        string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
        Console.WriteLine($"[From Client] {recvData}");
    }

    public override void OnSend(int numOfBytes)
    {
        Console.WriteLine($"Transferrd bytes: {numOfBytes}");
    }
}

//메인 코어
class Program
{
    static Listener _listener = new Listener();

    static void Main(string[] args)
    {
        //DNS 사용
        string host = Dns.GetHostName();
        IPHostEntry ipHost = Dns.GetHostEntry(host);
        IPAddress ipAddr = ipHost.AddressList[0];
        IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);

        _listener.Init(endPoint, () => { return new GameSession(); });
        Console.WriteLine("Listening...");

        while (true)
        {

        }
    }
}

 

반응형

'C# > 네트워크' 카테고리의 다른 글

[c#][서버] TCP vs UDP  (0) 2024.06.07
[c#][서버] Connector  (0) 2024.06.07
[c#][서버] Listener  (0) 2024.06.05
[c#][서버] 소켓 프로그래밍  (0) 2024.06.05
[c#][서버] 네트워크 기초 이론  (0) 2024.06.04