자바 병렬 프로그래밍(1~2장)

1장 개요

1.1 작업을 동시에 실행하는 일에 대한 간략한 역사

스레드는 멀티프로세서 시스템의 능력을 최대한 끌어낼 수 있는 가장 쉬운 방법이다.

프로세스는 각자가 서로 격리된 채로 독립적으로 실행되는 프로그램으로서 운영체제는 프로세스마다 메모리, 파일 핸들, 보안 권한 등의 자원을 할당한다.

스레드는 메모리, 파일 핸들과 같이 프로세스에 할당된 자원을 공유한다. 하지만 각 스레드는 각기 별도의 프로그램 카운터, 스택, 지역 변수를 갖는다.

또한 프로그램을 스레드로 분리하면 멀티프로세서 시스템에서 자연스럽게 하드웨어 병렬성을 이용할 수 있다. 즉, 한 프로그램 내 여러 스레드를 동시에 여러 개의 CPU 에 할당해 실행시킬 수 있다.

스레드는 자신이 포함된 프로세스의 메모리 주소 공간을 공유하기 때문에 한 프로세스 내 모든 스레드는 같은 변수에 접근하고 같은 힙(heap) 에 객체를 할당한다.

1.2 스레드의 이점

개발 및 유지 보수 비용을 줄이고 복잡한 애플리케이션의 성능을 향상시킬 수 있다. 비동기적인 일 흐름을 거의 순차적으로 바꿀 수 있어 사람이 일하고 상호 작용하는 방식을 모델링하기 쉬워진다.

  • 멀티프로세서 활용 - 프로세서 스케쥴링의 기본 단위는 스레드이기 때문에 스레드 하나로 동작하는 프로그램은 한 번에 최대 하나의 프로세서만 사용한다. 따라서 멀티스레드 프로그램은 가용한 프로서세 자원을 더 효율적으로 활용하여 처리 속도를 높이는 것임
  • 단순한 모델링 - 복잡하면서 비동기적인 작업 흐름을 각기 별도 스레드에서 수행되는 더 단순하고 동기적인 작업 흐름 몇 개로 나눌 수 있다. 이런 작업 흐름에서는 특정한 동기화 시점에서만 상호 작용이 발생한다.
  • 단순한 비동기 이벤트 처리 - 여러 클라이언트 프로그램에서 소켓 연결을 받는 서버 애플리케이션의 경우 각 연결마다 스레드를 할당하고 동기 I/O 를 사용하도록 하면 개발 작업이 쉬워진다.
  • 더 빨리 반응하는 사용자 인터페이스

1.3 스레드 사용의 위험성

  • 안정성 위해 요소 - 여러 스레드에서 실행되는 연산의 순서는 때로는 놀라울 만큼 예측하기가 어렵다.
    스레드는 서로 같은 메모리 주소 공간을 공유하고 동시에 실행되기 때문에 다른 스레드가 사용 중일지도 모르는 변수를 읽거나 수정할 수도 있다. ex) value++ 연산을 멀티 스레드로 구현했을 때
  • 활동성 위험 - 단일 스레드 프로그램에서는 발생하지 않는 추가적인 안정성 위험에 노출될 수 있고 또한 추가적인 형태의 활동성 장애가 발생할 수 있다. ex) deadlock, starvation, livelock
  • 성능 위험 - 스레드가 많은 프로그램에서는 컨텍스트 스위칭이 더 빈번하고, 그 때문에 상당한 부담이 생긴다.

1.4 스레드는 어디에나

모든 자바 프로그램은 기본적으로 스레드를 사용한다. JVM 을 시작시키면 main 메서드를 실행할 주 스레드 뿐 아니라 가비지 컬렉션이나 객체 종료(object finalization) 와 같은 JVM 내부 작업을 담당할 스레드도 생성한다. 프레임웍 때문에 프로그림이 병렬로 실행되는 경우가 생기면 병렬로 실행된다는 사실을 프레임웍 뿐만 아니라 프로그램에서도 인식하고 적절히 대응해야 한다.

  • 타이머(Timer)
  • 서블릿과 JSP
  • 원격 메서드 호출(RMI)
  • 스윙과 AWT

2장 스레드 안정성

스레드에 안전한 코드를 작성하는 것은 근본적으로는 상태, 즉 공유되고 변경할 수 있는 상태에 대한 접근을 관리하는 것이다.

객체의 상태는 인스턴스나 static 변수 같은 상태 변수에 저장된 객체의 데이터다. 공유됐다는 것은 여러 스레드가 특정 변수에 접근할 수 있다는 뜻이고, 변경할 수 있다(mutable)는 것은 해당 변수 값이 변경될 수 있다는 뜻이다.

스레드 안정성이란 데이터에 제어 없이 동시 접근하는 것을 막으려는 의미임을 알아두자. 객체를 스레드 안전하게 만들려면 동기화를 통해 변경할 수 있는 상태에 접근하는 과정을 조율해야 한다.

스레드가 하나 이상 상태 변수에 접근하고 그 중 하나라도 변수에 값을 쓰면, 해당 변수에 접근할 때 관련된 모든 스레드가 동기화를 통해 조율해야 한다. 자바에서 제공하는 동기화 기본 수단은 synchronized, volatile 변수, 명시적 락, 단일 연산 변수(atomic variable) 등이 있다.

만약 여러 스레드가 변경할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것이다. 이런 잘못된 프로그램을 고치는 데는 세 가지 방법이 있다.

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

(당연한 이야기지만) 스레드 안정성을 확보하기 위해 나중에 클래스를 고치는 것보다는 애당초 스레드에 안전하게 설계하는 편이 훨씬 쉽다. 프로그램 상태를 잘 캡슐화할수록 프로그램을 스레드에 안전하게 만들기 쉽고 유지 보수 팀에서도 역시 해당 프로그램이 계속해서 스레드에 안전하도록 유지하기 쉽다.

스레드 안전한 클래스를 설계할 땐, 바람직한 객체 지향 기법이 왕도다.
캡슐화와 불변 객체를 잘 활용하고, 불변 조건을 명확하게 기술해야 한다.

2.1 스레드 안정성이란?

여러 스레드가 클래스에 접근할 때 계속 정확하게 동작하면 해당 클래스는 스레드에 안전하다. 어떻게 스케쥴하든 어디에 끼워 넣든, 호출하는 쪽에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전하다고 말한다. 또한 스레드 안전한 클래스는 클라이언트 쪽에서 별도로 동기화할 필요가 없도록 동기화 기능도 캡슐화한다.

2.1.1 예제: 상태 없는 서블릿

1
2
3
4
5
6
7
8
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}

StatelessFactorizer 는 대부분의 서블릿처럼 상태가 없다. 즉, 선언한 변수가 없고 다른 클래스의 변수를 참조하지도 않는다. 특정 계산을 위한 일시적인 상태는 스레드의 스택에 저장되는 지역 변수에만 저장하고, 실행하는 해당 스레드에서만 접근할 수 있다.

따라서 두 스레드가 상태를 공유하지 않기 대문에 사실상 서로 다른 인스턴스에 접근하는 것과 같다.

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

2.2 단일 연산

2.2.1 경쟁 조건

병렬 프로그램의 입장에서 타이밍이 안 좋을 때 결과가 잘못될 가능성은 굉장히 중요한 개념이기 때문에 경쟁 조건(race condition)이라는 별도 용어로 정의한다.

가장 일반적인 경쟁 조건 형태는 잠재적으로 유효하지 않은 값을 참조해서 다음에 뭘 할지를 결정하는 점검 후 행동 형태의 구문이다. (check-then-act)

어떤 사실을 확인하고 그 관찰에 기반해 행동을 한다. 하지만 해당 관찰은 관찰한 시각과 행동한 시각 사이에 더 이상 유효하지 않게 되었을 수도 있다. ex) 스벅에서 친구 만나기

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

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

1
2
3
4
5
6
7
8
9
10
11
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;

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

위의 코드는 경쟁 조건 때문에 의도대로 동작하지 않을 가능성이 있다.

2.2.3 복합 동작

경쟁 조건을 피하려면 변수가 수정되는 동안 다른 스레드가 해당 변수를 사용하지 못하도록 막을 방법이 있어야 하며, 이런 방법으로 보호해두면 특정 스레드에서 변수를 수정할 때 다른 스레드는 수정 도중이 아닌 수정 이전이나 이후에만 상태를 읽거나 변경을 가할 수 있다.

작업 A를 실행 중인 스레드 관점에서 다른 스레드가 작업 B를 실행할 때
작업 B가 모두 수행됐거나 또는 전혀 수행되지 않은 두가지 상태로만 파악된다면
작업 A의 눈으로 볼 때 작업 B는 단일 연산이다.

스레드 안정성을 보장하기 위해 점검 후 행동과 읽고 수정하고 쓰기 등의 작업은 항상 단일 연산이어야 한다. 이런 일련의 동작을 복합 동작(compound action) 이라고 한다. 즉, 스레드에 안전하기 위해서는 전체가 단일 연산으로 실행돼야 하는 일련의 동작을 지칭한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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.3 락

아래의 코드는 입력한 숫자를 인수분해하여 입력한 숫자과 인수분해 결과를 캐시하여 보관하는 코드이다. AtomicLong 등의 변수를 사용하여 스레드 안정성을 확보한 코드처럼 보이지만 사실은 Thread 안전하지 않은 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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);
}
}

}

스레드 안정성의 정의에 따르면 여러 스레드에서 수행되는 작업의 타이밍이나 스케쥴링에 따른 교차 실행과 관계 없이 불변조건이 유지돼야 스레드에 안전하다.

UnsafeCachingFactorizer 에는 인수분해 결과를 곱한 값이 lastNumber 에 캐시된 값과 같아야 한다는 불변조건이 있으며, 이와 같은 불변조건이 항상 성립해야 서블릿이 제대로 동작한다고 볼 수 있다.

여기서 만약 타이밍이 좋지 않았다면 개별적인 각 set 메서드에서는 단일 연산으로 동작하지만, lastNumber 와 lastFactors 라는 두 개의 값을 동시에 갱신하지는 못한다. 하나는 수정됐고 다른 하나는 수정되지 않은 그 시점에 여전히 취약점이 존재한다. 이 순간 다른 스레드가 값을 읽어가면 불변조건이 깨진 상태를 보게 될 것이다.

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

2.3.1 암묵적인 락

자바에서는 단일 연산 특성을 보장하기 위해 synchronized 라는 구문을 제공한다. synchronized 구문은 락으로 사용될 객체의 참조 값과 해당 락으로 보호하려는 코드 블록으로 구성된다. 메서드 선언 부분에 synchronized 키워드를 지정하면 메서드 내부의 코드 전체를 포함하면서 메서드가 포함된 클래스의 인스턴스를 락으로 사용하는 synchronized 블록을 간략하게 표현한 것으로 볼 수 있다.

모든 자바 객체는 락으로 사용할 수 있다. 이와 같이 자바에 내장된 락을 암묵적인 락(intrinsic lock) 혹은 모니터 락(monitor lock)이라고 한다. 락은 스레드가 synchronized 블록에 들어가기 전에 자동으로 확보되며 정상적으로든 예외가 발생해서든 해당 블록을 벗어날 때 자동으로 해제된다.

자바에서 암묵적인 락은 뮤텍스(mutual exclusion lock)로 동작한다. 즉, 한 번에 한 스레드만 특정 락을 소유할 수 있다. 스레드 B가 가지고 있는 락을 스레드 A가 얻으려면 A는 B가 해당 락을 놓을 때까지 기다려야 한다.

특정 락으로 보호된 코드 블록은 한 번에 한 스레드만 실행할 수 있기 때문에 같은 락으로 보호되는 서로 다른 synchronized 블록 역시 서로 다른 단일 연산으로 실행된다. 한 스레드가 synchronized 블록을 실행 중이라면 같은 락으로 보호되는 synchronized 블록에 다른 스레드가 들어와 있을 수 없다.

2.3.2 재진입성

스레드가 다른 스레드가 가진 락을 요청하면 해당 스레드는 대기 상태에 들어간다. 하지만 암묵적인 락은 재진입 가능(reentrant)하기 때문에 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있다. 재진입성은 확보 요청 단위가 아닌 스레드 단위로 락을 얻는다는 것을 의미한다. 재진입성을 구현하려면 각 락마다 확보 횟수와 확보한 스레드를 연결시켜 둔다. 확보 횟수가 0 이면 락은 해제된 상태이다. 스레드가 해제된 락을 확보하면 JVM이 락에 대한 소유 스레드를 기록하고 확보 횟수를 1로 지정한다. 같은 스레드가 락을 다시 얻으면 횟수를 증가시키고, 소유한 스레드가 synchronized 블록 밖으로 나가면 횟수를 감소시킨다. 이렇게 횟수가 0이 되면 해당 락은 해제된다.

재진입성 때문에 락의 동작을 쉽게 캡슐화할 수 있고, 객체 지향 병렬 프로그램을 개발하기가 단순해졌다. 재진입 가능한 락이 없으면 하위 클래스에서 synchronized 메서드를 제정의하고 상위 클래스의 메서드를 호출하는 단순한 코드에서도 데드락에 빠질 것이다.

2.4 락으로 상태 보호하기

락은 자신이 보호하는 코드 경로에 여러 스레드가 순차적으로 접근하도록 한다. 보통 경쟁 조건을 피하려면 하나의 공유된 상태에 대한 복합 동작을 단일 연산으로 만들어야 한다.

하지만 단순히 복합 동작 부분을 synchronized 블록으로 감싸는 것으로는 부족하다. 특정 변수에 대한 접근을 조율하기 위해 동기화할 때는 해당 변수에 접근하는 모든 부분을 동기화해야 한다. 또한 해당 변수에 접근하는 모든 곳에서 반드시 같은 락을 사용해야 한다.

모든 변경할 수 있는 공유 변수는 정확하게 단 하나의 락으로 보호해야 한다.
유지보수하는 사람이 알 수 있게 어느 락으로 보호하고 있는지를 명확하게 표시하라.

락을 활용함에 있어 일반적인 사용 예는 먼저 모든 변경 가능한 변수를 객체 안에 캡슐화하고, 해당 객체의 암묵적인 락을 사용해 캡슐화한 변수에 접근하는 모든 코드 경로를 동기화함으로써 여러 스레드가 동시에 접근하는 상태에서 내부 변수를 보호하는 방법이다.

그런데 이런 식의 락에 대한 규칙은 새로운 메서드나 코드 경로를 추가하면서 실수로 동기화하는 걸 잊기만 해도 쉽게 무너질 수 있다.

특정 변수가 락으로 보호되면, 즉 해당 변수에 항상 락을 확보한 상태에서 접근하도록 하면, 한 번에 한 스레드만 해당 변수에 접근할 수 있다는 점을 보장할 수 있다. 클래스에 여러 상태 변수에 대한 불변조건이 있으면 불변조건에 관련된 각 변수는 모두 같은 락으로 보호돼야 한다는 추가 요구사항이 따라 붙는다.

여러 변수에 대한 불변조건이 있으면 해당 변수들은 모두 같은 락으로 보호해야 한다.

2.5 활동성과 성능

synchronized 블록의 범위를 줄이면 스레드 안정성을 유지하면서 쉽게 동시성을 향상시킬 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@ThreadSafe
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);
}
}

나름의 코드 해석을 적으면

  • 맴버 변수만 표시해보면 쓰기 뿐만 아니라 읽기 시에도 synchronized 처리 했음을 알 수 있다.
  • 메서드 앞에 synchronized 를 붙일 때에는 암묵적으로 해당 클래스의 객체를 락 객체로 사용되나, 특정 영역을 sync 할 때는 this 등의 선언을 붙여줘야 한다.

여기서 AtomicLong 클래스를 써도 괜찮지만 이미 synchronized 블록을 사용해 동기화하고 있으므로 별다른 장점은 없다. 성능이나 안전성 측면의 이점은 없고 혼동만 줄 뿐이다. 예시 코드는 단순성과 병렬 처리 능력 사이에 균형을 맞췄다. 락을 얻고 놓는 작업만으로도 어느 정도의 부하가 따르므로 synchronized 블록을 너무 잘게 쪼개는 일은 바람직하지 않다.

락을 사용할 땐 블록 안의 코드가 무엇을 하는지, 수행하는 데 얼마나 걸릴지를 파악해야 한다. 계산량이 많은 작업을 하거나 잠재적으로 대기 상태에 들어 갈 수 있는 작업을 하느라 락을 오래 잡고 있으면 활동성이나 성능 문제를 야기할 수 있다.

복잡하고 오래 걸리는 계산 작업, 네트웍 작업, 사용자 입출력 작업과 같이
빨리 끝나지 않을 수 있는 작업 부분에서는 가능한 락을 잡지 말아라