14.1. 상태 종속성 관리
-
병렬 객체의 상태 종속적인 메소드는 선행 조건이 만족하지 않았을 때 오류가 발생하는 문제에서 비켜날 수도 있겠지만,
비켜나는 일보다는 선행 조건을 만족할 때까지 대기하는 경우가 많아진다.
-
자바에 내장된 조컨 큐 메커니즘(condition queue mechanism)은 실행 중인 스레드가 특정 객체가 원하는 상태에 진입할 때까지 대기할 수 있도록 도와주며, 원하는 상태에 도달해서 스레드가 계속해서 실행할 수 있게 되면 대기 상태에 들어가 있던 스레드를 깨워주는 역할도 담당한다.
-
일단 선행 조건을 만족하지 않았다면 락을 다시 풀어줘야 다른 스레드에서 상태 변수를 변경할 수 있다.
만약 락을 풀어주지 않고 계속 잡고 있다면 다른 스레드에서 상태 변수의 값을 변경할 수 없기 때문에
선행 조건을 영원히 만족시키지 못한다.
-
상태 종속적인 작업의 동기화 구조
void blockingAction() throws InterruptedException{
상태 변수에 대한 락 확보
while( 선행 조건이 만족하지 않음 ){
확보했던 락을 풀어줌
선행 조건이 만족할만한 시간만큼 대기
인터럽트에 걸리거나 타임아웃이 걸리면 멈춤
락을 다시 확보
}
작업 실행
락 해제
}
-
프로듀서-컨슈머 패턴으로 구현된 앱에서는 ArrayBlockingQueue 와 같이 크기가 제한된 큐를 많이 사용한다.
크기가 제한된 큐는 put 과 take 메소드를 제공하며, put 과 take 메소드에는 다음과 같은 선행조건이 있다.
버퍼 내부가 비어 있다면 값을 take 할 수 없고, 버퍼가 가득 차 있다면 값을 put 할 수 없다.
상태 종속적인 메소드에서 선행 조건과 관련한 오류가 발생하면 예외를 발생시키거나 오류 값을 리턴하기도 하고,
아니면 선행 조건이 원하는 상태에 도달할 때까지 대기하기도 한다.
** 14.1.3. 조건 큐 - 문제 해결사
-
Object.wait 메소드는 현재 확보하고 있는 락을 자동으로 해제하면서 운영체제에게 현재 스레드를 멈춰달라고 요청하고,
따라서 다른 스레드가 락을 확보해 객체 내부의 상태를 변경할 수 있도록 해준다.
대기 상태에서 깨어나는 순간에는 해제했던 락을 다시 확보한다.
14.2. 조건 큐 활용
** 14.2.1. 조건 서술어
-
조건 큐를 올바로 사용하기 위한 가장 핵심적인 요소는 바로 해당 객체가 대기하게 될 조건 서술어(predicate)를 명확하게 구분해내는 일이다.
조건 서술어는 애초에 특정 기능이 상태 종속적이 되도록 만드는 선행 조건을 의미한다.
크기가 제한된 버퍼를 예로 들면 take 메소드는 버퍼에 값이 들어있는 경우에만 작업을 진행할 수 있고, 버퍼가 비어 있다면 대기해야 한다.
그러면 take 메소드의 입장에서는 작업을 진행하기 전에 확인해야만 하는 "버퍼에 값이 있어야 한다" 는 것이 조건 서술어이다.
조건 서술어는 클래스 내부의 상태 변수에서 유추할 수 있는 표현식이다.
-
조컨 큐와 연결된 조건 서술어를 항상 문서로 남겨야 하며,
그 조건 서술어에 영향을 받는 메소드가 어느 것인지도 명시해야 한다.
-
조건 서술어는 상태 변수를 기반으로 하고 있고, 상태 변수는 락으로 동기화돼 있으니
조건 서술어를 만족하는지 확인하려면 반드시 락을 확보해야만 한다.
또한 락 객체와 조건 큐 객체(wait 와 notify 메소드를 호출하는 대상 객체 ) 는 반드시 동일한 객체여야만 한다.
-
wait 메소드를 호출하는 모든 경우에는 항상 조건 서술어가 연결돼 있다.
특정 조건 서술어를 놓고 wait 메소드를 호출할 때,
호출자는 항상 해당하는 조건 큐에 대한 락을 이미 확보한 상태여야 한다.
** 14.2.2. 너무 일찍 깨어나기
-
wait 메소드를 호출하고 리턴됐다고 해서 반드시 해당 스레드가 대기하고 있던 조건 서술어를 만족한다는 것은 아니다.
-
wait 메소드는 누군가가 notify 해주지 않아도 리턴되는 경우까지 있다.
-
조건부 wait 메소드(Object.wait 또는 Condition.wait)를 사용할 때에는..
* 항상 조건 서술어(작업을 계속 진행하기 전에 반드시 확인해야 하는 확인 절차) 를 명시해야 한다.
* wait 메소드를 호출하기 전에 조건 서술어를 확인하고, wait 에서 리턴된 이후에도 조건 서술어를 확인해야 한다.
* wait 메소드는 항상 반복문 내부에서 호출해야 한다.
* 조건 서술어를 확인하는 데 관련된 모든 상태 변수는 해당 조건 큐의 락에 의해 동기화돼 있어야 한다.
* wait, notify, notifyAll 메소드를 호출할 때는 조건 큐에 해당하는 락을 확보하고 있어야 한다.
* 조건 서술어를 확인한 이후 실제로 작업을 실행해 작업이 끝날 때까지 락을 해제해서는 안 된다.
** 14.2.3. 놓친 신호
-
놓친 신호(missed signal)
특정 스레드가 이미 참을 만족하는 조건을 놓고 조건 서술어를 제대로 확인하지 못해 대기 상태에 들어가는 상황을 놓친 신호라고 한다.
즉 놓친 신호 문제가 발생한 스레드는 이미 지나간 일에 대한 알림을 받으려 대기하게 된다.
-
이런 놓친 신호 현상이 발생하는 원인은 스레드에 대한 알림이 일시적이라는 데에 있다.
wait 메소드를 호출하기 전에 조건을 확인하는 부분을 앞선 방법대로 작성하면 놓친 신호 문제에 대해서는 걱정하지 않아도 된다.
** 14.2.4. 알림
-
특정 조건을 놓고 wait 메소드를 호출해 대기 상태에 들어간다면,
해당 조건을 만족하게 된 이후에 반드시 알림 메소드를 사용해 대기 상태에서 빠져나오도록 해야 한다.
-
대기상태에 들어간 조건이 서로 다를 수 있기 때문에 notifyAll 대신 notify 메소드를 사용해 대기 상태를 풀어주는 방법은 위험성이 높다.
단 한번만 알림 메시지를 전달하게 되면 앞서 소개했던 "놓친 신호" 와 유사한 문제가 생길 가능성이 높다.
-
notifyAll 대신 notify 메소드를 사용하려면 다음과 같은 조건에 해당하는 경우에만 사용하는 것이 좋다.
단일 조건에 따른 대기 상태에서 깨우는 경우
해당하는 조건 큐에 단 하나의 조건만 사용하고 있는 경우이고, 따라서 각 스레드는 wait 메소드에서 리턴될 때 동일한 방법으로 실행된다.
한 번에 하나씩 처리하는 경우
조건 변수에 대한 알림 메소드를 호출하면 하나의 스레드만 실행시킬 수 있는 경우
-
notifyAll 을 사용하는게 비효율적이라고 볼 수 있다.
** 12.5. 조건부 알림(conditional notification)
-
조건부 알림 방법을 사용하면 성능은 향상시킬 수 있겠지만 제대로 동작하도록 만드는 과정은 꽤나 복잡하고 섬세한 면이 있다.
따라서 조건부 알림 방법은 굉장히 조심스럽게 사용해야 한다.
-
단일 알림 방법이나 조건부 알림 방법은 일반적인 방법이라기보다는 최적화된 방법이다.
단일 알림 방법이나 조건부 알림 방법을 사용하기 전에 항상 그랬던 것처럼
"일단 제대로 동작하게 만들어라. 그리고 필요한 만큼 속도가 나지 않는 경우에만 최적화를 진행하라" 는 원칙을 먼저 지킬 필요가 있다.
최적화 방법을 적절치 못하게 적용하고 나면 이상하게 발생하는 프로그램 오류를 만나게 될지도 모른다.
** 14.2.6. 하위 클래스 안전성 문제
-
조건부 알림 기능이나 단일 알림 기능을 사용하고 나면 해당 클래스의 하위 클래스를 구현할 때 상당히 복잡해지는 문제가 생길 수 있다.
일단 하위 클래스를 구현할 수 있도록 하려면 상위 클래스를 구현할 때 상위 클래스에서 구현했던 조건부 또는 단일 알림 방법을 벗어나는 방법을 사용해야만 하는 경우가 있을 수 있으며,
이런 경우에 상위 클래스 대신 하위 클래스에서 적절한 알림 방법을 사용할 수 있도록 구조를 갖춰둬야 한다.
-
상태 기반으로 동작하는 클래스는 하위 클래스에게 대기와 알림 구조를 완전하게 공개하고 그 구조를 문서로 남기거나,
아니면 아예 하위 클래스에서 대기와 알림 구조에 전혀 접근할 수 없도록 깔끔하게 제한해야 한다.
-
최소한 상태 기반으로 동작하면서 하위 클래스가 상속받을 가능성이 높은 클래스를 구현하려면
조건 큐와 락 객체 등을 하위 클래스에게 노출시켜 사용할 수 있도록 해야 하고,
그와 함께 조건과 동기화 정책 등을 문서로 남겨둬야 한다.
그러다보면 조건 큐와 락 객체뿐만 아니라 상태 변수 자체를 하위 클래스에게 열어줘야 할 가능성도 있다.
(상태 기반의 클래스를 구현할 때 저지를 수 있는 가장 큰 잘못은 클래스 내부의 상태를 하위 클래스가 볼 수 있도록 열어둔 상태에서 대기하거나 알림으로 깨어나는 규칙을 전혀 설명하지 않는 것이다.
이런 상황은 클래스의 상태 변수를 외부에 노출시켜두고 그에 대한 사용 조건을 전혀 명시하지 않는 것과 같다.)
-
클래스를 상속받는 과정에서 발생할 수 있는 오류를 막을 수 있는 간단한 방법 가운데 하나는 클래스를 final 로 선언해 상속 자체를 금지하거나
조건 큐, 락, 상태 변수 등을 하위 클래스에서 접근할 수 없도록 막아두는 방법이 있다.
** 14.2.7. 조건 큐 캡슐화
-
일반적으로 조건 큐를 클래스 내부에 캡슐화해서 클래스 상속 구조의 외부에서는 해당 조건 큐를 사용할 수 없도록 막는 게 좋다.
** 14.2.8. 진입 규칙과 완료 규칙
-
wait 와 notify 를 적용하는 규칙을 진입 규칙과 완료 규칙으로 표현할 수 있다.
상태를 기반으로 하는 모든 연산과 상태에 의존성을 갖고 있는 또 다른 상태를 변경하는 연산을 수행하는 경우에 항상 진입 규칙과 완료 규칙을 정의하고 문서화해야 한다.
진입 규칙은 해당 연산의 조건을 뜻한다.
완료 규칙은 해당 연산으로 변경됐을 모든 상태 값이 변경되는 시점에 다른 연산의 조건도 함께 변경됐을 가능성이 있으므로,
만약 다른 연산의 조건도 함께 변경됐다면 해당 조건 큐에 알림 메시지를 보내야 한다는 규칙이다.
14.3. 명시적인 조건 객체
-
암묵적인 락을 일반화시킨 형태가 Lock 클래스인 것처럼
암묵적인 조건 큐를 일반화한 형태는 바로 Condition 클래스이다.
-
암묵적인 조건 큐에는 여러 가지 단점이 있다.
모든 암묵적인 락 하나는 조건 큐를 단 한나만 가질 수 있다.
여러개의 스레드가 하나의 조건 큐를 놓고 여러 가지 조건을 기준으로 삼아 대기 상태에 들어갈 수 있다는 말이다.
그리고 락과 관련해 가장 많이 사용되는 패턴을 보면 바로 조건 큐 객체를 스레드에게 노출시키도록 돼 있다.
이 경우 단일 대기 조건을 만족시키기가 불가능하다.
-
암묵적인 락이나 조건 큐 대신 Lock 클래스와 Condition 클래스를 활용하면 여러 가지 종류의 조건을 사용하는 병렬 처리 객체를 구현하거나 조건 큐를 노출시키는 것에 대한 공부를 할 때 훨씬 유연하게 대처할 수 있다.
-
암묵적인 조건 큐가 암묵적인 락 객체를 사용해 동기화하는 것처럼
Condition 클래스 역시 내부적으로 하나의 Lock 클래스를 사용해 동기화를 맞춘다.
Condition 인스턴스를 생성하려면 Lock.newCondition 메소드를 호출한다.
-
Lock 클래스가 암묵적인 락보다 훨씬 다양한 기능을 제공하는 것처럼
Condition 클래스 역시 하나의 락에 여러 조건으로 대기하게 할 수 있고
또한 인터럽트에 반응하거나 반응하지 않는 대기 상태,
데드라인을 정해준 대기 상태,
공정하거나 공정하지 않은 큐 처리 방법 등 암묵적인 조건 큐보다 훨씬 다양한 기능을 제공한다.
-
Condition 객체는 암묵적인 조건 큐와 달리 Lock 하나를 대상으로 필요한 만큼 몇 개라도 만들 수 있다.
Condition 객체는 자신을 생성해준 Lock 객체의 공정성을 그대로 물려받는데,
이를테면 공정한 Lock 에서 생성된 Condition 객체의 경우에는 Condition.await 메소드에서 리턴될 때 정확하게 FIFO 순서를 따른다.
-
위험성 경고!!
암묵적인 락에서 사용하던 wait, notify, notifyAll 메소드의 기능은 Condition 클래스에서는 각각 await, signal, signalAll 메소드이다.
자바에서 모든 클래스가 그렇지만 Condition 클래스 역시 Object 를 상속받기 때문에
Condition 객체에도 wait, notify, notifyAll 메소드가 포함돼 있다.
따라서 실수로 await 대신 wait 메소드를 사용하거나 signal 대신 notify 메소드를 사용하면 동기화 기능에 큰 문제가 생길 수 있다.
-
하나의 암묵적인 조건 큐를 사용해 여러 개의 조건을 처리하느라 복잡해지는 것보다
조건별로 각각의 Condition 객체를 생성해 사용하면 클래스 구조를 분석하기도 쉽다.
Condition 객체를 활용하면 대기 조건들을 각각의 조건 큐로 나눠 대기하도록 할 수 있기 때문에 단일 알림 조건을 간단하게 만족시킬 수 있다.
따라서 signalAll 대신 그보다 더 효율적인 signal 메소드를 사용해 동일한 기능을 처리할 수 있으므로,
컨텍스트 스위치 횟수도 줄일 수 있고 버퍼의 기능이 동작하는 동안 각 스레드가 락을 획보하는 횟수 역시 줄일 수 있다.
-
암묵적인 락이나 조건 큐와 같이 Lock 클래스와 Condition 객체를 사용하는 경우에도
락과 조건과 조건 변수 간의 관계가 동일하게 유지돼야 한다.
조건에 관련된 모든 변수는 Lock 의 보호 아래 동기화돼 있어야 하고,
조건을 확인하거나 await 또는 signal 메소드를 호출하는 시점에는 반드시 Lock 을 확보한 상태여야 한다.
-
Condition 객체를 사용할 것이냐 아니면 암묵적인 조건 큐를 사용할 것이냐는 문제는 ReentrantLock 을 사용할 것이냐 아니면 synchronized 구문을 사용할 것이냐의 선택과 같은 문제이다.
공정한 큐 관리 방법이나 하나의 락에서 여러 개의 조건 큐를 사용할 필요가 있는 경우라면 Condition 객체를 사용하고,
그럴 필요가 없다면 암묵적인 조건 큐를 사용하는 편이 더 낫다.
14.4. 동기화 클래스의 내부 구조
-
AbstractQueuedSynchronizer(AQS) 는 락이나 기타 동기화 클래스를 만들 수 있는 프레임웍 역할을 하며
AQS 를 기반으로 하면 엄청나게 다양한 종류의 동기화 클래스를 간단하면서 효율적으로 구현할 수 있다.
ReentrantLock 이나 Semaphore 클래스, CountDownLatch, ReentrantReadWriteLock, SynchronousQueue, FutureTask 등의 클래스 역시 QS 기반으로 만들어져 있다.
-
동기화 클래스를 작성할 때 AQS 기반으로 작성하면 여러 가지 장점이 있다.
구현할 때 필요한 노력을 좀 줄여준다는 장점뿐만 아니라 동기화 클래스 하나를 기반으로 다른 동기화 클래스를 구현할 때
여러 면에서 신경 써야 하는 부분이 줄어든다.
-
AQS 기반으로 만들어진 동기화 클래스는 대기 상태에 들어갈 수 있는 지점이 단 한군데이기 때문에 컨텍스트 스위칭 부하를 줄일 수 있고, 결과적으로 전체적인 성능을 높일 수 있다.
AQS 자체도 원래부터 확장성을 염두에 두고 만들어졌으며, AQS 를 기반으로 만들어진
java.util.concurrent 패키지의 동기화 클래스 모두가 이런 장점을 그대로 물려받았다.
14.5. AbstractQueuedSynchronizer
-
개발자가 AQS 를 직접 사용할 일은 거의 없을 것이다.
JDK 에 들어 있는 표준 동기화 클래스만으로도 거의 모든 경우의 상황에 대처할 수 있기 때문이다.
그렇다 해도 표준 동기화 클래스가 어떻게 만들어졌는지를 살펴본다면 좋은 공부가 되겠다.
-
AQS 기반의 동기화 클래스가 담당하는 작업 가운데 가장 기본이 되는 연산은 바로 확보(acquire) 와 해제(release) 이다.
-
AQS 는 동기화 클래스의 상태 변수를 관리하는 작업도 어느 정도 담당하는데
getState, setState, compareAndSetState 등의 메소드를 통해 단일 int 변수기반의 상태 정보를 관리해준다.
-
확보 연산은 두 가지 부분으로 나눠 볼 수 있다.
첫 번째 부분은 동기화 클래스에서 확보 연산을 허용할 수 있는 상태인지 확인하는 부분이다.
만약 허용할 수 있는 상태라면 해당 스레드는 작업을 계속 진행하게 되고,
그렇지 않다면 확보 연산에서 대기 상태에 들어가거나 실패하게 된다.
이와 같은 판단은 동기화 클래스의 특성에 따라 다르게 나타난다.
두 번째 부분은 동기화 클래스 내부의 상태를 업데이트 하는 부분이다.
-
배타적인 확보 기능을 제공하는 동기화 클래스는 tryAcquire, tryRelease, isHeldExclusively 등의 메소드를 지원해야 하며,
배타적이지 않은 확보 기능을 지원하는 동기화 클래스는 tryAcquireShared, tryReleaseShared 메소드를 제공해야 한다.
-
java.util.concurrent 패키지에 들어 있는 동기화 클래스 가운데 AQS 를 직접 상속받는 클래스는 하나도 없고,
모두 AQS 를 private 인 내부 클래스로 선언해 위임 기법을 사용하고 있다.
Summary
-
상태 기반으로 동작하는 클래스, 즉 메소드 가운데 하나라도 상태 값에 따라 대기 상태에 들어갈 가능성이 있는 클래스를 작성해야 할 때 가장 좋은 방법은 바로 기존에 만들어져 있는 Semaphore, BlockingQueue, CountDownLatch 등을 활용해 구현하는 방법이다.
이미 많은 종류의 동기화 클래스가 제공되고 있음에도 불구하고 적절한 기능을 찾을 수 없다면,
암묵적인 조건 큐나 명시적인 Condition 클래스 또는 AbstractQueuedSynchronizer 클래스 등을 활용해 직접 원하는 기능의 동기화 클래스를 작성할 수도 있겠다.
상태 의존성을 관리하는 작업은 상태의 일관성을 유지하는 방법과 맞물려 있기 때문에
암묵적인 조건 큐 역시 암묵적인 락과 굉장히 밀접하게 관련돼 있다.
이와 비슷하게 명시적인 조건 큐인 Condition 클래스도 명시적인 Lock 클래스와 밀접하게 관련돼 있으며,
락 하나에서 다수의 대기 큐를 활용하거나 대기 상태에서 인터럽트에 어떻게 반응하는지를 지정하는 기능,
스레드 대기 큐의 관리 방법에 대한 공정성 여부를 지정하는 기능,
대기 상태에서 머무르는 시간을 제한할 수 있는 기능 등과 같이
암묵적인 버전의 조건 큐나 락보다 훨씬 다양한 기능을 제공한다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Java Concurrency] 자바 메모리 모델 (0) | 2017.05.10 |
---|---|
[Java Concurrency] 단일 연산 변수와 넌블로킹 동기화 (0) | 2017.05.09 |
[Java Concurrency] 명시적인 락 (0) | 2017.05.05 |
[Java Concurrency] 성능, 확장성 #2 (0) | 2017.05.04 |
[Java Concurrency] 성능, 확장성 #1 (0) | 2017.05.03 |
댓글