[Java Concurrency] 중단 및 종료 #1 |
-
작업이나 스레드를 안전하고 빠르고 안정적으로 멈추게 하는 것은 어려운 일이다.
더군다나 자바에는 스레드가 작업을 실행하고 있을 때 강제로 멈추도록 하는 방법이 없다.
대신 인터럽트(interrupt)라는 방법을 사용할 수 있게 되어 있는데, 인터럽트는 특정 스레드에게 작업을 멈춰달라고 요청하는 형태이다.
실제 상황에서 특정 스레드나 서비스를 "즉시" 멈춰야 할 경우는 거의 없고,
강제로 종료하면 공유되어 있는 여러 가지 상태가 비정상적인 상태에 놓일 수 있기 때문에
스레드 간의 협력을 통한 접근 방법이 올바르다.
다시 말해, 작업이나 서비스를 실행하는 부분의 코드를 작성할 때 멈춰달라는 요청을 받으면 진행 중이던 작업을 모두 정리한 다음 종료하도록 만들어야 한다.
실행 중이던 일을 중단할 때 정상적인 상태에서 마무리하려면
작업을 진행하던 스레드가 직접 마무리하는 것이 가장 적잘한 방법이다.
7.1. 작업 중단
-
실행 중인 작업을 취소하고자 하는 요구 사항은 여러 가지 경우에 나타난다.
사용자가 취소하기를 요청한 경우
시간이 제한된 작업
애플리케이션 이벤트
오류
종료
-
가장 기본적인 취소 형태는 취소 요청이 들어왔다는 플래그를 설정하고,
실행 중인 작업은 취소 요청 플래그를 주기적으로 확인하는 방법이다.
-
작업을 쉽게 취소시킬 수 있도록 만들려면 작업을 취소하려 할 때 "어떻게", "언제", "어떤 일"을 해야 하는지,
이른바 취소 정책(cancellation policy)을 명확히 정의해야 한다.
다시 말하면 외부 프로그램에서 작업을 취소하려 할 때 어떤 방법으로 취소 요청을 보낼 수 있는지,
작업 내부에서 취소 요청이 들어왔는지를 언제 확인하는지,
취소 요청이 들어오면 실행 중이던 작업이 어떤 형태로 동작하는지 등에 대한 정보를 제공해야 안전하게 사용할 수 있다.
-
블로킹 메소드를 호출하는 부분이 있다면
잘못된 취소정책은 큰 문제를 발생시킬 수 있다.
작업 내부에서 취소 요청이 들어왔는지 확인하지 못하는 경우도 생길 수 있으며,
그런 상황에서는 작업이 영원히 멈추지 않을 수도 있다.
-
API 나 언어 명세 어디를 보더라도 인터럽트가 작업을 취소하는 과정에 어떤 역할을 하는지에 대해 명시되어 있는 부분은 없다.
하지만 실제 상황에서는 작업을 중단하고자 하는 부분이 아닌 다른 부분에 인터럽트를 사용한다면
오류가 발생하기 쉬울 수밖에 없으며, 앱 규모가 커질수록 관리하기도 어려워진다.
-
모든 스레드는 boolean 값으로 인터럽트 상태를 갖고 있다.
스레드에 인터럽트를 걸면 인터럽트 상태 변수의 값이 true 로 설정된다.
interrupt 메소드는 해당하는 스레드에 인터럽트를 거는 역할을 하고,
isInterrupted 메소드는 해당 스레드에 인터럽트가 걸려 있는지를 알려준다.
스태틱으로 선언된 interrupted 메소드를 호출하면 현재 스레드의 인터럽트 상태를 해제하고, 해제하기 이전의 값이 무엇이었는지를 return 한다.
interrupted 메소드는 인터럽트 상태를 해제할 수 있는 유일한 방법이다.
-
Thread.sleep 이나 Object.wait 메소드와 같은 블로킹 메소드는 인터럽트 상태를 확인하고 있다가
인터럽트가 걸리면 즉시 리턴된다.
Thread.sleep 이나 Object.wait 메소드에서 대기하던 중에 인터럽트가 걸리면 인터럽트 상태를 해제하면서 InterruptedException 을 던진다.
Thread.sleep 이나 Object.wait 메소드에서 인터럽트가 걸렸을 때,
인터럽트가 걸렸다는 사실을 얼마나 빠르게 확인하는지는 JVM 에서 아무런 보장을 하지 않는다.
-
특정 스레드의 interrupt 메소드를 호출한다 해도 해당 스레드가 처리하던 작업을 멈추지 않는다.
단지 해당 스레드에게 인터럽트 요청이 있었다는 메시지를 전달할 뿐.
-
인터럽트를 이해하고자 할 때 중요한 사항이 있는데,
바로 실행 중인 스레드에 "실제적인 제한을 가해 멈추도록 하지 않는다" 는 것이다.
단지 해당하는 스레드가 상황을 봐서 스스로 멈춰주기를 요청하는 것 뿐이다.
-
인터럽트에 잘 대응하도록 만들어져 있는 메소드는 인터럽트가 걸리는 상황을 정확하게 기록해뒀다가
자신을 호출한 메소드가 인터럽트 상태에 따라서 다른 방법으로 동작할 수 있도록 정보를 제공하기도 한다.
인터럽트에 제대로 대응하지 못하는 메소드는 인터럽트 요청을 통채로 삼켜버리고는,
호출한 메소드에서도 인터럽트 상황을 전혀 알지 못하게 막아버리기도 한다.
-
static interrupted 메소드는 현재 스레드의 인터럽트 상태를 초기화하기 때문에 사용할 때 상당히 주의를 기울여야 한다.
interrupted 메소드를 호출했는데 결과 값으로 true 가 넘어온 경우,
요청을 무시할 것이 아니라면 인터럽트에 대응하는 어떤 작업을 진행해야 한다.
-
작업 취소 기능을 구현하고자 할 때는 인터럽트가 가장 적절한 방법이라고 볼 수 있다.
-
단일 작업마다 해당 작업을 멈출 수 있는 취소 정책이 있는 것처럼
스레드 역시 인터럽트 정책이 있어야 한다.
인터럽트 처리 정책은 인터럽트 요청이 들어 왔을 때,
해당 스레드가 인터럽트를 어떻게 처리해야 하는지에 대한 지침이다.
일반적으로 가장 범용적인 인터럽트 정책은 스레드 수준이나 서비스 수준에서 작업 중단 기능을 제공하는 것이다.
실질적인 수준에서 최대한 빠르게 중단시킬 수 있고, 사용하던 자원은 적절하게 정리하고,
심지어는 가능하다면 작업 중단을 요청한 스레드에게 작업을 중단하고 있다는 사실을 어떻게든 알려줄 수 있다면 가장 좋겠다.
-
작업(task)와 스레드(thread)가 인터럽트 상황에서 서로 어떻게 동작해야 하는지 명확히 구분할 필요가 있다.
스레드 풀에서 작업을 실행하는 스레드에 인터럽트를 거는 것은
"현재 작업을 중단하라" 는 의미일 수도 있고,
"작업 스레드를 중단시켜라" 라는 뜻일 수도 있다.
-
task 의 경우 스레드에서 적용하고 있는 인터럽트 정책에 대해 어떠한 가정도 해서는 안 된다.
ineterrupt 가 발생하면 작업을 실행중인 스레드의 인터럽트 상태는 그대로 유지시켜야 한다.
가장 일반적인 방법은 InterruptedException 을 던지는 것이다.
-
각 스레드는 각자의 인터럽트 정책을 갖고 있다.
따라서 해당 스레드에서 인터럽트 요청을 받았을 때 어떻게 동작할지를 정확하게 알고 있지 않은 경우에는 함부로 인터럽트를 걸어서는 안 된다.
-
블로킹 메소드를 호출하는 경우에 InterruptedException 이 발생했을 때 처리할 수 있는 실질적인 방법에는 대략 두 가지가 있다.
발생한 예외를 호출 스택의 상위 메소드로 전달 ( 이 방법은 호출하는 메소드도 블로킹 메소드로 만듬 )
호출 스택의 상단에 위치한 메소드가 직접 처리할 수 있도록 인터럽트 상태를 유지한다.
-
InterruptedException 을 상위 메소드로 전달할 수 없거나(Runnable 인터페이스를 구현해 작업 정의한 경우)
전달하지 않고자 하는 상황이라면 인터럽트 요청이 들어왔다는 것을 유지할 수 있는 다른 방법을 찾아야 한다.
인터럽트 상태를 유지할 수 있는 가장 일반적인 방법은 interrupt 메소드를 다시 한번 호출하는 것이다.
반대로 정확한 정책 없이 catch 블록에서 InterruptedException 을 잡아낸 다음 아무런 행동을 취하지 않고 예외를 먹는 일을 하지 말아야 한다.
대부분의 프로그램 코드는 자신이 어느 스레드에서 동작할지 모르기 때문에 인터럽트 상태를 최대한 그대로 유지해야 한다.
-
스레드의 인터럽트 처리 정책을 정확하게 구현하는 작업만이 인터럽트 요청을 삼켜버릴 수 있다.
일반적인 용도로 작성된 작업이나 라이브러리 메소드는 인터럽트 요청을 그냥 삼켜버려서는 안 된다.
-
작업 중단 기능을 지원하지 않으면서 인터럽트를 걸 수 있는 블로킹 메소드를 호출하는 작업은
인터럽트가 걸렸을 때 블로킹 메소드의 기능을 자동으로 재시도하도록 반복문 내부에서 블로킹 메소드를 호출하도록 구성하는 것이 좋다.
이런 경우 InterruptedException 이 발생하는 즉시 인터럽트 상태를 지정하는 대신 인터럽트 상태를 내부적으로 보관하고 있다가 메소드가 리턴되기 직전에 인터럽트 상태를 원래대로 복구하고 리턴하도록 해야 한다.
인터럽트를 걸 수 있는 블로킹 메소드는 대부분 실행되자마자 가장 먼저 인터럽트 상태를 확인하며
인터럽트가 걸린 상태라면 즉시 InterruptedException 을 던지는 경우가 많기 때문에
인터럽트 상태를 너무 일찍 지정하면 반복문이 무한반복에 빠질 수 있다.
-
작업 코드에서 인터럽트가 걸릴 수 있는 블로킹 메소드를 전혀 사용하지 않는다고 해도,
작업이 진행되는 과정 곳곳에서 현재 스레드의 인터럽트 상태를 확인해준다면 인터럽트에 대한 응답 속도를 크게 높일 수 있다.
인터럽트 상태를 얼마만에 한 번씩 확인할 것인지 주기를 결정할 때에는 응답 속도와 효율성 측면에서 적절한 타협점을 찾아야 한다.
-
작업 중단 기능은 인터럽트 상태뿐만 아니라 여러 가지 다른 상태와 관련이 있을 수 있다.
예를 들면 ThreadPoolExecutor 내부의 풀에 등록되어 있는 스레드에 인터럽트가 걸렸다면, 인터럽트가 걸린 스레드는
전체 스레드 풀이 종료되는 상태인지를 먼저 확인한다.
스레드 풀 자체가 종료되는 상태였다면 스레드를 종료하기 전에 스레드 풀을 정리하는 작업을 실행하고,
스레드 풀이 종료되는 상태가 아니라면 스레드 풀에서 동작하는 스레드의 수를 그대로 유지시킬 수 있도록 새로운 스레드를 하나 생성해 풀에 등록시킨다.
-
임시로 빌려 사용하는 스레드에 인터럽트 거는 것은 금물!!
해당 스레드의 interrupt 정책을 모르기 때문이다.
-
Future 에는 cancel 메소드가 있는데 mayInterruptIfRunning 이라는 불린 값을 하나 넘겨 받으며,
취소 요청에 따른 작업 중단 시도가 성공적이었는지를 알려주는 결과 값을 리턴받을 수 있다.
cancel 메소드를 호출할 때 mayInterruptIfRunning 을 true 로 하면,
작업이 어느 스레드에서건 실행되고 있었다면 해당 스레드에 인터럽트가 걸린다.
mayInterruptIfRunning 으로 false 를 넘겨주면
아직 실행하지 않았다면 실행시키지 말아라는 의미로 해석되며, 인터럽트에 대응하도록 만들어지지 않은 작업에는 항상 false 를 넘겨야 한다.
-
Executor에서 기본적으로 작업을 실행하기 위해 생성하는 스레드는 인터럽트가 걸렸을 때 작업을 중단할 수 있도록 하는 인터럽트 정책을 사용한다.
따라서 기본 Executor 에 작업을 등록하고 넘겨받은 Future 에서는 cancel 메소드에 mayInterruptIfRunning 값으로 true 를 넘겨 호출해도 문제가 없다.
ThreadPool 에 들어있는 스레드에 함부로 인터럽트 거는 일은 여전히 안 된다.
해당 스레드에 인터럽트가 걸리는 시점에 어떤 작업을 실행하고 있을지 아닐지를 알 수 없기 때문이다.
작업을 중단하려 할 때는 항상 스레드에 직접 인터럽트를 거는 대신 Future 의 cancel 메소드를 사용해야 한다.
작업을 구현할 때 인터럽트가 걸리면 작업을 중단하라는 요청으로 해석하고 그에 따라 행동하도록 만들어야 하는 또 다른 이유라고 볼 수 있는데,
그러면 Future 를 통해 쉽게 작업을 중단시킬 수 있기 때문이다.
-
Future.get 메소드에서 InterruptedException 이 발생하거나 TimeoutException 이 발생했을 때,
만약 예외 상황이 발생한 작업의 결과는 필요가 없다고 한다면
해당 작업에 대해 Future.cancel 메소드를 호출해 작업을 중단시키자.
-
자바 라이브러리에 포함된 여러 블로킹 메소드는 대부분 인터럽트가 발생하는 즉시 멈추면서 InterruptedException 을 띄우도록 되어 있으며, 따라서 작업 중단 요청에 적절하게 대응하는 작업을 쉽게 구현할 수 있다.
그런데 잘 보면 모든 블로킹 메소드가 인터럽트에 대응하도록 되어 있지는 않다.
해당 스레드가 대기 상태에 멈춰 있는 이유가 무엇인지를 정확하게 이해해야 한다.
java.io 패키지의 동기적 소켓 I/O
InputStream 클래스의 read 메소드와 OutputStream 의 write 메소드가 인터럽트에 반응하지 않는다.
해당 스트림이 연결된 소켓을 직접 닫으면 대기 중이던 read 나 write 메소드가 중단되면서 SocketException 이 발생한다.
java.nio 패키지의 동기적 I/O
InterruptibleChannel 에서 대기하고 있는 스레드에 인터럽트를 걸면 ClosedByInterruptException 이 발생하면서 해당 채널이 닫힌다.
( 해당 채널에 대기하고 있던 모든 스레드에 ClosedByInterruptException 이 발생한다. )
InterruptibleChannel 을 닫으면 해당 채널로 작업을 실행하던 스레드에서 AsynchronousCloseException 이 발생한다.
Selector 를 사용한 비동기적 I/O
스레드가 Selector 클래스(java.nio.channels 패키지)의 select 메소드에서 대기 중인 경우
close 메소드를 호출하면 ClosedSelectorException 을 발생시키면서 즉시 리턴된다.
락 확보
스레드가 암묵적인 락을 확보하기 위해 대기 상태에 들어가 있는 경우 언젠가 락을 확보할 수 있을 것이라는 보장을 하지 못할 뿐더러 어떤 방법으로든 다음 상태로 진행시켜 스레드의 주의를 끌 수 없기 때문에 어떻게 해 볼 방법이 없다.
하지만 Lock 인터페이스를 구현한 락 클래스의 lockInterruptibly 메소드를 사용하면 락을 확보할 떄까지 대기하면서 인터럽트에도 응답하도록 구현할 수 있다.
-
ThreadPoolExecutor 클래스의 newTaskFor 메소드는등록된 작업을 나타내는 Future 객체를 리턴해준다.
이전과는 다른 RunnableFuture 객체를 리턴한다.
RunnableFuture 인터페이스는 Future 와 Runnable 인터페이스를 모두 상속받으며,
FutureTask 는 자바 5에서 Future 를 구현했었지만 자바 6에서는 RunnableFuture 를 구현한다.
-
Future.cancel 메소드를 오버라이드하면 작업 중단 과정을 원하는 대로 변경할 수 있다.
이를테면 작업 중단 과정에서 필요한 내용을 로그 파일로 남긴다거나 몇 가지 통계 값을 보관하는 등의 작업을 할 수 있고,
언터럽트에 제대로 대응하지 않는 작업을 중단하도록 할 수도 있다.
7.2. 스레드 기반 서비스 중단
-
스레드를 직접 소유하고 있지 않는 한 (Executor 와 같은 경우) 해당 스레드에 인터럽트를 걸거나 우선 순위를 조정하는 등의 작업을 해서는 안 된다.
-
스레드 하나가 외부의 특정 객체에 소유된다는 개념을 사용할 수 있다면 상당한 도움이 된다.
스레드를 소유하는 객체는 대부분 해당 스레드를 생성한 객체이다.
스레드 풀의 경우, 스레드 풀의 모든 작업 스레드는 해당하는 스레드 풀이 소유한다고 볼 수 있고,
따라서 개별 스레드에 인터럽트를 걸어야 하는 상황이 되면, 그 작업은 스레드를 소유한 스레드 풀에서 책임을 져야 한다.
-
앱이 개별 스레드에 직접 액세스하는 대신 스레드 기반 서비스가 스레드의 시작부터 종료까지 모든 기능에 해당하는 메소드를 직접 제공해야 한다.
그럼 앱이 스레드 기반 서비스만 종료시키면 스레드 기반 서비스는 스스로 소유한 모든 작업 스레드를 종료시키게 된다.
ExecutorService 인터페이스는 shutdown 메소드와 shutdownNow 메소드를 제공하고 있으며,
다른 스레드 기반의 서비스 역시 이와 같은 종료 기능을 제공해야 한다.
-
스레드 기반 서비스를 생성한 메소드보다 생성된 스레드 기반 서비스가 오래 실행될 수 있는 상황이라면,
스레드 기반 서비스에서는 항상 종료시키는 방법을 제공해야 한다.
-
프로듀서-컨슈머 패턴으로 구현된 프로그램을 중단시키려면 프로듀서와 컨슈머 모두 중단시켜야 한다.
프로듀서는 전용 스레드에서 동작하는 것이 아니기 때문에 프로듀서를 중단시키는 일은 간단하지 않을 수 있다.
-
ExecutorService 를 종료하는 방법은 두 가지이다.
shutdown 메소드를 사용해 안전하게 종료하는 방법과
shutdownNow 메소드를 사용해 강제 종료하는 방법이다.
shutdownNow 를 사용한 경우 먼저 실행 중인 모든 작업을 중단하도록 한 다음 아직 시작하지 않은 작업의 목록을 그 결과로 리턴한다.
shutdownNow 는 응답이 훨씬 빠르지만 실행 도중에 스레드에 인터럽트를 걸어야 하기 때문에
작업이 중단되는 과정에서 여러 가지 문제가 발생할 가능성이 있고,
안전하게 종료하는 방법(shutdown)은 종료 속도가 느리지만 큐에 등록된 모든 작업을 처리할 때까지 스레드를 종료시키지 않고 놔두어
작업을 잃을 가능성이 없어 안전하다.
내부적으로 스레드를 소유하고 동작하는 서비스를 구현할 때에는 이와 비슷하게 종료 방법을 선택할 수 있도록 준비하는 것이 좋다.
-
ExecutorService 를 특정 클래스의 내부에 캡슐화하면 앱에서 서비스와 스레드로 이어지는 소유 관계에 한 단계를 더 추가하는 셈이고, 각 단계에 해당하는 클래스는 모두 자신이 소유한 서비스나 스레드의 시작과 종료에 관련된 기능을 관리한다.
-
프로듀서-컨슈머 패턴으로 구성된 서비스를 종료시키도록 하는 또 다른 방법으로는 독약(poison pill)이라고 불리는 방법이 있다.
이 방법은 특정 객체를 큐에 쌓도록 되어 있으며, 이 객체는 "이 객체를 받았다면, 종료해야 한다"는 의미를 갖고 있다.
FIFO 유형의 큐를 사용하는 경우에는 독약 객체를 사용했을 때 컨슈머가 쌓여 있던 모든 작업을 종료하고 독약 객체를 만나 종료되도록 할 수 있다.
FIFO 큐에서는 객체의 순서가 유지되기 떄문에 독약 객체보다 먼저 큐에 쌓인 객체는 항상 독약 객체보다 먼저 처리된다.
물론 프로듀서 측에서는 독약 객체를 한 번 큐에 넣고 나면 더 이상 다른 작업을 추가해서는 안 된다.
-
독약 객체는 프로듀서의 개수와 컨슈머의 개수를 정확히 알고 있을 때에만 사용할 수 있다.
각 프로듀서가 작업을 모두 생성하고 나면 각자 하나씩의 독약 객체를 큐에 넣고,
컨슈머는 프로듀서 개수만큼의 독약 객체를 받고 나면 종료하도록 할 수 있다.
컨슈머가 여럿인 경우에도 쉽게 적용할 수 있는데, 프로듀서가 컨슈머 개수만큼의 독약 객체를 만들어 큐에 쌓는 것으로 해결된다.
많은 수의 프로듀서와 컨슈머를 사용하는 경우에는 허술할 수 있다.
또한 독약 객체 방법은 크기에 제한이 없는 큐를 사용할 때 효과적으로 동작한다.
-
shutdownNow 메소드를 사용해 ExecutorService 를 강제로 종료시키는 경우에는
현재 실행 중인 모든 스레드의 작업을 중단시키도록 시도하고, 동록됐지만 실행은 되지 않았던 모든 작업의 목록을 리턴해준다.
그런데 실행 시작은 했지만 아직 완료되지 않은 작업이 어떤 것인지를 알아볼 수 있는 방법은 없다.
따라서 개별 작업 스스로가 작업 진행 정도 등의 정보를 외부에 알려주기 전에는 서비스를 종료하라고 했을 때
실행 중이던 작업의 상태를 알아볼 수 없다.
종료 요청을 받았지만 아직 종료되지 않은 작업이 어떤 작업인지 확인하려면 실행이 시작되지 않은 작업도 알아야 할 뿐더러
Executor 가 종료될 때 실행 중이던 작업이 어떤 것인지도 알아야 한다.
-
책에 TrackingExecutor 가 구현되어 있지만,
특정 경쟁 조건에 빠질 수 있어 멱등(다시 수행해도 같은 결과가 나오는 것)이 아닌 경우
안전성에 문제가 될 수 있으니 주의해야 한다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Java Concurrency] 스레드 풀 활용 (0) | 2017.04.27 |
---|---|
[Java Concurrency] 중단 및 종료 #2 (0) | 2017.04.26 |
[Java Concurrency] 작업 실행 (0) | 2017.04.24 |
[Java Concurrency] 구성 단위 #2 (0) | 2017.04.21 |
[Java Concurrency] 구성 단위 #1 (0) | 2017.04.20 |
댓글