728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

인터럽트

특정 스레드의 작업을 중간에 중단하려면 어떻게 해야할까?

다음 코드를 보자.

 

ThreadStopMainV1

package thread.control.interrupt;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class ThreadStopMainV1 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(4000);
        task.runFlag = false;
        log("runFlag = false로 변경");
    }

    static class MyTask implements Runnable {

        volatile boolean runFlag = true;

        @Override
        public void run() {
            while (runFlag) {
                log("작업 중");
                sleep(3000);
            }
            log("자원 정리");
            log("작업 종료");
        }
    }
}
  • 특정 스레드의 작업을 중단하는 가장 쉬운 방법은 변수를 사용하는 것이다.
  • 여기서는 runFlag를 사용해서 work 스레드에 작업 중단을 지시할 수 있다.
  • 작업 하나에 3초가 걸린다고 가정하고, sleep(3000)을 사용하자.
  • main 스레드는 4초 뒤에 작업 중단을 지시한다.
  • volatile 키워드는 뒤에서 자세히 설명한다. 지금은 단순히 여러 스레드에서 공유하는 값에 사용하는 키워드라고 알아두자.

실행결과

2024-07-19 10:08:20.946 [     work] 작업 중
2024-07-19 10:08:23.950 [     work] 작업 중
2024-07-19 10:08:24.920 [     main] runFlag = false로 변경
2024-07-19 10:08:26.952 [     work] 자원 정리
2024-07-19 10:08:26.954 [     work] 작업 종료

 

문제점

실행해보면 바로 느끼겠지만 main 스레드가 runFlag=false를 통해 작업 중단을 지시해도, work 스레드가 즉각 반응하지 않는다. 왜냐하면 MyTaskrun()에는 sleep(3000)가 있기 때문이다. 3초간 잠들어 있는 상태에서는 runFlag 값이 false여도 잠이 깬 다음 while(runFlag) 코드를 실행해야 작업을 중단할 수 있다. 어떻게 하면 sleep()처럼 스레드가 대기하는 상태에서 스레드를 깨우고, 작업도 빨리 종료할 수 있을까?

 

인터럽트 코드 추가

예를 들어, 특정 스레드가 Thread.sleep()을 통해 쉬고 있는데 처리해야 하는 작업이 들어와서 해당 스레드를 급하게 깨워야 할 수 있다. 또는 sleep()으로 쉬고 있는 스레드에게 더는 일이 없으니 작업 종료를 지시할 수도 있다. 

 

인터럽트를 사용하면 WAITING, TIMED_WAITING 같은 대기 상태의 스레드를 직접 깨워서 작동하는 RUNNABLE 상태로 만들 수 있다. 아래 예제를 보자.

 

ThreadStopMainV2

package thread.control.interrupt;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class ThreadStopMainV2 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(4000);
        log("작업중단 지시: thread.interrupt()");
        thread.interrupt();
        log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            try {
                while (true) {
                    log("작업 중");
                    Thread.sleep(3000);
                }
            } catch (InterruptedException e) {
                log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());
                log("interrupt message = " + e.getMessage());
                log("state = " + Thread.currentThread().getState());
            }

            log("자원 정리");
            log("작업 종료");
        }
    }
}
  • 예제의 run()에서 Thread.sleep(3000)를 사용했다. 이 sleep()InterruptedException을 처리해야 한다.
  • 특정 스레드의 인스턴스에 interrupt() 메서드를 호출하면, 해당 스레드에 인터럽트가 발생한다.
  • 인터럽트가 발생하면 해당 스레드에 InterruptedException이 발생한다.
    • 이때 인터럽트를 받은 스레드는 대기 상태에서 깨어나 RUNNABLE 상태가 되고, 코드를 정상 수행한다.
    • 이때 InterruptedExceptioncatch로 잡아서 정상 흐름으로 변경하면 된다.
  • 참고로, interrupt()를 호출했다고 해서 즉각 InterruptedException이 발생하는 것은 아니다. 오직 sleep()처럼 InterruptedException을 던지는 메서드를 호출하거나 호출 중일 때 예외가 발생한다.
    • 예를 들어, 위 코드에서 while(true), log("작업 중") 에서는 InterruptedException이 발생하지 않는다.
    • Thread.sleep()처럼 InterruptedException을 던지는 메서드를 호출하거나 또는 호출하며 대기중일 때 예외가 발생한다.

실행결과

2024-07-19 10:57:48.092 [     work] 작업 중
2024-07-19 10:57:51.096 [     work] 작업 중
2024-07-19 10:57:52.070 [     main] 작업중단 지시: thread.interrupt()
2024-07-19 10:57:52.093 [     main] work 스레드 인터럽트 상태1 = true
2024-07-19 10:57:52.093 [     work] work 스레드 인터럽트 상태2 = false
2024-07-19 10:57:52.094 [     work] interrupt message = null
2024-07-19 10:57:52.094 [     work] state = RUNNABLE
2024-07-19 10:57:52.095 [     work] 자원 정리
2024-07-19 10:57:52.095 [     work] 작업 종료
  • thread.interrupt()를 통해 작업 중단을 지시하고, 거의 즉각적으로 인터럽트가 발생한 것을 확인할 수 있다.
  • 이때 work 스레드는 TIMED_WAITING 에서 RUNNABLE 상태로 변경되면서 InterruptedException 예외가 발생한다.
  • 참고로, 스레드가 RUNNABLE 상태여야 catch의 예외 코드도 실행될 수 있다.
  • 실행 결과를 보면 work 스레드가 catch 블록 안에서 RUNNABLE 상태로 바뀐 것을 알 수 있다.

 

흐름을 자세히 뜯어보자.

  • main 스레드가 4초 뒤에 work 스레드에 interrupt()를 호출한다.
  • work 스레드는 인터럽트 상태(true)가 된다.
  • 스레드가 인터럽트 상태일 땐, sleep()처럼 InterruptedException이 발생하는 메서드를 호출하거나 또는 이미 호출해서 대기 중이라면 InterruptedException이 발생한다. 그게 아니라 log("작업 중") 이런 단계에서는 InterruptedException은 발생하지 않는다.
    • 그러니까 만약, interrupt()가 호출되서 인터럽트 상태가 true가 된 work 스레드가 현재 진행중인 코드가 log("작업 중")이면 InterruptedException이 발생하지 않고 계속 다음 코드를 진행하고 그렇게 진행하다가 InterruptedException을 발생시키는 코드인 Thread.sleep()을 만나면 InterruptedException이 터진다.
  • 이때 두가지 일이 발생한다.
    • work 스레드는 TIMED_WAITING 에서 RUNNABLE 상태로 변경되고, InterruptedException 예외를 처리하면서 반복문을 탈출한다. 
    • work 스레드는 인터럽트 상태가 된 상태고 인터럽트 예외가 발생한다. 인터럽트 예외가 발생하면 다시 이 work 스레드는 인터럽트 상태가 false가 된다. InterruptedException이 터졌으니까. 그리고 터짐과 동시에 work 스레드는 다시 작동하는 RUNNABLE 상태가 되는 것이다.

주요 로그

2024-07-19 10:57:52.093 [     main] work 스레드 인터럽트 상태1 = true //여기서 인터럽트 발생
2024-07-19 10:57:52.093 [     work] work 스레드 인터럽트 상태2 = false //이 지점은 인터럽트 예외가 터지고 다시 인터럽트 상태가 false가 된, 즉 RUNNABLE 상태가 된 지점
  • 인터럽트가 적용되고, 인터럽트 에외가 발생하면, 해당 스레드는 실행 가능 상태가 되고 인터럽트 발생 상태도 정상으로 돌아온다.
  • 인터럽트를 사용하면 대기중인 스레드를 바로 깨워서 실행 가능한 상태로 바꿀 수 있다. 덕분에 단순히 runFlag를 사용하는 이전 방식보다 반응성이 더 좋아진 것을 알 수 있다.

 

근데 한가지 위 코드에서 아쉬운 부분이 있다. 

while (true) { // 인터럽트 체크 안함
    log("작업 중");
    Thread.sleep(3000);
}

여기서 while(true) 부분은 인터럽트 체크를 하지 않는다는 점이다. 인터럽트가 저 시점에 이미 발생했다고 해도 체크하지 않기 때문에 다음 코드로 넘어가고 또 다음 코드로 넘어가서 Thread.sleep(3000); 이라는 인터럽트 예외가 발생할 수 있는 지점에 도착해서야 비로소 인터럽트가 발생한다. 

 

만약 다음과 같이 인터럽트 상태를 확인하면 더 빨리 반응할 수 있을것이다.

while (인터럽트 상태 확인) {
    log("작업 중");
    Thread.sleep(3000);
}

 

추가적으로 인터럽트의 상태를 직접 확인하면, 인터럽트를 발생시키는 sleep()과 같은 코드가 없어도 인터럽트 상태를 직접 확인하기 때문에 while 문을 빠져나갈 수 있다.

while (인터럽트 상태 확인) {
    log("작업 중");
}

 

그래서 다음과 같이 코드를 바꿔보자.

ThreadStopMainV3

package thread.control.interrupt;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class ThreadStopMainV3 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(100);
        log("작업중단 지시: thread.interrupt()");
        thread.interrupt();
        log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                log("작업 중");
            }
            log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());

            log("자원 정리");
            log("작업 종료");
        }
    }
}
  • Thread.currentThread().isInterrupted() 로 현재 스레드가 인터럽트 상태인지 확인할 수 있다.
  • 물론 이 Thread.currentThread().isInterrupted() 코드는 상태를 확인만 하지 인터럽트 상태를 변경하지는 않는다.

실행결과

...
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.008 [     main] 작업중단 지시: thread.interrupt()
2024-07-19 12:34:19.008 [     work] 작업 중
2024-07-19 12:34:19.016 [     work] work 스레드 인터럽트 상태2 = true
2024-07-19 12:34:19.016 [     main] work 스레드 인터럽트 상태1 = true
2024-07-19 12:34:19.016 [     work] 자원 정리
2024-07-19 12:34:19.016 [     work] 작업 종료
  • main 스레드는 interrupt() 메서드를 사용해서 work 스레드에 인터럽트를 건다.
  • work 스레드는 인터럽트 상태가 됐다. isInterrupted()true가 된다.
  • 이때 다음과 같이 while 조건이 인터럽트 상태가 아닌 경우에만 돌기 때문에 인터럽트 상태가 되면 이 while 문을 빠져나온다.

문제가 없어보이지만 이 코드에는 심각한 문제가 있다.

바로 work 스레드의 인터럽트 상태가 보는것과 같이 계속 true로 유지된다는 점이다.

앞서, 인터럽트 예외가 터진 경우 스레드의 인터럽트 상태는 false가 된다.

반면에 isInterrupted() 메서드는 인터럽트의 상태를 변경하지는 않고 상태를 확인만 한다.

 

그럼 원초적인 질문으로 돌아와서 왜 InterruptedException이 발생하면 다시 인터럽트 상태가 false로 변경될까?

다음과 같은 코드가 있다고 생각해보자.

@Override
public void run() {
    while (!Thread.currentThread().isInterrupted()) {
        log("작업 중");
    }
    log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());

    try {
        log("자원 정리");
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        log("인터럽트 예외 발생 - 자원 정리 실패");
    }
    log("작업 종료");
}
  • 인터럽트 상태라면 while 문을 빠져나오는 run() 메서드이다.
  • main 스레드가 work 스레드에 인터럽트를 발생시키고 이제 work 스레드는 인터럽트 상태가 된다.
  • 인터럽트 상태이니까 while 문을 빠져나온다.
  • 그러나 여전히 인터럽트 상태는 true이다.
  • 자원 정리를 시작한다. 자원 정리 시에는 상당시간 소요가 걸리는 작업이라고 가정하며 중간 중간에 sleep()을 통해 잠시 쉬어가야 하는 부분도 있다고 해보자.
  • 그러나 자원 정리 중 만나는 Thread.sleep(1000); 에서 인터럽트 예외가 발생한다. 왜? 인터럽트 상태가 계속 true 였으니까.

이걸 원하는 개발자는 없을것이다. 자원 정리시에는 어떤 상황에도 정리가 깔끔히 되기를 원하지만 인터럽트 예외가 터지지 않고서는 인터럽트 상태가 false로 변경되지 않으니까 자원 정리시 만나는 sleep()에 인터럽트 예외가 발생해서 자원 정리를 마저 할 수 없게 됐다.

 

이런 상황을 겪지 않게 하고자 InterruptedException이 발생하면 자동적으로 인터럽트 상태가 false로 변경되는 것이다. 즉, 인터럽트의 목적을 달성하면 인터럽트 상태를 다시 정상으로 돌려두어야 한다. 그리고 자원 정리 시 만나는 인터럽트 예외 시에 인터럽트 상태는 당연히 false로 변경된다.

 

그럼 어떻게 할까? 인터럽트를 확인한 다음에 인터럽트 상태를 확인해서 while 문을 빠져나오면 다시 인터럽트 상태를 정상으로 돌려두어야 한다.

 

Thread.interrupted()

스레드의 인터럽트 상태를 단순히 확인만 하는 용도라면 isInterrupted()를 사용하면 된다.

하지만 직접 체크해서 사용할땐 interrupted()를 사용해야 한다.

 

이 메서드는 굉장히 재밌다.

  • 우선 스레드가 인터럽트 상태인지 확인한다. 맞다면 true를 아니라면 false를 반환한다.
  • 그리고 true를 반환할 땐, 다른 말로 인터럽트 상태라면 인터럽트 상태를 원래대로(false) 돌려놓는다.

ThreadStopMainV4

package thread.control.interrupt;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class ThreadStopMainV4 {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread thread = new Thread(task, "work");

        thread.start();

        sleep(100);
        log("작업중단 지시: thread.interrupt()");
        thread.interrupt();
        log("work 스레드 인터럽트 상태1 = " + thread.isInterrupted());
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            while (!Thread.interrupted()) {
                log("작업 중");
            }
            log("work 스레드 인터럽트 상태2 = " + Thread.currentThread().isInterrupted());

            try {
                log("자원 정리");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                log("인터럽트 예외 발생 - 자원 정리 실패");
            }
            log("작업 종료");
        }
    }
}

실행결과

...
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.494 [     main] 작업중단 지시: thread.interrupt()
2024-07-19 12:56:21.495 [     work] 작업 중
2024-07-19 12:56:21.504 [     work] work 스레드 인터럽트 상태2 = false
2024-07-19 12:56:21.504 [     work] 자원 정리
2024-07-19 12:56:21.504 [     main] work 스레드 인터럽트 상태1 = true
2024-07-19 12:56:22.505 [     work] 작업 종료

 

실행 결과처럼, Thread.interrupted()로 인터럽트 상태를 체크하고, 맞다면 다시 원래대로 돌려 놓기 때문에 2번째 상태 체크에서 false를 반환했다. 마치 인터럽트 예외를 만났을 때와 같이 동작한다.

 

그래서 마침내 원하는 목적을 최종적으로 모두 달성할 수 있게 됐다. 물론, 무조건 인터럽트 상태를 체크했다면 다시 돌려놔야 하는게 정답은 아니다. 모든 세상사가 다 그렇듯 정해져있는 정답이란게 없기 때문에. 만약, 자원 정리고 뭐고 당장 이 스레드를 꺼야한다면 그냥 인터럽트 만나자마자 바로 끝내버려도 된다. 그래서 정답은 없지만 일반적인 흐름에서는 이렇게 인터럽트의 목적을 달성하면, 인터럽트를 원래 상태로 돌려놓아야 한다.

 

참고로, 예전에 Thread.stop()이라는 메서드가 있었다. Java 1.2 이후에 Deprecated된 메서드이고 이제는 사용하면 안된다. 원하는대로 동작도 안할것이고 사용하면 안된다 그냥. 왜 그러냐면 뭐 밑도 끝도 없이 그냥 바로 스레드를 중지 시키면 예측 할 수 없는 상황이 발생할 가능성이 너무 높기 때문에 이제는 사용하지 않고 Thread.interrupt()를 사용한다고 알아두자.

 

 

인터럽트의 실제 사용 예제

인터럽트를 그럼 실제로 어떤식으로 사용할 수 있는지 실용적인 예제를 만들어보자.

프린터가 있다고 생각해보자. 조금 특별한 프린터인데 사용자의 입력을 출력하는 프린터.

 

MyPrinterV1

package thread.control.printer;

import java.util.Queue;
import java.util.Scanner;
import java.util.concurrent.ConcurrentLinkedQueue;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class MyPrinterV1 {
    public static void main(String[] args) {
        Printer printer = new Printer();
        Thread printerThread = new Thread(printer, "printer");

        printerThread.start();

        Scanner userInput = new Scanner(System.in);
        while (true) {
            log("프린터할 문서를 입력하세요. 종료(q): ");
            String input = userInput.nextLine();
            if (input.equals("q")) {
                printer.work = false;
                break;
            }
            printer.addJob(input);
        }
    }

    static class Printer implements Runnable {
        volatile boolean work = true;

        Queue<String> q = new ConcurrentLinkedQueue<>();

        @Override
        public void run() {
            while (work) {
                if (q.isEmpty()) {
                    continue;
                }

                String job = q.poll();
                log("출력 시작: " + job + ", 대기 문서: " + q);
                sleep(3000);
                log("출력 완료");
            }
            log("프린터 종료");
        }

        public void addJob(String job) {
            q.offer(job);
        }
    }
}
  • 프린트 기능을 실질적으로 담당하는 스레드를 하나 만들었다. Printer
  • 이 스레드는 두 개의 필드를 가진다. work, q
  • work는 프린트 기능을 종료하는데 판단되는 필드이고 q는 사용자의 입력을 순서대로 받는 Queue이다.
  • workvolatile 키워드를 붙였다. 여러 스레드에서 접근하는 변수인 경우에 사용한다고 생각하면 된다. 이후에 더 자세히 배우게 된다.
  • qConcurrentLinkedQueue 인스턴스이다. 이 Concurrent 역시 여러 스레드에서 접근하는 경우에 사용하는 자료구조는 일반적인 자료구조를 사용하면 안전하지 않기 때문에 일단은 동시성을 지원하는 동시성 컬렉션을 사용한다고 생각하자.
  • run() 에서는 큐에 들어간 데이터가 없으면 continue;를 만나서 계속해서 while문을 다시 돌고 있다면 하나씩 앞에서부터 꺼내서 출력하고 큐에 남아있는 값들을 출력한다.
  • 만약, workfalse가 된다면 while 문을 빠져나오고 스레드는 종료된다.
  • addJob(String job) 메서드는 main 스레드에서 사용자의 입력을 받을때마다 그 값을 이 메서드를 호출해서 큐에 넣는다.

실행하면 다음과 유사한 결과를 얻는다.

2024-07-19 13:17:19.845 [     main] 프린터할 문서를 입력하세요. 종료(q): 
a
2024-07-19 13:17:21.631 [     main] 프린터할 문서를 입력하세요. 종료(q): 
2024-07-19 13:17:21.639 [  printer] 출력 시작: a, 대기 문서: []
b
2024-07-19 13:17:21.901 [     main] 프린터할 문서를 입력하세요. 종료(q): 
c
2024-07-19 13:17:22.228 [     main] 프린터할 문서를 입력하세요. 종료(q): 
d
2024-07-19 13:17:22.594 [     main] 프린터할 문서를 입력하세요. 종료(q): 
e
2024-07-19 13:17:22.923 [     main] 프린터할 문서를 입력하세요. 종료(q): 
f
2024-07-19 13:17:23.340 [     main] 프린터할 문서를 입력하세요. 종료(q): 
2024-07-19 13:17:24.640 [  printer] 출력 완료
2024-07-19 13:17:24.642 [  printer] 출력 시작: b, 대기 문서: [c, d, e, f]
2024-07-19 13:17:27.644 [  printer] 출력 완료
2024-07-19 13:17:27.644 [  printer] 출력 시작: c, 대기 문서: [d, e, f]
2024-07-19 13:17:30.646 [  printer] 출력 완료
2024-07-19 13:17:30.647 [  printer] 출력 시작: d, 대기 문서: [e, f]
2024-07-19 13:17:33.649 [  printer] 출력 완료
2024-07-19 13:17:33.650 [  printer] 출력 시작: e, 대기 문서: [f]
2024-07-19 13:17:36.651 [  printer] 출력 완료
2024-07-19 13:17:36.652 [  printer] 출력 시작: f, 대기 문서: []
2024-07-19 13:17:39.653 [  printer] 출력 완료

 

  • 여기서 사용자가 `q`를 입력하면 printer.work의 값을 false로 변경한다.
  • main 스레드는 while문을 빠져나가고 main 스레드가 종료된다.
  • printer 스레드는 while문에서 work의 값이 false인 것을 확인한다.
  • printer 스레드는 while문을 빠져나가고 프린터 종료를 출력하고 printer 스레드는 종료된다.

앞서 살펴보았듯, 이 방식의 문제는 종료(q)를 입력했을 때 바로 반응하지 않을것이라는 점이다. 왜냐하면 printer 스레드가 반복문을 빠져나오려면 while문을 체크해야 하는데 printer 스레드가 sleep(3000)을 통해 대기 상태에 빠져서 작동하지 않기 때문이다. 따라서 최악의 경우 q를 입력하고 3초 이후에 프린터가 종료된다. 이제 인터럽트를 사용해서 반응성이 느린 문제를 해결해보자.

 

인터럽트 도입

MyPrinterV1

package thread.control.printer;

import java.util.Queue;
import java.util.Scanner;
import java.util.concurrent.ConcurrentLinkedQueue;

import static util.MyLogger.log;

public class MyPrinterV1 {
    public static void main(String[] args) {
        Printer printer = new Printer();
        Thread printerThread = new Thread(printer, "printer");

        printerThread.start();

        Scanner userInput = new Scanner(System.in);
        while (true) {
            log("프린터할 문서를 입력하세요. 종료(q): ");
            String input = userInput.nextLine();
            if (input.equals("q")) {
                printerThread.interrupt();
                break;
            }
            printer.addJob(input);
        }
    }

    static class Printer implements Runnable {

        Queue<String> q = new ConcurrentLinkedQueue<>();

        @Override
        public void run() {
            try {
                while (!Thread.interrupted()) {
                    if (q.isEmpty()) {
                        continue;
                    }

                    String job = q.poll();
                    log("출력 시작: " + job + ", 대기 문서: " + q);
                    Thread.sleep(3000);
                    log("출력 완료");
                }
            } catch (InterruptedException e) {
                log("프린터 종료");
            }
        }

        public void addJob(String job) {
            q.offer(job);
        }
    }
}
  • 인터럽트를 도입한 코드이다. work 라는 스레드의 필드를 없애고 단순히 Thread.interrupted()while 문에서 체크한다.
  • 인터럽트를 main 스레드에서 발생시키면 while문에서도, Thread.sleep(3000); 에서도 인터럽트가 발생시 인터럽트 에외가 발생하고 곧바로 빠져나오게 된다.

 

yield

어떤 스레드를 얼마나 실행할지는 운영체제가 스케쥴링을 통해 결정한다. 그런데 특정 스레드가 크게 바쁘지 않은 상황이어서 다른 스레드에게 CPU 실행 기회를 양보하고 싶을 수 있다. 이렇게 양보하면 스케쥴링 큐에 대기중인 다른 스레드가 CPU 실행 기회를 더 빨리 얻을 수 있다. 

 

YieldMain

package thread.control.yield;

import static util.ThreadUtils.sleep;

public class YieldMain {

    static final int THREAD_COUNT = 1000;

    public static void main(String[] args) {
        for (int i = 0; i < THREAD_COUNT; i++) {
            Thread thread = new Thread(new MyRunnable());
            thread.start();
        }
    }

    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + " - " + i);
                // sleep(1);
                Thread.yield();
            }
        }
    }
}
  • 1000개의 스레드를 실행한다. 
  • 각 스레드가 실행하는 로직은 아주 단순하게 0 - 9까지 출력하는 것이다.

여기서 3가지 방식으로 사용해보자.

  • 아무것도 없이 그냥 실행 → 운영체제의 스레드 스케쥴링을 따른다.
  • sleep(1) → 특정 스레드를 잠시 쉬게 한다.
  • yield() → 다른 스레드에 실행을 양보한다.

아무것도 없이 실행

Thread-998 - 2
Thread-998 - 3
Thread-998 - 4
Thread-998 - 5
Thread-998 - 6
Thread-998 - 7
Thread-998 - 8
Thread-998 - 9
Thread-999 - 0
Thread-999 - 1
Thread-999 - 2
Thread-999 - 3
Thread-999 - 4
Thread-999 - 5
  • 결과가 조금씩 다르겠지만, 거의 한 스레드가 쭉 수행된 다음에 다른 스레드가 수행되는것을 확인할 수 있다.
  • 다른 예시보다 상대적으로 하나의 스레드가 쭉 연달아 실행되다가 다른 스레드로 넘어간다.

 

sleep()을 추가한 실행

Thread-626 - 9
Thread-997 - 9
Thread-993 - 9
Thread-949 - 7
Thread-645 - 9
Thread-787 - 9
Thread-851 - 9
Thread-949 - 8
Thread-949 - 9
  • sleep(1)을 사용해서 스레드의 상태를 1밀리초 동안 아주 잠깐 RUNNABLETIMED_WAITING 상태로 변경한다. 이렇게 되면 스레드는 CPU 자원을 사용하지 않고 실행 스케쥴러에서 잠시 제외된다. 1밀리초의 대기 시간 이후 다시 TIMED_WAITINGRUNNABLE 상태가 되면서 실행 스케쥴링에 포함된다.
  • 결과적으로 TIMED_WAITING 상태가 되면서 다른 스레드에 실행을 확실하게 양보하게 된다. 그리고 스케쥴링 큐에 대기중인 다른 스레드가 CPU 실행 기회를 빨리 얻을 수 있다.
  • 하지만 이 방식은 RUNNABLETIMED_WAITINGRUNNABLE 로 변경되는 복잡한 과정을 거치고 또 특정 시간 만큼 스레드가 실행되지 않는다는 단점이 있다. 예를 들어, 양보할 스레드가 없다면? 차라리 내 스레드를 계속 실행하는게 더 나은 선택일 것이다. 그러나 이 방법은 다른 스레드 모두 대기 상태로 쉬고 있어도 내 스레드까지 잠깐 실행되지 않는 경우가 생길 수 있다.

 

yield()를 추가한 실행

Thread-321 - 9
Thread-880 - 8
Thread-900 - 8
Thread-900 - 9
Thread-570 - 9
Thread-959 - 9
Thread-818 - 9
Thread-880 - 9
  • 자바의 스레드가 RUNNABLE 상태일 때, 운영체제의 스케쥴링은 다음과 같은 상태들을 가질 수 있다.
    • 실행 상태: 스레드가 CPU에서 실제로 실행중이다.
    • 실행 대기 상태: 스레드가 실행될 준비가 되었지만, CPU가 바빠서 스케쥴링 큐에서 대기중이다.
  • 운영체제는 실행 상태의 스레드들을 잠깐만 실행하고 실행 대기 상태로 만든다. 그리고 실행 대기 상태의 스레드들을 잠깐만 실행 상태로 변경해서 실행한다. 이 과정을 계속해서 반복한다. 참고로 자바에서는 두 상태를 구분할 수 없다. 둘 다 RUNNABLE이다.
  • Thread.yield() 메서드는 현재 실행 중인 스레드가 자발적으로 CPU를 양보하여 다른 스레드가 실행될 수 있도록 한다.
  • Thread.yield() 메서드를 호출한 스레드는 RUNNABLE 상태를 유지하면서 CPU를 양보한다. 이 스레드는 다시 스케쥴링 큐에 들어가면서 다른 스레드에게 CPU 사용 기회를 넘긴다.
  • 자바에서 Thread.yield() 메서드를 호출하면 현재 실행 중인 스레드가 CPU를 양보하고 스케쥴링 큐에 들어갈 뿐 RUNNABLE 상태를 유지하기 때문에 쉽게 이야기해서 양보할 사람이 없다면 본인 스레드가 계속 실행될 수 있다.

 

yield의 실제 사용 예제

난 처음에 이걸 왜 쓰나? 싶은 생각이 제일 먼저 들었다. 와닿지 않았으니까. 와닿는 예제가 바로 위에서 한 프린터 예제에 있다!

 

바로 이 부분이다.

while (!Thread.interrupted()) {
    if (q.isEmpty()) {
        continue;
    }
    ...
}

프린터 예제를 실행하면 생성된 스레드가 이 while문을 계속해서 돌면서 두 가지를 체크한다.

  • 스레드가 인터럽트가 걸렸는지 확인
  • 안 걸렸다면 자신이 가지고 있는 큐에 데이터가 있는지 확인
  • 없다면 continue를 만나 다시 while문으로 돌아와서 위 과정을 반복

이 과정이 눈에 안 보일뿐이지 쉴 틈 없이 CPU에서 이 로직이 수행되고 있다. 1초에 while문을 수억번 반복할 수도 있다! 운영체제가 컨텍스트 스위칭을 발생시켜 다른 스레드의 작업을 하기 전까지.

 

여기서 만약, 큐에 아무것도 없다면 continue를 만나서 다시 while문으로 돌아가 또 체크를 하는 과정을 몇번(어떻게 보면 몇억번)더 반복하게 CPU를 태우는것보다 다른 스레드에게 바로 양보하면 좋지 않을까? 그러니까 좀 더 크게 생각해서 이 프린터라는 스레드도 돌고 사용자 입력을 받는 스레드가 한 200개 있다고 가정하면 빨리 사용자의 입력을 받는 스레드에게 양보해서 큐에 채워넣는게 훨씬 더 이득일 것 같다. 

 

이렇게 코드를 수정해보자.

while (!Thread.interrupted()) {
    if (q.isEmpty()) {
        Thread.yield();
        continue;
    }
    ...
}

 

위에서 말했지만 yield()는 양보를 할 수 있게 다시 스케쥴링 큐에 들어가는 거지 RUNNABLE 상태임에는 변함이 없다. 또한, 양보할 스레드가 없다면 그냥 자기가 계속 CPU를 사용할수도 있다. 그래서 몇번 더 반복해서 사용자의 입력이 들어오거나 인터럽트가 발생하는걸 체크하는걸 먼저 하는 욕심을 부리지 않고 다른 스레드에게 양보하면 CPU를 효율적으로 사용할 수 있을 것 같다.

 

정리

interruptyield에 대해 알아보았다. 둘 다 왜 필요한지에 대해 깊이 있게 알아보았고 실전 예제로도 적용시켜 보면서 좀 더 와닿게 느껴졌다. 인터럽트는 인터럽트를 건다고 바로 인터럽트가 터지는게 아니라 인터럽트 예외를 터트리는 메서드를 만나거나 현재 인터럽트 상태인지 확인하고 맞다면 인터럽트 상태를 변경하는 Thread.interrupted() 같은 메서드를 만나야 인터럽트가 터진다. 그리고 이 경우 인터럽트 상태에서 원래대로 돌아온다.

 

yield()는 양보하는 방법이다. sleep()은 양보를 확실하게 주어진 시간만큼 하지만 양보하지 않아도 될 때에도 양보를 하게 되는 아이러니한 상황도 발생한다. 그리고 우선 RUNNABLETIMED_WAITING으로 스레드의 상태가 변경되는것도 비용이 발생하는 문제가 있다. 그래서 yield()를 사용해서 계속 RUNNABLE 상태를 유지하지만 스케쥴링 큐로만 돌아가는 그래서 다른 스레드에게 CPU를 사용하도록 양보하게 했다. 만약, 양보할 스레드가 없다면 그냥 계속 내가 사용하게 되는 좋은 이점도 가지고 있었다.

 

다음 포스팅에선 지금까지 volatile 이라는 키워드를 사용하면서 이게 뭔지 몰랐다. 이 녀석에 대해 알아보자.

 

 

728x90
반응형
LIST

+ Recent posts