[Java Concurrency] 구성 단위 #2 |
5.4. 블로킹 메소드, 인터럽터블 메소드
-
스레드는 여러 가지 원인에 의해 블록 당하거나, 멈춰질 수 있다.
스레드가 블록되면 동작이 멈춰진 다음 블록된 상태(BLOCKED, WAITING, TIMED_WAITING) 가운데 하나를 갖게 된다.
블로킹 연산은 단순히 실행 시간이 오래 걸리는 일반 연산과는 달리 멈춘 상태에서 특정한 신호를 받아야 계속해서 실행할 수 있는 연산을 말한다.
( I/O 작업 끝나기를 기다리거나, 기다리던 락을 확보하거나, 다른 스레드의 작업 결과를 받아오는 등의 신호 )
-
Thread 클래스는 해당 스레드를 중단시킬 수 있도록 interrupt 메소드를 제공하며,
해당 스레드에 인터럽트가 걸려 중단된 상태인지를 확인할 수 있는 메소드도 있다.
모든 스레드에는 인터럽트가 걸린 상태인지를 알려주는 불린 값이 있으며, 외부에서 인터럽트를 걸면 불린 변수에 true 가 설정된다.
단, 어떤 스레드라도 다른 스레드가 하고 있는 일을 중간에 강제로 멈추라고 할 수는 없다.
실행을 멈추라고 "요청"하는 것일 뿐이며, 인터럽트가 걸린 스레드 는 정상적인 종료 시점 이전에 적절한 때를 잡아 실행 중인 작업을 멈추면 된다.
-
호출하는 메소드 가운데 InterruptedException 이 발생할 수 있는 메소드가 있다면
그 메소드를 호출하는 메소드 역시 블로킹 메소드이다.
따라서 InterruptedException 이 발생했을 때 그에 대처할 수 있는 방법을 마련해둬야 한다.
라이브러리 형태의 코드라면 일반적으로 두 가지 방법을 사용할 수 있다.
1. InterruptedException 전달
받아낸 InterruptedException 을 그대로 호출한 메소드에게 넘겨버리는 방법.
2. 인터럽트를 무시하고 복구
특정상황에서는 InterruptedException 을 throw 할 수 없을 수 있다.
예를 들면 Runnable 인터페이스를 구현한 경우인데, 상위 호출 메소드가 인터럽트 상황을 알 수 있도록 해야 한다.
catch( InterruptedException e ){
Thread.currentThread().interrupt();
}
5.5. 동기화 클래스
-
상태 정보를 사용해 스레드 간의 작업 흐름을 조절할 수 있도록 만들어진 모든 클래스를 동기화 클래스(synchronizer) 라고 한다.
블로킹 큐 역시 동기화 클래스이며, 세마포어(semaphore), 베리어(barrier), 래치(latch) 도 동기화 클래스이다.
모두 동기화 클래스에 접근하려는 스레드가 어떤 경우에는 통과하고,
어떤 경우에는 대기하도록 멈추게 해야 하는지를 결정하는 상태 정보를 갖고 있고,
그 상태를 변경할 수 있는 메소드를 제공하고,
동기화 클래스가 특정 상태에 진입할 때까지 효과적으로 대기할 수 있는 메소드도 제공한다.
-
래치는 스스로가 터미널(terminal)상태에 이를 때까지 스레드가 동작하는 과정을 늦출 수 있도록 해주는 동기화 클래스이다.
래치는 일종의 관문으로 래치가 터미널 상태에 이르기 전에는 관문이 닫혀 있어 어떤 스레드도 통과할 수 없다.
그리고 래치가 터미널 상태에 다다르면 관문이 열리고 모든 스레드가 통과한다.
레치가 한 번 터미널 상태에 다다르면 그 상태를 다시 이전으로 되돌릴 수는 없으며,
따라서 한 번 열린 관문은 계속해서 열린 상태로 유지된다.
특정한 단일 동작이 완료되기 이전에는 어떤 기능도 동작하지 않도록 막아야 하는 경우에 요긴하게 사용할 수 있다.
-
CountDownLatch 의 countDown 메소드는 대기하던 이벤트가 발생했을 때 내부에 갖고 있는 이벤트 카운터를 하나 낮춰주고, await 메소드는 래치 내부의 카운터가 0이 될 때까지 대기하도록 하는 메소드이다.
외부 스레드가 await 메소드를 호출할 때 래치 내부의 카운터가 0보다 큰 값이었다면, await 메소드는 카운터가 0이 되거나, 대기하던 스레드에 인터럽트가 걸리거나, 대기 시간이 길어 타임아웃이 걸릴 때까지 대기한다.
-
FutureTask 역시 래치와 비슷하게 동작한다.
FutureTask 가 나타내는 연산 작업은 Callable 인터페이스를 구현하도록 되어 있는데,
시작 전 대기, 시작됨, 종료됨과 같은 세 가지 상태를 가질 수 있다.
종료된 상태는 정상적 종료, 취소, 예외 상황 발생과 같이 연산이 끝나는 모든 종류의 상태를 의미한다.
FutureTask 가 한번 종료됨 상태에 이르고 나면 더 이상 상태가 바뀌는 일은 없다.
Future.get 메소드는 FutureTask 의 작업이 종료됐다면 그 결과를 즉시 알려준다.
종료 상태에 이르지 못했다면 get 메소드는 작업이 종료 상태에 이를 때까지 대기하고,
종료된 이후에 연산 결과나 예외 상황을 알려준다.
FutureTask 는 실제로 연산을 실행했던 스레드에서 만들어 낸 결과 객체를 실행시킨 스레드에게 넘겨준다.
FutureTask 는 Executor 프레임웍에서 비동기적인 작업을 실행하고자 할 떄 사용하며,
기타 시간이 많이 필요한 모든 작업이 있을 때 실제 결과가 필요한 시점 이전에 미리 작업을 실행시켜두는 용도로 사용한다.
-
Future.get() 을 호출할때는 catch 로 ExecutionException 을 잡아야 하며,
ExecutionException.getCause() 를 통해 발생한 예외를 가져올 수 있다.
조금 복잡한 방법이다.
-
카운팅 세마포어(counting semaphore) 는 특정 자원이나 특정 연산을 동시에 사용하거나 호출할 수 있는 스레드의 수를 제한하고자 할 때 사용한다.
카운팅 세마포어의 이런 기능을 활용하면 자원 풀이나 컬렉션의 크기에 제한을 두고자 할 때 유용하다.
semaphore 클래스는 가상의 퍼밋(permit)을 만들어 내부 상태를 관리하며,
semaphore 를 생성할 때 생성 메소드에 최초로 생성할 퍼밋의 수를 넘겨준다.
외부 스레드는 퍼밋을 요청해 확보하거나, 이전에 확보한 퍼밋을 반납할 수도 있다.
현재 사용할 수 있는 남은 퍼밋이 없는 경우, acquire 메소드는 남는 퍼밋이 생기거나, 인터럽트가 걸리거나, 지정한 시간을 넘겨 타임아웃이 걸리기 전까지 대기한다.
release 메소드는 확보했던 퍼밋을 다시 세마포어에게 반납하는 기능을 한다.
-
래치는 일회성 객체이다. 즉, 래치는 한번 터미널 상태에 다다르면 다시는 이전 상태로 회복할 수가 없다.
배리어(barrier)는 특정 이벤트가 발생할 때까지 여러 개의 스레드를 대기 상태로 잡아둘 수 있다는 측면에서 래치와 비슷하다.
하지만 베리어는 모든 스레드가 배리어 위치에 동시에 이르러야 관문이 열리고 계속해서 실행할 수 있다.
래치는 "이벤트"를 기다리기 위한 동기화 클래스이고, 배리어는 "다른 스레드"를 기다리기 위한 동기화 클래스이다.
-
CyclicBarrier 클래스를 사용하면 여러 스레드가 특정한 배리어 포인트에서 반복적으로 서로 만나는 기능을 모델링할 수 있다.
커다란 문제 하나를 여러 개의 작은 부분 문제로 분리해 반복적으로 병렬 처리하는 알고리즘을 구현하고자 할 때 적용하기 좋다.
스레드는 각자가 배리어 포인트에 다다르면 await 메소드를 호출하며,
awiat 메소드는 모든 스레드가 배리어 포인트에 도달할 때까지 대기한다.
모든 스레드가 배리어 포인트에 도달하면 배리어는 모든 스레드를 통과시키며, await 메소드에서 대기하고 있던 스레드는 대기 상태가 모두 풀려 실행되고, 배리어는 다시 초기 상태로 돌아가 다음 배리어 포인트를 준비한다.
만약 await 를 호출하고 시간이 너무 오래 지나 타임아웃이 걸리거나 await 메소드에서 대기하던 스레드에 인터럽트가 걸리면 배리어는 깨진 것으로 간주하고,
await 에 대기하던 모든 스레드에 BrokenBarrierException 이 발생한다.
배리어가 성공적으로 통과하면 await 메소드는 각 스레드별로 배리어 포인트에 도착한 순서를 알려주며, 다음 배리어 포인트로 반복 작업을 하는 동안 뭔가 특별한 작업을 진행할 일종의 리더를 선출하는 데 이 값을 사용할 수도 있다.
-
Exchanger 는 두 개의 스레드가 연결되는 배리어이며, 배리어 포인트에 도달하면 양쪽의 스레드가 서로 갖고 있던 값을 교환한다.
Exchanger 클래스는 양쪽 스레드가 서로 대칭되는 작업을 수행할 때 유용하다.
Exchanger 객체를 통해 양쪽의 스레드가 각자의 값을 교환하는 과정에서 서로 넘겨지는 객체는 안전한 공개 방법으로 넘겨주기 때문에 동기화 문제를 걱정할 필요가 없다.
5.6. 효율적이고 확장성 있는 결과 캐시 구현
-
자세한 내용은 책의 소스코드 참조
1. HashMap
synchornized 로 처리하니 동시성이 떨어진다.
2. ConcurrentHashMap
동시성은 좋아졌으나 2번 compute 를 할 수 있다.
3. ConcurrentHashMap + Future
동시성과 2번 compute 확률은 적어졌으나 bad timing 에 2번 compute 할 수 있음
4. ConcurrentHashMap + Future + PutIfAbsent
문제를 모두 해결한다. but cache clear 정책이 부족하다. ( 이는 여기서는 논외 )
1부 Summary
-
상태가 바뀔 수 있단 말이다.
병렬성과 관련된 모든 문제점은 변경 가능한 변수에 접근하려는 시도를 적절하게 조율하는 것으로 해결할 수 있다.
변경 가능성이 낮으면 낮을수록 스레드 안전성을 확보하기가 쉽다.
-
변경 가능한 값이 아닌 변수는 모두 final 로 선언하라.
-
불변 객체는 항상 그 자체로 스레드 안전하다.
불변 객체는 병렬 프로그램을 엄청나게 간편하게 작성할 수 있도록 해준다.
불변 객체는 간결하면서 안전하고, 락이나 방어적 복사 과정을 거치지 않고도 얼마든지 공유해 사용할 수 있다.
-
캡슐화하면 복잡도를 손쉽게 제어할 수 있다.
모든 값을 전역 변수에 넣어 두더라도 프로그램을 스레드 안전하게 작성할 수는 있다.
하지만 도대체 무엇 떄문에 그런 짓을 하는가?
데이터를 객체 내부에 캡슐화하면 값이 변경되는 자유도를 쉽게 제어할 수 있다.
객체 내부에서 동기화하는 기법을 캡슐화하면 동기화 정책을 손쉽게 적용할 수 있다.
-
변경 가능한 객체는 항상 락으로 막아줘야 한다.
-
불변 조건 내부에 들어가는 모든 변수는 같은 락으로 막아줘야 한다.
-
복합 연산을 처리하는 동안에는 항상 락을 확보하고 있어야 한다.
-
여러 스레드에서 변경 가능한 변수의 값을 사용하도록 되어 있으면서 적절한 동기화 기법이 적용되지 않은 프로그램은 올바른 결과를 내놓지 못한다.
-
동기화활 필요가 없는 부분에 대해서는 일부러 머리를 써서 고민할 필요가 없다.
( 동기화할 필요가 없다고 이래저래 추측한 결론에 의존해서는 안 된다. )
-
설계 단계부터 스레드 안전성을 염두에 두고 있어야 한다.
아니면 최소한 결과물로 작성된 클래스가 스레드에 안전하지 않다고 반드시 문서로 남겨야 한다.
-
프로그램 내부의 동기화 정책에 대한 문서를 남겨야 한다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Java Concurrency] 중단 및 종료 #1 (0) | 2017.04.25 |
---|---|
[Java Concurrency] 작업 실행 (0) | 2017.04.24 |
[Java Concurrency] 구성 단위 #1 (0) | 2017.04.20 |
[Java Concurrency] 객체구성 (0) | 2017.04.18 |
[Java Concurrency] 객체공유 (0) | 2017.04.17 |
댓글