Link
Today
Total
10-17 00:15
Archives
관리 메뉴

초보개발자 긍.응.성

(이펙티브 자바 3) 11장 동시성 정리 본문

책 정리/이펙티브 자바 3

(이펙티브 자바 3) 11장 동시성 정리

긍.응.성 2020. 12. 10. 22:22
반응형

스레드는 여러 활동을 동시에 수행할 수 있게 해준다. 하지만 동시성 프로그래밍은 단일 스레드 프로그래밍보다 어렵다. 이번 장에는 동시성 프로그램을 명확하고 정확하게 만들고 잘 문서화하는 방법을 배워보자.

공유 중인 가변 데이터는 동기화해 사용하라

synchronized 키워드는 해당 메서드나 블록을 한 번에 한 스레드씩 수행하도록 보장한다. 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

long, double, boolean 필드를 읽고 쓰는 작업은 원자적이다. 하지만 공유 중인 가변 데이터가 비록 원자적으로 읽고 쓸 수 있을지라도 동기화하지 않으면 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에나 보게 될지 보증할 수 없다.

public class StopThread {
	private static boolean stopRequested;

	public static void main(String[] args) throws InterruptedException {
		Thread backgroundThread = new Thread(() -> {
			int i = 0;
			while(!stopRequested) {
				i++;
			}
		});
		backgroundThread.start();

		TimeUnit.SECONDS.sleep(1);
		stopRequested = true;
	}
}

이의 해결책은 synchronized한 쓰기와 읽기 메서드를 통해 동기화해주는 것이다. 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다. 더 빠른 대안으로는 stopRequested 필드를 voliatile으로 선언하고 동기화를 생략하는 것이다. volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.

하지만 volatile 또한 주의하여 사용해야 한다. volatile 필드의 값에 증가 연산자(++)가 사용되었다면 올바르게 동작하지 않을 수 있는데 이는 증가 연산자가 코드상으로는 하나지만 실제로 필드에 두 번 접근하는 동작이기 때문이다. 먼저 값을 읽고, 증가를 시키기에 다른 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 된다. 이러한 오류를 안전 실패(safety failure)라고 한다.

가장 좋은 방법은 java.util.concurrent.atomic 패키지를 활용하는 것이다. 이 패키지에는 락 없이도(lock-free) 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨 있다. volatile은 동기화의 두 효과 중 통신쪽만 지원하지만 이 패키지는 원자성까지 지원한다. 또한 동기화 성능도 우수하다.

동기화 문제를 피하는 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 불변 데이터만 공유하거나 아무것도 공유하지 말자. 가변 데이터는 단일 스레드에서 쓰도록 하자.

안전한 객체를 다른 스레드에게 건네는 행위를 안전 발행(safe publication)이라고 한다. 객체를 안전하게 발행하는 방법은 클리스 초기화 과정에서 객체를 정적 필드, volatile 필드, final 필드, 락을 통해 접근하는 필드에 저장하는 등 많은 방법이 존재한다.

과도한 동기화는 피하라

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에게 양도하면 안된다. 동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출하면 안 되며, 클라이언트가 넘겨준 함수 객체를 호출해서도 안 된다. 동기화된 영역을 포함한 클래스가 아닌 외부 클래스에서 온 메서드를 외계인 메서드(alien method)라 부른다. 외계인 메서드가 하는 일에 따라 동기화된 영역은 예외를 일으키거나, 교착상태에 빠지거나, 데이터를 훼손할 수도 있다.

자바 언어의 락은 재진입(reentrant)을 허용한다. 이는 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있도록 해주지만, 응답 불가(교착상태)가 될 상황을 안전 실패(데이터 훼손)로 변모시킬 수도 있다. 외계인 메서드 호출을 동기화 블록 바깥으로 옮겨 이러한 문제를 해결할 수 있다.

동기화 영역 바깥에서 호출되는 외계인 메서드를 열린 호출(open call)이라 한다. 외계인 메서드는 얼마나 오래 실행될지 알 수 없는데, 동기화 영역 안에서 호출된다면 그동안 다른 스레드는 보호된 자원을 사용하지 못하고 대기해야만 한다. 따라서 열린 호출은 실패 방지 효과외데도 동시성 효율을 크게 개선해준다.

기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는것이다. 멀티코어가 일반화된 오늘날, 과도한 동기화가 초래하는 진짜 비용은 락을 얻는 데 드는 CPU 시간이 아닌 경쟁하느라 낭비한 시간이다.

가변 클래스를 작성하려면 동기화를 전혀 하지 말고, 그 클래스를 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하거나, 동기화를 내부에서 수행에 스레드 안전한 클래스로 만들자.

또한 여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기화하자.

스레드보다는 실행자, 태스크, 스트림을 애용하라

java.util.concurrent 패키지는 실행자 프레임워크(Executor Framework)라 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.

Executors.newCachedThreadPool - 요청 태스크를 큐에 쌓지 않고 즉시 스레드에 위임

Executors.newFiexedThreadPool - 무거운 프로덕션 서버에서 사용. 스레드 개수 고정

실행자 프레임워크에서는 작업 단위와 실행 매커니즘이 분리된다.

태스크는 작업 단위를 나타내는 핵심 추상 개념이다. 태스크는 Runnable과 Callable 두 가지 종류가 존재한다. Callable은 Runnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다.

실행자 서비스는 실행 매커니즘을 담당한다. 태스크 수행을 실행자 서비스에게 맡기면 원하는 태스크 수행 정책을 선택할 수 있고, 생각이 바뀌면 언제든 변경할 수 있다. 핵심은 실행자 프레임워크가 작업 수행을 담당한다는 것이다.

자바 7 이후 실행자 프레임워크는 포크-조인(fork-join) 태스크를 지원하도록 확장되었다. 포크-조인 풀을 이용하여 실행 가능하다. ForJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있고, ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하면, 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수 있다.

wait와 notify보다는 동시성 유틸리티를 애용하라

wait과 notify는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자. java.util.concurrent의 고수준 유틸리티는 실행자 프레임워크(Executor Framework), 동시성 컬렉션(concurrent collection), 동기화 장치(synchronizer)이다.

동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 추가한 고성능 컬렉션이다. 동기화는 각자의 내부에서 수행하기에 동시성을 무력화하는것은 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.

  • ConcurrentHashMap은 여러기본동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드들이 추가되었다 (putIfAbsent(key, value))
  • BlockingQueue에 추가된 메서드는 take 메서드를 이용해 큐의 첫 원소를 꺼낸다. 큐가 비었다면 새로운 원소가 추가될 때까지 기다리며 이러한 특성 덕에 BlockingQueue는 작업 큐(생산자-소비자 큐)로 쓰기에 적합하다 (ThreadPoolExecutor를 포함한 대부분의 실행자 서비스의 구현체에서 BlockingQueue를 사용한다)

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있게 해 준다. 자주 쓰이는 동기화 장치는 CountDownLatch와 Semaphore이며, 가장 강력한 동기화 장치는 Phaser이다.

  • CountDownLatch는 일회성 장벽으로, 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다. CountDownLatch의 유일한 생성자는 int값을 받으며, 이 값이 래치의 countDown 메서드를 몇 번 호출해야 대기 중인 스레드들을 깨우는지를 결정한다.

새로운 코드라면 언제나 wait와 notify가 아닌 동시성 유틸리티를 써야 한다. 하지만 어쩔 수 없이 레거시 코드를 다루어야 할 경우가 있다.

  • wait - wait 메서드는 스레드가 어떤 조건이 충족되기를 기다리게 할 때 사용된다. 락 객체의 wait 메서드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다. wait 메서드를 사용할 때는 반드시 대기 반복문(wait loop) 관용구를 사용하라. 반복문 밖에서는 절대로 호출하지 말자.
  • notify/notifyAll - notify는 스레드 하나만 깨우며, notifyAll은 모든 스레드를 깨운다. 일반적으로는 notifyAll을 사용하는 게 합리적이다. 깨어나야 하는 모든 스레드가 깨어남을 보장하니 항상 정확한 결과를 얻을 것이다. 다른 스레드들이 깨어날 수 있지만, 기다리던 조건이 충족되었는지 확인하여, 충족되지 않았다면 다시 대기할 것이다. 단, 모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족될 때마다 단 하나의 스레드만 혜택을 받을 수 있다면 notify를 사용해 최적화할 수 있다.
728x90

스레드 안정성 수준을 문서화하라

멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다. 다음 목록은 스레드 안전성이 높은 순으로 나열한 것이다.

  • 불변(immutable): 이 클래스의 인스턴스는 마치 상수와 같아서 외부 동기화도 필요 없다. ex) String, Long, BigInteger
  • 무조건적 스레드 안전(uncoditionally thread-safe): 이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동작하여 별도의 외부화 동기화 없이 동시에 사용해도 안전하다. ex) AtomicLong, ConcurrentHashMap
  • 조건부 스레드 안전(conditionally thread-safe): 무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다. ex) Collections.synchronized 래퍼 클래스가 반환한 컬렉션
  • 스레드 안전하지 않음(not thread-safe): 이 클래스의 인스턴스는 수정될 수 있다. 동시에 사용하려면 각각의 메서드 호출을 클라이언트가 선택한 외부 동기화 메커니즘으로 감싸야한다. ex) ArrayList, HashMap과 같은 기본 컬렉션
  • 스레드 적대적(thread-hostile): 클래스의 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. 이 수준의 클래스는 일반적으로 정적 데이터를 아무 동기화 없이 수정한다.

스레드 안정성 애노테이션

  • @Immutable - 불변
  • @ThreadSafe - 무조건적 스레드 안전 / 조건부 스레드 안전
  • @NotThreadSafe - 스레드 안전하지 않음 / 스레드 적대적

조건부 스레드 안전한 클래스는 주의해서 문서화해야 한다. 어떤 순서로 호출할 때 외부 동기화가 필요한지, 그리고 그 순서로 호출하려면 어떤 락 혹은 락들을 얻어야 하는지 알려줘야 한다. 일반적으로 인스턴스 자체를 락으로 얻지만 예외도 있다. 클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하며 독특한 특성의 메서드라면 해당 메서드의 주석에 기재하도록 하자.

클래스가 외부에서 사용할 수 있는 락을 제공하면 클라이언트에서 일련의 메서드 호출을 원자적으로 수행할 수 있다. 하지만 이런 경우 내부에서 처리하는 고성능 동시성 제어 메커니즘과 혼용할 수 없는 담정이 있다. 또한, 클라이언트가 공개된 락을 오래 쥐고 놓지 않는 서비스 거부 공격(denial-of-service attack)을 수행할 수 있다.

서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다.

private final Object lock = new Object(); // 비공개 락 객체 관용구

public void foo() {
	synchronized(lock) {
		...
	}
}

앞의 코드에서는 lock 필드를 final로 선언했다. 이는 우연히라도 락 객체가 교체되는 일을 예방해준다. 일반적인 감시 락이든 java.util.concurrent.locks 패키지에서 가져온 락이든 락 필드는 항상 final로 선언하라

비공개 락 객체 관용구는 무조건적 스레드 안전 클래스에서만 사용할 수 있다. 조건부 스레드 안전 클래스에서는 특정 호출 순서에 필요한 락이 무엇인지를 클라이언트에게 알려주어야 하므로 이 관용구를 사용할 수 없다 (비공개이므로).

지연 초기화는 신중히 사용하라

지연 초기화(lazy initailization): 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법

주로 최적화 용도로 쓰이거나, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다. 하지만 꼭 필요할 때까지는 하지 않는 것을 추천한다. 대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.

멀티스레드 환경에서는 지연 초기화를 하기가 까다롭다. 지연 초기화하는 필드를 둘 이상의 스레드가 공유한다면 어떤 형태로든 반드시 동기화해야 한다.

지연 초기화가 초기화 순환성(initialization circularity)을 깨뜨릴 것 같으면 synchronized를 단 접근자를 사용하자.

// 일반적인 인스턴스 초기화
private final FieldType field = computeFieldValue();

// synchronized 접근자 방식을 통한 인스턴스 필드 지연 초기화
private FieldType field;

private synchronized FieldType getField() {
	if (field == null) {
		field = computeFieldValue();
	}
	return field;
}

위의 두 방식은 정적 필드에서도 똑같이 적용된다(필드와 접근자 메서드 선어에 static 한정자를 추가). 성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구를 사용하자. 클래스는 클래스가 처음 쓰일 때 비로소 초기화된다는 특성을 이용한 관용구다.

private static class FieldHolder {
	static final FieldType field = computeFieldValue();
}

private static FieldType getField() { return FieldHolder.field; }

getField가 처음 호출되는 순가 FieldHolder.field가 처음 읽히면서, 비로소 FieldHolder 클래스 초기화를 촉발한다. 이 관용구는 getField 메서드가 필드에 접근하면서 동기화를 전혀 하지 않으니 성능이 느려질 거리가 전혀 없다.

성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사(double-check) 관용구를, 반복해서 초기화해도 상관없는 인스턴스 필드를 초기화할 때는 단일검사(single-check) 관용구를 사용하자.

  • 이중검사
private volatile FieldType field; // 필드가 초기화된 이후 동기화하지 않으므로 volatile 사용

private FieldType getField() {
	FieldType result = field;
	if (result != null) { // 첫 번째 검사 (락 사용 안함)
		return result;
	}

	synchronized(this) {
		if (field == null) { // 두 번째 검사 (락 사용)
			field = computeFieldValue();
		}
		return field;
	}
}
  • 단일검사
private volatile FieldType field; // 필드가 초기화된 이후 동기화하지 않으므로 volatile 사용

private FieldType getField() {
	FieldType result = field;
	if (result == null) { // 락 사용 안함
		result = field = computeFieldValue();
	}
	return result;
}

꼭 지연 초기화를 써야 한다면, 상황에 맞는 올바른 지연 초기화 기법을 사용하자.

프로그램의 동작을 스레드 스케줄러에게 기대지 말라

프로그램의 동작을 스레드 스케줄러에게 기대지 말자. 견고성과 이식성을 모두 해치는 행위다. 같은 이유로, Thread.yield 와 스레드 우선순위에 의존해서도 안 된다. 스레드 우선순위는 이미 잘 동작하는 프로그램의 서비스 품질을 높이기 위해 드물게 쓰일 수는 있지만, 간신히 동작하는 프로그램을 '고치는 용도'로 사용해서는 절대 안된다.

견고하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법은 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다. 실행 준비된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자. 전체 스레드수, 대기 중인 스레드 수, 실행 가능한 스레드 수는 각각 다르다. 대기 중인 스레드는 실행 가능하지 않다.

실행 가능한 스레드 수를 적게 유지하는 주요 기법은 각 스레드가 작업을 완료한 후에는 다음 일거리가 생길 때까지 대기하도록 하는 것이다. 당장 처리해야 할 작업이 없다면 스레드는 실행되어서는 안 된다.

반응형
Comments