////
Search

11장 - 동시성

Created
2022/10/14 01:01
Tags
Java

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

동기화

synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다.
동기화는 두 가지 기능이 존재한다.
객체를 하나의 일관된 상태에서 다른 상태로 변화를 일으키는 순간에 해당 객체의 상태 변화를 볼 수 없도록 한다.
동기화는 다른 스레드에서 만든 변화를 볼 수 있도록 해준다.
언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적(atomic)이다.
이 말을 듣고 "성능을 높이려면 원자적 데이터를 읽고 쓸 때는 동기화하지 말아야겠다"고 생각하기 쉬운데, 아주 위험한 발상이다.
동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.
공유 중인 가변 데이터를 비록 원자적으로 읽고 쓸 수 있을지라도 동기화에 실패하면 처참한 결과로 이어질 수 있다.

스레드 중지

먼저 스레드 중지에 Thread.stop() 사용은 금지다! (자바11에서 제거되었다)
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; } }
Java
복사
잘못된 코드
1초 후 정지될 것으로 보이는 코드지만 메인 스레드가 동기화 되지 않은 stopRequested로 인해 영원히 루프에 빠지게 된다.

이유

// 원래 코드 while (!stopRequested) i++;
Java
복사
동기화가 빠진 코드는 JVM이 원래 코드를 아래와 같이 최적를 수행할 수도 있기 때문이다.
// 최적화 된 코드 if (!stopRequested) while (true) i++;
Java
복사

해결

public class StopThread { private static boolean stopRequested; private static synchronized void requestStop() { stopRequested = true; } private static synchronized boolean stopRequested() { return 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); requestStop(); } }
Java
복사
쓰기 메서드와 읽기 메서드 모두를 동기화 했음에 주목하자.
쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.
동기화는 배타적 수행과 스레드 간 통신이라는 두 가지 기능을 수행하는데, 이 코드에서는 통신 목적으로만 사용된 것이다.

더 나은 방법

public class StopThread { private staic volatile boolean stopRequest; public static void main(String[] args) throws InterruptedException { Thread backgroundThread= new Thread(() -> { int i = 0; while (!stopRequested) i++; }); backgroundThread.start(); TimeUnit.SECONDS.sleep(1); stopRequest = true; } }
Java
복사
stopRequested 필드를 volatile으로 선언하면 동기화를 생략해도 된다.
volatile 한정자는 배타적 수행과는 상관 없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.

volatile은 주의해서 사용해야 한다.

private static volatile int nextSerialNumber = 0; public static int generateSerialNumber() { return nextSerialNumber++; }
Java
복사
해당 코드 역시 동기화가 없이는 올바로 동작하지 않는다.
문제는 증가 연산자(++)로 코드상으로는 하나지만 실제로는 nextSerialNumber 필드에 두번 접근한다.
먼저 값을 읽고, 그 다음 새로운 값을 저장하는 것이다.
만약 두번째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 되는데, 프로그램이 잘못된 결과를 계산해내는 이런 오류를 안전 실패라고 한다.
해당 메서드에 synchronized 한정자를 붙여야하고 이때 nextSerialNumbervolatile을 제거해야한다.

더 나은 방법

private static final AtomicLong nextSerialNumber = 0; public static int generateSerialNumber() { return nextSerialNumber++; }
Java
복사
java.util.concurrent.atomic 패키지의 AtomicLong를 이용해 락 없이도 스레드 안전한 프로그래밍을 할 수 있다.
이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨있다.
성능이 우수한 코드를 작성 할 수 있다.

동기화 문제를 피하는 좋은 방법

가장 좋은 방법은 데이터를 공유하지 않거나 불변 데이터만 공유하는 것이다. 가변 데이터는 단일 스레드에서만 쓰도록 하자.

79. 과도한 동기화는 피해라

과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 심지어 예측할 수 없는 동작을 낳기도 한다.
응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다.
예를 들어 동기화된 영역 안에서는 재정의할 수 있는 메서드를 호출하면 안되며, 클라이언트가 넘겨준 함수 객체를 호출해서도 안된다.
이런 메서드는 예외를 일으키거나 교착상태에 빠지거나 데이터를 훼손할 수도 있다.

구체적인 예시들

public class ObservableSet<E> extends ForwardingSet<E> { public ObservableSet(Set<E> set) { super(set); } private final List<SetObserver<E>> observers = new ArrayList<>(); public void addObserver(SetObserver<E> observer) { synchronized (observers) { observers.add(observer); } } public boolean removeObserver(SetObserver<E> observer) { synchronized (observers) { return observers.remove(observer); } } private void notifyElementAdded(E element) { synchronized (observers) { for(SetObserver<E> observer : observers) { observer.added(this, element); } } } @Override public boolean add(E element) { boolean added = super.add(element); if(added) { notifyElementAdded(element); } return added; } @Override public boolean addAll(Collection<? extends E> c) { boolean result = false; for (E element : c) { result |= add(element); //notifyElementAdded를 호출 } return result; } } public static void main(String[] args) { ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>()); set.addObserver(new SetObserver<>() { public void added(ObservableSet<Integer> s, Integer e) { System.out.println(e); if (e == 23) s.removeObserver(this); } }); for (int i = 0; i < 100; i++) set.add(i;) }
Java
복사
관찰자들은 addObserverremoveObserver 메서드를 호출해 구독을 신청하거나 해지한다.
눈으로 보기에 ObservableSet은 잘 동작할 것 같지만 23까지 출력 후 ConcurrentModificationException을 던지게 된다.
관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다.
리스트에서 원소를 제거하려 하는데 마침 리스트를 순회하는 도중에 발생하는 문제다.
set.addObserver(new SetObserver<Integer>() { public void added(ObservableSet<Integer> s, Integer e) { System.out.println(e); if (e == 23) { ExecutorService exec = Executors.newSingleThreadExecutor(); try { exec.submit(() -> s.removeObserver(this)).get(); } catch (ExecutionException | InterruptedException ex) { throw new AssertionError(ex); } finally { exec.shutdown(); } } } });
Java
복사
쓸떼없이 백드라운드 스레드를 사용하는 관찰자
이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다.
백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 메인 스레드가 이미 락을 쥐고 있기 때문이다.
그와 동시에 메인 스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다리는 중이다. 바로 교착상태다!
해당 예제는 좀 억지스러운 예지만 보인 문제 자체는 진짜다.
실제 시스템에서도 동기화된 영역 안에서 외계인 메서드를 호출될 때 교착상태에 빠질 수 있다.
외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 해결된다.
혹은 관찰자 리스트를 복사해서 사용하면 락 없이 이용할 수 있다.

동기화의 기본 규칙

동기화 영역에서는 가능한 한 일을 적게하자

락을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고, 락을 놓는다.
오래 걸리는 작업이라면 아이템 78의 지침을 어기지 않으며 동기화 영역 바깥으로 옮기는 방법을 찾아보자.

과도한 동기화를 피해야하는 이유

멀티코어가 일반화된 오늘날, 과도한 동기화가 초래하는 문제는 락을 얻는데 드는 CPU 시간이 아니다.
바로 경쟁하느라 낭비하는 시간, 즉 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다.
가상머신의 코드 최적화를 제한한다는 점도 과도한 동기화의 또다른 숨은 비용이다.

가변 클래스 작성 가이드

첫 번째, 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자.
두 번째, 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.
단, 클라이언트가 외부에서 객체 전체에 락을 거는것보다 동시성을 월등하게 개선할 수 있을때만 두번째 방법을 선택한다.

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

실행자 프레임워크

java.util.concurrent 패키지는 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.
// 큐를 생성한다. ExecutorService exec = Executors.newSingleThreadExecutor(); // 태스크 실행 exec.execute(runnable); // 실행자 종료 exec.shutdown();
Java
복사

실행자 서비스의 주요 기능들

특정 태스크가 완료되기를 기다린다.
태스크 모음 중 아무것 하나(invokeAny 메서드) 혹은 모든 태스크(invokeAll 메서드)가 완료되기를 기다린다.
실행자 서비스가 종료하기를 기다린다.(awaitTermination 메서드)
완료된 태스크들의 결과를 차례로 받는다.(ExecutorCompletionService 메서드)
태스크를 특정 시간에 혹은 주기적으로 실행하게 한다.(ScheduledThreadPoolExecutor 이용)

실행자 서비스를 사용하기에 까다로운 예시

작은 프로그램이나 가벼운 서버라면 Excutors.newCachedThreadPool이 일반적으로 좋은 선택일 것이다.
특별히 설정할 게 없고 일반적인 용도에 적합하게 동작한다.
하지만 CachedThreadPool은 무거운 프로덕션 서버에는 좋지 못하다.
서버가 무겁다면 CPU 이용률이 100%로 치닫고, 새로운 태스크가 도작하는 족족 또 다른 스레드를 생성하며 상황을 더욱 악화시킨다.

실행자 서비스를 적극 사용하자

작업 큐를 손수 만드는 일을 삼가야 하고, 스레드를 직접 다루는것도 삼가야 한다.
스레드를 직접 다루면 Thread가 작업 단위와 수행 메커니즘 역할을 모두 수행하게 된다.
반면 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다.
태스트 수행을 실행자 서브스에 맡기면 원하는 태스크 수행 정책을 선택할 수 있고, 생각이 바뀌면 언제든 변경할 수 있다.

포크-조인 태스크

자바 7이 되면서 실행자 프레임워크는 포크-조인 태스크를 지원하도록 확장되었다.
포크-조인 태스크, 즉 ForkJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있고, ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하며, 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수도 있다.
이렇게 모든 스레드가 바삐 움직여 CPU를 최대한 활용하며 높은 처리량과 낮은 지연시간을 달성한다.
이러한 포크-조인 태스크를 직접 작성하고 튜닝하기란 어려운 일이지만, 포크-조인 풀을 이용해 만든 병렬 스트림을 이용하면 적은 노력으로 그 이점을 얻을 수 있다.
물론 포크-조인에 적합한 형태의 작업이어야 한다.

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

waitnotifiy는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자.
java.util.concurrent의 고수준 유틸리티는 세 범주로 나눌 수 있다.
실행자 프레임워크, 동시성 컬렉션, 동기화 장치다.

동시성 컬렉션

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

상태 의존적 수정 메서드

동시성 컬렉션의 동시성을 무력화하지 못하기 때문에 여러 메서드를 원자적으로 묶어 호출하는 것도 불가능하다.
그래서 여러 동작을 하나의 원자적 동작으로 묶는 상태 의존적 수정 메서드가 추가되었다.
예를 들면 putIfAbsent는 주어진 키에 매핑된 값이 없을 때만 새 값을 집어넣는다.
그리고 기존 값이 있으면 그 값을 반환하고 없는 경우에는 null을 반환한다.
private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>(); public static String intern(String s) { String result = map.get(s); if (result == null) { result = map.putIfAbsent(s, s); if (result == null) { result = s; } } return result; }
Java
복사
ConcurrentMap은 동시성이 뛰어나며 속도도 무척 빠르다.
이제는 Collections.synchronizedMap보다는 ConcurrentHashMap을 사용하는 것이 훨씬 좋다.
동기화된 맵을 동시성 맵으로 교체하는 것 하나만으로 성능은 극적으로 개선될 수 있다.

동기화 장치

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여 서로의 작업을 조율할 수 있게 해준다.
가장 자주 쓰이는 동기화 장치로는 CountDownLatch와 Semaphore다.
CyclicBarrier와 Exchanger는 그보다 덜 쓰이고 가장 강력한 동기화 장치는 Phaser다.
카운트다운 래치(latch; 걸쇠)는 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.
CountDownLatch의 유일한 생성자는 인자로 int 값을 받으다.
이 값은 래치의 countdown 메서드를 몇 번 호출해야 대기 중인 스레드들을 깨우는지 결정한다.
public class CountDownLatchTest { public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(5); try { long result = time(executorService, 3, () -> System.out.println("hello")); System.out.println(result); } catch (Exception e) { e.printStackTrace(); } finally { executorService.shutdown(); } } public static long time(Executor executor, int concurrency, Runnable action) throws InterruptedException { CountDownLatch ready = new CountDownLatch(concurrency); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(concurrency); for (int i = 0; i < concurrency; i++) { executor.execute(() -> { // 타이머에게 준비가 됐음을 알린다. ready.countDown(); try { // 모든 작업자 스레드가 준비될 때까지 기다린다. start.await(); action.run(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 타이머에게 작업을 마쳤음을 알린다. done.countDown(); } }); } ready.await(); // 모든 작업자가 준비될 때까지 기다린다. long startNanos = System.nanoTime(); start.countDown(); // 작업자들을 깨운다. done.await(); // 모든 작업자가 일을 끝마치기를 기다린다. return System.nanoTime() - startNanos; } }
Java
복사
동시 실행 시간을 재는 간단한 프레임워크
이 코드는 카운트다운 래치를 3개 사용한다.
ready 래치는 작업자 스레드들이 준비가 완료됨을 타이머 스레드에게 통지할 때 사용한다.
통지를 끝낸 작업자 스레드들은 두 번째 래치인 start가 열리기를 기다린다.
time에 넘겨진 executorconcurrency 매개변수로 지정한 값만큼의 스레드를 생성할 수 있어야 한다.
그렇지 못하면 메서드 수행이 끝나지 않는데 이를 스레드 기아 교착 상태라고 한다.
시간을 잴 때는 시스템 시간과 무관한 System.nanoTime을 사용하는 것이 더 정확하다.

wait과 notify메서드

새로운 코드라면 waitnotify가 아닌 동시성 유틸리티를 써야 한다.
wait 메소드는 스레드가 어떤 조건이 충족되기를 기다리게 할 때 사용하고 락 객체의 wait 메소드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야한다
synchronized (obj) { while (<조건 미충족>) { obj.wait(); // 락을 놓고, 깨어나면 다시 잡기 } ... // 조건 충족시 동작 수행 }
Java
복사
wait 메소드를 사용할 땐 반드시 대기 반복문(wait loop) 관용구를 사용하고 반복문 밖에서는 절대 호출하지 말자.
대기 전 조건을 검사해 조건이 이미 충족되었다면 wait을 건너뛰게 하는 것은 응답 불가 상태를 예방하는 조치다.
만약 조건이 충족되지 않았는데 스레드가 동작을 이어가면 락이 보호하는 불변식을 깰 위험이 있다.
조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황의 예시를 살펴보자
스레드가 notify를 호출한 다음 대기 중이던 스레드가 깨어나는 사이! 다른 스레드가 락을 얻어 동기화 블럭 안의 상태를 변화시킬 수 있다
조건이 만족되지 않았는데 다른 스레드가 실수 혹은 악의적으로 notify를 호출할 수 있다. 공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출되고, 외부에 노출된 객체의 동기화된 메소드 안에서 호출하는 wait는 모두 이 문제에 영향을 받는다.
깨우는 스레드의 관대함에, 대기 중인 스레드 중 일부만 조건이 충족되도 notifyAll을 호출해 모든 스레드를 깨울 수 있다.
대기중인 스레드가 notify 없이 깨어날 수 있는데 허위 각성(spurious wakeup) 현상이다.

notify vs notifyAll

notify는 스레드 하나만 깨우며, notifyAll은 모든 스레드를 깨운다.
일반적으론 언제나 notifyAll을 사용하는게 낫다.
모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족될 때마다 단 하나의 스레드만 헤택을 받는다면 notify를 사용해 최적화할 수 있다.

82. 스레드 안전성 수준을 문서화하라

메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API에 속하지 않는다.
멀티스레드 환경에서도 API를 안전하게 사용하려면 클래스가 지원하는 스레드 안정성 수준을 정확히 명시해야 한다.

스레드 안전성이 높은 수준으로 정리

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

동기화에 대한 문서화

/** * synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵을 락으로 사용해 수동으로 동기화하라. * Map<K, V> = Collections.synchronizedMap(new HashMap()); * Set<K> s = m.keySet(); // 동기화 블록 밖에 있어도 된다 * ... * synchronized (m) { // s가 아닌 m을 사용해 동기화해야 한다! * for(K key : s) *. key.f(); * } * 이대로 따르지 않으면 동작을 예측할 수 없다. */
Java
복사
Collections.synchronizedMap의 API의 문서
조건부 스레드 안전한 클래스는 주의하여 문서화해야 한다.
어떤 순서로 호출할 때 외부 동기화 로직이 필요한지, 그리고 그 순서대로 호출하려면 어떤 락 혹은 락들을 얻어야만 하는지 알려줘야 한다.
클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하지만, 독특한 특성의 메서드라면 해당 메서드의 주석에 기재하도록 하자.
반환 타입만으로 명확히 알 수 없는 정적 팩토리 메서드라면 자신이 반환하는 객체에 대한 스레드 안전성을 문서화해야 한다.

외부에 공개된 락

클래스가 외부에서 사용할 수 있는 락을 제공하면 클라이언트에게 일련의 메서드 호출을 원자적으로 수행할 수 있다.
하지만 이 유연성에는 대가가 따른다.
내부에서 처리하는 고성능 동시성 제어 메커니즘과 혼용할 수 없게 되는 것이다.
클라이언트가 공개된 락을 가지고 놓지 않는 서비스 거부 공격(denial-of-service attack)을 수행할 수 있다.
서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다.
synchronized 역시 공개된 락이나 마찬가지다.
private final Object lock = new Object(); public void someMethod() { synchronized(lock) { // do something } }
Java
복사
비공개 락 객체는 클래스 바깥에서는 볼 수 없으니 클라이언트가 그 객체의 동기화에 관여할 수 없다.
여기서 lock 멤버를 final로 선언한 이유는 우연히라도 락 객체가 교체되는 상황을 방지하기 위함이다.

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

지연 초기화는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다.
값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않는다.
지연 초기화는 주로 최적화 기법으로 사용되지만, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과가 있다.
다른 모든 최적화와 마찬가지로 “필요할 때 까지는 하지 말라”
클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄어들지만, 초기화된 각 필드의 호출 빈도에 따라 실제로 성능이 느려지게 할 수도 있다.
멀티스레드의 경우 지여 초기화가 까다롭다.
대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.

지연 초기화가 필요한 경우

해당 클래스의 인스턴스 중 그 필드를 사용하는 인스턴스의 비율이 낮은 반면, 그 필드를 초기화 하는 비용이 크다면 제 역할을 해줄 것이다.
지연 초기화 적용 전후의 성능 측정을 통해 확인해보자.

지연 초기화 방법

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

여러 스레드가 실행 중이면 운영체제의 스레드 스케줄러가 어떤 스레드를 얼마나 오래 실행할지 정한다.
정상적인 운영체제라면 이 작업을 공정하게 수행하지만 구체적인 스케줄링 정책은 운영체제마다 다를 수 있다.
정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.

견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법

실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다.
그래야 스레드 스케줄러가 고민할 거리가 줄어든다.
실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자.
이런 프로그램이라면 스레드 스케줄링 정책이 아주 상잏간 시스템에서도 동작이 크게 달라지지 않는다.
여기서 실행 가능한 스레드의 수와 전체 스레드 수는 구분해야 한다.

실행 가능한 스레드 수를 적게 유지하는 주요 기법

각 스레드가 무언가 유용한 작업을 완료한 후에는 다음 일거리가 생길 때까지 대기하도록 하는 것이다.
스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안 된다.
실행자 프레임워크를 예로 들면, 스레드 풀 크기를 적절히 설정하고 작업은 짧게 유지하면 된다.
단, 너무 짧으면 작업을 분배하는 부담이 오히려 성능을 떨어뜨릴 수도 있다.

Thread.yield 사용을 자제하자.

특정 스레드가 다른 스레드들과 비교해 CPU 시간을 충분히 얻지 못해서 간신히 돌아가는 프로그램을 보더라도 Thread.yield를 써서 문제를 고쳐보려는 유혹을 떨쳐내자.
Thread.yield는 테스트할 수단도 없다.