■ atomic의 한계
변수 하나만을 보호한다는 한계가 있음. 즉, 함수 내에 여러 공유 변수 전체를 보호할 수 없음.
또한 변수를 보호하더라도 load, store와 같이 대입 및 저장 같은 단순한 연산을 보호하는 것이고 동적할당을 하며 메모리를 옮겨다니는 벡터와 같은 컨테이너는 100% 보호 가능하다는 보장을 할 수가 없음.
■ 동적 할당에서의 멀티쓰레딩
아래 코드를 동작시키면 크래쉬가 발생한다.
그 이유는 앞서 배운 벡터의 원리를 생각하면 이해가 된다. Capcity를 들고있는데 Size가 Capacity와 같아진다면 벡터는 더 넓은 capacity를 차지하기 위해 모든 메모리를 이사시킨다. 이때 다른 쓰레드가 벡터에 접근하게 된다면 이사하고나서 메모리 주소가 바뀌었음에도 불구하고 이제는 사용하지 않는 전 주소를 활용하다보니 크래쉬가 나는 것이다.
그러면 이사를 하지 않을 정도로 Capacity를 최대한 많이 잡으면 어떨까? 그럼에도 불구하고 이는 정답이 아니다.
그 이유는 아래와 같다.
(1) 예측 못한 이상한 값이 도출된다.
(2) 그렇게까지 해야하나? 메모리 관리가 비효율적이다.
이상한 값이 나오는 이유는 Thread1이 벡터의 마지막 요소에 push_back하려고 할 때 Thread2도 마지막 요소에 push_back을 하려고 한다면 하나의 메모리에 데이터 두 개를 동시에 집어넣으려고 하는 경쟁상태가 발생한다.
이럴 경우 일부 요소가 중복 추가되거나 누락될 수가 있다.
■ 해결책
윈도우에선 CriticalSection을 이용하여 임계영역을 보호했었는데 윈도우 운영체제에 국한될 뿐더러
C++11부터 표준으로 lock을 제공해주므로 이젠 표준에 맞는 라이브러리를 사용한다.
■ 사용법
#include <mutex> 헤더를 추가한다.
mutex m; 과 같이 변수를 선언한다.
(mutex = Mutual Exclusive [상호배타적])
그 다음 사용하고자 하는 함수안에
lock()함수로 시작, unlock()함수로 끝내면 된다.
■ 신경써야할 것
(1) 그렇다고 모든 것을 lock 걸어서 끝내면 안된다. 이는 싱글스레드로 작업하는 것과 다를바가 없고 그렇다면 차라리 싱글스레드로 작업하는 것이 더 편하다. 적재적소에 맞게 lock을 사용해야 한다.
(2) lock을 하고나서 unlock을 꼭 해야한다. unlock을 안할 경우 다른 스레드가 접근 못한다.
■ Lock Guard
만약 수동으로 lock, Unlock 둘다 신경쓰기 힘들다면 래핑클래스를 하나 만들어 관리할 수 있는데 RAII (Resource Acquisition Is Initialization) 패턴이 있다.
이는 직접 래퍼 클래스를 만들어 사용해도 되고 표준에서 제공하는 std::lock_guard<T>를 사용해도 된다.
이러면 해당 클래스의 생몀 주기에 따라 함수 단락을 빠져나갈 때 소멸자가 실행되므로 알아서 unlock이 실행될 것이다.
이는 C++에서 표준으로 제공해주기도 한다.
(1) std::lock_guard = 위 클래스와 비슷한 원리
(2) std::unique_lock = 일반 lock_guard에 추가적으로 내가 원하는 때에 lock을 걸 수 있는 옵션을 줄수있음.
■ 스핀락 (Spinnlock)
스레드 1, 2, 3,... 이 있다고 가정하고 우리가 표준에서 제공하는 mutex가 아니라 직접 Lock을 만들어 사용했을 때 생길 수 있는 상황 중 스레드 1, 2가 동시에 코드를 호출하여 Lock을 해버릴 경우 둘이서 동시에 영역을 차지하려고 하는 문제가 발생한다. 이때 크래쉬가 일어나는데 이러한 현상을 해결하는 방법은 스핀락이다.
예로 아래 Push함수를 Thread1, Thread2가 실행한다고 했을 때 언젠가는 타이밍이 엇나가 _flag가 true인 상태로 데드락(deadlock) 상황이 발생할 수 있다.
이러한 상황을 해결하기 위해 앞서 공부한 RAII패턴을 사용하여 동시 접근을 제어하여 락을 안전하게 얻을 수도 있지만, RAII는 보통 메모리 리소스와 같은 자원을 안전하게 관리하는데 사용되고, 위와 같이 다중 스레드 환경에서 데이터의 일관성을 유지하는 목적이 짙다면 동기화를 위해 CAS(Cmpare-And-Swap) 연산을 활용한다고 한다.
아직까지 내 눈에는 둘의 큰 차이가 없어보이는데 ChatGPT말로는 CAS같은 경우 주로 락이 필요하지 않은 경량화된 동기화를 구현할 때 사용된다고 하는데 그게 어떤 상황인지 머리속에서 안그려져서 나중에 공부를 더 해보면서 익숙해져야겠다.. 지금은 잘 모르겠다.
[구현]
아래 atomic 변수의 compare_exchange_strong 함수를 활용하면 된다.
함수를 까보면 아래와 같은 구조인 것을 볼 수 있다.
보통 사용할 때는 아래와 같이 반복문안에 조건으로 걸어놓고 탈출을 위해 expected=false를 둔다고 한다.
[스핀락 vs 일반 Lock]
Spinlock | 일반적인 Lock |
· Spinlock은 Busy Waiting 상태로 lock이 해제될 때까지 계속해서 CPU를 소비한다. 즉, 다른 작업을 수행하는 대신 계속해서 lock이 해제될 때까지 반복해서 확인한다. (ex. KTX에서 화장실 순서를 기다리는데 앞 사람이 곧 나올 것 같아서 문 앞에서 쭉 기다리는 것) · Spinlock은 lock을 오랫동안 보유하지 않는 것이 중요한 경우에 사용된다. 예를 들어, 잠금이 매우 짧거나 일시적으로 잠겨 있는 경우이다. · 멀티코어 환경에서 특히 유용하며, 다른 코어에서 lock을 소유하고 있는 경우에도 효율적으로 대기할 수 있다. |
· 일반적인 lock은 대기하는 동안 스레드를 블록시키고 다른 작업을 수행할 수 있도록 CPU를 양보한다. (ex. KTX에서 화장실을 기다리는데 앞 사람이 너무 오래 걸릴 것 같아서 자리로 들어갔다가 이후에 다시 오는 것. 하지만 다시 돌아왔을 땐 다른 사람이 앞서 기다리고 있을 수도 있음) · 일반적인 lock은 보통 대기 큐나 대기 리스트를 사용하여 스레드를 대기시킨다. 스레드가 lock을 얻을 때까지 대기하고, lock이 해제되면 대기중인 스레드 중 하나가 lock을 얻게 된다. · 이 방식은 짧은 잠금보다는 장기적인 잠금이 필요한 경우에 적합하다. Busy Waiting에 비해 자원을 효율적으로 사용하며, 대기 중인 스레드가 깨어나면 즉시 실행할 수 있습니다. |
[요약]
따라서 스핀락을 사용할 때는 잠금을 소유하는 시간이 짧거나 lock 경합이 적은 상황에서 효과적이다. 그러나 잠금을 오랫동안 보유하거나 경합 조건이 발생할 가능성이 있는 경우엔 일반적인 lock 을 사용하는 것이 더 합리적이다.
■ 데드락 (Deadlock)
교착 상태라고도 불린다. 두 스레드가 서로 차지하고 있는 것을 원할 때, 혹은 특정 상황에서 무한 루프에 빠지게 되는데 이러한 것을 데드락이라고 한다.
[대표적인 상황]
(1) lock을 해줬는데 unlock 을 안해줬을 때
-> 이땐 레퍼클래스나 표준에서 제공하는 lock_guard 등을 활용해서 자동으로 해제하도록 세팅한다
(2) 서로 원하는 자원을 물고 있을 때
[위 해결 방법]
이런 상황에선 코드 순서를 아래와 같이 바꿔주면 된다.
(3) 그 외 등등 많지만 보통은 코드 순서의 문제이고 대부분 QA중에 발견을 하지 못하고 동접자가 많은 런타임 중에 발견하는 때가 대부분이다.
멀티쓰레드는 아무리 공부해도 실무로 많이 데어봐야 익숙해질 것 같다.. 이론을 아무리 이해해봤자 어디선가 실수할 것 같다.
'개발 (언어) > C++' 카테고리의 다른 글
[MultiThread] Event, Condition Variable (0) | 2024.04.08 |
---|---|
[MultiThread] 공유 자원, Atomic (0) | 2024.04.07 |
[MultiThread] 캐시 및 CPU 명령어 파이프라인 (1) | 2024.04.07 |
[MultiThread] 쓰레드 생성 (1) | 2024.04.07 |
[Modern C++] weak_ptr, unique_ptr (0) | 2024.03.31 |