Q1. 경합조건을 식당의 일화에 비유해 설명해 보세요.
Q2. 각 스레드에서 10000회씩 1을 더하고 빼주는 코드에서 경합조건이 왜 발생했는지 설명해 보세요.
Q3. 원자성에 대해 설명해 보세요.
Q4. 코드의 경합조건을 해결해 보세요.
Q5. Interlocked. 계열의 함수의 단점은 무엇인가요?
Q6. Interlocked의 기능중 한번에 일을 한다는 것 말고 어떤 기능이 있는지 설명하세요. 그 기능이 왜 Q5의 단점의 원인이 되는지 설명하세요.
Q7. 원자성을 추가했을 때의 상황을 식당에 비유해 설명해 보세요.
Q8. Interlocked.Increment(ref number);에서 매개변수를 ref로 넣어준 이유는 무엇인지 설명해 보세요. Interlocked.Increment의 결과값을 알려면 반환값으로 받아야 하는 이유는 무엇인지 설명해 보세요.
지난 시간까지 하드웨어 최적화에 대한 문제들을 살펴봤고, 그것을 메모리 배리어를 이용해서 우회할 방법들에 대해 의논을 해봤어.
컴파일러나 하드웨어 최적화로 인한 문제들은 생각보다 신경 안써도 된다. 나중에 가면 메모리배리어 안써도 우아한 솔루션들이 있다. lock이나 atomic이니 하는. 그런 것들에 대해 슬슬 알아볼 때가 왔다.
오늘은 공유 변수 접근에 대한 문제점에 대해 몇가지 실험을 해보자.
일단 따라 만들어 보자.
namespace ServerCore
{
internal class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
number++;
}
static void Thread_2()
{
for (int i = 0; i < 100000; 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);
}
}
}
이 상태에서 ctrl+f5를 누르면
이상한 값이 나온다.
혹시 최적화니 가시성 문제인가 해서 int number 에 volatile을 붙여본다.
static volatile int number = 0;
가시성 문제는 아닌 거 같다.
도대체 왜 결과가 이상하게 나올까?
지난시간에 주문현황판에 업로드해서 다른 사람들도 똑같은 주문현황을 볼 수 있는 방법을 알아보았어.
오늘은 직원을 3명 채용했어. 2번 테이블에 콜라 주문이 들어왔는데 콜라는 냉장고에서 꺼내주면 된다.
3명의 직원들이 보자마자 냉장고로 달려가서 콜라를 꺼내왔어.
2번 테이블에 배달을 해줬어.
2번 테이블은 1개를 주문했는데 3개가 왔어.
이런 상황을 race condition (경합조건)이라고 한다.
온갖 쓰레드들이 즉 직원들이 경합을 하면서 차례를 지키지 않고 막무가내로 서로 일감을 꺼내서 실행하다 보니까 실제로는 한명이 일감을 처리했으면 나머지는 하면 안됨에도 불구하고 동시다발적으로 일들이 일어났다는 거다.
다시 코드로 돌아와서 왜 문제인지 분석을 해보자. number++이란 행위가 어떤 행위인지 분석을 해보면 된다. Main에 number++;을 추가하고 어떻게 실행되는지 살펴보자
중단점 잡고 실행한 뒤 Debug→Windows→disassembly를 눌러보면
어셈블리는 오른쪽에서 왼쪽으로 넣어주는 거다.
memory 주소에 있는걸 ecx에 옮겨왔다가 inc를 실행해 1을 늘려주고 다시 ecx레지스터에 있는 값을 처음에 있던 메모리에 넣어주고 있는 거다.
이렇게 3단계에 걸쳐 일어나고 있다.
C++이나 C#코드로 볼 때는 1줄이었지만 실제로 머신이 기계어로 실행을 할 때는 3단계에 걸쳐서 일어난다고 보면 된다.
의사코드로 표현을 하면
int temp = number;
temp += 1;
number = temp;
근데 왜 한번에 늘리지 않고 이렇게 3단계에 걸쳐서 하는지 이상하게 생각할 수 있는데
애당초 덧샘연산을 하는거랑, 어떤 주소에 가서 값을 갖고 오는 거랑, 어떤 주소값에 가서 해당 위치에 값을 넣는거랑 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;
}
}
이런식으로 동작을 하고 있었다는 얘기가 된다.
그럼 뭐가 문제일까. Thread_1, 2가 거의 동시 다발적으로 실행을 했다 가정을 해보자.
static volatile int number = 0;
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
int temp = number; // 0
temp += 1; // 1
number = temp;
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
int temp = number; // 0
temp -= 1; // -1
number = temp;
}
}
그 다음에 number = temp는 Thread_1, Thred_2중 누가 먼저 실행이 될까?
만약 위에가 먼저 실행이 됐다면
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
int temp = number; // 0
temp += 1; // 1
number = temp; // number = 1
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
int temp = number; // 0
temp -= 1; // -1
number = temp; // number = -1;
}
}
문제는 애당초
int temp = number; // 0
temp += 1; // 1
number = temp; // number = 1
이 연산을 한번에 실행했어야 되는 거였는데 그게 아니라 단계별로 number의 값을 추출했다가 늘렸다가 다시 값을 넣는 3단계에 걸쳐서 이루어지기 때문에 애당초 문제가 되었던 거다.
사실 이걸 유식하게 표현을 하자면 원자성이라는 용어를 많이 쓰게 된다.
atomic하다. = 원자성
물리에서 원자라고 하면 더이상 쪼개질 수 없는 최소의 물질을 말해.
더이상 쪼개지면 안되는 그런 작업을 말해. 사실 멀티 쓰레드에서만 하는 얘기는 아니고 데이터베이스에서도 다루게 될거야.
상점에서 아이템을 구매할 때
골드 -= 100;
인벤토리 += 검
DB에 각각 저장을 해야 하는데 원자적으로 이루어져야 해.
원자적으로 처리 안하면 골드를 100 줄인 상태에서 갑자기 서버가 크래시 나서 다운 됐다고 가정을 하면 골드는 줄어서 DB에 저장이 됐는데 인벤에는 검이 추가되지 않은 상황이 발생한다.
더 심각한 문제도 발생해.
집행검을 두 유저가 거래창을 통해 거래를 하면
집행검을 User2 인벤에 넣어라
집행검을 User1 인벤에서 없애라
이런식으로 동작을 시킬 수 있을거야. 근데 만약 원자성 보장 안되고 각각 일어난다고 보면
집행검이 User2인벤에 들어가는게 성공했는데 어떤 이유에서 집행검 User1인벤에 없애는 건 실패했다면 집행점이 지금 유저1, 유저2에게 있는 아이템 복사가 일어났다고 볼 수 있다.
원자성은 굉장히 중요한 개념.
작업으로 돌아가보면 애당초 하고 싶었던 것은
number++이란 작업과 number—라는 작업이 원자적으로 한번에 일어났으면 좋겠다는 생각이 든다.
그런 경우에는 Interlocked계열의 시리즈가 있다.
여러 쓰레드가 공유하는 변수에 대한 원자 연산을 제공합니다.
원자적으로 이루어 지는 걸 보장하는 거고 어떻게 보장하냐면 CPU 명령에서 이걸 원자적으로 만들어주는 그런 명령어가 있다. 그래서 할 수 있는 거.
그렇다고 해서 이제 ++안하고 무조건 Interlocked계열의 Increment 한다는 거 말이 안된다. 당연히 단점도 존재할거야. 성능에서 어마어마하게 손해를 본다는 단점이 있다. 그럼에도 불구하고 지금 상태에서는
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이 뜨는 것을 볼 수 있다.
for문을 1000000회로 고치고 실행을 해보면
그래도 0이 나온다.
문제를 해결하기 위해 첫번째 방법을 알아봤다.
원자적으로 덧셈을 한다. 원자적으로 뺄셈을 한다. 가 첫번쨰 결론이 된다.
참고로 Interlocked계열의 함수를 사용할 때는 내부에 이전시간에 배원 MemoryBarrier를 간접적으로 사용하고 있다. 그래서 int number를 굳이 volatile하지 않고
static int number = 0;
일반적 int로 해도 똑같이 작동한다. Interlocked를 사용했으면 voilatile은 잊고 살아도 된다.
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Increment(ref number); // 0 1
}
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Decrement(ref number); // 0 -1
}
}
이걸 처음 보면 왜 문제를 해결하는지 어리둥절 할 수 있다.
동시에 실행하면 1이 되고 -1이 되는게 아닌가 착각이 들 수 있다. 중요한 사실은 Interlocked 계열의 함수를 실행했으면 원자성을 보장하는 버전이라 했으니까 All or nothing 실행이 되거나 안되거나 하나야. Interlocked.Increment(ref number); 얘가 실행이 됐으면 Interlocked.Decrement(ref number);얘는 끝날 때 까지 기다려야 한다. 거의 동시다발적으로 실행을 했다 해도 둘중에 승자는 있기 마련이다.
static void Thread_1()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Increment(ref number); // 0 1
}
}
static void Thread_2()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Decrement(ref number); // 0 1 0
}
}
결국에는 ++과 다르다는 건 물론 3단계가 한번에 일어나는 것도 중요하지만 그거에다가 플러스로 순서 보장이 생긴다는 얘기가 된다. 동시 다발적으로 실행을 하면 경합을 해서 최종 승자가 먼저 얘를 실행을 할건데 먼저 실행하는 쪽이 일단 결과를 보장 받는다. 아애 실행 안했으면 안바뀌겠지만 실행했으면 무조건 1이 늘어나는 거 까지 보장이 되는 거고, 나머지 애들은 그 동안에 할 수 없이 기다려야 된다는 게 결론이다.
그러다 보니 당연히 일반적인 number++, — 보다 훨씬 느릴 거야.
이런식으로 하면 캐시의 개념이 쓸모 없게 되는 거라 할 수 있다. 만약에 여기 함수에 들어와서 뭔가 하고 있으면 다른 애들이 접근하는게 의미가 없게 되는 거야.
결국에는 순서를 보장해서 최종 승자가 결정이 되니까 그래서 아까 일어난 race condition 문제가 해결이 된다고 보면 된다.
원자성을 추가한다면 이제 콜라를 경합해서 갖다주는게 아니라 콜라를 주문 현황에서 없애고 그걸 배달하는 모든 일련의 과정들을 원자적으로 한번에 일어나게 만들어 준다는 얘기가 된다.
결국 얘네들이 주문 현황에 콜라가 뜨면 맨 처음에 도착한 애만
이렇게 꺼내서 실제 배달할 권리를 얻게 된다.
나머지 2명은 늦게 도착해서 허탕을 치는 그런 개념이 된다.
물론 ++이나 —는 갖고 온 거를 1을 늘리고 다시 집어 넣고 왔다 갔다 작업을 하겠지만 어찌됐든 실제로 작업을 할 수 있는 애는 한번에 한명씩만 작업을 할 수 있다는 게 굉장히 중요한 차이다.
마무리 하기 전에 하나만 집고 가면
Interlocked.Increment(ref number);
number를 넣어줄 때 ref로 넣어주고 있다. 참조 값으로 넣어준다는 건 number라는 수 자체를 넣어주는게 아니라 주소값을 넣어주고 있다고 생각하면 되는데 이렇게 된 이유를 생각해 보면
Interlocked.Increment(number);
만약 이렇게 됐으면 int를 넣어준 거니까 여기 값을 복사해서 Increment에 넣어준다는 얘기가 된다. 이렇게 하면 말이 안되는게 number의 값을 갖고 오는 순간에 다른 애가 접근 해서 그 값을 다른 값으로 수정했을수도 있어. race condition 문제가 해결이 되지 않는다. 그렇다면 ref를 붙였다는 건 number가 어떤 값인지 알지 못하지만 여기 있는 값을 참조해서 즉 주소에 가서 거기 안에 있는 수치를 어떤 값인지 모르지만 무조건 1을 늘려줘라는 명령을 내려준거. 이 미묘한 차이를 잘 깨우쳐야 한다.
처음에 할 때 헷갈리는 건
int prev = number;
Interlocked.Increment(ref number);
int next = number;
prev에서 1을 더하면 next가 나올 거 같아. 하지만 그렇게 나오지 않는다.
이제 싱글 쓰레드 마인드를 버려야 한다.
애당초 number라는 애는 쓰레드 끼리 공유하며 사용하고 있으니까
int prev = number; 이렇게 꺼내와서 사용하는게 말이 안된다. 왜냐하면 꺼내와서 쓰는 순간에도 누군가가 Thread_2에서처럼 건드려서 값이 바뀔 수 있다는 얘기가 되는 거.
그렇다는 건 증가된 값이 얼마인지를 추출하고 싶은데
int next = number; 이렇게 사용하는 건 말이 안된다고 했어. 꺼내오는 순간에 유효한지 아닌지 검증 할 수 없으니까 막 꺼낼 수 없다고 했어. 그렇기 때문에
여기 return 값이 있는 거다. Increment가 반환하는 값은 Increment 된 다음에 실제로 바뀐 값.
for (int i = 0; i < 1000000; i++)
{
int afterValue = Interlocked.Increment(ref number);
}
afterValue는 여기서 추출하면 100% 맞는 값을 리턴하겠지만
그게 아니라 나중에 궁금하다고 number를 따로 빼서 작업하는 건 사실 말이 안된다.
어렵긴 한데 곰곰히 생각해보면 좋겠어.
물론 나중에 Implement 계열이나 lock을 쓰다 보면 자연스럽게 익혀지고 익숙해지기 때문에 고민들도 안하게 되긴 하는데 처음엔 개인적으로 너무 헷갈렸어.
그래서 ref를 붙여주는 것과 변경된 값을 굳이 반환타입으로 하는 이유에 대해서도 한번 고민해보면 좋겠다가 최종 결론.
'Server programming' 카테고리의 다른 글
01_08_멀티쓰레드_DeadLock (0) | 2023.03.14 |
---|---|
01_07_멀티쓰레드_Lock 기초 (0) | 2023.03.13 |
01_05_멀티쓰레드_메모리 베리어 (0) | 2023.03.13 |
01_04_멀티쓰레드_캐시이론 (0) | 2023.03.12 |
01_03_멀티쓰레드_컴파일러 최적화 (0) | 2023.03.12 |
댓글