멀티 쓰레드
지난 시간까지 하드웨어 최적화로 인한 문제를 살펴보았다. 그러나 사실 이는 생각보다 크게 신경쓰지 않아도 된다. 왜냐하면 락, 아토믹 같은 것들을 사용하는 솔루션이 있기 때문이다. 오늘은 공유 변수 접근에 대한 문제점에 대해서 또 다른 실험을 할 예정이다. 😀
소스코드는 아래와 같다.
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 10000; i++)
number++;
}
static void Thread_2()
{
for (int i = 0; i < 10000; i++)
number--;
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
위 소스코드를 실행하면 당연히 10,000번 더하고, 10,000번 빼주었기 때문에 결과 값은 당연히 0이 된다. 그러나 10,000번이 아니라 100,000 으로 설정 하여 실행 할 경우, 0의 값이 나타나지 않는다. 하.. 도대체 또 왜이러는 것일까..? 🤦♂️
경합 조건(Race Condition), 원자성(Atomic)
또 다시 식당으로 예를 들어보자. 우리는 장사가 너무 잘되는 나머지 신입 직원 3명을 또 뽑았고, 이 신입 직원들은 열정이 불타올라 주문 현황에 있는 작업을 빨리 처리하고자 한다. 신입 직원 3명은 주문 현황에 콜라를 보자마자 서로 득갈같이 가져다 주기 위해 냉장고에서 콜라를 꺼내어 주문을 한 2번 테이블에 가져다 주려고 한다. 이 결과로 2번 테이블에서는 하나만 시킨 콜라를 3명의 신입 직원이 서로 콜라를 가져다 주어서 3개를 받는 셈이 되어버린 것이다. 이러한 상황을 경합 조건
(Race Condition) 이라고 한다. 즉. 직원들이 서로 경합을 하면서 차례를 지키지 않고 서로 막무가내로 일감을 꺼내 처리하는 것이다. 따라서 동시 다발적인 일의 처리가 진행된다.
그렇다면 소스코드로 다시 넘어가보자. number++과 --를 하는 부분을 분석해보자. 보다 쉽게 이해하기 위해 number++을 메인 메서드에서 처리하고 중단점을 찍고 이를 디스어셈블리로 확인하여 보자.
어셈블리를 보면, [7FF~]로 생성된 메모리 주소를 가져와 inc를 실행하고, ecx에 있는 값을 다시 [7FF~]에 넣어주는 것이다. 즉. 우리가 소스코드로 생각했던 number++은 사실 어셈블리에서 3단계로 나뉘어져 실행되고 있는 것이다. 이를 우리가 이해하기 쉽게 의사코드로 정리하면 아래와 같다.
int temp = number;
temp += 1;
number = temp;
그렇다면 여기서 또 의문이 든다. 왜 한번에 처리를 진행하지 않고 3번으로 나누어서 진행할까? 이는 해당 작업을 쪼갤 수 없는 최소 단위이기 때문이다. 값을 주소에서 가져오는 행위, 덧셈을 진행하는 행위, 주소에 값을 다시 넣는 행위가 하나의 최소 단위이기 때문이다. 우리는 코드상으로는 한번에 일어난다고 생각하지만 사실은 그것이 아니였던 것이다. 따라서 우리의 코드는 다음과 같이 변환할 수 있다.
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
int temp = number;
temp += 1;
number = temp;
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
int temp = number;
temp -= 1;
number = temp;
}
}
그렇다면 다시 분석을 해보자. 쓰레드 1과 2가 동시 다발적으로 실행된다면 각각의 쓰레드에서 내부 처리는 단계별로 처리가 진행이 된다. 바로 이것이 문제점이다. 쓰레드의 있는 소스 코드가 한번에 우다다! 실행이 되었어야 하는 것인데, 단계별로 처리가 진행되어서 문제가 생기는 것이다. 이것이 원자성의 개념이다. (❗❓ 정보처리기사 트랜잭션의 4가지 특성 중 하나가 여기서 나오는 것이다. 😲..)
게임으로 예를 들어보자. 우리는 상점에서 검을 사는데 이를 단계 별로 나누어본다면 1. 골드 -= 100; -> 2. 인벤 += 검과 같이 진행될 것이다. 이 작업은 원자적으로 진행되야 한다. 만약 원자적으로 진행되지 않는다면 골드를 100을 줄인 상태에서 서버가 크러쉬가 난다면 데이터베이스에는 무리의 골드가 -= 100이 처리가 되었는데 인벤토리에는 검이 들어오지 않는 상태가 일어나는 것이다. 가볍게 얘기했지만 조금 더 상황을 키운다면 아이템 복사, 골드 복사와 같은 문제도 여기서 일어나는 것이다.
따라서 우리는 number++과 number--를 원자적으로 한번에 처리하고 싶은 것이다. 이를 처리하려면 interlocked 명령어를 사용해야 한다. 해당 메서드를 사용하면 CPU 내부에서 원자적으로 처리를 진행한다. 그렇다고 해서 모든 로직을 interlocked 이용해 무차별적으로 사용하면 성능 저하의 문제가 생길 것이다.
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
Interlocked.Increment(ref number);
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
Interlocked.Decrement(ref number);
}
}
따라서 코드를 위와 같이 수정하면, 우리가 원하는 값인 0이 정상적으로 뜨는 것을 볼 수 있다. 결국 문제를 해결하기 위해서는 원자적으로 처리를 진행해야 한다는 것이다. 또한 Interlocked 메서드를 사용하면 메모리 베리어와 같이 순서 보장이 생긴다는 것이다. 즉 두 쓰레드가 동시에 실행되는 것이 아니라 쓰레드 1이 먼저 실행된다면 쓰레드 2는 쓰레드 1이 끝날 때 까지 기다린다는 것이다. 이를 식당으로 비유하자면 아래 사진과 같다.
Interlocked는 왜 ref를 사용해야 할까?
만약 ref를 지우고 'number'만 입력하면 어떻게 될까? 이렇게 될 경우 값을 복사하여서 사용하겠다. 라는 뜻인데 이렇게 될 경우 number 라는 값을 가져오는 순간에 이미 다른 쓰레드가 접근하여서 다른 값으로 수정할 수 있게 되는 것이다. 이렇게 될 경우 경합조건이 정상적으로 해결되지 않는다.
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
int prev = number;
Interlocked.Increment(ref number);
int next = number;
// ❌ number의 쓰레드 값은 공유하여서 사용하고 있기 때문에
// 꺼내와서 사용하는 순간에 다른 쓰레드가 이를 사용해 값이 변경될 수 있다.
int afterValue = Interlocked.Increment(ref number);
// ⭕ 리턴 값은 값을 보장한다.
// number의 값을 보기 위해서는 위와 같이 사용해야 한다.
}
}
'공부 > 인프런 - Rookiss' 카테고리의 다른 글
Part 4-2-8. 멀티쓰레드 프로그래밍 : 데드락 (0) | 2023.09.12 |
---|---|
Part 4-2-7. 멀티쓰레드 프로그래밍 : Lock 기초 (0) | 2023.09.12 |
Part 4-2-5. 멀티쓰레드 프로그래밍 : 메모리 배리어 (0) | 2023.09.11 |
Part 4-2-4. 멀티쓰레드 프로그래밍 : 캐시 이론 (1) | 2023.09.06 |
Part 4-2-3. 멀티쓰레드 프로그래밍 : 컴파일러 최적화 (0) | 2023.09.06 |