Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
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
Archives
Today
Total
관리 메뉴

알고리즘 공부방

JobQueue(NW - Socket_Programming) 본문

카테고리 없음

JobQueue(NW - Socket_Programming)

head89 2024. 1. 6. 17:11

먼저 앞서 만든 소켓 프로그래밍이 잘 돌아가는지 검증하는 방법 중 하나가 채팅 테스트이다.

한 마디로 DummyClient 하나당 하나의 유저로 생각하고, DummyClient 숫자를 늘리면서 서버에 부하가 얼마나 오는지,

테스트를 해보는 것이다.

 

채팅 테스트를 구현했을 때 JobQueue를 사용하고 안 하고 차이가 얼마나 있을까?

 

먼저 JobQueue를 사용하지 않고 구현한 코드를 보자.

class GameRoom
    {
        List<ClientSession> _sessions = new List<ClientSession>();
        object _lock = new object();
        public void Enter(ClientSession session)
        {
            lock (_lock)
            {
                _sessions.Add(session);
                session.room = this;
            }
        }

        public void Lever(ClientSession session)
        {
            lock (_lock)
            {
                _sessions.Remove(session);
            }
        }

        public void Brodcast(ClientSession clientSession, string chat)
        {
            S_Chat packet = new S_Chat();
            packet.playerId = clientSession.sessionId;
            packet.chat = $"{chat} I am {packet.playerId}";
            ArraySegment<byte> segment = packet.Write();
            lock (_lock)
            {
                foreach(ClientSession session in _sessions)
                {
                    session.Send(segment);
                }
            }
        }
    }

 

위의 코드는 가상의 공간을 만들고, DummyClient가 Connect 할 때마다 가상의 공간에 들어가고, Disconnect 할 때 방을 공간에서 나가고, 자신이 어떤 행동을 했을 때 같은 공간에 있는 Client들에게 BrodCast 하는 코드이다.

 

위의 코드를 보면 어떤 문제가 있을까?

 

나는 크게 2가지의 문제점이 보인다.

1. 멀티 쓰레드환경이지만, Lock을 너무 남발하고 있다는 생각이 든다.

2. 명령이 들어오면 바로바로 실행하여 부화가 많이들 것 같다는 생각이 든다.

 

실제로 테스트를 해보면 BrodCast부분에서 엄청난 수의 쓰레드가 생성되는 것을 볼 수 있다.

 

이러한 문제점을 해결하기 위해선 JobQueue를 사용한다.

 

JobQueue는 실행해야 할 명령을 Queue에 저장시켜 놓고, 스레드가 일을 진행할 수 있을 때 들고 가서 하는 것을 말한다.

원래는 일을 하는 스레드와 받는 스레드가 같았다면,  일을 하는 스레드와 일을 받는 스레드를 나눈다고 생각하면 편하다.

그러면 JobQueue를 구현할 부분을 크게 보면

1. 명령을 JobQueue에 등록한다.

2. Queue에 쌓인 명령을 언제 처리할지 결정한다.

 

이러한 JobQueue를 구현하는 방법을 C#언어의 특성에 맞춰서 생각해 보면,

원래 명령을 실행하던 부분에서 deligate를 사용하여 함수를 Acion형태로 Queue에서 저장시키는 방법이 있고,

Task라는 Interface를 만들어 명령 행위자체를 클래스화 시켜 Task를 상속받고, Task를 Queue에 저장시키는 방법이 있다.

내 개인적인 생각으로는 후자 쪽 방법이 더 좋다고 생각한다.

먼저 후자 방법이 더 직관적이다. 명령 행위자체를 클래스화 시킴으로써 역할이 분리됨에 따라 다른 사람이 볼 때 편하게 볼 수 있을 것 같다.

두 번째로 모든 행위를 하나의 클래스에서 함수로 존재하면 클래스의 크기가 너무 커질 수 있다는 우려점이 있을 것 같다.

 

일단 내가 듣고 있는 강의에선 전자의 방식으로 진행하고 있다.

그래도 전자의 코드와 후자의 코드 모두 리뷰해 보겠다.

 

먼저 전자 쪽 코드를 보면

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ServerCore
{
    public class JobQueue : IJobQueue
    {
        Queue<Action> _jobqueue = new Queue<Action>();
        object _lock = new object();
        bool _flush = false;
        public void Push(Action job)
        {
            bool flush = false;
            lock (_lock)
            {
                _jobqueue.Enqueue(job);
                if (!_flush)
                {
                    _flush = flush = true;
                }
            }

            if(flush)
            {
                Flush();
            }
        }
        public void Flush()
        {
            while (true)
            {
                Action action = Pop();
                if (action == null) return;
                action.Invoke();
            }
        }

        Action Pop()
        {
            lock (_lock)
            {
                if (_jobqueue.Count > 0) return _jobqueue.Dequeue();
                else
                {
                    _flush = false;
                    return null;
                }
            }
        }
    }
}

 

JobQueue 클래스를 만들어, 일을 관리하는 코드이다.

여기서 눈여겨봐야 할 부분이 언제 일을 처리하는지 이다.

코드에선 스레드가 쉬고 있을 때를 bool타입 변수 flush를 이용하여 구현했다.

위에서 flush 변수를 두 개 쓴 이유는 멀티 스레드 환경이다 보니, _flash는 언제든 바뀔 수 있다. 그러다 보니 flsuh 변수를 두 개를 써서 멀티 쓰레드 환경에서의 방지역할을 한다.

 

이제 JobQueue를 통해 일을 관리하기에 원래 메서드를 바로 실행시키는 부분을 JobQueue에 넣는 코드를 넣어주면 된다.

class PacketHandler
{   
    public static void C_ChatHandler(PacketSession session, IPacket packet)
    {
        C_Chat p = packet as C_Chat;
        ClientSession clientSession = session as ClientSession;
        GameRoom room = clientSession.room;
        if (room == null) return;

        room.Push(() => { room.Brodcast(clientSession, p.chat); });
    }
}

 

이런 식으로 deligate를 활용하여 메스드를 Action화 시켜 전달해 주는 코드이다.

 

이렇게 되면 JobQueue에서 이미 멀티 스레드 환경을 고려해 일을 실행시켜 주기 때문에 위의 GameRoom 코드에서 lock을 모두 지워줄 수 있다.

 

 

두 번째로 위에 처럼 deligate를 사용하여 메서드를 Action화 시키는 것이 아닌, 행위를 클래스화 시키는 방법이다.

먼저 모든 Taks는 ITask라는 Interface를 상속받아 Execute라는 메서드를 갖게 만들어 나중에 JobQueue에서 Execute함수만 실행시키면 되게끔 만든다.

namespace Server
{
    interface ITask
    {
        void Execute();
    }
}

 

이제 예를 들어 Brodcast 부분을 Task화 시킨다 했을 때 위에서 Brodcast 함수를 구성하는 것을 멤버 변수로 만들고, 생성자에서 그것을 받고, Execute에서 메서드에 있는 것을 구현해 주면 된다.

   class BrodCastTask : ITask
    {
        ClientSession clientSession;
        string chat;
        GameRoom room;

        BrodCastTask(ClientSession clientSession, string chat, GameRoom room)
        {
            this.clientSession = clientSession;
            this.chat = chat;
            this.room = room;
        }

        public void Execute()
        {
            S_Chat packet = new S_Chat();
            packet.playerId = clientSession.sessionId;
            packet.chat = $"{chat} I am {packet.playerId}";
            ArraySegment<byte> segment = packet.Write();
            foreach (ClientSession session in room._sessions)
            {
                session.Send(segment);
            }
        }


    }

 

 

JobQueue의 경우는 Acion을 활용한 방식과 동일하게 가도 상관은 없다.

대신 Action의 Invoke메서드를 부르는 것이 아닌, ITask의 Execute 메서드를 부르는 것으로 바꾸면 될 것이다.

 

 

이렇게 채팅 테스트의 최적화를 진행해 봤지만, 여전히 문제가 존재한다.

Brodcast 메서드를 보면 여전히 N^2으로 진행되고 있다.

저런 문제를 해결하는 것이 패킷 모아 보내기이다.

즉, 하나하나 다 Send하는 것이 아닌, 패킷을 모아서 보내는 것이다.

이걸 Client단에서 패킷을 모아 보낼 수 있고, Sever단에서 패킷을 모아 보낼 수 있을 것 같은데,

이 부분은 다음 포스팅 때 더 공부해 보고 써야 할 것 같다.