본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Java] 병렬 프로그래밍 - 중단 및 종료

by 돼지왕 왕돼지 2012. 8. 7.
반응형

이 자료는 "에어콘" 사의 "자바 병렬 프로그래밍" 이라는 도서의 내용을 학습하면서 정리한 내용입니다. 예제로 제시된 소스코드 및 자세한 설명은 책을 참조하세요~ 


07. 중단 및 종료

 
- 작업이나 스레드를 시작시키기는 쉽다. 하지만 대부분의 경우 시작된 작업이 언제 멈출지는 그 작업이 끝까지 실행돼 봐야 알 수 있게 되어 있다.

- 작업이나 스레드를 안전하고 빠르고 안정적으로 멈추게 하는 것은 어려운 일이다. 더군다나 자바에는 스레드가 작업을 실행하고 있을 때 강제로 멈추도록 하는 방법이 없다. ( 예전에 있었지만 지금은 사용하지 않는 Thread.stop 과 Thread.suspend 메소드는 이런 기능을 제공하려고 시도했던 기능이다. 하지만 기능을 만든 지 얼마 되지 않아 문제가 많다는 사실을 깨달았고, 이제는 사용하지 말아야 할 기능이 됐다. ) 대신 인터럽트( interrupt ) 라는 방법을 사용할 수 있게 되어 있는데, 인터럽트는 특정 스레드에게 작업을 멈춰 달라고 요청하는 형태이다.

- 실제 상황에서 특정 스레드나 서비스를 '즉시' 멈춰야 할 경우는 거의 없고, 강제로 종료하면 공유되어 있는 여러 가지 상태가 비정상적인 상태에 놓을 수 있기 때문에 스레드 간의 협력을 통한 접근 방법이 올바르다고 할 수 있다. 다시 말하면 작업이나 서비스를 실행하는 부분의 코드를 작성할 때 멈춰달라는 요청을 받으면 진행 중이던 작업을 모두 정리한 다음 종료하도록 만들어야 한다. 실행 중이던 일을 중단할 때 정상적인 상태에서 마무리하려면 작업을 진행하던 스레드가 직접 마무리하는 것이 가장 적절한 방법이다. 따라서 작업이나 스레드가 스스로 작업을 멈출 수 있도록 구성해두면 시스템의 유연성이 크게 늘어날 것이다.


7.1. 작업 중단.

- 실행 중인 작업을 취소하고자 하는 요구 사항은 여러 가지 경우에 나타난다.

* 사용자가 취소하기를 요청한 경우

* 시간이 제한된 작업

* 애플리케이션 이벤트 : 여러 루트를 돌려 그 중 하나를 얻었다면, 나머지 루트  취소

* 오류

* 종료 


- 작업 취소를 요청하는 스레드와 작업을 수행하는 스레드가 협력적인 방법으로 작업을 취소하는 가장 기본적인 형태는 바로 취소 요청하는 스레드가 '취소 요청이 들어왔다' 는 플래그를 설정하고, 실행 중인 스레드의 작업은 취소 요청 플래그를 주기적으로 확인하는 방법이다.
 
- 작업을 쉽게 취소시킬 수 있도록 만들려면 작업을 취소하려 할 때 '어떻게', '언제', '어떤 일'을 해야 하는지, 이른바 취소 정책( Cancellation Policy )을 명확히 정의해야 한다.  다시 말하면 외부 프로그램에서 작업을 취소하려 할 때 어떤 방법으로 취소 요청을 보낼 수 있는지, 작업 내부에서 취소 요청이 들어 왔는지를 언제 확인하는지, 취소 요청이 들어오면 실행 중이던 작업이 어떤 형태로 동작하는지 등에 대한 정보를 제공해야 안전하게 사용할 수 있다.


7.1.1. 인터럽트


- 취소를 위한 Custom flag 사용시 작업 내부에서 BlockingQueue.put 과 같은 블로킹 메소드를 호출하는 부분이 있다면 큰 문제가 발생할 수 있다. 작업 내부에서 취소 요청이 들어 왔는지를 확인하지 못하는 경우도 생길 수 있고, 그런 상황에서는 작업이 영원히 멈추지 않을 수도 있다.

- 블로킹 될 수 있는 라이브러리 가운데 일부는 인터럽트를 걸 수 있다. 스레드에 거는 인터럽트는 특정 스레드에게 적당한 상황이고 작업을 멈추려는 의지가 있는 상황이라면, 현재 실행 중이던 작업을 멈추고 다른 일을 할 수 있도록 해야 한다고 신호를 보내는 것과 같다.

API나 언어 명세 어디를 보더라도 인터럽트가 작업을 취소하는 과정에 어떤 역할을 하는지에 대해 명시되어 있는 부분은 없다. 하지만 실제 생황에서는 작업을 중단하고자 하는 부분이 아닌 다른 부분에 인터럽트를 사용한다면 오류가 발생하기 쉬울 수밖에 없으며, 애플리케이션 규모가 커질수록 관리하기도 어려워진다.


 - 모든 스레드는 불린 값으로 인터럽트 상태를 갖고 있다. 스레드에 인터럽트를 걸면 인터럽트 상태 변수의 값이 true 로 설정된다.

- interrupt 메소드는 해당하는 스레드에 인터럽트를 거는 역할을 하고, isInterrupted 메소드는 해당 스레드에 인터럽트가 걸려 있는지를 알려준다. 스태틱으로 선언된 interrupted 메소드를 호출하면 현재 스레드의 인터럽트 상태를 해제하고, 해제하기 이전의 값이 무엇이었는지를 알려준다. ( interrupted 라는 이름으로는 유추하기 어려운 기능이다. ) interrupted 메소드는 인터럽트 상태를 해제할 수 있는 유일한 방법이다.

- Thread.sleep 이나 object.wait 메소드와 같은 블로킹 메소드는 인터럽트 상태를 확인하고 있다가 인터럽트가 걸리면 즉시 리턴된다. Thread.sleep 이나 Object.wait 메소드에서 대기하던 중에 인터럽트가 걸리면 인터럽트 상태를 해제하면서 InterruptedException 을 던진다. 여기서 던지는 InterruptedException 은 인터럽트가 발생해 대기 중이던 상태가 예상보다 빨리 끝났다는 것을 뜻한다. Thread.sleep 이나 Object.wait 메소드에서 인터럽트가 걸렸을 때, 인터럽트가 걸렸다는 사실을 얼마나 빠르게 확인하는지는 JVM에서도 아무런 보장을 하지 않는다. ( 하지만 보통 늦게 반응하는 경우는 없다고 본다. )

- 스레드가 블록되어 있지 않은 실행 상태에서 인터럽트가 걸린다면, 먼저 인터럽트 상태 변수가 설정되긴 하지만 인터럽트가 걸렸는지 확인하고, 인터럽트가 걸렸을 경우 그에 대응하는 일은 해당 스레드에서 알아서 해야 한다.

특정 스레드의 interrupt 메소드를 호출한다 해도 해당 스레드가 처리하던 작업을 멈추지 않는다. 단지 해당 스레드에게 인터럽트 요청이 있었다는 메세지를 전달할 뿐이다.


- 인터럽트를 이해하고자 할 때 중요한 사항이 있는데, 바로 실행 중인 스레드에 실제적인 제한을 가해 멈추도록 하지 않는다는 것이다. 단지 해당하는 스레드가 상황을 봐서 스스로 멈춰주기를 요청하는 것뿐이다. ( 스레드가 멈추기 좋은 상황을 취소 포인트( cancellation point ) 라고 한다. ).

- wait, sleep, join 과 같은 메소드는 인터럽트 요청을 굉장히 심각하게 처리하는데, 실제로 인터럽트 요청을 받거나 실행할 때 인터럽트 상태라고 지정했던 시점이 되는 순간 예외를 띄운다. 인터럽트에 잘 대응하도록 만들어져 있는 메소드는 인터럽트가 걸리는 상황을 정확하게 기록해뒀다가 자신을 호출한 메소드가 인터럽트 상태에 따라서 다른 방법으로 동작할 수 있도록 정보를 제공하기도 한다. 하지만 인터럽트에 제대로 대응하지 못하는 메소드는 인터럽트 요청을 통채로 삼켜버리고는, 호출한 메소드에서도 인터럽트 상황을 전혀 알지 못하게 막아버리기도 한다.

- static interrupted 메소드는 현재 스레드의 인터럽트 상태를 초기화하기 때문에 사용할 때에 상당히 주의를 기울여야 한다. interrupted 메소드를 호출했는데 결과 값으로 true 가 넘어왔다고 해보자. 만약 인터럽트 요청을 꿀꺽 삼켜버릴 생각이 아니라면 인터럽트에 대응하는 어떤 작업을 진행해야 한다.

작업 취소 기능을 구현하고자 할 때는 인터럽트가 가장 적절한 방법이라고 볼 수 있다. 


- 반복문의 맨 앞에서 인터럽트 상태를 확인하면 소수를 계산하는 것처럼 시간이 오래 걸리는 작업을 시작조차 하지 않도록 할 수 있기 때문에 취소되는 시점에 CPU 등의 자원을 덜 사용하게 되어 그 의미를 충분히 찾을 수 있다. 다시 말해 인터럽트에 반응하는 블로킹 메소드를 상대적으로 적게 사용하고 있다면, 반복문의 조건 확인 부분에서 인터럽트 여부를 확인하는 방법으로 응답 속도를 개선할 수 있다.



7.1.2. 인터럽트 정책


- 단일 작업마다 해당 작업을 멈출 수 있는 취소 정책이 있는 것처럼 스레드 역시 인터럽트 처리 정책이 있어야 한다. 인터럽트 처리 정책은 인터럽트 요청이 들어 왔을 때, 해당 스레드가 인터럽트를 어떻게 처리해야 하는지에 대한 지침이다.

- 일반적으로 가장 범용적인 인터럽트 정책은 스레드 수준이나 서비스 수준에서 작업 중단 기능을 제공하는 것이다. 실질적인 수준에서 최대한 빠르게 중단시킬 수 있고, 사용하던 자원은 적절하게 정리하고, 심지어는 가능하다면 작업 중단을 요청한 스레드에게 작업을 중단하고 있다는 사실을 어떻게든 알려줄 수 있다면 가장 좋겠다.

- 작업( task )와 스레드( thread )가 인터럽트 상황에서 서로 어떻게 동작해야 하는지를 명확히 구분할 필요가 있다. 인터럽트 요청 하나로 중단시키고자 하는 대상이 여럿일 수 있는데, 예를 들어 스레드 풀에서 작업을 실행하는 스레드에 인터럽트를 거는 것은 '현재 작업을 중단하라' 는 의미일 수도 있고, '작업 스레드를 중단시켜라' 는 뜻일 수도 있다.

- 작업은 그 작업을 소유하는 스레드에서 실행되지 않고, 스레드 풀과 같이 실행만 전담하는 스레드를 빌려 사용하게 된다. 실제로 작업을 실행하는 스레드를 갖고 있지 않은 프로그램( 스레드 풀을 예로 들자면 스레드 풀에 작업을 넘기는 모든 클래스 )은 작업을 실행하는 스레드의 인터럽트 상태를 그대로 유지해 스레드를 소유하는 프로그램이 인터럽트 상태에 직접 대응할 수 있도록 해야 한다. 스레드에서 실행되는 작업이 인터럽트를 처리하도록 되어 있다 해도 말이다.

- 대부분의 블로킹 메소드에서 인터럽트가 걸렸을 때 InterruptedException을 던지도록 되어 있는 이유가 바로 이것 때문이다. 블로킹 메소드를 스스로의 스레드에서 실행하는 일은 전혀 없기 때문에 외부 작업이나 자바 내부의 라이브러리 메소드에서 동시에 적용할 수 있는 가장 적절한 인터럽트 정책, 즉 실행 중에 최대한 빨리 작업을 중단하고 자신ㅇ르 호출한 스레드에게 전달받은 인터럽트 요청을 넘겨 인터럽트에 대응해 추가적인 작업을 할 수 있도록 배려하는 정책을 구현하고 있다.

- 개별 작업은 스스로가 특별한 인터럽트 정책에 대응하도록 만들어져 있지 않은 한 자신을 실행하는 스레드에서 적용하고 있는 인터럽트 정책에 대해 어떠한 가정도 해서는 안 된다. 작업 실행 도중에 인터럽트가 걸렸을 때 인터럽트 상황을 작업 중단이라는 의미로 해석할 수도 있고, 아니면 인터럽트에 대응해 뭔가 작업을 처리할 수도 있는데, 어찌 됐건 작업을 실행중인 스레드의 인터럽트 상태는 그대로 유지시켜야 한다. 가장 일반적인 방법은 InterruptedException 을 던지는 것인데, 그렇게 하지 못한다해도 다음과 같은 코드를 실행해 스레드의 인터럽트 상태를 유지해야 한다.

Thread.currentThread().interrupt();


- 작업을 실행하는 스레드에서 인터럽트가 발생했을 때 어떤 의미를 갖는지를 작업 클래스의 코드에서 아무렇게나 가정해서는 안 되는 것처럼, 작업 취소 기능을 담당하는 코드 역시 각종 스레드에 대한 인터럽트 정책이 어떻다고 섣불리 가정하면 안 된다. 스레드에는 해당 스레드를 소유하는 클래스에서만 인터럽트를 걸어야 한다.

각 스레드는 각자의 인터럽트 정책을 갖고 있다. 해당 스레드에서 인터럽트 요청을 받았을 때 어떻게 동작할지를 정확하게 알고 있지 않은 경우에는 함부로 인터럽트를 걸어서는 안 된다.





7.1.3. 인터럽트에 대한 대응


- 블로킹 메소드를 호출하는 경우에 InterruptedException 이 발생했을 때 처리할 수 있는 실질적인 방법에는 대략 두 가지가 있다.

* 발생한 예외를 호출 스택의 상위 메소드로 전달한다. 이 방법을 사용하는 메소드 역시 인터럽트를 걸 수 있는 블로킹 메소드가 된다.
* 호출 스택의 상단에 위치한 메소드가 직접 처리할 수 있도록 인터럽트 상태를 유지한다. 


- InterruptedException을 상위 메소드로 전달할 수 없거나 ( Runnable 인터페이스를 구현해 작업을 정의한 경우 ) 전달하지 않고자 하는 상황이라면 인터럽트 요청이 들어왔다는 것을 유지할 수 있는 다른 방법을 찾아야 한다. 인터럽트 상태를 유지할 수 있는 가장 일반적인 방법은 interrupt 메소드를 다시 한 번 호출하는 것이다. 반대로 catch 블록에서 InterruptedException을 잡아낸 다음 아무런 행동을 취하지 않고 말 그대로 예외를 먹어버리는 일은 하지 말아야 한다.

- 대부분의 프로그램 코드는 자신이 어느 스레드에서 동작할지 모르기 때문에 인터럽트 상태를 최대한 그대로 유지해야 한다.

스레드의 인터럽트 처리 정책을 정확하게 구현하는 작업만이 인터럽트 요청을 삼켜버릴 수 있다. 일반적인 용도로 작성된 작업이나 라이브러리 메소드는 인터럽트 요청을 그냥 삼켜버려서는 안 된다.


- 작업 중단 기능을 지원하지 않으면서 인터럽트를 걸 수 있는 블로킹 메소드를 호출하는 작업은 인터럽트가 걸렸을 때 블로킹 메소드의 기능을 자동으로 재시도하도록 반복문 내부에서 블로킹 메소드를 호출하도록 구성하는 것이 좋다. 이런 경우 InterruptedException 이 발생하는 즉시 인터럽트 상태를 지정하는 대신 인터럽트 상태를 내부적으로 보관하고 있다가 메소드가 리턴되기 직전에 인터럽트 상태를 원래대로 복구하고 리턴되도록 해야 한다.

- 인터럽트를 걸 수 있는 블로킹 메소드는 대부분 실행되자마자 가장 먼저 인터럽트 상태를 확인하며 인터럽트가 걸린 상태라면 즉시 InterruptedException 을 던지는 경우가 많기 때문에, 인터럽트 상태를 너무 일찍 지정하면 반복문이 무한반복에 빠질 수 있다. ( 인터럽트가 걸릴 수 있는 메소드는 일반적으로 대기 상태에 들어가기 전이나 복잡한 작업을 시작하기 전에 인터럽트 상태를 확인하도록 되어 있는데, 시간이 오래 걸리는 작업 이전에 인터럽트 상태를 한 번 확인해야 인터럽트에 대한 응답 속도를 최대한 높일 수 있기 때문이다. )

- 작업 코드에서 인터럽트가 걸릴 수 있는 블로킹 메소드를 전혀 사용하지 않는다 해도 작업이 진행되는 과정 곳곳에서 현재 스레드의 인터럽트 상태를 확인해준다면, 인터럽트에 대한 응답 속도를 크게 높일 수 있다. 인터럽트 상태를 얼마만에 한 번씩 확인할 것인지 주기를 결정할 때에는 응답 속도와 효율성 측면에서 적절한 타협점을 찾아야 한다.

- ThreadPoolExecutor 내부의 풀에 등록되어 있는 스레드에 인터럽트가 걸렸다면, 인터럽트가 걸린 스레드는 전체 스레드 풀이 종료되는 상태인지를 먼저 확인한다. 스레드 풀 자체가 종료되는 상태였다면 스레드를 종료하기 전에 스레드 풀을 정리하는 작업을 실행하고, 스레드 풀이 종료되는 상태가 아니라면 스레드 풀에서 동작하는 스레드의 수를 그대로 유지시킬 수 있도록 새로운 스레드를 하나 생성해 풀에 등록시킨다.



7.1.4. 예제 : 시간 지정 실행




7.1.5. Future 를 사용해 작업 중단


- Future 에는 cancel 메소드가 있는데 mayInterruptIfRunning 이라는 불린 값을 하나 넘겨 받으며, 취소 요청에 따른 작업 중단 시도가 성공적이었는지를 알려주는 결과 값을 리턴받을 수 있다.( 여기에서 작업 중단 시도가 성공적이었다는 의미는 인터럽트를 제대로 걸었다는 의미이며, 해당 작업이 인터럽트에 반응해 실제로 작업을 중단했다는 것을 뜻하지는 않는다. ).

- cancel 메소드를 호출할 때 mayInterruptIfRunning 값으로 true 를 넘겨줬고 작업이 어느 스레드에서건 실행되고 있었다면, 해당 스레드에 인터럽트가 걸린다. mayInterruptIfRunning 값으로 false 를 넘겨주면 "아직 실행하지 않았다면 실행시키지 말아라" 는 의미로 해석되며 인터럽트에 대응하도록 만들어지지 않은 작업에는 항상 false 를 넘겨줘야 한다.

- Executor 에서 기본적으로 작업을 실행하기 위해 생성하는 스레드는 인터럽트가 걸렸을 때 작업을 중단할 수 있도록 하는 인터럽트 정책을 사용한다. 따라서 기본 Executor 에 작업을 등록하고 넘겨받은 Future 에서는 cancel 메소드에 mayInterruptIfRunning 값으로 true 를 넘겨 호출해도 아무런 문제가 없다. 물론 스레드 풀에 들어 있는 스레드에 함부로 인터럽트를 거는 일은 여전히 안 되는데, 해당 스레드에 인터럽트가 걸리는 시점에 어떤 작업을 실행하고 있을 지 알 수 없기 때문이다. 따라서 작업을 중단하려 할 때는 항상 스레드에 직접 인터럽트를 거는 대신 Future 의 cancel 메소드를 사용해야 한다. 작업을 구현할 때 인터럽트가 걸리면 작업을 중단하라는 요청으로 해석하고 그에 따라 행동하도록 만들어야 하는 또 다른 이유라고 볼 수 있는데, 그러면 Future 를 통해 쉽게 작업을 중단시킬 수 있기 때문이다.

Future.get 메소드에서 InterruptedException 이 발생하거나 TimeoutException 이 발생했을 때, 만약 예외 상황이 발생한 작업의 결과는 필요가 없다고 한다면 해당 작업에 대해 Future.cancel 메소드를 호출해 작업을 중단시키자.





7.1.6. 인터럽트에 응답하지 않는 블로킹 작업 다루기.


- 자바 라이브러리에 포함된 여러 블로킹 메소드는 대부분 인터럽트가 발생하는 즉시 멈추면서 InterruptedException 을 띄우도록 되어 있으며, 따라서 작업 중단 요청에 적절하게 대응하는 작업을 쉽게 구현할 수 있다. 그런데 잘 살펴보면 모든 블로킹 메소드가 인터럽트에 대응하도록 되어 있지는 않다.

- 동기적인 소켓 I/O 를 실행하는 도중에 스레드가 멈춰 있는 경우라던가 암묵적인( intrinsic ) 락을 확보하기 위해 대기하는 등의 작업에 멈춰있는 경우라면, 인터럽트를 거는 것이 인터럽트 상태 변수의 값을 설정하는 것 말고는 아무런 실제적 효과가 없다.

* java.io 패키지의 동기적 소켓 I/O : InputStream 클래스의 read 메소드와 OutputStream 의 write 메소드가 인터럽트에 반응하지 않도록 되어 있다. 해당 스트림이 연결된 소켓을 직접 닫으면 대기 중이던 read 나 write 메소드가 중단되면서 SocketException 이 발생한다.

* java.nio 패키지의 동기적 I/O : InterruptibleChannel 에서 대기하고 있는 스레드에 인터럽트를 걸면 ClosedByInterruptException 이 발생하면서 해당 채널이 닫힌다. ( 더불어 해당 채널에서 대기하고 있던 모든 스레드에서 ClosedByInterruptException 이 발생한다. ) . InterruptibleChannel을 닫으면 해당 채널로 작업을 실행하던 스레드에서 AsynchronousCloseException 이 발생한다. 대부분의 표준 Channle 은 모두 InterruptibleChannel 을 구현한다.

* Selector를 사용한 비동기적 I/O : 스레드가 Selector 클래스( java.nio.channels 패키지 ) 의 select 메소드에서 대기 중인 경우, close 메소드를 호출하면 ClosedSelectorException 을 발생시키면서 즉시 리턴된다.

* 락 확보 : 스레드가 암묵적인 락을 확보하기 위해 대기 상태에 들어가 있는 경우 언젠가 락을 확보할 수 있을 것이라는 보장을 하지 못할 뿐더러 어떤 방법으로든 다음 상태로 진행시켜 스레드의 주의를 끌 수 없기 때문에 어떻게 해 볼 방법이 없다. 하지만 Lock 인터페이스를 구현한 락 클래스의 lockInterruptibly 메소드를 사용하면 락을 확보할 때까지 대기하면서 인터럽트에도 응답하도록 구현할 수 있다. 





7.1.7. newTaskFor 메소드로 비표준적인 중단 방법 처리


- 자바6 버전의 ThreadPoolExecutor 클래스의 newTaskFor 메소드는 등록된 작업을 나타내는 Future 객체를 리턴해주는데, 이전과는 다른 RunnableFurue 객체를 리턴한다. RunnableFuture 인터페이스는 Future 와 Runnable 인터페이스를 모두 상속받으며, FutureTask 는 자바 5에서 Future 를 구현했지만, 자바 6에서는 RunnableFuture 를 구현한다.

- Future.cancel 메소드를 오버라이드하면 작업 중단 과정을 원하는 대로 변경할 수 있다. 이를테면 작업 중단 과정에서 필요한 내용을 로그 파일로 남긴다거나 몇 가지 통계 값을 보관하는 등의 작어을 할 수 있고, 인터럽트에 제대로 대응하지 않는 작업을 중단하도록 할 수 있다.








반응형

댓글