2. JobQueue #1
잡 큐가 뭔지 알아보기 전에 커맨드 패턴이 무엇인지 먼저 알아보도록 하자.
지금 만들고 있는 서버의 구조는 각 스레드들이 일감을 받아오고 그 스레드가 함수를 실행하거나 하는 방식이다.
식당을 예로 들면 직원이 손님한테 가서 주문을 받은 후 직원이 직접 주방에 들어가 요리를 하는 것이다. 이 방식이 잘못된 건 아니지만 만약 손님이 많아지게 되고 주방에 조리대는 1개밖에 없다면 1명의 직원만 요리를 하고 나머지 직원은 기다려야 되는 것이다.
그럼 이런 방식을 생각해볼 수 있다. 주방장이 따로 있고 직원은 주문서를 받아 전달만 해주는 것이다. 그렇게 되면 직원과 주방장의 일을 구분하여 나눌 수 있다. 또한 주문을 받은 시점과 요리를 하는 시점이 다를 수 있다는 것도 가장 큰 특징이다. 만약 주방장이 받은 주문이 너무 많다면 우선순위에 따라 순차적으로 처리하면 된다. 이때 직원은 다른 일을 수행할 수 있으므로 효율적이라고 볼 수 있다. 그리고 아직 요리가 시작하기 전이라면 주문서의 내용을 수정할 수도 있다.
이것이 커맨드 패턴의 핵심이다. 요청을 캡슐화해서 보내는 것이다. 이렇게 된다면 모든 스레드들이 락을 잡고 공유자원에 접근하는 것이 아니라 그 일을 담당하는 스레드에서 요청을 던지고 자신은 다른 일을 마저 하러 갈 수 있게 된다.
그래서 지금부터는 이 이론을 토대로 락을 걸지 않고 Job(Task)를 만들어 건내주는 방식으로 수정해 볼 것이다.
방식은 굉장히 많은데 가장 원시적인 방법부터 단계별로 하나씩 나아가보자.
// [요청 내용] : 1번 플레이어에게 10만큼 힐
// 행동 : heal
// 인자 : 1번 유저, 힐량 10
이런 요청이 있다고 생각해보자.
Job.h
#pragma once
class IJob
{
public:
virtual void Excute() {}
};
class HealJob : public IJob
{
public:
virtual void Excute() override
{
//_target 찾아서
//_target->AddHp(_heaValue)
cout << _target << "에게 힐 " << _healValue << endl;
}
public:
uint64 _target = 0;
uint32 _healValue = 0;
};
main.cpp
// Test Job
{
// [요청 내용] : 1번 플레이어에게 10만큼 힐
// 행동 : heal
// 인자 : 1번 유저, 힐량 10
HealJob healJob;
healJob._target = 1;
healJob._healValue = 10;
healJob.Excute();
}
// Job
기본 아이디어는 이렇다. HealJob이라는 요청을 만들어서 Excute를 해주면 요청이 실행된다.
이 방법은 직관적이지만 단점이 있다. 일감이 늘어날 때마다 개별 클래스를 하나하나 만들어야 하고 일감을 만들 때마다 객체를 만들어야 된다는 것이다. 그냥 생각해봐도 그렇게 효율적이지는 않아 보인다. 지금은 알아가는 단계이므로 일단 이 방식으로 전에 만들었던 채팅 서버 코드를 수정해 볼 것이다.
먼저 Job.h에 JobQueue라는 클래스를 만들어주어 일감을 순차적으로 받도록 해준다.
using JobRef = shared_ptr<IJob>;
class JobQueue
{
public:
void Push(JobRef job)
{
WRITE_LOCK;
_jobs.push(job);
}
JobRef Pop()
{
WRITE_LOCK;
if (_jobs.empty())
return nullptr;
JobRef ret = _jobs.front();
_jobs.pop();
return ret;
}
private:
USE_LOCK;
queue<JobRef> _jobs;
};
그리고 일감을 하나하나 클래스로 만들어야하기 때문에 Room을 수정해준다.
Room.h
#pragma once
#include "Job.h"
class Room
{
friend class EnterJob;
friend class LeaveJob;
friend class BroadcastJob;
private:
// 싱글 스레드처럼 코딩
void Enter(PlayerRef player);
void Leave(PlayerRef player);
void Broadcast(SendBufferRef sendBuffer);
public:
// 멀티스레드 환경에서는 일감으로 접근
void PushJob(JobRef job) { _jobs.Push(job); }
void FlushJob();
private:
map<uint64, PlayerRef> _players;
JobQueue _jobs;
};
extern Room GRoom; //테스트용
// Room Jobs
class EnterJob : public IJob
{
public:
EnterJob(Room& room, PlayerRef player) : _room(room), _player(player)
{
}
virtual void Excute() override
{
_room.Enter(_player);
}
public:
Room& _room;
PlayerRef _player;
};
class LeaveJob : public IJob
{
public:
LeaveJob(Room& room, PlayerRef player) : _room(room), _player(player)
{
}
virtual void Excute() override
{
_room.Leave(_player);
}
public:
Room& _room;
PlayerRef _player;
};
class BroadcastJob : public IJob
{
public:
BroadcastJob(Room& room, SendBufferRef sendBuffer) : _room(room), _sendBuffer(sendBuffer)
{
}
virtual void Excute() override
{
_room.Broadcast(_sendBuffer);
}
public:
Room& _room;
SendBufferRef _sendBuffer;
};
Room.cpp
#include "pch.h"
#include "Room.h"
#include "Player.h"
#include "GameSession.h"
Room GRoom;
void Room::Enter(PlayerRef player)
{
_players[player->playerId] = player;
}
void Room::Leave(PlayerRef player)
{
_players.erase(player->playerId);
}
void Room::Broadcast(SendBufferRef sendBuffer)
{
for (auto& p : _players)
{
p.second->ownerSession->Send(sendBuffer);
}
}
일감을 만들어서 큐에 푸시하는 것으로 만들었기 때문에 cpp를 보면 락을 걸지 않은 것을 볼 수 있다. 싱글스레드처럼 코딩하면 된다.
주의해야 할 점은 외부에서 바로 함수에 접근하는 것이 아니라 Job이라는 일을 만들어서 집어넣고 Excute로 실행해야 한다는 것이다. 또한 락을 아예 걸지 않는 것은 아니고 큐에 푸시, 팝을 할 때 잠깐이나마 락을 걸게 된다. 그래도 스레드들의 경합을 줄일 수 있다.
오늘의 방식은 새로운 함수가 만들어질 때마다 Job을 하나씩 만들어줘야한다는 불편함이 있었다. 다음에는 이런 1세대 방식보다 조금 더 원활하게 Job을 만들고 관리하는 방법을 알아볼 것이다.