본문 바로가기

개발 개발/자바

[Thread] 자바 Thread의 interrupt 이해하기

출처 : http://javafreak.tistory.com/210

처음 자바를 배울때 가장 난해한 개념이 Thread의 인터럽트였던걸로 기억한다.

"인터럽트를 건다"는 개념도 생소했거니와 그래서 어떤 영향을 미치는가? 를 도무지 알 길이 없었다. 왜냐하면 thread에 대한 이해도 일천했는데다가  자바 쓰레드에서 언급되는 "인터럽트"와 운영체제에서 배우던 인터럽트가 상호 교차되어 퓨전 떡볶이처럼 두리뭉실하게 머릿속에 자리잡았기 때문인 듯 하다.

운영체제를 배울때의 인터럽트를 간단하게 정리해보면 

1. cpu가 무슨 일을 열심히하고 있는데
2. 어디선가 인터럽트 신호가 들어온다.
3. cpu는 현재 하고 있는 job을 대강 정리하고 상태를 보존해놓는다.
4. 인터럽트 신호를 따라가서 인터럽트 처리 루틴을 실행해서 문제를 해결해주고
5. 아까 중간에 멈췄던 job의 상태를 복원해서 그 일을 마저 한다.

위와같은데 하던 일을 잠깐 멈추고 다른 일을 봐주고, 다시 돌아와서 아까 그 일을 마저 해주는 식이니 일종의 "새치기"라고 보면 된다.(사실 새치기로 번역한책도 있었다 -_-;)

하지만 자바 인터럽트는 의미가 약간 다른데 한쪽 thread가 다른 thread에게 신호만 보낼 뿐이다. 정상적인 경우라면(프로그램을 제대로 짰을 경우) 쓰레드에 인터럽트가 걸렸을 때 적절한 조치를 취하겠지만 프로그램 구조를 잘못 잡았을 경우에는 인터럽트 신호를 받은 쓰레드가 이 신호를 그냥 "씹어먹고" 지나쳐버릴 수가 있다.(대역죄인...)

예를 들어서 아래와 같은 코드를 보면 Main 쓰레드가 yielder 쓰레드에게 인터럽트 신호를 보냈으나 이를 전달받은 yielder 쓰레드가 보고도 모른척하고 그냥 지 하던 일을 계속 하는 모습을 볼 수 있다.
public class ThreaInterrupt implements Runnable{
    static private StringBuffer buf = new StringBuffer();
    static long start  = System.currentTimeMillis();
    static String lastMassage = "";
    
    public void run(){
        long end = System.currentTimeMillis();
        String message = "";
        while ( true ) {
            Thread.yield();
            
            if ( Thread.currentThread().isInterrupted())
                message = "Yielder : interrupted. ";
            
            if ( end - start > 1500){
                message += "Yielder : time over ";
                break;
            }
            else message += "Yielder : keep going!!! ";
            end = System.currentTimeMillis();
            print(message);
            message = "";
        }
        print(" : Yielder ends");
    }
    
    static void print(String msg){
        synchronized (buf) {
            if ( lastMassage.equals(msg)) return;        
            buf.append((System.currentTimeMillis() - start ) + " " + msg + "\n");
            lastMassage = msg;
        }
    }
    public static void main(String[] args) {
        // on the main thread stack
        
        Thread yielder = new Thread(new ThreaInterrupt());
        yielder.start(); // main thread makes yielder thread begin
        try {
            Thread.sleep(500);
            yielder.interrupt();
            print(">>>>>>>>>>>> first interrupt! ");
        } catch (InterruptedException e) {}
        
        try {
            Thread.sleep(1000);
            yielder.interrupt();
            print(">>>>>>>>>>>> second interrupt! ");
        } catch (InterruptedException e) {}
        print("Main Thread ends");
        
        try {    yielder.join(); } catch (InterruptedException e) {}
        
        System.out.println(buf.toString());
    }
}
0 Yielder : keep going!!! 
500 Yielder : interrupted. Yielder : keep going!!! 
500 >>>>>>>>>>>> first interrupt! 
500 Yielder : interrupted. Yielder : keep going!!! 
1500 >>>>>>>>>>>> second interrupt! 
1500 Yielder : interrupted. Yielder : keep going!!! 
1500 Main Thread ends
1500 Yielder : interrupted. Yielder : keep going!!! 
1501  : Yielder ends

메인쓰레드는 0.5초와 1초에 yielder 쓰레드에게 두차례나 인터럽트 신호를 보냈지만 이를 확인한 yielder 쓰레드는 이 신호를 적절히 처리하지 않고(무시하고) 자신에게 주어진 1.5초동안 계속해서 루프를 돌다가 끝나는 모습이다.

위에서 보여주려고 한 것은 yield와 같이 블로킹 되지 않는 메소드가 실행되는 와중에 인터럽트가 걸려 있음을 보여주려는 것이다. yield가 아니더라도 쓰레드가 블로킹 되지 않고 계속 실행 중인 상태에서는 인터럽트가 걸렸을때 반드시 루프 안에서

Thread.currentThread.isInterrupted();

메소드로 확인을 해야만 한다. "실행 중인 상태"를 강조하는 것은 Thread.sleep();이나 Obejct.wait(); 와 같은 블로킹 메소드를 실행해서 쓰레드가 BLOCK 상태(실행중이지 않은 상태)에 있을때에는 외부에서 인터럽트 신호가 들어오면 지체하지 않고 실행 상태로 전환되기 때문이다. 즉, 쓰레드가 실행중인 상태가 아니라면 인터럽트 신호가 전달될 때 반드시 "깨어나게" 된다.

그런데 실행중이지 않은 상태에서 인터럽트 신호가 들어와서 단잠에서 깨어나면 이때는 이미 인터럽트 신호가 해제된 상태이다.
public class ThreadInterrupt2 implements Runnable{

    public void run(){
        try {
            Thread.sleep(10* 1000);
        } catch ( InterruptedException e) {
            if ( Thread.currentThread().isInterrupted() )
                System.out.println("interruped state");
            else
                System.out.println("not interrupted");
        }
        System.out.println("sleeper finshed");
    }
    public static void main(String[] args) {
        Thread sleeper = new Thread(new ThreadInterrupt2());
        sleeper.start();
        
        try {
            Thread.sleep(2*1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        sleeper.interrupt(); // send interrupt signal to sleeper
        System.out.println("main finished");
    }
}
not interrupted
sleeper finshed
main finished

위에서 sleeper 쓰레드가 run() 메소드를 실행하면 10초간 BLOCK 상태로 들어가있게 된다. 즉 별다른 일이 없었다면 sleeper 쓰레드는 10초 후에 깨어나게 된다.

그러나 메인 쓰레드가 2초 후에 sleeper.interrupt(); 를 호출하고 이로 인해서 한참 잠을 자고 있던 sleepr 쓰레드는 BLOCK 상태에서 깨어나고 이 때 InterruptedException을 전달받으며 catch 절부터 다시 실행하게 된다.

하지만 이렇게 자다가 깨어났을때에는 위 코드에서 보듯이 인터럽트 신호가 해제된 상태이다. 반면에, yielder 쓰레드처럼 블로킹되지 않은, 실행중인 상태의 쓰레드가 인터럽트 신호를 받았을때에는 isInterrupted() 메소드로 확인을 하지 않는 신호가 왔는지도 모르고 계속 자기 할 일만 하게 된다.
실행중인 쓰레드는 인터럽트 신호를 받아도 본인이 확인하기 전까지는 이를 알지 못한다.
블로킹 상태의 쓰레드는 인터럽트 신호를 받으면 곧바로 깨어난다. 하지만 깨어났을때에는 이미 인터럽트 신호가 해제된 상태이다.
이렇게 쓰레드의 상태에 따라서 인터럽트 신호에 대한 반응이 다르기 때문에 run() 메소드 안에서는 이 두가지 상태에서 인터럽트 신호를 처리하는 로직을 제대로 갖추고 있어야 한다.

결국 쓰레드간에 주고받는 인터럽트는 단순히 신호를 보내서 "너 인터럽트 걸렸다"라고 말해주는 것 외에 아무런 기능이 없다. 한 쓰레드가 다른 쓰레드를 멈추게 하거나 종료하게 할 수 있는 수단은 전혀 없고 오로지 쓰레드 본인만이 이를 결정할 수 있다.

결국 남는 것은 쓰레드간에 주고받는 인터럽트 신호를 어떤 의미로 사용할 것인가? 하는 것이다.

일례로 큐에서 요청을 하나씩 가져다가 파일을 다운로드하는 기능을 멀티스레드로 구현할 경우, 파일 내려받기를 잠시 멈추거나 또는 아예 취소할 수 있어야 한다. 이럴 경우 FileDownloader 는 "시작전" "내려받기중" "잠시 정지" "취소중" "대기중" "종료중" 과 같은 다양한 상태를 왔다갔다하게 되고 이런 상태 변화는 외부에서 start, pause, resume, cancel 등 메소드 호출을 통해서 이루어진다.
public interface FileDownloader {
    /**
     * 다운로드 시작
     */

    public void start();
    /**
     * 일시 정지
     */

    public void pause();
    /**
     * 내려받기 재개함
     */

    public void resume();
    /**
     * 현재 내려받기를 취소
     */

    public void cancel();
    /**
     * 완전히 종료
     */

    public void exit();
}
FileDownloader 에 대한 참조를 통해 요청을 전달하는 외부 스레드들은는 인터페이스를 통해서 호출만 하고 원하는대로 수행되기를 기대한다. 인터럽트 신호를 걸거나 이 신호에 반응해서 작업을 잠시 중지할지, 이미 중지 상태라면 하던 일을 마저 계속 할지, 아니면 아예 취소해버리고 다른 작업을 기다릴지는 전적으로 FileDownloader 를 구현하는 클래스 내부에서만 이루어져야 한다.
public class DefaultFileDownloader implements FileDownloader, Runnable {
    Thread thisThread = null;
    private String INIT = "INIT";
    private String READY = "READY";
    private String RUNNING = "RUNNING";
    private String PAUSED = "PAUSED";
    private String EXIT = "EXIT";
    
    private String state =  INIT;
    
    DefaultFileDownloader(){    }
    
    @Override
    public void start() {
        if ( this.state != INIT){
            throw new IllegalStateException("already started");
        }
        // 직접 생성한 쓰레드에 대해서 인터럽트를 걸도록 한다.
        thisThread = new Thread(this);
        thisThread.start();
        this.state = READY;
    }
    
    @Override
    public void cancel() {
        if ( this.state != EXIT){
            throw new IllegalStateException("already cancelled");
        }
        this.state = EXIT;
        thisThread.interrupt(); // 인터럽트 신호를 설정해놓는다.
    }
        ..... // rest of implemetation
}
FileDownloader 인터페이스를 구현한 DefaultFileDownloader는 본인이 직접 쓰레드를 생성하고 관리하는 책임을 맡고 있다. 따라서 인터럽트 신호가 들어왔을 때 이를 어떻게 해석하고 어떤 행동을 할지는 DefaultFileDownloader 안에서 이루어져야 한다. 위에서 말했듯이 인터럽트 신호는 말 그대로 신호에 불과하기 때문에 이 신호를 전달 받고, 확인하고 여기에 반응하는 모든 행위들은 DefaultFileDownloader 안에서만 은밀하게(?) 관리되어야 한다.

만일 DefaultFileDownloader를 사용하는 스윙 애플리케이션에서 한참 내려받던 파일을 취소하려는데, 단순히 cancel() 메소드 호출 대신에 DefaultFileDownloader가 생성하고 시작시킨 thisThread 쓰레드에 대한 참조를 얻어서 직접 인터럽트 신호를 보낸다면(thisThread.interrupte()) 어떻게 될까?

캡슐화의 원칙에도 위배되지만 더 큰 문제는 DefaultFileDownloader 바깥의 다른 컴포넌트들은 "인터럽트 신호"가 thisThread에게 어떤 영항을 미치는지 알 수 없기 때문에(DefaultFileDownloader의 실제 구현 내용을 모르기 때문에) 의도하지 않은 결과를 가져올 수가 있다.

따라서 인터럽트 신호를 어떤 식으로 이용하고 있는지 가장 잘 알고 있는 DefaultFileDownloader만이 thisThread에게 인터럽트 신호를 보내고 이를 처리하도록 하는게 멀티쓰레드 환경에서 삑사리(?)를 방지하고 코드가 난잡해지는 것을 막을 수 있다.