자바병렬프로그래밍 5. 구성 단위 Building Blocks
이전 챕터에서
- thread-safe class를 어떻게 만드는지
- 기존에 존재하는 thread-safe class를 이용해 thread-safe를 위임하는 방법
이번에는
- 자바 5, 6에 포함된 클래스 위주로 살펴본다
이전 챕터 보기
5.1 Synchronized Collections
- Vector, HashTable
- Collections.synchronizedXXX
- 모두 public 함수 내부에 캡슐화해서 내부 값을 한 스레드만 접근하게 동기화 해놓았다
5.1.1 Problems with Synchronized Collections
public static Object getLast(Vector list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
Vector는 thread-safe class이지만, synchronizedXXX, Vector를 사용한다 하더라도, 여러개의 연산을 묶어 하나의 단일 연산처럼 활용해야할 필요성이 생겼다.
특히, list.get()에서 lastIndex가 달라져서, ArrayIndexOutOfBoundsException이 발생할 수도 있다.
synchronized를 이용하여 list(vector)에 락을 잡음으로써, size()와 get()의 두 액션을 하나의 액션으로 연결해주었다.
for (int i = 0; i < vector.size(); i++)
doSomething(vector.get(i));
이 코드 역시 size() 에서는 10이 나와서 10번의 for-loop을 돌지만, 그 사이에 다른 스레드에서 vector의 아이템을 지운다면 ArrayIndexOutOfBoundsException이 날 수 있다.
synchronized (vector) {
for (int i = 0; i < vector.size(); i++)
doSomething(vector.get(i));
}
이렇게 처리하면, size()를 얻어도고, doSomething()을 처리하는 동안 다른 스레드에서 조작할 수 없도록 할 수 있다. 하지만 이 코드가 동작하는 동안 vector object 내부 값을 읽고, 처리하는 모든 액션을 처리하는 스레드가 대기 상태에 들어간다. 이렇게 하면 병렬프로그램의 장점이 있을까?
5.1.2 Iterator 와 ConcurrentModificationException
Iterator를 통해 컬랙션 내부의 값을 탐색할 때, 아이템을 추가하거나 삭제하면 예제 5.3에서 보았던 문제가 발생할 수 있다. 이런 상황을 미리 포착하기 위해, fast-fail 즉시-멈춤으로 반응하도록 설계되어 있다.
즉, 컬랙션 클래스는 내부에 값 변경 횟수를 카운팅하는 변슈가 있고, 반복문이 실행되는 동안 변경 횟수 값이 바뀌면 hasNext()나 next() 에서 ConcurrentModificationException을 발생시킨다.
- 단일스레드환경에서도 ConcurrentModificationException이 발생할 수 있다, Iterator.remove 등의 메소드를 사용하지 않고, 컬랙션 내부 값을 제거하려고 하면 예외상황이 발생한다.
List<Widget> widgetList
= Collections.synchronizedList(new ArrayList<Widget>());
...
// May throw ConcurrentModificationException
// new type of For-loop in Java5
for (Widget w : widgetList)
doSomething(w);
여기 코드에, 동기화를 넣는다면, 여기 for-loop 전체에 widgetList로 동기화 시킬 수도 있겠지만, 단점이 있다.
- 컬랙션에 item에 많아서 소요되는 시간이 길어진다면,
- doSomething() 내부에서 다른 락을 사용한다면, 그 다른 락을 얻기위해 내부적으로 대기상태에 빠지고 데드락이 걸릴 수 있다.
반복문을 실행하는 동안 컬랙션에 락을 건 효과를 얻으려면, clone 메소드로 복사본을 만들어서 반복문을 사용하는 방법을 이용할 수도 있다. 하지만 clone도 효과적인지는 테스트해봐야 한다. 엄청나게 많은 양을 복사하는 것도 시간이 오래 걸리거나, 메모리를 많이 사용할 수도 있기 때문이다.
5.1.3 숨겨진 Iterator
HiddenIterator 클래스를 보면, iterator를 쓰는 부분이 없어보이지만, DEBUG용 로그를 찍으려고 스트링과 set을 연결하는 부분을 보자. 문자열 두 개를 +연산으로 연결하는데, 이 과정에서 컬랙션 클래스의 toString()을 호출하게 되고, 컬랙션 클래스의 toString() 소스코드를 보면 컬랙션의 iterator를 호출해 문자열을 만들고 있다.
즉, addTenThins()에서 ConcurrentModificationException이 발생할 수 있는 것이다. add() 자체는 동기화 되었지만, println()을 할 때는 동기화를 하는데 실패한 것이다.
이 클래스에서는 HashSet 보다는 동기화된 클래스를 사용하는 것이 더 나을 수도 있다.
- ConcurrentHashSet
- Collections.synchronizedSet(HashSet)
📕 toString() 뿐만 아니라 hashCode(), equals()도 내부적으로 iterator를 사용한다. containsAll(), removeAll(), retainAll(), 컬랙션 클래스는 파라미터로 받는 생성자들도 모두 iterator를 사용하므로, ConcurrentModificationException이 발생할 수 있다.
5.2 병렬 컬렉션 Concurrent Collections
자바 5.0 이전에는 synchronized collection(동기화된 컬렉션)으로 thread-safe를 구현했다. 하지만 이렇게 구현하면 여러 스레드에서 접근했을 때, 단 하나의 스레드만 작업할 수 있으므로 효율성이 떨어진다.
그 이후에 Concurrent Collections 이 나왔고, 이는 여러 스레드에서 동시에 사용할 수 있도록 설계되었다.
- Hash 기능과 병렬성을 확보한 ConcurrentHashMap
- List 클래스 하위로, 객체 목록을 반복하며 열람하는 연산의 성능을 최우선으로 구현한 CopyOnWriteArrayList
- ConcurrentMap 인터페이스는 putIfAbsent() — 없는 경우 새로 추가, replace, conditional remove — 조건부 제거 함수가 추가되었다
- Queue, BlockingQueue 컬렉션 추가
- ConcurrentLinkedQueue — 전통적인 FIFO큐
- PriorityQueue — 우선순위에 따라 큐의 순서가 바뀌는 특징을 갖고 있다
- 큐에서 뽑아낼 항목이 없을 때는 단순히 null을 리턴한다
- List를 대체해서도 구현할 수 있겠지만, Queue에 꼭 필요한 기능들을 갖고 있다.
- BlockingQueue 는 항목을 추가하거나 뽑아낼 때, 상황에 따라 Thread를 대기할 수 있게 한다 Producer-Consumer 패턴에 굉장히 편리하게 사용할 수 있다.
- ConcurrentSkipListMap과 ConcurrentSkipListSet → SortedMap과 SortedSet의 Concurrent 컬랙션으로 보면 된다
📗 synchronized collections를 concurrent collections로 교체하는 것만으로도 성능을 상당히 끌어올릴 수 있다.
5.2.1 ConcurrentHashMap
HashMap.get(), List.contains() 등 특정 아이템이 들어있는지를 확인하기 위해 모든 객체를 순서대로 탐색해야할 수도 있다.
Hash를 기반으로 하는 컬렉션이라도 특정 hash에 아이템들이 치우쳐져있다면, 결국 linkedList와 거의 동일한 형태로 탐색할 수도 있다는 뜻이다. 그리고 이 탐색과정에서 다른 스레드들은 기다려야한다.
synchornized map 은 모든 연산을 하나의 락을 사용해서, 하나의 스레드만 컬렉션에 접근할 수 있었다. 하지만 ConcurrentHashMap은 락스트라이핑 lock striping 기법을 사용한다(이 기법은 11.4.3에 소개된다). 이런 세밀한 기법으로 여러 스레드에서 컬렉션을 공유할 수 있게 되었고, 읽기/쓰기연산을 동시에 처리할 수 있다. 단일 스레드에서도 성능상의 단점을 찾아볼 수 없을 정도로 빨라졌다.
ConcurrentHashMap의 Iterator도 발전했다.
- ConcurrentModificationException이 발생하지 않는다. iterator를 할 때 따로 동기화할 필요가 없다.
- 여기서는 즉시 멈춤 fast-fail 전략이 아닌 미약한 일관성 weakly consistent 전략을 사용했다.
- weakly consistent
- 반복문과 동시에 컬렉션 내용이 변경되어도 iterator를 만든 시점 상황으로 작업한다
- iterator를 만든 시점 이후에 변경된 내용을 반영해 동작할 수도 있다
단점은
- size(), isEmpty() 값이 정확하지 않을 수 있다는 점이다. get(), put(), containsKey(), remove() 등 핵심연산의 병렬성과 성능을 향상하려면
- 또 하나, 컬렉션 자체를 독점적으로 사용할 수 없다는 점이다. 단일 연삼으로 여러개의 값을 Map에 넣고자 한다거나 Map의 내용을 반복하여 아이템의 순서가 바뀌지 않고 복사해야한다거나
5.2.2 Map 기반에 추가된 단일 연산들 Additional Atomic Map Operations
ConcurrentHashMap 클래스는 락을 사용할 수가 없다. putIfAbsent()와 같은 ‘없을 경우에만 추가’ 같이 여러 개의 액션을 단일 연산으로 만들고자할 때, client-side locking을 활용할 수 없다. 4.4장
ConcurrentHashMap에는 putIfAbsent(), removeIfEqual(), replaceIfEqual() 같이 자주 사용하는 몇가지 연산이 이미 구현되어 있다. ConcurrentHashMap은 ConcurrentMap을 구현하고 있다.
5.2.3 CopyOnWriteArrayList
synchronized list 보다 병렬성을 높인 자료구조이다.
기존에는 List에 들어있는 값을 Iterator로 사용할 때 List 전체에 락을 걸거나(synchronizedXXX) 혹은 아이템을 전부 복사해서 썼었다.
“변경할 때마다 복사하는 컬렉션 클래스”는 불변 아이템 리스트를 외부에 공개하여 여러 스레드가 동시 작업하더라도 동기화 작업이 필요없었다.
만약 CopyOnWriteArrayList에서 Iterator를 뽑아내 사용한다면,
- 뽑아내는 시점의 collection items 기준으로 iterator를 만들고
- iterate 하는 동안 추가되거나 삭제되는 아이템은 복사본을 대상으로 반영된다
- 즉, 동시 사용해도 문제가 없다.
- 결국 iterator를 사용할 때 ConcurrentModificationException이 발생하지 않는다
CopyOnWriteArrayList는 add, remove 등 데이터가 변경될 때 lock을 이용하여 새로운 데이터셋을 만든 후 데이터를 조작하고 setArray()로 원본 데이터를 교체한다.
그리고 Iterator로 읽을 때는 따로 락을 사용하지 않는다.
많은 양의 데이터가 들어있다면 메모리 손실이 클 수도 있다, 결국 데이터 조작보다는 읽는 작업이 많다면 효과적일 것이다.
책에서 예로든 것은, 이벤트 리스너를 여러개 등록할 때, 리스너를 자주 등록하고 삭제하지는 않지만 리스너를 iterate 하는 시점이 많으므로 이점이 많다고 설명한다.
5.3 blockingQueue와 producer-consumer pattern
blocking queue는 put(), take() 라는 핵심 메서드가 있다.
offer(), poll()
- 큐가 가득차 있다면, put() 은 공간이 생길 때까지 대기한다.
- 큐가 비어있다면, take() 값이 들어올 때까지 대기한다.
- 만약 queue 크기가 제한이 없다면, put()이 대기하는 일은 없다.
blocking queue는 Producer-Consumer Pattern을 구현하기에 좋다.
- 해야할 작업을 만들어내는 주체(Producer) 와 작업을 처리하는 주체(Consumer) 를 분리시키는 설계 방법
- 이렇게 명확하게 나누면, 개발과정을 명확하고 단순화 시킬 수 있고
- 작업을 생성하는 부분과 처리하는 부분을 각각 부하 조절을 할 수 있다는 장점이 있다.
- 즉, 큐와 함께 스레드풀을 사용하면 Producer-Consumer 패턴의 가능 흔한 경우
take() — 값이 들어올 때까지 멈추고 대기하기 때문에 컨슈머 코드를 작성하기 편하다
put() — 프로듀서가 컨슈머가 감당할 수 있는 것보다 많은 양의 작업을 만들어내면 해당 애플리케이션의 큐에 계속 쌓여, 메모리 오류가 발생할 수 있다. → 결국 큐의 크기를 제한하면 put() 메소드에서 프로듀서가 대기하므로 역시, 프로듀서 코드를 작성하기에 훨씬 편해진다.
offer() — 큐에 값을 넣을 수 없을 때, 대기하지 않고 공간이 모자라 추가할 수 없다는 오류를 낸다. 이렇게 하면 디스크에 임시로 저장하거나 프로듀서의 스레드 수를 줄이는 등 .. 프로듀서의 여러 가지 동작을 정의할 수 있다.
5.5.3 Semaphore로 블로킹큐를 쉽게 적용할 수 없을 때, 사용하면 좋을 데이터 구조
FIFO — LinkedBlockingQueue, ArrayBlockingQueue 기존 synchronizedList()로 linkedList나 arraylist를 사용하는 것보다 병렬성 성능이 좋다.
PriorityBlockingQueue는 우선순위 기준으로 동작한다 — Comparator를 사용하여 정렬시킬 수 있다.
SynchronousQueue 클래스 — 큐에 항목이 쌓이지않으며, 큐 내부에 아이템을 저장하는 공간도 없다. 대신, 값을 추가하려는 스레드와 값을 읽어가려는 스레드를 관리한다. 즉, 프로듀서 스레드는 컨슈머에게 직접 아이템을 전달해준다 .. 컨슈머가 데이터를 받을 때까지 프로듀서는 기다릴 수 밖에 없다. 하지만 데이터가 넘어가는 순간은 굉장히 짧아진다. 그리고 컨슈머에게 직접 데이터를 전달하므로 누구에게 전달했는지, 그 데이터가 처리되었는지 등등의 데이터도 알 수 있다는 장점이 있다.
5.3.1 예제 : 데스크탑 검색
로컬 디스크에 들어있는 문서를 전부 읽어두어 나중에 검색하게 좋게 색인을 만드는 작업을 만들어본다
FileCrawler는 fileQueue, fileFilter, root를 받고(아마도 생성자에서 받을 것 같다), root 부터 탐색을 하면서 fileFilter에 맞는 파일들은 fileQueue에 파일을 넣어두는 프로듀서 역할을 담당한다.
그리고 Indexer는 FileCrawler와 같은 fileQueue를 받았을테고, queue에서 계속 꺼내서 indexFile()이라는 작업을 하는 컨슈머 역할을 한다.
이런 프로듀서-컨슈머패턴은
- 이렇게 파일을 크롤링하는 작업하고, 인덱싱하는 작업을 나눠두면 코드의 가독성이 올라간다
- 두개의 기능을 하나의 클래스로 구현할 때보다 재사용성이 올라간다
- 성능적인 측면
- 프로듀서가 네트워크나 IO에 시간을 많이 소모하고, 컨슈머는 CPU를 많이 사용한다면 이런 기능을 하나의 클래스 혹은 하나의 스레드로 작동하게 개발했다면, 프로듀서가 동작할 때는 CPU가 일처리를 하지 않고, 컨슈머가 동작할 때는 네트워크나 IO가 일처리를 하지 않게 된다.
5.3.2 직렬 스레드 한정 Serial Thread
프로듀서-컨슈머 패턴과 blocking queue는 가변 객체 mutable object를 넘기는 과정에서 직렬 스레드 한정 Serial Thread Confinement 기법을 사용한다.
스레드 한정 → 특정 스레드만 객체를 소유할 수 있는 기법
프로듀서 스레드에서 만든 객체 object는 컨슈머 스레드로 소유권이 이전되며, 프로듀서 스레드는 소유권을 완전히 잃는다.
객체 풀 object pool 은 직렬 스레드 한정 기법 풀 내부에 소유하던 객체를 외부에 공개할 때 적절한 동기화 작업이 되어 있고, 객체를 빌려다 사용하는 스레드 역시 빌려온 객체를 적절하게 사용하고 다시 pool 에 반납한다면 소유권이 이전되었다가 잘 돌아오는 것을 확인할 수 있다.
항상 소유권을 이전받는 스레드는 단 하나여야 한다.
이런 작업은 ConcurrentMap, AtomicReference로 처리할 수 있다. → 나중에 자세히 나온다
5.3.3 덱, 작업 가로채기 Deques, Work Stealing
Deque는 앞, 뒤 어느 쪽에서도 객체를 쉽게 삽입하거나 제거할 수 있는 큐 → ArrayDeque, LinkedBlockingDeque
작업 가로채기 Work Stealing Pattern을 구현할 때 쓰인다.
프로듀서-컨슈머 패턴은 하나의 queue를 공유해서 사용하지만, Work Stealing Pattern에서는 컨슈머가 본인의 dequeu를 갖는다. 만약 특정 컨슈머가 본인의 일을 다 처리했다면, 다른 컨슈머의 마지막 deque에서 작업을 꺼내올 수 있도록 설계할 수 있다. 앞이 아니라 뒤에서 작업을 가져오기 때문에 원래 컨슈머와 경쟁하지 않아도 된다.
또한, 컨슈머가 작업을 처리하다가 도중에 처리해야할 일이 생긴다면(예를 들어, 웹크로링 중에 다른 링크가 등장한다면) 자신의 덱에 새로운 작업을 추가하면 된다.
5.4 블로킹 메소드, 인터럽터블 메소드 Blocking and Interruptible Methods
스레드는 블록당하거나 멈춰질 수 있다.
- IO 작업이 끝나기를 기다릴 때도 있고
- 락을 확보하기 위해 기다릴 때도 있고
- Thread.sleep()
- 다른 스레드가 작업 중인 결과를 기다릴 수도
스레드가 블록되면 다음 스레드 상태 중 하나를 갖는다.
BLOCKED, WAITING, TIMED_WAITING
블로킹 메소드 blocking method는 멈춘 상태에서 특정한 신호(위에서 이야기했던 상황이 끝나기를 기다리는)를 받아서 RUNNALBE 상태로 넘어갈 수 있다.
BlockingQueue 의 put(), take()는 Thread.sleep()과 같이 InterruptedException이 발생할 수 있다. InterruptedException이 발생할 수 있는 메소드들은 결국 blocking method라는 것을 알 수 있다. 이렇게 인터럽트가 걸리면 blocking 상황에서 깨어나야 한다는 의미이다.
Thread에는 작업을 중단시킬 수 있는 interrupt() 메소드가 있고, interrupt()를 호출하면 isInterrupted()는 true를 반환한다.
Runnable을 Thread에 넣어서 스타트를 시켰을 때, queue.take() 에서 InterruptedException이 발생하면 현재 Thread에 interrupt 가 발생했다고 알려주고 있다. 만약 여기서 Thread.currentThread().interrupt()를 실행해주지 않으면 해당 Thread는 interrupt가 있었다는 것을 모르고 계속 Thread를 진행시킨다.
5.5 동기화 클래스 Synchronizers
여기서는 이런 컨셉의 동기화를 도와주는 class들이 있다고만 알아두면 좋을 것 같다.
모든 동기화 클래스들은 스레드가 어떤 경우에 통과할 수 있고, 어떤 경우에 대기하게 하는지 결정하는 상태정보를 갖고 있다. 그 상태를 변경/조회/대기를 할 수 있는 메소드를 제공한다.
5.5.1 래치 Latch
래치는 틀정한 단일 동작이 완료되기 이전에는 어떤 기능도 동작하지 않도록 막아내야 하는 경우에 요긴하게 쓰인다.
- binaryLatch 는 어떤 액션이 끝나기를 기다릴 때, 그 액션이 끝나기를 여러 스레드가 기다리는 상황이라면 적합하게 쓰일 수 있다.
- 혹은 여러명의 사용자가 게임을 할 준비가 끝났는지 확인해야할 때
CountDownLatch — 여러 스레드가 여러 개의 이벤트가 일어날 때까지 대기해야할 때
TestHarness에는 2개의 랫치가 쓰인다.
- startGate → nThreads 개의 스레드들이 startGate가 열리기를 기다린다.
- endGate → 여러 개의 스레드들 중 끝난 스레드들이 endGate.countDown()을 한다. endGate는 n개의 스레드가 모두 끝나기를 기다리게 된다.
- 그리고 모든 스레드가 동작이 얼마나 걸렸는지를 계산한다.
5.5.2 FutureTask
FutureTask는 Runnable을 상속받았고, 연산결과를 갖고있는 Callable<V>, 상태를 나타내는 state를 맴버변수로 갖고 있다.
그리고 get() 함수로 결과를 기다릴 수 있는 blocking method가 있다.
FutureTask에 Callable을 하나 만들어 추가했고, 결과로 ProductInfo를 받는다. Thread에 future를 실행시키면 future.get()에서 실행이 끝날 때까지 blocking으로 기다렸다가 결과가 나오면 반환한다. (그래서 InterruptedException을 명시했다)
여기서 중요한 부분은, Callable은 예외가 발생할 때, ExecutionException으로 한 번 감싸서 throw 한다. 이 부분이 exception 처리할 때 약간 귀찮지만, 왜 exception이 발생했는지 cause로 한 번 체크해야한다.
그리고 RuntimeException을 던지기위해 한 번 체크한 후 넘겨준다.
5.5.3 세마포어 Semaphore
특정 연산을 동시에 사용할 수 있고, 동시에 작업할 수 있는 스레드 숫자를 제한할 수 있다.
자원 풀 pool, 컬렉션 크기에 제한을 둘 때
- 생성자에 퍼밋 permit 숫자를 넘겨준다.
- acquire — permit 을 얻고 작업을 시작한다. 예를 들어, bound = 10이라면 aquire()를 호출하면 9개의 permit이 남는다. 그리고 가져갈 permit 이 없으면 aquire()에서 permit이 생길때까지 기다린다.
- release — 가져간 permit을 반납한다.
BoundedHashSet을 사용하면 HashSet 자체 사이즈를 주지 않아도 Semaphore의 카운트와 동일하게 유지할 수 있다.
5.5.4 배리어 barrier
Latch는 카운트를 세팅한 다음에 countdown()을 하면서 결국 터미널 상태(종료)가 되고 다시 이전상태로 회복할 수 없다.
배리어 Barrier는 Latch와 비슷한데,
- Latch는 이벤트가 발생하기를 기다리는 동기화 클래스
- barrier는 다른 스레드들을 기다리기 위한 동기화 클래스
CellularAutomata는 Board라는 객체를 받는다.
생성자에서 count는 현재 디바이스에서 사용할 수 있는 cpu의 갯수로 세팅이 되고, CyclicBarrier에 카운트와 카운트만큼의 작업이 끝나면 실행할 runnable을 받는다.
그리고 worker들이 본인들의 작업이 끝나면 barrier.await()을 호출하는데, cyclicBarrier는 barrier.await()이 카운트만큼 호출되기를 기다렸다가 모든 스레드들이 작업이 끝나면 mainBoard.commitNewValues() 를 호출하게 되는 것이다.
5.6 효율적이고 확장성 있는 결과 캐시 구현 Building an Efficient, Scalable Result Cache
우리가 이전에 구현했던 인수분해 클래스에서 사용할 캐시를 HashMap으로 어떻게 구현하는지 확인해보자.
Computable<A,V> 입력값 A와 리턴값 V를 정의하고 있다.
그리고 ExpensiveFunction은 Computable를 구현하고 있고, compute에서 계산이 오래걸린다고 가정해보자. Memorizer1은 HashMap을 캐시로 갖고 있고, compute() 할 때 캐시가 있으면 값을 내어주고, 아니면 계산을 한다. 하지만 compute()는 synchronized 로 걸려있어서 메소드 전체를 동기화 시키는 정책을 사용했다. 즉, 한 번에 한 스레드만 작업할 수 있다.
Memorizer2는 cache를 ConcurrentHashMap을 사용하므로써 동기화 정책을 위임했다. 하지만 compute() 에서는 get(), put()은 단일 연산이 되지 않으므로 아래 그림처럼 compute()가 여러번 될 수 있다.
그럼 이제, ConcurrentHashMap<A, Future<V>>로 바꾸고, 작업이 시작했는지를 체크해볼 수 있다.
하지만 여기서도 cache.get(arg)을 동시에 두번 했을 때 FutureTask가 2개가 생길 수 있다.
마지막 Memorizer에서는 ConcurrentHashMap에 있는 putIfAbsent 함수를 써서 FutureTask가 2개가 만들어질 수는 있지만 하나만 실행되게 작업을 했다.
while(true) loop을 도는 이유는, 코드가 정상적으로 처리되면 딱 한번만 실행되고 return f.get() 으로 리턴된다. 하지만 만약 CancellationException이 나온다면 cache.remove()가 실행되고, 다시 정상적인 결과를 받아서 캐시하기 위해서다.
여기까지 어떻게 thread-safe class, thread-safe method, thread-safe variable을 만드는지에 초점이 맞춰져있었다. 이제 다음 챕터부터는 어떻게 thread를 생성해서 쓰는 것이 올바른지에 대해 배운다.