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

G General님의 프로필 이미지
G General

작성한 질문수

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

Lock-Free Stack #2

아토믹 관련 질문입니다!

작성

·

210

0

NodeoldHead = _head;
while (oldHead && _head.compare_exchange_weak(oldHeadoldHead->next) == false) {};

Push 1개, Pop 3개로 Push와 TryPop에 중단점을 걸어놓고 한 줄씩 디버깅 해봤는데요. 이해가 잘 안되고, 디버깅 하면서도 잘 모르겠는 부분이 있어서 질문드립니다!

우선 디버깅 결과 Pop 3개 중 하나의 쓰레드가 승리하는 순간 나머지 2개의 쓰레드에서 _head값이 0x000000으로 null값이 되는 것을 확인했습니다. 승리한 쓰레드가 _head를 null로 바꿨기 때문에 이 점은 이해가 됩니다.

그런데 나머지 패배한 쓰레드들이 while문에서 어떻게 동작하는지 잘 모르겠습니다. 그러니까 && 연산자가 atomic하게 동작하는지 파악이 잘 안 됩니다. 당연히 atomic하지 않게 동작할 것 같은데 그럼 아래처럼 문제가 생길 여지가 있는 것 같습니다.

예를 들어서 딱 1개의 요소가 push된 상황에서, && 연산자가 atomic하지 않게 동작한다고 생각했을 때, t1, t2가 동시에 oldHead를 통과했고 t2는 아직 CAS에 진입하지 않은 상태를 생각해봤습니다.  t1이 CAS를 먼저 실행해서 _head를 null로 바꾸게 됐다고 또 가정해보면, t2가 호출하는 _head.CAS는 null.CAS이므로 크래시가 발생해야 하는데, 크래시가 발생하지 않습니다. 제가 떠올릴 수 있는 가능성은 다음처럼 두 가지입니다.

1) &&연산자가 atomic하게 동작한다.

2) 승리한 쓰레드가 바꾼 _head가 즉각적으로 반영되지 않았다.(즉, 아직까지는 크래시가 발생하지 않았다)

1번은 어셈블리어를 까보면 아무리 봐도 atomic하게 작동하지 않는 것 같습니다.

while (oldHead && _head.compare_exchange_weak(oldHeadoldHead->next) == false) {};
00007FF767E78582  cmp         qword ptr [oldHead],0  
00007FF767E78587  je          LockFreeStack<int>::TryPop+9Dh (07FF767E785ADh)  
00007FF767E78589  mov         rax,qword ptr [this]  
00007FF767E78590  mov         rcx,qword ptr [oldHead]  
00007FF767E78594  mov         r8,qword ptr [rcx+8]  
00007FF767E78598  lea         rdx,[oldHead]  
00007FF767E7859C  mov         rcx,rax  
00007FF767E7859F  call        std::atomic<LockFreeStack<int>::Node * __ptr64>::compare_exchange_weak (07FF767E7168Bh)  

위처럼 디스어셈블리를 까보면 7859F만 atomic한 것 같고 나머지는 atomic하지 않은 것 같아보입니다.

그럼 2번이 그나마 가능성이 높다는 말인데, 제가 디버그창에서 한 쓰레드가 승리한 순간, 다른 쓰레드의 _head값을 확인 했을 때 null값인 걸 보긴 했지만, 이게 위 어셈블리코드 때문에 확신이 서질 않습니다. 

t2가 null이 아닌 _head를 레지스터에 막 로드했는데(아직 oldHead는 로드하지 않은 상태), 마침 t1의 CAS가 껴들어서 oldHead를 null로 만들었다면, 곧이어 t2가 메모리에서 불러올 oldHead는 null값이게 되고, 따라서 oldHead->next를 로드할 때 크래시가 발생해야하는데, 아직 크래시가 발생 안한걸까요 아니면 접근방법이 틀린건가요? - (첫번째 질문)

그리고 아토믹의 의미가 정확하게 어떻게 되나요? 그냥 cpu가 수행하는 최소단위, 한 번 들어가면 끝날 때까지는 안 나오는 계산 정도로야 알고있지만, 구체적으로는 어셈블리 레벨에서의 단 1줄 짜리 명령인지 아닌지를 잘 모르겠습니다... - (두번째 질문)

다음으로 

if (--_popCount == 0) {
  00007FF767E78406  mov         rax,qword ptr [this]  
  00007FF767E7840D  add         rax,8  
  00007FF767E78411  mov         rcx,rax  
  00007FF767E78414  call        std::_Atomic_integral<unsigned int,4>::operator-- (07FF767E71159h)  
  00007FF767E78419  test        eax,eax  
  00007FF767E7841B  jne         LockFreeStack<int>::TryDelete+98h (07FF767E78428h)  
          // 운이 없어서 삭제 예약만 된 노드들을 싸그리 지운다.
          DeleteNodes(node);
  00007FF767E7841D  mov         rcx,qword ptr [rbp+8]  
  00007FF767E78421  call        LockFreeStack<int>::DeleteNodes (07FF767E711B3h)  
        }
  00007FF767E78426  jmp         LockFreeStack<int>::TryDelete+0AFh (07FF767E7843Fh)  

위와 같은 어셈블리에서도 궁금한 점이 있습니다.

운이 더럽게 나쁘면 jne를 통과한 후 mov 직전과 직후 단계 (Atomic DeleteNodes(78421) 사이의 단계)에서 쓰레드가 끼어들어서 popCount++을 한 극악의 상황도 발생할 수 있나요? - (세번째 질문)

답변 1

0

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

1)
&& 는 당연히 ATOMIC 하지 않습니다.
애당초 CAS 랑 && 두 개의 연산을 동시에 같이 할 순 없습니다.

2)
CAS 코드에서 굳이 어셈 분석은 도움이 되지 않습니다.
내부적으로 lock ~ 하는 단일 instruction으로 atomic 명령을 구현하는데
어차피 atomic 클래스 내부에 숨겨져 있으므로 봐도 아무 소용이 없습니다.
그리고 일부 타입의 경우 atomic 클래스를 사용하더라도,
내부적으로 mutex와 같은 lock방식을 이용해서 원자성을 구현할 수도 있긴 합니다.

3)

결과적으로 이 코드만 놓고 분석을 하시면 됩니다.
CAS 연산이 하는 것을 우선 잘 이해하고 계신지 확인이 필요하고,
의사 코드로 풀어 쓰면 아래처럼 됩니다.

Node* oldHead = _head;
while (oldHead)
{
    if (_head == oldHead)
    {
        _head = oldHead->next;
        break;
    }
    else
    {
       oldHead = _head;
   }
}

그 중 위에 Bold 처리 된 부분은 한 번에 처리된다의 차이는 있겠죠.
CAS 결과가 실패했으면 else로 들어오는 코드가 실행됩니다.
oldHead = _head로 다시 세팅한 다음, 다시 루프를 돌텐데.
이 때 만약 oldHead이 nullptr이면 빠져 나올테고 (데이터 없음으로 간주)
nullptr이 아니라면 다시 한 번 CAS를 실행하게 됩니다.
(최초 oldHead = _head;로 가정한 _head값이 다른 쓰레드의 개입으로 바뀐 상태로 간주)

이 부분은 스스로 더 고민을 해볼 필요가 있습니다.
CAS 코드의 작동이 헷갈리면 항상 풀어 써보면 도움이 됩니다.

또한 _head가 저장하는 데이터가 nullptr이라고 해서
_head.CAS가 크래시 나는 것은 절대 아닙니다.
_head 자체는 별도의 atomic 타입의 객체이고
그냥 내부 데이터(값)이 nullptr인 것 분입니다.

G General님의 프로필 이미지
G General
질문자

답변 감사드립니다!

어셈코드에서는 데이터를 각자 따로따로 로드한 뒤에 atomic 함수를 call하는 것처럼 보였는데, 굳이 신경쓸 필요는 없고 로드부터 call까지 한 방에 한다고 생각하면 될 것 같네요. 이게 애초에 처음에는 이렇게 이해하고 있었는데 다른 이유로 확인해볼게 있어서 어셈 까보니 로드부터 call까지 한 방에 안 일어나는것 같은데? 하는 의심이 생겨서 꼬여버렸네요..ㅠ

_head가 nullptr이라는 말은 곧 oldHead 역시 nullptr이라는 말인데, 이 때 oldHead->next가 크래시가 나니까 결국 _head.CAS가 크래시 나는것 아닌가요?

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

그 부분은 while oldHead로 널체크를 하고 있습니다

G General님의 프로필 이미지
G General
질문자

그게 예를들어 t2가 while oldHead를 통과하고 >> t1이 CAS연산을 끝마쳐서 _head를 null로 바꾸고 >> t2가 null인 _head를 읽는 순서로 발생하는 상황은 없나요?

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

 >> t1이 CAS연산을 끝마쳐서 _head를 null로 바꾸고
물론 이 케이스는 가능합니다.

>> t2가 null인 _head를 읽는 순서로 발생하는 상황은 없나요?
oldHead에서는 이미 oldHead = _head;로 이전 상태의 _head를 추출한 상태입니다.
뒤늦게 다른 쓰레드가 _Head를 nullptr로 바꾼다고 해도
oldHead는 예전히 null이 아님을 보증받은 (oldHead 널체크를 통해) 상태입니다.
따라서 oldHead->next 자체는 문제가 없는 상태이죠.

_head가 nullptr로 뒤늦게 바뀐 상태라면,
oldHead == _head가 성립하지 않으니 CAS가 실패해서 false를 리턴하지만,
그렇다고 딱히 크래시가 나는 상황은 아닙니다.
다시 강조드리지만 _head는 포인터가 아닙니다.
atomic 변수 자체적으로 들고 있는 내부 값이
nullptr이라고 atomic 자체의 사용에 딱히 문제가 되진 않습니다.

G General님의 프로필 이미지
G General
질문자

뒤늦게 다른 쓰레드가 _Head를 nullptr로 바꾼다고 해도
oldHead는 예전히 null이 아님을 보증받은 (oldHead 널체크를 통해) 상태입니다.

이해했습니다. 괜히 어셈 까봐서 oldHead를 메모리에서 다시 로드하는걸로 착각한 것 같습니다! 답변 감사합니당!

G General님의 프로필 이미지
G General

작성한 질문수

질문하기