- 스레드와 잠금장치를 이용하는 프로그래밍은 원시적이고 어려우며, 안정성도 떨어지고 위험하다.
- 그럼에도 불구하고 스레드와 잠금장치는 많은 동시성 소프트웨어 작성에서 기본 선택이 되고 있으며, 때문에 그 동작 원리를 이해할 필요가 있다.
2.1 동작하는 가장 단순한 코드
- 스레드와 잠금장치는 실제 하드웨어가 동작하는 방식을 그대로 옮긴 것과 거의 동일하다.
2.2 상호배제와 메모리 모델
- 알아야 하는 개념 : 상호배제(mutual exclusion), 경쟁 조건(race condition), 데드락(deadlock), 메모리 모델
2.2.1 스레드 만들기
thread.start();
thread.join();
2.2.2 첫번째 잠금장치
- 여러 개의 스레드가 공유된 메모리에 접근할 때에는 서로 동작이 엉킬 수 있음
- 여러 스레드에서 잠금장치 없이 공유 메모리에 접근하면, 실행할 때마다 다른 결과를 얻게 됨
- 이는 스레드들이 메모리의 값을 읽을 때 발생하는 경쟁 조건 때문임
count++
의 경우 컴파일러가 만드는 바이크 코드는 다음과 같음
getfield #2 // count 값을 읽음
iconst_1
iadd // 1을 더함
putfield #2 // 결과가 count에 저장
- 읽기 -> 수정하기 -> 쓰기
- 여러 스레드가 같은 값을 읽어와 같은 값을 쓰기하면서 count 가 적절하게 증가되지 않음
- 우리는 한 번에 하나의 스레드만 보유할 수 있는 잠금장치를 사용해 이러한 상황을 피할 수 있음
- 자바에는 잠금장치가 내재되어 있음(mutex, monitor 혹은 critical section 이라고 불리는)
public synchronized void increment() { ++count; }
synchronized
키워드가 있는 메서드를 호출하면 잠금장치를 요구
- 리턴할 때는 잠금장치도 해제됨
- 한 번에 오직 하나의 스레드만 이 메서드를 호출할 수 있으며, 동시에 접근하는 다른 스레드들은 잠금장치가 해제될 때까지 블로킹됨
2.2.3 메모리의 미스터리
- 컴파일러는 코드가 실행되는 순서를 바꿈으로써 정적 최적화를 수행할 수 있다.
- JVM은 코드가 실행되는 순서를 바꿈으로써 동작 최적화를 수행할 수 있다.
- 코드를 실행하는 하드웨어도 코드의 순서를 바꾸는 것이 가능하다.
- 이는 공유 메모리를 사용하는 병렬 컴퓨터에 필요한 최적화 부분으로 이 부분을 받아들이고 다룰 수 있어야 함
- 어떤 부분까지 신뢰할 수 있는지 정해둔 규칙이 자바 메모리 모델
2.2.4 메모리 가시성
- 흔히 간과하는 사실 : 스레드가 모두 동기화 되어야 함
- 값을 변경하는 스레드 뿐만 아니라, 값을 조회하는 스레드도 동기화 되어야 한다.
- 잘못된 값을 조회할 수 있기 때문
2.2.5 여러 개의 잠금장치
- 멀티 스레드 환경에서 안정성을 보장하는 유일한 방법이 모든 메서드를 동기화 하는 것처럼 보일 수 있음
- 이 경우 효율성이 극도로 떨어짐
- 대부분의 스레드가 블로킹 된 상태에서 대기하게 될 것
- 추가로, 둘 이상의 스레드에서 동기화하는 경우 데드락이 발생할 가능성이 매우 높음
식사하는 철학자
- 철학자는 배가 고프면 양쪽의 젓가락을 집어올리고 한동안 식사한다.
- 식사가 끝나면 젓가락을 내려놓는다.
- 모든 철학자가 동시에 식사하기로 마음을 먹는 경우,
- 모두 자기 왼쪽의 젓가락을 들어올리고, 오른 쪽의 철학자가 젓가락을 내려놓기 전까지 동작을 멈추게 됨
- 그 순간은 영원히 오지 않음 = 데드락
- 데드락의 위험성은 어떤 스레드가 둘 이상의 잠금장치를 손에 넣으려고 할 때 반드시 존재하게 됨!
- 이를 해결하기 위해서는 잠금장치를 요청할 때 항상 정해진 공통의 순서를 따르면 됨
- 예를 들어, 왼쪽 -> 오른쪽의 순서로 젓가락을 집는 게 아니라, (젓가락 마다 숫자로 이루어진 고유한 ID가 주어지고) ID가 낮은 것부터 집는다고 가정
- 이렇게 하면 동작을 멈추는 일 없이 영원히 동작이 수행됨!
- Min(left, right) != Max(all)
- 처음 집는 젓가락은 ID 의 최댓값이 될 수 없음 -> 데드락이 발생하지 않음(누군가는 두번쨰로 최댓값을 집고 락을 해제할 것)
2.2.6 외부 메서드의 위험
- 하나의 장소에서 잠금장치를 얻는 경우, 공통 순서를 부과하는 것이 쉬움
- 하지만 커다란 프로그램에서는 모든 코드가 공통 규칙을 따르기 어려움
- 따라서 데드락을 방지하기 위해서는 다음의 원칙을 지킬 필요가 있음
어떤 잠금 장치를 보유하고 있는 상태에서는 외부 메서드를 호출하지 않는다.
private ArrayList<Listener> listenrs;
private synchronized void updateProgress(int n) {
for(Listener listener : listeners) {
listener.onProgress(n);
}
}
- 원칙에 따라 위의 코드는 아래와 같이 바꿀 수 있음
private ArrayList<Listener> listenrs;
private synchronized void updateProgress(int n) {
ArrayList<Listener> listenrsCopy;
synchronized(this) {
listenrsCopy = listeners.clone();
}
for(Listener listener : listenrsCopy) {
listener.onProgress(n);
}
}
- 리스너 목록에 락을 잡으면,
onProgress()
에서 데드락이 발생할 수 있음
- 반면,
clone()
중에만 락을 잡으면 데드락도 피하고 성능 상 이점도 가져갈 수 있음
- 락을 잡는 시간이 줄어들어 성능 상 이점이 있음
정리
- 공유 되는 변수에 대한 접근은 반드시 동기화한다.
- 이 때 쓰는 스레드 뿐만 아니라 읽는 스레드도 동기화 되어야 한다.
- 여러 개의 잠금장치를 동시에 사용하지 않는다.
- 동시에 사용하는 경우 데드락이 발생할 수 있다.
- 잠금 장치를 가진 상태에서는 외부 메서드를 호출하지 않는다.
- 여러 개의 잠금장치를 사용하는 경우 미리 정해진 공통의 순서에 따라 요청한다.
- 잠금장치는 최대한 짧게 보유한다.