개요
이 포스팅의 내용은 <<자바 병렬 프로그래밍>> 의 2장 스레드 안정성을 요약한 내용다.
스레드 안정성
스레드에 안전한 코드를 작성하는 것은, 근본적으로는 상태에 대한 접근을 관리하는 것이다. 객체의 상태는 인스턴스나 static 변수에 저장된 객체의 데이터이다. 각 데이터는 공유되거나 변경될 수 있다. 다음 용어는 이렇게 정의하자
- 공유되었다 : 여러 스레드가 객체의 특정 변수에 접근할 수 있다.
- 변경할 수 있다 : 해당 변수 값이 변경될 수 있다.
만약 객체에 여러 스레드가 접근한다면, 객체는 스레드에 안전해야 한다. 객체를 스레드에 안전하게 만드려면 동기화를 통해 변경된 상태에 접근하는 과정을 조율해야 한다. 즉, 스레드가 하나 이상의 상태변수에 접근하고 그 중 하나라도 변수에 값을 쓰면, 해당 변수에 접근할 때 모든 스레드 동기화를 통해 조율해야 한다.
자바에서는 다음과 같은 수단을 제공한다.
synchronized, volatile 변수, 명시적 락, atomic variable
만약에 여러 스레드가 변경할 수 있는 하나의 상태 변수에 적절한 동기화 없이 접근하면, 그 프로그램은 잘못된 것이다. 이렇게 잘못된 프로그램을 고치는 데는 세 가지 방법이 있다.
- 해당 상태 변수를 스레드 간에 공유하지 않거나
- 해당 상태 변수를 변경할 수 없도록 만들거나
- 해당 상태 변수에 접근할 땐 언제나 동기화를 사용한다.
클래스를 설계할 때 스레드 안전하게 하는 방법은 캡슐화를 꼼꼼하게 하는 것이다. 프로그램 상태를 잘 캡슐화할수록 프로그램을 스레드에 안전하게 만들기 쉽고, 유지보수 팀에서도 역시 해당 프로그램이 계속해서 스레드에 안전하도록 유지하기 쉽다.
만약 객체지향 설계 방법과 성능이 반비례 관계라면 다음의 규칙을 따르자
- 코드를 올바르게 작성한다.
- 필요한 만큼 성능을 개선한다.
- 최적화는 실제와 동일한 상황을 구현해 성능을 측정하고, 예상되는 수치가 목표 수치와 차이가 있을 때 적용한다.
스레드 안정성이란?
스레드 안정성을 정의하기는 쉽지 않다. 스레드에 대한 납득할 만한 정의의 핵심은 모두 정확성 개념과 관련이 있는데, 스레드 안정성에 대한 정의가 모호한 것은 정확성에 대한 명확한 정의가 없기 때문이다. 정확성이란 클래스가 해당 클래스의 명세에 부합한다는 뜻이다. 이런 모호한 정의 말고 좀 더 실전적으로 이야기하면, “특정 코드가 동작한다” 고 확신하는 것이다.
이런 맥락에서 스레드 안정성은 다음과 같이 정의할 수 있다.
여러 스레드가 클래스에 접근할 때, 실행 환경이 해당 스레드들의 실행을 어떻게 스케쥴하든 어디에 끼워 넣든, 호출하는 측에서 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전하다고 말한다.
이제 앞에 세 가지 조건에 맞는 예제를 가지고 어떻게 코드를 스레드 안전하게 변경할 수 있는지 살펴볼 것이다.
상태 없는 서블릿
인수분해할 숫자를 서블릿 요청에서 빼내 인수분해하고, 결과를 응답에 인코딩해 넣는 코드가 있다고 가정해보자
public class statelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}이 코드는 스레드 안전한데, 이유는 상태가 없기 때문이다. 위의 방법 중 첫 번째 방법을 사용한 것이다.
해당 상태 변수를 스레드 간에 공유하지 않거나
상태가 없는 객체는 항상 스레드 안전하다.
이제 상태를 추가해 보자
단일 연산
statelessFactorizer 에 처리한 요청의 수를 기록하는 접속 카운터를 추가해 보자
@NotThreadSafe
public class statelessFactorizer 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);
}
}위의 코드는 단일 스레드 환경에서는 잘 동작하지만, 스레드 안전하지는 않다. ++count 는 단일 연산처럼 보이지만, 실제로는 다음 세 연산의 합이다.
- 현재 값을 가져온다
- 거기에 1을 더한다
- 새 값을 저장한다.
그렇기 때문에 위의 서블릿을 멀티스레드로 호출하면, count 값이 증가되기 전에 다른 스레드가 count 변수의 값을 참조하게 되어 생각한 것처럼 로직이 동작하지 않게 된다. 위의 사례를 일반화하면 다음과 같다.
- 어떤 사실을 확인한다.
- 그 관찰에 기반하여 행동한다.
하지만 사실을 확인한 후, 행동하는 동안 그 관찰이 더 이상 유효하지 않게 될 수 있다. 이런 경우 문제가 발생하게 된다.
다른 예제를 살펴보자
늦은 초기화 시 경쟁 조건
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null) {
instance = new ExpensiveObject();
}
return instance;
}
}이 코드는 lazy initalization 으로, 특정 객체가 실제로 필요할 때까지 초기화를 미루다가, 동시에 단 한번만 초기화하여 값을 반환하는 코드이다. 만약 멀티스레드 환경에서 해당 코드를 호출하게 되면, 경쟁 조건 때문에 제대로 동작하지 않을 가능성이 있다.
스레드 A, B 가 동시에 getInstance 를 호출하게 되면, A 가 null 임을 확인하고(어떤 사실을 확인) 객체를 생성할 때(관찰에 기반하여 행동), B 또한 null 임을 확인하고 객체를 생성하게 될 수 있다. 따라서, 두 스레드가 각기 다른 인스턴스를 반환할 수 있다.
복합 동작
위의 두 예제에서 이야기하는 핵심은 다음과 같다 : 일련의 작업은 외부 스레드에서 보았을 때 더 이상 나눠질 수 없는 단일 연산이어야 한다.
경쟁 조건을 피하려면 변수가 수정되는 동안 다른 스레드가 해당 변수를 사용하지 못하도록 막을 방법이 있어야 하며, 이런 방법으로 변수를 보호해두면 특정 스레드에서 변수를 수정할 때, 다른 스레드는 수정 전 혹은 수정 이후에만 상태를 읽거나 변경할 수 있다.
첫 번째 예제를 단일 연산으로 수정해 보자
@ThreadSafe
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 클래스를 활용하여 단일 연산으로 변경하였다. 이 문제는 이렇게 해결할 수 있다. 그러나, 상태가 하나가 아닌 둘 이상이 될 때는 다른 문제가 발생할 수 있다.
락
상태가 2개 이상이 된다고 가정해 보자. 과연 각기 상태를 단일 연산으로 유지하는 것 만으로 스레드 안정성을 보장할 수 있을까? 서로 다른 클라이언트가 연이어 같은 숫자를 인수분해한다고 가정해보자. 최근 계산 결과를 캐시에 보관해 인수분해 예제 서블릿의 성능을 향상시켜보도록 하겠다.
@NotThreadSafe
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);
}
}
}위와 같은 접근법은 제대로 동작하지 않는다. 인수분해 결과를 곱한 값이 lastNumber 에 캐시된 값과 같아야 한다는 불변조건이 있는데, 이 조건이 항상 성립해야 위의 로직은 제대로 동작한다. 즉, 위 로직에선 한 변수의 값이(lastNumber) 다른 변수에 들어갈 수 있는 값을 제한할 수 있어서(lastFactors) 변수 하나를 갱신할 땐 다른 변수도 동일한 연산 작업 내에서 함께 변경해야 한다.
개별적인 각 set 메서드는 단일 연산이지만, lastNumber 와 lastFactors 라는 두 값을 동시에 갱신할 수는 없다. 하나가 갱신되고 다른 하나가 수정되기 않은 그시점에 여전히 취약점이 존재한다. 그 때 다른 스레드가 값을 읽어가면 불변조건이 깨지게 된다.
상태를 일관성 있게 유지하려면 관련 있는 변수들을 하나의 단일 연산으로 갱신해야 한다.
암묵적인 락
위의 연산을 지원하기 위해, 자바에서는 synchronized 라는 구문을 지원한다.
synchronized (lock) {
// lock 으로 보호된 공유 상태에 접근하거나 해당 상태를 수정한다.
}위의 코드로 보호된 코드 블록은 한번에 한 스레드만 실행할 수 있기 때문에, 단일 연산으로 실행된다. 동기화 연산을 쓰면 인수분해 서블릿을 스레드 안전하게 수정할 수 있다. 같은 락으로 보호되는 synchronized 는 단일 연산으로 실행되기 때문에, 코드는 다음과 같이 수정할 수 있다.
@ThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
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);
}
}
}이렇게 하면 스레드 안전하게 서블릿을 고칠 수 있다. 하지만, 이 방법은 너무 극단적이라 퍼포먼스를 심하게 떨어뜨린다. 다른 방법을 찾아야 한다.
락으로 상태 보호하기
락은 자신이 보호하는 코드 경로에 여러 스레드가 순차적으로 접근하도록 하기 때문에, 공유된 상태에 베타적으로 접근하게 만들 수 있다. 이를 통해 경쟁 조건을 피하려면, 하나의 공유된 상태에 대한 복합동작을 단일 연산으로 만들어야 한다. 그러나, 단순한 복합 동작 부분을 synchronized 블록으로 감싸는 것으로 문제를 해결할 수 없다.
특정 변수에 대한 접근을 조율하기 위해 동기화 할 때는, 해당 변수에 접근하는 모든 부분을 동기화해야 한다. 또한 락을 사용할 때 변수에 접근하는 모든 곳에서 같은 락을 사용하여야 한다.
흔하게 하는 실수 중 하나는, 공유 변수에 값을 쓸 때만 동기화가 필요하다는 것인데 잘못된 생각이다.
모든 변경할 수 있는 공유 변수는 정확하게 단 하나의 락으로 보호해야 한다.
정리하면 다음과 같다.
- 변경 가능한 데이터를 여러 스레드에서 접근하는 경우, 데이터를 락으로 보호한다.
- 여러 메소드가 하나의 복합 동작으로 묶일 때는 락을 사용해 추가로 동기화한다.
- 모든 변경할 수 있는 공유 변수는 정확하게 단 하나의 락으로 보호한다.
활동성과 성능
위의 규칙을 UnsafeCachingFactorizer 에 적용한다고 생각해 보자. 위의 서블릿은 synchronized 를 사용하여 요청을 한번에 하나씩만 처리하고 있다. 이런 코드는 요청이 많아지면, 퍼포먼스의 저하가 있다.
다행히 synchronized 블록의 범위를 줄이면 스레드 안전성을 유지하면서 동시성을 향상시킬 수 있다. 즉, 단일 연산으로 처리해야 하는 작업을 메소드에서 잘 발라낸 다음에, 그 부분만 정확하게 synchronized 로 묶어주면 된다. 다음은 수정한 코드이다.
@ThreadSafe
public class CachingFactorizer implements Servlet {
private BigInteger lastNumber;
private BigInteger[] lastFactors;
private long hits;
private long cacheHits;
// 두 hits 는 변경할 수 있는 공유 상태이기 때문에, 접근할 땐 항상 동기화 구문을 사용해야 한다.
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;
// 해당 부분을 보호했다. 캐시된 결과를 가지고 있는지 확인하는 로직 (check - and - act)
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);
}
}락을 얻고 놓는 작업만으로도 어느정도 부하가 생기기 때문에, 연산 구조에 문제가 생기지 않는다고 해도 synchronized 블록을 너무 잘게 쪼개는건 좋지 않다. 위의 코드는 상태 변수에 접근하고 복합 동작을 수행하는 동안 락을 잡지만, 오래 걸릴 수 있는 인수분해 작업을 시행할 때는 락을 놓는다. 이런 작업을 통해 병렬 처리 능력에 큰 영향을 미치지 않으면서 스레드 안정성을 유지할 수 있다.
가끔 설계 원칙이 상충할 수 있다. 다음 규칙을 따르자
동기화 정책을 구현할 때는 성능을 위해 조급하게 단순성(잠재적으로 안전성을 훼손하면서)을 희생하고픈 유혹을 버려야 한다.
또한 복잡하게 오래 걸리는 작업, I/O, 네트웤 작업과 같이 빨리 끝나지 않는 작업에는 가급적이면 락을 잡지 않도록 하자. 락을 오래 잡으면 성능 문제를 야기할 수 있다.