[스레드의 병렬 처리]

#include <iostream>

int32 sum = 0;

void Add()
{
	for (int32 i = 0; i < 1'000'000; i++)
	{
		sum++;
	}
}

void Sub()
{
	for (int32 i = 0; i < 1'000'000; i++)
	{
		sum--;
	}
}

int main()
{
	thread temp(Add);   // 꿀팁팁 : Ctrl + D 하면 바로 아래줄에 코드 복붙
	thread temp2(Sub);
	temp.join();
	temp2.join();
	cout << sum << endl;
}

 

일반적인 경우로 Add(), Sub() 함수를 실행시키면 전역 변수 sum의 값은 0이 될 것입니다. 하지만 두 함수를 각 스레드의 엔트리 포인트로 할당한 뒤 실행해주면 sum이 0아 이니라 예상치못한 값이 나오는 것을 확인할 수 있습니다.

 

스레드는 프로세스에서 스택을 제외한 모든 영역을 공유하기 때문에 임계 영역에 대한 문제가 발생합니다. 어셈블리 모드로 Add() 함수를 확인해보겠습니다.

00007FF7924C2436  mov         eax, dword ptr[sum(07FF7924CF470h)]
00007FF7924C243C  inc         eax
00007FF7924C243E  mov         dword ptr[sum(07FF7924CF470h)], eax

 

명령어는 순서대로 다음과 같습니다.

1. 어떤 주소(데이터 영역의 sum을 가리키는 중)에 있는 값을 eax 레지스터에 넣어라

2. eax 레지스터에 있는 값을 1 증가시켜라

3. 그 결과물을 다시 eax 레지스터에 넣어라

 

위의 순서를 sum++에 적용하면, 의사코드는 다음과 같아집니다.

void Add()
{
	for (int32 i = 0; i < 1'000'000; i++)
	{
		// sum++;
		int32 eax = sum;
		eax = eax + 1;
		sum = eax;
	}
}

 

단순한 하나의 줄이 세 개로 나눠지는 이유는, CPU가 메모리를 가져와서 연산하고 반환하는 과정을 한 번에 수행할 수 없기 때문입니다(동시 연산 불가능).

 

Add와 Sub 함수가 병렬처리 되는 과정을 살펴보겠습니다.

1. 데이터 영역의 sum은 현재 0

2. Add() 호출 시 int eax = sum이 실행되면서 sum의 값 0이 eax에 들어감

3. 마침 Sub()도 동시에 int eax = sum을 실행하면서 eax가 0이 됨

4. Add()가 다음 로직을 먼저 수행한다고 하면, eax = eax + 1을 실행

5. 그리고 연산된 eax 값을 sum에 넣으면 sum은 1이 됨

6. 이때 Sub()도 연산을 마치고 sum에 값을 넣으면 1에 값을 덮어쓰면서 -1이 됨

7. 이 과정이 반복될 경우 숫자가 의도대로 계산되지 않고 꼬이게 됨

 

 

[atomic]

 

데이터를 공유하는 것은 큰 장점이지만, 값을 수정하면 위와 같은 문제가 발생합니다. 이를 위해 3단계로 나눠지는 연산을 하나로 묶어 처리할 수 있는 방법이 존재합니다. 바로 atomic을 활용하면 됩니다.

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

atomic<int32> a_sum = 0;

int main()
{
}

 

atomic은 All-or-nothing입니다. DB에서 트랜잭션 개념을 설명할 때 원자성(atomically)을 얘기하곤 하는데, 동일한 의미를 가지고 동일한 동작을 수행하도록 해줍니다.

트랜잭션 원자성은 데이터베이스 트랜잭션이 모두 수행되거나 아예 수행되지 않는 '모두 아니면 전무(All or Nothing)' 특성을 의미

 

Windows에서 interlockAdd() 함수로 실행할 수 있지만, 이 또한 호환성 문제가 발생합니다. 이를 위해 C++에서 atomic을 include하면 사용할 수 있도록 기능을 만들어두었습니다. template 클래스이기 때문에 사용하고자 하는 겂을 감싸서 선언해주면 됩니다.

 

이렇게 감싸진 값은 세 단계로 나눠지는 일련의 과정을 하나의 단계로 묶어줍니다. 동일하게 전위/후위 연산자를 활용할 수 있지만, 이러면 atomic 변수인지 아닌지 알기 어렵기 때문에, 명시적 사용을 위하여 fetch_add() 함수를 활용해주면 좋습니다.

void Add()
{
	for (int32 i = 0; i < 1'000'000; i++)
	{
		//sum++;
		a_sum.fetch_add(1);
	}
}

 

결과를 확인해보면 이전과 다르게 정상적으로 0이 출력되는 것을 확인할 수 있습니다. 데이터가 오고 가는 과정을 CPU 차원에서 관리해주기 때문에 Add() 함수의 세 단계에 로직이 다 끝나야 Sub() 함수가 다음으로 접근해서 작업을 수행할 수 있게 되는 것입니다.

 

atomic을 활용한 값을 어셈블리어로 확인해볼 경우 코드가 한 줄 추가된 것을 확인할 수 있습니다.

00007FF692BF24A6  mov         r8d, 5
00007FF692BF24AC  mov         edx, 1
00007FF692BF24B1  lea         rcx, [a_sum(07FF692BFF48Ch)]
00007FF692BF24B8  call        std::_Atomic_integral<int, 4>::fetch_add(07FF692BF1532h)

 

'서버' 카테고리의 다른 글

[Server] 스핀락(Spinlock)과 sleep  (0) 2025.11.14
[Server] 데드락(DeadLock)  (0) 2025.03.17
[Server] Lock(mutex)  (0) 2025.03.17
[Server] 스레드  (0) 2025.03.17

+ Recent posts