BOOK 5 - 자바 병렬 프로그래밍(2)

스레드 안정성

  • Thread-safe 한 코드를 작성하는 것은 공유되고 변경할 수 있는 상태에 대한 접근을 관리하는 것이다.
    • 객체의 상태는 인스턴스나 static 변수같은 상태 변수에 저장된 객체의 데이터를 의미한다.
  • 객체가 스레드에 안전하게 만드려면 동기화를 통해 변경할 수 있는 상태에 접근하는 과정을 조율해야 한다.
  • 여러 스레드가 변경할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것이고, 언제든 오류가 발생할 수 있다.
    • 이렇게 잘못된 프로그램을 고치는 방법은 다음 세가지가 있다.
      • 해당 상태 변수를 스레드 간에 공유하지 않거나,
      • 해당 상태 변수를 변경할 수 없도록 만들거나,
      • 해당 상태 변수에 접근할 땐 언제나 동기화를 사용한다.
  • 프로그램 규모가 커지면 특정 변수를 여러 스레드에서 접근하는지 파악하기 어려울 수 있다.
    • 객체 지향 프로그래밍 기법에서 사용하는 캡슐화나 데이터 은닉 같은 기법이 Thread-safe한 클래스를 작성하는데 도움이 될 수 있다.
    • 특정 변수에 접근하는 코드가 적을수록 적절히 동기화가 사용됐는지 확인하기 쉽고, 어떤 조건에서 특정 변수에 접근하는지도 판단하기 쉽기 때문이다.
    • 따라서 객체 상태를 잘 캡슐화할수록 프로그램을 스레드에 안전하게 만들기 쉽고 유지보수하기도 쉽다.

스레드 안정성이란?

  • 스레드 안정성에 대한 정의의 핵심은 정확성 개념과 관련이 있다.
    • 정확성이란 클래스가 해당 클래스의 명세에 부합한다는 뜻이다.
    • 즉, 클래스 명세에 정의된 객체 상태를 제약하는 불변조건과 연산 수행 후 효과를 기술하는 postcondition에 제대로 부합하는 것이다.
    • 클래스 명세가 충분히 작성되어 있지 않더라도 “특정 코드가 동작한다”고 확신한다면 정확성과 대략 일치한다.
    • 여러 스레드가 클래스에 접근할 때 계속 정확하게 동작한다면 해당 클래스는 Thread-safe하다.
      • 호출하는 쪽에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전하다고 말한다.
      • 스레드 안전한 클래스는 클라이언트 쪽에서 별도로 동기화할 필요가 없도록 동기화 기능도 캡슐화한다.

예제 : 상태 없는 서블릿

  • 스레드 안정성이 필요한 경우는 직접 스레드를 생성하는 경우보다 서블릿 프레임워크같은 수단을 사용하기 때문인 경우가 많다.
  • 서블릿 기반 인수분해 서비스를 만들고, 스레드 안정성을 유지하면서 기능을 추가하는 예제를 보자.
public class StatelessFactorizer implements Servlet {
  public void service(ServletRequest req, ServletResponse resp) {
    BigInteger i = extractFromRequest(req);
    BigInteger[] factors = factor(i);
    encodeIntoResponse(resp, factors);
  }
}
  • StatelessFactorizer 는 상태가 없다.
    • 즉, 선언한 변수가 없고 다른 클래스의 변수를 참조하지도 않는다.
  • 특정 계산을 위한 일시적인 상태는 스레드의 stack에 저장되는 local 변수에만 저장하고, 이 local 변수는 실행중인 해당 스레드에서만 접근할 수 있다.
    • 따라서 특정 스레드는 같은 StatelessFactorizer 에 접근하는 다른 스레드의 결과에 영향을 줄 수 없다.
    • 두 스레드가 상태를 공유하지 않기 때문이다.
  • 상태 없는 객체는 항상 Thread-safe하다.

단일 연산

  • StatelessFactorizer 에 처리한 요청의 수를 기록하는 ‘접속 카운터’ 를 추가해보자.
public class UnsafeCountingFactorizer implements Servlet {
  private long count = 0;
  
  public long getCount() {
    return count;
  }
  
  public void service(ServletRequest req, ServletResponse resp) {
    BigInteger i = extractFromRequest(req);
    BigInteger[] factors = factor(i);
    ++count;
    encodeIntoResponse(resp, factors);
  }
}
  • UnsafeCountingFactorizer 는 단일 스레드 환경에서는 잘 동작하겠지만 스레드에 안전하지 않다.
  • ++count 는 단일 작업처럼 보이지만, 실제로는 단일 연산이 아니다.
    • 현재 count 값을 가져와서
    • 거기에 1을 더하고
    • 새 값을 count 에 저장하는 별도의 3개의 작업을 순차적으로 실행하는 것이다.
  • 여러 개의 스레드가 카운터를 증가시키려고 할 때 동기화 되어있지 않다면 변경한 값을 잃어버리게 되는 경우가 생길 수 있다.
    • 예를 들어, 두개의 스레드가 모두 9를 읽고, 1을 더한 뒤, 카운터에 10을 기록할수도 있다.
    • 즉, 증가된 값 1이 없어졌고 접속 카운터는 영영 1만큼 틀리게 된다.
  • 병렬 프로그램 입장에서 타이밍이 안 좋을 때 결과가 잘못될 가능성은 중요한 개념이기 때문에 race condition이라는 별도 용어로 정의한다.

경쟁 조건 (race condition)

  • 경쟁 조건은 상대적인 시점이나 JVM이 여러 스레드를 교차해서 실행하는 상황에 따라 계산의 정확성이 달라질 때 나타난다.
  • 가장 일반적인 경쟁 조건 형태는 잠재적으로 유효하지 않은 값을 참조해서 다음에 뭘 할지를 결정하는 check-then-act 형태의 구문이다.
    • 원하는 결과를 얻을 수 있을지의 여부는 여러가지 연산의 상대적인 시점에 따라 달라진다.
    • 잠재적으로 유효하지 않은 관찰 결과로 결정을 내리거나 계산을 하는 것을 check-then-act 라고 한다.
      • 즉, 어떤 사실을 확인하고, 그 관찰 결과에 기반해 행동을 한다.
      • 그러나 해당 관찰은 관찰한 시각과 행동한 시각 사이에 더 이상 유효하지 않게 됐을 수도 있다.
      • 이런 경우에 데이터 무결성에 문제가 발생한다.
  • UnsafeCountingFactorizer 의 카운터를 증가시키는 작업과 같은 read-modify-write 동작은 이전 상태를 기준으로 객체의 상태를 변경한다.
    • 따라서 특정 스레드가 카운터를 갱신하는 동안 다른 스레드에서 그 값을 변경하거나 사용하지 않도록 해야 한다.

예제 : 늦은 초기화 시 경쟁 조건

  • check-then-act 의 대표적인 패턴으로 lazy initialization이 있다.
public class LazyInitRace {
  private ExpensiveObject instance = null;
  
  public ExpensiveObject getInstance() {
    if(instance == null) {
      instance = new ExpensiveObject();
    }
    return instance;
  }
}
  • 스레드 A와 스레드 B가 동시에 getInstance 를 호출한다고 해보자.
    • 스레드 B가 instance 변수를 확인할 때 instancenull 인지 여부는 스케줄이 어떻게 변경될지 또는 스레드 A가 ExpensiveObject 를 생성하고 instance 변수에 저장하기까지 얼마나 걸리는지 등의 예측하기 어려운 타이밍에 따라 달라진다.
  • 원래 getInstance 는 항상 같은 인스턴스를 리턴하도록 설계되어있지만, 타이밍에 따라 getInstance 를 호출한 두 스레드가 각각 서로 다른 인스턴스를 가져가게 될 수도 있다.

경쟁 조건으로 인해서 프로그램에 오류가 항상 발생하지는 않으며, 운 나쁘게 타이밍이 꼬일 때만 문제가 발생한다.

그러나 경쟁 조건은 그 자체로 심각한 문제를 일으킬 수 있기 때문에 주의해야 한다.

복합 동작

  • 위에서 살펴본 LazyInitRaceUnsafeCountingFactorizer 가 처리하는 일련의 작업은 외부 스레드에서 봤을 때 더이상 나눠질 수 없는 단일 연산이어야 했다.
    • 즉, 경쟁 조건을 피하려면 변수가 수정되는 동안 다른 스레드가 해당 변수를 사용하지 못하도록 막을 방법이 있어야 한다.
    • 작업 A를 실행 중인 스레드 관점에서 다른 스레드가 작업 B를 실행할 때 작업 B가 모두 수행됐거나 또는 전혀 수행되지 않은 두가지 상태로만 파악된다면 작업 B는 단일 연산이다.
  • 스레드 안정성을 보장하기 위해 check-then-actread-modify-write 같은 작업은 항상 단일 연산이어야 한다.
  • 다음 코드는 UnsafeCountingFactorizer 를 Thread-safe한 기존 클래스를 이용해 다른 방법으로 고쳐본 예제이다.
public class CountingFactorizer implements Servlet {
  private final AtomicLong count = new AtomicLong(0);
  
  public long getCount() {
    return count.get();
  }
  
  public void service(ServletRequest req, ServletResponse resp) {
    BigInteger i = extractFromRequest(req);
    BigInteger[] factors = factor(i);
    count.incrementAndGet();
    encodeIntoResponse(resp, factors);
  }
}
  • java.util.concurrent.atomic 패키지에 숫자나 객체 참조값에 대해 상태를 단일 연산으로 변경할 수 있는 atomic variable 클래스가 존재한다.
  • 카운터 변수를 AtomicLong 으로 변경하여 카운터에 접근하는 모든 동작이 단일 연산으로 처리되도록 했다.
  • 상태 없는 클래스에 상태 요소를 하나 추가할 때 Thread-safe한 객체 하나로 모든 상태를 관리한다면 해당 클래스는 Thread-safe하다.

  • 둘 이상의 상태를 추가할 때에도 그저 Thread-safe한 상태 변수를 추가하기만 하면 충분할까?
  • 위 예제에서 서로 다른 클라이언트가 연이어 같은 숫자를 인수분해하길 원하는 경우를 생각해보자.
    • 가장 최근 계산 결과를 캐시에 보관해 성능을 향상시키려고 한다면 서블릿은 두가지 정보를 기억해야 한다.
      • 가장 마지막으로 인수분해하기 위해 입력된 숫자와
      • 그 입력 값을 인수분해한 결과 값이다.
  • 앞에서 카운터 상태를 AtomicLong 변수에 보관해서 스레드 안정성을 확보한 것처럼 마지막 입력 값과 결과 값을 관리하기 위해 AtomicReference 클래스를 사용할 수 있지 않을까?
public class UnsafeCachingFactorizer implements Servlet {
  private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
  private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
  
  public void service(ServletRequest req, ServletResponse resp) {
    BigInteger i = extractFromRequest(req);
    if(i.equals(lastNumber.get())) {
      encodeIntoResponse(resp, lastFactors.get());
    } else {
      BigInteger[] factors = factor(i);
      lastNumber.set(i);
      lastFactors.set(factors);
      encodeIntoResponse(resp, factors);
    }
  }
}
  • 위 예제는 제대로 동작하지 않는다.
  • 단일 연산 참조 변수 각각은 스레드에 안전하지만 UnsafeCachingFactorizer 자체는 틀린 결과를 낼 수 있는 경쟁 조건을 가지고 있다.
  • 여러개의 변수가 하나의 불변 조건을 구성하고 있다면, 이 변수들은 서로 독립적이지 않다.
    • 즉, 한 변수의 값이 다른 변수에 들어갈 수 있는 값을 제어할 수 있다.
    • UnsafeCachingFactorizerlastFactors 에 속한 인수분해 결과값들을 곱한 값이 lastNumber 에 캐시된 값과 일치해야 한다는 불변 조건을 가지고 있다.
    • 따라서 타이밍이 좋지 않다면 UnsafeCachingFactorizer 의 불변조건이 깨질 수 있다.
      • 하나는 수정됐고 다른 하나는 수정되지 않은 그 시점에 취약점이 존재하는 것이다.
  • 상태를 일관성 있게 유지하기 위해서는 관련 있는 변수들을 하나의 단일 연산으로 갱신해야 한다.

암묵적인 락

  • 자바에서는 단일 연산 특성을 보장하기 위해 synchronized 라는 구문으로 사용할 수 있는 lock을 제공한다.
synchronized (lock) {
  // lock으로 보호된 공유 상태에 접근하거나 값을 수정한다.
}
  • synchronized 구문은 lock으로 사용될 객체의 참조 값과 lock으로 보호하려는 코드 블록으로 구성된다.
  • 메소드 선언 부분에 synchronized 키워드를 지정하면 해당 메소드가 포함된 클래스의 instance를 lock으로 사용한다.
    • static 으로 선언된 메소드는 해당 Class 객체를 lock으로 사용한다.
  • 모든 자바 객체는 lock으로 사용할 수 있고, 이렇게 자바에 내장된 락을 암묵적인 락(intrinsic lock) 또는 모니터 락(monitor lock) 이라고 한다.
    • 락으로 보호된 synchronized 블록이나 메소드에 들어가야만 암묵적인 락을 확보할 수 있다.
  • 자바에서 암묵적인 락은 mutex로 동작한다.
    • 즉, 한 번에 한 스레드만 특정 락을 소유할 수 있고, 다른 스레드는 락을 놓을 때까지 기다려야 한다.
  • 특정 락으로 보호된 코드 블록은 한번에 한 스레드만 실행할 수 있기 때문에 같은 락으로 보호되는 서로 다른 synchronized 블록 역시 서로 단일 연산으로 실행된다.
    • 따라서 한 스레드가 synchronized 블록을 실행 중이라면, 같은 락으로 보호되는 synchronized 블록에 다른 스레드가 들어와 있을 수 없다.
  • 동기화 수단을 쓰면 간단하게 인수분해 서블릿을 Thread-safe 하게 고칠 수 있다.
public class SynchronizedFactorizer implements Servlet {
  @GuardedBy("this") private BigInteger lastNumber;
  @GuardedBy("this") private BigInteger[] lastFactors;
  
  public synchronized void service(ServletRequest req, ServletResponse resp) {
    BigInteger i = extractFromRequest(req);
    if(i.equals(lastNumber.get())) {
      encodeIntoResponse(resp, lastFactors.get());
    } else {
      BigInteger[] factors = factor(i);
      lastNumber.set(i);
      lastFactors.set(factors);
      encodeIntoResponse(resp, factors);
    }
  }
}
  • 하지만 이 방법은 너무 극단적이라 인수분해 서블릿을 여러 클라이언트가 동시에 사용할 수 없어서 응답성이 많이 떨어질 수 있다.

재진입성

  • 암묵적인 락은 재진입이 가능하기 때문에 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있다.
  • 재진입성을 구현하기 위해 각 락마다 확보 횟수와 확보한 스레드를 연결시켜 둔다.
    • 확보 횟수가 0이면 lock은 해제된 상태이다.
    • 스레드가 해제된 락을 확보하면 JVM이 락에 대한 소유 스레드를 기록하고, 확보 횟수를 1로 카운트한다.
    • 같은 스레드가 락을 다시 얻으면 확보 횟수를 증가시키고, 해당 스레드가 synchronized 블록을 벗어나면 확보 횟수를 감소시킨다.
    • 이런식으로 확보 횟수가 다시 0이 되면 해당 락은 해제된다.
  • 재진입성 때문에 락의 동작을 쉽게 캡슐화할 수 있다.
  • 재진입이 가능하지 않다면 아래와 같이 하위 클래스에서 메소드를 재정의하고 상위 클래스의 메소드를 호출하는 코드 같은 경우도 데드락에 빠지게 된다.
public class Widget {
  public synchronized void doSomething() {
    ...
  }
}

public class LoggingWidget extends Widget {
  @Override
  public synchronized void doSomething() {
    System.out.println(toString() + ": calling doSomething");
    super.doSomething();
  }
}
  • WidgetLoggingWidgetdoSomething() 메소드는 둘 다 synchronized 로 선언되어있기 때문에 각각 실행 전에 락을 얻으려고 시도할 것이다.
    • 암묵적인 락이 재진입 가능하지 않았다면, LoggingWidget.doSomething() 에서 이미 락을 확보했기 때문에 super.doSomething() 호출에서 락을 얻을 수 없게 되고, 결과적으로 확보할 수 없는 락을 기다리면서 영원히 대기하고 있었을 것이다.
    • 재진입성은 이런 경우 deadlock에 빠지지 않도록 해준다.

락으로 상태 보호하기

  • 경쟁 조건을 피하려면 접속 카운터를 증가시키거나(read-modify-write) 늦게 초기화하는(check-then-act) 경우 하나의 공유된 상태에 대한 복합 동작을 단일 연산으로 만들어야 한다.
  • 변수에 대한 접근을 조율하기 위해 락을 사용할때는 해당 변수에 접근하는 모든 곳에서 반드시 같은 락을 사용해야 한다.
  • 락의 일반적인 사용 예는 모든 변경 가능한 변수를 객체 안에 캡슐화하고, 해당 객체의 암묵적인 락을 사용해 캡슐화한 변수에 접근하는 모든 코드 경로를 동기화함으로써 내부 변수를 보호하는 방법이다.
    • 이 때 객체의 상태를 나타내는 모든 변수는 객체의 암묵적인 락으로 보호된다.
  • 클래스에 여러 변수에 대한 불변조건이 있으면 해당 변수들은 모두 같은 락으로 보호해야 한다.
    • 이렇게 하면 관련된 모든 변수를 하나의 단일 연산 작업 내에서 접근하거나 갱신할 수 있다.
    • SynchronizedFactorizer 가 이 규칙을 따른 예다.
      • 캐시된 입력 값과 결과 값 모두 서블릿 객체의 암묵적인 락으로 보호된다.
  • 무차별적으로 synchronized 를 적용하면 동기화가 너무 과도하거나 부족할 수 있다.
    • 모든 메소드를 동기화하면 활동성이나 성능에 문제가 생길수도 있다.

활동성과 성능

  • 위 예제에서 SynchronizedFactorizer 는 각 상태변수를 서블릿 객체의 암묵적인 락으로 보호하기 위해 service() 메소드 전체를 동기화했다.
    • synchronized 키워드를 지정했기 때문에 service() 메소드는 한 번에 한 스레드만 실행할 수 있다.
    • 이는 동시에 여러 요청을 처리할 수 있게 설계된 servlet framework의 의도와 배치된다.
    • 여러 개의 요청이 동시에 들어와도 요청들은 큐에 쌓여 순차적으로 하나씩 처리되게 된다.
  • synchronized 블록의 범위를 줄이면 스레드 안정성을 유지하면서 동시성을 향상시킬 수 있다.
  • 아래 코드는 메소드 전체를 동기화 하는 대신에 두 개의 짧은 코드 블록을 synchronized 키워드로 보호한 예제이다.
public class CachedFactorizer implements Servlet {
  @GuardedBy("this") private BigInteger lastNumber;
  @GuardedBy("this") private BigInteger[] lastFactors;
  @GuardedBy("this") private long hits;
  @GuardedBy("this") private long cacheHits;
  
  public synchronized long getHits() {
    return hits;
  }
  
  public synchronized double getCacheHitRatio() {
    return (double) cacheHits / (double) hits;
  }
  
  public void service(ServletRequest req, ServletResponse resp) {
    BigInteger i = extractFromRequest(req);
    BigInteger[] factors = null;
    
    synchronized (this) {
      ++hits;
      if(i.equals(lastNumber)) {
        ++cacheHits;
        factors = lastFactors.clone();
      }
    }
    
    if(factors == null) {
      factors = factor(i);
      synchronized (this) {
        lastNumber = i;
        lastFactors = factors.clone();
      }
    }
    
    encodeIntoResponse(resp, factors);
  } 
}
  • 접속 카운터 hits 와 캐시가 사용된 횟수를 세는 cacheHits 변수도 추가했다.
    • 두 카운터 역시 변경할 수 있는 공유 상태이기 때문에 접근할 때는 항상 동기화 구문을 사용해야 한다.
  • 캐시된 결과를 가지고 있는지 확인하는(check-then-act) 부분과 캐시된 입력값과 결과값을 새로운 값으로 변경하는 부분을 각각 synchronized 블록으로 감싸 보호했다.
  • synchronized 블록 밖에 있는 코드는 다른 스레드와 공유되지 않는 local variable만 사용하기 때문에 동기화가 필요없다.
  • lock을 얻고 해제하는 작업만으로도 어느정도의 부하가 생기기 때문에 synchronized 블록을 너무 잘게 쪼개는 것도 바람직하지 않다.
    • 위 코드에서는 상태 변수에 접근할 때와 복합 동작을 수행하는 동안 lock을 얻지만, 오래 걸릴 가능성이 있는 인수분해 작업을 시작하기 전에 lock을 해제함으로써 병렬 처리 능력에 심각한 영향을 주지 않으면서 스레드 안정성을 유지한다.
  • 계산량이 많은 작업을 하거나 잠재적으로 대기 상태에 들어갈 수 있는 작업을 하느라 락을 오래 잡고 있으면 활동성이나 성능 문제를 야기할 수 있다.
    • 복잡한 계산 작업, network 작업, 사용자 I/O 작업과 같이 오래걸릴 수 있는 작업을 하는 부분에서는 가능한 한 lock을 잡지 않도록 한다.