I'm FanJae.
[Multi Thread] 4. 경쟁 상태와 동기화 (원자성, 일관성, 뮤텍스) 본문
[멀티 스레드 시리즈 글]
[Multi Thread] 1. 멀티 스레드를 이해하기 위한 기초 (프로그램, 프로세스, 스레드)
[Multi Thread] 2. 호출 스택과 실행 흐름
[Multi Thread] 3. 스레드는 어떻게 동작하는가 (컨텍스트 스위칭과 데이터 레이스)
[Multi Thread] 4. 경쟁 상태와 동기화 (원자성, 일관성, 뮤텍스)
[Multi Thread] 5. 교착 상태(Deadlock)와 해결 방법 (잠금 순서, 재귀 뮤텍스)
1. 스레드에 대한 주의 사항
1) 경쟁 상태(Race Condition)
- 멀티 스레드 환경에서는 2개의 스레드가 값 하나에 동시에 접근하는 경우(정확히는 스레드 2개가 값 하나를 번갈아 가면서 접근하는 경우)가 종종 발생한다.
- 두 스레드가 한 값에 접근하는 것에 대해 조치를 하지 않으면 문제가 발생할 수 있다.
- 여러 스레드의 실행 순서나 타이밍에 따라 결과가 달라지는 문제를 경쟁 상태(Race Condition)이라고 한다.
- 그 중에서도 여러 스레드가 같은 메모리 위치에 동시에 접근하고, 그중 하나 이상이 쓰기 작업을 할 때 적절한 동기화가 없는 경우를 데이터 레이스(Data Race)라고 한다.
2) 경쟁 상태가 발생하는 이유
x += y;
- 겉보기에는 위 소스 코드는 한 번에 실행되는 한 줄의 코드처럼 보인다.
- 하지만 프로그래밍 언어에서 작성한 한 줄의 코드는 실제로 여러 CPU 명령어로 나뉘어 실행될 수 있다.
x = 2
// 스레드 1
x += 3
// 스레드 2
x += 4
// 내가 기대하는 결과
x = 9
x = 2
#스레드 1
t1 = x // t1 = 2
t1 = t1 + 3 // t1 = 5
x = t1 // x = 5
#스레드 2
t2 = x // t2 = 5
t2 = t2 + 4 // t2 = 9
x = t2 // x = 9
- 위와 같은 순서로 실행되면, 기대하는 결과대로 실행된 것이다. 컨텍스트 스위치가 무작위(Random) 발생이다.
- 따라서, 아래와 같이 실행될 수도 있다.
x = 2
// 스레드 1
x += 3
// 스레드 2
x += 4
// 내가 기대하는 결과
x = 9
x = 2
# 스레드 1
t1 = x // t1 = 2
t1 = t1 + 3 // t1 = 5
# 스레드 2
t2 = x // t2 = 2
t2 = t2 + 4 // t2 = 6
x = t2 // x = 6
# 스레드 1
x = t1 // x = 5
- 위와 같이 실행될 수 있다.
- 다른 스레드가 끼어 들면서 의도하지 않은 결과가 발생할 수 있다.
- 앞에서 본 것처럼 x += y는 한 줄의 코드처럼 보이지만 실제로는 값을 읽고 계산하고 저장하는 여러 단계가 존재한다.
- 이때 어떤 작업이 중간에 끊기지 않고 하나의 작업처럼 처리되도록 만들어야 한다.
- 즉, 다른 스레드 입장에서 해당 작업이 아직 실행되지 않았거나, 전부 실행이 되었어야 한다. 이를 원자성(Atomicity)이라고 한다.
- 공유 데이터가 프로그램의 규칙에 맞는 상태를 유지할 수 있게 일관성 있는 상태를 유지하는 것이 필요한데, 이를 일관성(consistency)이라고 한다.
3) 경쟁 상태를 해결하는 방법
멀티스레드 프로그래밍에서는 원자성과 일관성을 보장하기 위해 공유 자원에 대한 접근을 제어해야 한다.
이러한 제어 기법을 동기화(Synchronize)라고 한다.
대표적인 방법은 다음과 같다.
- 임계 영역(Critical Section)
- 뮤텍스(Mutex) (상호 배제)
- 잠금(락) 기법
2. 임계 영역(Critical Section)과 뮤텍스(Mutex)
1) 임계 영역과 뮤텍스의 정의
- 경쟁 상태를 해결하는 방법 중 하나는 공유 자원에 접근하는 구간을 한 번에 하나의 스레드만 실행하도록 제한하는 것이다.
- 예를 들어 현재 어떤 스레드가 공유 변수 x를 사용하고 있다면, 다른 스레드는 해당 스레드가 작업을 끝낼 때까지 기다리게 만든다.
- 이때 여러 스레드가 동시에 접근하면 문제가 발생할 수 있는 코드 영역을 임계 영역(Critical Section)이라고 한다.
- 이 임계 영역에 한 번에 하나의 스레드만 들어갈 수 있도록 막아주는 동기화 도구 중 하나가 뮤텍스(Mutex)다.
2) 문제점과 대안점
- 하지만 매번 lock()과 unlock()을 직접 호출하는 방식은 번거롭고 위험하다.
- 특히 임계 영역 안에서 예외가 발생하거나 중간에 함수가 반환되면, unlock()이 호출되지 않을 수 있다.
- 이 경우 뮤텍스가 계속 잠긴 상태로 남아 다른 스레드가 영원히 대기하는 문제가 발생할 수 있다.
- C++에서는 이런 문제를 줄이기 위해 std::lock_guard를 제공한다.
- std::lock_guard는 생성될 때 뮤텍스를 잠그고, 객체가 스코프를 벗어나 소멸될 때 자동으로 잠금을 해제한다.
3) Multi-Thread 예제
#include <vector>
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
#include <functional> // std::ref
using namespace std;
const int MaxCount = 500000;
const int ThreadCount = 4;
bool IsPrimeNumber(int number) // 소수 구하기
{
if (number == 1)
return false;
if (number == 2 || number == 3)
return true;
for (int i = 2; i < number - 1; i++)
{
if ((number % i) == 0)
return false;
}
return true;
}
void Worker(int& num, mutex& num_mutex, vector<int>& prime_list, mutex& primes_mutex)
{
while (true)
{
int n;
{ // Mutex 범위 제한
lock_guard<mutex> num_lock(num_mutex);
n = num;
num++;
}
if (n >= MaxCount)
break;
if (IsPrimeNumber(n)) // 공유 데이터 보호
{
lock_guard<mutex> primes_lock(primes_mutex);
prime_list.push_back(n);
}
}
}
void PrintNumbers(const vector<int>& prime_number)
{
for (int number : prime_number)
{
cout << number << endl;
}
}
int main()
{
int num = 1;
mutex num_mutex;
vector<int> prime_list;
mutex primes_mutex;
auto t0 = chrono::system_clock::now(); // 현재 시각 기록
vector<thread> threads;
for (int i = 0; i < ThreadCount; i++)
{
threads.emplace_back(Worker,ref(num),ref(num_mutex),ref(prime_list),ref(primes_mutex));
}
for (thread& t : threads) // 스레드 대기
{
t.join();
}
auto t1 = chrono::system_clock::now(); // 실행 이후 현재 시간 기록
auto duration = chrono::duration_cast<chrono::milliseconds>(t1 - t0).count(); // 실행 시간 계산
cout << "Took " << duration << " milliseconds." << endl;
// PrintNumbers(prime_list);
}
- MaxCount와 ThreadCount를 조정하면, 실행 시간 확인이 가능하다.
4) 뮤텍스에 대해 고려가 필요한 문제점
- 소수 판별 예제에서 스레드 개수(Thread Count)를 늘려보면, 성능이 선형적으로 좋아지지 않는다.
- 이는 뮤텍스를 사용하는 과정 자체에도 비용이 있기 때문이다.
- 뮤텍스는 한 번에 하나의 스레드만 임계 영역에 들어갈 수 있도록 막아준다.
- 그 과정에서 스레드는 잠금을 얻기 위해 대기할 수 있고, 잠금 획득과 해제 자체에도 추가적인 연산 비용이 필요하다.
- 공유 자원을 보호하기 위해 뮤텍스를 사용하면 안전성은 높아진다.
- 동시에 병렬로 실행될 수 있는 구간이 줄어든다.
- 여러 스레드가 같은 뮤텍스를 자주 잠그려고 하면, 대부분의 스레드는 실제 작업을 수행하지 못하고 대기하는 상황이 발생한다. 이를 컨텐션(Contention)이라고 한다.
- 따라서 뮤텍스는 필요한 곳에만 사용해야 하며, 임계 영역의 범위는 작게 유지하는 것이 좋다.
- 다만 임계 영역을 줄이기 위해 뮤텍스를 여러개로 나누면 프로그램이 복잡해지고, 교착 상태(Dead lock) 문제가 발생할 수 있다.
'System Programming' 카테고리의 다른 글
| [Multi Thread] 5. 교착 상태(Deadlock)와 해결 방법 (잠금 순서, 재귀 뮤텍스) (0) | 2026.05.03 |
|---|---|
| [Multi Thread] 3. 스레드는 어떻게 동작하는가 (컨텍스트 스위칭과 데이터 레이스) (0) | 2026.04.28 |
| [Multi Thread] 2. 호출 스택과 실행 흐름 (0) | 2026.04.26 |
| [Multi Thread] 1. 멀티 스레드를 이해하기 위한 기초 (프로그램, 프로세스, 스레드) (0) | 2026.04.25 |
| [System Programming] 컴퓨터 구조의 접근방법 (4) | 2024.09.20 |