class SpinLock
{
volatile bool _locked = false;
public void Acquire()
{
// 잠금이 풀릴때 까지 대기
while (_locked)
{
}
// 획득
_locked = true;
}
public void Release()
{
_locked = false;
}
}
class Program
{
const int MAX = 100000;
static int _num = 0;
static SpinLock _lock = new SpinLock();
static void Thread_1()
{
for (int i = 0; i < MAX; ++i)
{
_lock.Acquire(); // 획득
++_num;
_lock.Release(); // 해제
}
}
static void Thread_2()
{
for (int i = 0; i < MAX; ++i)
{
_lock.Acquire(); // 획득
--_num;
_lock.Release(); // 해제
}
}
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(_num);
}
}
결과 - 어림없다. - Acquire 함수가 Atomic이 보장이 안되기때문.
public void Acquire()
{
// 두개 Thread가 동시에 접근할 경우 _locked는 아직 false이므로
while (_locked)
{
}
// 두 Thread 모두 _locked를 획득하게 된다.
_locked = true;
}
해결법 1. Interlocked.Exchange 사용 - Atomic을 보장하는 함수 - 값을 ref 변수에 Atomic을 보장하여 넣어준다. - 반환값은 값을 넣기전에 값
class SpinLock
{
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
// Interlocked으로 _locked에 1을 대입한다.
// 반환값이 0이면 이전에 사용하고 있던 Thread가 없는 것이고,
// 반환값이 1이면 이전에 Thread가 사용하고 있고 아직 Release안된 상태.
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0)
break;
}
}
public void Release()
{
_locked = 0;
}
}
해결법 2. 위 방법도 괜찮지만 뭔가 직관적이지 않음 - 무조건 Lock을 걸어주고, Lock걸기 이전에 Lock이 안걸려있는 경우에 Lock이 걸린다 : 복잡.. - Lock이 걸려있지 않으면 Lock을 걸어준다 : Good - 간단한 코드로 써보면
if(_locked == 0)
_locked = 1;
하지만 이렇게하면 Atomic하지 않기 때문에 Interlocked에서 제공되는 함수가 있다. Interlocked.CompareExchange(ref _locked, 넣을값, 비교값) - _locked와 '비교값'을 비교하여 같으면 '넣을값'을 _locked에 넣어준다. - Interlocked.Exchange와 똑같이 original값을 반환한다.
int original = Interlocked.CompareExchange(ref _locked, 1, 0);
if (original == 0)
break;
변수를 사용하여 좀 더 명시적으로 바꾸면
int expected = 0; // 예상한 값
int desired = 1; // 예상한 값이 맞으면 넣을 값
if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
break;
전체코드
class SpinLock
{
volatile int _locked = 0;
public void Acquire()
{
while (true)
{
int expected = 0; // 예상한 값
int desired = 1; // 예상한 값이 맞으면 넣을 값
// _locked가 0이면 1을 넣어주고, 0(original == expected)을 반환다.
// original이 0이었다는건 이전에 사용하고있지 않은 상태이기 때문에 사용권을 획득한다.
if (Interlocked.CompareExchange(ref _locked, desired, expected) == expected)
break;
}
}
public void Release()
{
// 이미 Acquire 단계에서 Atomic이 보장된 상황이기때문에 별도 Interlocked없이 해제해도 된다.
_locked = 0;
}
}
Interlocked는 정수만 제어가능하다는 단점이 있다. - 구문을 Atomic하게 제어할 수 있는 무언가가 필요하다.
Monitor를 이용한 Atomic구현 - 빈 object를 기준으로 Atomic영역의 시작과 끝을 정의할 수 있음.
하지만 코드가 길어질 경우 실수로 return을 하여 Monitor.Exit가 실행이 안되면 프로그램이 멈춰버림 - Thread_1은 return이후에 정상적으로 실행이 되겠지만, Thread_2의 경우 Thread_1에 의해 잠겨버린 _obj에 의해 동작이 멈춰버리게 된다. - Thread_2는 Dead Lock 상태(죽은 자물쇠) - 문을 잠그고 안열어준 상태
해결책1. try - finally - 구문을 try로 묶고 finally에 Lock해제구문을 넣게되면 중간에 return을 하더래도 무조건 finally가 실행되어 Lock해제 된다.
해결책2. "lock"키워드 사용 - lock내부적으론 위와 같이 Monitor.Enter와 Monitor.Exit로 구현돼 있다. - Monitor를 사용하는 것보다 좀더 편리하고 안전하게 Atomic영역을 관리할 수 있다.
MultiThread 동작 시 하나의 변수에 대해 2개의 Thread가 값을 변경할 경우 예상과 다른 결과가 나올 수 있다. - 값 연산 횟수가 많아질수록 더 심해진다.
이유 - Assembly Level에서의 변수의 값증가 행위는 3단계로 나뉜다. 1. 해당 변수의 주소에 접근하여 값을 ecx register에 임시 저장한다. 2. ecx를 1증가 시킨다. 3. ecx값을 변수 주소에 다시 넣어준다. - Multi Thread에 경우 Atomic(원자성)이 보존되지 않아 1번 동작이 2개의 Thread에서 동시에 일어나게 되면 서로 다른 연산을 하게된다. - 이 경우 "변수의 주소"는 Critical Section(임계 영역)이라고 한다.
// c#에서 증가
number++;
// Assembly에서 증가
mov exc, dword ptr[주소] // number주소에서 exc로 number값 저장
inc exc // exc(number에서 가져온값)을 1증가
mov dword ptr[주소], exc // exc의 증가된값을 number 주소로 다시 넣어준다.
// Assembly를 C#느낌으로 쉽게 풀어쓰면
int temp = number;
temp++;
number = temp;
Interlocked - Atomic을 보장하는 기능 - 2개의 Thread가 하나의 주소에 접근할 경우 둘 중 먼저 접근한 Thread의 연산처리가 끝난뒤 다른 Thread가 순차적으로 접근하여 연산하게 된다. - 때문에 성능상의 저하는 피할 수 없다.
MultiThread환경에서 변수의 증감에 대한 Atomic한 결과를 확인하는 방법
// 잘못된 방법
Interlocked.Increment(ref number);
int result = number; // 이 시점에 number가 위에서 증가된 이후에 실행됐다는 보장이 없다.
// 옳바른 방법
// Interlocked.Increment의 반환값은 증가된 순간의 값을 반환하므로 Atomic한 값이다.
int result = Interlocked.Increment(ref number);
Cache - CPU가 메모리에 데이터를 전송하기 전 데이터를 저장하고있는 저장 공간 - 데이터를 바로 RAM에 올리는것이 아닌, 일정량을 Cache에 저장하고 있다가 한번에 RAM에 올린다. - 속도는 Register -> L1 Cache -> L2 Cache -> RAM(MainMemory) 순이다.
Cache 철학 - 무엇을 Cache 할것인가? - 시간적 측면 : 가장 최근에 사용한 데이터 - 공간적 측면 : 가장 최근에 사용된 메모리 근처
Cache Test - 2차원 배열에서 [0,0][0,1][0,2]를 참조하는 것과 [0,0][1,0][2,0]을 참조하는 시간이 차이가 난다. - 공간적 측면에서 보면 [0,0]을 참조한 순간 Cache는 [0,?]을 우선으로 사용할 것이라고 가정했기 때문.
Thread의 기본 생성 - Thread 생성시 넣은 함수가 종료되면 Thread도 같이 종료된다.
static void MainTread()
{
Console.WriteLine("Hello Thread");
}
static void Main(string[] args)
{
// Thread 생성 (생성된 Thread가 동작시킬 함수)
Thread t = new Thread(MainTread);
// Thread 시작
t.Start();
Console.WriteLine("Hello world");
}
C#의 Thread는 Default로 Foreground로 생성된다. - 이 경우 Thread가 종료되지 않으면 프로그램이 종료되지 않는다. - isBackground = true 해줄 경우 Thread가 background로 실행되기 때문에 프로그램 종료와 함께 종료된다.
static void MainTread()
{
while(true)
Console.WriteLine("Hello Thread");
}
static void Main(string[] args)
{
// Thread 생성 (생성된 Thread가 동작시킬 함수)
Thread t = new Thread(MainTread);
t.IsBackground = true;
// Thread 시작
t.Start();
Console.WriteLine("Hello world");
}
생성한 Thread가 종료되기전까지 프로그램이 종료되지 않는다. / isBackground = true일 경우 프로그램이 종료되면 Thread도 종료된다.
Thread가 Background로 실행되고 있어도 Join()함수를 통해 해당 Thread가 종료될때까지 프로그램을 대기시킬 수 있다.
static void MainTread()
{
while(true)
Console.WriteLine("Hello Thread");
}
static void Main(string[] args)
{
// Thread 생성 (생성된 Thread가 동작시킬 함수)
Thread t = new Thread(MainTread);
t.IsBackground = true;
// Thread 시작
t.Start();
Console.WriteLine("Waiting for Thread");
// background Thread가 종료될때 까지 대기
t.Join();
Console.WriteLine("Hello world");
}
디버깅을 통해 현재 Thread상태 확인하기 - 프로그램 실행중 "정지"버튼을 누르고 Thread를 선택하면 해당 Thread의 동작지점이 표시된다.
Thread Pool - 프로그램 동작 중 간단한 작업을 위해 잦은 Thread 생성이 필요한 경우 사용하면 좋음. - C#에서 미리 Poolling해둔 Thread를 가져다가 사용하고 해당 Thread가 종료되면 자동으로 Pool로 반납됨 - 대신 ThreadPool에서 가져온 Thread를 오래 사용할 경우 ThreadPool로 반납이 안되어, ThreadPool의 Thread가 부족해질 수 있고, 그럴경우 ThreadPool이 동작하지 않는 상황 발생. - 그래서 간단한 작업을 할때 사용하자.
Thread Pool의 Max개수만큼 Thread를 사용하고 반납이 안될경우 더 이상 추가 Thread할당이 안됨
Task - TheadPool에서 Thread를 할당 받아 사용. - 하지만 Task생성시 옵션으로 "TaskCreationOptions.LongRunning"을 설정해 줄 경우 ThreadPool에서 할당하지 않고 별도의 Thread를 생성하여 할당하기 때문에 Thread Pool의 부족현상을 해결 할 수 있다. - "TaskCreationOptions.LongRunning" 옵션을 넣지 않으면 ThreadPool에서 할당한다. - 따라서 Task위주로 Thread를 활용하고 오래걸리는 작업일 경우 Task생성시 "TaskCreationOptions.LongRunning"를 넣어주자.
CPU가 Single Core일 때 Thread 할당 방법 - Core의 작업을 나누어 각 Thread별로 일정 단위시간만큼 할당한다.
CPU가 Multi Core일 때 Thread 할당 방법 - 각 코어가 Thread별로 할당됨 - Thread를 많이 생성한다고 효율이 좋지않다. -> Thread가 많을 수록 CPU가 Thread를 옮겨가며 작업하는 횟수가 많아지고 그 행위자체가 비효율 - 그래서 Thread숫자는 Core숫자와 맞춰주는게 효율이 좋다.
Multi Thread의 메모리 영역 - Heap : 공용 - 데이터(static) : 공용 - stack : 쓰레드별 할당