13-8. 쓰레드의 실행 제어

Hyun Jun·2022년 2월 12일
0

자바의 정석

목록 보기
51/52
post-thumbnail
post-custom-banner

쓰레드의 실행제어

쓰레드 프로그래밍은 동기화(synchronization)와 스케줄링(scheduling) 때문에 난이도가 어려움

멀티 쓰레딩을 잘 구현하기 위해서는 자원과 시간의 낭비가 발생하지 않도록 정교한 스케줄링 스킬이 필요

따라서 쓰레드의 상태 및 스케줄링 관련 메서드를 잘 알아둬야함

상태설명
NEW쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE실행 중, 또는 실행 가능한 상태
BLOCKED동기화 블럭에 의해서 일시 정지된 상태
WAITNG,
TIMED_WAITING
쓰레드의 작업이 종료되진 않았지만 실행 가능하지 않은 일시 정지 상태.
TIMED_WAITING은 일시 정지 시간이 지정된 경우
TERMINATED쓰레드의 작업이 종료된 상태

 

sleep()

일정 시간 동안 쓰레드를 멈추게 함.

static void sleep(long ms)
static void sleep(long ms, int ns)

ms는 밀리세컨드(1000분의 1초), ns는 나노세컨드(10억 분의 1초)

try {
    Thread.sleep(1, 50000); // 0.0015초 동안 정지
} catch (InterruptedException e) {}

지정된 시간이 다 되거나, interrupt()가 호출되면 깨어나서 실행 대기 상태가 됨.

그런데 interrupt()InterruptedException를 발생시키므로 항상 try-catch문으로 예외 처리를 해주어야 함.

class SleepExample {
    public static void main(String[] args) throws Exception {
        Thread1 t1 = new Thread1();
        Thread2 t2 = new Thread2();

        t1.start();
        t2.start();

        try {
            t1.sleep(2000);
        } catch (InterruptedException e) {}

        System.out.println("main 쓰레드 종료");
    }
}

class Thread1 extends Thread {
    @Override
    public void run() {
        System.out.println("t1 쓰레드 종료");
    }
}

class Thread2 extends Thread {
    @Override
    public void run() {
        System.out.println("t2 쓰레드 종료");
    }
}

실행 결과)

t1 쓰레드 종료
t2 쓰레드 종료
main 쓰레드 종료

t1은 분명 2초간 정지시켰으니까 가장 마지막에 끝나야 하는 것 아닌가?

그런데도 가장 먼저 종료된 이유는, sleep()이 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문

sleep()이 호출된 곳은 main 메서드를 실행 중인 main 쓰레드임

그래서 t1.sleep(2000)처럼 참조 변수로 호출하지 않고, Thread.sleep(2000)처럼 스태틱 메서드 호출 방식을 사용해야함.

 

interrupt()와 interrupted()

  • void interrupt(): 진행 중인 쓰레드의 작업을 취소함.

  • boolean interrupted(): 쓰레드에 대해 interrupt()가 호출되었는지(interrupted 상태) 알려주고, interrupted 상태를 false로 변경

  • static boolean isInterrupted(): 쓰레드에 대해 interrupt()가 호출되었는지 알려줌

 

class InterruptExample {
    public static void main(String[] args) {
        Thread1 t1 = new Thread1();
        t1.start();

        String input = JOptionPane.showInputDialog("Insert any value");
        System.out.println("Your input is: " + input);
        t1.interrupt();
        System.out.println(t1.isInterrupted());
        System.out.println("main thread terminated");
    }
}

class Thread1 extends Thread {
    @Override
    public void run() {
        int i = 10;
        while (i != 0 && !interrupted()) {
            System.out.println(i--);
            for (long x = 0; x < 10000000000L; x++); // 시간 지연을 위한 빈 for문
        }
        System.out.println("t1 thread terminated");
    }
}

실행 결과)

10
9
8
Your input is: hello
true
main thread terminated
t1 thread terminated

t1 쓰레드를 실행시키면 while문에 의해 10부터 카운트 다운이 시작되는데, main 쓰레드에서 입력값을 받아와 출력하면 이어서 interrupt()가 호출됨.

interrupt()가 호출되면 while문의 두번째 조건식이 false가 되면서 반복을 빠져나오고, 쓰레드가 종료됨.

 

여기서 시간 지연을 위한 for문 대신에

try {
    Thread.sleep(1000);
} catch (InterruptedException e) {}

를 사용하면 아래의 결과가 나옴

10
9
8
7
Your input is: hello
true
main thread terminated
6
5
4
3
2
1
t1 thread terminated

카운트 다운이 멈추지 않은 이유는 sleep()에서 InterruptedException이 발생했기 때문.

예외가 발생되면 쓰레드의 interrupted 상태가 false로 초기화 되기 때문에 while문의 조건식이 true로 유지된 것.

쓰레드가 sleep(), wait(), join()에 의해 "일시정지 상태(WAITING)"에 있을 때, interrupt()를 호출하면

sleep(), wait(), join()에서 InterruptedException이 발생하면서 쓰레드는 "실행 대기 상태(RUNNABLE)"가 됨.

(RUNNABLE일 때의 interrupted 상태는 당연히 false)

예외 발생 후에도 interrupted 상태를 true로 유지하고 싶으면 catch 블럭 안에 interrupt()를 한번 더 호출해주면 됨.

 

suspend(), resume(), stop()

  • suspend(): sleep()처럼 쓰레드를 일시정지 시킴.

  • resume(): suspend()로 멈춰진 쓰레드를 다시 실행 대기 상태로 돌려놓음.

  • stop(): 쓰레드를 종료시킴.

 

이 3가지 메서드는 쓰레드를 교착 상태(dead lock)로 만들기 쉽다는 문제 때문에 deprecated 이므로 사용하지 말자.

 

yield()

자신에게 주어진 실행 시간을 다른 쓰레드에게 양보하고 실행 대기 상태가 됨.

class YieldExample {
    public static void main(String[] args) {
        YieldingThread yt1 = new YieldingThread("A");
        YieldingThread yt2 = new YieldingThread("B");
        YieldingThread yt3 = new YieldingThread("C");

        yt1.start();
        yt2.start();
        yt3.start();

        try {
            Thread.sleep(2000);
            yt1.suspend();
            Thread.sleep(2000);
            yt2.suspend();
            Thread.sleep(3000);
            yt1.resume();
            Thread.sleep(3000);
            yt1.stop();
            yt2.stop();
            Thread.sleep(2000);
            yt3.stop();
        } catch (InterruptedException e) {}
    }
}

class YieldingThread implements Runnable {
    boolean suspended = false;
    boolean stopped = false;

    Thread t;

    YieldingThread (String name) {
        t = new Thread(this, name);
    }

    @Override
    public void run() {
        String name = t.getName();

        while (!stopped) {
            if (!suspended) {
                System.out.println(name);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println(name + ": interrupted");
                }
            } else {
                Thread.yield(); // (1)
            }
        }
        System.out.println(name + ": stopped");
    }

    public void suspend() {
        suspended = true;
        t.interrupt();
        System.out.println(t.getName() + ": interrupt() executed by suspend()");
    }

    public void stop() {
        stopped = true;
        t.interrupt(); // (2)
        System.out.println(t.getName() + ": interrupt() executed by stop()");
    }

    public void resume() { suspended = false; }
    public void start() { t.start(); }
}
실행 결과
A
B
C
A
C
B
A: interrupt() executed by suspend()
A: interrupted
C
B
C
B
B: interrupt() executed by suspend()
B: interrupted
C
C
C
A
C
A
C
A
C
A: interrupted
A: interrupt() executed by stop()
B: interrupt() executed by stop()
A: stopped
B: stopped
C
C
C: interrupt() executed by stop()
C: interrupted
C: stopped

실행 환경에 따라 결과는 다를 수 있음

 

이 코드는 interrupt()yield()를 적재적소에 배치해서 프로그램의 응답성을 높이고 바람직한 스케줄링을 구현함.

✏️ 주석 1번:

만약 else문으로 yield() 호출을 안했더라면 suspendedtrue일 때, 해당 쓰레드는 자신에게 주어진 실행 시간을 채우기 위해 남은 시간 동안 의미 없이 while문을 돌아야함*. yield()를 넣음으로써 같은 상황에서 즉시 자신의 실행 시간을 다른 쓰레드에게 양보해서 프로그램이 낭비 없이 돌아가게 함.

*이런 상태를 "바쁜 대기 상태(busy-waiting)"이라 부름.

✏️ 주석 2번:

만약 stop() 안에 interrupt()를 호출하지 않았다면, stoppedtrue가 되었어도 반복문의 조건식으로 되돌아가기까지 1초의 시간을 기다려야만 함. 그러나 interrupt()를 넣음으로써 즉시 InterruptedException을 발생시키고 이 불필요한 1초 정지 상태에서 바로 벗어날 수 있게 됨.

 

join()

자신이 하던 작업은 잠시 멈추고 다른 쓰레드가 지정된 시간 동안 작업을 수행하게끔 함.

  • void join()

  • void join(long ms)

  • void join(long ms, int ns)

첫번째처럼 시간을 지정하지 않는 경우, 다른 쓰레드가 작업을 모두 마칠 때까지 기다려야함.

try {
    t1.join(); // 현재 실행 중인 쓰레드에서 작업을 중단하고 t1 쓰레드의 작업이 끝날 때까지 기다림
} catch (InterruptedException e) {}

보다시피 sleep()처럼 interrupt()에 의해서 대기 상태에서 즉시 벗어날 수 있으며, 이 때 예외가 발생하므로 try-catch문으로 감싸줘야함.

sleep()과는 다르게 static 메서드가 아니므로 주의

 

class JoinExample {
    public static void main(String[] args) {
        MyGC myGc = new MyGC();
        myGc.setDaemon(true);
        myGc.start();

        int requiredMemory = 0;

        for(int i = 0; i < 10; i++) {
            requiredMemory = (int)(Math.random() * 10) * 40;
            boolean isAvailable = myGc.availableMemory() > requiredMemory && myGc.availableMemory() > myGc.getMaxMemory() * 0.3;

            if (!isAvailable) {
                myGc.interrupt();
                try {
                    myGc.join(100);
                } catch (InterruptedException e) {}
            }

            myGc.usedMemory += requiredMemory;
            System.out.println("Occupied: " + myGc.usedMemory);
        }
    }
}

class MyGC extends Thread {
    final static int MAX_MEMORY = 1000;
    int usedMemory = 0;

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                System.out.println("Awaken by interrupt()");
            }

            collectGarbage();
            System.out.println("Garbage collected. Occupied: " + usedMemory);
        }
    }

    public void collectGarbage() {
        usedMemory -= 300;
        if (usedMemory < 0) usedMemory = 0;
    }

    public int getMaxMemory() { return MAX_MEMORY; }
    public int availableMemory() { return MAX_MEMORY - usedMemory; }
}

실행 결과)

Occupied: 0
Occupied: 360
Occupied: 400
Occupied: 400
Occupied: 720
Awaken by interrupt()
Garbage collected. Occupied: 420
Occupied: 500
Occupied: 860
Awaken by interrupt()
Garbage collected. Occupied: 560
Occupied: 720
Awaken by interrupt()
Garbage collected. Occupied: 420
Occupied: 620
Occupied: 860

MyGC라는 이름의 매우 단순화된 Garbage Collector를 구현

최대 메모리 1000을 가지고 있는 프로그램에 계속해서 랜덤한 규모의 메모리를 사용하게 만들고, 특정 조건이 만족할 때마다 자고 있던 myGc 쓰레드를 깨워 메모리를 청소해주는 코드

만약 join()이 사용되지 않았다면, myGccollectGarbage()를 호출하여 자신의 역할을 다하기도 전에 main 쓰레드가 계속해서 메모리를 사용하게 됨.

join()을 통해 main 쓰레드의 진행을 잠시 멈추고, myGc 쓰레드가 메모리 비우는 작업을 할 시간을 벌어준 뒤, 그 작업이 끝나면 다시 main이 하려던 일을 이어감.

profile
Back-end Engineer 👨‍💻
post-custom-banner

0개의 댓글