[C#] 비동기 소켓 통신(IOCP) - APM 패턴


Development note/C#  2020. 2. 2. 09:00

안녕하세요. 명월입니다.


이 글은 C#에서 비동기 소켓 통신(IOCP) - APM 패턴에 대한 글입니다.


이전에 비동기 소켓 통신(IOCP)에 대해 설명하고 그 방식에 대해 EAP(Event-based Asynchronous Pattern)로 소개했습니다.

링크 - [C#] 비동기 소켓 통신(IOCP) - EAP 패턴

이번에는 같은 비동기 소켓 통신이기는 합니다만 패턴이 조금 다릅니다.


EAP는 이벤트 타입으로 SocketAsyncEventArgs의 클래스의 Completed 이벤트를 이용해서 AcceptAsync와 SendAsync, ReceiveAsync의 대기, 송신, 수신을 했습니다.

이게 .Net framework 4.0 이전까지는 이렇게 했는데 4.0부터는 AsyncCallback의 델리게이트를 이용해서 BeginAccept아 BeginSend, BeginReceive의 대기, 송신, 수신을 합니다.

아마 4.0부터는 Lambda식에 대응하기 위해 이전의 이벤트 방식에서 델리게이트 방식으로 변한 듯 싶습니다.


개인적으로 성능이나 개념은 크게 바뀐 건 없어 보이고, 소스가 엄청 간단해 졌습니다.

using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
// 프로그램 개시 main은 아래에 있습니다.
namespace AsyncSocketServer
{
  // Client 클래스로 Client가 접속하면 생성됩니다.
  class Client
  {
    // 메시지는 개행으로 구분한다.
    private static char CR = (char)0x0D;
    private static char LF = (char)0x0A;

    private Socket socket;
    // 메시지를 모으기 위한 버퍼
    private byte[] buffer = new byte[1024];
    private StringBuilder sb = new StringBuilder();
    public Client(Socket socket)
    {
      this.socket = socket;
      // buffer로 메시지를 받고 Receive함수로 메시지가 올 때까지 대기한다.
      this.socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, Receive, this);
      // 접속 환영 메시지
      var remoteAddr = (IPEndPoint)socket.RemoteEndPoint;
      Console.WriteLine($"Client:(From:{remoteAddr.Address.ToString()}:{remoteAddr.Port},Connection time:{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})");
      
      Send("Welcome server!\r\n>");
    }
    // 메시지가 오면 호출된다.
    private void Receive(IAsyncResult result)
    {
      // 접속이 연결되어 있으면...
      if (socket.Connected)
      {
        // EndReceive를 호출하여 데이터 사이즈를 받는다.
        // EndReceive는 대기를 끝내는 것이다.
        int size = this.socket.EndReceive(result);
        // 데이터를 string으로 변환한다.
        sb.Append(Encoding.ASCII.GetString(buffer, 0, size));
        // 메시지의 끝이 이스케이프 \r\n의 형태이면 서버에 표시한다.
        if (sb.Length >= 2 && sb[sb.Length - 2] == CR && sb[sb.Length - 1] == LF)
        {
          // 개행은 없애고..
          sb.Length = sb.Length - 2;
          // string으로 변환한다.
          string msg = sb.ToString();
          // 콘솔에 출력한다.
          Console.WriteLine(msg);
          // Client로 Echo를 보낸다.
          Send($"Echo - {msg}\r\n>");
          // 만약 메시지가 exit이면 접속을 끊는다.
          if ("exit".Equals(msg, StringComparison.OrdinalIgnoreCase))
          {
            // 접속 종료 메시지
            var remoteAddr = (IPEndPoint)socket.RemoteEndPoint;
            Console.WriteLine($"Disconnected:(From:{remoteAddr.Address.ToString()}:{remoteAddr.Port},Connection time:{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})");
            // 접속을 중단한다.
            this.socket.Close();
            return;
          }
          // 버퍼를 비운다.
          sb.Clear();
        }
        // buffer로 메시지를 받고 Receive함수로 메시지가 올 때까지 대기한다.
        this.socket.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, Receive, this);
      }
    }
    // Send도 비동기 식으로 만들 수 있는데.. 굳이 send는 그럴 필요가 없습니다.
    // 메시지를 보내는 함수
    private void Send(string msg)
    {
      byte[] data = Encoding.ASCII.GetBytes(msg);
      //this.socket.BeginSend(data, 0, data.Length, SocketFlags.None, Send, this);
      // Client로 메시지 전송
      socket.Send(data, data.Length, SocketFlags.None);
    }
    // Send 비동기 식임.. 현재는 미사용.
    private void Send(IAsyncResult result)
    {
      // 접속이 연결되어 있으면...
      if (socket.Connected)
      {
        this.socket.EndSend(result);
      }        
    }
  }
  // 메인 Program은 Socket을 상속받고 서버 Socket으로 사용한다.
  class Program : Socket
  {
    public Program() : base(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
    {
      // 포트는 10000을 Listen한다.
      base.Bind(new IPEndPoint(IPAddress.Any, 10000));
      base.Listen(0);
      // 비동기 소켓으로 Accept 클래스로 대기한다.
      BeginAccept(Accept, this);
    }
    // 클라이언트가 접속하면 함수가 호출된다.
    private void Accept(IAsyncResult result)
    {
      // EndAccept로 접속 Client Socket을 받는다. EndAccept는 대기를 끝나는 것이다.
      // Client 클래스를 생성한다.
      var client = new Client(EndAccept(result));
      // 비동기 소켓으로 Accept 클래스로 대기한다.
      BeginAccept(Accept, this);
    }
    // 프로그램 시작 함수
    static void Main(string[] args)
    {
      // Program 클래스를 실행한다.
      new Program();
      // q키를 누르면 서버는 종료한다.
      Console.WriteLine("Press the q key to exit.");
      while (true)
      {
        string k = Console.ReadLine();
        if ("q".Equals(k, StringComparison.OrdinalIgnoreCase))
        {
          break;
        }
      }
    }
  }
}

위 소스는 Window의 telnet에서 메시지를 주고 받을 수 있게 맞추어서 작성했습니다.


그럼 telnet으로 접속해 보겠습니다.

ip는 로컬(127.0.0.1)로 설정하고 포트는 10000번입니다.

텔넷에서 메시지를 보내니 서버 측에서 메시지를 제대로 받습니다. echo도 제대로 수신됩니다.

종료까지 깔끔하게 통신이 이루어 집니다.


서버는 만들었고 이제는 Client를 만들겠습니다. client라고 해봐야 서버 소스와 거의 비슷합니다.

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

namespace AsyncSocketClient
{
  // 메인 Program은 Socket을 상속받고 클라이언트 Socket으로 사용한다.
  class Program : Socket
  {
    // 메시지는 개행으로 구분한다.
    private static char CR = (char)0x0D;
    private static char LF = (char)0x0A;
    // 메시지를 모으기 위한 버퍼
    private byte[] buffer = new byte[1024];
    private StringBuilder sb = new StringBuilder();

    public Program() : base(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
    {
      // 접속한다.
      base.BeginConnect(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 10000), Connect, this);
      while (true)
      {
        // 콘솔로 부터 메시지를 받으면 서버로 보낸다.
        string k = Console.ReadLine();
        Send(k + "\r\n");
        // exit면 종료한다.
        if ("exit".Equals(k, StringComparison.OrdinalIgnoreCase))
        {
          break;
        }
      }
    }
    // 접속되면 호출된다.
    private void Connect(IAsyncResult result)
    {
      // 접속 대기를 끝낸다.
      base.EndConnect(result);
      // buffer로 메시지를 받고 Receive함수로 메시지가 올 때까지 대기한다.
      base.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, Receive, this);
    }
    // 메시지가 오면 호출된다.
    private void Receive(IAsyncResult result)
    {
      if (Connected)
      {
        // EndReceive를 호출하여 데이터 사이즈를 받는다.
        // EndReceive는 대기를 끝내는 것이다.
        int size = this.EndReceive(result);
        // 데이터를 string으로 변환한다.
        sb.Append(Encoding.ASCII.GetString(buffer, 0, size));
        // 메시지의 끝이 이스케이프 \r\n와 >의 형태이면 클라이언트에 표시한다.
        if (sb.Length >= 3 && sb[sb.Length - 3] == CR && sb[sb.Length - 2] == LF && sb[sb.Length - 1] == '>')
        {
          // string으로 변환한다.
          string msg = sb.ToString();
          // 콘솔에 출력한다.
          Console.Write(msg);
          // 버퍼를 비운다.
          sb.Clear();
        }
        // buffer로 메시지를 받고 Receive함수로 메시지가 올 때까지 대기한다.
        base.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, Receive, this);
      }
    }

    // Send도 비동기 식으로 만들 수 있는데.. 굳이 send는 그럴 필요가 없습니다.
    // 메시지를 보내는 함수
    private void Send(string msg)
    {
      byte[] data = Encoding.ASCII.GetBytes(msg);
      //base.BeginSend(data, 0, data.Length, SocketFlags.None, Send, this);
      // Client로 메시지 전송
      Send(data, data.Length, SocketFlags.None);
    }
    // Send 비동기 식임.. 현재는 미사용.
    private void Send(IAsyncResult result)
    {
      // 접속이 연결되어 있으면...
      if (base.Connected)
      {
        base.EndSend(result);
      }
    }
    // 프로그램 시작 함수
    static void Main(string[] args)
    {
      new Program();
    }
  }
}

비동기 소켓으로 만든 클라이언트에서 서버로 접속이 잘됩니다.

https://docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/


여기까지 C#에서 비동기 소켓 통신(IOCP) - APM 패턴에 대한 설명이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.