본문 바로가기

Java

[실전 Java 고급 1편] - 1. Thread 의 제어

728x90

프로세스와 스레드 개요

 

 

1. 멀티태스킹과 멀티 프로세싱

 

 

* 멀티태스킹 - 하나의 CPU 코어가 Time Sharing 기법을 사용하여 동시에 여러 작업을 수행하는 능력

* 스케줄링 - CPU 코어에 어떤 프로그램이 얼만큼 실행될지 결정하는 것, 운영체제가 여러가지 최적화를 사용

* 멀티프로세싱 - CPU 코어가 둘 이상일 때, 한 컴퓨터 시스템에서 여러 작업을 동시에 처리하는 것

 

 

멀티프로세싱은 하드웨어 장비의 관점으로, HW 기반 성능 향상을 말한다. 멀티태스킹은 단일 CPU 코어가 여러 작업을 동시에 수행하는 것처럼 보이게 하는 것으로, SW 기반의 작업이다.

 

 

 

2. 프로세스와 스레드

 

 

운영체제 안에 프로세스, 프로세스 안에 스레드

 

 

프로그램은 실행하기 전까지 단순 파일이고, 실행되는 순간 운영체제 안에서 인스턴스가 되며 실행중인 프로그램을 프로세스라고 한다 (Program in execution). 각 프로세스는 독립적인 메모리 공간을 가지고 서로 간섭하지 않는다. 프로세스의 메모리는 다음과 같은 네가지로 구성되어 있다.

 

 

코드 : 실행 프로그램의 코드가 저장
데이터 : 프로그램이 돌면서 전역 변수 및 정적 변수가 저장
힙 : 동적으로 할당되는 메모리(Memory)
스택 : 함수 호출시 생성되는 지역 변수 및 반환 주소가 저장 (스레드에 존재)

 

 

프로세스는 반드시 하나 이상의 스레드를 가진다. 스레드란 프로세스 내에서 실행되는 작업의 단위로, 프로세스의 메모리 공간을 공유한다. 프로세스는 프로세스의 코드, 데이터, 힙을 공유하고, 개별 스택 영역을 가지고 있다.

 

 

프로그램이 실행된다는 것은 디스크에 있는 파일인 프로그램을 메모리로 불러오는 것이다. 그리고 프로세스의 코드가 한 줄씩 실행되는 것. 이 때 "누군가"가 한 줄씩 실행시켜야 할텐데, 그 "누군가"가 스레드이다. 즉, 프로세스는 실행 환경과 자원을 제공하는 컨테이너의 역할을 하고, 스레드는 CPU 를 사용해서 코드를 한 줄씩 실행한다. 

 

 

멀티스레드란 한 프로세스 안에서 여러 스레드가 작업을 동시에 수행하는 것을 말하는데, 한 프로그램 안에서도 동시에 여러 작업이 필요한 경우가 많기 때문에 필요하다 (ex: 유튜브 영상 보면서 댓글달기). 

 

 

 

3. 스레드와 스케줄링

 

 

코어는 여러 프로세스를 스레드 단위로 일을 번갈아가며 수행한다

 

 

위 그림처럼 CPU 코어는 여러 프로세스를 멀티 태스킹하며 수행하는데, 여러 프로세스들의 스레드 단위로 스케줄링된 일을 수행한다. 위 그림처럼 A1 이후 B1, 이후 C1.. 이런 과정을 반복하는 것이다. 

 

 

운영체제는 내부에 스케줄링 큐를 가지고 있고, 각 스레드는 이 큐에서 대기한다. 운영체제는 가장 앞에있는 스레드(A)의 일을 꺼내서 CPU 를 통해 수행하고, 중지하고 다시 큐에 넣는다. 그리고 그 다음 스레드(B)를 수행하는데 이를 반복한다.

 

 

CPU 코어는 스케줄링 큐에서 작업을 꺼내오고, 중지 후 다시 넣고 다음 작업을 꺼내온다

 

 

멀티 코어에서도 똑같이 스케줄링 큐에서 작업을 꺼내고 중지하고 다시 넣고를 반복하는데, 물리적으로 두 개의 일꾼이 있으니 더 많은 스레드를 동시에 실행하게 된다. 큐 내의 순서를 지속 정렬하는 과정이 스케줄링인데, 단순히 작업 시간만이 아닌 다양한 우선순위 및 최적화 기법이 사용된다 (스케줄링은 운영체제 이론 참고). 

 

 

 

4. Context Switch (⭐⭐)

 

 

멀티 작업에는 대가가 있다. 스레드 간 어디까지 수행했는지 계속 기억해야한다. A 스레드를 멈추는 시점에 CPU 에서 사용하던 값들을 메모리에 저장해두고, 이후 A 스레드를 다시 실행할 때 이 값들을 다시 CPU에 불러와야 하는데, 이를 Context Switch 라고 한다 (스레드 뿐만 아니라 프로세스 전환 관점도 Context Switch 라 한다). 사실 컨테스트 스위칭 시간은 매우 짧지만, 스레드가 매우 많거나 CPU 가 매우 적다면 이 비용이 커질 수 있다. 

 

 

그렇다면 프로그램을 만들 때 관리할 스레드는 어떻게 지정해야 할까? 이상적으로는 CPU 코어 수 + 1 개 정도로 스레드를 맞추는게 좋다고 하는데 (이 부분 사실 잘 이해가 안가긴 한다. CPU 당 단일 스레드로 돌리라는 걸까?), 실무에서 정말 그렇게 하면 안된다. CPU 를 모든 작업들이 동일한 모습으로 사용하지 않기 때문이다.

 

 

- CPU Bound Task : CPU 중심 작업으로, CPU 코어 수 + 1개 로 스레드를 맞추면 거의 100% CPU 를 활용할 수 있다 (Context Switch 도 아낄 수 있다)

- I/O Bound Task : CPU 노는 시간있는 작업으로, CPU 를 최대한 사용할 수 있을만큼 스레드를 생성하는게 좋다. 너무 많은 스레드를 생성하면 Context Switch 비용에 대한 Trade Off 존재

 

 

스레드가 하는 작업은 크게 위 2가지로 구분할 수 있다. CPU 작업은 CPU의 연산 능력을 중심적으로 요구하는 작업으로, 알고리즘 실행, 데이터 분석, 시뮬레이션 등이 해당한다. I/O 작업은 디스크, 네트워크 등을 통한 IO를 요구하는 작업을 말한다. I/O 작업은 CPU 가 사용되지 않는 상태가 길다는 특징이 있다.

 

 

분야마다 다르겠지만 가장 일반적인 웹 같은 경우에는 I/O 작업이 훨씬 많다. 만약 자바 웹 앱에서 CPU 코어 4개 서버라고 스레드 수도 4개로 하면 안된다. 각 CPU 들이 대기하며 4명의 요청만 처리하지만, CPU 는 10%도 활용되지 못하고 있는 상황이기 때문이다. "Context Switching 비용 드니까 코어만큼만 스레드 만들어야지" 하지 말라는거다

 

 

실제로 스레드 갯수로 제어되는 문제인데 머신 문제인줄 알고 머신을 스케일링 하는 경우도 많다고 한다. 성능 테스트를 통해 CPU 활용 정도 등을 보며 최적의 스레드 숫자를 찾는게 이상적이라고 한다.

 

 

 

 

스레드 생성과 실행

 

 

1. 자바 메모리 구조

 

 

Java 메모리 구조

 

 

Method Area : 프로그램 실행에 있어 필요한 공통 데이터 영역. 코드 정보, Static 변수 보관, Runtime 상수 풀 등을 저장

Stack Area : Java 실행시 하나의 실행 스택 (main method) 이 생성되고, 함수 호출시 하나씩 쌓인다. 지역 변수, 중간 연산 결과, 함수 호출 정보 등을 저장

Heap Area : 객체와 배열이 생성되는 영역으로, GC 가 주로 활동하는 영역

 

 

참고로 위에서 언급하였듯이, Java 에서도 동일하게 스레드 별로 하나의 스택 영역이 생성된다. 스레드를 추가할 때마다 스택 영역이 더 생성된다는 점을 알고 있으면 된다 (그럼 main 스택 프레임은 main 스레드에 있는걸까?).

 

 

 

2. 스레드 생성하기 (Thread Class)

 

 

public class HelloThread extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ": run()");
    }
}

 

 

Java 에서 스레드를 생성하고 싶으면 Thread 클래스를 상속받아서 사용하면 된다. 이후 다음과 같이 HelloThread 객체에 대해서 main 함수에서 테스트를 해볼 수 있다. main 함수에서 실행되는 Thread 와 함께 출력해보자.

 

 

public static void main(String[] args) {
    Thread whoIsRunningMain = Thread.currentThread();
    System.out.println(whoIsRunningMain.getName() + ": main() START!");

    HelloThread myThread = new HelloThread();
    
    System.out.println(Thread.currentThread().getName() + ": start 호출 전 Thread");
    myThread.start(); // 내가 만든 Thread 의 run 함수를 호출한다 (run 함수를 사용하는게 아니다)
    System.out.println(Thread.currentThread().getName() + ": start 호출 후 Thread");

    System.out.println(whoIsRunningMain.getName() + ": main() END!");
}


------------ OUTPUT

main: main() START!
main: start 호출 전 Thread
main: start 호출 후 Thread
Thread-0: run() // Thread-0 의 실행되는 순간은 계속 다를 수 있다 (스케줄링에 따라 달라짐)
main: main() END!

 

 

위 상황에서 알 수 있는 부분을 정리해보면, main() 함수는 main 이라는 이름의 스레드가 실행시킨다 (프로세스가 작동하려면 최소한 하나의 스레드 필요). Java 가 실행 시점에 main 이라는 스레드를 만들고 main 함수를 실행시킨다.

 

 

스레드를 start 시 새로운 스택공간을 할당한다

 

 

HelloThread 로 별개의 스레드를 생성하고 start() 함수를 호출하는 순간 Java 는 해당 스레드만을 위한 별도의 스택 공간을 할당한다. 그리고 Thread-0 는 할당받은 스택공간에서 자신의 run() 함수를 frame 으로 쌓고 일을 시작한다. 중요한 점은 Main 스레드는 run 함수를 호출하는게 아니라, start 함수를 통해 타 스레드에게 "일을 지시"할 뿐이다. run 함수는 지시 받은 스레드에서 수행되는 것이다. 그리고 main 스레드는 run 함수가 호출되길 전혀 기다리지 않고 자신의 일을 계속 한다.

 

 

만약에 helloThread 가 start() 가 아닌 run() 을 호출했다면 어떨까? 출력 결과를 확인하면 main 함수가 run 을 직접 실행하고 있음을 알 수 있다!

 

 

마지막으로 run 함수는 마치 main thread 의 main 함수처럼, 스택의 가장 아래 있는 함수이다. 따라서 run 함수가 종료되면, 해당 스레드는 사라지고 스택 메모리 공간을 반납하게 된다.

 

 

 

3. 데몬 스레드

 

 

데몬 스레드란 백그라운드에서 보조적인 작업을 수행하며 사용자에게 직접적으로 보이지 않는 스레드를 말한다. JVM 은 모든 사용자 스레드 (non-daemon) 가 종료되면 종료되며, JVM 이 종료되면 데몬 스레드들도 강제 종료된다.

 

 

public static void main(String[] args) {
    System.out.println(Thread.currentThread().getName() + ": main () START!");

    DaemonThread dThread = new DaemonThread();
    dThread.setDaemon(true); // 해당 스레드는 데몬 스레드
    dThread.start();

    System.out.println(Thread.currentThread().getName() + ": main () END!");
}

static class DaemonThread extends Thread {

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ": running...");

        try {
            Thread.sleep(1000); // 10 초간 스레드 대기
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("THREAD NEEDS TO PRINT THIS!");
        System.out.println(Thread.currentThread().getName() + ": END!");
    }
}

----------- OUTPUT

main: main () START!
main: main () END!
Thread-0: running...

 

 

위 출력결과를 확인하면, DaemonThread 스레드가 할 일을 마무리 하지 못한채로 종료되는 것을 확인할 수 있다. 그 이유는 main 스레드가 종료되어 어떤 사용자 스레드도 남지 않았기 때문에, JVM 이 종료되면서 DaemonThread 역시 강제 종료 시킨 모습이다. 참고로 setDaemon(false) 하면 사용자 스레드이기 때문에 출력을 정상적으로 진행한다.

 

 

 

4. 스레드 생성하기 (Runnable Interface) ()

 

 

자바에서 스레드는 Thread Class 뿐만 아니라 Runnable Interface 로도 구현이 가능하다. 다음 예제 코드에서 차이점을 살펴보자. 

 

 

public static void main(String [] args){
    
    System.out.println(Thread.currentThread().getName() + ": main() START!");
    
    HelloRunnable hRun = new HelloRunnable(); // Runnable 은 "작업"만 있다
    Thread thr = new Thread(hRun); // Thread 에 Thread 가 할 행위를 주입
    thr.start();
    
    System.out.println(Thread.currentThread().getName() + ": main() END!");
    
}

 

 

스레드를 사용할 때는 Thread Class 보다는 Runnable Interface 를 사용하는 방식을 지향해야 한다고 한다. Thread Class 는 다른 클래스와 같이 상속받을 수 없고 (Java 는 다중 클래스 상속 안됨), Runnable 에 비해 유연성이 떨어진다. Runnable Interface 는 위에서 볼 수 있듯이, 스레드와 스레드가 실행할 작업이 분리되어, 역할을 분리할 수 있다. 여러 스레드가 동일한 Runnable 을 공유(⭐)할 수도 있으며, 타 클래스를 상속받아도 문제가 되지 않는다.

 

 

Runnable 인터페이스의 구현체를 사용하는 또다른 좋은 점은, Thread 를 상속받고 있으면 해당 클래스에서 마치 Thread 를 엄청 잘 활용해야 할 것 같고, 의미 없는 기능들을 제공해서 잘못된 상황이 나올 수도 있다 (개발자들이 대부분 필요로 하지 않는 Thread 클래스의 기능들을 제공하게 된다). 하지만 Runnable 은 정말 텅 빈 인터페이스며, "작업" 이라는 범주를 주기 때문에, 역할을 확실하게 분리된 모습이다. 

 

 

 

5. 로거 만들기

 

 

지금까지 Thread.currentThread().getName() 을 통해서 확인했듯이, 코드를 어떤 스레드가 실행하는지 아는 것은 매우 중요하다. 따라서 Logger 를 만들어볼건데, 아래 예시처럼 [시간 + 어떤 스레드가 실행하는지 + 로그 내용]의 포멧으로 출력하는 로거를 만들어보겠다.  

 

 

15:39:02.000 [       main]   hello  thread
15:39:02.000 [       main]   123

 

 

public abstract class MyLogger { // 구현체로 선언 불가

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); // 이런 Formatter 가지고 너무 깊이 팔 필요 없다...

    public static void log(Object object) {
        String timeFormat = LocalDateTime.now().format(formatter);
        
        // %9s 는 9칸을 확보한 상태에서 채운다는 뜻
        // object 도 toString 자동 출력을 지원하도록 포멧 출력
        System.out.printf("%s [%9s] %s\n", timeFormat, Thread.currentThread().getName(), object); 
    }
}

 

 

해당 함수를 MyLogger.log(OBJ) 형태로 실행하면 위와 같이 원하는 형태로 출력해주는 것을 확인 할 수 있다. 해당 로거를 강의 전반적으로 스레드를 확인하기 위해 사용될 예정이다.

 

 

 

6. 여러 스레드 만들기

 

 

public static void main(String[] args) {
    log("main() START!");

    // 동일한 작업 "인스턴스" 를 전달한다
    HelloRunnable runnable = new HelloRunnable();
    for (int i = 0; i < 100; i++) { // 해당 작업을 100번 진행
        Thread ts = new Thread(runnable);
        ts.start();
    }
    
    log("main() END!");
}

-------- OUTPUT

Thread-4: run() START!
Thread-1: run() START!
Thread-5: run() START!
Thread-2: run() START!
Thread-0: run() START!
...

 

 

스레드와 조금 더 친해져보기 위해 여러 스레드를 만들었다. 위에서 배운 것처럼 동일한 Runnable 인스턴스 (작업 인스턴스) 를 여러 스레드에서 실행하였다. 즉, 동일한 작업을 병렬로 여러번 시행하기 위함이다. 예상하듯이 운영체제의 스케줄링에 따라 스레드가 진행되는 순서는 무작위임을 알 수 있다.

 

 

 

7. Runnable 을 만드는 다양한 방법 (익명 클래스, 변수에 직접 선언, 람다)

 

 

// 중첩클래스를 활용한 Runnable 활용
public class InnerRunnableMainV1 {

    public static void main(String[] args) {

        log("main() start!");

        MyRunnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start();

        log("main() end!");

    }

    // 여러 곳에서 안 쓰고, 이 public class 안에서만 쓸 것 같으면 중첩클래스를 활용하는게 좋다
    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            log("run()!");
        }
    }
}

 

 

우선 위처럼 중첩클래스를 활용하는 방법이 있다. 해당 Public Class 내부에서만 사용될 것 같은 스레드는 중첩 클래스를 활용하여 생성할 수 있다 (Sidecar 컨테이너처럼).

 

 

// 익명 클래스로 넣는다
public class InnerRunnableMainV2 {

    public static void main(String[] args) {

        log(":main() START!");

        Runnable anonRun = new Runnable() { // 익명 클래스로 선언
            @Override
            public void run() {
                log(":run() START & END!");
            }
        };

        Thread thread = new Thread(anonRun);
        thread.start();

        log(":main() END!");
    }
}

------
// 변수에 직접 선언한다
new Thread(new Runnable(){
    @Override
    public void run(){
        ...
        
------
// 람다식을 이용해 생성자에 익명 함수를 주입한다
Thread thread = new Thread(() -> log(":run() START & END!"));

 

 

또, 위와 같이 특정 메서드 안에서만 간단하게 스레드를 활용하고 싶을 때는 익명 클래스를 활용해도 된다. 단, 인스턴스 생성이 불가능하기 때문에 해당 클래스를 재활용할 수 없다. new Runnable() 을 직접 new Thread() 안에 변수로 생성하거나, 아니면 람다 함수로 선언하는 방법도 있다.

 

 

 

8. 스레드 예제문제 해결해보기

 

 

A 스레드에서는 1초마다 "A" 를 출력하고, B 스레드에서는 0.5초마다 "B" 를 출력하고, 정지 전에는 무한으로 출력하는 로직을 작성하라. 출력은 MyLogger 를 사용하라.

 

 

public static void main(String[] args) {

//    Runnable 을 두 개 생성할 수도 있지만, Runnable 의 장점을 활용할 수도 있다
//    Thread th1 = new Thread(new ARunnable());
//    Thread th2 = new Thread(new BRunnable());

    Thread th1 = new Thread(new PrintWorkRun(1000, "A"));
    Thread th2 = new Thread(new PrintWorkRun(500, "B"));

    th1.start();
    th2.start();

}

class PrintWorkRun implements Runnable {

    // 항상 사용하는 Class 처럼 Runnable 인터페이스를 상속받은 클래스를 활용하는 것
    private int waitTime;
    private String text;

    public PrintWorkRun(int waitTime, String text) {
        this.waitTime = waitTime;
        this.text = text;
    }

    @Override
    public void run() {
        while(true){
            log(text);
            try {
                Thread.sleep(waitTime);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

 

 

ARunable, BRunnable 두 개를 통해서 만들 수도 있었겠지만, 어쨌든 작업도 Runnable 을 상속받는 한 "Class" 라는 점을 이용해서, 서로 다른 두 작업 인스턴스를 같은 클래스로 만들 수 있다는 점을 이용할 수 있었다. 

 

 

 

 

 

스레드 제어와 생명주기 - Join 에 대하여

 

 

1. 스레드의 기본 정보들

 

 

public class ThreadInfoMain {

    public static void main(String[] args) {
    
        // main 스레드
        Thread mainThread = Thread.currentThread();
        log("main thread: " + mainThread);
        log("main thread id: " + mainThread.threadId());
        log("main thread name: " + mainThread.getName());
        log("main thread priority: " + mainThread.getPriority());
        log("main thread group: " + mainThread.getThreadGroup());
        log("main thread state: " + mainThread.getState());

        // my Thread
        Thread myThread = new Thread(new HelloRunnable(), "myThread"); // 스레드 이름은 디버깅 용
        log("my thread: " + myThread);
        log("my thread id: " + myThread.threadId());
        log("my thread name: " + myThread.getName());
        log("my thread priority: " + myThread.getPriority());
        log("my thread group: " + myThread.getThreadGroup());
        log("my thread state: " + myThread.getState()); // 생성만 하고 실행은 안했으므로 NEW state 일 것
    }
}

---------------

13:44:13.035 [     main] main thread: Thread[#1,main,5,main]
13:44:13.051 [     main] main thread id: 1
13:44:13.052 [     main] main thread name: main
13:44:13.061 [     main] main thread priority: 5
13:44:13.061 [     main] main thread group: java.lang.ThreadGroup[name=main,maxpri=10]
13:44:13.062 [     main] main thread state: RUNNABLE
13:44:13.063 [     main] my thread: Thread[#21,myThread,5,main]
13:44:13.064 [     main] my thread id: 21
13:44:13.064 [     main] my thread name: myThread
13:44:13.065 [     main] my thread priority: 5
13:44:13.065 [     main] my thread group: java.lang.ThreadGroup[name=main,maxpri=10]
13:44:13.066 [     main] my thread state: NEW

 

 

1. toString - [ID / 이름 / 우선순위 / 그룹] 문자열

2. id - 스레드의 고유 식별자, 생성될 때 할당 (직접 할당 불가)

3. name - 스레드 이름, 설정 가능

4. priority - OS에게 전달하는 스레드 우선순위. 기본이 5, 10이 가장 높음. CPU에게 이 스레드 좀 더 고려해달라고 부탁하는 수준 (무조건 적으로 OS 에게 반영되진 않고, 직접 건들 일 거의 없음)

5. group - 스레드가 소속된 그룹, 부모 스레드와 동일한 그룹에 기본적으로 속한다. 그룹 관리 기능을 사용할 수 있다 (일괄 종료, 일괄 우선순위 적용 등)

6. state - 스레드의 현 상태

 

 

스레드에는 위와 같은 기본적인 정보들이 포함되어 있고, 필요에 따라 제어하면서 운영이 가능하다.

 

 

 

2. 스레드의 생명주기

 

 

스레드의 생명 주기

 

 

NEW : 아직 시작되지 않은 상태로, Thread.start() 가 호출되지 않은 상태이다

 

RUNNABLE : 실행 중인 상태로, 넓게는 실행 대기중인 상태를 말한다

  • 모든 스레드가 동시에 실행중일 수 없으므로, 실행 대기열 (Q) 에 있거나 실제 CPU에서 실행중인 스레드들 모두 Runnable 상태

  • Java 에서는 둘을 구분할 수 없고, 사실상 둘을 구분하는 것은 무의미

 

일시 중지 상태 : CPU 를 사용하지 않고, 가만히 놀고 있는 상태를 말한다

  • BLOCKED : 스레드가 "동기화 락"을 기다리는 상태 (빨리 들어가고 싶어 하지만 막힌 상태). 타 스레드가 "락"을 가지고 있어서 기다리고 있는 상황

  • WAITING : 스레드가 무기한으로 타 스레드 작업 완료를 기다리는 상태. wait(), join() 호출시 전이되며, 타 스레드가 notify(), notifyAll() 메소드를 호출하여 알려주거나, join() 이 완료되면 종료

  • TIMED_WAITING : 일정 대기시간이 정해진채로 기다리는 상태. wait,join,sleep 로 시간과 함께 호출시 전이되며, WAITING 과 도일한 조건 혹은 지정 시간이 지나면 종료

 

TERMINATED : 스레드가 실행을 마친 상태를 말한다 (정상 종료 or 예외 발생 종료)

  • 스레드 스택 마지막 함수가 run() 인데, run() 이 종료되면 스택이 비워지므로 스레드가 종료

 

 

public static void main(String[] args) throws InterruptedException {

    Thread myThread = new Thread(new MyRunnable(), "MyThread");
    log("myThread.state = " + myThread.getState());
    log("myThread.sec3_start()");
    myThread.start();

    // Main 을 재우고 찍어보는게 좋다 -> 바로 다음줄을 MyThread 가 자기 전에 찍어버릴 수 있기 때문
    Thread.sleep(1000);
    log("myThread.state3 = " + myThread.getState()); // run 에서 자고 있음. 여기서 찍어보는 것 __ TIMED_WAITING 상태 확인 가능

    Thread.sleep(4000); // myThread 의 run 종료를 기다려보자
    log("myThread.state5 = " + myThread.getState()); // run 이 실행 완료됨 __ TERMINATED 상태 전이 확인 가능

}

static class MyRunnable implements Runnable {

    @Override
    public void run() {
    
        try {
            log("MyThread run() sec3_start!!--------------------");
            log("myThread.state2 = " + Thread.currentThread().getState()); // NEW -> RUNNABLE 상태 전이 확인

            log("MyThread sleep() sec3_start ---");
            Thread.sleep(3000); // 여기서 자고 있으니까, 다음에 getState 을 찍으면 당연히 RUNNABLE 임.. main 에서 찍어야 함
            log("MyThread sleep() end ---");

            log("myThread.state4 = " + Thread.currentThread().getState()); // TIMED_WAITING 에서 시간 종료로 다시 RUNNABLE 전이 확인

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        log("MyThread run() end!!--------------------");
    }
}

--------------

15:57:29.926 [     main] myThread.state = NEW
15:57:29.935 [     main] myThread.sec3_start()
15:57:29.936 [ MyThread] MyThread run() sec3_start!!--------------------
15:57:29.936 [ MyThread] myThread.state2 = RUNNABLE
15:57:30.937 [     main] myThread.state3 = TIMED_WAITING  -- main 함수에서 myThread 상태를 찍어본 부분
15:57:32.941 [ MyThread] myThread.state4 = RUNNABLE
15:57:32.941 [ MyThread] MyThread run() end!!--------------------
15:57:34.938 [     main] myThread.state5 = TERMINATED

 

 

코드를 통해서 MyThread 의 상태가 어떻게 전이되는지 대략적으로 알아볼 수 있었다. 다양한 일시 중지 상태들이 사실상 스레드를 공부하는 이유이므로, 이들에 대해선 앞으로 더 구체적으로 배워볼 예정이라고 한다.

 

 

 

3. 스레드와 체크 예외

 

 

자꾸 실습을 하면서 불편하다고 느끼는 점은, run() 함수는 Compile 에러가 발생해서 try / catch 문을 걸어서 InterruptedException 을 직접 처리해줘야 한다는 점이다. 왜 run 함수는 던지지 못하고 무조건 체크 예외를 처리해 줘야 하는걸까? Java 에서는 Method 재정의 할 때도, 예외와 관련해서 Override Method 가 지켜야 할 규칙이 있다.

 

 

1. 런타임 (언체크) - 아무 상관 없음

2. 체크 - 부모가 정의한 선 안에서만 던질 수 있다 (체크 예외도 상속됨)
 1) 부모 메서드가 체크 예외를 던지지 않을 경우, 자식도 던질 수 없다
 2) 부모 메서드가 체크 예외를 던질 경우, 자식은 해당 예외의 하위 Type 들만 던질 수 있다

 

 

Java 에서는 왜 이렇게 규칙을 만들었을까? Java 에서 보통 Compile 오류는 보통 보호를 위해서이다. 다음과 같은 상황을 살펴보자.

 

 

public static void main(String [] args){

    Parent p = new Child();
    try{
        p.hello();
    } catch(InterruptedException e){ // 실제 Child 객체가 hello() 를 수행하므로, Exception 이 던져지면 잡을 수 없다
    }
}

class Parent {
    void hello() throws InterruptedException {
    
    }
}

class Child extends Parent {
    // 사실상 불가능 문법
    @Override
    void hello() throws Exception { // InterruptedException 이상을 던질 수 없다
    }
}

 


위와 같은 상황에서는 InterruptedException 의 상위 예외들은 잡을 수 없다. 즉, SomeOtherCheckedException 이 발생할 경우, Child hello() 는 자신을 사용하는 쪽으로 예외를 던질 수 있지만(Exception 을 전부 던지기 때문), try / catch 를 해서 잡아야 한다는걸 알 수 없다 ("내가 보기엔 추가로 잡을 수 있지만, 잡아야 함을 모른다" 가 더 정확한 설명 같다). 아무튼 위와 같은 이유로 자바는 LSP 원칙에 기반하여 (자식만으로 부모를 대체할 수 있어야 함) 막아 놓은 문법이다. 

 

public interface Runnable {
    void run();
}

 

 

Runnable Intf 는 run() 함수가 밖으로 예외를 던지지 못하게 만들어 두었다. 왜 예외를 던지지 못하게 만들어 뒀을까? 이를 통해 개발자가 반드시 try-catch 로 처리를 해야하는데 예외가 적절하게 처리되지 않아서 프로그램이 비정상 종료되는 것을 방지할 수 있다. 즉, 메인이 아닌 다른 스레드를 제어하는 상황에서 발생하는 체크 예외들은 개발자가 인지를 해야된다는 뜻이 담긴 것이다. (사실 이건 자바 초창기 분위기이고, 요즘은 런타임을 많이 선호해서, 최신 Runnable 버전인 Callable 에서는 Exception 을 던지도록 설계되어 있다)

 

 

 

4. join() 에 대하여

 

 

public static void main(String[] args) {
    log("Start");
    
    Thread t1 = new Thread(new Job(), "thread-1");
    Thread t2 = new Thread(new Job(), "thread-2");
    t1.start();
    t2.start();
    
    log("End");
}

static class Job implements Runnable {
    @Override
    public void run() { // 2초짜리의 작업을 하는 스레드
        log("작업 시작");
        ThreadUtils.sleep(1000);
        log("작업 끝");
    }
}

 

 

위와 같이 "2초 짜리의 Job 을 실행하는 Thread 2개를 동시에 실행" 하는 상황을 보자. main 스레드는 다른 두 스레드의 작업 경과는 아무런 신경쓰지 않고 자신이 할 일을 한다는 점을 알고 있고, 출력결과도 랜덤이다. 하지만 main 스레드가 두 작업의 결과를 받아서 처리하고 싶다면 어떻게 해야할까? 다음과 같이 당장 시도해볼 수 있을 것이다.

 

 

public static void main(String[] args) { // Client 는 두 스레드의 합을 사용해야 한다

    log("Start");
    SumTask task1 = new SumTask(1, 50);
    SumTask task2 = new SumTask(51, 100);
    Thread thread1 = new Thread(task1, "thread-1");
    Thread thread2 = new Thread(task2, "thread-2");
    thread1.start();
    thread2.start();
    
    log("task1.result = " + task1.result); // 각 스레드들이 2초간 대기먼저 하므로 다 0으로 찍힌다
    log("task2.result = " + task2.result);
    
    int sumAll = task1.result + task2.result; // 각 스레드들이 대기하는 동안 Main 은 종료되어버림
    
    log("total result = " + sumAll);
    log("End");
    
}
static class SumTask implements Runnable {

    // private 해도 중첩클래스니까 클래스 내에선 접근 가능
    private int startValue;
    private int endValue;
    private int result;
    
    public SumTask(int startValue, int endValue) {
        this.startValue = startValue;
        this.endValue = endValue;
    }
    
    @Override
    public void run() { // 2초짜리의 작업을 하는 스레드라고 가정
        log("작업 시작");
        ThreadUtils.sleep(2000); // 2초간 대기
        for (int i = startValue; i <= endValue; i++) {
            result += i;
        }
        log("작업 완료, result = " + this.result);
    }
}

-----------------------

21:03:29.189 [     main] Start
21:03:29.197 [ thread-1] 작업 시작
21:03:29.198 [ thread-2] 작업 시작
21:03:29.204 [     main] task1.result = 0
21:03:29.205 [     main] task2.result = 0
21:03:29.205 [     main] total result = 0
21:03:29.206 [     main] End  // 두 합을 사용해야할 Main 은 이미 종료된 상태 
21:03:31.203 [ thread-1] 작업 완료, result = 1275
21:03:31.203 [ thread-2] 작업 완료, result = 3775

 

 

main 스레드는 이미 종료되었고, 각 스레드의 스택 영영에서 run() 함수 frame 에 this 에 대한 정보가 있어서, 여전히 출력을 진행한다

 

 

실행을 해보면 당연히 메인 스레드는 각 스레드가 완료되는 시점을 기다리지 않고, 이미 스택 프레임 함수들이 모두 완료시켜 버리고 종료되어 버린다. 메인 스레드를 타 스레드를 기다리게 하도록 하려면 어떻게 해야할까?

 


참고로 하나 더 알 수 있는 것은 메인 함수가 종료되어 버려도 각 스레드는 자신들의 일을 묵묵히 수행한다. 그리고 자신을 호출하는 인스턴스의 정보를 토대로 일을 한다. 
각 스택 프레임에는 자신을 호출하는 인스턴스에 대한 주소를 this 로 가지고 있다. 따라서 각 스레드 내에 스택 프레임들은 함수 실행시 자신이 어떤 인스턴스의 함수를 호출하고 있는 것인지 알 수 있다.

 

 

// 가장 기본적인 기다리게 하는 방법 - 딱봐도 정확한 타이밍을 맞춰서 기다리긴 어려워 보임
log("main 스레드 sleep");
ThreadUtils.sleep(3000); // 그동안 다른 스레드들이 자신이 할 일을 함 (2초)
log("main 스레드 깨어남");

 

 

Main 함수가 기다리게 하는 방법은 기본적으로 Sleep 시킬 수 있다. 하지만 Sleep 은 바로 알 수 있듯이 정확한 타이밍을 맞추어 기다리긴 어렵다. 스레들간 서로의 존재를 알고 상호작용 한다고 보기 어렵기 때문이다.

 

 

...
thread1.start();
thread2.start();

// 메인 스레드는 타 스레드들이 종료될 때까지 대기 (해당 라인을 수행하는 스레드는 join 을 사용하는 스레드가 완료될 때까지 대기해야 한다)
log("join() - main 스레드가 t1, t2 종료까지 대기");
thread1.join();
thread2.join();
log("main 스레드 대기 종료");
...

-----------

22:22:15.028 [     main] Start
22:22:15.036 [     main] main 스레드 sleep
22:22:15.036 [ thread-1] 작업 시작
22:22:15.037 [ thread-2] 작업 시작
22:22:17.052 [ thread-1] 작업 완료, result = 1275
22:22:17.052 [ thread-2] 작업 완료, result = 3775
22:22:18.039 [     main] main 스레드 깨어남
22:22:18.040 [     main] task1.result = 1275
22:22:18.040 [     main] task2.result = 3775
22:22:18.041 [     main] total result = 5050
22:22:18.041 [     main] End

 

 

기본적으로 다음과 같이 join() 함수를 사용해서 스레드가 타 스레드를 기다리게 할 수 있다. a_thread.join() 을 수행하는 스레드가 a_thread 가 완료되는 것을 기다리는 방식이다 (대기하는 스레드는 WAITING 상태가 된다). WAITING 상태이기 때문에 대상 스레드가 완료 (TERMINATED)되기까지 무기한 기다리게 된다. 결과를 통해서 알 수 있듯이 두 스레드가 거의 완료되지 마자 main 스레드는 깨어나서 다시 일을 수행하기 시작한다. 

 

 

단점은 바로 알 수 있듯이, 바로 "무기한" 기다린다는 점이다. 만약 작업 완료가 무조건 보장된 스레드를 기다리기 위해 가볍게 설계하는 것이 아니라면, 당연히 피하는게 좋은 로직이라고 생각한다.

 

 

...
thread1.start();

log("join(1000) - main 스레드가 t1, t2 종료까지 1초만 대기");
thread1.join(1000);
log("main 스레드 대기 종료");

log("task1.result = " + task1.result);
...

-----------

22:40:55.692 [     main] join(1000) - main 스레드가 t1, t2 종료까지 1초만 대기
22:40:55.692 [ thread-1] 작업 시작
22:40:56.695 [     main] main 스레드 대기 종료 // 1초만 대기함
22:40:56.701 [     main] task1.result = 0 // T1 은 2초 걸려야 완료되기 때문에 완료하지 못한 모습
22:40:56.701 [     main] End
22:40:57.695 [ thread-1] 작업 완료, result = 1275

 

 

위와 같이 int 로 대기할 시간을 join 에 전달하면, 해당 시간 만큼만 대기하게 된다. 보통 "무기한 대기"에는 위험성이 있으니, TIMED_WAITING 상태 활용이 훨씬 안전할 것 같다. 단, 물론 완료가 아닌, timeout 으로 인해 종료가 된 것이라면 exception 처리 혹은 로그 처리가 필요할 것 같다 (thread.isAlive 함수 활용 등)

 

 

 

5. Join 함수 활용 예제 

 

 

public static void main(String[] args) throws Exception {
    Thread t1 = new Thread(new MyTask(), "t1");
    Thread t2 = new Thread(new MyTask(), "t2");
    Thread t3 = new Thread(new MyTask(), "t3");
    
    t1.start();
    t1.join();
    
    t2.start();
    t2.join();
    
    t3.start();
    t3.join();
    System.out.println("모든 스레드 실행 완료");
}

static class MyTask implements Runnable {
    @Override
    public void run() {
        for (int i = 1; i <= 3; i++) {
            log(i);
            ThreadUtils.sleep(1000);
        }
    }
}

 

 

위에 main 스레드가 종료되기까지 총 3초가 아니라, 9초가 걸린다는 점은 바로 알아채야 한다. 이렇게 순차적으로 실행되면 좋은 작업 Flow 일 경우 (앞의 스레드 결과를 다음 스레드가 활용해야 하는 경우) 위와 같이 설계하면 될 것이다. 위의 경우 Task 들 간 아무 상호작용이 없기 때문에, 사실상 join 없이 3초 안에 완료되어도 무관할 것으로 보인다.

 

 

 

 

 

스레드 제어와 생명주기 - Interrupt 에 대하여

 

 

1. interrupt() 에 대하여

 

 

앞에서는 스레드가 대기하는 모습에 대해 살펴봤다면, 이번에는  진행중인 스레드의 작업을 멈추고 싶은 경우이다. 다음 코드를 살펴보자.

 

 

public static void main(String[] args) {

    MyTask task = new MyTask();
    Thread t1 = new Thread(task, "work thread");
    t1.start();

    ThreadUtils.sleep(4000); // t1 이 작업하는 시간을 줘보기 위함
    log("작업 중단 지시 runFlag = false 화");
    task.runFlag = false;

}

static class MyTask implements Runnable {

    volatile boolean runFlag = true; // 일단 쓰는 volatile, 내가 알기론 캐싱하지 말고 매번 직접 이 값을 사용하라는거였던 것 같은데..

    @Override
    public void run() {
        while (runFlag) { // runFlag True 는 외부에서 바꿔줘야 스레드가 종료된다는 것을 알 수 있다
            log("Task 작업 중");
            ThreadUtils.sleep(3000);
        }
        log("자원 정리");
        log("자원 종료");
    }
}

------------------------

23:08:58.196 [work thread] Task 작업 중
23:09:01.207 [work thread] Task 작업 중
23:09:02.146 [     main] 작업 중단 지시 runFlag = false 화
23:09:04.212 [work thread] 자원 정리
23:09:04.213 [work thread] 자원 종료

 

 

위 예제처럼, flag 를 활용하여 작업 완료 시점을 catch 하는 방법을 사용할 수 있다. 특이한 점은 Main 스레드가 타 스레드의 변수를 제어하여 작업을 종료시켰다는 점이다. volatile 은 "여러 스레드간 공유하는 값에 사용하는 키워드" 라고만 일단 알고 있자. 

 

 

부자연스러운 점이 있다면, 위와 같은 방법은 마치 sleep() 함수처럼, 타이밍이 정확하게 맞는게 아니라는 점이다. 즉 "멈추라는 지시"가 직접적이지 않기 때문에 이에 대한 즉각 반응을 하는 것이 아닌, 나중에 확인을 하고 이에 맞춰 반응하는 방식인 점이다. 우리가 원하는 것은 위 상황처럼 작업 스레드가 "WAITING" 상태에 있더라도, 즉각 반응하여 그만 기다리고 바로 작업을 종료함을 인지하는 것이다. 이 때 Interrupt() 를 사용할 수 있다. Interrupt() 는 WAITING, TIMED_WAITING 같은 대기 상태의 스레드를 직접적으로 깨워서 RUNNABLE 상태로 전환시킬 수 있다

 

 

public static void main(String[] args) {

    MyTask task = new MyTask();
    Thread t1 = new Thread(task, "work thread");
    t1.start();

    ThreadUtils.sleep(4000); // t1 이 작업하는 모습 출력을 위함
    log("작업 중단 지시 Thread.interrupt 로 지시");
    t1.interrupt(); // Interrupt 상태로 전환. T1 이 WAITING / TIMED_WAITING 으로 전환될 시 Interrupt 를 발생시킨다
    log("work 스레드 interrupt 상태1 = " + t1.isInterrupted());

}

static class MyTask implements Runnable {

    @Override
    public void run() {
        try {
            while (true) {
                log("Task 작업 중");
                Thread.sleep(3000);
            }
        } catch (InterruptedException e) { // Sleep 도중 interrupt 가 걸리면 빠져 나오게 된다
            log("work 스레드 interrupt 상태2 = " + Thread.currentThread().isInterrupted()); // 현재 interrupt 걸렸는지 확인
            log("interrupt msg = " + e.getMessage());
            log("Work Thread State = " + Thread.currentThread().getState());
        }

        log("자원 정리");
        log("자원 종료");
    }
}

------------

23:11:17.302 [work thread] Task 작업 중
23:11:18.248 [     main] 작업 중단 지시 Thread.interrupt 로 지시
23:11:18.256 [     main] work 스레드 interrupt 상태1 = true
23:11:18.256 [work thread] work 스레드 interrupt 상태2 = false
23:11:18.257 [work thread] interrupt msg = sleep interrupted
23:11:18.257 [work thread] Work Thread State = RUNNABLE

 

 

우선 interrupt() 를 걸자 걸린 t1 (work thread) 가 catch 블록에 의해 InterruptedException 으로 진입하는 모습을 확인할 수 있다. 이처럼 특정 스레드가 WAITING, TIMED_WAITING 상태에 있을 때 interrupt 를 통해 해당 상태를 즉시 중단하고 다시 RUNNABLE 로 동작할 것을 명령할 수 있다 (단, InterruptedException 을 던진 상태로 들어간다)

 

 

로그를 통해 알 수 있는 점은, main 스레드가 interrupt 지시를 하면 work thread 의 .isInterrupted 상태는 TRUE 로 전환된다. 그리고 Catch 문을 실행할 때 비로소 RUNNABLE 상태가 되고 .isInterrupted 는 FALSE 로 다시 전환된다. 또한, work thread 가 정상적으로 일을 하고 있을 때는 interrupted 가 걸리지 않고 .isInterrupted = TRUE 인 상태를 유지하다가, Thread.sleep() 처럼 WAITING 상태로 전환되는 즉시 interrupt 가 걸리고 예외가 터지게 된다. 예외에 걸린 이후는 개발자가 원하는 로직대로 처리해야 한다 (스레드 중단 혹은 속행).

 

 

Q: 개인적으로 이해 안되는 부분이 있는데, interrupt 상태1 로그가 항상 true 인 점이다. t1.interrupt() 실행 시점은 work thread 가 WAITING 일 때이다. 그렇다면 즉각 Interrupt Exception 이 터져서 .isInterrupted 를 false 로 전환할텐데, 저 로그가 먼저 예외보다 찍힌다 해도 어떻게 항상 true 로 출력되는건지 궁금하다.

ANW: GPT에 따르면 맞긴 한데, 100% 가 아닌거지 스케줄링상 매우 높은 확률로 log 가 먼저 찍히는 거라고 한다. 강사님은 별다른 언급 없으셨다. (개인적인 생각으로는 Interrupt 를 받고 예외를 수행하는건 Library 상 많은 일을 처리하는 과정이 숨어있기 때문인 것 같기도 하다 ex: 인터럽트 해제, state 변경, 다시 CPU 대기열 등록 등)

 

 

나는 별로 아쉽진 않은데, Interrupt 가 걸린 이후 WAITING 이 되어야만 Interrupt 가 발생한다는 점이 아쉽다고 한다. 따라서 Interrupt 를 터트리기 위한 State 전환은 삭제하고, 그냥 현재 isInterrupted 여부를 체크해서 while 문을 탈출하도록 작성해보면 다음과 같다.

 

 

static class MyTask implements Runnable {
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()) { // 현 스레드 상태 확인
            log("작업 중");
//            Thread.sleep(3000); // 직접적으로 interrupt 여부를 체킹하기 때문에, interrupt 를 걸리게 하기 위한 WAITING 으로의 전환은 불필요
        }

        // 탈출하면 Interrupt Exception 이나 마찬가지인 설계
        // 하지만 예외가 아니기 때문에 interrupted 상태를 전환해주지 않음!! (따라서 true 를 유지)
        log("work 스레드 interrupt 상태2 = " + Thread.currentThread().isInterrupted());

        log("자원 정리");
        log("자원 종료");
    }
}
---------------------
23:13:30.174 [work thread] work 스레드 interrupt 상태2 = true // 바뀌지 않는다

 

 

위와 같이 작업을 변경하면, Interrupted Exception 을 터뜨리지 않아도 더 직접적으로 interrupt 를 잡을 수 있으나, 현 스레드의 isInterrupted 를 다시 False 로 전환시켜주지 못한다(예외가 안터짐). 그렇다면 이제야 생각해볼 문제는, 애초에 InterruptException 은 왜 굳이 다시 Thread 의 interrupted 여부를 False 로 바꿔줄까..?

 

 

당연히 후속 작업을 할 때 영향을 주지 않기 위해서이다. 만약 위와 같이 interrupt 를 해제해주지 않은 상태에서 "이미 interrupt 를 빠져나왔다" 라는 상황으로 인지하고 있으면, 후속 작업에서 WAITING 상태로 변환될 필요가 있을 때 즉시 Interrupt 를 발생시키게 된다 (interrupt = true 를 유지하기 때문, 한번 발생하면 계속 발생하는거임). 그렇다면 우린 직접적으로 인터럽트를 알리면서, 인터럽트 상태를 바꿀 수 있는 방법까지 필요하다.

 

 

static class MyTask implements Runnable {

    @Override
    public void run() {

        while (!Thread.interrupted()) { // 이전에 isInterrupted() 상태 확인과 다르게, 상태를 확인 후 True 면 False 로 변환해주는 함수
            log("작업 중"); // 작업중이다가 interrupt 상태를 인지하고, while 조건에 맞지 않아 빠져나옴
        } // 그리고 현 스레드의 interrupt 를 false 로 바꿔준다

        log("work 스레드 interrupt 상태2 = " + Thread.currentThread().isInterrupted()); // false 반환을 확인한다!
        log("자원 정리");
        log("자원 종료");
    }
}
----------
23:13:30.174 [work thread] work 스레드 interrupt 상태2 = false

 

 

Thread.interrupted() 를 사용하면 WAITING 상태 변환이 아니여도 Interrupt 를 잡을 수 있고, 현 스레드의 인터럽트 여부를 다시 false 로 변환시켜준다 (조회 후 True 였으면 한번 True 반환 후 False 로 변환). 이후 로그에서 isInterrupted 가 false 로 우리가 원하는 모습으로 찍히는 것을 확인할 수 있다. Interrupt 상태를 직접 체크하는 제어가 필요한 경우 (원하는 작업을 못했음을 인지해야 하는 경우) 에는 .interrupted 를 사용하여야 한다.

 

 

 

2. Interrupt 실사용 - Printer 예제

 

 

실제 어떤 상황에서 사용될지 실용적인 예제를 한 번 살펴보자. 다음과 같이 Printer Class 를 정의하고, Work Thread 가 Queue 에 있는 작업을 꺼내와서 Print 하는 예제를 만들었다. 

 

 

/*
 실제 Printer 가 동작하는 것처럼!
 Main Thread 에서 Job Queue 에 Print 할 것을 넣어주고
 Work Thread 는 여기에서 꺼내서 작업을 수행한다
 */

public class MyPrinterV1 {

    public static void main(String[] args) {

        Printer printer = new Printer();
        Thread printerThread = new Thread(printer, "printer-thread");
        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); // Main Thread 가 넣어주고, Work Thread 는 값을 꺼내서 사용한다
        }

    }

    static class Printer implements Runnable {
        /*
         여러 스레드가 활용할 변수, 여러 스레드가 동시에 접근할 자료구조 등에는 항상 세밀한 제어가 필요
         */
        volatile boolean work = true;
        Queue<String> jobQueue = new ConcurrentLinkedDeque<>(); // 여러 스레드가 같이 쓸 때는 활용

        @Override
        public void run() {
            while (work) {
                if (jobQueue.isEmpty()) { // job queue 가 비어있으면 while 문을 반복
                    continue;
                }

                String job = jobQueue.poll();
                log("JOB 진행 시작: " + job + ", 대기 중인 Job: " + jobQueue);
                log("출력완료");
                ThreadUtils.sleep(3000);

            }
        }

        public void addJob(String input) {
            jobQueue.offer(input); // add vs offer?
        }
    }
}

----------
...
23:49:32.606 [printer-thread] JOB 진행 시작: b, 대기 중인 Job: [c, d, e]
23:49:32.607 [printer-thread] 출력완료
23:49:35.608 [printer-thread] JOB 진행 시작: c, 대기 중인 Job: [d, e]
23:49:35.608 [printer-thread] 출력완료
23:49:38.613 [printer-thread] JOB 진행 시작: d, 대기 중인 Job: [e]
23:49:38.614 [printer-thread] 출력완료
...

 

 

 

Printer 스레드는 자신이 담당하는 인스턴스에 저장된 Job 을 꺼내와서 일을 한다

 

 

Printing 작업을 하는데 3초가 소요되기 때문에, 우리가 작업을 종료하고 싶으면 바로 종료되지 않는다. 가령, 우리가 세번째 요소는 Print 를 하고 싶지 않아져서 종료를 했는데, 스레드는 그것을 수행하지 않고 모두 출력 후 종료하게 된다. 따라서 이런 경우에 interrupt 를 사용할 수 있다. 다음과 같이 변경을 해볼 수 있다.

 

 

... main
            String input = userInput.nextLine();
            if (input.equals("q")) {
                printerThread.interrupt();
                break;
            }
... Printer Runnable
            while(!Thread.interrupted()){
                if (jobQueue.isEmpty()) { // job queue 가 비어있으면 while 문을 반복
                    continue;
                }

 

 

이와 같이 수정해서 즉각적으로 Thread 가 interrupt 걸려 일을 중단하도록 변경할 수 있었다. 또 불필요한 volatile 변수 work 역시 삭제할 수 있었다. 

 

 

 

3. Yield - 양보하기

 

 

특정 스레드가 크게 바쁘지 않은 상황이어서, 다른 스레드에게 CPU 실행 기회를 양보하고 싶을 수도 있다. 사실 실제로 이런 경우까지 고려하는 케이스가 있을까 싶긴 하다. 아니면 처음에 Priority 를 해두는게 낫지 않나 싶기도 하고. 다음 예제를 살펴보자.

 

 

static final int THREAD_CNT = 1000;

public static void main(String[] args) {
    for (int i = 0; i < THREAD_CNT; 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);
            // 아무것도 안했을 경우, Random 이긴 하지만, 그래도 한 스레드가 쭈욱 실행되는 느낌이 들긴 함 222444555666667711 막 이런 느낌
            // ThreadUtils.sleep(1); // Sleep 을 할경우 Thread 상태가 바뀌기 때문에, 쭈욱 실행드는 느낌이 거의 없고 완전히 Random 하게 실행된다 94 -> 734 -> 233 -> 342 이런 느낌
            // Thread.yield(); // RUNNABLE 상태로 한 TURN 기다리는 것 -> 아무것도 안한 것과, Sleep 의 사이 정도의 느낌을 받을 수 있다 (그냥 그렇다고 해라!)
        }
    }
}

-----
...
Thread-557 - 0
Thread-252 - 5
Thread-252 - 6
Thread-251 - 3
Thread-251 - 4
Thread-251 - 5
...

 

 

1_위 상태 그대로 했을 경우, 2_ sleep 을 했을 경우, 3_ yield 를 했을 경우 나눠서 결과를 확인할 수 있다 (결과는 위 주석으로 적음). 2번 같은 경우는 아주 잠깐 동안 TIMED_WAITING 상태로 변경되는데, 이 때는 CPU 자원을 사용하지 않는 상태이다. 3번 같은 경우는 TIMED_WAITING 상태로 변경되지 않고, 그냥 스케줄링된 상태를 유지하지만, 2번과 3번 모두 다른 스레드에게 실행을 양보 (다른 스레드에게 CPU 사용 기회 넘김) 하는 결과를 야기한다. 3번이 존재하는 이유는, 굳이 2번을 사용하면 상태를 전이하는 복잡한 과정을 거쳐야 하기 때문이다.

 

 

만약 위와 같이 코딩된 상태에서, Thread.sleep() 을 썼는데 양보할 스레드가 없다면, 그냥 혼자 쉬고 있게 되지만, Thread.yield() 를 사용해서 양보하는 구현을 했다면 양보할 스레드가 없을 때는 본인이 yield 상관 없이 실행될 수 있다. 참고로 yield 역시 thread.setPriority() 처럼, 절대적인 것이 아니라 운영체제에게 "힌트"만을 줄 뿐이다. 당연히 상황을 고려하여 운영체제가 양보안시킬 수도 있는 것이다. 참고로 요즘 같이 CPU 가 우수하고 여러개있고 분산 서버들을 사용하는 환경에서는 예상한대로 CPU 양보까지는 잘 사용되지 않는다고 한다. 이 yield 를 가지고 위 Printer 까지 도입해서 실험해보자.

 

 

static class Printer implements Runnable {
    ...
    
    @Overrie
    public void run() {
        
        while(!Thread.interrupted()) {
            if(jobQueue.isEmpty()) {
                Thread.yield(); // 차라리 다른 스레드가 수행되는게 맞지만 Sleep 까지 걸어주고 싶진 않을 때
                continue;
            }
        }
    
        ...
    }

 

 

Printer 예제에서 위 부분은 기존에는 인터럽트 상태를 체크하고 JobQueue 의 상태까지 체크를 하는 로직이 쉴 틈없이 반복되어 CPU 자원을 많이 소모하고, 1초에 수억번 돌 수도 있는 모습이다. 만약 스레드가 많은 환경에서 위와 같이 인터럽트도 없고 Queue 까지 비어있는데 단순 체크 로직에서 CPU 자원을 많이 소모하게 되면 서비스 성능에 치명적일 수도 있다. 위와 같이 yield 를 넣어주면 해당 부분에서 지속적으로 다른 스레드에게 양보를 하며, 조금 더 효율적인 CPU 활용을 유도할 수 있다. yield 로도 부족할 것 같으면 Sleep 으로 아예 TIMED_WAITING 으로 정해주고 나중에 확인하라고 해주는 방식도 물론 기존보단 훨씬 낫다 (요구사항에 따라 다름).

 

 

 

ㅋㅋㅋㅋㅋㅋㅋ

 

 

 

 

출처

 

 

[실전 Java 고급 1편]으로 엮인 모든 포스트들은 교육 사이트 인프런의 지식공유자이신 김영한님의 [김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성] 강의를 기반으로 작성되었습니다. 열심히 정리하고 스스로 공부하기 위해 만든 포스트이지만, 제대로 공부하고 싶으시면 해당 강의를 꼭 들으시는 것을 추천드립니다. 

 

 

https://www.inflearn.com/course/%EA%B9%80%EC%98%81%ED%95%9C%EC%9D%98-%EC%8B%A4%EC%A0%84-%EC%9E%90%EB%B0%94-%EA%B3%A0%EA%B8%89-1/dashboard

 

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

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

www.inflearn.com

 

728x90