목요일, 1월 24

Java 동기화 관련 키워드 정리




 Multi-Threading 에 동기화는 중요한 이슈입니다.

 이번 글은 Java에서 동기화를 사용할 때, 자주 등장하는 키워드를 정리해보고자 합니다.


 0. 키워드 정리 전에 앞서.
 이 글은 멀티 쓰레딩이나 동기화를 정리하려는게 아니고 키워드의 의미만 집고 넘어가려는 글입니다. 그러므로 어느 정도 기반 지식이 있으셔야 합니다.

 1. Atomic
 그런 측면에서 Atomic = 원자 연산에 대해서는 기본적으로 이해하고 계셔야 됩니다. 이 개념을 모르고 멀티쓰레딩이나 동기화를 논한다는 것 자체가 어이없는 일이라고 생각이 드니까요. 비유를 하자면 한글도 모르는 사람이 문법 따지는 격입니다.

 Atomic 연산은 원자처럼 더 나누어 쪼개지지 않는,
 즉, cpu의 한 사이클에 처리될 것을 보장하는 연산입니다.

 예를 들면,

        int a, b = 0;
        a = b + 1;
 과 같은 연산은 cpu에서 보면
   0x0000000000400492 <+11>:    mov    -0x4(%rbp),%eax
   0x0000000000400495 <+14>:    add    $0x1,%eax
   0x0000000000400498 <+17>:    mov    %eax,-0x8(%rbp)
 처럼 컴파일 될 수 있는데, 각 라인이 의미하는 바는, 
-0x4(%rbp),%eax ; load var b to register eax
add    $0x1,%eax   ; add 1
mov    %eax,-0x8(%rbp) ;  store to var a
 입니다.

 여기서  각 라인의 명령어가 처리되는 과정에 Context 전환이 되거나, 메모리 참조가 실제 메모리가 아닌 캐쉬참조가 되거나, CPU0이 값을 레지스터에 불러온 뒤에 CPU1이 변경을 하거나 하는 등의 상상할 수 있는 모든 경우의 수를 원천적으로 차단할 수 있는 기능을 CPU는 제공하고 있습니다.

 이를 통칭 ATOMIC 이라고 부릅니다.


 2. Volatile

 가장 많은 분이 헤깔려하고 이해하지 못하면서 대충 이해하는 키워드가 이 Volatile이 아닌가 싶습니다.

 Volatile 키워드의 의미는 "컴파일러나 옵티마이저가 특정 메모리에 대해서 최적화나 캐쉬등의 작업을 하지 말아라." 라고 주문해 놓는 것입니다. 만약 Volatile 을 지시하지 않는다면, Java의 컴파일러 혹은 runtime optimizer는 해당 변수의 대입등을 최적화 하기 위해서 여러가지 수를 쓰게 됩니다.

 예를 들어, 슈퍼스칼라 엔진처럼 CPU단의 최적화가 이뤄지게 되면, 레지스터에 값을 불러오거나, 저장하는 과정 중에 실제 메모리가 아닌 각 CPU에 존재하는 캐쉬를 참조하여, 결과가 CPU마다 상이하게 나오는 경우가 생길 수도 있습니다.

 이럴 경우 마찬가지로 동기화 이슈가 발생할 수 있습니다.

 때문에 Volatile 키워드를 제공하는데, 이는 Volatile이 지정된 변수(메모리 공간)과 연관있는 모든 명령은 직접 메모리를 참조하도록 최적화를 금하겠다는 선언인 것 입니다. 이를 위해 CPU가 제공하는 특수 명령어들이 존재합니다. 예를들어 mfence와 같은 명령이 그것인데요. 명령어 자체가 memory-fence라고 하여 이 명령부터는 메모리를 실제 물리 메모리를 참조할 것이라는 선언입니다.

 앞의 예제를 예로 들면,
   mfence --- memory fence -----------------------
   0x0000000000400492 <+11>:    mov    -0x4(%rbp),%eax  <<< CPU1
   0x0000000000400495 <+14>:    add    $0x1,%eax               <<< CPU 0
   0x0000000000400498 <+17>:    mov    %eax,-0x8(%rbp)





 3 . Synchronized

 Synchronized는 임계영역입니다.
 이는 말 그대로, 두 개 이상의 쓰레드가 동시에 접근하는 것을 막는다는 뜻입니다.

 Volatile과 다른 점은, Volatile은 컴파일러, 최적화, 런타임-최적화(슈퍼스칼라 등으로 인한)를 막는 것이라면, Synchronized는 멀티-쓰레드 환경에서 CPU간의 문맥 전환으로 인해 생길 수 있는 동기화 요소입니다.

        int a, b = 0;
        a = b + 1;

 을 다시 예로 들면, Synchronized에서 방어하고자 하는 문제 요소는
 과 같은 연산은 cpu에서 보면

   0x0000000000400492 <+11>:    mov    -0x4(%rbp),%eax  <<< CPU1
   0x0000000000400495 <+14>:    add    $0x1,%eax               <<< CPU 0
   0x0000000000400498 <+17>:    mov    %eax,-0x8(%rbp)

입니다.

문제가보이시나요?

CPU0에서 1을 증감시켰지만, 실질적으로 메모리 공간에 대입되지 않아, CPU1은 1이 증가되기 전의 값을 레지스터에 불러오게 됩니다. 때문에 CPU0에서 이후 처리는 a=1인채로 실행되지만,  CPU1에서 a=0이 되버리는 겁니다.

이런 문제를 방어하기 위해서 CPU는 특정 구간에 모든 메모리 대역을 강제로 동기화시키는 기능을 제공합니다. 즉, Synchronized가 선언된 구간에 돌입하면 실제로 CPU의 모든 메모리 대역은 잠기고 한 번에 하 나의 LOAD와 STORE만 동작합니다.

실제로 이런 CPU명령들은 특수한 접두어나 특수한 명령어로 명명됩니다.
예를 들어 우리가 다뤘던 예에서는,

xchg $0x1, -0x8(rbp)

처럼 a와 0x1을 xchg하는 식으로 변경할 수 있는데 이 때 xchg와 같은 명령이 바로 ATOMIC 연산을 보장하는 특수 명령 계통이기 때문에 자연스럽게 모든 메모리가 동기화 되고 a에 다른 값이 대입될 여지를 원천적으로 봉쇄하는 겁니다.

이런 특수 명령계통을 사용할 수 없는 경우, 앞의 예라면, 어쩔 수 없이 이런 명령을 사용한 lock을 사용할 수 밖에 없겠죠, mutex, spinlock 같은.




댓글 없음:

댓글 쓰기