IT_Programming/Java

[펌] 멀티코어 시스템의 Java 동시성 버그 패턴

JJun ™ 2011. 4. 20. 05:22



 출처 : http://www.ibm.com/developerworks/kr/library/j-concurrencybugpatterns/index.html

 Double Checked Locking, Wait Not In Loop, Unconditional Wait, Mismatched Wait and Notify, Two Locks Held While Waiting 과 같은
 동시성 패턴 관련 기본적인 선수 지식을 가지고 보는 것이 좋습니다.




멀티코어 시스템의 Java 동시성 버그 패턴


덜 알려진 여섯 가지 Java 동시성 버그 패턴


Zhi Luo, 소프트웨어 엔지니어, IBM China
Yarden Nir-Buchbinder, Research Scientist, IBM
Raja Das, Software Architect, IBM


요약:  

동시성 버그 패턴을 연구하면 동시성 프로그래밍에 대한 일반적인 인식을 향상시킬 수 있을 뿐만 아니라 작동하지 않거나 작동하지 않을 수도 있는
코딩 이디엄을 인식하는 방법을 배울 수 있습니다. 이 기사에서 작성자인 Zhi Da Luo, Yarden Nir-Buchbinder 및 Raja Das는 멀티코어 시스템에서 실행되는
Java™ 애플리케이션의 스레드 안전성과 성능에 위협이 되는 잘 알려지지 않은 여섯 가지 버그를 설명합니다.

이 기사에 태그:  디버깅, 멀티쓰레딩, 자바

 

원문 게재일:  2010 년 12 월 21 일 번역 게재일:  2011 년 4 월 19 일 
난이도: 초급 원문:  보기 PDF:  A4 and Letter (73KB | 12 pages) 

 

[목차]

  • 1. Jetty의 안티패턴
  • 2. 변경 가능 필드에 대한 동기화
  • 3. java.util.concurrent 잠금 오류
  • 4. 동기화된 블록의 성능 조정
  • 5. 다단계 액세스
  • 6. 대칭적 잠금 교착 상태
  • 결론
  • 참고자료
  • 필자소개
  • 의견
  •  


    멀티스레드 프로그래밍에 대한 경험이 부족한 프로그래머가 멀티코어 시스템용 소프트웨어에 적용하는 데는 두 가지 과제가 존재한다.

    첫째, 동시성으로 인해 재생하고 진단하기가 매우 어려운 새로운 범주의 Java 프로그램 버그(예: 데이터 경쟁 및 교착 상태)가 생겨났다.
    둘째, 많은 프로그래머가 코드에서 오류를 일으킬 수 있는 특정 멀티스레드 프로그래밍 이디엄의 미묘한 점을 인식하지 못한다.


    동시성 프로그래밍을 하는 과정에서 버그가 생기지 않도록 하려면,
    Java 프로그래머가 멀티스레드 코드에서 버그가 나타날 수 있는 중요한 시점을 인식하는 방법을
    배워서 버그가 없는 소프트웨어를 작성해야 한다.
    이 기사는 동시성 프로그래밍의 특성에 이해 수준이 높지 않거나 낮은 개발자를 대상으로 한다.


    이중 확인 잠금(double-checked locking), 반복 대기(spin wait) 및 wait-not-in-loop와 같은 잘 알려진 동시성 버그 패턴에 초점을 맞추기 보다는 덜 알려졌지만
    실제 Java 애플리케이션에서 자주 드러나는 여섯 가지 패턴을 소개한다. 사실상, 첫 번째 두 가지 사례는 일반적으로 사용되는 두 가지 웹 서버에서
    실제로 발견된
    버그이다.





    1. Jetty의 안티패턴


    첫 번째 동시성 버그는 널리 사용되는 오픈 소스 HTTP 서버인 Jetty에서 발견되었다.
    이 버그는 Jetty 커뮤니티에서 실제로 확인한 버그이다. (버그에 대한 보고서는
     참고자료를 참조한다.)


    목록 1. 잠금을 유지하지 않은 상태에서의 휘발성 필드에 대한 비원자적 조작
    // Jetty 7.1.0,
    // org.eclipse.jetty.io.nio,
    // SelectorManager.java, line 105
    private volatile int _set;
    ......
    public void register(SocketChannel channel, Object att)
    {
       int s=_set++;
       ......
    }
    ......
    public void addChange(Object point)
    {
       synchronized (_changes)
       {
          ......
       }
    }
    

    목록 1 에는 여러 개의 오류가 결합되어 있다.

    • 첫 번째, _set volatile로 선언되었다. 따라서 다중 스레드에서 이 필드를 액세스할 수 있다.

    • 그러나 _set++는 원자적이 아니므로 반드시 하나의 개별 조작으로 실행되는 것은 아니다.
      그 보다는 세 개의 분리된 조작(
      읽기 - 수정 - 쓰기)으로 구성된 시퀀스가 단축된 것으로 볼 수 있다.

    • 결국, _set++는 잠금을 통해 보호할 수 없다. register 메소드는 다중 스레드에 의해 동시에 호출된다.
      따라서 경쟁 조건이 발생하여
       _set 값이 올바르지 않게 된다.


    이러한 오류는 Jetty 코드에서 발생했던 것만큼 쉽게 독자의 코드에서도 나타날 수 있으므로 이러한 오류가 어떻게 발생했는지 자세히 살펴보도록 하자.



    버그 패턴의 요소


    코드의 논리적 시퀀스를 통해 코드를 따라가다 보면 이러한 버그 패턴을 명확하게 파악할 수 있다. 다음과 같은 변수
     i에 대한 조작 등은

    i++
    --i
    i += 1
    i -= 1
    i *= 2
    

    비원자적 조작이다(즉, read-modify-write). Java 언어의 volatile 키워드는 변수의 가시성만 보장하지 원자성은 보장하지 않는다.
    따라서 이점을 진지하게 생각해 보아야 한다. 잠금으로 보호할 수 있는 휘발성 필드에 대한 비원자적 조작으로 인해 경쟁 조건이
     발생할 수 있지만,
    이러한 상황은 다수의 스레드에서 비원자적 조작을 동시에 액세스하는 경우에만 해당된다.


    스레드로부터 안전한 프로그램에서는 하나의 쓰기 스레드만 변수를 수정할 수 있으며, 다른 스레드는 변수를
     volatile로 선언하여 최신 값만을 읽을 수 있다.
    따라서, 코드에 버그가 많은지 적은지는 다수의 스레드가 해당 조작에 어떻게 동시에 액세스할 수 있는지에 달렸다.
    하나의 스레드에서 비원자적 조작을 호출하면, 시작 - 결합 관계나 외부 잠금으로 인해 코드 이디엄이 스레드로부터 안전하게 된다.


    Java 코드의
     volatile 키워드는 변수가 가시적이라는 점만 보장하지 원자성은 보장하지 않는다는 점을 기억해야 한다.
    변수 조작이 비원자적이고 다수의 스레드에서 이 변수 조작을 액세스할 수 있는 경우에는 휘발성 동기화 기능에 의존해서는 안 된다.
    그 대신
    java.util.concurrent 패키지의 동기화된 블록, 잠금 클래스 및 원자적 클래스를 사용해야 한다.
    이러한 것들은 스레드 안전성 문제를 보장하기 위해 설계되었다.






    2. 변경 가능 필드에 대한 동기화


    Java 언어에서는 동기화된 블록을 사용하여 멀티스레드 시스템에서의 공유 자원 액세스를 보호하는 상호배제 잠금을 수행한다.
    그러나 변경 가능 필드를 대상으로 동기화를 하는 경우에는 허점이 생기고 이로 인해 상호배제 규칙을 위반하게 될 수 있다.
    이에 대한 해결책은 동기화된 블록을 언제나
     private final로 선언하는 것이다. 그 이유를 확인하기 위해 이러한 문제점을 자세히 살펴보도록 하자.



    업데이트된 필드에 대한 동시적 잠금


    동기화된 블록은 필드 자체보다는 동기화 필드에서 참조된 오브젝트에 의해 보호된다. 동기화 필드가 변경 가능하고 따라서 이 필드를
    프로그램의 초기화 부분 이외의 어느 위치에 지정할 수 있는 경우, 다른 스레드는 다른 오브젝트를 대상으로 동기화할 수 있기 때문에
    동기화 필드에 유용한 시맨틱이 있다고 할 수 없다.


    목록 2에서 이러한 문제를 확인할 수 있다. 목록 2는 오픈 소스 웹 애플리케이션 서버인 Tomcat의 코드 스니펫이다.


    목록 2. 오류가 있는 Tomcat 
    96: public void addInstanceListener(InstanceListener listener) {
    97:
    98:    synchronized (listeners) {
    99:       InstanceListener results[] =
    100:        new InstanceListener[listeners.length + 1];
    101:      for (int i = 0; i < listeners.length; i++)
    102:          results[i] = listeners[i];
    103:      results[listeners.length] = listener;
    104:      listeners = results;
    105:   }
    106:
    107:}
    

    listeners는 배열 A를 참조하고, 스레드 T1이 먼저 배열 A에 대한 잠금을 획득한 다음, 배열 B를 작성 중이라고 가정하자.
    그동안에 뒤이어 T2가 배열 A에 대한 잠금을 차단한다. T1이 배열 B에
     listeners를 설정하는 것을 완료하고 이러한 차단을 종료하면,
    T2는 배열 A를 잠그고 배열 B를 복사하기 시작한다. 그런 다음에는 뒤이어 T3가 배열 B를 잠근다.
    스레드가 서로 다른 잠금을 획득했기 때문에 이제 T2와 T3는 배열 B를 동시에 복사하게 된다.


    그림 1에는 이러한 시퀀스가 자세히 설명되어 있다.


    그림 1. 변경 가능 필드에 대한 동기화로 인한 상호배제의 결여


     

    이와 같은 설정으로 부터 바람직하지 않은 작동이 많이 발생할 수 있다.
    적어도 새로운 listners 중 하나가ArrayIndexOutOfBoundsException을 잃거나 스레드 중 하나가 이것을 얻을 수 있는데,
    이는 listeners가 이것을 참조하고 메소드의 어느 위치에서는 이것의 길이가 변경될 수 있기 때문이다.

    동기화 필드를 언제나 private final로 선언하는 것이 우수 사례이다. 이렇게 하면 잠금 오브젝트가 변경되지 않으며 상호배제가 보장된다.






    3. java.util.concurrent 잠금 오류


    잠금은 java.util.concurrent.locks.Lock 인터페이스로 구현되며 이것을 이용하여 다수의 스레드가 공유 자원을 액세스하는 방법을 제어한다.
    이러한 잠금에는 차단 구조가 필요하지 않기 때문에 잠금은 동기화 방법이나 동기화 명령문보다 더욱 유연하다.
    그러나 차단을 하지 않으면 잠금은 결코 자동으로 릴리스되지 않기 때문에 이러한 유연성으로 인해 코딩 오류가 발생할 수 있다.
     
    Lock.lock() 호출에는 동일한 인스턴스를 대상으로 하는 unlock() 호출이 없기 때문에 잠금 오류가 발생할 수 있다.


    throw를 사용하여 처리할 수 있는 예외와 같은 메소드 작동을 중요한 코드에서 간과하게 되면
     java.util.concurrent 잠금 오류 버그가 발생하기 쉽다.
    목록 3에서 이것을 확인할 수 있다. 여기서는 공유 자원을 액세스하는 과정에서
     accessResource 메소드가InterruptedException을 throw로 처리한다.
    결과적으로
     unlock()은 호출되지 않는다.


    목록 3. 잠금 오류의 구조
    private final Lock lock = new ReentrantLock();
    public void lockLeak() {
       lock.lock();
       try {
          // access the shared resource
          accessResource();
          lock.unlock();
       } catch (Exception e) {}
    public void accessResource() throws InterruptedException {...}
    

    잠금이 릴리스되었는지 확인하려면 모든 lock 메소드를 try-finally 블록에 있어야 하는 unlock 메소드와 짝을 맞추어 본다.
    이 과정은 목록 4에 표시되어 있다.



    목록 4. unlock 호출을 언제나 try-finally 블록에 삽입

    private final Lock lock = new ReentrantLock();
    public void lockLeak() {
       lock.lock();
       try {
          // access the shared resource
          accessResource();
       } catch (Exception e) {}
       finally {
          lock.unlock();
       }
    public void accessResource() throws InterruptedException {...}
    


    4. 동기화된 블록의 성능 조정


    동시성 버그 중에는 코드를 손상을 주지는 않지만, 애플리케이션의 성능에 악영향을 줄 수 있는 것들이 있다.
    목록 5에 있는 동기화 블록을 생각해 보자.



    목록 5. 불변 코드가 있는 동기화된 블록

    public class Operator {
       private int generation = 0; //shared variable
       private float totalAmount = 0; //shared variable
       private final Object lock = new Object();
       public void workOn(List<Operand> operands) {
          synchronized (lock) {
             int curGeneration = generation; //requires synch
             float amountForThisWork = 0;
             for (Operand o : operands) {
                o.setGeneration(curGeneration);
                amountForThisWork += o.amount;
             }
             totalAmount += amountForThisWork; //requires synch
             generation++; //requires synch
          }
       }
    }
    

    목록 5에 있는 두 개의 공유 변수에 대한 액세스는 올바르게 동기화되지만, 자세히 살펴보면 동기화된 블록에는 적정 수준보다 더 많은 연산이
    필요하다는 사실을
    알 수 있다. 목록 6에 표시된 바와 같이 관련 행을 다시 정렬하여 이러한 문제를 수정할 수 있다.


    목록 6. 불변 코드가 없는 동기화된 블록
    public void workOn(List<Operand> operands) {
       int curGeneration;
       float amountForThisWork = 0;
       synchronized (lock) {
          int curGeneration = generation++;
       }
       for (Operand o : operands) {
          o.setGeneration(curGeneration);
          amountForThisWork += o.amount;
       }
       synchronized (lock)
          totalAmount += amountForThisWork;
       }
    }
    

    멀티코어 시스템에서는 두 번째 코드가 훨씬 더 잘 수행된다. 목록 5에 있는 동기화된 블록이 병렬 실행을 방해하기 때문이다.
    이 메소드의 연산 시간은 주로 루프에서 소비된다. 목록 6에서는 루프가 동기화된 블록 밖에 있기 때문에 다수의 스레드가 이 루프를 병렬로 실행할 수 있다.
    가능한 동기화된 블록을 간결하게 하는 것이 스레드 안전성에 일반적으로 유익하다.




     그렇다면 다음과 같은 경우는 어떨까?

     

     목록 5와 6에 있는 두 개의 공유 변수에 AtomicInteger와 AtomicFloat를 사용하여 동기화를 완전히 제거하는 것이 더 나을지 모른다고 생각할 수도 있다. 

     이것이 가능할지 여부는 다른 메소드가 이러한 변수로 무엇을 수행하는지 그리고 이러한 메소드 간에 의존성이 있는지에 따라 달라진다.









    5. 다단계 액세스


    두 개의 테이블이 있는 애플리케이션에서 작업 중이라고 가정하자. 

    하나는 직원 이름을 일련 번호에 맵핑하는 테이블이고 다른 하나는 일련 번호를 봉급에 맵핑하는 테이블이다.
    이 데이터는 동시 액세스와 업데이트를 지원해야 한다. 

    목록 7에 표시된 바와 같이 이러한 기능은 스레드로부터 안전한 ConcurrentHashMap을 통해 지원할 수 있다.



    목록 7. 2단계 액세스
    public class Employees {
       private final ConcurrentHashMap<String,Integer> nameToNumber;
       private final ConcurrentHashMap<Integer,Salary> numberToSalary;
       ... various methods for adding, removing, getting, etc...
       public int geBonusFor(String name) {
          Integer serialNum = nameToNumber.get(name);
          Salary salary = numberToSalary.get(serialNum);
          return salary.getBonus();
       }
    }
    

    이 솔루션은 스레드로부터 안전해 보이지만, 사실상 그렇지 않다. getBonusFor 메소드가 스레드로부터 안전하지 않다는데 문제가 있다.
    일련 번호를 가져오는 과정과 일련 번호를 사용하여 봉급을 가져오는 과정 사이에서 또 다른 스레드가 두 테이블에서 직원 데이터를 제거할 수 있다.
    이런 경우에는 두 번째 맵 액세스에서 
    이 리턴되고 예외가 처리된다.


    각 맵을 그 자체로 스레드로부터 안전하게 하는 것만으로는 충분하지 않다. 맵 간에는 의존성이 존재하며 두 맵을 액세스하는 조작 중에는 원자적 액세스가
    필요한 것도 있다. 이러한 경우에는 스레드로부터 안전하지 않은 컨테이너(예: 
    java.util.HashMap)를 사용한 다음, 동기화를 명시적으로 사용하여
    각 액세스를 보호함으로써 스레드 안전성을 달성할 수 있다. 그런 다음에는 필요에 따라 동기화된 블록에서 두 가지 액세스를 모두 처리할 수 있다.






    6. 대칭적 잠금 교착 상태


    스레드로부터 안전한 컨테이너 클래스, 즉 클라이언트에 스레드 안전성을 보장하는 데이터 구조를 생각해 보자.
    (이 컨테이너 클래스는java.util에 있는 대부분의 컨테이너와 달리 그 클라이언트가 해당 컨테이너의 사용법에 따라 동기화되어야 한다.)
    목록 8에서는 수정 가능한 멤버가 데이터를 저장하며 잠금 오브젝트가 이러한 데이터에 대한 모든 액세스를 보호한다.



    목록 8. 스레드로부터 안전한 컨테이너
    public <E> class ConcurrentHeap {
       private E[] elements;
       private final Object lock = new Object(); //protects elements
       public void add (E newElement) {
          synchronized(lock) {
             ... //manipulate elements
          }
       }
       public E removeTop() {
          synchronized(lock) {
             E top = elements[0];
             ... //manipulate elements
             return top;
          }
       }
    }
    

    이제 또 다른 인스턴스를 취하는 메소드를 추가하고 이 메소드의 모든 요소를 현재의 인스턴스에 추가하도록 하자.
    이 메소드는 두 가지 인스턴스에 있는 
    elements 멤버에 액세스해야 하므로 목록 9에 표시된 바와 같이 이 메소드는 두 가지 잠금을 취한다.



    목록 9. 이렇게 하면 교착 상태에 빠짐
    public void addAll(ConcurrentHeap other) {
       synchronized(other.lock) {
          synchronized(this.lock) {
             ... //manipulate other.elements and this.elements
          }
       }
    }
    



     단지 컨테이너만 해당되는 것은 아니다.


     대칭적 잠금 교착 상태 시나리오는 Java 1.4가 릴리스 되었을 때 발생했기 때문에 다소 잘 알려졌으며, 여기에서는Collections.synchronized 메소드가
     리턴한 동기화된 컨테이너의 일부가 교착 상태에 빠졌다. 그러나 컨테이너만 대칭적 잠금 교착 상태에 취약한 것은 아니다.

     필요한 것은 같은 클래스의 또 다른 인스턴스를 그 인수로 취하는 메소드가 있는 클래스가 전부이며 이 클래스는 두 인스턴스의 멤버에 대한 조작을
     자동으로 수행하는 데도 필요하다. 
    compareTo와 equals 메소드는 모두 좋은 예가 된다.

     교착 상태가 발생할 가능성이 있는가? 프로그램에 두 개의 인스턴스, heap1과 heap2가 있다고 하자.
     스레드는 하나는 
    heap1.addAll(heap2)를 호출하고 동시에 다른 하나는 heap2.addAll(heap1)을 호출하는 경우, 이러한 스레드는 결국 교착 상태에 빠진다.
     다시 말해서 첫 번째 스레드는 
    heap2를 잠그지만, 첫 번째 스레드가 이렇게 하기 전에 두 번째 스레드가 이 메소드를 실행하기 시작하여 heap1을 잠근다.
     결과적으로 각 스레드는 결국 다른 스레드가 시행한 잠금을 대기하게 된다.


     인스턴스 간의 순서를 결정함으로써 대칭적 잠금 교착 상태를 방지할 수 있으므로 두 개의 인스턴스가 서로 잠금을 취해야 할 경우에는 그 순서를 동적으로
     계산하여 어느 잠금을 먼저 취할 것인지 결정해야 한다. Brian Goetz는 자신이 저술한 
    Java Concurrency in Practice에서 이러한 해결책을 자세하게
     설명하고 있다. (
    참고자료 참조).










    결론


    대부분의 Java 개발자가 멀티코어 환경에 적합한 동시 프로그램을 작성하는 방법을 배우는 초기 단계에 있다.
    이 과정에서 대부분의 개발자는 본래 더욱 복잡한 멀티스레드 프로그래밍을 하기 위해 익힌 싱글스레드 프로그래밍 이디엄을 포기한다.
    동시성 버그 패턴을 연구하는 것은 멀티스레드 프로그래밍의 함정을 발견할 수 있는 좋은 방법이며 미묘한 멀티스레드 이디엄에 익숙해지는 데 도움이 된다.

    코드를 작성하거나 코드를 검토하는 과정에서 특정 신호가 위험 신호로 역할을 하게 되도록 버그 패턴을 여러 개의 버그가 결합된 것으로 인식하는 것을
    배울 수 있다.
    또한, 이러한 목적으로 정적 분석 도구를 사용할 수 있다. FindBugs는 코드에 있을 가능성이 있는 버그 패턴을 찾는 오픈 소스 정적 분석 도구이다. 사실상, FindBugs는 이 기사에서 살펴본 두 번째와 세 번째 버그 패턴을 찾는데 사용될 수 있다.


    정적 분석 도구는 잘못된 알람을 생성한다는 한 가지 단점이 있는 것으로 알려져 있다.
    따라서 버그가 별로 없는 코드 패턴을 확인할 때보다는 더 많은 시간이 소요될 수 있다.
    새롭게 부각되고 있는 동적 분석 도구는 동시성 프로그래밍을 테스트하는데 특히 적합하다.
    IBM® Multicore Software Development Kit(MSDK) 및 ConcurrentTesting(ConTest)과 같은 도구를 alphaWorks에서 무료로 사용할 수 있다.





    참고자료


    교육

    제품 및 기술 얻기

    • IBM Multicore SDK
      : 이 툴킷은 Java 멀티스레드 프로그램에서 데이터 경쟁, 교착 상태 및 잠금 경합을 찾는 데 사용될 수 있다. 

    • IBM ConcurrentTesting 도구
      : 멀티스레드 애플리케이션의 유닛 테스트에 사용되는 ConcurrentTesting은 병렬 및 분산 Java 프로그램에서 동시성 관련 버그를 제거하는 데
       도움이 된다. 



    토론

    • My developerWorks 커뮤니티에 참여하자.
      개발자가 이끌고 있는 블로그, 포럼, 그룹 및 Wiki를 살펴보면서 다른 developerWorks 사용자와 의견을 나눌 수 있다.