KoreanFoodie's Study

[C++ 게임 서버] 6-7. JobTimer 본문

Game Dev/Game Server

[C++ 게임 서버] 6-7. JobTimer

GoldGiver 2023. 12. 21. 11:21

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 6-7. JobTimer

핵심 :

1. JobTimer 를 사용하면, Job 의 배분을 일정 주기 이후 실행하도록 균등하게 배분할 수 있다.

2. 세션이 종료될 때 Memory Leak 이 일어나지 않도록 종료 처리를 잘 해주자. 

이전에 Job 의 처리를 쓰레드에게 어느 정도 균등하게 분배하기 위해 Tick 과 소유권에 대한 개념을 도입했다.

그런데 각 쓰레드 별로 Tick 을 루프를 돌며 체크하는 일은 엄청난 낭비가 아닐 수 없다. 😅 이 문제를 해결하기 위해, Global 하게 JobTimer 라는 녀석을 도입해 일정 시간 후, Job 을 알아서 처리하도록 만들어 보자.

 

JobTimer.h

struct JobData
{
	JobData(weak_ptr<JobQueue> owner, JobRef job) : owner(owner), job(job)
	{

	}

	weak_ptr<JobQueue>	owner;
	JobRef				job;
};

struct TimerItem
{
	bool operator<(const TimerItem& other) const
	{
		return executeTick > other.executeTick;
	}

	uint64 executeTick = 0;
	JobData* jobData = nullptr;
};

/*--------------
	JobTimer
---------------*/

class JobTimer
{
public:
	void			Reserve(uint64 tickAfter, weak_ptr<JobQueue> owner, JobRef job);
	void			Distribute(uint64 now);
	void			Clear();

private:
	USE_LOCK;
	PriorityQueue<TimerItem>	_items;
	Atomic<bool>				_distributing = false;
};

먼저, JobQueue 와 Job 을 들고 있는 JobData 를 만들어 준다. 순환 참조 문제를 해결하기 위해, JobQueue 는 WeakPtr 로 참조하도록 한다.

TimerItem 은 executeTick 과 JobData 에 대한 생포인터를 갖고 있고, 이 아이템은 JobTimer 클래스에서 PriorityQueue 로 정의된 _item 큐에 삽입될 것이다. 정렬 기준은, executeTick 이 더 작은 녀석부터 Pop 이 될 것이다.

_distributing 은, 현재 일감이 분배된 상태인지를 체크하는데 사용되는 flag 이다.

이제 실제 구현을 보자.

 

JobTimer.cpp

void JobTimer::Reserve(uint64 tickAfter, weak_ptr<JobQueue> owner, JobRef job)
{
	const uint64 executeTick = ::GetTickCount64() + tickAfter;
	JobData* jobData = ObjectPool<JobData>::Pop(owner, job);

	WRITE_LOCK;

	_items.push(TimerItem{ executeTick, jobData });
}

void JobTimer::Distribute(uint64 now)
{
	// 한 번에 1 쓰레드만 통과
	if (_distributing.exchange(true) == true)
		return;

	Vector<TimerItem> items;

	{
		WRITE_LOCK;

		while (_items.empty() == false)
		{
			const TimerItem& timerItem = _items.top();
			if (now < timerItem.executeTick)
				break;

			items.push_back(timerItem);
			_items.pop();
		}
	}

	for (TimerItem& item : items)
	{
		if (JobQueueRef owner = item.jobData->owner.lock())
			owner->Push(item.jobData->job);

		ObjectPool<JobData>::Push(item.jobData);
	}

	// 끝났으면 풀어준다
	_distributing.store(false);
}

void JobTimer::Clear()
{
	WRITE_LOCK;

	while (_items.empty() == false)
	{
		const TimerItem& timerItem = _items.top();
		ObjectPool<JobData>::Push(timerItem.jobData);
		_items.pop();
	}
}

먼저, Reserve 를 보면... 해당 Job 이 실행되기를 원하는 Tick 값과 JobData 를 큐에 넣어줌으로써, Job 이 실행되는 것을 예약하고 있음을 알 수 있다.

실제로 Job 을 배분하는 것은 Distribute 에서 일어나고 있는데... 일단 쓰레드 1개에게 일감이 배분되었으면 바로 return 을 때려 준다.

만약 배분된 상태가 아니라면, _items 큐를 돌면서 예약된 Job 을 보고 Tick 이 실행할 만한 상태라면 꺼내서 Vector 에 옮겨 준다. 참고로, LOCK 을 거는 것을 최소화하기 위해 item 들을 벡터에 복사하고, LOCK 이 없는 상태에서 실행을 해 주는 것이다.

그리고 주석을 보면, '한 번에 1 쓰레드만 통과' 라는 내용이 있는데... 이건 왜 체크해야 하는 것일까?

여러 쓰레드에게 distribute 가 가능하다고 가정해 보자. 사실 LOCK 이 걸리는 부분은 Job 을 꺼내는 부분인데... 아이템을 실제로 Execute 해주기 전에, 약간의 렉이 걸려서 다른 쓰레드가 뒤늦게 꺼낸 Job 이 먼저 '실행' 된다면...?

순차적으로 실행이 되어야 하는 Job 들의 순서가 꼬이는 상황이 발생하게 될 수도 있을 것이다! 😮 물론 이런 상황은 거의 발생하지는 않지만, 미리 방지해 주는 것이 좋으므로, distribute 받는 쓰레드는 한 번에 1개만 가능하도록 만들었다.

 

이제 JobQueue 는 DoAsync 대신, DoTimer 같은 함수로 JobTimer 를 통해 Job 을 예약만 해주면 될 것이다.

void DoTimer(uint64 tickAfter, CallbackType&& callback)
{
    JobRef job = ObjectPool<Job>::MakeShared(std::move(callback));
    GJobTimer->Reserve(tickAfter, shared_from_this(), job);
}

template<typename T, typename Ret, typename... Args>
void DoTimer(uint64 tickAfter, Ret(T::* memFunc)(Args...), Args... args)
{
    shared_ptr<T> owner = static_pointer_cast<T>(shared_from_this());
    JobRef job = ObjectPool<Job>::MakeShared(owner, memFunc, std::forward<Args>(args)...);
    GJobTimer->Reserve(tickAfter, shared_from_this(), job);
}

 

그럼 이제 GameServer 에서는 아래와 같이 Job 을 간단하게 예약해 줄 수 있다! 😉

int main()
{
	GRoom->DoTimer(1000, [] { cout << "Hello 1000" << endl; });
	GRoom->DoTimer(2000, [] { cout << "Hello 2000" << endl; });
	GRoom->DoTimer(3000, [] { cout << "Hello 3000" << endl; });
}

 

마지막으로, 메모리 Leak 방지를 위해, 클라이언트 연결이 종료되었을 때, GameSession 에서 메모리가 제대로 해제되도록 수정해 주자.

GameSession.h

class GameSession : public PacketSession
{
public:
	~GameSession()
	{
		cout << "~GameSession" << endl;
	}

	virtual void OnConnected() override;
	virtual void OnDisconnected() override;
	virtual void OnRecvPacket(BYTE* buffer, int32 len) override;
	virtual void OnSend(int32 len) override;

public:
	Vector<PlayerRef> _players;
	
	// 아래 추가
	PlayerRef _currentPlayer;
	weak_ptr<class Room> _room;
};

 

GameSession.cpp

void GameSession::OnDisconnected()
{
	GSessionManager.Remove(static_pointer_cast<GameSession>(shared_from_this()));

	// 아래 코드 추가
	if (_currentPlayer)
	{
		if (auto room = _room.lock())
			room->DoAsync(&Room::Leave, _currentPlayer);
	}

	_currentPlayer = nullptr;
	_players.clear();
}

이제 연결이 끊겼을 때, 현재 플레이어가 속한 룸에서 플레이어를 없애 줄 수 있게 되었다 😁

Comments