전에 공부했던 것들을 복습해보자
- 여러 스레드가 동일한 메모리에 동시에 접근할 때, 그 중 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라는 것을 줄 수 있다.
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 |