프로젝트 : 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
이 접근할 수 있도록 한다.
담고 나면 AccepterThread
를 notify_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_bIsAccepterRun
가 false
가 돼서 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가 다른 스레드를 생성해서 비동기적으로 수행한다.