인프런 커뮤니티 질문&답변

cckiz153님의 프로필 이미지
cckiz153

작성한 질문수

[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버

PacketSession

궁금한 점이 있습니다.

작성

·

368

3

강의 초반부에서 Session::Send()를 수정해주셨는데

 

{

    WRITE_LOCK;

    _sendQueue.push(sendBuffer);

}

if(_sendRegistered.exchange(true) == false)

    RegisterSend();

 

이렇게 작성하면 안되는 것일까요?

별도로 스택 변수를 사용해야 되는 이유가 있는지 궁금합니다.

멀티스레드 개념이 잘 안잡혀서 번거롭게 사소한 것까지 

여쭤봐서 죄송합니다..

답변 2

0

분석해 보았을 때 우려하신 문제가 발생하려면 꽤 까다로운 조건을 충족해야 합니다.

문제 상황 1(보낼 SendBuffer가 없는데 WSASend()를 호출)

1. Send가 걸린 상황

2. Thread A : Lock을 걸어 sendQueue에 데이터를 삽입 후 Lock을 품(Send).

3. Thread B : 완료 루틴에 의해 깨어나 sendQueue에 데이터가 있는지 확인하고 RegisterSend 호출(ProcessSend).

4. Thread B : sendQueue에 예약된 데이터를 전송(RegisterSend).

5. Thread C : 전송 결과를 완료 루틴으로 받고 sendRegistered 플래그를 품(ProcessSend).

6. Thread A : 이어서 sendRegistered 플래그를 확인하고 RegisterSend 호출(Send).

7. Thread A : sendQueue를 확인했으나 SendBuffer가 없는 상황이 발생(RegisterSend).

8. Thread A : 보낼 SendBuffer가 없는데 전송을 시도하니 WSAEINVAL(잘못된 인자) 에러가 뜨고 실패함(RegisterSend).

 

이 문제 상황 1을 더 깊게 파고 들면 다음의 연장선에 위치한 문제를 파악할 수 있습니다.

문제 상황 2(데이터를 넣었는데 보내지 않는 상황)

1. Send가 걸린 상황

2. Thread A : Lock을 걸어 sendQueue에 데이터를 삽입 후 Lock을 품(Send).

3. Thread B : 완료 루틴에 의해 깨어나 sendQueue에 데이터가 있는지 확인하고 RegisterSend 호출(ProcessSend).

4. Thread B : sendQueue에 예약된 데이터를 전송(RegisterSend).

5. Thread C : 전송 결과를 완료 루틴으로 받고 sendRegistered 플래그를 품(ProcessSend).

6. Thread A : 이어서 sendRegistered 플래그를 확인하고 RegisterSend 호출(Send).

7. Thread A : sendQueue를 확인했으나 SendBuffer가 없는 상황이 발생(RegisterSend).

8. Thread A : 보낼 SendBuffer가 없는데 전송을 시도하니 에러가 뜨고 실패함(RegisterSend).

int32 errCode = ::WSAGetLastError();
if (WSA_IO_PENDING != errCode)
{
    HandleError(errCode);

    _sendEvent.owner = nullptr;
    _sendEvent.sendBuffers.clear();

    // <-- 여기서 처리가 지연되는 상황이 발생

    _sendRegistered.store(false);
}

9. Thread D : Lock을 걸어 sendQueue에 데이터를 삽입 후 Lock을 품(Send).

10. Thread D : sendRegistered 플래그를 확인했는데 누군가 점유 중인 상태이니 RegisterSend를 호출하지 않음(Send).

11. Thread A : sendRegistered 플래그를 false로 바꿈(RegisterSend).

12. Thread D에서 넣은 SendBuffer를 처리하지 못 하는 상황이 발생.

정확히 말해선 7번 과정에서 sendQueue에 있는 내용을 전부 빼오고 Lock을 푸는 그 시점부터 WSASend() 호출 후 에러가 발생해 sendRegistered를 false로 바꾸기 이전의 모든 구간에서 문제 상황 2가 발생할 수 있습니다.

애초에 WRITE_LOCK을 걸고 sendQueue에 변화를 주는 로직과 sendRegistered의 상태를 전이하는 로직이 분리되어서 생기는 문제이기에 다음과 같이 Lock을 건다고 해서 해결되지 않습니다.

sendQueue는 이미 변화되었고 이를 탐지하지 못 했기에 발생한 문제이니까요.

int32 errCode = ::WSAGetLastError();
if (WSA_IO_PENDING != errCode)
{
    HandleError(errCode);

    _sendEvent.owner = nullptr;
    _sendEvent.sendBuffers.clear();

    WRITE_LOCK;

    _sendRegistered.store(false);
}

ProcessSend()의 문제가 아니라 여러 스레드를 통해 Send() -> RegisterSend()를 하는 과정에서 발생하는 문제입니다.
그리고 그 과정에서 문제 상황 1이 먼저 선행되어 에러가 발생할 수 있는 조건이 충족되어야 합니다.

0

Rookiss님의 프로필 이미지
Rookiss
지식공유자

정말 심오한 문제인데...
사실 이건 한 번 깨져봐야 터득할 수 있습니다.
스포일러를 드리자면, 당장은 별 문제가 없어 보이지만
나중에 _sendRegistered를 false로 초기화해주는 시점이 분명 있을겁니다.
그런데 정말 절묘하게 타이밍이 어긋나면

2쓰레드) RegisterSend 실행중
1쓰레드) _sendQueue.push
1쓰레드) if(_sendRegistered.exchange(true) == false)
 에서 exchange 실패해서 통과
2쓰레드) _sendRegistered = false

요런 식으로 어긋나서 아무도 전송을 안 하는 이슈가 생기게 됩니다.
그런데 이런건 겁내지 말고 이것저것 고쳐보면서 문제를 많이 만나보는게 학습할 땐 좋습니다.

cckiz153님의 프로필 이미지
cckiz153
질문자

강사님 답변 정말 감사드립니다.

exchange와 store가 서로 다른 스레드에서 동시에 수행될 때 문제가 발생할 수 있다는 것을 깨달았습니다.

강사님께서 말씀해주신대로

{

    WRITE_LOCK;

    _sendQueue.push(sendBuffer);

}

if(_sendRegistered.exchange(true) == false)

    RegisterSend();

와 같이  WRITE_LOCK을 풀고 RegisterSend를 하게 되면 답변해주신 것처럼 

에코 스트레스 테스트를 해봤을 때 약 10000개의 패킷 중에 1개 정도의 유실이 발생했습니다.

강사님께서 알려주신대로 exchange와 store가 동시에 실행될 수 있는 시점이 있는가에 대해서

이틀동안 고민해봤는데

Send()에서  _sendRegistered.exchange(true)를 수행하기 전에 WRITE_LOCK을 잡고

큐에 푸시한 후 LOCK을 해제하고

ProcessSend()에서는 WRITE_LOCK을 잡고 큐가 비었는지를 검사하고 이때 비어있어야먄

store를 수행하는데 이렇게 되면 exchange와 store가 동시에 수행될 수 있는 경우가 있는건지

아무리 고민해봐도  이해가 되질 않습니다..

 

USE_LOCK은 큐에 대한 동기화 객체이고 _sendRegistered는 WSASend 작업에 대한 동기화 객체라고 이해했는데 제가 잘못 이해한 것일까요? WRITE_LOCK을 잡고 RegisterSend()까지 수행해버리면 사실상 Session Send에 대한 전체 락이니 분리해야 효율적인 것은 알겠는데 왜 강사님께서 알려주신대로

WRITE_LOCK안에서 작업하면 문제가 없는데(스트레스 테스트를 했을 때에 당연히 문제가 발생하지 않았습니다..) 밖에서 sendRegistered를 건드면 안되는건지 이해가 어렵습니다.

 

 

3달 전 처음 이 강의를 봤을 때에도 이 부분이 너무 궁금해서 오랫동안 고민해봤지만 해결이 되지 않았고 강의를 처음 보고 이미 진도가 많이 지나갔지만 아직도 이해가 되지 않는게 왜 강사님 코드처럼

WRITE_LOCK안에서 RegisterSend()를 수행하면 문제가 없는데 밖에서 수행하면 문제가 발생하는지

너무 어렵습니다.

강의를 처음 들었을 때부터 너무 궁금했던 부분이라 염치불구하고 다시 질문드립니다.. ㅠㅠ

 

Rookiss님의 프로필 이미지
Rookiss
지식공유자

WRITE_LOCK 안에서 RegisterSend()를 하면 문제 없는 이유는
[_sendQueue에다 데이터를 처음으로 밀어넣는 애가무조건 실행도 책임지고 해준다]~라는
법칙이 성립하기 때문입니다.

if(_sendRegistered.exchange(true) == false)
    RegisterSend();

WRITE_LOCK 밖에다 이렇게 해도 얼핏 같아 보이지만,
아주 의미가 달라지고 큐에다 데이터를 넣는 행위와, RegisterSend가 
두 단계로 나눠지게 됩니다.

문제의 상황은 이미 어떤 쓰레드가 RegiterSend를 호출중일 때 발생합니다.
해당 쓰레드가 하는 일은
- 1) sendQueue에 예약된 모든 데이터 전송
- 2) 다 보냈으면 _sendRegistered 플래그를 꺼줌

위 2가지 일을 하는데, 만약 두 일감이 따로 따로 실행된다면 (두 단계로) 멀티쓰레드 이슈가 생깁니다.
왜냐하면 1과 2 사이에 절묘하게 
다른 쓰레드가 개입해서 sendQueue에다 데이터를 넣어줄 수 있기 때문이죠.
아직 2가 실행되진 않았으므로,
데이터를 방금 밀어넣은 쓰레드 측에서 살펴보면
    if(_sendRegistered.exchange(true) == false)
은 만족하지 않아 데이터를 보내려 하지 않습니다.



cckiz153님의 프로필 이미지
cckiz153
질문자

강사님 그런데 저희가 배운 강의코드를 보면 RegisterSend()나 ProcessSend()가 이미 수행 중인 상황에 푸쉬가 되더라도 푸쉬를 하기 위해선 락을 잡아야되고 ProcessSend()에서 큐가 비었는지 검사를 하기 전에도 락을 잡고 검사한 후 대신 보내주기 때문에 문제가 발생할 수 없지 않나요? 

Rookiss님의 프로필 이미지
Rookiss
지식공유자

_sendRegistered를 풀어주는 부분은 LOCK과 무관하게 별도로 실행되기 때문에
2 단계로 나뉘어지면 아래 그림과 같이, 중간에 틀어짐이 발생할 수 있습니다.
이 부분이 이해가 안가시면 더 고민을 해보세요. (왼쪽 : A쓰레드, 오른쪽: B쓰레드)

cckiz153님의 프로필 이미지
cckiz153

작성한 질문수

질문하기