[C# 서버] 소켓 프로그래밍 - JobQueue

이정석·2023년 8월 22일
0

CSharpServer

목록 보기
11/13

Client-Server

여러 클라이언트가 하나의 서버를 통해 통신하는 구조를 나타내면 아래 그림과 같다.

위 구조에서 클라이언트는 서버와 통신을 위한 Session을 가지고 있고 Session을 통해 다른 클라이언트와 데이터를 주고 받을 수 있다. 하지만, 서버는 특정 클라이언트에 대한 정보가 오면 다른 클라이언트에게 Broadcast해주기 위해 연결된 클라이언트들의 Session 목록을 가지고 있어야 한다.

예를들어, 그룹채팅 시스템에서 한 클라이언트가 서버에게 채팅을 보내는 상황이라고 하자. 채팅을 보내는 상황순서는 아래그림과 같이 나타낼 수 있다.

  • [1]: 클라이언트가 채팅패킷을 서버에게 보낸다.
  • [2]: 서버의 내부로직에 따라 받은 패킷에 대한 처리를 한다.
  • [3]: 들어온 채팅내용을 클라이언트에게 전송한다.

여기서 주의해야할 점은 Server에 등록된 Client와의 Session목록은 멀티쓰레드환경이기 때문에 각각의 Session에 채팅내용을 전송하는과정은 lock을 이용해 한번에 한 쓰레드만 접근가능한 환경이라는 것이다.

만약, N개의 클라이언트가 동시에 채팅을 입력한다면 서버는 N개의 채팅내용을 N개의 Client에게 전송해야 한다. 이러한 상황에서 한 쓰레드만 전송하도록하면 밀려오는 채팅내용을 전송하는데 대기하는 쓰레드는 점점 쌓이게 될 것이다.


JobQueue

한 쓰레드가 [2]를 진행하는 동안 다른 쓰레드는 대기하는 상황을 해결하기 위해 JobQueue를 사용하는 방법이 있다. JobQueue는 앞으로 해야할 일들을 Queue형식으로 저장하는 자료구조로 사용하며 대기하는 쓰레드는 JobQueue에 [2]라는 Job을 넣어주고 다른작업을 할 수 있다.

구현하고자하는 JobQueue는 일반 Queue와 다르게 Push 작업만 있다. JobQueue는 "~한 일을 할 수 있으면 하고 다른 쓰레드가 사용중이라면 Queue에 넣는 기능"을 수행하기 때문에 Pop은 필요가 없다. 물론 JobQueue에 존재하는 Queue에 진입할 때는 lock을 통해 하나의 쓰레드만 Enqueue하도록 해야한다.

    public interface IJobQueue
    {
        void Push(Action Job);
    }

JobQueue는 Action을 저장한다. 즉, Queue에는 실행할 함수를 넣으며 위의 예제에서는 클라이언트에게 채팅내용을 전송하는 함수를 넣는다. JobQueue에 접근하는 쓰레드의 실행 순서는 다음과 같다.

  1. Queue에 Action을 넣는다.
  2. 만약, 전송을 하고있는 쓰레드가 없다면 Queue에 있는 Action들을 직접 수행한다.
  3. 다른 쓰레드가 사용중이라면 종료한다.
  • JobQueue의 내부변수
    Queue<Action> _jobQueue = new Queue<Action>();
    object _lock = new object();
    bool _flush = false;
  1. _jobQueue: 실제 Action을 저장할 Queue
  2. _lock: lock을 위해 사용할 object
  3. _flush: 현재 Queue를 사용하고 있다는 것을 나타내기 위한 bool변수

_flush같은 경우 값을 변경할 때 멀티쓰레드환경을 고려해서 lock으로 잠긴 영역에서 값을 변경해야 다른 쓰레드의 진입을 막을 수 있다.

  • Push()
    public void Push(Action Job)
    {
        bool flush = false;

        lock (_lock)
        {
            _jobQueue.Enqueue(Job);
            if (_flush == false)
                flush = _flush = true;
        }

        if (flush)
            Flush();
    }

공유자원인 _jobQueue에 Enqueue할 때는 _lock을 이용해 임계구역을 형성해 작업을 해야한다. 그리고 Push함수는 Queue에 Enqueue만 하는것이 아닌 다른 쓰레드가 작업을 처리하고 있지 않으면 직접 처리해야한다. 지역변수와 임계구역을 사용해 현재 다른 쓰레드가 사용하고 있는지를 판별한 뒤 flush == true이면 직접 처리하도록 Flush함수를 호출한다.

  • Flush()
    void Flush()
    {
        while (true)
        {
            Action action = Pop();
            if (action == null)
            {
                _flush = false;
                return;
            }
            action.Invoke();
        }
    }

작업을 직접 처리하는 함수로 Queue가 빌 때까지 Action을 Invoke한다. 여기서 "Queue에서 Pop하는데 lock을 안해도 되는건가?"라고 생각할 수 있다. 하지만 위의 Push함수와 함께 생각하면 Flush함수에 접근하는 쓰레드는 단 하나만 존재한다. 즉, Flush함수에는 lock이 필요없다.

  • Pop()
    Action Pop()
    {
        if (_jobQueue.Count == 0)
        {
            return null;
        }
        return _jobQueue.Dequeue();
    }

Queue에 있는 값을 Pop하는 함수로 여기 역시 Flush와 같은 이유로 lock이 필요없다.

1. 최종코드

    public interface IJobQueue
    {
        void Push(Action Job);
    }
    
    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 == false)
                    flush = _flush = true;
            }

            if (flush)
                Flush();
        }

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

        Action Pop()
        {
            if (_jobQueue.Count == 0)
            {
                return null;
            }
            return _jobQueue.Dequeue();
        }
    }
profile
게임 개발자가 되고 싶은 한 소?년

0개의 댓글