지우너
[C# 게임서버] 네트워크 프로그래밍(2) 본문
1. Session #1
저번 시간에는 Listener class를 만들어서 Accept를 비동기로 만들었다. Session #1에서는 Receive() 비동기로 만들어 본다!
1.1. 지난 시간에 들 수 있는 의문점
1.2. Session 클래스에 Receive 옮기기(비동기로 만들기)
1.2.1. RegisterRecv()
Listener를 만들 때와 동일했던 부분은 빨간 네모로 코드에 표시+빨간 테두리 설명 / 다른 부분(Socket 생성)은 초록색 박스로 표시했다.
박스로 표시한 부분 외에도 _listenSocket.Bind(endPoint); _listenSocket.Listen(10); 과 같이 Accept에 필요한 요소나 recvArgs.SetBuffer(new byte[1024], 0, 1024);와 같이 Receive에 필요한 요소는 당연히 다르다!
1.2.2. OnRecvCompleted()
1.2.3. Disconnect()
[전체 코드]
Listener class
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
class Listener
{
Socket _listenSocket;
Action<Socket> _onAcceptHandler;
// endPoint를 이용해서 bind도 하고 listen도 하니까 초기화할 때 인자로 필요할 것이다.
public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
{
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_onAcceptHandler += onAcceptHandler;
_listenSocket.Bind(endPoint);
// backLog: 최대 대기수
_listenSocket.Listen(10);
// 한 번만 만들어주면 다음에 계속 재사용할 수 있다
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(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)
{
// Todo
_onAcceptHandler.Invoke(args.AcceptSocket);
}
else
Console.WriteLine(args.SocketError.ToString());
// 여기까지 왔으면 모든 일이 다 끝났다는 의미가 되므로, 다음 클라이언트를 위해 다시 등록해준다.
RegisterAccept(args);
}
}
}
Session Class
using System.Net.Sockets;
using System.Text;
namespace ServerCore
{
class Session
{
Socket _socket;
int _disconnected = 0;
public void Init(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()
{
if (Interlocked.Exchange(ref _disconnected, 1) == 1)
return;
_socket.Shutdown(SocketShutdown.Both);
_socket.Close();
}
#region Network communication
void RegisterRecv(SocketAsyncEventArgs args)
{
bool pending = _socket.ReceiveAsync(args);
if (pending == false)
OnRecvCompleted(null, args);
}
void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
try
{
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
}
}
Program class - ServerCore
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
namespace ServerCore
{
class Program
{
static Listener _listener = new Listener();
static void OnAcceptHandler(Socket clientSocket)
{
try
{
Session session = new Session();
session.Init(clientSocket);
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome!!");
session.Send(sendBuff);
Thread.Sleep(1000);
session.Disconnect();
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
static void Main(string[] args)
{
// 내 로컬 컴퓨터의 호스트 네임을 host 변수로 받아온다.
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
// 배열 0번 인덱스에 있는 주소만 사용할 예정
IPAddress iPAddress = ipHost.AddressList[0];
// 최종 주소. 식당으로 비유하자면 iPAddress는 식당 주소이고, port 7777은 문의 번호에 해당한다.
IPEndPoint endPoint = new IPEndPoint(iPAddress, 7777);
_listener.Init(endPoint, OnAcceptHandler);
Console.WriteLine("Listening...");
while (true)
{
//Socket clientSocket = _listener.Accept();
}
}
}
}
2. Session #2
Session #2에서는 Send()를 비동기로 만들어 본다. Listener나 Receive의 경우 비슷한 방식으로 구현이 됐다. 하지만 Send의 경우 간단하지는 않다. Receive는 던져놓은 다음에 어떤 클라이언트가 우리한테 메시지를 보낼 때 처리하면 됐다.
Send의 경우 예약을 하는 개념이 아니다. 내가 뭘 보낸지를 모르니까 그때그때 원하는 타이밍에 Send를 호출해야 하기 때문에 조금 까다롭다.
2.1. Receive()를 만들었던 방식에서 출발하기
2.1.1. 해당 방식의 문제점
그런데 OnRecvCompleted에서 RegisterRecv를 다시 불러줬던 것처럼, OnSendCompleted에서 다시 RegisterSend를 해줄 필요가 있을까? OnSendCompleted에 매개변수로 들어온 SocketAsync 이벤트(sendArgs)는 우리가 보내고 싶은 정보(Send의 매개변수 byte[] sendBuff)를 SetBuffer에 넣어서 설정해줬던 것이다. 그런데 이 sendArgs를 다시 RegisterSend해준다는 것은 아까 보낸 메시지를 다시 보내겠다는 의미이다. 같은 정보를 2번 보낼 필요는 없지 않을까?
즉, sendArgs는 recvArgs처럼 재사용할 수 없다는 의미가 된다.
sendArgs를 재사용할 수 없는 게 문제이긴 한데, 이것보다 더 치명적인 문제는 RegisterSend를 매번 다시하고 있다는 것이다.
만약 MMO가 굉장히 흥행해서 1000명의 유저가 동시에 같은 구역에 모여있다고 가정해보자.
그럼 일반적으로 A유저가 한 발자국 움직이기 시작하면 A가 움직였다는 정보를 주변의 모든 유저들한테 loop를 돌면서 한 번 씩 싹 다 보내줄 것이다. 그런데 누구는 움직이고, 누구는 스킬을 쓰고 그러니까 Send의 호출 횟수가 굉장히 많아진다. 이때마다 실질적으로 RegisterSend라는 애를 호출해서 SendAsync를 매번 호출하는 것은 문제가 있다.
왜냐하면 우리가MMO에서 성능테스트를 해보면 대부분 이런 Send랑 Receive하는 네트워크 송수신 부분이 가장 느리고 부하가 되는 부분이라고 나온다. 이전 시간에 커널(식당비유에서 식당 관리인)에 관한 이야기를 했었다. 근데 이런 네트워크 패킷을 보내는 것은 유저 모드에서는 당연히 불가능하고, 운영체제가 커널 단에서 다 처리를 해주는 것이다. 그렇기 때문에 얘를 이렇게 쉽게 막 쓰는 것은 문제가 된다.
2.2. SocketAsyncEventArg sendArgs를 재사용 가능하도록 수정하기
이벤트를 만들어서 매번 보내는 게 아니라 어떤 식으로든 조금 뭉쳐서 보내면 좋을 것 같다.
2.2.1. 이 방식에서 SetBuffer의 문제점: 동시에 Send를 호출할 경우 다른 내용으로 바뀔 위험
그런데 이렇게 고친 게 오히려 문제가 되는 부분이 있다. 멀티 시스템 환경에서 동시에 Send를 호출하는 경우이다. 우리는 별도의 새로운 이벤트로 만들어주는 게 아니라 동일한 이벤트를 계속 사용하도록 수정해주었다.
그런데 SetBuffer가 기존에 넣어준 sendBuff가 처리되지도 않았는데 마음대로 다른 내용으로 바꿔주면 에러가 난다.
우리가 하고 싶은 건 우리가 보내는 걸 매번 Register하는 게 아니라 어떤 큐(Queue)에 차곡차곡 쌓아서 한 번에 하나씩 내보내도록 만들 것이다.
RegisterSend()에서 _socket.SendAsync()가 끝나서 OnSendCompleted가 완료되기 전까지 데이터를 보내지 않고 Queue에 차곡차곡 쌓아뒀다가, 모든 걸 다 보내는 게 완료됐으면 다시 돌아와서 나머지 Queue를 비우는 방식으로 고쳐볼 것이다.
2.2.2. lock(obj) { }를 이용해서 동시에 Send() 못하도록 하기
그런데 우리는 Send라는 애를 멀티스레드 환경에서 실행할 수 있어야 한다. 누군가 동시에 Send를 할 수도 있으니까 Lock의 개념이 들어가야 한다. Lock을 쓰기 위해서 object 하나를 생성해준다(object _lock = new object();).
[C# 게임 서버] 멀티쓰레드 프로그래밍(2) 1.2. lock(obj)에서 아래의 문서를 보여준 적이 있다.
lock은 내부적으로 Monitor.Enter(obj)/Exit(obj)을 이용하여 구현되어 있기 때문에 object _key = new object();를 해서 lock(_key){ }를 해줬었다! 기억이 안 나면 lock부분을 복습할 것.
2.3. OnSendCompleted()에서 할 일
[최종 코드]
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
namespace ServerCore
{
class Program
{
static Listener _listener = new Listener();
static void OnAcceptHandler(Socket clientSocket)
{
try
{
Session session = new Session();
session.Init(clientSocket);
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome!!");
session.Send(sendBuff);
Thread.Sleep(1000);
session.Disconnect();
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
static void Main(string[] args)
{
// 내 로컬 컴퓨터의 호스트 네임을 host 변수로 받아온다.
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
// 배열 0번 인덱스에 있는 주소만 사용할 예정
IPAddress iPAddress = ipHost.AddressList[0];
// 최종 주소. 식당으로 비유하자면 iPAddress는 식당 주소이고, port 7777은 문의 번호에 해당한다.
IPEndPoint endPoint = new IPEndPoint(iPAddress, 7777);
_listener.Init(endPoint, OnAcceptHandler);
Console.WriteLine("Listening...");
while (true)
{
//Socket clientSocket = _listener.Accept();
}
}
}
}
using System.Net.Sockets;
using System.Text;
namespace ServerCore
{
class Session
{
Socket _socket;
int _disconnected = 0;
object _lock = new object();
Queue<byte[]> _sendQueue = new Queue<byte[]>();
bool _pending = false;
SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
public void Init(Socket socket)
{
_socket = socket;
SocketAsyncEventArgs recvArgs = new SocketAsyncEventArgs();
recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
recvArgs.SetBuffer(new byte[1024], 0, 1024);
_sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);
RegisterRecv(recvArgs);
}
public void Send(byte[] sendBuff)
{
lock (_lock)
{
_sendQueue.Enqueue(sendBuff);
if (_pending == false)
RegisterSend();
}
}
public void Disconnect()
{
if (Interlocked.Exchange(ref _disconnected, 1) == 1)
return;
_socket.Shutdown(SocketShutdown.Both);
_socket.Close();
}
#region Network communication
void RegisterSend()
{
_pending = true;
byte[] buff = _sendQueue.Dequeue();
_sendArgs.SetBuffer(buff, 0, buff.Length);
// pending==보류라는 의미
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($"OnRecvCompleted Failed {e}");
}
}
else
{
Disconnect();
}
}
}
void RegisterRecv(SocketAsyncEventArgs args)
{
bool pending = _socket.ReceiveAsync(args);
if (pending == false)
OnRecvCompleted(null, args);
}
void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
try
{
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
}
}
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
class Listener
{
Socket _listenSocket;
Action<Socket> _onAcceptHandler;
// endPoint를 이용해서 bind도 하고 listen도 하니까 초기화할 때 인자로 필요할 것이다.
public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler)
{
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_onAcceptHandler += onAcceptHandler;
_listenSocket.Bind(endPoint);
// backLog: 최대 대기수
_listenSocket.Listen(10);
// 한 번만 만들어주면 다음에 계속 재사용할 수 있다
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(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)
{
// Todo
_onAcceptHandler.Invoke(args.AcceptSocket);
}
else
Console.WriteLine(args.SocketError.ToString());
// 여기까지 왔으면 모든 일이 다 끝났다는 의미가 되므로, 다음 클라이언트를 위해 다시 등록해준다.
RegisterAccept(args);
}
}
}
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace DummyClient
{
class Program
{
static void Main(string[] args)
{
// 내 로컬 컴퓨터의 호스트 네임을 host 변수로 받아온다.
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
// 배열 0번 인덱스에 있는 주소만 사용할 예정
IPAddress iPAddress = ipHost.AddressList[0];
// 최종 주소. 식당으로 비유하자면 iPAddress는 식당 주소이고, port 7777은 문의 번호에 해당한다.
IPEndPoint endPoint = new IPEndPoint(iPAddress, 7777);
while (true)
{
Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
// 서버에서 받으면 연결이 되고, 아니면 계속 여기서 대기를 할 것.
socket.Connect(endPoint);
Console.WriteLine($"Connected to {socket.RemoteEndPoint?.ToString()}");
// 보낸다.
for (int i = 0; i < 5; i++)
{
byte[] sendBuff = Encoding.UTF8.GetBytes($"{i}번째 Hello Server!!");
int sendBytes = socket.Send(sendBuff);
}
// 받는다.
byte[] recvBuff = new byte[1024];
int recvBytes = socket.Receive(recvBuff);
string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes);
Console.WriteLine($"From Server: {recvData}");
// 나간다.
socket.Shutdown(SocketShutdown.Both);
socket.Close();
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
Thread.Sleep(1000);
}
}
}
}
[참고 사이트]
[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버
'Programming > Server' 카테고리의 다른 글
[C# 게임서버] 네트워크 프로그래밍(3) (0) | 2024.01.22 |
---|---|
[C# 게임서버] 네트워크 프로그래밍(1) (0) | 2024.01.16 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(4) (0) | 2024.01.14 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(3) (0) | 2024.01.12 |
[C# 게임 서버] 멀티쓰레드 프로그래밍(2) (0) | 2024.01.11 |