Java Concurrency in Practice, 스레드 안전성

스레드 안전성

공유가 됐다는 것은 여러 스레드가 특정 변수에 접근할 수 있다는 뜻이다. 변경할 수 있다(mutable)는 것은 해당 변수 값이 변경될 수 있다는 뜻이다. 스레드 안전성이 코드를 보호하는 것처럼 보일 수 있지만, 실제로는 데이터에 제어 없이 동시 접근하는 것을 막으려는 의미이다.

자바에서 동기화 수단은 synchronized 키워드로서 배타적인 락을 통해 보호 기능을 제공한다. 하지만 volatile 변수, 명시적 락, 단일 연산 변수 atomic variable을 사용하는 경우에도 동기화라는 용어를 사용한다.

프로그램을 작성할 때는 공유된 상태에 대한 접근을 동기화해야 한다는 원칙에 특별한 경우의 예외가 있다고 생각하고 싶겠지만, 그런 유혹은 버려야한다.

  • 꼭 필요한 동기화 구문이 빠진 프로그램도 테스트를 통과하고 몇 년 동안 잘 동작하는 등 얼핏 제대로 동작하는 것 처럼 보일 수도 있다. 하지만 잘못됐다는 사실에는 변함이 없고 언제든 오동작할 수 있다.
만약 여러 스레드가 변경할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것이다. 이렇게 잘못된 프로그램을 고치는 데는 세 가지 방법이 있다.
1. 해당 상태 변수를 스레드 간에 공유하지 않는다.
2. 해당 상태 변수를 변경할 수 없도록 만든다.
3. 해당 상태 변수에 접근할 땐 언제나 동기화를 사용한다.
  • 클래스를 설계하면서 애당초 동시 접근을 염두에 두지 않았다면, 뒤늦게 위 세가지 방법 중 일부를 적용하고자 할 때 설계를 상당히 많이 고쳐야 할 가능성이 높다.
  • 스레드 안전성 확보를 위해 나중에 클래스를 고치는 것보다는 애당초 스레드에 안전하게 설계하는 편이 훨씬 쉽다.
  • 캡슐화데이터 은닉이 도움이 될 수도 있다.
스레드에 아전한 클래스를 설계할 땐, 바람직한 객체 지향 기법이 왕도다. 캡슐화와 불변 객체를 잘 활용하고, 불변 족ㄴ을 명확하게 기술해야 한다.

스레드 안전한 클래스스레드 안전한 프로그램이란 용어를 구분 없이 사용했다. 그럼 스레드 안전한 프로그램은 스레드 안전한 클래스로만 구성된 프로그램일까? 꼭 그런것은 아니다.

2.1 스레드 안전성이란?

  • 스레드에 대한 납득할 만한 정의의 핵심은 모두 정확성(correctness) 개념과 관계가 있다.
    정확성일ㄴ 클래스가 해당 클래스의 명세에 부합한다는 뜻이다.
여러 스레드가 클래스에 접근할 때, 실행 환경이 해당 스레드들의 실행을 어떻게 스케줄하든 어디에 끼워넣든, 호출하는 쪼에서 추가적인 동기화나 다른 조율 없이도 정혹하게 동작하면 해당 클래스는 스레드 안전하다고 말한다.
  • 애당초 단일 스레드 환경에서도 제대로 동작하지 않으면 스레드 안전할 수 없다.
스레드 안전한 클래스는 클라이언트 쪽에서 동기화할 필요가 없도록 동기화 기능도 캡슐화한다.
상태 없는 (stateless) 객체는 항상 스레드 안전하다.

2.2 단일 연산

@AntiPattern
@NotThreadSafe
public class UnsafeCountingFactorizer extends GenericServlet 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);
    }

    void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[] { i };
    }
}
  • 동기회 구문 없이 요청 횟수를 카운트 하는 서블릿, 안티 패턴!

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

@NotThreadSafe
public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null)
            resource = new Resource(); // unsafe publication
        return resource;
    }

    static class Resource {
    }
}
  • 스레드 A와 B가 동시에 getInstance를 수행한다고 할때, A와 B는 서로 다른 Instance를 가져갈 수도 있다. (의도와는 다른 동작)
@ThreadSafe
public class CountingFactorizer extends GenericServlet 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);
    }

    void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {}
    BigInteger extractFromRequest(ServletRequest req) {return null; }
    BigInteger[] factor(BigInteger i) { return null; }
}
  • AtomicLong으로 바뀐 부분에서 카운터에 접근하는 모든 동작이 단일 연산으로 처리된다. 서블릿의 상태가 카운터의 상태이고 카운터가 스레드에 안전하기 때문에 서블릿도 스레드에 안전하다.
가능하면 클래스 상태를 관리하기 위해 AtomicLong처럼 스레드에 안전하게 이미 만들어져 있는 객체를 사용하는 편이 좋다. 스레드 안전하지 않은 상태 벼수를 선언해두고 사용하는 것보다 이미 스레드 안전하게 만들어진 크래스가 가질 수 있는 가능한 상태의 변화를 파악하는 편이 훨씬 쉽고, 스레드 안전성을 더 쉽게 유지하고 검증할 수 있다.

2.3.1 암묵적인 락

synchronized (lock) { // Lock으로 보호된 공유 상태에 접근하거나 해당 상태를 수정한다. }

이와같이 자바에 내장된 락을 암묵적인 락 intrinsic lock 혹은 모니터 락 monitor lock이라고 한다. 락은 스레드가 synchronized 블록에 들어가기전에 자동으로 확보됨.

@ThreadSafe
public class SynchronizedFactorizer extends GenericServlet 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))
            encodeIntoResponse(resp, lastFactors);
        else {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[] { i };
    }
}
  • 동기화 수단을 쓰면 아주 쉽게 인수분해 서블릿을 스레드 안전하게 고칠 수 있다. 이제 SynchronizedFactorizer는 스레드에 안전하다. 하지만 이 방법은 너무 극단적이라 인수분해 서블릿을 여러 클라이언트가 동시에 사용할 수 없고, 이 때문에 응답성이 엄창나게 떨어질 수 있다.
종종 단순성과 성능이 서로 상충할 때가 있다. 동기화 정책을 구현할 때는 성능을 위해 조급하게 단순성(잠재적으로 안전성을 훼손하면서)을 희생하고픈 유혹을 버려야 한다.
복잡하고 오래 걸리는 계산 작업, 네트웍 작업, 사용자 입출력 작업과 같이 빨리 끝나지 않을 수 있는 작업을 하는 부분에서는 가능한 락을 잡지 말아라.

출처 :브라이언 괴츠, 더그 리, 팀 피얼스, 조셉 보우비어, 데이빗 홈즈, 조슈아 블로쉬 공저, 『 멀티코어를 100% 활용하는 자바 병렬 프로그래밍』, 에이콘(2008.7.30), 2장 인용.