- 공유 됐다는 것은 여러 스레드가 특정 변수에 접근할 수 있다는 뜻이고, 변경할 수 있다(mutable)는 것은 해당 변수 값이 변경될 수 있다는 뜻이다. 스레드 안전성이 마치 코드를 보호하는 것처럼 이해하는 경우가 많지만, 실제로는 데이터에 제어 없이 동시 접근하는 걸 막으려는 의미임을 알아두자.
- 객체가 스레드에 안전해야 하느냐는 해당 객체에 여러 스레드가 접근할지의 여부에 달렸다. 즉 프로그램에서 객체가 어떻게 사용되는가의 문제지 그 객체가 뭘 하느냐와는 무고나하다. 객체를 스레드에안전하게 만들려면 동기화를 통해 변경할 수 있는 상태에 접근하는 과정을 조율해야 한다. 동기화가 제대로 되지 못하면 데이터가 손상되거나 기타 바람직하지 않은 여러 결과가 생길 수 있다.
- 자바에서 동기화를 위한 기본 수단은 synchronized 키워드로서 배타적인 락을 통해 보호 기능을 제공한다. 하지만 volatile 변수, 명시적 락, 단일 연산 변수(atomic variable)를 사용하는 경우에도 ‘동기화'라는 용어를 사용한다.
만약 여러 스레드가 변경할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것이다. 이렇게 잘못된 프로그램을 고치는 데는 세 가지 방법이 있다.
- 해당 상태 변수를 스레드 간에 공유하지 않거나
- 해당 상태 변수를 변경할 수 없도록 만들거나
- 해당 상태 변수에 접근할 땐 언제나 동기화를 사용한다.
- 설계하면서 애당초 동시 접근을 염두에 두지 않았다면, 뒤늦게 위 세가지 방법 중 일부를 적용하고자 할 때 설계를 많이 고쳐야 할 가능성이 높다. 스레드 안전성을 확보하기 위해 나중에 클래스를 고치는 것보다 애당초 스레드에 안전하게 설계하는 편이 쉽다.
스레드 안전한 클래스를 설계할 땐, 바람직한 객체 지향 기법이 왕도다. 캡슐화와 불변객체를 잘 활용하고, 불변 조건을 명확하게 기술해야 한다.
- 스레드 안전하지 않은 클래스와 안전한 클래스의 구분
- 안전하다는 것의 뜻: 정확성 개념과 관련이 있음
정확성이란 클래스가 해당 클래스의 명세에 부합한다는 뜻
— 잘 작성된 클래스 명세는 객체 상태를 제약하는 불변조건과 연산 수행 후 효과를 기술하는 후조건을 정의
— 여러 스레드가 클래스에 접근할 때 계속 정확하게 동작하면 해당 클래스는 스레드 안전
스레드 안정성이란?
- 객체가 제대로 구현됐다면 어떤 일련의 작업도 해당 객체의 불변조건이나 후조건에 위배될 수 없다. 스레드에 안전한 클래스 인스턴스에 대해서는 순차적이든 동시든 어떤 작업들을 행해도 해당 인스턴스를 잘못된 상태로 만들 수 없다.
- 스레드 안정성이 필요한 경우는 직접 스레드를 생성하는 경우보다 서블릿 프레임웍 같은 수단을 사용하기 때문인 경우가 꽤 많다.
- 두 스레드가 상태를 공유하지 않기 때문에 사실상 서로 다른 인스턴스에 접근하는 것과 같다. 상태 없는 객체에 접근하는 스레드가 어떤 일을 하든 다른 스레드가 수행하는 동작의 정확성에 영향을 끼칠 수 없기 때문에 상태 없는 객체는 항상 스레드 안전하다.
단일 연산
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, SevletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}
- ++count 는 간단한 코드라서 단일 작업처럼 보이지만 실제로는 단일 연산이 아니다.
> 나눌 수 없는 최소 단위의 작업으로 실행되는 것이 아니라는 뜻 - 병렬 프로그램의 입장에서 타이밍이 좋지 않을 때 결과가 잘못될 가능성이 있고, 이런 개념은 경쟁 조건(race condition)이라 함
경쟁 조건
- 가장 일반적인 경쟁 조건 형태는 잠재적으로 유효하지 않은 값을 참조해서 다음에 뭘 할지를 결정하는 점검 후 행동(check-then-else) 형태의 구문
- 원하는 결과를 얻을 수 있을지의 여부는 여러 가지 사건의 상대적인 시점에 따라 달라짐.
- 대부분의 경쟁 조건은 관찰 결과의 무효화로 특징 지어짐
> 잠재적으로 유효하지 않은 관찰 결과로 결정을 내리거나 계산을 하는 것 - 어떤 사실을 확인하고 그 관찰에 기반해 행동을 한다. 하지만 해당 관찰은 관찰한 시각과 행동한 시각 사이에 더 이상 유효하지 않게 됐을 수도 있다.
> 아래의 예제에서 instance == null 조건문에 동시에 두 스레드가 접근할 경우, 두 스레드에서 모두 null로 판단하여 각 각 instance를 생성
@NotThreadSafe
public class LazyInitRace {
private ExprensiveObject instance = null; public ExpensiveObject getInstance() {
if (instance == null) {
instance = new ExpensiveObject();
}
return instance;
}
}
- ++count 예제의 경우는 카운터를 증가시키는 작업과 같은 읽고 수정하고 쓰기 동작은 이전 상태를 기준으로 객체의 상태를 변경한다. 다시 말해 카운터를 증가 시키려면 이전 값을 알아야 하고 카운터를 갱신하는 동안 다른 스레드에서 그 값을 변경하거나 사용하지 않도록 해야 한다.
복합 동작
- 스레드 안전성을 보장하기 위해 점검 후 행동과 읽고 수정하고 쓰기 등의 작업은 항상 단일 연산이어야 한다. 점검 후 행동과 읽고 수정하고 쓰기 같은 일련의 동작을 복합 동작이라고 한다. 즉, 스레드에 안전하기 위해서는 전체가 단일 연산으로 실행돼야 하는 일련의 동작을 지칭한다.
@ThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, SevletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}
락
- 스레드 안정성의 정의에 따르면 여러 스레드에서 수행되는 작업의 타이밍이나 스케줄링에 따른 교차 실행과 관계 없이 불변조건이 유지돼야 스레드에 안전하다.
암묵적인 락
- synchronized 구문: 단일 연산 특성 보장을 위해 사용할 수 있는 락
- synchronized 구문은 락으로 사용될 객체의 참조 값과 해당 락으로 보호하려는 코드 블록으로 구성
synchronized (lock) {
//lock으로 보호된 공유 상태에 접근하거나 해당 상태 수정
}
- 모든 자바 객체는 락으로 사용할 수 있으며 이와 같은 자바에 내장된 락을 암묵적인 락 혹은 모니터 락이라 한다.
- 락은 스레드가 synchronized 블록에 들어가기 전에 자동으로 확보되며 정상적으로든 예외가 발생해서든 해당 블록을 벗어날 때 자동으로 해제된다. 해당 락으로 보호된 synchronized 블록이나 메서드에 들어가야만 암묵적인 락을 확보할 수 있다.
- 자바에서 암묵적인 락은 뮤텍스(mttexes, 상호 배제 락)로 동작한다. 즉 한 번에 한 스레드만 특정 락을 소유할 수 있다. 스레드 B가 가지고 있는 락을 스레드 A가 얻으려면 A는 B가 해당 락을 놓을 때까지 기다려야 한다. 만약 B가 락을 놓지 않으면 A는 영원히 기다릴 수 밖에 없다.
- 단일 연산 특성은 일련의 문장이 하나의 나눌 수 없는 단위로 실행되는 것처럼 보인다는 것이다. 한 스레드가 synchronnized 블록을 실행 중이라면 같은 락으로 보호되는 synchronized 블록에 다른 스레드가 들어와 있을 수 없다.
@ThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors; public synchronized void service(ServletRequest req, SevletResponse resp) { BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber)) {
encodeIntoResponse(resp, lasatFactors);
} else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
재진입성
- 스레드가 다른 스레드가 가진 락을 요청하면 해당 스레드는 대기 상태에 들어간다. 하지만 암묵적인 락은 재진입 가능(reentarant)하기 때문에 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있다. 재진입성은 확보 요청 단위가 아닌 스레드 단위로 락을 얻는다는 것을 의미한다.
- 재진입성을 구현하려면 각 락마다 확보 횟수와 확보한 스레드를 연결시켜 둔다. 확보 횟수가 0이 되면 락은 해제된 상태이다. 스레드가 해제된 락을 확보하면 JVM이 락에 대한 소유 스레드를 기록하고 확보 횟수를 1로 지정한다.
- 같은 스레드가 락을 다시 얻으면 횟수를 증가시키고, 소유한 스레드가 synchronized 블록 밖으로 나가면 횟수를 감소시킨다.
- 재진입 가능한 락이 없으면 하위 클래스에서 synchronized 메소드를 재정의하고 상위 클래스의 메소드를 호출하는 아래와 같은 코드도 데드락에 빠질 것이다.
public class Widget {
public synchronized void doSomething() { ... }
}public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ":calling doSomething");
super.doSomething()
}
}
락으로 상태 보호하기
- 락은 자신이 보호하는 코드 경로에 여러 스레드가 순차적으로 접근하도록 하기 때문에, 공유된 상태에 배타적으로 접근할 수 있도록 보장하는 규칙을 만들 때 유용하다.
- 경쟁 조건을 피하려면 접속 카운터를 증가시키거나 늦게 초기화하는 경우 하나의 공유된 상태에 대한 복합 동작을 단이 ㄹ연산으로 만들어야 한다.
- 특정 변수에 대한 접근을 조율하기 위해 동기화할 때는 해당 변수에 접근하는 모든 부분을 동기화해야 한다. 또한 변수에 대한 접근을 조율하기 위해 락을 사용할 땐 해당 변수에 접근하는 모든 곳에서 반드시 같은 락을 사용해야 한다.
- 특정 객체의 락을 얻는다고 해도 다른 스레드가 해당 객체에 접근하는 걸 막을 순 없다. 락을 얻으면 단지 다른 스레드가 동일한 락을 얻지 못하게 할 수 있을 뿐이다. 모든 객체에 내장된 락이 있다는 점 때문에 매번 별도로 락 객체를 생성할 필요가 없어 단지 편리할 뿐이다. 공유 상태에 안전하게 접근할 수 있도록 락 규칙이나 동기화 정책을 만들고 프로그램 내에서 규칙과 정책을 일관성 있게 따르는 건 순전히 개발자에게 달렸다.
- 무차별적으로 synchronized를 적용하면 동기화가 너무 과도하거나 심지어는 부족할 수 있다.
활동성과 성능
- 락을 얻어놓는 작업만으로도 어느 정도의 부하가 따른다. 따라서 단일 연산 구조에 문제가 생기지 않는다 해도 synchronized 블록을 너무 잘게 쪼개는 일은 바람직하지 않다.