Tucker님의 "게임 네트워킹의 이해" 영상을 시청한 내가 Race Condition이라는 것에 대해 내가 알고 있었던 내용은, 멀티 쓰레드 방식에서 공유 자원에 접근하는 순서에 따라 결과가 달라질 수 있다는 것이었다. 또한 해당 영상에서는 "Locking을 쓰다보면 서로 간에 경쟁이 심해져서 Race Condition이 발생한다" 고 설명을 해서 Lock을 건 상태에서 발생하는 문제라고 이해하고 있었다. 그렇기 때문에 Race Condition 문제를 어떻게 해결하느냐? 라는 질문에 대해서 전혀 답을 낼 수 없었다. Lock을 걸은 상태이기 때문에 들어오는 순서에 따라 결과가 달라진다면, 들어오는 순서를 항상 일정하게 보장해줘야 할텐데, 언제 누가 들어올지도 모르는 상황에서 순서를 정한다는 것이 말이 안된다고 생각했기 때문이다.
사실 Race Condition은 Lock을 걸지 않은 상태에서 공유 자원에 동시 접근할 때 생기는 문제이다. 완전히 잘못 이해하고 있었다. 다른 개념일지는 모르겠지만, 적어도 찾아본 바에 의하면 영상에서 개념을 잘못 설명해준 것이다. 간단하게 해결책은 Lock을 걸어준다. 라고도 할 수 있을 것 같은데, 나는 Lock이 이미 걸려있는 상태에서 발생하는 문제라고 인식을 했기 때문에 완전히 번지수가 틀렸던 것이다.
그래서 오늘은 Race Condition, 그리고 이에 관련된 동기화, 임계 영역, 스핀락, 뮤텍스, 세마포어에 대해 자세히 알아보고 또 정리하고자 한다.
경쟁상황 (Race Condition), 뮤텍스, 세마포어 (tistory.com)
경쟁상황 (Race Condition), 뮤텍스, 세마포어
경쟁 상황(Race Condition)이란? 동시에 여러 개의 프로세스가 동일한 자료를 접근하여 조작하고, 그 실행 결과가 접근이 발생한 특정 순서에 의존하는 상황을 경쟁 상황(race condition)이라고 합니다.
yongc.tistory.com
맨 위 링크의 글과 2개의 유튜브 영상을 참조했다. 글도 잘 적혀있는 것 같은데, 이해가 살짝 어려운 부분이 있어서 쉬운코드님의 유튜브 영상을 함께 참조했다. 확실히 유튜브 영상이 잘 모르는 사람들을 배려해주는 경향이 있어서 이해하기 편한 것 같다.
가장 먼저 Race Condition이란, 여러 프로세스/쓰레드가 동시에 같은 데이터를 조작할 때 타이밍이나 접근 순서에 따라 결과가 달라질 수 있는 상황을 의미한다.
위와 같은 코드에서 thread_1을 쓰레드1이 실행하고, thread_2를 쓰레드2가 실행한다고 가정하면, 순서에 상관없이 각각 총 1000만씩을 더하고 빼기 때문에 count는 0이 될 것이라고 생각할 수 있지만, 실제로는 그렇지 않을 가능성이 있다.
count++와 같이 ++ 연산자는 왼쪽 프로세스 A에 있는 3줄의 동작을 수행한다. 문제는 이 일련의 동작을 수행하는 도중에 컨텍스트 스위칭이 발생할 수 있다는 것이다. 프로세스 A에서 수행을 하다가 중간에 멈추고 프로세스 B를 수행하게 되는 것이다. 그러면 프로세스 B를 돌면서 -1을 1000만번 반복하여 count에 -1000만이 들어가게 된다. 문제는 이 다음인데, 위의 사진에서 컨텍스트 스위칭이 발생한 지점에서 다시 실행한다면, 레지스터에 저장된 +1이라는 값이 count에 들어가는 부분부터 시작하게 된다. 즉, count가 +1로 덧씌워지는 것이다. 프로세스 B에서 연산했던 값이 사라지는 셈이다. 이 경우, 2개의 프로세스가 완료되었을 때 count에는 1000만이라는 값이 저장된다.
반대로, 프로세스 B가 먼저 실행되다가 동일한 상황에서 A로 컨텍스트 스위칭이 발생한다면, 2개의 프로세스가 완료되었을 때 count에는 -1000만이라는 값이 저장될 것이다. 즉, 접근 순서에 따라 결과가 달라진 것이다. 만약 컨텍스트 스위칭이 "레지스터가 count에 값 반환" 이후에 일어난다면, 결과는 0이 될 것이다. 이 경우는 타이밍에 따라 결과가 달라지는 셈이다.
따라서 Race Condition에서 중요한 것은 동기화를 하는 것이다. 동기화란, 여러 프로세스/쓰레드를 동시에 실행해도 공유 데이터의 일관성을 유지하는 것이다. 싱글 코어의 경우, 위의 예에서 인터럽트가 발생하지 않도록 해주는 방법이 있다. 다만 멀티 코어의 경우에는 여러 코어에서 동시에 프로세스를 실행할 때 문제가 발생할 수 있기 때문에 활용하기 어려운 방법이다. 가령 "레지스터에 count 할당" 부분을 두 코어에서 동시에 실행한다면, 두 프로세스가 돌아도 값은 1번만 증가할 것이다. 따라서 현실적인 방법은 count++ 혹은 count-- 부분에 한 번에 한 쓰레드만 접근할 수 있도록 제한을 걸어주는 것이다. 이를 위해 임계 영역(critical section)이라는 것을 설정한다. 임계 영역이란, 공유 데이터의 일관성을 보장하기 위해 하나의 프로세스/쓰레드만 진입해서 실행 가능한 영역을 의미한다.
위 사진처럼, critical section에 진입하기 전에, entry section에서 현재 진입이 가능한지 여부를 체크하고, 진입이 가능하다면 다른 쓰레드가 접근하지 못하도록 막아준다. 이후 critical section을 실행하고 벗어날 때 exit section에서 다른 쓰레드가 접근할 수 있도록 작업을 해준다. 이 세 가지 sction을 제외한 section이 remainder section이다. 이렇게 하면 한 번에 하나의 쓰레드만 임계 영역에 접근할 수 있게 되는 것이다.
또한, 이 방식을 제대로 활용하기 위해서 지켜야하는 3가지 조건이 있다. 상호 배제(mutual exclusion), 진행(progress), 한정된 대기(bounded waiting)이다. 먼저 상호 배제는 한 번에 하나의 프로세스/쓰레드만 임계 영역에 접근할 수 있도록 해야한다는 것이다. 진행은 임계 영역이 비어있고, 임계 영역에 진입을 원하는 프로세스/쓰레드가 있을 경우, 실행할 수 있도록 해야 한다는 것이다. 마지막으로 한정된 대기는, 어떤 프로세스/쓰레드가 임계 영역에 들어가기 위해 무한정 대기하는 상태가 되면 안된다는 것이다. 우선순위 스케줄링에서는 대기 시간에 따라 우선순위를 높여주는 방식을 사용해서 이것을 방지하는데, 임계 영역을 구현할 때도 이런 식으로 기아 상태가 발생하지 않도록 방지해줘야 한다는 것이다.
mutual exclusion을 보장하는 방법은 lock을 사용하는 것이다. 이전 사진의 entry section에서 acquire lock으로 lock을 획득하여 lock을 획득한 프로세스/쓰레드만 critical section에 접근할 수 있다. 이후 exit section에서 release lock으로 lock을 풀어주어 다른 프로세스/쓰레드가 접근할 수 있도록 한다. 이 lock을 거는 방법은 크게 3가지가 있는데, 각각 스핀락, 뮤텍스, 세마포어 방법이다.
*아래 코드들은 이해를 돕기 위한 예시이며 구체적인 동작 방식은 OS, 프로그래밍 언어에 따라 다를 수 있음
먼저 스핀락(spinlock)방식에 대해서 살펴보겠다. 위 사진에서는 lock 0일 때 임계 영역에 접근이 가능해진다. 만약 lock이 1이라면 while문을 계속 반복해서 돌면서 lock을 얻어올 때 까지 대기하게 된다. 임계 영역에 이미 진입한 프로세스/쓰레드가 있다면 임계 영역 실행을 마치고 lock을 0으로 초기화시켜준 이후에 다른 프로세스/쓰레드가 접근할 수 있게 된다.
+ TestAndSet는 CPU의 atomic 명령어로, 실행 중간에 간섭받거나 중단되지 않으며, 같은 메모리 영역에 대해 동시에 실행되지 않는다. 즉, lock을 얻어오는 요청을 동시에 실행한다고 하더라도 동시에 실행되지 않는다.
이 방식의 문제점은 lock을 얻어오기 위해 무한정 대기하기 때문에 CPU를 낭비한다는 점이다. 그동안 다른 작업을 처리할 수도 있는데 말이다. 때문에, lock을 얻기 위한 목록인 queue에 등록하고, lock 준비되면 queue에 있는 프로세스/쓰레드 중 하나를 깨우는 방식이 바로 뮤텍스(mutex) 방식이다.
뮤텍스 방식에서는 lock을 얻어올 수 없다면 현재 쓰레드를 큐에 넣고, 임계 영역을 마칠 때, 큐에 대기 중인 쓰레드가 있다면 그 중에 하나를 깨운다. 없다면 lock을 풀어서 다른 쓰레드가 접근할 수 있도록 해준다. guard는... 위에 왜 저렇게 쓰여 있는지나 영상을 봐서는 이해하기가 조금 어려운데, 검색해본 바에 의하면 C++11부터는 guard를 호출할 때 lock이 호출되고, guard가 소멸할 때 unlock이 호출된다는 것 같다. unlock을 까먹을 가능성을 차단해줘서 조금 더 편리하게 사용할 수 있는 것 같다. C++17부터는 scoped_lock이라는 것이 다중 잠금이 가능하여 lock_guard를 대체할 수 있는 것 같다.
보통은 뮤텍스 방법이 CPU를 놀리지 않기 때문에 안정적이고 효율적인 방법이지만, 멀티 코어 환경이고 임계 영역 내에서의 작업이 컨텍스트 스위칭보다 더 빨리 끝난다면 스핀락이 더 빠를 수 있다고 한다. 뮤텍스 방법은 큐에 대기 중인 쓰레드를 깨워서 컨텍스트 스위칭을 하기 때문에, 이것보다 빠르게 임계 영역에서의 작업이 끝나면 더 빠르게 다음 쓰레드로 넘어갈 수 있다는 것이다. 싱글 코어에서는 스핀락에서도 컨텍스트 스위칭이 필요하기 때문에 이점이 전혀 없다고 한다.
마지막으로 세마포어 방법은 signal mechanism을 가진, 하나 이상의 프로세스/쓰레드가 임계 영역에 접근 가능하도록 하는 장치를 이야기한다. 뮤텍스 방법과의 차이점은, lock이 wait이 되었고, unlock이 signal이라는 이름으로 변경되었다. 그리고 lock을 1, 0으로 설정해주는 것이 아니라 1만큼 더하고 뺀다. 초기 value가 1이라면 동시에 1개만 접근할 수 있지만, 초기 value를 2, 3, 4 등등으로 설정하여 한 번에 초기 value값 만큼의 프로세스/쓰레드가 임계 영역에 접근할 수 있도록 설정할 수 있다. 그리고 세마포어 방법은 signal mechanism을 가졌기 때문에 순서를 정해줄 때 사용할 수 있다.
위 그림의 경우, task2가 끝난 이후에 wait()를 통해 task1이 끝난 이후 전송되는 signal()을 기다린다. 이 경우, 반드시 task1이 실행된 이후에 task3가 실행된다. task3의 작업을 하기 위해 task1이 실행되어야 하는 경우에 이렇게 구현할 수 있다는 것이다.
마지막으로, 세마포어 방식의 초기 값이 1인 경우에 뮤텍스와 동일하게 보일 수 있지만, 실제로는 차이가 있다고 한다. 먼저 세마포어 방식은 우선순위가 높은 스레드가 락을 해제할 수 있다. 반면에 뮤텍스는 락의 소유자만 락을 해제할 수 있기 때문에, 어떤 프로세스가 락을 가진 상태에서 컨텍스트 스위칭이 되어 우선순위가 높은 프로세스가 해당 락에 접근하게 되면, 우선순위가 낮은 프로세스에 묶이게 되는 문제가 생긴다. 이를 해결하기 위해서 우선순위가 높은 프로세스가 얻으려고 하는 락을 가진 프로세스의 우선순위를 접근하려는 프로세스와 동등하게 높여주는 방식을 사용한다고 한다. 따라서 뮤텍스는 priority inheritance 속성을 가진다고 한다.
이렇게 Race Condition에 대해서 자세히 알아보고 정리해보았다. 새로운 것에 대해서 알아가는 것이 힘들기도 하지만 또 한편으로는 재밌기도 하고, 또 나의 지식을 확장시키는 일이기 때문에 만족감도 든다. 다만 알아갈수록 내가 모르는 영역이 참 많구나 하는 것을 새삼 깨닫기도 한다. 그래도 오늘 하루 또 새로운 지식을 얻은 것에 대해서 만족한다.
'개발 > 공부' 카테고리의 다른 글
Unity에서의 Vector(Normalize/normalized와 Garbage) (1) | 2023.05.08 |
---|---|
모바일 게임과 PC 게임의 차이 (0) | 2023.05.04 |
게임 네트워킹의 이해 3 - Socket Programming, MMORPG 서버 구조 (0) | 2023.05.01 |
게임 네트워킹의 이해 2 - P2P, Relay Server, 홀펀칭, Deterministic과 게임핵 (0) | 2023.04.29 |
게임 네트워킹의 이해 1 - 네트워킹, UDP/TCP, Deterministic-Delay/Rollback 방식 등 (0) | 2023.04.27 |