7 min read

자바 병렬 프로그래밍 - 2장 스레드 안전성

동기화 프로그램 작성 시 아래의 3가지 사항을 고려한다.

  1. 해당 상태 변수를 스레드 간에 공유하지 않거나
  2. 해당 상태 변수를 변경할 수 없도록 만들거나
  3. 해당 상태 변수에 접근할 땐 언제나 동기화를 사용한다.

프로그램 상태를 잘 캡슐화할 수록 스레드 안전해진다.

2.1 스레드 안전성이란?

특정 객체가 스레드에 안전하려면 해당 객체에 여러 스레드가 접근하는 여부에 따라 나뉘게 된다.

2.1.1 상태 없는 서블릿

스레드 안전하다 → 정확성을 만족한다 → 해당 클래스의 명세에 클래스가 부합한다.

public class StatelessFactorizer implements Servlet {
	public void service(ServletRequset req, ServletResponse resp){
		BigInteger i = extractFromReq(req);
		BigInteger[] factors = factor(i);
		encodeIntToResp(factors);
	}
}

StatelessFactorizer 는 stateless하다. 클래스 내의 인스턴스 변수도 없고 다른 클래스의 변수를 참조하지도 않는다. 계산을 위한 변수는 스레드의 스택에 저장되는 지역변수만 저장하고 해당 스레드만이 자신의 지역 변수에 접근할 수 있다. → 상태가 없는 객체는 항상 스레드에 안전하다.

2.2 단일 연산

public class UnsafeCountingFactorizer implements Servlet {
	private long count = 0;
	public long getCount() {return count;}
	public void service(ServletRequset req, ServletResponse resp){
		BigInteger i = extractFromReq(req);
		BigInteger[] factors = factor(i);
		++count;
		encodeIntToResp(factors);
	}
}

++count 는 원자적으로 작동하는 단일 작업이 아니다. 나눌 수 없는 최소 단위의 작업처럼 보이지만 실제로는 아니다. count 값이 사용자가 호출하는 값일 경우 무결성 문제의 원인이 된다.

2.2.1 경쟁 조건

경쟁 조건은 상대적인 시점이나 또는 JVM이 여러 스레드를 교차해서 실행하는 상황에 따라 계산의 정확성이 달라질 때 나타난다. 즉, 타이밍이 맞게 계산될 때만 정답을 얻을 수 있다.

경쟁 조건 중 점검 후 행동은 어떤 사실을 확인하고(파일 X가 없음) 그 관찰에 기반해 행동(X를 생성)하는 것이다.하지만 해당 관찰은 관찰한 시각과 행동을 시작하는 시각 사이에서 유효하지 않을 수 있다.(그 동안 파일 X가 생성)

2.2.2 늦은 초기화 시 경쟁 조건

점검 후 행동하는 흔한 프로그래밍의 패턴으로 늦은 초기화가 있다.

public class LazyInitRace {
	private ExpensiveObject instance = null;
	public ExpensiveObject getInstance() {
		if (instance == null) {
			instance = new ExpensiveObject();
		}
		return instance;
	}
}

스레드 A,B가 동시에 if (instance == null) 를 수행할 경우 서로 다른 instance를 가져갈 수도 있다.

대부분 병렬 처리 오류는 항상 발생하지는 않지만 무결성을 보장하지 않아 심각한 문제를 일으킬 수 있다.

2.2.3 복합 동작

public class UnsafeCountingFactorizer implements Servlet {
	private final AtomicLong count = new AtomicLong(0);
	public long getCount() {return count.get();}
	public void service(ServletRequset req, ServletResponse resp){
		BigInteger i = extractFromReq(req);
		BigInteger[] factors = factor(i);
		count.incrementAndGet();
		encodeIntToResp(factors);
	}
}

위와 같이 단일 연산 변수를 통해 처리할 수 있다. 하지만 상태가 하나가 아닌 둘 이상이 될 때는 상태가 없다가 하나만 추가되지 않기에 다른 방식이 필요하다.

2.3 락

@NotThreadSafe
public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
    private final AtomicReference<BigInteger> lastNumber
            = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors
            = new AtomicReference<BigInteger[]>();

    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);
        }
    }

    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};
    }
}

위 코드는 인수분해 결과 값이 lastNumber에 캐시된 값과 동일해야한다. 하지만 lastNumber에 새로운 값을 갱신하였고 다른 스레드가 해당 값을 요청하였을 때, lastFactors가 적절히 갱신되지 않았다면 무결성이 깨지게 된다. 상태를 일관성 있게 유지하려면 연산과 관련된 변수들이 원자적으로 동작하도록 구성해야한다.

자바는 Lock이라는 인터페이스를 제공한다. 락을 통해 synchronized 블럭 내에 진입할 수 있고 해당 블럭을 빠져나오면 Lock이 해제된다. 이러한 락은 뮤텍스(상호 배제)로 동작한다. 뮤텍스로 동작한다는 것은 한번에 한 스레드만이 해당 락을 점유할 수 있다는 의미이다.

이러한 특성은 만약 하나의 스레드가 Lock을 점유하고 synchronized 내의 연산을 수행하면 다른 스레드 입장에서는 해당 연산이 원자적으로 처리되는 것처럼 보이게 된다. 위의 예제 또한 synchronized 블럭을 통해 아주 간단히 동시성 문제를 해결할 수 있다. 다만 서블릿의 중요한 특성인 응답성을 감소시킨다. 하나의 스레드가 누군가가 점유 중이 락을 요청하면 해당 스레드는 대기 상태로 변경되기 때문이다.

자바의 synchronized의 경우 재진입성을 지원한다. 예를 들어 synchronized 메소드 A에서 또 다른 synchronized 메서드 B를 호출 한다고 가정해보자. 스레드 A는 메서드 A에 진입하여 Lock을 획득하였고 JVM은 확보 횟수를 1로 기록한다. 이후 메서드 B에 진입하려 할 때 이미 스레드 A는 확보 횟수가 1이기에 해당 메서드에 진입할 수 있다. 이러한 특성을 재진입성이라고 한다.

2.4 락으로 상태 보호하기

여러 스레드에서 접근 가능하고 변경 가능한 변수들을 대상으로 해당 변수에 대한 연산을 수행 할 때는 항상 변수들에 대해 동일한 Lock을 확보해야한다.

2.5 활동성과 성능

앞선 UnsafeCachingFactorizer의 service 메서드에 synchronized를 추가한 메서드와 아래 메서드 중 어느 메서드가 성능 측면에서 유리할까? 아마 아래의 메서드라고 생각된다.

synchronized의 경우 블럭 내의 연산을 단일 연산처럼 만들어 주지만 그만큼의 성능 저하가 발생한다.

@ThreadSafe
public class CachedFactorizer extends GenericServlet 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);
    }

    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};
    }
}

그래서 synchronized를 사용할 때는 블록의 범위를 줄여 스레드의 안전성을 유지하면서 동시성을 향상 시킬 수 있다. 복잡하고 오래 걸리는 계산작업, I/O 작업 등에는 가능한 락을 잡지 않게 구현하는것이 성능관점에서 좋다.