AccepterThread 수정하기

이창준, Changjoon Lee·2025년 8월 1일
0

Game Server Hyperion 🎮

목록 보기
4/14

프로젝트 : https://github.com/HoonInPark/ServerHyperion.git
본 포스트에 대한 내용은 feat/framesync 브랜치에 있다.

사담

한여름인데, 몸살 감기를 쎄게 앓고 거의 회복했다.
앓는 동안 정말 아무 생산적인 것도 안하고 누워있기로 다짐했는데, 매우 성공적이었다.
잘 쉬었다.
이제 점점 텐션을 올려보자.

문제

현재 나의 AccepterThread() 함수 내의 루프는 32ms 마다 계속 돈다.

void AccepterThread()
{
	while (mIsAccepterRun)
	{
		auto curTimeSec = chrono::duration_cast<std::chrono::seconds>(chrono::steady_clock::now().time_since_epoch()).count();

		for (auto client : m_ClientInfos)
		{
			if (client->IsConnected())
			{
				continue;
			}

			if ((UINT64)curTimeSec < client->GetLatestClosedTimeSec())
			{
				continue;
			}

			auto diff = curTimeSec - client->GetLatestClosedTimeSec();
			if (diff <= RE_USE_SESSION_WAIT_TIMESEC)
			{
				continue;
			}

			client->PostAccept(mListenSocket, curTimeSec);
		}

		this_thread::sleep_for(chrono::milliseconds(32));
	}
}

AccepterThread 이해하기에서 언급했듯, PostAccept(...) 함수 내의 AcceptEx(...) 함수가 Client Info 세션 객체의 접속을 받도록 예약을 걸어둔다.
연결 안됐으면 32ms마다 중복하여서 다시 접속 대기 예약을 건다.
만약 세션 풀링을 구현하게 되면, for문 전체에 lock을 걸고 풀을 순회해야 한다.
으 너무 싫어.

해결

그러면, 접속 완료된 세션 객체만 따로 모아 저장할 경우, 계속 이런 식으로 중복해서 접속 예약을 걸어둘 필요가 없지 않을까?

내가 지금 상상하는 동작 방식은 다음과 같다.
1. 서버가 시작할 때 모든 풀 원소(세션 객체)들에게 PostAccept(...) 함수로 예약을 걸어 둔다.
2. 접속이 들어오면 IO Thread에서 m_ClientInfoPool 풀에서 디큐, 접속 중인 세션 객체만 모아두는 자료형 m_ConnectedClientInfos에 담는다.
3. 접속이 끝나면 m_ConnectedClientInfos에서 제거, 다시 PostAccept(...) 함수로 예약하고, m_ClientInfoPool에 반환한다.
4. 반환하기 전 RE_USE_SESSION_WAIT_TIMESEC 만큼 기다리도록 비동기 수행.

완성된 코드는 다음과 같다.

void CloseSocket(shared_ptr<stClientInfo> pClientInfo, bool bIsForce = false)
{
	auto clientIndex = pClientInfo->GetIndex();
	pClientInfo->Close(bIsForce);
	OnClose(clientIndex);

	// 'cause io thread num is 4, it is mpsc scenario
	{
		unique_lock<mutex> Lock(m_ConVarLock);
		m_ClosedSessionQ.Enqueue(pClientInfo);
	}
	m_ConVar.notify_all(); // 'cause we has only one waitable thread, which is accepter thread.
}

CloseSocket(...)은 IO Thread에서 호출된다.
세션이 끝나면 m_ClosedSessionQ에 담아 AccepterThread이 접근할 수 있도록 한다.
담고 나면 AccepterThreadnotify_all()로 깨운다.
여기서 쓰이는 건 std::condition_variable이다.
그 전까진 AccepterThread는 sleep 상태여서 CPU 점유율은 0에 가깝다.

void AccepterThread()
{
	for (shared_ptr<stClientInfo> Elem : m_ClientInfoPool)
	{
		Elem->PostAccept(m_ListenSocket, 0);
	}

	shared_ptr<stClientInfo> pCliInfo;
	while (m_bIsAccepterRun)
	{
		{
			unique_lock<mutex> Lock(m_ConVarLock);
			m_ConVar.wait(Lock, [this] 
				{
					return !m_ClosedSessionQ.IsEmpty() || !m_bIsAccepterRun; // if false, it sleeps
				});

			if (!m_ClosedSessionQ.Dequeue(pCliInfo))
				continue;
		}

		thread([=]() mutable
			{
				this_thread::sleep_for(chrono::seconds(RE_USE_SESSION_WAIT_TIMESEC));
					
				if (pCliInfo)
				{
					pCliInfo->PostAccept(m_ListenSocket, 0);
					m_ClientInfoPool.Return(pCliInfo);
				}
			}).detach();
	}
}

먼저 for문으로 풀 객체 전체에 접속 예약 AcceptEx(...)를 걸어둔다.

wait(...) 내에 인수로 담기는 람다식은 리턴 타입이 bool이어야 한다.
만약 반환이 false면 스레드가 깨어났다 해도 해당 줄에서 다시 sleep이 된다.

서버가 내려갈 때 m_bIsAccepterRunfalse가 돼서 AccepterThread가 종료돼야 할 때 저기서 블록돼 있으면 안되니 !m_bIsAccepterRun을 조건으로 넣었다.
또, 이 경우 디큐해도 아무것도 없으니 continue.
잊지 말자 -- 서버가 내려갈 때 DestroyThread() 내에서 join()으로 AccepterThread를 기다리는데, 기다리기 전에 반드시 m_ConVar.notify_all();를 해줘야 한다.
안그러면 블록된 AccepterThread를 계속 기다리고 있게 된다.

깨어난 AccepterThread는 비동기적으로 2초 쉬고 원래 세션 객체가 대기하고 있었던 풀로 반환하는 행위를 한다.

반면, 그냥 스레드와 람다식을 그때그때 생성해서 비동기적 수행을 하도록 만드는 법도 있다.
다음과 같이 작성하면, 부모 스레드와 상관 없이 자신의 작업을 완료하고 사라지는 스레드가 만들어진다.

thread([...]() {
   // ...
}).detach();

이렇게 해서 내가 처음 의도했던 대로 개발이 됐다.
1. 세션 객체를 접속 여부로 따로 분류했다. 그것도 안전하게 RE_USE_SESSION_WAIT_TIMESEC 동안 쉬고.
2. 접속 예약 AcceptEx(...) 처리를 중복해서 걸지 않아도 된다.
3. AccepterThread는 필요할 때만 돈다.
4. 혹은 필요할 때 IO Thread가 다른 스레드를 생성해서 비동기적으로 수행한다.

profile
C++ Game Developer

0개의 댓글