하아찡

[C++ 11]std::atomic, SpinLock 본문

C++/추가공부

[C++ 11]std::atomic, SpinLock

하아찡 2024. 12. 27. 01:50

atomic은 원자라는 뜻을 가지고있는데

원자적인 연산을 한다라고 생각을 하시면됩니다.

원자적인 연산이란게 무엇일까? 더이상 쪼개질 틈이 없어 다른 쓰레드가 연산할  틈을 안준다라고 생각하시면 될거같습니다.

그래서 atomic은 둘중에 한개입니다. 전부 처리를했다 혹은 처리를 하지못했다  이두가지로 나눠지게 됩니다.

 

그러면 저게 도대체 무슨소리인가? 코드를 전부처리했다? 처리하지못했다? 이게 뭐가 중요할까요? 둘다 중요합니다. 하지만 여기서 또 봐줘야할점이 "다른 쓰레드가 연산할 틈을 안준다" 이것도 중요하다고 봅니다.

 

이게 사실 싱글쓰레드에선 별일이 아닙니다. 그저 순서를 지키고 처리가 될뿐인데, 별일이 아닌곳은 바로 멀티쓰레드 환경에서는 매우 중요한 일입니다.

 

왜? 멀티쓰레드환경에서 중요한일 인가?

싱글쓰레드 환경에선 우리가 "A처리하고 B처리해줘" 명령을 순차적으로 시키게 되는데

멀티쓰레드 환경에선 "A와 B처리해줘" 라고 명령을 내릴 수 있게됩니다. 각각의 쓰레드에게 A처리와 B처리를 맡겨버리면 되닌깐요. 바로 이과정에서 문제가 발생할 수 있습니다.

 

만약에 A와 B에서 같은 변수의 값을 변경하게되면 어떻게될까요?

예를하나 들어봅시다

int sum = 0;
A쓰레드{
	sum += 50;
}

B쓰레드{
	sum += 100;
}

cout << sum << endl;

위 상황처럼 A쓰레드가 sum에게 50을 더하고 B쓰레드가 sum에게 100을 더했으니 우리가 생각하기엔

sum의 출력값이 당연하게도 150이 나와야하는데...

결과가 150이 나올 수도 있고, 안나올 수도 있습니다.

이게 무슨소리냐?

우리가 쓰레드를 생성을 하면 각각의 코어가 쓰레드를 동작시키게 됩니다.

 

우리가 일반적으로 생각했을때 sum이라는 메모리 주소를 가지고있으니 해당 주소의 값을 참조해서 값을 더하면 되는 일인데, 사실 연산이 들어갈때 위와같은 방식으로 연산이 처리가 되지않습니다.

 

우리가 메모리에 변수값들을 할당을 하고 해당 연산을 진행할때 해당 주소의 값을 캐쉬로 불러와서 불러와진 값을 가지고 연산을 하게 됩니다.

 

이 과정에서 문제가 발생하게됩니다.

A쓰레드와 B쓰레드가 동시에 메모리에 존재하던 값을 가지고오는데 sum이 0이였던 상태를 보고 가져올 수도 있는 상황인겁니다.

A쓰레드와 B쓰레드는 동시에 sum에 0이라는 값을 가져와서 

A쓰레드는 캐쉬로 가져왔던 sum(0)값 과 50을 더한값을 다시 sum에게 넣은 것 이고

B쓰레드도 마찬가지로 sum(0)값과 100을 더한값을 다시 sum에게 넣게된 것 입니다.

 

이러한 과정을 처리하게되다보니 해당 과정은 50이 나올수도있고 100이나올수도있고 150이 나올 수도 있는 상황이 된겁니다.

 

여기서 포인트는 그겁니다. 메모리에 저장이 됐어도 해당 메모리의 값을 캐쉬로 가져갔을때 값의 오류(데이터 경쟁)가 발생할 수 있는 상황이 된겁니다.

 

직관적인 모습

 

 

 

더욱 자세한 내용은 아래 사이트 가셔서 확인하시면 더 좋아요!

https://modoocode.com/271

 

씹어먹는 C++ - <15 - 3. C++ memory order 와 atomic 객체>

여러분의 코드는 여러분이 생각하는 순서로 작동하지 않습니다. (단일 쓰레드 관점에서) 결과값이 동일하다면 컴파일러와 CPU 는 명령어의 순서를 재배치 할 수 있습니다. 문제는 이렇게 마음대

modoocode.com

 

 


 

자이제 그러면 SpinLock을 살펴봅시다

 

일단 SpinLock은 뭐일까요? 이름 그대로 돌면서 Lock을 잡을라고하는겁니다.

기존에 다른쓰레드가 Lock을 소유하고있으면 다른쓰레드는 해당 Lock을 소유 할 수 없게됐죠.

그래서 다른쓰레드는 Lock을 얻기위해 계속 "야 됐냐?" 하면서 빙빙 돕니다. 언제까지? Lock을 소유할때까지.

그러면 빙빙 도는동안 CPU자원을 계속 먹고 있는거 아닌가? 맞습니다. 그래서 Lock을 오래 잡아먹지 않는 작업에 SpinLock을 처리합니다.

그러면 SpinLock의 장점은 뭔데?

문맥교환(Context Switching)이 발생하지 않습니다.

아... 문맥교환 저게뭔데...

 

현재 쓰레드가 CPU를 점유하고 있다가 다른 쓰레드로 전환하는 과정에서 발생하는 작업입니다.

해당 과정에서 잘봐야할게 CPU를 점유하고있으면 발생하지 않습니다. SpinLock은 CPU를 계속 점유하고 있기때문에 문맥교환이 발생하지 않는 장점이 있게된겁니다.

 

문맥교환이 얼마나 성능 저하를 하기에 저게 장점인가...?

쓰레드가 변경이 되면 해당 코어 캐쉬에 불러져있던 이전 쓰레드에 데이터가 사실상 필요가 없게됩니다.

그리고 새로 불러와진 쓰레드는 메모리에서 다시 캐쉬로 데이터를 불러오는 과정을 진행해야 하죠.

 

아무튼 이러한 이유때문에 Lock을 오래동안 잡지 않는경우엔 SpinLock을 사용하는것도 좋다.

 

자 그러면 우리는 SpinLock을 어떻게 만드는지 확인을 해야하죠.

 

바로 코드로 봅시다.

우리는 위에서 atomic을 배웠기에 바로 본론으로 넘어갑니다.

 

class SpinLock {
public:
    void lock() {

        bool expected = false;
        bool desired = true;

        while (!_locked.compare_exchange_weak(expected, desired)) {
            expected = false;
        }
    }

    void unlock() {
        _locked.store(false);
    }

private:
    atomic<bool> _locked = false;
};

int32 sum = 0;
SpinLock spinLock;
void Add() {
    for (int i = 0; i < 100000; i++) {
        lock_guard<SpinLock> guard(spinLock);
        sum += i;
    }
}

void Sub() {
    for (int i = 0; i < 100000; i++) {
        lock_guard<SpinLock> guard(spinLock);
        sum -= i;
    }
}
int main()
{
    mutex m;
    std::thread t1(Add);
    std::thread t2(Sub);

    
    if (t1.joinable()) {
        t1.join();
    }
    if (t2.joinable()) {
        t2.join();
    }

    cout << sum << endl;

}

 

참 간단하죠!

 

atomic에 우리가 알아야할 두가지 함수가 있습니다

compare_exchange_strong과

compare_exchange_weak이 있습니다.

 

특징 compare_exchange_strong compare_exchange_weak
정확도 항상 정확한 비교 "Squrious Failure"를 허용
거짓 실패( Squrious Failur ) 없음 발생 가능
성능 반복적인 사용에서 약간 느릴 수 있음 반복적인 사용에서 더 빠를 수 있음
사용 사례 단일 CAS연산으로 완료가 필요한작업 반복으로 CAS를 확인하는 작업

 

두개의 차이는 위 표처럼 존재하게 됩니다.

 

정확도야 뭐 둘다 정확하게 처리를 하겠다만 compare_exchange_weak에 거짓실패( Squrious Failure ) 이놈이 무엇인가를 알아 봐야합니다.

 

본론부터 말하면 진짜 거짓된 실패입니다.

?? 그게 무슨소리인데

성공상태 유무를 상관하지않고 하드웨어 최적화 때문에 임의로 실패처리를 하는겁니다. 해당 과정에서 내부적으로 이득을 챙겨 성능향상에 도움을 주게됩니다.

 

말이 어려우니 코드로 봅시다.

일단 compare_exchange_strong부터 살펴봅시다.

bool compare_exchange_strong(T& expected, T desired) {
    atomic_lock(); // 원자적 접근을 위한 하드웨어 락
    if (atomic_value == expected) {
        atomic_value = desired; // 값 교체
        atomic_unlock();
        return true; // 교체 성공
    } else {
        expected = atomic_value; // 현재 값을 expected로 업데이트
        atomic_unlock();
        return false; // 교체 실패
    }
}

atomic_value => 자신의 값

expected => 현재 자신의 값과 비교할값

desired => 위 두개가 맞을때 교체될 값

 

값을 교체를 성공하면 true를 반환하며 값도 변경이 됩니다.

 

자그러면 compare_exchange_weak은 어떨까요? 아래코드는 유사코드입니다. 눈에 보이기위해 추가된 코드가 있습니다.

bool compare_exchange_weak(T& expected, T desired) {
    atomic_lock(); // 원자적 접근을 위한 하드웨어 락
    if (atomic_value == expected) {
        atomic_value = desired; // 값 교체
        atomic_unlock();
        return true; // 교체 성공
    } else {
        // 거짓 실패를 허용
        if (random_failure_condition()) {
            atomic_unlock();
            return false; // 거짓 실패 발생
        }

        expected = atomic_value; // 현재 값을 expected로 업데이트
        atomic_unlock();
        return false; // 교체 실패
    }
}

 

읭? 위에꺼랑 뭐 거의 똑같네? 

추가된거는 뭐

// 거짓 실패를 허용
if (random_failure_condition()) {
		atomic_unlock();
		return false; // 거짓 실패 발생
}

이거밖에없네? 위 코드는 임의로 추가한겁니다.

실질적으론 내부 하드웨어 동작으로 인해 거짓 실패가 발생하는 차이점입니다.

반응형

'C++ > 추가공부' 카테고리의 다른 글

[C++] 메모리 모델  (0) 2024.12.27
[C++ 11] condition variable(조건 변수)  (0) 2024.12.27
[C++] Event  (0) 2024.12.27
[C++ 11] SleepLock  (1) 2024.12.27
Volatile 예약어  (0) 2024.10.30