이번장에서는 작업과 스레드가 정상적으로 완료되기 이전에 종료 시키는 방법에 대해 알아본다.
작업이나 스레드를 시작시키기는 쉽다.
근데 언제 멈출지는 작업이 끝까지 실행되어 봐야 알 수 있다.
어플리케이션을 작동하다 보면 정상적으로 멈추기 전에 멈추도록 해야 할 때가 있다.
자바에서는 강제로 멈출 수 있는 방법이 없다. 대신 인터럽트를 이용해 특정 스레드에게
작업을 멈춰 달라고 요청을 한다.
즉, 즉시 멈추는 경우는 없고 멈춰달라는 요청을 받으면 하던 작업을 모두 정리한 다음
종료 하도록 해야한다.
실행 사이클을 종료하는 문제를 적절하게 대응하면 그렇지 않은 어플리케이션과 품질에서 차이가 커진다
작업 중단
실행 중인 작업을 취소하고자 하는 경우는 여러가지다
사용자가 취소하기를 요청한 경우 : 취소 버튼을 누른경우
시간이 제한된 작업: 일정 시간 내에서만 동작을 해야하고 그 시간이 지나면 작업은 모두 취소된다.
어플리케이션 이벤트: 특정 작업 결과로 원하는 값을 얻으면 나머지는 모두 종료 한다.
오류: 오류가 발생하면 다른 작업도 모두 취소시킨다.
종료: 어플리케이션이 종료 할때는 현재 처리하는 중이었던 작업에 대한 내용들이 마무리 하는 절차가 필요하다.
작업 스레드와 취소 요청한 스레드가 함께 작업을 멈추는 협력적인 방법을 사용해야한다.
협력적인 방법 중 가장 기본적인 형태는
"취소 요청이 왔다" 는 플래그를 설정하고, 실행 중인 작업은 주기적으로 플래그를 확인 하는 방법이다.
플래그가 설정되면 실행되던 작업을 멈추도록 한다
취소요청이 들어올때까지 계속 로직을 반복 하다가 플래그가 들어오면 작업을 멈추고 있다.
1초 후에 멈추라고 되어있지만 정확히 1초 후에 멈춘다는 보장은 없다.
cancelled 플래그를 확인 할때까지의 최소 시간이 필요하기 때문이다.
finally 구문에서 cancel 을 시키기 때문에 인터럽트가 걸리던 소수 계산 작업은 멈출 수 있다.
PrimeGenerator 클래스는 가장 기본적인 취소 정책을 사용하고 있다.
외부 프로그램에서는 cancel 메소드를 호출해 취소 요청을 보내게 되고
PrimeGenerator 는 소수를 하나 찾아낼 때마다 취소 요청이 들어왔는지 확인 하고 취소 요청이 들어오면 바로 작업을 멈춘다.
- 인터럽트
PrimeGenerator 의 취소방법은 결국엔 소수 찾는 로직을 멈추게 할 수 있지만,
때에 따라선 종료하는데 시간이 너무 걸릴수도 있다.
심지어 작업 내부에서 취소 요청을 확인 못하고 영원히 멈추지 않을 수도 있다.
프로듀서 스레드가 소수를 찾아내는 작업을 진행하고 ,
소수를 블로킹 큐에 집어 넣는다.
근데 컨슈머가 가져가는것보다 소수를 찾는 속도가 빠르면
큐는 가득차고 put 은 블록 된다.
이 상태에서 컨슈머가 멈추려고 cancel 을 호출하면
cancelled 는 변경되도 put 메소드에서 멈춰 있고 take 를 해줘야 할 컨슈머도 종료가 됐기때문에
종료 되지 못하고 멈춰 있게 된다.
여기서 인터럽트를 봐보자
스레드는 불린 값으로 인터럽트 상태를 갖고있다 스레드에 인터럽트를 걸면 상태 변수의 값이 true 가 된다.
또 isInterrupted 메소드는 해당 스레드에 인터럽트가 걸려있는지를 알려준다.
interrupted 메소드를 호출 하면 현재 스레드의 인터럽트 상태를 해제 하고 해제 하기 이전의 값이 무엇인지 알려준다.
Thread.sleep 이 Object.wait 들은 인터럽트가 걸리면 즉시 Exception을 던져 버려서
굉장히 심각하게 받는 순가 바로 예외를 던져 버린다.
잘 대응되도록 만들어진 메소드는 상황을 잘 기억하고 있다가 인터럽트 되게 한다.
여기서 interrupted 를 받을 수 있는건 2가지가 있다 .
Thread.isInterrupted 와 queue.put 이다
isInterrupted 는 인터룹트가 발생하면 현재 메소드에 인터럽트라는걸 전파시키고
put은 InterruptedException을 발생시키고 정상 종료가 된다.
- 인터럽트 정책
스레드 역시 인터럽트 를 어떻게 멈출지 처리 정책이 있어야 한다.
인터럽트 요청이 들어 오면 걸린 사실을 확인하고
어디에서 무슨일을 하고, 어디까지 보호 할 수 있는지 범위를 확인 하고
인터러트에 어떻게 잘 대응할지 지침을 뜻한다.
일반적인 정책은 스레드 수준이나 서비스 수준에서 작업 중단 기능을 제공하는것
사용하던 자원을 적절히 정리하고 작업을 요청한 스레드에 중단하고 있다고 알려주면 가장 좋다
작업은 소유하는 스레드에서 실행되지 않고, 스레드 풀과 같이 실행만 전담하는 스레드를 빌려 사용하기 때문에
작업 스레드의 인터럽트 상태를 그대로 유지해 소유하는 프로그램이 적절히 대응 할 수 있도록 해야 한다.
근데 개별 작업은 스스로 특별한 인터럽트 정책에 대응하도록 만들어져 있는게 아니라면
인터럽트 정책에 어떤 가정도 하면 안된다.
각 스레드는 각자의 인터럽트 정책을 갖고 있다.
따라서 인터럽트 요청을 받았을 때 어떻게 동작할지 정확하게 알고 있지 않은 경우에는 함부러 인터럽트를 걸면 안된다.
- 인터럽트에 대한 대응
BlockingQueue.put 이나 Thread.sleep 같이 인터럽트를 걸 수 있는 블로킹 메소드를 호출 하는 경우
InterruptedException이 발생했을때 처리 할 수 있는 두 가지 방법이 있다.
- 발생한 예외를 호출 스택의 상위 메소드에 전달
- 호출 스택의 상단의 위치한 메소드가 직접 처리 하도록 인터럽트 상태를 유지
throws 로 지정하는 것만으로 충분하다
근데 상위 메소드로 전달 할 수 없다면
인터럽트 요청이 들어왔다는 것을 유지할 수 있는 다른 방법을 찾아야 한다.
인터럽트 상태를 유지 할 수 있는 가장 일반적인 방법은 interrupt 메소드를 다시 한 번 호출 하는 것이다.
스레드 기반 서비스 중단
스레드 풀은 서비스를 시작시킨 메소드보다 오래 실행되는 경우가 일반적이다.
어플리케이션을 깔끔하게 종료하려면, 서비스 내부에 생성되어 있는 스레드를 안전하게 종료시킬 필요가 있다.
근데 선점적인 방법으로 강제로 종료 시킬 수 없으니, 스레드에게 알아서 종료해달라고 해야 한다.
스레드를 소유하는 객체는 대부분 해당 스레드를 생성한 객체라고 볼 수 있다.
스레드 풀을 예로들면, 모든 작업 스레드들은 해당 스레드 풀이 소유 한다고 볼 수 있다.
따라서 개별 스레드에 인터럽트를 걸어야 하는 상황이 된다면 그 작업은 스레드 풀이 책임져야 한다.
어플리케이션은 스레드 기반 서비스를 생성해 사용하며 스레드 서비스는 필요한 개별 스레드를 생성해 사용하지만,
어플리케이션이 직접 스레드를 소유하고 있지 않기 때문에, 직접 조작하는 일은 없어야 한다.
어플리케이션이 조작하지 않는 대신,스레드 기반 서비스가 스레드의 시작부터 종료까지 제공해야 한다.
- 로그 서비스
먼저 Log 메소드를 호출 했을때 , 큐레 쌓아두고 , 큐에 쌓인 메시지를
로그 출력용 스레드에서 가져다 출력하는 방법을 봐보자
7.13은 로그 출력을 독립적인 스레드로 구현하는 모습을 볼 수 있다.
스레드가 직접 스트림으로 메시지를 출력하지 않고,
BlockingQueue 를 사용해 메시지를 출력 전담 스레드에게 넘겨주고, 출력 전담 스레드는
큐에 쌓인 메시지를 가져다 화면에 출력한다.
이 구조는 전형적인 다수의 프로듀서와 단일 컨슈머가 동작하는 패턴이다.
로그를 남기는 log 메소드를 호출하는 모든 스레드가 프로듀서고,
로그 출력을 전담하는 스레드가 컨슈머의 역할을 한다.
여기서 출력 전담 스레드가 문제가 생기면, 출력 스레드가 다시 올바르게 동작하기전까지
BlockingQueue 가 막혀버리는 현상이 생길 수 있다.
위 예제에서, 실제 어플리케이션에서 사용하려고 할때,
로그 출력 전담 스레드가 계속 실행되느라 JVM 이 정상적으로 멈추지 않는 현상이 발생하면 안된다.
로그 출력 스레드는 계속 인터럽트에 대응하도록 만들어져 있는 BlockingQueue 의 take 를 호출 하기 때문에
종료하는건 쉽다.
따라서 출력 스레드는 작업 도중 인터럽트가 걸렸을때 InterruptedException 을 잡아서 그냥 리턴 되도록
구현 하면 쉽게 종료 할 수 있다.
근데, 그냥 멈추기만 하면 안된다.
단순히 멈추면 출력하려고 쌓던 큐가 모두 사라질 것이고, 더 큰 문제는 큐가 가득차서 메시지를
큐에 넣기 위해 대기 상태에 있던 스레드가 영원히 대기 상태에 있게 된다는 것.
프로듀서- 컨슈머 패턴으로 구현된 프로그램을 중단시키려면 프로듀서, 컨슈머 모두 중단시켜야한다.
7.14 는 플래그를 두고 플래그에 설정되었을땐 큐에 못 넣게 하는 방법이다.
근데 이걸 사용하면 실행 도중 경쟁 조건에 들어갈 수 있다.
로그 출력 스레드가 아직 종료 안됐다고 판단하고 큐에 쌓으려고 대기 상태에 들어갈 가능성이 있다.
더 안정적인 방법을 찾아야 하는데, 즉 로그 메시지를 추가하는 부분을 단일 연산으로 구현해야 한다는 것
그렇다고 락을 확보하도록 하면 put 메소드에서 대기 상태에 들어 갈 수 있기 때문에 좋은 방법은 아니다.
7.15같이 단일 연산으로 종료됐는지를 확인하며 ,
로그 메시지를 추가 할 수 있는 권한이라고 볼 수 있는 카운터를 하나 증가시키는 방법을 사용하자
- ExecutorService 종료
6장에서는 ExecutorService 를 종료 하는 두가지 방법을 봤었다.
하나는 shutdown 메소드를 사용해 안전하게 종료하는 방법이었고,
또 하나는 shutdownNow 메소드를 사용해 강제 종료 하는 방법이었다.
shutdownNow 를 사용하면 실행 중인 모든 작업을 중단 하도록 한 다음 아직 시작하지 않은 작업의 목록을 결과로 리턴해준다.
두가지 모두 안전성과 응답성의 측면에서 서로 장단점을 가지고 있다.
안전하게 종료하는 방법은 속도가 느리지만 모든 작업을 처리 할때까지 스레드를 종료시키지 않기 때문에
안전하다.
내부적으로 스레드를 소유하고 동작하는 서비스를 구현 할때에는 이와 비슷하게 종료 방법을 선택 할 수 있도록 준비하는게 좋다.
스레드를 직접 갖다 쓰기보다는 ExecutorService 를 사용해 스레드의 기능을 활용하는데,
ExecutorService 를 특정 클래스 내부에 캡슐화하면
어플리케이션에서 서비스와 스레드로 이어지는 소유 단계에 한 단계를 추가하는 셈이고
클래스는 자신이 소유한 서비스나 스레드의 시작과 종료에 관련된 기능을 관리한다.
- 독약
프로듀서 - 컨슈머 패턴 구성된 서비스를 종료시키도록 하는 방법으로 또 독약이 있다.
독약은 "이 객체를 받았으면 종료해야 한다" 는 뜻을 담고 있다.
FIFO 큐에서는 순서를 유지 할 수 있는데 FIFO 에서 독약 객체를 넣게 되면 먼저 넣어진 작업들은 전부 실행하고
독약 객체 이후로는 큐에 데이터를 넣으면 안된다.
다음 예제는 단일 프로듀서, 단일 컨슈머 를 사용하면서 독약 객체로 종료되는 예제를 보자
만일 다수의 프로듀서를 사용할때 독약을 쓰려면, 정확한 프로듀서와 컨슈머의 수를 알아야한다.
다수의 프로듀서 작업을 모두 생성하고 마지막에 독약 객체를 큐에 넣고,
컨슈머는 독약 객체를 받고 나면 종료 하는 방식이다.
- 단번에 실행하는 서비스
메소드 내부에서 ExecutorService 를 가지면 서비스의 시작과 종료를 쉽게 관리 할 수 있다.
- shutdownNow 메소드의 약점
실행되기 시작은 했지만 ,아직 완료되지 않은 작업이 어떤 것인지를 알 방법이 없다.
비정상적인 스레드 종료 상황 처리
스레드에는 UncaughgtExceptionHandler 기능을 제공하는데,
이 기능을 사용하면 처리하지 못한 예외 상황으로 인해 특정 스레드가 종료되는 시점을 정확히 알 수 있다.
잠깐 실행하고 마는 어플리케이션이 아닌 이상, 예외가 발생했을 때 로그 파일에
오류를 출력하는 간단한 기능만이라도 할 수 있도록,
모든 스레드를 대상으로 UncaughtExceptionHandler 를 활용해야 한다.
JVM 종료
JVM 이 종료되는 두 가지 경우를 생각할 수 있는데,
하나는 예정된 데로 종료되는 경우고, 다른 하나는 예기치 못하게 종료되는 경우다.
절차에 맞게 종료되는 경우에는 '일반' 스레드가 모두 종료되는 시점, 또는
system.exit 메소드를 호출 하거나 등등에 JVM 종료 절차가 시작된다.
그외에도 Runtime.halt 메소드를 호출하거나 운영체제 에서 JVM 프로세스를 종료하는 방법 등으로 종료할 수 있다.
- 종료 훅
예정된 절차대로 종료되는 경우에 JVM 은 종료 훅을 실행시킨다.
종료 훅이란 Runtime.addShutdownHook 메소드를 사용해 등록된
아직 기작되지 않은 스레드를 의미한다.
하나의 JVM 에 여러 종료 훅을 등록 할 수 있고, 두개 이상의 종료 훅이 있을때 어떤 순서로 훅을 실행하는지
규칙이 없다.
JVM 종료 절차 중에 사용하던 스레드가 계속 동작 한다면,
종료 절차가 진행되는 동안에는 스레드도 계속해서 실행되기도 한다.
JVM 은 종료 훅이 모두 작업을 마치면 , 클래스의 finallize 메소드를 호출하고 모두 종료한다.
이때 인터럽트를 걸지 않고 강제 종료한다.
만약 종료 훅이나 finallize 메소드가 작업을 마치지 못하고 계속 있으면 JVM 은
종료를 못하고 있는다. 이땐 강제로 종료시키는 방법 밖에 없는데
이떄는 종료 훅을 실행시키지 않고 바로 강제 종료된다.
그러므로, 종료 훅은 스레드 안전하게 만들어야 하고
종료 훅은 어떤 서비스나 어플리케이션 자체의 여러 부분을 정리하는 목적으로 사용하면 좋다.
- 데몬 스레드
어플리케이션을 만들다 보면 스레드를 하나 만들어 부수적인 기능을 하게 만들고 ,
또한 이 스레드가 떠있다는 이유로 JVM 이 종료되지 않게 하고 싶지는 않을 때가 있다.
이럴때 사용하는데 데몬 스레드다.
JVM 이 처음 시작할때 main 스레드를 제외하고 JVM 내부적으로 사용하기 위해 실행하는 스레드는 모두 데몬 스레드다
따라서, main 에서 생성하는 스레드는 기본적으로 일반 스레드가.
일반 스레드와 데몬스레드는 종료방법만 다르고 그 외에는 동일하다.
스레드 하나가 종료되면 JVM은 남아있는 모든 스레드 가운데 일반 스레드가 있는지 확인하고,
일반 스레드가 모두 종료되어서 남은 스레드가 전부 데몬스레드면,
JVM 은 즉시 종료 절차를 갖는다.
JVM이 종료될떄 데몬스레드는 finally 코드도 실행 되지 않고 호출 스택도 복구 되지 않는다.
데몬 스레드는 메모리 내부에 관리하고 있는 캐시에서 시한이 만료된 항목을 주기적으로 제거하는 등의
부수적인 단순 작업을 맡기기에 적절하다.
- finallize 메소드
어플리케이션 내부에서 더이상 사용하지 않는 객체가 있으면 대부분
가비지 컬렉터가 알아서 수집해 메모리를 확보하지만,
파일이나 소켓과 같은 일부 자원은 더이상 사용하지 않을때 운영체제에게 되돌려 주려면
반드시 자원을 명시적으로 정리해야 한다.
finallize 메소드는 JVM 이 관리하는 스레드에서 직접 호출 하기 때문에
상태 변수를 다른 스레드에서도 얼마든지 동시에 사용할 수 있고, 동기화 작업이 필수적으로 필요하다.
근데 finalize 메소드가 언제 실행될지에 대한 보장이 없고, 성능상의 문제점이 생길 수 있다.
finallize 메소드를 사용하지 말고, try-catch 를 쓰자.
요약
작업, 스레드, 서비스, 어플리케이션 모두 마치고 종료되는 시점을 적절하게 관리하려면
프로그램이 훨씬 복잡해질수있다.
자바에서는 선점적으로 작업을 중단하거나 스레드를 종료 시키는 방법을 제공하지 않고 있고,
대신 인터럽트를 사용해 중단 시키도록 한다.
작업 중단 기능을 구현하는것 모두 개발자의 몫이다.
FutureTask 나 Executor 등의 프레임웍을 사용하면 작업이나 서비스 실행 도죽에 중단 할 수 있는
기능을 쉽게 구현 할 수 있다.
'Java > 병렬 프로그래밍' 카테고리의 다른 글
자바 병렬 프로그래밍 - 활동성을 최대로 높이기 (0) | 2021.01.11 |
---|---|
자바 병렬 프로그래밍 - 스레드 풀 활용 (0) | 2021.01.11 |
자바 병렬 프로그래밍 - 작업 실행 (0) | 2021.01.07 |
자바 병렬 프로그래밍 - 구성 단위 (0) | 2021.01.05 |
자바 병렬 프로그래밍 - 객체 구성 (0) | 2021.01.04 |