본문 바로가기

Game Server

[C++/서버/컴퓨터구조] 메모리 구조와 Atomic

결론 : 멀티스레드로 오면서 CPU 파이프라인과 메모리에 write가 적용되는 과정에서 발생하는 가시성 문제를 해결하기 위해서 atomic을 이용해서 동일 객체 상 동일 수정 순서를 보장해주는 식으로 메모리 상 대입의 순서를 보장한다.

이 과정에서는 다양한 메모리의 정책이 있는데 크게 3개로 나눌 수 있다.

  1. Sequentially Consistent (seq_cst) - 가장 엄격(컴파일러 입장에서 최적화 여지가 적다. = 직관적), 기본 설
  2. Acquire-Release (acquire, release)
  3. Relaxed (relaxed)  - 가장 자유(컴파일러 입장에서 최적화, 직관적이지 않음)

[NC 면접 질문]


 

이전까지는 싱글 스레드를 기반으로 작업을 하는 환경이 많았기 때문에, 문제가 안 되던 CPU의 write 연산이 멀티 스레딩 환경에선 문제가 되는 경우가 많아졌다. 이런 경우 race condition 한국어로 경합 상황이 발생하게 된다. 이런 상황은 정의되지 않은 행동(undefined behavior)이기 때문에 Lock을 통한 Mutex 구현이나 Atomic한 연산을 할 수 있게 했었어야 했다. 그리고 이런 기능은 모던 C++로 오면서 표준으로 돌아오면서 메모리 접근에 대한 방식을 확고하게 만들어주었다.

 

Atomic을 좀 더 심화해서 알아보자면, 사실 이 방식은 모든 상황을 해결해주지 않는다. 유일하게 C++에서 보증하는 법칙은 다음과 같다. 어떤 Atomic 연산이 있다면 모든 스레드가 동일 객체에 대해서 동일한 수정 순서를 관찰한다는 법칙이다.

이는 풀어서 말하자면, 일단 동일 객체와 동일 수정 순서가 중요하다. 다음과 같은 코드가 있다고 가정하자.

atomic<int64> num;

void thread_1()
{
	// num = 1;
	num.store(1);
}

void thread_2()
{
	// num = 2;
	num.store(2);
}

void Thread_Observer()
{
	while (true) int64 value = num.load();
}

이런 경우 간발의 차이로 store(1)이 먼저 실행된다면 1이고, 간발의 차이로 store(2)가 되는 경우 2가 될 것이다. 그리고 이 때 옵저버가 있다고 가정해보자. CPU의 경우 N개의 코어를 가지고 있다. 그렇기 때문에 num의 값을 바로 수정했더라도 저버가 이 수정된 메모리의 값을 바로 읽는다는 보장이 없다. 즉 스레드가 값을 수정했다 해도, 다른 스레드 입장에선 그 사정을 모를 가능성이 있다는 점이다.

하지만 동일 객체에 대해서 동일한 수정 순서를 보장한다는 점이다. num의 숫자가 다음과 같이 변화한다고 가정하자.

이 num값은 처음에는 0으로 시작했다가 2로 그리고 1로 수정된 뒤 최종에는 5로 수정된다. 이 상황에 대해서 다른 스레드가 관찰하면, 직접적으로 고친 것이 아니라면 그 값을 바로 관찰하는 것이 아니다. 즉 관찰 시점은 다음과 같을 수 있단 것이다.

하지만 Atomic의 경우 0 → 2 → 1 → 5의 그 순서를 보장해준다는 점이 크다. 즉 값을 읽는데 있어서 시간의 역행이 일어나지 않고 가시성이 떨어질 수 있다는 점이다. 단 중간 값은 역행이 아니라면 스킵될 수 있다. 즉 읽는 순서가 0 → 2 → 5 또는 0 → 5 같은 읽는 순서 상황이 일어날 수 있다는 점이다. (즉 물리학적 시간 속에 돌아가는 우주관찰하는 느낌이다.) 

과거의 모습을 보고 있지만, 그럼에도 시간은 역행하지 않고 앞으로 간다.

단 주의할 점은 Atomic은 한 번에 일어나는 연산의 결과를 말한다. 즉 쪼개질 수 없는 연산을 말하며 CPU가 한번에 처리할 수 있는 연산을 말하는 것이다. 즉 한 번에 일어나는 연산에 대해서는 이 규칙이 성립한다는 것이다. 

int64 num;

void thread_1()
{
	num = 1;
}

void thread_2()
{
	num = 2;
}

64비트 컴퓨터인 지금은 연산이 한 번에 일어나겠지만, 예전 컴퓨터의 경우(32비트 컴퓨터 시절의 경우) 다음과 같은 코드를 실행한다면 내부적으론 한 번에 수정할 수 없어 상위 비트 4비트 수정 후 하위 비트 4비트를 수정하는 식으로 돌아갔을 것이다. 즉 어떤 상황에선 원자적이지만 어떤 상황에서는 원자적인 상황이 일어나지 않았을 것이다.  이 상황을 체크하기 위해서 C++에서는 다음과 같은 것을 지원한다.

// not static
std::atomic::is_lock_free()

true가 뜬다면 CPU 차원에서 원자적으로 처리할 수 있는 수라는 것이다. 즉 int64가 원자적으로 처리가 되는 것은 CPU가 64비트를 지원한다는 점이다. 하지만 만약 false가 뜬다면 CPU 차원에서 64비트 연산을 지원해줄 수 없다는 것이다. 즉 이 연산을 위해서 억지로 Lock을 잡아서 연산을 실행한 뒤 원자적으로 실행할 수 있게 유도한다는 것이다.

 

그리고 하나를 더 말하자면, 동일 수정 순서 말고도 동일 객체도 굉장히 중요하다. 

atomic<int64> num;

void thread_1()
{
	// num = 1;
	num.store(1);
}

void thread_2()
{
	// num = 2;
	num.store(2);
}

void Thread_Observer()
{
	while (true) int64 value = num.load();
}

이 코드에서 만약 num이 하나만 있지 않고 여러개 있다면, 같이 수정하더라도 순서가 서로 혼합된다는 점이다. 그리고 사실 저 코드는 원래 다음과 같다.

atomic<int64> num;

void thread_1()
{
	// num = 1;
	num.store(1, memory_order::memory_order_seq_cst);
}

void thread_2()
{
	// num = 2;
	num.store(2, memory_order::memory_order_seq_cst);
}

void Thread_Observer()
{
	while (true) int64 value = num.load(memory_order::memory_order_seq_cst);
}

즉 메모리를 읽는데에 대해서도 메모리 순서에 대한 정책이 존재한다는 점이다. 그리고 위에서 말한 가시성이 떨어지는 문제 또한 이 정책을 수정한다면 해결할 수 있다. 

/*
 * 지금까지 말한 것을 복습
 */

atomic<bool> flag = false;
	
// 원자성을 락이 없어도 보장이 되었는가
// 아니면 C++ 차원에서 이를 보장하는가를 체크하는 메서드
// true : CPU 차원에서 락이 없어도 원자적으로 보장
// false : CPU 차원에서 원자성을 보장할 수 없어
// Lock을 걸어서 원자성을 보장해준다.
flag.is_lock_free();

// 원자성을 보장하는가?
flag = true;
bool val = flag;

// 가독성이 상승.
flag.store(true, memory_order::memory_order_seq_cst);
val = flag.load(memory_order::memory_order_seq_cst);

문제 사항이 있는 코드를 보자.

	// 이런 flag 값을 prev에 넣고, flag 값을 수정
	{
		bool prev = flag;

		// 만약 이 도중에 다른 스레드가 prev를 수정하면
		// prev는 유효하지 않음

		flag = true;
	}

이런 경우는 일감이 두 줄에 즉 두 명령어 이상으로 실행되니 문제가 발생, 이를 한 번에 할 수 있게 해결하자.

bool prev = flag.exchange(true);

CAS의 경우 다음과 같이 사용을 해서 원자성을 보장한다.

// CAS (Compare And Swap) - 조건부 수정
{
	// 만약 flag 값이 expected 값이라면,
	// 나는 desired값을 수정할 것이다.
	bool expected = false;
	bool desired = true;
	flag.compare_exchange_strong(expected, desired);
    
    // if (flag == expected) {
    //		expected = flag;
    //		flag = desired;
    //		return true;
    // } else {
    //		expected = flag;
    //		return false;
    // }
}

위의 코드는 Strong을 기준으로 했는데 Weak또한 존재한다.

	{
		// 만약 flag 값이 expected 값이라면,
		// 나는 desired값을 수정할 것이다.
		bool expected = false;
		bool desired = true;
		flag.compare_exchange_weak(expected, desired);
        
        // Spurious Failure
        // if (flag == expected) {
        //		// 가끔 이 상황이 실패할 수 있음
        //		if(이상 상황) return false;
        //	
    	//		expected = flag;
    	//		flag = desired;
    	//		return true;
    	// } else {
    	//		expected = flag;
    	//		return false;
    	// }
	}

이것을 사용하는 이유는 동작 방식은 비슷하지만 내부 동작이 다르다. 저 가짜 실패로 인한, 즉 하드웨어상의 이유의 상황 때문에 실패할 수 있기 때문에, Weak가 좀 더 구체적인 이상상황에 대해 false를 띄어서 이를 처리하지 않도록 막아준다. Strong을 바로 return을 하는 것이 아니라  계속 루프를 돌면서 성공을 어떻게든 시킨다. 즉 Weak보다 Strong이 좀 더 무거운 연산이라 볼 수 있는 것이다. 


이제 atomic이 메모리에 어떤 정책을 가지고 있는지 알아보자. 위에서 본 memory_order에는 6가지 정책이 있다. 그 중 제일 중요한 것 3가지만 소개를 하면 다음과 같다.

  1. Sequentially Consistent (seq_cst) - 가장 엄격(컴파일러 입장에서 최적화 여지가 적다. = 직관적), 기본 설
  2. Acquire-Release (acquire, release)
  3. Relaxed (relaxed)  - 가장 자유(컴파일러 입장에서 최적화, 직관적이지 않음)

즉 버전에 따라서 컴파일러가 관여가 얼마나 되는가를 나타내는 것이다.

 

다음과 같은 코드가 있다고 가정하자.

atomic<bool> ready;
int32 val;

void Producer()
{
	val = 10;

	ready.store(true, memory_order::memory_order_seq_cst);
}

void Consumer()
{
	while (ready.load(memory_order::memory_order_seq_cst) == false);

	cout << val << endl;
}

이런 경우 가장 엄격하기 때문에, 사실상 가시성 문제가 바로 해결되고, 코드 재배치가 해결된다. 사실 위에서 열심히 떠들었단 것은 기본 정책을 따른다면 해결된다는 점이고 그렇기 때문에 우리가 볼 수 없었던 것이다.

근데 만약 가장 자유로운 Relaxed로 바꾼다면 어떻게 될까? 이 경우는 코드 재배치도 멋대로 가능하고 가시성 해결이 전혀되지 않는다. 이는 진짜 가장 기본적인 조건인 동일 객체 동일 관전 순서까지만 보장된다는 것이다. 그리고 이런 경우 Producer와 Consumer 간에서 순서 문제가 발생한다는 점이기 때문에, 거의 활용하지 않는다. 

 

Acquire-Release 관계는 딱 중간이다. 이 경우 Acquire와 Release의 짝을 맞춰줘야 한다.

void Producer()
{
	val = 10;

	ready.store(true, memory_order::memory_order_release);
    // 절취선 ----------
}

void Consumer()
{
	// 절취선 ----------
	while (ready.load(memory_order::memory_order_acquire) == false);

	cout << val << endl;
}

이렇게 된다면, release 명령 이전의 메모리 명령들이 해당 명령 이후로 재배치 되는 것을 금지를 한다. 즉 컴파일러가 val = 10이 ready.store 뒤로 가게 만드는 등의 최적화가 일어나지 않는다는 것이다. 그리고 acquire로 같은 변수를 읽는 스레드가 있다면 release 이전의 명령들이 -> acquire 하는 순간에 관찰이 가능하다. (즉 가시성이 보장)

 

하나 좋은 소식이라면 인텔이나 AMD 같은 경우 칩 자체가 순서의 일관성을 보장하기 때문에, Relaxed를 사용하던 기본 메모리 정책인 Sequentially Consistent를 사용하던 별 다른 차이가 없다. 하지만 ARM의 경우 구조 상의 차이가 있기 때문에, 메모리 정책 간 꽤 차이가 있는 편이다.