Android Document  SDK old PDF 파일
Android SMP(5/7) - 사례:Java에서 하지 말 것
작성자
작성일 2011-05-02 (월) 21:41
ㆍ추천: 0  ㆍ조회: 11925      
IP: 121.xxx.76

 
 
 
 
목차
 
1. 도입
2. 이론
   2.1. 메모리 일관성 모델(Memory Consistency Models)
   2.2. 데이터 메모리 장벽(Data Memory Barriers)
   2.3. 원자적 오퍼레이션들(Atomic Operations)
3. 사례
   3.1. C에서 하지 말 것
   3.2. Java에서 하지 말 것.
   3.3. 해야 할 것
4. 부록
 
 
 

3.2 Java에서 하지 (What Not To Do In Java)

 
우리는 관련된 몇몇 Java 언어 특징을 논의하지 않았다. 그러므로 먼저 이것들에 대해 빠르게 살펴볼 것이다.

 
3.2.1 Java “synchronized” “volatile” 키워드

 
“synchronized” 키워드는 Java 언어의 in-build locking 메커니즘을 제공한다. 모든 오브젝트는 상호간에
배타적인 접근을 제공하기 위해 사용될 수 있는 관련된 “monitor”를 갖는다.
 
“synchronized” 블럭의 구현은 spin lock 예제와 동일한 기본적인 구조를 갖는다:
이것은 CAS(compare-and-set)을 획득함으로써 시작되며, store를 해제하면서 끝난다. 이것은 컴파일러와
코드 최적화가 “synchronized” 블록내에서 코드를 이동시키는 것이 자유롭다는 것을 의미한다.
실질적인 결론은: 여러분이 어떤 함수에서 synchronized 블록 내의 코드가 그것 위의 있는 설비 다음으로
오거나, 또는 그것 아래 있는 설비 앞으로 오게끔 해서는 안 된다는 것이다. 더 나아가, 만약 메쏘드가 동일한
오브젝트를 lock하는 두 개의 synchronized 블록을 가지고 있고, 또 다른 쓰레드에 의해 관측 가능한 
서로사이에 끼어있는 코드in the intervening code에 대한 오퍼레이션이 없다면,
컴파일러는 “lock coarsening”을 수행할 수 있으며, 이것들은 하나의 단일 블록으로 묶을 수도 있다.
 
관련된 나머지 키워드가 “volatile”이다. Java 1.4와 그 이전에 명세서에 정의된 것처럼, volatile 선언은
이것에 대한 C 대용물 만큼이나 약하다. Java 1.5에 대한 명세서는 이것이 거의 monitor synchronization의
레벨인 더 강한 보장을 제공하도록 업데이트되었다.
 
volatile 접근의 효과는 예제를 사용해서 설명될 수 있다. 만약 쓰레드 1 이 volatile 필드에 write하고,
이어서 쓰레드 2 가 동일 필드에서 read를 한다면, 그러면 쓰레드 1 에 의해 만들어진 해당 write와
그 이전의 모든 write를 쓰레드 2 가 보는 것이 보장된다. 더 일반적으로, 쓰레드 2 가 read를 할 때
이것이 볼 필드가 write 되도록 해당 write가 임의의 쓰레드에 의해 만들어 진다. 효과 측면에서,
volatile에 write 하는 것은 monitor 해제와 같고 volatile에서 read 하는 것은 monitor 획득과 같다.
 
Non-volatile 접근들은 흔히 volatile 접근들과 재정렬될 수 있다. 예를 들어, 컴파일러는 non-volatile
load 또는 store를 volatile store 위로 옮길 수 있다. 그러나 아래로는 옮길 수 없다. volatile 접근들은
서로간에는 재정렬될 수는 없다. VM은 적절한 메모리 장벽을 제기하는데 주의를 기한다.
 
오브젝트 참조의 load와 store 그리고 대부분의 기본primitive 타입은 원자적이지만, long 과 double
필드는 그것들이 volatile로 표기되지 않는 한 원자적으로 접근되지 않는다. Non-volatile 64bit 필드에
대한 멀티 쓰레드화 된 업데이트는 심지어 단일 프로세서에서 조차도 문제가 있다.
 

3.2.2 예제

 
여기에 간단한 잘못 구현된 monotonic(단조롭게 1증가 및 감소를 하는 함수) 카운터가 있다.


class Counter {

private int mValue;

 

public int get() {

return mValue;

}

 

public void incr() {

mValue++;

}

}


예제 J-1
 
get()과 incr()이 멀티 쓰레드에서 호출되며, 그리고 모든 쓰레드가 get()을 호출할 때 현재 카운트를
보게끔 하고자 한다고 가정하자. 가장 확연한 문제는 “mValue++”로, 이 실제로는 다음과 같은 세 개의
오퍼레이션이라는 것이다.

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg
만약 두 쓰레드가 동시에 incr()을 실행한다면, 업데이트 중 하나는 분실될 것이다. 증가를 원자적으로
만들기 위해, 우리는 incr()을 synchronized로 선언하는 것이 필요하다. 이렇게 바꿈으로써, 위의 코드는
멀티 쓰레드 단일 프로세스 환경에서 올바르게 동작할 것이다.
 
하지만, 그것은 SMP에서는 여전히 깨진다. 서로 다른 쓰레드들이 get()을 통해 서로 다른 결과를 볼 수
있다. 왜냐하면 우리가 평범한 load을 사용해서 그 값을 읽고 있기 때문이다. 우리는 get()을 synchronized로
선언함으로써 문제를 바로잡을 수 있다. 이렇게 변경함으로써, 그 코드는 명백하게 옳게 한다.
 
불행하게도, 우리는 성능을 저해할 수 있는 lock 경합contention의 가능성을 소개했다.
get()을 synchronized로 선언하는 대신에, mValue를 “volatile”로 선언할 수 있다. (incr()은 여전히
synchronize를 사용해야 한다는 것에 주의하라.)
이제 우리는 mValue에 대한 volatile write가 이어지는
mValue에 대한 임의의 volatile read에서 보여질 것이라는 것을 안다. incr()은 다소 더 느려질 것이지만,
get()은 더 빨라질 것이다. 그러므로 만약 read가 write보다 더 많다면 심지어 경합이 없을 때조차도 더 빠르다.
 
(또한 java.util.concurrent.atomic.AtomicInteger 를 보라.)
 
여기에 앞선 C 예제 형식과 비슷한 또 다른 예제가 있다.

class MyGoodies {

public int x, y;

}

 

class MyClass {

static MyGoodies sGoodies;

 

void initGoodies() { // runs in thread 1

MyGoodies goods = new MyGoodies();

goods.x = 5;

goods.y = 10;

sGoodies = goods;

}

 

void useGoodies() { // runs in thread 2

if (sGoodies != null) {

int i = sGoodies.x; // could be 5 or 0

....

}

}

}

 
예제 J-2
 
이것은 C 코드에서처럼 동일한 문제를 갖는다. 즉 “sGoodies = goods” 할당assignment은 “goods”의
필드들이 초기화되기 전에 관측될 수 있다. 만약 여러분이 sGoodies를 volatile 키워드를 사용해서
선언한다면, 여러분은 load를 마치 atomic_acquire_load 호출처럼, 그리고 store를 atomic_release_store
호출처럼 간주할 수 있다.
 
(오직 “sGoodies” 참조만이 volatile이라는 점에 주의하라. 그 내부의 필드에 대한 접근은 아니다.
구문 “z = sGoodies.x” 는 MyClass.sGoodies에 대한  volatile load를 수행하고 이어서 sGoodies.x의
non-volatile load를 수행할 것이다. 만약 여러분이 “MyGoodies localGoods = sGoodies”라는 local
참조를 만든다면, “z = localGoods.x”는 어떤 volatile loads도 수행하지 않을 것이다.)

 
Java 프로그래밍에서 보다 보편적인 관용구는 악명높은 “double-checked locking”이다.

class MyClass {

private Helper helper = null;

 

public Helper getHelper() {

if (helper == null) {

synchronized (this) {

if (helper == null) {

helper = new Helper();

}

}

}

return helper;

}

}

 



예제 J-3
 
MyClass 인스턴스와 결부된 단일한 Helper 오브젝트 인스턴스를 갖고자 한다는 하자. 우리는 오직
그것을 한번만 생성해야 한다. 그러므로 우리는 전용 getHelper() 함수를 통해 그것을 생성하고 리턴한다.
두 개의 쓰레드가 인스턴스를 생성할 때의 경쟁을 피하기 위해, 우리는 오브젝트 생성을 synchronize할
필요가 있다. 하지만, 우리는 모든 호출에 “synchronized” 블록에 대한 오버헤드를 지불하고 싶지 않다.
그러므로 우리는 오직 현재 “helper”가 null인 경우에만 그 영역을 수행한다.
 
이것은 전통적인 Java 소스 컴파일러와 인터프리터만 있는interpreter-only VM을 사용하지 않는 한,
단일프로세서 시스템에서 올바르게 동작하지 않는다. 여러분이 복잡한 코드 최적화기와 JIT 컴파일러를
추가한다면 이것은 깨진다. 더 상세한 것은 부록에 있는 “‘Double Checked Locking is Broken’ Declaration”
또는 Josh Bloch’s Effective Java, 2nd ed의 항목 71(“Use lazy initialization judiciously”)을 보라.
 
SMP 시스템에서 이것을 실행하면, 실패하는 또 다른 경우가 발생한다. 마치 C 비슷한 언어로 컴파일된 것
같은 조금 바뀐 동일한 코드를 고려해 봐라. (Helper 생성자 행위를 표현하기 위해 한 쌍의 정수 필드를
추가했다.)



if (helper == null) {

// acquire monitor using spinlock

while (atomic_acquire_cas(&this.lock, 0, 1) != success)

;

if (helper == null) {

newHelper = malloc(sizeof(Helper));

newHelper->x = 5;

newHelper->y = 10;

helper = newHelper;

}

atomic_release_store(&this.lock, 0);

}


 
예제 J-3A
 
이제 문제를 명백히 하자: “helper”에 대한 store는 메모리 장벽 이전에 발생하고 있다. 이것은 x/y 필드에
대한 store 이전에 다른 쓰레드가 “helper”의 non-null 값을 관측할 수 있다는 것을 의미한다.
 
여러분은 “helper”에 대한 store가 atomic_release_store 이후에 발생하도록 그 코드를 재배치할 수 있지만,
그것은 도움이 안 될 것이다. 왜냐하면, 코드가 위로 이동하는 것이 가능하기 때문이다 - 컴파일러는 그
할당assignment을 atomic_release_store 위로 그것의 원래 위치로 뒤로 이동시킬 수 있다.
 
이것을 해결하는 두 가지 방법이 있다.

  1. 단순화 시켜서 외부에서의 검사를 삭제하라. 이것은 synchronized 블록 밖에서 “helper”의 값을
    결코 검사하지 못하게 한다.
  2. “helper”를 volatile로 선언하라. 이 작은 변경 때문에, Example J-3의 코드는 Java 1.5와 그 이후에서
    올바르게 동작할 것이다. (이것이 사실이라는 것을 스스로에게 납득시키기 위해 약간의 시간을 원할
    수도 있다.)
다음의 예제는 volatile를 사용할 때 중요한 두 가지 이슈를 설명한다.

class MyClass {

int data1, data2;

volatile int vol1, vol2;

 

void setValues() { // runs in thread 1

data1 = 1;

vol1 = 2;

data2 = 3;

}

 

void useValues1() { // runs in thread 2

if (vol1 == 2) {

int l1 = data1; // okay

int l2 = data2; // wrong

}

}

 

void useValues2() { // runs in thread 2

int dummy = vol2;

int l1 = data1; // wrong

int l2 = data2; // wrong

}

}


 
예제 J-4
 
useValues1()을 살펴보면, 만약 쓰레드 #2 가 아직 vol1 에 대한 업데이트를 관측하지 못했다면, 그러면
그 쓰레드는 data1 또는 data2 가 아직 set 되었는지 알 수 없다. 일단 그 쓰레드가 vol1 에 대한 업데이트를
본다면, 그 쓰레드는 또한 data1 에 대한 변경을 볼 수 있다는 것을 안다. 왜냐하면, 그것은 vol1 이 변경되기
전에 이루어졌기 때문이다. 하지만, 그 쓰레드는 data2 에 대한 어떤 가정도 할 수 없다. 왜냐하면,
그 store는 volatile store 이후에 수행되기 때문이다.
 
useValues2()의 코드는 VM으로 하여금 메모리 장벽 만들도록 하기 위해 두 번째 volatile 필드, vol2 를
사용한다. 이것은 일반적으로 동작하지 않는다. 제대로 된 “happensbefore” 관계를 확립하기 위해서는,
두 쓰레드 모두는 동일한 volatile 필드를 가지고 서로 상호작용할 필요가 있다. 여러분은 vol2 가 쓰레드 1
에서 data1/data2 이후에 설정되는 것을 알아야만 할 것이다. (코드를 살펴보면 이것이 동작하지 않는다는
사실은 아마도 명백하다; 여기에서 주의사항은 일련의 정렬된 접근들을 생성하는 대신에 영리하게
“원인관계cause” 메모리 장벽을 시도한다는 것이다.)

계속 :




덧글 쓰기 0
3500
※ 회원등급 레벨 0 이상 읽기가 가능한 게시판입니다.