서버/멀티스레드

10. 메모리 모델

광란의슈가슈가룬 2024. 7. 24. 00:28

전에 공부했던 것들을 복습해보자

- 여러 스레드가 동일한 메모리에 동시에 접근할 때, 그 중 write 연산에서 문제가 발생한다.

- Race Condition(경합 조건)이 발생한다.

- 이러한 행위를 Undefined Behavior(정의되지 않은 행동)이라고 한다.

   * Lock(mutex)를 이용하여 상호배제(mutual exclusion)을 만족시킨다.

   * Atomic(원자적) 연산을 이용한다.

 

c++은 "atomic 연산에 한해, 모든 스레드가 동일 객체에 대해서 동일 수정 순서를 관찰"하도록 보증한다고 한다.

 

atomic 연산 즉, 원자적인 연산이 꼭 Atomic 키워드를 사용하는 것이 아니라 cpu가 한 번에 처리할 수 있는 연산을 말한다.

이는 환경에 따라 달라지는데 예를 들어 8byte의 데이터를 입력할 때 64비트 환경에서는 한 번에 저장할 수 있지만 32비트 환경에서는 2번에 나누어 메모리에 접근을 해야한다. 따라서 32비트 환경에서는 원자적이지 않다고 말할 수 있다.

 

동일 수정 순서를 관찰한다는 것은 1 -> 2 -> 3 -> 4 순서로 값이 수정이 될 때 데이터를 읽어오는 입장에서 1 -> 2 -> 3 -> 4로 읽는다는 것이다. 물론 1 -> 3 -> 4, 1 -> 4이런식으로 건너 뛸 수도 있다. 일반적인 상황에서는 당연하게 보일 수 있지만 멀티스레드 환경에서는 당연하지 않을 수 있다. 어떤 스레드가 1 -> 2로 수정을 했지만 다른 스레드에서는 1이라는 데이터를 읽어올 수 있기 때문이다!

 

예전 글 Atomic에서 스레드가 sum값을 증가, 감소시켰지만 다른 스레드에서 이전의 값을 읽어와 증가, 감소시킨 값이 제대로 반영되지 않는 것을 보았다. 동일 수정 순서를 관찰한다는 것은 어떤 스레드가 1 -> 2 -> 3으로 값이 변하는 변수에서 2로 변하는 시점에 값을 읽었다고 가정하면 다른 스레드는 반드시 2 이후의 값을 읽어야 한다라는 말이다.(과거의 값을 읽을 수 없다!) 쉽게 말해 마지막으로 관측한 값 이후의 값만 읽을 수 있다!

 

#include <iostream>
#include <atomic>


int main()
{
	atomic<bool> flag = false;

	cout << flag.is_lock_free() << endl;
	// 출력 : 1
}

flag라는 변수를 아토믹 키워드를 사용해서 선언을 하였다. 이 때 is_lock_free라는 함수를 사용하면 해당 자료형의 데이터를 다룰 때(위의 코드에서 bool) 락을 걸어야 되는지의 여부를 알 수 있다. true(1)라면 flag = true;와 같은 연산을 할 때 굳이 락을 걸지 않아도 된다는 뜻이고 false(0)라면 atomic 클래스에서 내부적으로 락을 걸어 처리를 한다.

 

//flag = true;
flag.store(true);

//bool val = flag;
bool val = flag.load();

atomic 변수에 값을 저장하거나 가져올 때는 일반 변수처럼 사용해도 문제는 없지만 코드의 가독성을 위해 store과 load를 사용하자.

 

#include <iostream>
#include <atomic>


int main()
{
	atomic<bool> flag = false;
    
	bool prev = flag;
	flag = true;
}

flag의 값을 다른 변수에 저장한 후 값을 바꾸려고 한다고 가정해보자. 이 때 flag의 값을 prev에 저장을 하는 중 다른 스레드가 flag의 값을 바꾼다면 우리가 저장하려고 하는 flag의 값은 더 이상 유효하지 않게 된다. 또한 flag의 값을 읽고 쓰는 행위도 위와 같이 2줄, 즉 2번에 걸쳐 일어나는 것이 아니라 원자적으로 일어나야 할 것이다. 이런 기능을 해주는 함수가 있다.

#include <iostream>
#include <atomic>


int main()
{
	atomic<bool> flag = false;
    
	bool prev = flag.exchange(true);
	//bool prev = flag;
	//flag = true;
}

exchange라는 함수는 기존의 값을 반환하고 동시에 값을 바꿀 수 있다.(위 코드에서 prev = false, flag = true) 이 작업은 원자적으로 실행된다.

 

그럼 이제 메모리 모델에 대해 알아보자

앞서 보았던 store, load, exchange모두 그냥 사용해 왔지만 인자로 memory_order라는 것을 줄 수 있다.

memory order

flag.store(true, memory_order_seq_cst);

 

지금까지 그냥 사용해왔지만 디폴트로 위의 속성이 들어가있던 것이다!

 

메모리 모델에는 크게 3가지 정책이 있다.

1) Sequentially Consistant (seq_cst)

2) Acquire-Release (consume, acquire, release, acq_rel)

3) Relaxed (relaxed)

 

여기서 기억할 것을 seq_cst, acquire, release, relaxed 4가지이다. acq_rel는 acquire, release를 합쳐놓은 것이므로 굳이 신경쓰지 않아도 된다.

위로 갈 수록 엄격하고(컴파일러 최적화 여지 적음 = 직관적)

아래로 갈 수록 자유롭다.(컴파일러 최적화 여지 많은 = 직관적X)

 

1. seq_cst

#include <iostream>
#include <thread>
#include <atomic>

atomic<bool> ready;
int value;

void Producer()
{
	value = 10;

	ready.store(true, memory_order_seq_cst);
}

void Consumer()
{
	while (ready.load(memory_order_seq_cst) == false)
	{
	}

	cout << value << endl;
}

int main()
{
	ready = false;
	value = 0;
	
	thread t1(Producer);
	thread t2(Consumer);
	t1.join();
	t2.join();

}

가장 엄격한 속성으로 거의 모든 명령에 사용된다.(그러니까 디폴트이지 않을까...)가시성, 코드 재배치 문제를 모두 해결한다.

 

2. acquire, release

#include <iostream>
#include <thread>
#include <atomic>

atomic<bool> ready;
int value;

void Producer()
{
	value = 10;

	ready.store(true, memory_order_release);
	//-------------------------------------------------
}

void Consumer()
{
	//-------------------------------------------------
	while (ready.load(memory_order_acquire) == false)
	{
	}

	cout << value << endl;
}

int main()
{
	ready = false;
	value = 0;
	
	thread t1(Producer);
	thread t2(Consumer);
	t1.join();
	t2.join();

}

release는 이전의 메모리 명령들이 해당 명령 이후로 재배치 되는 것을 방지한다. 컴파일러는 결과가 같으면 최적화를 위해 코드를 재배치 하기도 한다.

ready.store(true, memory_order_release);

value = 10;

코드 재배치를 방지한다는 말은 컴파일러가 위와 같이 순서를 변경하는 것을 막겠다는 뜻이다. 단일 스레드 환경에서는 결과가 같지만 멀티스레드 환경에서는 오류가 생길 수 있다.

 

acquire 또한 이후의 명령들이 해당 명령 이전으로 재배치 되는 것을 방지한다. 위 코드의 점선이 방지선이라고 보면 된다.

만약 acquire로 같은 변수를 읽는 스레드가 있다면 release 이전의 명령들이 acquire하는 순간에 관찰 가능하다.(가시성 보장)

 

3. relaxed

#include <iostream>
#include <thread>
#include <atomic>

atomic<bool> ready;
int value;

void Producer()
{
	value = 10;

	ready.store(true, memory_order_relaxed);
}

void Consumer()
{
	while (ready.load(memory_order_relaxed) == false)
	{
	}

	cout << value << endl;
}

int main()
{
	ready = false;
	value = 0;
	
	thread t1(Producer);
	thread t2(Consumer);
	t1.join();
	t2.join();

}

가시성, 코드 재배치 모두 해결해 주지 않는다. 오로지 동일 객체에 대해서 동일 수정 순서 관찰만 보장한다. 값이 0 -> 1로 수정이 되어도 모든 스레드가 0을 읽을 수 있다는 뜻이다. 안그래도 고려해야할 것들이 많은 멀티스레드 환경에서 신경 쓸 부분이 더 많아지게 된다. 그래서 그런지 relaxed는 거의 사용하지 않는다.

 

인텔, AMD의 경우 애초에 순차적 일관성을 보장하여 seq_cst를 사용해도 별 다른 부하가 없다고 한다.

'서버 > 멀티스레드' 카테고리의 다른 글

12. Lock-Based Queue/Stack  (0) 2024.07.26
11. Thread Local Storage  (3) 2024.07.25
9. Future  (0) 2024.07.21
8. Condition Variable  (0) 2024.07.20
7. Event  (0) 2024.07.20