반응형

제목: 테스트에서 경합상황 재현하기(Reproducing Race Conditions in Tests)저자: Stephen Vance문서유형: 웹사이트(http://www.informit.com) 기사, 2012 5 9

 

경합상황에 대한 이해를 통해 테스트에서 경합상황을 확정적으로(deterministically) 재현하는 방법에 대하여 기술한 자료

 


 

경합상황(race condition)

  • 발견된 버그를 수정하고 수정 후 재발하지 않음을 보장하려면 해당 버그가 반드시 테스트에서 재현되어야 하지만 실험실 환경에서 쉽게 재현이 안 되는 버그들이 존재(경합상황도 여기에 속함)
  • 경합상황은 둘 또는 그 이상의 실행 쓰레드가 동일한 데이터를 동시에 수정 할 때 발생(별도의 코어에서 실제로 동시에 실행되거나 또는 동일 코어에서 시간 분할에 따른 실행). 주로 데이터 보호를 위한 잠금(lock) 메커니즘이 사용되지 않은 영역에서 read write 오퍼레이션 수행과 관련이 있으며, 단일 변수 같은 단순 데이터부터 상호간에 일관성이 요구되는 오브젝트들의 모음 같이 복잡한 데이터도 대상이 될 수 있음
  • 경합상황의 가장 단순한 예로 아래와 같은 보호되지 않은 단일 프로그램 문장을 들 수 있음

i = i + 1

i가 공유 데이터(shared data)이며, 컴파일러가 위 문장을 다음과 같이 해석한다고 가정함

    

쓰레드 Ai 값을 읽어 레지스터에 넣고 증가시킴

    

쓰레드 B도 자신의 레지스터에서 같은 작업을 함

    

쓰레드 A가 레지스터 값을 복사하여 i를 위한 스토리지에 다시 넣음

    

쓰레드 B도 자신의 레지스터 값을 복사하여 같은 작업을 함

 

예를 들어, i의 초기 값이 17이였다면 쓰레드 A B17을 읽어 각자의 레지스터에 넣음. 양쪽 쓰레드 모두 17 18로 증가시키고, 마찬가지로 양쪽 쓰레드 모두 값 18을 복사하여 변수 스토리지에 집어 넣음. , 두 개 쓰레드가 i의 값을 1씩 증가시킨 결과로 2가 증가해야 하지만 실제는 1만 증가됨. 이런 결과는 두 개 쓰레드가 동시에 실행을 시도할 때에만 발생하고 그 확률이 아주 적기는 하지만 이런 종류의 산발적 동작(sporadic behavior)이 경합상황의 특징임

 

  

임계영역(critical section)

  • 데이터가 일관적일 필요가 있는 시간대 또는 코드 영역을 말하며 아래 2가지 이유에서 특별한 관심이 요구되는 곳임

    -
    임계영역이 버그를 포함한다
    .
    -
    임계영역이 버그를 재현할 기회(opportunities)를 포함한다.
  • 위 예에서는 임계영역이 단일 문장 내에 존재: read 오퍼레이션은 표현식 우측에서 변수 i를 사용(use)하고 write 오퍼레이션은 표현식 좌측에 i를 할당(assign). 이렇게 임계영역이 작고 조밀할 때 경합상황을 테스트에서 재현하는 것이 쉽지는 않지만 불가능하지도 않음
  • 위 경합상황은 타겟 아키텍쳐에 따라서 단순히 컴파일러 최적화(compiler optimizations)를 활성화시키거나, 표현식을 i++ 또는 i+=1로 변경하거나, 또는 JavaAtomicInteger 같은 시스템 동시성 프리미티브(primitives)를 사용하여 해결될 수 있음

 

  

수작업 검토를 통해 경합상황 식별

  • 경합상황을 눈으로 찾을 수 있는 가능성 높은 방법은 공유 데이터가 read되고 write되는 장소를 검토하여 동일한 잠금 범위(locking scope)에 포함되는지를 확인하는 것임
  • 이 규칙을 아래 Java 코드(현실에서 사용되는 프로그램인 AuditTrail 클래스에서 경합상황과 관련 없는 부분은 제외하고 관련된 부분만 추출한 것)로 설명하면

    -
    logRecordsread되는 장소: 2가지의 read가 존재함. 하나는 firstElement()에서 exception을 트리거하고, 다른 하나는 isEmpty()에서 Vectorempty인지 체크함

    -
    logRecords가 변경되는 장소: remove() 호출이 logRecords write가 되는 유일한 장소임

    -
    가정되는 임계영역은 while 조건문의 empty 체크로부터 synchronized 블록의 remove() 호출 이후까지임

    -
    아래 코드에서 레코드 제거문 주위로 synchronization 블록(, synchronized 사용을 통해 임계영역 선언)을 정의한 것으로 보아 코드 작성자가 잠재적인 경합상황을 인지한 것으로 보임. 하지만 두 개 read 중 하나가 선언된 임계영역 밖에 위치하고 이것이 경합상황을 야기함

    -
    결과적으로 firstElement() 호출에서 NoSuchElementException이 트리거되는 실패 증상이 나타남

 

private static final Logger = Logger.getLogger(AuditTrail.class);

private final Vector<AuditRecord> logRecords = new Vector<AuditRecord>();

 

public void postFlush(...) {

      ...

      while (!logRecords.isEmpty()) {

            log.debug("Processing log record");

            AuditRecord logRecord;

            synchronized (logRecords) {

                  logRecord = logRecords.firstElement();

                  logRecords.remove(logRecord);

            }

            // Process the record

            ...

      }

}

 

NoSuchElementException이 트리거되었다는 것은 while 루프에 진입은 하였지만(, logRecords가 엘리먼트를 포함하고 있음) 첫 엘리먼트의 read를 시도할 쯤에는 실제 남아있는 엘리먼트가 없다는 것을 의미함. 아래는 어떻게 경합상황이 일어나는지를 설명함

    

쓰레드 AlogRecordsempty가 아님(하나의 레코드가 남아 있음)을 확인하고 루프에 진입

    

쓰레드 B도 마찬가지로 logRecordsempty가 아님을 확인하고 루프에 진입

    

쓰레드 Asynchronized 블록에 진입하고(이는 쓰레드 B가 해당 블록에 진입하는 것을 막음) logRecords의 첫 엘리먼트를 읽고 삭제함

    

쓰레드 Asynchronized 블록을 떠남(이제 쓰레드 B가 이 블록에 진입할 수 있게 됨)

    

쓰레드 Bsynchronized 블록에 진입하여 logRecords의 첫 엘리먼트를 읽으려 시도하지만 empty인 것을 발견하고 NoSuchElementException을 트리거함

 

 

 

경합상황 재현(Reproducing Race Conditions)

  • Michael Feathers가 자신의 저서에서 ‘seam(번역하면 조각을 꿰매 맞춘 솔기’, ‘이음새’, 또는 접합선등을 의미)’의 개념을 소개함. seam은 기존 프로그램을 편집하지 않고도 그 동작을 변경시킬 수 있는 위치를 지칭함
  • 경합상황을 재현하기 위해서는 두 개 쓰레드를 임계영역으로 집어 넣을 방법을 찾아야만 함(양쪽 쓰레드 모두 경합을 시작시키는 read 오퍼레이션을 수행하게 하고, 두 쓰레드가 write 오퍼레이션에 도달하기 전에 멈추게 만듬). , 두 개 쓰레드를 동기화 시킬 수 있는 접합선(seam)을 찾아야 함
  • 이런 동기화 접합선(synchronization seam)은 여러 형태가 될 수 있음. 덮어쓰거나(override) 가로챌 수 있는(intercept) 메쏘드 호출이나 기타 동작이 접합선의 좋은 예임. 또한 이미 동시성(concurrent)을 가지도록 작성된 코드는 기 존재하는 synchronization이 접합선으로 안성 맞춤임
  • 앞의 예에서 기 존재하는 synchronization를 접합선으로 활용하고자 했지만 메쏘드 isEmpty()가 동일 lock 상에서 동기화되고 이는 해당 메쏘드와 synchronized 블록 사이에서 실행을 멈추도록 만드는 것을 어렵게 함. 따라서 디버그 로깅 문장(debug logging statement)seam으로 대신 사용하기로 함
  • 여기서 사용된 log4j 같은 Java의 현대식 로깅 프레임워크는 출력 타겟, 로깅 레벨 등을 변경하기 위해 동적으로 재구성(dynamically reconfigured) 될 수 있음. , 로깅 목적을 위해 재구성을 허용하는 이런 특성이 synchronization 목적을 위해 핸들러를 삽입하는 것 또한 허용함. log4j에서 메시지 핸들러는 appender로 알려짐(이것을 원하는 어떤 범위에든 프로그래밍적으로 추가할 수 있음). 앞의 예를 위하여 seam으로 활용한 appender가 아래와 같음

 

class SynchronizationAppender extends AppenderSkeleton {

    @Override

    protected void append(LoggingEvent loggingEvent) {

        try {

            this.wait();

        } catch (InterruptedException e) {

            return;

        }

    }

    ... // Other required overrides

}

 

 

  • 위와 더불어 쓰레드 상에서 postFlush() 호출을 하기 위한 java.util.concurrent.Callable도 생성함. exception이 발생했는지 판단하기 위해 FutureTask를 가진 Callable을 사용. 아래가 테스트 메쏘드 코드임. 테스트 대상 소프트웨어와 이를 호출할 쓰레드를 셋업 한 후에는 로깅을 재구성한다.

 

@Test

public void testPostFlush_EmptyRace()

        throws InterruptedException, ExecutionException {

    // Set up software under test with one record

    AuditRace sut = new AuditRace();

    sut.addRecord();

    // Set up the thread in which to induce the race

    Callable<Void> raceInducer = new PostFlushCallable(sut);

    FutureTask<Void> raceTask = new FutureTask<Void>(raceInducer);

    Thread inducerThread = new Thread(raceTask);

    // Configure log4j for injection

    SynchronizationAppender lock = new SynchronizationAppender();

    Logger log = Logger.getLogger(AuditRace.class);

    log.addAppender(lock);

    log.setLevel(Level.DEBUG);

 

    inducerThread.start();

    while (Thread.State.WAITING != inducerThread.getState());

    // We don't want this failure to look like the race failure

    try {

        sut.getLogRecords().remove(0);

    } catch (NoSuchElementException nsee) {

        Assert.fail();

    }

    synchronized (lock) {

        lock.notifyAll();

    }

 

    raceTask.get();

}

 

  

  • 이렇게 구조물(fixtures)을 셋업 한 후에 쓰레드를 시작시키고 waiting 상태로 들어가길 기다림(Thread.State.WAITING 또는 Thread.State.BLOCKING의 사용은 synchronize 하기 위해 사용하는 메커니즘에 달려 있음)
  • 여기서 구축한 테스트는 logRecords empty인지 체크한 후에 쓰레드를 기다리게 하고 하나 남아 있는 레코드를 제거한 후에 다시 쓰레드가 실행되도록 함. 만약 경합상황이 존재하면 raceTask.get() 호출이 NoSuchElementException을 원인으로 하는 ExecutionException을 트리거함(, 경합상황의 존재를 증명함). 이 경합을 해결하면 테스트가 통과(pass)하게 된다.

 

   

 

반응형

+ Recent posts