관리 메뉴

나구리의 개발공부기록

스레드 제어와 생명 주기, 인터럽트, 프린터 예제, yield - 양보하기, 프린터 예제에 yield 도입 본문

인프런 - 실전 자바 로드맵/실전 자바 - 고급 1편, 멀티스레드와 동시성

스레드 제어와 생명 주기, 인터럽트, 프린터 예제, yield - 양보하기, 프린터 예제에 yield 도입

소소한나구리 2025. 2. 10. 21:15
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급1편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 인터럽트

1) 스레드의 작업을 중간에 중단

(1) ThreadStopMainV1

  • 특정 스레드의 작업을 중단하는 가장 쉬운 방법은 변수를 사용하는 것임
  • 여기서는 runFlag를 사용해서 work 스레드에 작업 중단을 지시할 수 있음
  • 작업 하나에 3초가 걸린다고 가정하고 sleep(3000)을 사용하였고 main 스레드에서 4초 뒤에 runFlag를 false로 변경하여 작업 중단을 지시함
  • volatile 키워드는 뒤에서 자세히 설명하는데 지금은 단순히 여러 스레드에서 공유하는 값에 사용하는 키워드라는 정도로 알고 넘어가면 됨
  • 프로그램을 실행해보면 시작 후 4초 뒤에 main 스레드가 작업 중단 지시를 내리고 프로그램이 종료되는 것을 확인할 수 있음
package thread.control.interrupt;

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

        sleep(4000);
        log("작업 중단 지시 runFlag=false");
        task.runFlag = false;
    }

    static class MyTask implements Runnable {

        volatile boolean runFlag = true;

        @Override
        public void run() {
            while (runFlag) {
                log("작업 중");
                sleep(3000);
            }
            log("자원 정리");
            log("자원 종료");
        }
    }
}
/* 실행 결과
16:48:06.328 [     work] 작업 중
16:48:09.334 [     work] 작업 중
16:48:10.321 [     main] 작업 중단 지시 runFlag=false
16:48:12.340 [     work] 자원 정리
16:48:12.341 [     work] 자원 종료
*/

 

(2) 실행 분석

  • work 스레드는 runFlag가 true인 동안 계속 실행됨
  • 프로그램 시작 후 4초 뒤에 main 스레드는 runFlag를 false로 변경하므로 2번째 while문이 실행되는 중간에 변경됨
  • work 스레드는 while(runFlag)에서 runFlag의 조건이 false로 변한 것을 확인하고 while문을 빠져 나가면서 작업을 종료함

(3) 문제점

  • 실행을 해보면 바로 알겠지만 main 스레드가 runFlag=false를 통해 작업 중단을 지시해도 work 스레드가 즉각 반응하는 것이 아니라 작업 중단 지시 2초 정도 이후에 자원과 작업을 종료하는 로그를 확인할 수 있음
  • 이 방식의 가장 큰 문제는 while문 내부의 sleep()에 있음
  • main 스레드가 runFlag를 false로 변경해도 work 스레드는 sleep(3000)을 통해 3초간 잠들어있음
  • 지금 상황에서는 3초간의 잠이 깬 다음에 while(runFlag) 코드를 실행해야 runFlag를 확인하고 작업을 중단할 수 있으므로 스레드가 대기하는 상태에서 스레드를 깨우는 방법이 필요함

** 참고

  • runFlag를 변경한 후 2초라는 시간이 지난 이후에 작업이 종료되는 이유는 work 스레드가 3초에 한 번씩 깨어나서 runFlag를 확인하는데 main 스레드가 4초에 runFlag를 변경했기 때문임
  • work 스레드 입장에서 보면 두 번째 sleep()에 들어가고 1초 후 main 스레드가 runFlag를 변경했으므로 2초가 더 있어야 깨어남

2) 인터럽트 사용

(1) ThreadStopMainV2

  • 인터럽트를 사용하면 WAITING, TIMED_WAITING 같은 대기 상태의 스레드를 직접 깨워서 작동하는 RUNNABLE 상태로 만들 수 있음
  • 여기에서는 인터럽트를 이해하기 위해 직접 만든 sleep()대신 Thread.sleep()을 사용하고 try-catch로 예외를 처리하였음
  • 특정 스레드의 인스턴스에 interrupt() 메서드를 호출하면, 해당 스레드에 인터럽트가 발생하고 인터럽트가 발생하면 해당 스레드에 InterruptedException이 발생함
  • 이때 인터럽트를 받은 스레드는 대기 상태에서 깨어나 RUNNABLE 상태가 되고 코드를 정상 수행하며 InterruptedException을 catch로 잡아서 정상 흐름으로 변경하면 됨
  • interrupt()를 호출했다고 해서 즉각 InterruptedException이 발생하는 것이 아니라 sleep()처럼 InterruptedException을 던지는 메서드를 호출하거나 또는 호출 중일 때 예외가 발생함
  • 즉, 아래의 코드에서 while(true), log("작업 중")에서는 InterruptedException이 발생하지 않고 Thread.sleep() 처럼 InterruptedException을 던지는 메서드도 호출하거나 호출하며 대기 중일 때 예외가 발생함
  • 실행 결과를 확인해보면 thread.interrupt()를 통해 작업 중단을 지시하고 거의 즉각적으로 인터럽트가 발생한 것을 확인할 수 있음
  • 이때 work 스레드는 TIMED_WAITING -> RUNNABLE 상태로 변경되면서 InterruptedException 예외가 발생하고 catch의 코드 블럭으로 이동하여 정상흐름으로 코드를 수행함
  • work 스레드가 catch블럭 안에서 출력한 상태를 보면 RUNNABLE 상태로 바뀐 것을 확인할 수 있음
package thread.control.interrupt;

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("자원 종료");
        }
    }
}
/* 실행 결과
17:17:42.726 [     work] 작업 중
17:17:45.732 [     work] 작업 중
17:17:46.720 [     main] 작업 중단 지시 thread.interrupt()
17:17:46.721 [     main] work 스레드 인터럽트 상태1 = true
17:17:46.722 [     work] work 스레드 인터럽트 상태2 = false
17:17:46.722 [     work] interrupt message = sleep interrupted
17:17:46.722 [     work] state = RUNNABLE
17:17:46.723 [     work] 자원 정리
17:17:46.723 [     work] 자원 종료
*/

 

(2) 실행 분석

  • main 스레드가 4초 뒤에 work 스레드에 interrupt()를 걸면 work 스레드는 인터럽트 상태(true)가 됨
  • 스레드가 인터럽트 상태일 때는, sleep() 처럼 InterruptedException이 발생하는 메서드를 호출하거나 또는 이미 호출하고 대기 중이라면 InterruptedException이 발생함
  • 이때 2가지 일이 발생함
    1. work 스레드는 TIME_WAITING 상태에서 RUNNABLE 상태로 변경되고 InterruptedException 예외를 처리하면서 반복문을 탈출하며 work 스레드가 인터럽트 상태이기 때문에 인터럽트 예외가 발생함
    2. 인터럽트 상태에서 인터럽트 예외가 발생하면 work 스레드는 다시 작동하는 상태가 되므로 work 스레드의 인터럽트 상태는 종료됨(false)
  • 인터럽트가 적용되고 인터럽트 예외가 발생한 후 해당 스레드는 실행 가능 상태가 되고 인터럽트 발생 상태도 정상으로 돌아옴
  • 인터럽트를 사용하면 대기 중인 스레드를 바로 깨워서 실행 가능한 상태로 바꿀 수 있어 runFlag를 사용하는 이전 방식보다 반응성이 좋아졌음

3) 개선 시도

(1) 아쉬운 점

  • 앞선 코드는 while(true)의 부분은 체크를 하지 않기 때문에 인터럽트가 발생해도 다음 코드로 넘어가고 sleep()을 호출하고 나서야 인터럽트가 발생함
  • 만약 while 문을 체크하는 부분에서 인터럽트의 상태를 확인하면 인터럽트 상태를 더 빨리 반응할 수 있으므로 더 빠르게 while문을 빠져나갈 수 있음
  • 추가로 while문에서 인터럽트의 상태를 직접 확인하면 인터럽트를 발생 시키는 sleep()과 같은 코드가 없어도 인터럽트 상태를 직접 확인하기 때문에 while문을 빠져나갈 수 있음
  • 물론 지금 예제의 경우 코드가 단순하기 때문에 실질적인 차이는 매우 적음

(2) ThreadStopMainV3

  • while문에 인터럽트의 상태를 직접 확인하고 run() 반복문에서 sleep() 코드도 제거한 후 코드를 실행해보면 작업 중단 지시 이후에 빠르게 작업이 종료되는 것을 확인할 수 있음
  • 그러나 실행 결과를 보면 V2 버전과의 차이가 있는데, V2에서는 catch문을 거쳐서 자바가 스스로 인터럽트의 상태를 false로 변경해주었지만 지금은 작업 중단이 되었음에도 인터럽트의 상태가 여전히 true로 되어있는 것을 확인할 수 있음
  • main 스레드는 interrupt() 메서드를 사용해서 work 스레드에 인터럽트를 걸면 work 스레드는 인터럽트 상태이므로 while문의 조건이 false가 되면서 while문을 탈출함(!true -> false)
  • 여기까지만 보면 인터럽트가 true가 되어도 아무 문제가 없어보이지만 매우 심각한 문제가 있음
package thread.control.interrupt;

public class ThreadStopMainV3 {
    public static void main(String[] args) {
        // ... 기존 코드 동일

        sleep(100); // 0.1초 뒤에 작업 중단 지시
        
        // ... 기존 코드 동일
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {

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

            log("자원 정리");
            log("자원 종료");
        }
    }
}
/* 실행 결과 일부
...
17:49:44.293 [     work] 작업 중
17:49:44.293 [     work] 작업 중
17:49:44.293 [     main] 작업 중단 지시 thread.interrupt()
17:49:44.296 [     main] work 스레드 인터럽트 상태1 = true
17:49:44.296 [     work] work 스레드 인터럽트 상태2 = true
17:49:44.296 [     work] 자원 정리
17:49:44.296 [     work] 자원 종료
*/

 

(3) ThreadStopMainV3 - 추가

  • work 스레드는 이후에 자원을 정리하는 코드를 실행하는데 이때도 인터럽트의 상태는 계속 true로 유지하게 됨
  • 이때 만약 인터럽트가 발생하는 sleep()과 같은 코드가 수행된다면 해당 코드에서 인터럽트 예외가 발생하게 됨
  • 실제 기대하는 동작은 while()문을 탈출하기 위해 딱 한 번만 인터럽트를 사용하는 것이지 다른 곳에서도 계속해서 인터럽트가 발생하는 것은 기대하는 결과가 아님
  • 결과적으로 실행 결과를 보면 자원 정리 중에 InterruptedException이 발생하여 정상적인 자원 종료가 되지 않는 것을 확인할 수 있음
  • 자바에서 인터럽트 예외가 한 번 발생하면, 스레드의 인터럽트 상태를 다시 정상(false)로 돌리는 이유가 이런 이유 때문임
  • 스레드의 인터럽트 상태를 정상으로 돌리지 않으면 이후에도 계속 인터럽트가 발생하게 되므로 인터럽트의 목적을 달성하면 인터럽트 상태를 다시 정상으로 돌려 두어야 함
package thread.control.interrupt;

public class ThreadStopMainV3 {
    // ... 기존 코드 동일 생략
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {

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

            try {
                log("자원 정리");
                Thread.sleep(1000);
                log("자원 종료");
            } catch (InterruptedException e) {
                log("자원 정리 실패 = 자원 정리 중 인터럽트 발생");
                log("work 스레드 인터럽트 상태 3 = " + Thread.currentThread().isInterrupted());
            }
            log("작업 종료");
        }
    }
}
/* 실행 결과 일부
18:06:29.432 [     work] 작업 중
18:06:29.432 [     work] 작업 중
18:06:29.432 [     main] 작업 중단 지시 thread.interrupt()
18:06:29.432 [     work] 작업 중
18:06:29.435 [     main] work 스레드 인터럽트 상태1 = true
18:06:29.435 [     work] work 스레드 인터럽트 상태2 = true
18:06:29.436 [     work] 자원 정리
18:06:29.436 [     work] 자원 정리 실패 = 자원 정리 중 인터럽트 발생
18:06:29.436 [     work] work 스레드 인터럽트 상태 3 = false
18:06:29.436 [     work] 작업 종료
*/

4) 최종 개선

(1) ThreadStopMainV4

  • isInterrupted(): 스레드의 인터럽트 상태를 단순히 확인할 때 사용
  • Thread.interrupted(): 직접 체크해서 사용할 때 사용
  • 이 메서드는 스레드가 인터럽트 상태라면 true를 반환하는데 해당 스레드의 인터럽트 상태를 false로 변경하고, 스레드가 인터럽트 상태가 아니라면 false를 반환하고 스레드의 인터럽트 상태를 변경하지 않음
  • while문의 조건에 Thread.interrupted()를 사용하여 인터럽트 상태가 true일때 false로 자동으로 변경되도록하여 코드를 실행해보면 중간에 InterruptedException을 발생하는 코드가 존재해도 정상적으로 자원이 종료되는 것을 확인할 수 있음
package thread.control.interrupt;

public class ThreadStopMainV4 {
    public static void main(String[] args) {
        // ... 기존 코드 동일 생략
    }

    static class MyTask implements Runnable {
        @Override
        public void run() {
            while (!Thread.interrupted()) { // 인터럽트 상태가 true이면 false로 변경됨
                // ... 기존 코드 동일 생략 
    }
}
/* 실행 결과
18:18:40.594 [     work] 작업 중
18:18:40.594 [     main] 작업 중단 지시 thread.interrupt()
18:18:40.594 [     work] 작업 중
18:18:40.597 [     work] work 스레드 인터럽트 상태2 = false
18:18:40.597 [     work] 자원 정리
18:18:40.597 [     main] work 스레드 인터럽트 상태1 = false
18:18:41.602 [     work] 자원 종료
18:18:41.603 [     work] 작업 종료
*/

 

(2) 정리

  • Thread.interrupted()를 호출했을 때 스레드가 인터럽트 상태라면 true를 반환하고 해당 스레드의 인터럽트 상태를 false로 변경하여 while문을 탈출하는 시점에 스레드의 인터럽트 상태도 false로 변경되었음
  • 그 결과 자원을 정리하는 코드를 실행하는데 인터럽트가 발생하는 코드가 수행되어도 인터럽트가 발생하지 않아 자원을 정상적으로 잘 정리하는 것을 확인할 수 있음
  • 자바는 인터럽트 예외가 한번 발생하면 스레드의 인터럽트 상태를 다시 정상으로 돌리며, 스레드의 인터럽트 상태를 정상으로 돌리지 않으면 이후에도 계속 인터럽트가 발생하기 때문에 인터럽트의 목적을 달성하면 상태를 다시 정상으로 돌려 두어야 함
  • 인터럽트의 상태를 직접 체크해서 사용하는 경우 Thread.interrupted()를 사용하면 이런 부분을 해결할 수 있으며 isInterrupted()는 스레드의 상태를 변경하지 않고 확인할 때 사용하면 됨
  • 이것은 모든 상황에서의 정답은 아닐 수 있는데, 너무 긴급한 상황이어서 자원 정리도 하지 않고 최대한 빨리 스레드를 종료해야하여 해당 스레드를 다시 인터럽트 상태로 변경하는 경우도 있음

** 참고)

  • 스레드가 과거에 제공했던 stop()이라는 메서드도 호출할 수 있는데, 이것을 사용하면 IDE가 빨간줄로 사용하지 말라고 알려줌
  • 이 메서드는 스레드를 바로 중단 시키도록 동작하도록 제공되었었는데 이는 예측 불가능한 문제가 발생할 수 있어서 호출해도 동작하지 않고 더이상 사용되지 않음

2. 프린터 예제

1) 예제 작성

(1) 예제 설명

  • 사용자의 입력을 프린터에 출력하는 예제
  • 사용자의 입력을 받는 main스레드와 사용자의 입력을 출력하는 printer 스레드로 나누어짐

(2) MyPrinterV1

  • volatile: 여러 스레드가 동시에 접근하는 변수에는 volatile 키워드를 붙여주어야 안전하므로 main스레드, printer 스레드 둘다 work 변수에 동시에 접근할 수 있으므로 사용하였음
  • ConcurrentLinkedQueue: 여러 스레드가 동시에 접근하는 경우 컬렉션 프레임워크가 제공하는 일반적인 자료 구조를 사용하면 안전하지 않으므로 동시성을 지원하는 동시성 컬렉션을 사용해야함, Queue의 경우 ConcurrentLinkedQueue를 사용하면 됨
  • 작성한 코드를 실행해보면 main 스레드에서 값을 입력받고 printer스레드에서 값을 출력하는 것을 확인할 수 있음

** 참고

  • volatile, ConcurrentLinkedQueue에 대한 자세한 내용은 뒤에서 다룸
package thread.control.printer;

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

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

    static class Printer implements Runnable {

        volatile boolean work = true;
        Queue<String> jobQueue = new ConcurrentLinkedQueue<>();

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

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

        public void addJob(String input) {
            jobQueue.add(input);
        }
    }
}
/* 실행 결과
19:56:50.333 [     main] 프린터할 문서를 입력하세요. 종료(q): 
a
19:56:51.230 [     main] 프린터할 문서를 입력하세요. 종료(q): 
19:56:51.230 [  printer] 출력 시작: a, 대기 문서: []
b
19:56:51.543 [     main] 프린터할 문서를 입력하세요. 종료(q): 
c
19:56:51.797 [     main] 프린터할 문서를 입력하세요. 종료(q): 
d
19:56:52.067 [     main] 프린터할 문서를 입력하세요. 종료(q): 
e
19:56:52.355 [     main] 프린터할 문서를 입력하세요. 종료(q): 
19:56:54.234 [  printer] 출력 완료: a
19:56:54.234 [  printer] 출력 시작: b, 대기 문서: [c, d, e]
19:56:57.239 [  printer] 출력 완료: b
19:56:57.240 [  printer] 출력 시작: c, 대기 문서: [d, e]
19:57:00.245 [  printer] 출력 완료: c
19:57:00.245 [  printer] 출력 시작: d, 대기 문서: [e]
19:57:03.250 [  printer] 출력 완료: d
19:57:03.251 [  printer] 출력 시작: e, 대기 문서: []
19:57:06.256 [  printer] 출력 완료: e
q
19:57:14.554 [  printer] 프린터 종료
*/

 

(2) 동작 설명

  • 프린터 동작
    • main스레드: 사용자의 입력을 받아서 Printer 인스턴스의 jobQueue에 담음
    • printer 스레드: jobQueue가 있는지 확인하고  jobQueue에 내용이 있으면 poll()을 이용해서 꺼낸다음 출력하고 비어있다면 continue를 사용해서 while문을 반복하여 jobQueue에 출력할 내용이 들어올 때 까지 계속 확인함
    • 출력하는데 약 3초의 시간이 걸린다고 가정하기 위해 sleep(3000)을 사용하였고, 출력을 완료하면 while문을 다시 반복함
  • 프린터 종료
    • main스레드: 사용자가 q를 입력하면 printer.work의 값을 false로 변경하고 main 스레드는 while문을 빠져나가고 main 스레드가 종료됨
    • printer 스레드: while문에서 work의 값이 false인 것을 확인한 후 while문을 빠져나가고 "프린터 종료"를 출력한 뒤에 printer 스레드가 종료됨
  • 이 방식의 문제는 앞서 경험했듯이 종료(q)를 입력했을 때 printer스레드가 반복문을 빠져나오려면 while문을 체크해야 하는데 printer 스레드가 sleep(3000)을 통해 대기 상태에 빠져서 작동하지 않기 때문에 바로 반응하지 않는다는 점임
  • 최악의 경우 q를 입력하고 3초 이후에 프린터가 종료되므로 인터럽트를 사용해서 느린 문제를 해결할 수 있음

2) 인터럽트 도입

(1) MyPrinterV2

  • Printer의 run() 메서드에서 직접 만든 sleep()이 아닌 Thread.sleep()을 사용하도록 변경하고 main 스레드에서 work 변수를 false로 변경하고 printer스레드의 인터럽트도 함께 호출하면 인터럽트 상태가 되어 catch문으로 넘어가고 break문을 통해서 반복문이 종료됨
  • 프로그램을 실행 후 q를 입력하면 즉시 종료되는 것을 확인할 수 있음
package thread.control.printer;

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

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

    static class Printer implements Runnable {

        volatile boolean work = true;
        Queue<String> jobQueue = new ConcurrentLinkedQueue<>();

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

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

        public void addJob(String input) {
            jobQueue.add(input);
        }
    }
}
/* 실행 결과
19:58:05.966 [     main] 프린터할 문서를 입력하세요. 종료(q): 
a
19:58:06.602 [     main] 프린터할 문서를 입력하세요. 종료(q): 
19:58:06.603 [  printer] 출력 시작: a, 대기 문서: []
b
19:58:06.829 [     main] 프린터할 문서를 입력하세요. 종료(q): 
c
19:58:07.053 [     main] 프린터할 문서를 입력하세요. 종료(q): 
d
19:58:07.323 [     main] 프린터할 문서를 입력하세요. 종료(q): 
e
19:58:07.699 [     main] 프린터할 문서를 입력하세요. 종료(q): 
q
19:58:07.997 [  printer] 인터럽트!
19:58:07.998 [  printer] 프린터 종료
*/

3) 인터럽트 코드 개선

(1) MyPrinterV3

  • Thread.interrupted() 메서드를 while문의 조건으로 사용하여 기존의 work 변수와 관련된 코드를 모두 제거할 수 있음
  • printer 스레드의 인터럽트 상태가 true이면, while의 조건은 false가되고 인터럽트의 상태도 false로 복귀하게 됨
  • 물론 프로그램을 실행 후 q를 입력하면 바로 종료됨
package thread.control.printer;

public class MyPrinterV3 {
    public static void main(String[] args) {
        // ... 기존 코드 동일 생략
        
            if (input.equals("q")) {
                printerThread.interrupt();
                break;
            }
            
        // ... 기존 코드 동일 생략

    static class Printer implements Runnable {

            // ... 기존 코드 동일 생략

            while (!Thread.interrupted()) {
            
            // ... 기존 코드 동일 생략

    }
}

3. yield - 양보하기

1) yield

(1) YieldMain

  • 1000개의 스레드를 실행하며 각 스레드는 0 ~ 9까지 출력하면 종료되는 단순한 로직임
  • run() 메서드에 있는 sleep(1)과 Thread.yield()의 주석 상태를 변경하면서 실행하면서 실행 결과를 관찰
    1. Empty: sleep(1), Thread.yield() 없이 호출하면 운영체제의 스레드 스케줄링을 따름
    2. sleep(1): 특정 스레드를 잠시 쉬게 함
    3. yield(): 다른 스레드에 실행을 양보함
package thread.control.yield;

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);
                // 1. empty
//                sleep(1); // 2. sleep
//                Thread.yield();
            }
        }
    }
}

 

(2) Empty 실행 결과

  • 특정 스레드가 쭉 수행된 다음에 다음 스레드가 수행되는 것을 확인할 수 있음
  • 참고로 실행 환경에 따라 결과는 달라질 수 있지만 다른 예시보다 상대적으로 하나의 스레드가 쭉 연달아서 실행되다가 다른 스레드로 넘어가는 것을 확인할 수 있음
  • 운영체제의 스케줄링 정책과 환경에 따라 다르지만 대략 0.01초(10ms)정도 하나의 스레드가 실행되고 다른 스레드로 넘어감
/* 실행 결과 일부
...
Thread-996 - 7
Thread-996 - 8
Thread-996 - 9
Thread-995 - 8
Thread-995 - 9
Thread-998 - 0
Thread-998 - 1
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
Thread-999 - 6
Thread-999 - 7
Thread-999 - 8
Thread-999 - 9
*/

 

(3) sleep(1) 실행 결과

  • 실행 결과를 보면 거의 대부분의 스레드가 번갈아가면서 실행되고 있는 것을 확인할 수 있음
  • sleep(1)을 사용해서 스레드의 상태를 1밀리초 동안 아주 잠깐 RUNNABLE -> TIMED_WAITING으로 변경하기 때문에 스레드는 CPU자원을 사용하지 않고 실행 스케줄링에서 잠시 제외됨
  • 1밀리초의 대기 시간 이후 다시 TIMED_WAITING -> RUNNABLE 상태가 되면서 실행 스케줄링에 포함됨
  • 결과적으로 TIMED_WAITING 상태가 되면서 다른 스레드에 실행을 양보하게 되고 스케줄링 큐에 대기 중인 다른 스레드가 CPU의 실행 기회를 빨리 얻을 수 있음
  • 그러나 이 방식은 RUNNABLE -> TIMED_WAITING -> RUNNABLE로 변경되는 복잡한 과정을 거치고 특정 시간만큼 스레드가 실행되지 않는 단점이 존재함
  • 만약 양보할 스레드가 없다면 계속 실행중인 스레드를 더 실행하는 것이 더 나은 선택일 수 있은데 이 방법은 나머지 스레드가 모두 대기 상태로 쉬고 있어도 내 스레드까지 강제로 잠깐 실행되지 않는 것임
  • 즉, 양보할 사람이 없는데 혼자서 양보하고 있는 이상한 상황이 될 수 있음
/* 실행 결과 일부
...
Thread-833 - 7
Thread-887 - 8
Thread-886 - 9
Thread-773 - 9
Thread-777 - 9
Thread-961 - 8
Thread-715 - 8
Thread-641 - 9
Thread-887 - 9
Thread-833 - 8
Thread-961 - 9
Thread-715 - 9
Thread-833 - 9
*/

 

(4) yield() 실행 결과

  • 실행 결과를 보면 sleep(1) 처럼은 아니지만 한 스레드가 실행하다가 다른 스레드로 교체되는 것을 확인할 수 있음
  • 자바의 스레드가 RUNNABLE상태일 때 운영체제의 스케줄링은 다음과 같은 상태를 가짐
  • 실행 상태(Running): 스레드가 CPU에서 실제로 실행 중
  • 실행 대기 상태(Ready): 스레드가 실행될 준비가 되었지만 CPU가 바빠서 스케줄링 큐에서 대기 중
  • 운영체제는 실행 상태의 스레드를 잠깐만 실행하고 실행 대기 상태로 만든 후 실행 대기 상태의 스레드들을 잠깐 실행 상태로 변경해서 실행하는 과정을 반복함
  • 참고로 자바에서는 두 상태를 구분할 수 없음
  • Thread.yield(): 현재 실행 중인 스레드가 자발적으로 CPU를 양보하여 다른 스레드가 실행될 수 있도록함
  • yield() 메서드를 호출한 스레드는 RUNNABLE상태를 유지하면서 CPU를 양보하므로 다시 스케줄링 큐에 들어가면서 다른 스레드에게 CPU 사용 기회를 넘김
  • 자바에서 Thread.yield() 메서드를 호출하면 현재 실행 중인 스레드가 CPU를 양보하도록 힌트를 주어 스레드가 자신에게 할당된 실행 시간을 포기하고 다른 스레드에게 실행 기회를 줌
  • 하지만 운영체제의 스케줄러에게 단지 힌트를 제공할 뿐 강제적인 실행 순서를 지정하지않기 때문에 반드시 다른 스레드가 실행되는 것은 아니며 RUNNABLE 상태를 유지하기 때문에 양보할 스레드가 없다면 본인 스레드가 계속 실행될 수 있음
/* 실행 결과 일부
...
Thread-908 - 7
Thread-908 - 8
Thread-908 - 9
Thread-915 - 8
Thread-915 - 9
Thread-913 - 8
Thread-913 - 9
Thread-403 - 8
Thread-403 - 9
Thread-920 - 9
Thread-930 - 8
Thread-930 - 9
Thread-656 - 6
Thread-952 - 9
Thread-969 - 9
Thread-129 - 9
Thread-656 - 7
Thread-487 - 9
Thread-560 - 8
Thread-560 - 9
Thread-656 - 8
Thread-656 - 9
*/

 

** 참고

  • 최근에는 10코어 이상의 CPU도 많기 때문에 스레드 10개 정도만 만들어서 실행하면 양보를 해도 CPU 코어가 남아서 양보하지 않고 계속 수행될 수 있으므로 양보가 크게 없어짐
  • CPU 코어 수 이상의 스레드를 만들어야 양보하는 상황을 확인할 수 있으므로 이번 예제에서도 1000개의 스레드를 생성하여 실행한 것임
  • 직접 만든 log()는 현재 시간도 획득해야하고, 날짜 포멧도 지정해야하는 등 복잡하기 때문에 이 사이에 스레드의 컨텍스트 스위칭이 발생하기 쉽기 때문에 스레드의 실행 순서를 일정하게 출력하기 어려워 log()를 사용하지 않고 System.out.println()을 사용하였음

4. 프린터 예제에 yield 도입

1) 예제에 yield 도입

(1) MyPrinterV4 - yield도입 

while (!Thread.interrupted()) {
    if (jobQueue.isEmpty()) {
        continue;
    }
  • 앞서 개발한 프린터 예제를 보면 yield()를 적용하기 딱 좋은 곳이 있는데 바로 위의 부분임
  • 이 코드는 인터럽트가 발생하기 전까지 계속 인터럽트의 상태를 체크하고 jobQueue의 상태를 확인하는데 문제는 쉴 틈 없이 CPU에서 이 로직이 계속 반복해서 수행되고 있다는 점임
  • 1초에 while문을 수억 번 반복할 수도 있어 결과적으로 CPU 자원을 많이 사용하게 됨
  • 현재 작동하는 스레드가 아주 많다고 가정할 때 인터럽트도 걸리지 않고, jobQueue도 비어있는데 이런 체크 로직에 CPU 자원을 많이 사용하게 되면 정작 필요한 스레드들의 효율이 상대적으로 떨어질 수 있음
  • 차라리 그 시간에 다른 스레드들을 더 많이 실행해서 jobQueue에 필요한 작업을 빠르게 만들어서 넣어주는게 더 효율적이므로 jobQueue에 작업이 비어있으면 yield()를 호출해서 다른 스레드에 작업을 양보하는게 전체적인 관점에서 보면 더 효율적임
package thread.control.printer;

public class MyPrinterV3 {
    public static void main(String[] args) {
        // ... 기존 코드 동일 생략
    }

    static class Printer implements Runnable {

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

        @Override
        public void run() {
            while (!Thread.interrupted()) {
                if (jobQueue.isEmpty()) {
                    Thread.yield();  // 추가
                    continue;
                }
                
        // ... 기존 코드 동일 생략

}

 

** 참고

  • 이 부분은 선택이지만 yield()를 사용하는 것도 CPU자원을 계속 쓰기 때문에 sleep()을 1 ~ 10 정도 넣어주면 CPU 사용률을 조금 더 줄일 수 있음
728x90