수업


🧵 주제

C++에서 std::atomic을 활용해 멀티스레드 환경에서 발생할 수 있는 경쟁 조건(Race Condition)을 방지하고, 안전하고 정확한 데이터 처리를 구현하는 방법

세 편의 블로그는 공통적으로 std::atomic의 필요성과 작동 원리를 중심으로 설명하며, sum++, sum-- 같은 단순 연산도 원자적이지 않기 때문에 멀티스레드 환경에서는 예상치 못한 결과를 초래할 수 있고, 이를 어떻게 해결할 수 있는지를 다룬다.


📘 개념

✅ 멀티스레드 환경과 데이터 경쟁(Race Condition)

  • 멀티스레드 환경에서 여러 스레드가 동시에 하나의 변수에 접근하고 수정하면 충돌이 발생할 수 있다.
  • sum++, sum-- 같은 간단한 연산도 사실 내부적으로는 메모리에서 값을 읽고 → 수정하고 → 다시 저장하는 3단계의 비원자적 연산으로 구성되어 있다.
  • 따라서 하나의 스레드가 sum을 읽는 사이에 다른 스레드가 값을 바꾸면, 최종적으로 덮어쓰기가 발생해 값이 꼬이게 된다.

✅ 공유 메모리와 비공유 메모리

  • 공유 메모리: 힙 영역, 데이터 영역 (전역 변수, static 변수)
    → 여러 스레드가 동시에 접근 가능
  • 비공유 메모리: 스택 영역 (함수 내부 지역 변수, 파라미터)
    → 각 스레드마다 독립된 복사본을 가짐

✅ 원자성(Atomicity)이란?

  • 연산이 더 이상 나눌 수 없는 단일 작업으로 수행되는 성질을 말한다.
  • 어떤 연산이 원자적이면, 그 연산 도중에 다른 스레드가 개입할 수 없다.
  • C++에서는 std::atomic을 통해 이 원자성을 보장할 수 있다.

std::atomic의 동작 방식

  • std::atomic<T>T형 변수에 대해 읽기, 쓰기, 덧셈, 뺄셈 등 기본 연산을 원자적으로 처리한다.
  • 예: sum.fetch_add(1)은 내부적으로 sum = sum + 1과 같은 동작을 하지만, 이 전체가 단일한 CPU 명령으로 처리된다.
  • CPU는 이를 위해 LOCK 프리픽스나 특수한 메모리 배리어(Memory Barrier)를 사용한다.

✅ 성능 주의사항

  • atomic 연산은 일반 변수 연산보다 느리다.
  • 최적화 여지가 줄어들고, 성능 저하로 이어질 수 있으므로 꼭 필요한 곳에만 사용해야 한다.

📚 용어 정리

용어설명
std::atomic<T>T 타입 변수에 대해 원자성을 보장하는 템플릿 클래스
fetch_add(n)n을 더하는 원자적 연산
fetch_sub(n)n을 빼는 원자적 연산 (fetch_add(-n)과 동일)
Race Condition두 개 이상의 스레드가 동시에 데이터를 읽고 쓰며 충돌이 발생하는 상황
Atomicity연산이 도중에 끊기거나 개입당하지 않고, 완전히 수행되는 특성
All-or-Nothingatomic 연산은 부분적으로 실행되지 않고 반드시 전부 수행되거나 안 되거나 둘 중 하나다

💻 코드 분석

✅ 일반 변수 사용 시 (경쟁 조건 발생 예)

int sum = 0;

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

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

int main()
{
	thread t1(Add);
	thread t2(Sub);

	t1.join();
	t2.join();

	cout << "sum : " << sum << endl;
}

🔍 설명

  • sum은 전역 변수이므로 두 스레드가 동시에 접근할 수 있다.
  • 싱글 스레드 환경에서는 +10만 -10만 = 0이 맞지만, 병렬로 실행하면 sum의 결과는 매번 달라진다.
  • 이 문제는 ++sum, --sum이 다음과 같은 비원자적 명령어 조합으로 이루어지기 때문이다:
mov eax, [sum]   ; 값 읽기
inc eax          ; 증가
mov [sum], eax   ; 다시 저장

📌 위 3개의 명령 사이에 다른 스레드가 개입하면 값 충돌 발생


✅ 재현된 C++ 코드

int32 eax = sum;
eax = eax + 1;
sum = eax;
  • 이 연산 자체는 안전해 보이지만, 스레드 간에는 eax가 서로 다른 값을 가질 수 있기 때문에 덮어쓰기 발생

✅ atomic으로 해결한 코드

std::atomic<int> sum = 0;

void Add()
{
	for (int i = 0; i < 100'000; ++i)
	{
		sum.fetch_add(1);
	}
}

void Sub()
{
	for (int i = 0; i < 100'000; ++i)
	{
		sum.fetch_add(-1);
	}
}

int main()
{
	thread t1(Add);
	thread t2(Sub);

	t1.join();
	t2.join();

	cout << sum << endl;
}

🔍 설명

  • std::atomic<int>를 사용함으로써 sum은 이제 원자적으로 작동한다.
  • fetch_add(n)은 다음과 같은 연산을 한 번에 처리한다:
    1. 현재 값을 읽는다.
    2. n만큼 더한다.
    3. 결과를 저장한다.
      이 전체가 단일 명령처럼 취급되므로 스레드 간 충돌이 발생하지 않음.

📌 결과는 항상 0이 되며, 정확하게 작동한다.


✅ 주석 기반 내부 동작 예시

// sum.fetch_add(1);
// 내부 동작:
// eax = sum;   // 읽기
// eax = eax + 1;
// sum = eax;   // 쓰기
// → 단, 이 전체가 원자적으로 처리됨!

🎯 핵심

  • 단순한 연산(sum++, sum--)도 실제로는 다단계 명령어로 구성되어 있기 때문에 멀티스레드 환경에서는 충돌 가능성이 있다.
  • 이런 상황에서 발생하는 문제를 Race Condition이라고 하며, 결과값이 예측 불가능해진다.
  • 이 문제를 해결하기 위해 std::atomic을 사용하면, 연산 전체를 원자적으로 처리할 수 있다.
  • atomic 연산은 내부적으로 CPU 명령어 수준에서 락(lock)과 메모리 배리어를 활용하여 동기화를 보장한다.
  • 하지만 atomic 연산은 성능이 일반 연산보다 떨어지며, 남용은 금물이다.
  • 공유되는 메모리는 반드시 동기화가 필요하며, std::atomic은 그 중 가장 간단하고 강력한 해결책이다.

profile
李家네_공부방

0개의 댓글