지난 시간까지 하드웨어 최적화시 문제에대해서 알아보았다.
그 문제들을 메모리 베리어를 이용해 가지고
뭐 하는 그런 작업들을 해보았다.
그런데 이런 "컴파일러"나 하드웨어에서 일어나는 최적화 문제들은
생각보다 우리가 그렇게 크게 신경을 안써도 되는 것이
나중에 가면은 이런 메모리 베리어까지 사용을 하지 않더라도
조금더 우아한 그런 솔루션들이 있다.
"Lock"이니 "아토믹"이니 하는 그런 솔루션들이 있다.
그래서 이제 슬슬 그런것들에 대해서 알아볼 차례가 왔는데
그래서 일단 오늘은 이런
"공유 변수 접근"에 대한 "문제점"에 대해서 몇가지 실험을 해보는 시간을 가지도록 하겠다.
오늘 할 것들은 굉장히 직관적이다.
이런식으로 공유 변수가 있다고 가정을 해보도록 하자.
그리고 쓰레드를 두가지를 만들어서 이런식으로 실행을 해본다고 가정을 해보자.
이상태에서 실행을 해보면은
당연히 0이 나와야 할거같은데
0이 나오지 않는다.. 분명히 만번 더하고 만번 뺏는데
뭐 이런 이상한 값이 나온다...
여기서 다시 "멀티쓰레드"의 흑마법이 시작이 된 것이다.
그래서 "가시성"의 문제가 인가 싶어
이렇게 붙여서 실행을 해보아도(최적화를 하지말라라는 키워드를 붙여보아도)
이렇게 이상한 값이 나옴(음수가 나올 때도 있다...)
그래서 덧샘 뺄샘을 똑같이 해주어도 왜 결과가 이상하게 나올까??
이것이 오늘의 "주제"가 될 것이다.
이것을 또 기술적으로 설명을 하기 보다는
예전에 계속 설명을 해주던 고급 식당 스토리로 돌아가보도록 하자.
지난시간까지는 직원들을 잘 채용을 해가지고 주문을 관리하는 그런 작업까지 해보았다.
그래서 자기 메모장에다가만 적는 것이 아니라 주문 현황판에다가 동기화를 해가지고
그러니까, 업로드를 해가지고 다른 직원들도 똑같은 주문 현황을 볼 수 있는
방법에 대해서 알아보았고
오늘은 반대로
직원을 여러명 채용한 상태라고 가정을 해보자.
신입직원 세명을 채용을 했는데
이 아이들이 신입이다 보니까 열정이 넘치고
일감이 생기자마자 바로 처리를 하려고 하는 상태라고 가정을 해보자.
그런데 지난 시간에 콜라를 2번 테이블에서 주문을 했었었다.
그래서 주문현황판을 보니까 2번테이블에서 콜라를 주문을 했다고 딱 뜬 것이다.
그런데 콜라같은 경우에는 굳이 주방까지 갈 필요 없이 냉장고에서 갔다가 꺼내주면 될 것이다.
그러다 보니까 어떤 상황이 일어나는 것이냐하면은
3명의 직원들이 이것을 보자마자 냉장고로 가가지고
이런식으로 콜라를 꺼내왔다.
그래서 배달을 하기 위해서
이런식으로 콜라를 배달을 했는데
3개씩이나 배달을 하게되는 이상한 상황이 발생 한 것이다.
이게 오늘의 스토리이다.
분명히 2번 테이블에서는 콜라를 하나를 주문을 했는데
어쩌다 보니까 콜라가 3개가 오는 상황이 발생을 했는데
이런 상황을
Race Condition == 경합 조건 이라고 한다.
뭔가 쓰레드들이(직원들이) 경합을 하면서 차례를 지키지 않고 마음대로 일감을 꺼내서 실행을 하다보니까
실제로 한명이 일감을 처리했으면 나머지 애들은 일을 하면 안되는데도 불구하고
이렇게 동시 다발적으로 일들이 일어나서 문제가 발생 한 것이다.
그러면
다시 코드로 와가지고
왜 이부분에서 Race Condition이 발생을 하는지 분석을 해볼 것인데
이렇게 _number++이 뭘 하는지 분석을 하기 위해 여기다가 _number++ 선언하고 breakPoint잡아서 실행을 한다음
디스 어셈블리 들어가면
_number++하는 부분은
여기서 핵심적인 부분을 담당하고있는데
(어셈블리 언어는 오른쪽에서 왼쪽으로 넣어주는 것이다)
이렇게 어떤 포인터를 가지고오는데(주소값)
그다음에 ecx레지스터에 옮겨 왔다가
inc라는 것을 실행해가지고
ecx에 1을 늘려준다음에
다시 ecx에 있는 레지스터에 있는 값을
처음에 있던
이 메모리 값에다가 넣어 주는 것이다.
이렇게 세단계에 걸쳐 일어 나고 있다는 것이다.
그래서 사실은 이것이 C#이나 C++에서 볼때는 한줄 이였지만 사실은
실제로 실행이 될때는
"머신"이 기계어로 실행을 할때는
한단계가 아니라 세단계에 걸쳐서 일어난다고 생각하면된다.
이것을 의사코드로 표현을 하면은
이런식으로 표현이 가능한데
왜 이렇게 세단계로 나누어서 실행을 하는지 궁금할 수 있는데
애당초,
이렇게 덧샘 연산을 하는 거랑
어떤 주소에가서 값을 가져오는 거랑
어떤 주소 값에 가가지고, 해당 위치에다가 어떤 값을 이렇게 넣는 거랑
이 세가지 동작은 어떻게 보면 더이상
조개질 수 없는 최소 단위라고 생각을 하면된다.
우리는 지금까지 _number++이 한번에 일어난다고 생각을 했지만
그게 아니라
이런식으로 세 단계에 걸쳐서 일어나는 작업이라고 생각을 하면 된다,
그래서
이 작업이
뭐 이런식으로 일어 났었다는 얘기이다.
그래서 이거를 보더라도 도대채 뭐가 문제인지 살펴 보도록 하자.
Thread_1이랑 Thread_2랑 거의 동시다발적으로 실행이 되었다고 가정을 해보도록 하자.
여기서 동시 다발적으로 들어왔다면 _number값을 추출해낼텐데
여기서 0으로 시작을 할 것이다.
그리고 이런식으로 1, -1이 될 테고
그다음에 이제
_number = temp 의 작업이 실행이 될텐데
Thread_1, Thread_2 둘중에 뭐가 먼저 실행이 될 까요??
그거는 모르지만
만약 Thread_1이 먼저 실행이 되었다고 가정을 해보자.
그러면 number = 1이라는 값을 넣어 줄 것이다.
반면
이녀석은 -1을 넣어 줄 것이다.
그래서 Thread_1, Thread_2 은 덧셈을 해주고 뺄셈을 해준 것인데
이 연산을 애당초 한번에 실행했어야지만 되는 거였는데
이렇게 지금 값을 추출 했다가
늘렸다가 다시 넣어주는 세단계에 걸쳐서 했기 때문에 문제가 되었던 것이다.
그리고 사실 이것을 유식한 표현으로 말하자면
"원자성" 이라고 표현을 한다.
원자성 하다 이렇게 말하는데
우리가 물리에서 원자는 쪼개질 수 없는 최소의 단위라고 얘기를 하는데
그래서 원자라는 것은 쪼갤 수가 없다는 것인데
"원자성"도 마찬가지로
더 이상 쪼개지면 안되는 그런 작업을 말하는 것이다.
이게 사실 "멀티쓰레드"에서만 얘기하는 것은 아니고 데이터 베이스에서도 다루게 된다.
이렇게 검을 구입을하면
이 사실을 DB에다가도 저장을 해야되는데
이 작업이 사실은 "원자적"으로 일어나야한다.
이작업을 원자적으로 처리하지 않으면 어떤일이 발생할 수 있냐하면은
골드를 뺀 상태에서 갑자기 서버가 다운 되었다고 얘기를 해보자.
그러면 골드에서 100을 뺀다음에 DB에다가는 저장이 되었는데
인벤에는 검이 추가가 되지 않는 상태가 발생 할 것이다.
유저 두명이 거래창을 통해서
이렇게 몇천만원 하는 집행검을 거래를 한다고 가정을 해보자.
그러면 이런식으로 동작을 시킬 수 있을 것이다.
그런데 이 과정이 "원자성"이 보장되지 않고
"각각" 일어난다고 가정을 해보자.
그래서 이까지는OK해서 성공을 했는데
그런데 어떤 이유에서인지는 모르겠지만
이 부분이 실패를 하면
User1에게도 집행검이 있고 User2에게도 집행검이 있는 일종의 아이템 복사가 일어났다고 볼 수 있다.
그래서 결국
이 "원자성"은 어마어마하게 중요한 개념이다.
"어떤 동작이 한번에 일어 나야한다는 것"은 굉장히 중요한 개념인데
우리가 결국 하고 싶은것은
number++이라는 작업과
number--라는 작업이 한번에 일어 났으면 좋겠다라는 것이다.
그래서 이렇게 하러면 이런식으로 만들면 안되고
InterLocked 계열의 시리즈들이 쭉있다.
InterLocked. 해보면 쭉 나오는데
이렇게해서 읽어보면
as an atomic operation이라고 원자적으로 보장한다는 말이 있다.
실제로 이녀석을 어떻게 보장을 하냐면은
애초에 CPU명령어에서 이녀석을 원자적으로 만들어 주는 그런 명령어가 따로 있다.
그런데 그렇다고해서 number++; 을 사용하지않고
InterLocked계열의 Increment를 사용을 하겠다라고하면 당연히 말이 안된다.
이것을
이렇게 할 수 있다는 것은 당연히 "단점"도 존재를 할 것이다.
그것은 당연히 "성능"에서 어마어마하게 손해를 본다는 그런 단점이 있을 것이다.
그럼에도 불구하고 지금 상태에서는 굉장히 좋은 해결책중 하나이다.
이렇게 되면 진짜 number++이 한번에 일어 나게된다.
그래서 값을 뽑아오고 늘리고 다시 넣는 작업이 한번에 일어 나게 되니까
아까 문제가 되었던 것이 해결이 될 것이다.
그래서 Decrement를 사용을 하면은
Thread_2도 1을 줄이는 작업이 한번에 일어나게 되니까
아까와 같은 문제가 없어질 것이라고 가정을 할 수 있다.
그래서 실행을 해보면
이제는 항상 0이 뜨는 것을 볼 수 있다.
그래서 아까의 문제를 해결을 하기 위해서 첫번째 방법을 알아 보았다.
그래서 원자적으로 덧셈을한다.
원자적으로 뺄셈을 한다. 이것이 결론이 되겠다.
그리고 참고로 InterLocked 계열의 함수를 사용할때
Increment, Decrement등등 여기 안에다가
이전시간에 배운 MemoryBarrier를 간접적으로 사용을 하고있는 것이다.
그래서 가시성 문제때문에
volatile 키워드를 사용하지 않아도 똑같이 작동을 한다.
그래서 InterLocked를 사용했다면
volatile는 잊고 살아도 된다는 말이고
그런데
지금 쓰레드_1에서 1을 늘리고
Thread_2에서 1을 줄이면 달라지는 게 없는거 아닌가? 라는 생각이 들 수 있는데
여기서 굉장히 중요한 사실이 뭐냐 하면은
이
Interlocked.Increment(ref _number);
InterLocked 계열의 함수를 실행했으면,
이녀석은 "원자성"이 보장되는 버젼이라고 했었다.
그래서 사진처럼 "All or Nothing"인데
실행이 되거나 안되거나 둘중 하나이다.
그래서 만약 Thread_1에서 InterLocked 계열의 함수를 실행을 했다면
"원자성"이 보장 받기 때문에
Thread_2에서 InterLocked.Decrement함수는 Thread_1의 InterLocked.Increment함수가
실행이 끝날 때 까지 기다려야 된다는 의미이다.
만약, 실제로 Thraed_1, Thread_2 두개가 거의 동시다발적으로 실행이 되었다고 해도
둘중에 승자는 있기 마련이다.
그런데 만약 Thread_1에서의 InterLocked 계열의 함수가 먼저 실행이 되었다면
일단 _number는 1로 튀어 나오게 될 것이고
그 상태에서
Thread_1에서 InterLocked 계열의 함수 실행이 끝났기 때문에
InterLocked.Decrement를 실행하게 되어서 _number가 1인 상태에서
Decrement를 하면은
다시 _number는 0으로 돌아온다는 얘기이다.
그래서 아까 _number++과 다르다는 점이 무엇이냐 하면은
아까말한 세단계가 한번에 일어난다는 사실도 중요하지만,
그거에다가 추가로 Increment먼저 일어나고 Decrement가 그다음 실행하는 식의
"순서"보장이 된다는 것이다.
동시다발 적으로 두마리가 실행을 하면은
서로 "경합"을 해가지고
승자인 이녀석이 먼저 실행을 할것인데(승자는 바뀔 수 있음)
먼저 실행하는 쪽이 일단 결과를 보장 받는다.
_number가 1이 늘어나는 것까지는 보장이 되는 것이다.
나머지 애들은 이전 실행이 끝날 때까지 기다려야 한다는 그런 결론이 있다.
그러다 보니 당연하게
일반적으로 쓰는 number++, number-- 와 같은 연산보다는 당연히 느리다.
우리가
애당초 이런식으로 연산을 하게되면
"캐시"의 개념은 쓸모가 없게 될 수도 있다.
InterLocked로
_number에 접근을 해가지고 뭔짓거리를 하고있으면
다른애들이 접근을 할 수가 없으니까, 의미가 없게 되니까
그래서
결국에는 InterLocked 계열은 순서를 보장해서 결과가 보장이 되니까
아까 일어난
"Race Condition" 문제가 해결이 될 것이다.
그래서 다시 고급식당 예제로 돌아오면은
이렇게 콜라 주문을 받았는데
이제는 콜라를 주문현황판에서 없애고
배달하는 일련의 작업을 "원자적으로" 즉, 한번에 일어 나도록 만들어 준다는
개념이 되겠다.
그래서 결국에는 이 세명이 주문현황에 콜라가 뜬 것을 보자마자
열심히 달리기 시작해서 맨 먼저 도착한 애만
이렇게 꺼내가지고 실제 배달할 권리를 얻게 된다.
그럼 나머지 두명은 늦게 도착했으니 허탕을 치는 그런 개념이 되겠다.
자, 그리고 매무리 짓기전에 하나만 더 짚고 넘어가자면
InterLocked.Increment( ) 안에다가 _number를 그냥 넣어준 것이 아니라
ref(레퍼런스)를 넣어 주고 있다.
즉, "참조" 값으로 넣어주고 있는데
참조값으로 넣어주고 있다는 것은
결국 _number라는 수치 자체를 넣어주는 것이 아니라
이 number의 주소값을 넣어주고 있다고 생각하면 되는데
이렇게 된 이유에 대해서도 곰곰히 생각을 해볼 필요가 있다.
만약 이렇게 되었으면은
int자체를 넣어 준다는 개념이 되니까
이 _number의 값을 복사를해서 Increment에 넣어준다는 의미가 될 것이다.
그런데 이렇게 하면 말이 안되는게 우리가 _number의 값을 가져오는 순간에
다른애가 _number에 접근을 해가지고 다른 값으로 수정을 했을 수도 있을 것이다.
그래서 아까 RaceCondition문제가 해결이 되지 않는 다는 말이고
이렇게 레퍼런스를 붙여서 넣게되면 어떻게 해석이 되냐하면은
지금 이 _number라는 값이 어떤 값인지는 알지는 못하지만,
_number라는 값을 참조를 해가지고 즉,
이 주소에 가가지고 이 안에 있는 수치를
어떤 값인지는 모르지만 무조건 1을 늘려줘 라는 명령을 내린 것이다.
그래서 ref가 붙은 것과 아닌 것의 "미묘한" 차이를 깨우쳐야 한다.
그래서 처음에 할때 햇갈리는 부분이 뭐냐하면은
이렇게 1을 늘리기전에 값을 prev에다가 추출을 해보도록 하자.
그다음에
이녀석이 실행이 되었으면은
_number는 1늘어난 상태가 될 것이다.
그렇다는 것은 _number를
next에다가 이런식으로 추출을 할 수 있다는 얘기인데
그렇다는 것은
prev랑 next랑 비교를 해보면
우리가 지금까지 생각을 하던 "싱글 쓰레드"마인드로 생각을 하면은
얘가 1 증가시키는 것이니까
prev에서 1을 더하면 next가 나올거 같죠??
그런데...! 당연히 그렇지 않다!!
이제는 왜냐하면
이제는 싱글 쓰레드 마인드를 버려야한다.
애당초
이 _number라는 녀석은 쓰레드끼리 공유를 해서 사용을 하고 있었으니까
_number에서 값을 꺼내와서 prev에 추출하는게 사실 말이 안되는 것이다.
왜냐하면
우리가 꺼내와서 쓰는 "순간"에도
_number를 누군가가( 예를들어 Thread_2 가)
이런식으로 건드려가지고
_number의 값이 바뀔 수도 있다는 얘기가 된다.
지금 좀 이해가 되는 부분은
멀티 쓰레드 이기때문에
prev에서 값을 가져올려고 하는 순간
다른 쓰레드가 number의 값을 변경 할 수도 있기 때문에 말이 안된다는 것까지는 OK인데
ref레퍼런스로 참조를 해서 하면 rece Condition이 왜 해결이 되는 것인가??
-> InterLocked 계열이 ref를 쓰면 가시성 보장 + 순서를 보장해서 _number의 진짜 값에 접근을 하는 것을 막아 줘서인가??
그런데 또하나 궁금한 것이
여기서 증가된 값이 또 얼마인지 추출을 하고 싶은데
이런식으로 사용을 하는 것은 말이 안된다고 했었다.
_number에서 꺼내오는 순간 이 녀석이 유효한지 아닌지 검증을 할 수 없으니까
int next = _number;
이런식으로 막 꺼내올 수 없다라고 했었다.
그렇기 때문에 Increment에 마우스 갖다 대면
이렇게 return 값이 (int) 있는 것이다.
그래서 InterLocked.Increment가 반환하는 값은
Increment가 된다음에
"실제로" 바뀐 값, 즉,
afterValue로 추출을 하면 100% 맞는 값을 추출을 하겠지만
중간에 아니면 나중에 _number의 값이 궁금하다고
이런식으로 따로 빼서 추출을 하는 것은 말이 안된다는 것이다.
=> 왜냐하면 멀티 쓰레드 환경이니까, 다른 쓰레드가 값을 변경 할 수도 있기 때문에 검증이 되었는지 아닌지, 유효한지 아닌지 알 수 없기 때문이다.!!!!!!!!!!!!!
그래서 이부분이 조금 어렵긴한데
이부분에 대해서 곰곰히 생각을 해보면 좋을 것이다.
나중에 InterLocked계열이나 Increment를 쓰다보면은
자연스럽게 익혀지고 익숙해지기 때문에
이런 고민들도 잘 안하게 되기는 하는데
처음에는 많이 햇갈린다.
하지만 하다보면 익숙해진다.
질문글