반응형

코드 재배치 및 메모리 가시성문제 예제(https://3dmpengines.tistory.com/2197)

 

이 글에 이어가기 전에 약간 복습을 하고 가면..

 

동시에 값을 접근하여 읽는건 문제가 되지 않지만 쓸때 문제가 되고 

이때 경합이 일어난다 (Race Condition)

쓸때 아무 처리 하지 않으면 정의되지 않은 행동이 일어난다

 

결국엔 공용데이터에 접근 하려면 lock (mutex) 또는 atomic 연산을 이용하여 해결해야 한다

즉 lock 또는 원자적(Atomic = All or Nothing) 방식 중 하나를 선택해서 해결해야한다

원자적연산이란 더이상 쪼개 질수 없는 연산 즉 한번에 실행 될수 있는 연산을 말한다 꼭 atomic<> 을 말하는 것은 아님

 

 

본론으로 돌아와서...

 

Atomic 에 한하여 모든 스레드가 동일 객체에 대해 동일한 수정 순서를 관찰한다는 법칙이 있다

 

atomic<__int64> num;

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

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

void threadObserver()
{
	while (true)
	{
		__int64 value = num.load();
	}
}

 

thread_1 , thread_2 에서 값을 num 에 쓸때는 한번에 하나 밖에 쓰지 못한다

그런데 다른 스레드에서 값을 계속 관찰 한다고 했을때 value = num.load()  

thread_1, thread_2 의 스레드에서 연산이 이뤄지고 있어서 서로 다른 스레드에서 다른 값이 어떻게 수정되고 있는지에 대해서는 threadObserver 에서 그 값을 꺼내와서 관찰 할 수가 없다!

 

하지만 이때 동일한 객체에 대해서 동일한 수정 순서를 관찰 하는 것은 가능하다

 

 

A:

 

B:

 

변수에 대한 수정 순서가 0 , 2, 1, 5 의 순으로 수정되는 순서를 갖는다고 할때

A 에서 변수 값을 그 당시 현재 관찰할때 0을 관찰한 상황이라 가정한다

 

시간이 좀 지나서

B: 상태가 되고 화상표 있는 곳 까지 진행 되는 도중에 값을 관찰 할때는 다른 스레드에서는 값이 2로 관찰 될 수도 있고 또는 0으로 관찰 될 수가 있다는 것 (다른 코어 캐시에 의해) 이때 중요한것은 A 에서 이미 값은 0 이였음으로 0 이전에 어떤 값이 있었다면 그 이전으론 당연히 가지 않는다는 것이다, 0 을 포함 미래에 대한 것만 관찰이 가능하다는 것이다

 

그리고 관찰 되는 값은 B: 시점 기준에서

0 2,1,5

0,2 5

0, 1,5 

이렇게 미래에 대한것들로 관찰 될 수가 있다

 

 

 

 

C++ 의 모든 객체들은 수정 순서(modification order) 라는 것을 정의할 수 있습니다. 수정 순서라 하는 것은, 만약에 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 있다고 하였을 때, 해당 객체의 값의 변화를 기록한 것이라 보면 됩니다. (물론 실제로 존재하지 않고, 가상의 수정 순서가 있다고 생각해봅시다.)

 

a 의 수정 순서

 

만약에 위 처럼, 어떤 변수 a 의 값이 위와 같은 형태로 변화해왔다고 해봅시다.

 

C++ 에서 보장하는 사실은, 원자적 연산을 할 경우에 모든 쓰레드에서 같은 객체에 대해동일한 수정 순서 를 관찰할 수 있다는 사실입니다.

여기서 강조할 점은 순서 가 동일하다는 것이라는 점입니다.

 

쉽게 말해 어떤 쓰레드가 a 의 값을 읽었을 때, 8 로 읽었다면, 그 다음에 읽어지는 a 의 값은 반드시 8, 6, 3 중에 하나여야 할 것입니다. 수정 순서를 거꾸로 거슬러 올라가서 5 를 읽는 일은 없습니다.

 

모든 쓰레드에서 변수의 수정 순서에 동의만 한다면 문제될 것이 없습니다.

이 말이 무슨 말이냐면, 같은 시간에 변수 a 의 값을 관찰했다고 해서

굳이 모든 쓰레드들이 동일한 값을 관찰할 필요는 없다 라는 점입니다.

 

예를 들어서 정확히 같은 시간에 쓰레드 1 과 2 에서 a 의 값을 관찰하였을 때

쓰레드 1 에서는 5 를 관찰하고,

쓰레드 2 에서는 8 을 관찰해도 문제될 것이 없습니다.

 

심지어, 동일한 코드를 각기 다른 쓰레드에서 실행하였을 때, 실행하는 순서가 달라도 (결과만 같다면) 문제가 안됩니다.

쓰레드 간에서 같은 시간에 변수의 값을 읽었을 때 다른 값을 리턴해도 된다는 점은 조금 충격적입니다.

하지만, 이 조건을 강제할 수 없는 이유는 그 이유는 아래 그림 처럼 CPU 캐시가 각 코어별로 존재하기 때문입니다.

Cache (2) - 코어당 캐시메모리 (https://3dmpengines.tistory.com/2194) 참고

 

 

코어 각각 L1, L2 캐시를 가지고 있다.

 

 

보시다시피, 각 코어가 각각 자신들의 L1, L2 캐시들을 갖고 있는 것을 알 수 있습니다.

따라서, 만약에 쓰레드 1 에서 a = 5 을 한 후에 자신들의 캐시에만 기록해 놓고 다른 코어들에게 알리지 않는다면,

쓰레드 3 에서 a 의 값을 확인할 때, 5 를 얻는다는 보장이 없다는 이야기 입니다.

물론, 매번 값을 기록할 때 마다, 모든 캐시에 동기화를 시킬 수 있겠지만 동기화 작업은 시간을 꽤나 잡아먹는 일입니다. 다행이도, C++ 에서는 로우 레벨 언어 답게, 여러분들이 이를 세밀하게 조정할 수 있는 여러가지 도구들을 제공하고 있습니다.

 

 

 

앞서 이야기 했듯이, C++ 에서 모든 쓰레드들이 수정 순서에 동의해야만 하는 경우는

바로 모든 연산들이 원자적 일 때 라고 하였습니다.

 

원자적인 연산이 아닌 경우에는 모든 쓰레드에서 같은 수정 순서를 관찰할 수 있음이 보장되지 않기에 여러분이 직접 적절한 동기화 방법을 통해서 처리해야 합니다. 만일 이를 지키지 않는다면, 프로그램이 정의되지 않은 행동(undefined behavior)을 할 수 있습니다. ( 즉 한번에 연산 되어야 하는 것에만 해당 된다는 얘기 )

 

 

그렇다면 원자적 이라는 것이 무엇일까요?

이미 이름에서 짐작하셨겠지만, 원자적 연산이란, CPU 가 명령어 1 개로 처리하는 명령으로, 중간에 다른 쓰레드가 끼어들 여지가 전혀 없는 연산을 말합니다. 즉, 이 연산을 반 정도 했다 는 있을 수 없고 이 연산을 했다 혹은 안 했다 만 존재할 수 있습니다. 마치 원자처럼 쪼갤 수 없다 해서 원자적(atomic) 이라고 합니다.

 

하지만 원자적 연산 자체가 바로 문제를 해결해 주진 않는다

 

 

 

 

Atomic 연산은 모든 쓰레드가 동일 객체에 대해서 동일한 수정 순서를 관찰 할수 있게 한다는것이 기본 법칙

 

 

원자적 Atomic 에 대한 예시를 한번 살펴보자

 

 

원자 연산의 특성 1

동일한 수정 순서란 무엇인가?

atomic<__int64> num;

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

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



//관찰자 스레드로 실시간으로 num 값을 관찰하고 있다고 가정한다
void threadObserver()
{
	while (true)
	{

		//이때 읽어오는 값은 어떻게 되는가?
		//atomic 또한 다른 쓰레드에서 값을 스고 있지만 코어당 캐쉬가 별도 있고  cpu 와 캐쉬 간의 연산할때의 값과 램에있는
		//값을 threadObserver 에서 관찰할떄의 가시성 때문에
		//이 쓰래드에선 순간적으로 num 값이 cpu와 캐쉬에서 현재 연산하고 있는 값과 다를 수 있다
		// 
		//하지만 동일한 수정 순서를 관찰 할수 있는데 이것은 값이 변경 될때 현재와 앞으로의 값중에
		//어떤 값이 될지모르지만 그중에 하나를 얻을수 있다는 것인데 과거 것은 안된다는 것
		//수정순서는 이런 얘기

		__int64 value = num.load();

	}
}

while(true)

{

value  이때 읽어오는 값은 어떻게 되는가?
atomic 또한 다른 쓰레드에서 값을 쓰고 있지만 코어당 캐쉬가 별도 있고  cpu 와 캐쉬 간의 연산할때의 값과 램에있는
값을 threadObserver 에서 관찰할떄의 가시성 때문에
이 쓰래드에선 순간적으로 num 값이 cpu와 캐쉬에서 현재 연산하고 있는 값과 다를 수 있다
  
하지만 동일한 수정 순서를 관찰 할수 있는데 이것은 값이 변경 될때 현재와 앞으로의 값중에
어떤 값이 될지모르지만 그중에 하나를 얻을수 있다는 것인데 과거 것은 안된다는 것
동일한 수정 순서는 이런 얘기

  __int64 value = num.load();

 

 

 

 

원자 연산의 특성 2

 

C++ 에서는 몇몇 타입들에 원자적인 연산을 쉽게 할 수 있도록 여러가지 도구들을 지원하고 있습니다.

또한 이러한 원자적 연산들은 올바른 연산을 위해 굳이 뮤텍스가 필요하지 않습니다! 즉 속도가 더 빠릅니다.

 

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

void worker(std::atomic<int>& counter) {
  for (int i = 0; i < 10000; i++) {
    counter++;
  }
}

int main() {
  std::atomic<int> counter(0);

  std::vector<std::thread> workers;
  for (int i = 0; i < 4; i++) {
    workers.push_back(std::thread(worker, ref(counter)));
  }

  for (int i = 0; i < 4; i++) {
    workers[i].join();
  }

  std::cout << "Counter 최종 값 : " << counter << std::endl;
}

 

void worker(std::atomic<int>& counter) {

가 지역변수인 

std::atomic<int> counter(0);

를 받고 공용으로 수정하려고 함으로 atomic 을 사용 한것 (전역 변수로 사용한것과 엇 비슷한 효과)

 

출력 결과는 

Counter 최종 값 : 40000

 

 

놀랍게도 counter ++; 을 아무런 뮤텍스로 보호하지 않았음에도 불구하고, 정확히 Counter  40000 으로 출력 되었습니다. 원래 counter ++ 을 하기 위해서는 CPU 가 메모리에서 counter 의 값을 읽고 - 1 더하고 - 쓰는 총 3 개의 단계를 거쳐야만 했습니다. 그런데, 여기서는 lock 없이도, 제대로 계산하였지요.

 

 

 

그렇다면 컴파일러는 이를 어떻게 원자적 연산으로 만들었을까요? 이를 알기 위해서는 다시 컴파일러가 어떤 어셈블리 코드를 생성했는지 살펴봐야 합니다.

붉은색 테두리가 counter ++ 부분이다.

놀랍게도 counter ++ 부분이 실제로 어셈블리 명령 한 줄인

  lock add DWORD PTR [rdi], 1

로 나타남을 알 수 있습니다. 원래 CPU 는 한 명령어에서 메모리에 읽기 혹은 쓰기 둘 중 하나 밖에 하지 못합니다. 메모리에 읽기 쓰기를 동시에 하는 명령은 없습니다. 하지만, 이 lock add 의 경우 rdi 에 위치한 메모리를 읽고 - 1 더하고 - 다시 rdi 에 위치한 메모리에 쓰기를 모두 해버립니다.

참고로 이러한 명령어를 컴파일러가 사용할 수 있었던 이유는 우리가 어느 CPU 에서 실행할 지 (x86) 컴파일러가 알고 있기 때문에 이런 CPU 특이적인 명령어를 제공할 수 있던 것입니다. 물론, CPU 에 따라 위와 같은 명령이 없는 경우도 있습니다.

이 경우 CPU 는 위와 같은 원자적인 코드를 생성할 수 없습니다. 이는 해당 atomic 객체의 연산들이 과연 정말로 원자적으로 구현될 수 있는지 확인하는 is_lock_free() 함수를 호출해보면 됩니다. 예를 들어서

 

std::atomic<int> x;
std::cout << "is lock free ? : " << boolalpha << x.is_lock_free() << std::endl;

를 실행해보면

실행 결과

Is lock free ? : true

와 같이 나옵니다. 여기서 lock free  lock 과 실제 어셈블리 명령에서의 lock 과는 다른 lock 을 의미합니다.

위 어셈블리 명령어에서의 lock 은 해당 명령을 원자적으로 수행하라는 의미로 사용되고, 

lock free 에서의 lock 이 없다 라는 의미는 뮤텍스와 같은 객체들의 lock, unlock 없이도 해당 연산을 올바르게 수행할 수 있다는 뜻입니다.

 

 

 

struct Knight

{

  int32 level;

  int32 hp;

  int32 mp;

};

 

atomic<Knight> v;

cout<< v.is_lick_free() << endl;

 

이 결과는 false 가 나오는데 즉 원자적으로 연산을 일반적으론 할 수없어서 atocmi<> 내에서 뮤텍스처럼 lock 을 잡고 값을 수정한다음 다시 unlock 하는 식으로 내부적으로 구현 처리가 된다

 

 

 

ref : https://modoocode.com/271

반응형

+ Recent posts