Session을 직접 구현해보기 전에
컴퓨터 네트워크에서 송수신을 어떻게 하는 지 알아볼 필요가 있다.
소켓을 만들면, 내부적으로 커널 레벨에 [수신버퍼(RecvBuffer)]와 [송신버퍼(SendBuffer)]가 만들어진다.
우리가 서버 코드에서 Send를 요청할 떄 커널 송선버퍼에 데이터가 복사되면 성공으로 간주한다.
그 다음 운영체제가 이를 상대로 컴퓨터에 보내는 것을 처리할테고, 이 데이터는 상대방의 커널 송신버퍼에 들어가게 된다.
번대로, 우리가 Recv를 호출하면, 커널 수신버퍼에 있는 데이터를 유저레벨로 복사하는데 이때, 데이터가 1바이트라도 있으면 성공, 아니면 실패한 상황이 된다.
결국 나의 컴퓨터와 상대방의 컴퓨터가 서로 맞물린 구조이므로, 상대방이 재빨리 데이터를 Recv해서 수신버퍼를 비워주어야 우리 컴퓨터의 송신버퍼를 비울 수 있게 된다.
정리하자면 Send의 완료조건은 [커널 송신버퍼에 데이터를 복사할 수 있는 경우 == 상대방의 수신버퍼가 비워진 경우]이다. 그렇지 않으면 송신버퍼에 담긴 데이터가 상대방의 수신버퍼로 갈 때 까지 대기하면서 한참 후에 완료된다.
Recv의 완료조건은 [커널 수신버퍼에 1바이트라도 있는 경우]이다. 그렇지 않으면 수신버퍼에 데이터가 채워지기 전까지 완료되지 않는다.
필드와 초기화 함수부터 보자.
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();
// 이런 식으로 하면 RegisterSend 할 때 마다 Argument를 인자로 전달할 필요 없음
SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs();
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();
}
_socket은 Session 클래스에서 사용하는 소켓이고 프로그램에서 대리인의 소켓(clientSocket)을 넘겨받는다.
Disconnect는 원자성을 보장하면서 한 번만 실행할 수 있어야 한다. 연결이 끊어졌는데 다시 연결을 끊으면 에러가 날 것이다. _disconnected는 이를 위한 flag이다.
_lock은 말 그대로 lock(object obj)에 인자로 들어가는 자물쇠이다. 멀티쓰레드 환경에서 Send가 동시다발적으로 호출되는 것을 막기 위함이다.
_sendQueue는 Send로 정보를 보낼 때, 큐에 정보를 담아서 한 번에 보내기 위함이다. (최적화)
_pendingList는 Send 예약이 있는지 없는지 확인할 수도 있고, 큐에 담긴 정보를 옮겨 Send하는 역할이다.
_sendArgs와 _recvArgs는 이벤트에 대한 정보를 담고 있는 EventArgs이다. 또한 .Completed 이벤트 핸들러를 통해 이벤트가 완료될 시 함수가 실행되게 설정할 수 있다.
_recvArgs.SetBuffer()를 통해 데이터를 받을 버퍼를 설정한다. 그리고 Start함수에서 RegisterRecv를 호출하여 Recv를 예약한다.
public void RegisterRecv()
{
bool pending = _socket.ReceiveAsync(_recvArgs); // 비동기로 Receive 하겠다.
if(pending == false)
{
OnRecvCompleted(null, _recvArgs);
}
}
public void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
// 디도스 공격같은 과도하게 많은 정보를 받는 상황을 고려해야 한다.
{
// args.ByteTransferred가 0이면 연결이 끊어진 것 아니면 다 0 이상
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();
}
catch (Exception e)
{
Console.WriteLine($"OnRecvCompleted Failed {e}");
}
}
else // 연결이 끊어진 경우
{
Disconnect();
}
}
RegisterRecv를 통해 _socket에 비동기로 Recv를 걸어준다.
ReceiveAsync는 만약 데이터가 바로 수신버퍼에 들어왔다면 비동기가 아닌 동기로 실행을 시키고 false를 반환한다.
Recv가 완료되면 OnRecvCompleted가 실행된다.
만약 args.ByteTransferred가 0이면 아무런 데이터도 들어오지 않았다는 뜻으로 상대방과 연결이 끊어진 경우이다.
public void Send(byte[] sendBuff) // Send는 예약할 수가 없다.
// 언제 뭐라고 보낼지 어떻게 알음? 예약X
// 부하가 많이 걸리기 때문에 EventArgs를 재사용을 해야한다.
{
lock (_lock) // 멀티쓰레드 환경을 고려
{
_sendQueue.Enqueue(sendBuff);
if(_pendingList.Count == 0) // _pendingList가 0이 라는 뜻은
// 대기 중인 정보가 하나도 없다는 뜻
{
RegisterSend();
}
}
}
public void RegisterSend() // _pendingList가 비었을 때만 들어올 수 있다.
{
//_pending = true; // _pending 여부에 따라 Send를 예약했는지 판단
// _pendingList를 사용하므로 이제는 사용안한다.
while(_sendQueue.Count > 0) // 큐가 빌 때 까지
{
byte[] buff = _sendQueue.Dequeue();
_pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length));
// 한 번에 정보들을 BufferList 담아서 보내는 최적화 작업.
}
_sendArgs.BufferList = _pendingList;
bool pending = _socket.SendAsync(_sendArgs); // 비동기로 Send 하겠다.
if(pending == false)
{
OnSendCompleted(null,_sendArgs);
}
}
public void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
lock (_lock)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
try
{
_sendArgs.BufferList = null;
_pendingList.Clear(); // OnSendCompleted는 Send 작업이 완료되었을 때 실행되는 콜백함수
// 따라서 정보가 Send 됬으니 _pendingList를 Clear 해도 괜찮다.
Console.WriteLine($"Transferd Bytes {args.BytesTransferred}");
if(_sendQueue.Count > 0)
{
RegisterSend(); // 뭔 소리여
}
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
else
{
Disconnect();
}
}
}
Send 함수를 먼저 보자. Lock을 걸고, 매개변수로 받은 SendBuff를 Queue에 넣어준다. 그리고 pendingList가 비어 있을 때 RegisterSend를 호출한다.
RegisterSend는 Queue가 비워질 때 까지 Queue에 담긴 데이터를 pendingList에 옮긴다.
그리고 pendingList의 주소를 _sendArgs.BufferList에 할당해주고 SendAsync에 _sendArgs를 인자로 넘겨준다.
만약 상대방의 수신버퍼가 바로 비워져서 송신이 가능하다면 SendAsync은 false를 반환하고 동기로 실행한다.
이 상황에서는 pendingList가 비워있지 않기 때문에 Send에 접근한 다른 쓰레드들은 Queue에 데이터를 전달할 뿐 RegisterSend를 호출하지 못한다.
그리고 비동기적으로 송신버퍼에 데이터가 전달되어, Send가 성공했을 때 OnSendCompleted가 이벤트 핸들러로 호출된다.
OnSendCompleted에서는 pendingList를 비워주고 만약에 Queue에 보내주기로 예약된 데이터가 있으면 다시 RegisterSend를 실행한다.
추가로, OnSendCompleted가 이벤트 핸들러로 실행되었을 시 여러 쓰레드에서 동시에 접근하는 것을 막아주어어야 한다. 따라서 OnSendCompleted에는 Lock을 걸어준다. 반대로, RegisterSend는 Send에서 Lock이 걸린 상태로 호출되기에 굳이 Lock을 걸어주지 않았다.
참고) Program의 OnAcceptHandler 메소드에 대한 코드이다.
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();
session.Disconnect();
}
catch (Exception e)
{
Console.WriteLine(e);
}
}