자바병렬프로그래밍 2. 스레드 안정성Thread Safety

Eugene Lim
15 min readMar 27, 2022

--

“자바병렬프로그래밍” 책이 읽기 어렵다고 주변에서 많이 들었다. concurrency programming은 자주 사용할 기회가 없고, 생소해서 그렇게 느낀다고 생각한다.

오늘부터 14주에 걸쳐, 1주씩 1 chapter를 같이 읽는다고 생각하고 포스팅 하려고 한다. 이번주는 2장 스레드 안정성이다. 이 책은 앞부분이 제일 어렵다는 느낌을 받는다. 왜냐하면 책 전반적인 내용을 앞에서 설명하고, 점점 풀어가는 방식 때문이다. 책을 읽으면서 모호한 표현들 혹은 생각해볼 부분들에 대해 조금이나마 이 글이 여러 사람에게 도움이 되었으면 한다.

객체의 상태라는 것은 무엇인가?

인스턴스나 static 변수(property) 같은 상태 변수에 저장된 객체의 데이터가 객체의 상태라고 보면 된다. 예를 들어, HashMap의 상태는 여러 field들도 있지만 map을 이루고 있는 element들의 상태도 마찬가지이다.

여기서 헷갈리는 용어 2개가 있다. 우리가 선언하는 변수들 중 variable(local variable)와 property(instance variable)의 차이점은 무엇인가?

예를들어, 함수 내에서 선언한 variable는 stack memory에 올라가고 함수가 끝나면 없어진다.

public void doSomething() {
int i = 0;
}

하지만 우리가 class에 선언한 변수는 heap memory에 올라가고 class의 라이프사이클과 동일하게 살아있게 되며, 이런 property들로 객체의 상태가 결정된다.

public class Person {

private final String name;

Person(String name) {
this.name = name;
}
}

Person 클래스들은 동기화가 필요없다. 클래스 밖 언제, 어디서 보든 name은 같은 값이다.

스레드가 하나 이상 상태 변수에 접근하고 그 중 하나라도 변수에 값을 쓰면, 해당 변수에 접근할 때 관련된 모든 스레드가 동기화를 통해 조율해야한다.

public class Person {

private String name;

Person(String name) {
this.name = name;
}

public void changeName(String name) {
this.name = name;
}
}

이름을 변경할 수 있게 되었다. 이 때부터 우리는 동기화에 신경을 써야 한다.

Thread-safe Class를 만드는 법

  1. 변수를 스레드 간에 공유하지 않거나
  2. 변수를 변경할 수 없도록 하거나(위의 java code)
  3. 변수에 접근할 땐 언제나 동기화를 사용한다

2.1 스레드 안정성이란?

우리가 어떤 코드를 읽거나 작성할 때, input/output을 예상할 수 있다. 만약 어떤 코드가 single-thread 환경에서 A를 넣었을 때, B가 나온다면 multi-thread 환경에서도 동일한 결과가 나와야 한다.

책에서는 이런 특성을 정확성이라고 표현했다.

스레드 안전한 클래스는 클라이언트 쪽에서 별도로 동기화할 필요가 없도록 동기화 기능도 캡슐화한다.

  • 여기서 말하는 클라이언트는, 클라이언트/서버 구조의 개념이 아니고 library나 언어를 만드는 사람들 입장에서 그 코드를 가져다 쓰는 사람들을 말한다.

2.1.1 상태 없는 서블릿

  • 서블릿은 현재는 쓰이지 않지만, http에서 request 요청이 들어왔을 때, service() 함수가 수행되어 html result를 주는 방식이라고 생각하면 될 것 같다.
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}

이 클래스는 상태를 표현하는 클래스 변수(property)가 없고, 다른 클래스의 변수도 참조하지 않는다. 이런 클래스를 상태없는 객체 stateless object라고 할 수 있다.

상태가 없는 객체는 항상 스레드 안전하다

2.2 단일 연산 Atomicity

만약 접속카운터를 추가해서 property를 사용한다면?

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

++count는 한줄짜리 간단한 코드라 단일 작업처럼 보이지만 단일 연산이 아니다.
책의 그림 1.1에서 발생할 수 있는 상황이 그대로 재현된다.

그림 1.1

2.2.1 경쟁 조건 Race Condition

경쟁 조건은 상대적인 시점이나 또는 JVM이 여러 스레드를 교차해서 실행하는 상황에 따라 계산의 정확성이 달라질 때 나타난다.

그림 1.1 에서 나타난 상황이 “경쟁 조건” 중 가장 일반적인 형태인 점검 후 행동 check-then-act 이다. 어떤 사실을 확인하고(value=9) 그 관찰에 기반해 행동(value = 9+1) 을 한다. 하지만 해당 관찰은 더이상 유효하지 않게 됐을 수도 있다.

그리고 책에서는 데이터경쟁 data race에 대해서도 설명하는데, final이 아닌 필드를 동기화로 적절히 보호하지 않아서 값이 제대로 보이지 않을 때를 말한다. 데이터 경쟁은 경쟁 조건의 한 형태로도 나타나기도 하며, Thread 간의 경쟁 조건이 아닌 경우에도 발생한다.

2.2.2 늦은 초기화 시 경쟁 조건

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

이 코드를 보면, 동시에 A, B thread가 ExpensiveObject를 2개 만들 수 있다. 그림 1.1.과 같이 instance를 2 thread에서 보았을 때, null 이었고 각각 instance field에 새로 만든 ExpensiveObject를 할당하려고 할 것이다.

게다가 A thread에서 getInstance()로 얻어간 객체와 B thread에서 getInstance()로 얻어간 객체가 달라진다. 우리가 의도한 바랑 완전 다른 결과가 나올 수 있다. 그리고 이런 케이스가 100% 발생하는 것이 아니기 때문에, 코드가 릴리즈 되었을 때 어디서 값이 잘못되었는지 찾기가 어려워진다.

분명 이 코드를 작성한 사람과 읽는 사람은 ExpensiveObject 객체가 싱글톤이라고 착각할 것이다. 앞서 말한, 클래스가 정확성이 깨지는 순간이다.

2.2.3 복합 동작

그래서 이번에는 방문자 카운터에 AtomicLong 을 추가하였다.

private final AtomicLong count = new AtomicLong(0);
...
count.incrementAndGet();
...

++count를 AtomicLong으로 대체하므로써, check-then-act 의 3가지 연산을 하나의 단일 연산으로 묶어주었다. AtomicLong.incrementAndGet()이 어떻게 단일연산으로 처리해주는지는 뒤에 나온다. 지금은 이런 단일 연산 변수 클래스 atomic variable class 들이 존재한다는 것만 알아둔다.

2.3 락

위의 서블릿 예제에서 가장 최근 결과를 캐시한다고 생각해보자.

@NotThreadSafe
public class UnsafeCachingFactorizer 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);
}
}
}

AtomicReference를 통해 단일 연산을 만들어주고 thread-safe한 클래스가 되도록 설계했다. lastNumber와 lastFactors 자체는 thread-safe 하지만 lastNumber와 lastFactors를 단일 연산으로 묶어주지는 않는다. 여기서 최악의 사태는 lastNumber=10인데 lastFactors는 전혀 다른 숫자의 결과가 캐시될 수 있다는 점이다.

not thread-safe condition
요청이 10이 들어왔을 때, 9의 인수분해 결과가 나갈 수 있다

상태를 일관성 있게 유지하려면 관련있는 변수들을 하나의 단일 연산으로 업데이트 해야한다.

2.3.1 암묵적인 락 Intrincsic Locks

synchronized (object) {
// Access or modify shared state guarded by lock
}

해당 블록은 object객체로 보호된 synchronized 블록이다. 이 블록에 들어가기 전에 자동으로 object객체를 통해 락이 확보되며 정상이든 예외든 블록을 벗어날 때 락이 해제된다.

synchronized 키워드를 사용하면 인수분해 서블릿을 고칠 수 있다. 자바에서 이런 암묵적인 락은 뮤텍스 mutexes로 동작한다, 한 번에 한 스레드만 특정 락을 소유할 수 있다.

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

이렇게 함수 앞에 synchronized 키워드를 붙이면 this 에 락이 걸리고, 함수가 블록이 된다.

2.3.2 재진입성

암묵적인 락은 재진입이 가능하기 때문에 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있다.

스레드가 해제된 락을 확보하면 JVM이 락에 대한 소유 스레드를 기록하고 확보 횟수를 1로 지정한다. 같은 스레드가 락을 다시 얻으면 횟수를 증가시키고, 소유 스레드가 synchronized 블록 밖으로 나가면 횟수를 감소시킨다. 이렇게 횟수가 0이 되면 해당 락은 해제된다.

객체와 스레드로 구분하여 동일한 스레드가 들어오면 카운트를 증가시킨다

만약 ioThread2가 LoggingWidget@1234에 대하여 lock을 확보하려고 한다면, 0이 될때까지 기다릴 것이다.

2.4 락으로 상태 보호하기

흔히 값을 쓸 때만 동기화가 필요하다고 생각하기 쉽다. 변경 가능한 모든 변수(property)를 대상으로 쓰기/읽기를 할 때 항상 동일한 락으로 보호해야 한다.

위의 예제 SynchronizedFactorizer 는 함수자체가 this로 암묵적 락으로 보호되어 있다. 책에서는 Vector 같은 동기화된 컬랙션 클래스를 사용하는 것도 방법이라고 되어 있다.

Vector코드를 한 번 살펴보면, get/set 부분에 모두 synchronized가 걸려있는 것을 확인할 수 있다.

그럼 동기화란 모든 함수에 synchronized를 걸어주면 되는 것 아닌가??

하지만 책에서는 그것도 방법이 아니라고 알려준다.

  1. 단일 스레드로 들어오는 함수들은 동기화하지 않아도 된다
  2. 그리고 멀티스레드로 바꿔야 한다면, 변경 가능한 변수를 객체 안으로 캡슐화한다.
  3. 여러 변수에 대한 불변조건이 있으면 해당 변수들은 모두 같은 락으로 보호해야한다.
    (여러 변수에 대한 불변조건은, 서블릿에서 두 변수가 동기화 되어야 할 때)
  4. 그리고 이렇게 모두 synchronized를 하면 성능의 문제가 있다.

2.5 활동성과 성능

인수분해 서블릿 자체에 synchonized 했을 때

service() 함수 자체에 synchronized를 붙여서 그림과 같이 multi-thread의 이점은 하나도 없다. 오히려 single-thread로 처리했을 때보다도 더 느릴 것이다. 또한, 만약에 thread A에서 엄청 큰 숫자가 들어와서 factor(n)이 오래 걸린다면, thread B,C는 마냥 기다린다.
그래서 꼭 동기화될 블록만 찾아내서 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);
}
}

아까처럼 hit를 AtomicLong으로 쓸 필요가 없다. 오히려 같은 this lock으로 보호되므로 동기화 문제도 없다. AtomicLong은 또한 다른 방식으로 동기화를 해서 성능상 이점이 저하된다.

그리고 synchronized 블록을 너무 작게 자르는 것도 성능상 좋지 않다.

response에 쓸 factors를 clone()하거나 lastFactors.clone()하는 이유는, 만약 lastFactors의 reference가 그냥 클래스밖으로 나가버려서 배열이 조작되거나 특정 index의 값이 바뀌는 것을 방지하기 위해서이다.

복잡하고 오래걸리는 계산 작업, 네트워크 등등 가능한 락을 잡지 않는다.

이번 챕터에서는 스레드 안전성이 무엇인지 정의했고, 스레드 안전한 클래스를 만드는 방법 중에 가장 심플한 암묵적인 락에 대해서도 알아보았다.

--

--