특정 스레드의 작업을 중간에 중단하려면 어떻게 해야할까?
public class ThreadStopMainV1 {
private static final Logger log = LoggerFactory.getLogger(ThreadStopMainV1.class);
public static void main(String[] args) {
MyTask task = new MyTask();
Thread thread = new Thread(task, "work");
thread.start();
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
log.info("main 스레드 sleep 중 인터럽트 발생", e);
}
log.info("작업 중단 지시 runFlag = false");
task.runFlag = false;
}
static class MyTask implements Runnable {
volatile boolean runFlag = true;
@Override
public void run() {
try {
while (runFlag) {
log.info("작업 중");
Thread.sleep(3000);
}
} catch (InterruptedException e) {
log.info("작업 중 sleep 중 인터럽트 발생", e);
}
log.info("자원 정리");
log.info("작업 종료");
}
}
}
특정 스레드의 작업을 중단하는 가장 쉬운 방법은 변수를 사용하는 것이다. 여기서는 runFlag
를 사용해서 work
스레드에 작업 중단을 지시할 수 있다. 작업 하나에 3초가 걸린다고 가정하고 Thread.sleep(3000)
을 사용하고 main
스레드는 4초 뒤에 작업 중단을 지시한다.
volatile
키워드에 대해서는 다음 블로그에서 다루겠다.
실행 결과
18:58:27.520 [ work] 작업 중
18:58:30.525 [ work] 작업 중
18:58:31.510 [ main] 작업 중단 지시 runFlag=false
18:58:33.532 [ work] 자원 정리
18:58:33.533 [ work] 작업 종료
work
스레드는 runFlag
가 true
인 동안 계속 실행된다.
프로그램 시작 후 4초 뒤에 main
스레드는 runFlag
를 false
로 변경한다. work
스레드는 while(runFlag)
에서 runFlag
의 조건이 false
로 변한 것을 확인하고 while문을 빠져나가면서 작업을 종료한다.
여기서 문제가 발생한다. main
스레드가 runFlag=false
를 통해 작업 중단을 지시해도 work
스레드가 즉각 반응하지 않는다. 로그를 보면 작업 중단 지시 2초 정도 이후에 자원을 정리하고 작업을 종료한다. 위 방식의 가장 큰 문제는 Thread.sleep()
에 있다.
try {
while (runFlag) {
log.info("작업 중");
Thread.sleep(3000);
}
} catch (InterruptedException e) {
log.info("작업 중 sleep 중 인터럽트 발생", e);
}
main
스레드가 runFlag
를 false
로 변경해도 work
스레드는 Thread.sleep(3000)
을 통해 3초간 잠들어 있다. 3초간의 잠이 깬 다음에 while(runFlag)
코드를 실행해야 runFlag
를 확인하고 작업을 중단할 수 있다. 참고로 runFlag
를 변경한 후 2초라는 시간이 지난 이후에 작업이 종료되는 이유는 work
스레드가 3초에 한번씩 깨어나서 runFlag
를 확인하는데 main
스레드가 4초에 runFlag
를 변경했기 때문이다. work
스레드 입장에서 보면 두 번째 Thread.sleep()
에 들어가고 1초 후 main
스레드가 runFlag
를 변경한다. 3초간 Thread.sleep(3000)
이므로 아직 2초가 더 있어야 깨어난다.
인터럽트를 사용하면 WAITING
, TIMED_WAITING
같은 대기 상태의 스레드를 직접 깨워서 작동하는 RUNNABLE
상태로 만들 수 있다.
public class ThreadStopMainV3 {
private static final Logger log = LoggerFactory.getLogger(ThreadStopMainV3.class);
public static void main(String[] args) {
MyTask task = new MyTask();
Thread thread = new Thread(task, "work");
thread.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
log.info("main 스레드 sleep 중 인터럽트 발생", e);
}
log.info("작업 중단 지시 - thread.interrupt()");
thread.interrupt();
log.info("work 스레드 인터럽트 상태1 = {}", thread.isInterrupted());
}
static class MyTask implements Runnable {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
log.info("작업 중");
}
log.info("work 스레드 인터럽트 상태2 = {}", Thread.currentThread().isInterrupted());
try {
log.info("자원 정리 시도");
Thread.sleep(1000);
log.info("자원 정리 완료");
} catch (InterruptedException e) {
log.info("자원 정리 실패 - 자원 정리 중 인터럽트 발생");
log.info("work 스레드 인터럽트 상태3 = {}", Thread.currentThread().isInterrupted());
}
log.info("작업 종료");
}
}
}
interrupt()
메서드를 호출하면 해당 스레드에 인터럽트가 발생한다. InterruptedException
이 발생한다.RUNNABLE
상태가 되고 코드를 정상 수행한다.InterruptedException
을 catch
로 잡아서 정상 흐름으로 변경하면 된다.interrupt()
를 호출했다고 해서 즉각 InterruptedException
이 발생하는 것은 아니다. Thread.sleep()
처럼 InterruptedException
을 던지는 메서드를 호출하거나 또는 호출하며 대기중일 때 예외가 발생한다.실행 결과
18:32:05.247 [ work] 작업 중
18:32:05.247 [ work] 작업 중
18:32:05.247 [ main] 작업 중단 지시 - thread.interrupt()
18:32:05.247 [ work] 작업 중
18:32:05.250 [ main] work 스레드 인터럽트 상태1 = true
18:32:05.250 [ work] work 스레드 인터럽트 상태2 = true
18:32:05.251 [ work] 자원 정리 시도
18:32:05.251 [ work] 자원 정리 실패 - 자원 정리 중 인터럽트 발생
18:32:05.251 [ work] work 스레드 인터럽트 상태3 = false
18:32:05.251 [ work] 작업 종료
thread.interrupt()
를 통해 작업 중단을 지시를 하고 거의 즉각적으로 인터럽트가 발생한 것을 확인할 수 있다. 이때 work
스레드는 TIMED_WAITING
-> RUNNABLE
상태로 변경되면서 InterruptedException
예외가 발생한다. 참고로 스레드가 RUNNABLE
상태여야 catch
의 예외 코드도 실행될 수 있다. 실행 결과를 보면 work
스레드가 catch
블럭 안에서 RUNNABLE
상태로 바뀐 것을 확인할 수 있다.
주요 실행 순서
main
스레드는 interrupt()
메서드를 사용해서 work
스레드에 인터럽트를 건다.work
스레드는 인터럽트 상태이다. isInterrupted()=true
가 된다.false
가 되면서 while문을 탈출한다.while (!Thread.currentThread().isInterrupted())
while (!true)
while (false)
여기까지 보면 아무런 문제가 없어 보인다. 하지만 이 코드에는 심각한 문제가 있다. 바로 work
스레드의 인터럽트 상태가 true
로 계속 유지된다는 점이다. 앞서 인터럽트 예외가 터진 경우 스레드의 인터럽트 상태는 false
가 된다. 반면에 isInterrupted()
메서드는 인터럽트의 상태를 변경하지 않는다. 단순히 인터럽트의 상태를 확인만 한다. work
스레드는 이후에 자원을 정리하는 코드를 실행하는데 이때도 인터럽트의 상태는 계속 true
로 유지된다. 이때 만약 인터럽트가 발생하는 Thread.sleep()
과 같은 코드를 수행한다면 해당 코드에서 인터럽트 예외가 발생하게 된다. 이것은 우리가 기대한 결과가 아니다. 우리가 기대하는 것은 while()
문을 탈출하기 위해 딱 한 번만 인터럽트를 사용하는 것이지 다른 곳에서도 계속해서 인터럽트가 발생하는 것이 아니다. 결과적으로 자원 정리를 하는 도중에 인터럽트가 발생해서 자원 정리에 실패한다.
자바에서 인터럽트 예외가 한 번 발생하면 스레드의 인터럽트 상태를 다시 정상(false
)으로 돌리는 것은 이런 이유 때문이다. 스레드의 인터럽트 상태를 정상으로 돌리지 않으면 이후에도 계속 인터럽트가 발생하게 된다. 인터럽트의 목적을 달성하면 인터럽트 상태를 다시 정상으로 돌려두어야 한다. while(인터럽트_상태_확인)
같은 곳에서 인터럽트의 상태를 확인한 다음에 만약 인터럽트 상태(true
)라면 인터럽트 상태를 다시 정상(false
)으로 돌려두면 된다.
while (!Thread.interrupted()) { //인터럽트 상태 변경O
log("작업 중");
}
실행 결과
18:42:18.214 [ work] 작업 중
18:42:18.214 [ work] 작업 중
18:42:18.214 [ main] 작업 중단 지시 - thread.interrupt()
18:42:18.214 [ work] 작업 중
18:42:18.219 [ main] work 스레드 인터럽트 상태1 = true
18:42:18.219 [ work] work 스레드 인터럽트 상태2 = false
18:42:18.219 [ work] 자원 정리 시도
18:42:19.221 [ work] 자원 정리 완료
18:42:19.222 [ work] 작업 종료
주요 실행 순서
main
스레드는 interrupt()
메서드를 사용해서 work
스레드에 인터럽트를 건다.work
스레드는 인터럽트 상태이다. Thread.interrupted()
의 결과는 true
가 된다.Thread.interrupted()
는 이때 work
스레드의 인터럽트 상태를 정상(false
)으로 변경한다.false
가 되면서 while문을 탈출한다.while (!Thread.interrupted())
while (!true)
while (false)
Thread.interrupted()
를 호출했을 때 스레드가 인터럽트 상태(true
)라면 true
를 반환하고 해당 스레드의 인터럽트 상태를 false
로 변경한다.어떤 스레드를 얼마나 실행할지는 운영체제가 스케줄링을 통해 결정한다. 그런데 특정 스레드가 크게 바쁘지 않은 상황이어서 다른 스레드에 CPU 실행 기회를 양보하고 싶을 수 있다. 이렇게 양보하면 스케줄링 큐에 대기 중인 다른 스레드가 CPU 실행 기회를 더 빨리 얻을 수 있다.
public class YieldMain {
private static final Logger log = LoggerFactory.getLogger(YieldMain.class);
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-" + i);
thread.start();
}
}
static class MyRunnable implements Runnable {
public void run() {
for (int i = 0; i < 10; i++) {
log.info("{} - {}", Thread.currentThread().getName(), i);
// 1. empty (기본 실행 흐름)
// try {
// Thread.sleep(1); // 2. sleep: 대기 상태 전환
// } catch (InterruptedException e) {
// log.info("Interrupted during sleep", e);
// }
// Thread.yield(); // 3. yield: CPU 양보 (RUNNABLE 상태 유지)
}
}
}
}
1000개의 스레드를 실행한다. 각 스레드가 실행하는 로직은 아주 단순하다. 스레드당 0~9까지 출력하면 끝난다.
여기서는 3가지 방식을 사용한다.
1. Empty
: sleep(1)
, yield()
없이 호출한다. 운영체제의 스레드 스케줄링을 따른다.
2. sleep(1)
: 특정 스레드를 잠시 쉬게 한다.
3. yield()
: yield()
를 사용해서 다른 스레드에 실행을 양보한다.
실행 결과 - 1. Empty
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
특정 스레드가 쭉 수행된 다음에 다른 스레드가 수행되는 것을 확인할 수 있다. 참고로 실행 환경에 따라 결과는 달라질 수 있다. 다른 예시보다 상대적으로 하나의 스레드가 쭉 연달아 실행되
다가 다른 스레드로 넘어간다. 이 부분은 운영체제의 스케줄링 정책과 환경에 따라 다르지만 대략 0.01초(10ms)정도 하나의 스레드가 실행되고 다른 스레드로 넘어간다.
실행 결과 - 2. 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밀리초 동안 아주 잠깐 RUNNABLE
-> TIMED_WAITING
으로 변경한다. 이렇게 되면 스레드는 CPU 자원을 사용하지 않고 실행 스케줄링에서 잠시 제외된다. 1 밀리초의 대기 시간 이후 다시 TIMED_WAITING
-> RUNNABLE
상태가 되면서 실행 스케줄링에 포함된다. 결과적으로 TIMED_WAITING
상태가 되면서 다른 스레드에 실행을 양보하게 된다. 그리고 스캐줄링 큐에 대기중인 다른 스레드가 CPU의 실행 기회를 빨리 얻을 수 있다.
하지만 이 방식은 RUNNABLE
-> TIMED_WAITING
-> RUNNABLE
로 변경되는 복잡한 과정을 거치고 또 특정 시간만큼 스레드가 실행되지 않는 단점이 있다. 예를 들어서 양보할 스레드가 없다면 차라리 나의 스레드를 더 실행하는 것이 나은 선택일 수 있다. 이 방법은 나머지 스레드가 모두 대기 상태로 쉬고 있어도 내 스레드까지 잠깐 실행되지 않는 것이다. 쉽게 이야기해서 양보할 사람이 없는데 혼자서 양보한 이상한 상황이 될 수 있다.
실행 결과 - 3. yield()
Thread-805 - 9
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
상태일 때 운영체제의 스케줄링은 다음과 같은 상태들을 가질 수 있다.
yield()의 작동
Thread.yield()
메서드는 현재 실행 중인 스레드가 자발적으로 CPU를 양보하여 다른 스레드가 실행될 수 있도록 한다. yield()
메서드를 호출한 스레드는 RUNNABLE
상태를 유지하면서 CPU를 양보한다. 즉, 이 스레드는 다시 스케줄링 큐에 들어가면서 다른 스레드에게 CPU 사용 기회를 넘긴다. 자바에서 Thread.yield()
메서드를 호출하면 현재 실행 중인 스레드가 CPU를 양보하도록 힌트를 준다. 이는 스레드가 자신에게 할당된 실행 시간을 포기하고 다른 스레드에게 실행 기회를 주도록 한다. 참고로 yield()
는 운영체제의 스케줄러에게 단지 힌트를 제공할 뿐 강제적인 실행 순서를 지정하지 않는다. 그리고 반드시 다른 스레드가 실행되는 것도 아니다. yield()
는 RUNNABLE
상태를 유지한 채 CPU를 양보하며 이는 컨텍스트 스위칭 비용 없이 실행 흐름만 넘긴다. 양보 대상이 없다면 계속 본인 스레드가 실행될 수 있다.
참고로 최근에는 10코어 이상의 CPU도 많기 때문에 스레드 10개 정도만 만들어서 실행하면 양보가 크게 의미가 없다. 양보해도 CPU 코어가 남기 때문에 양보하지 않고 계속 수행될 수 있다. CPU 코어 수 이상의 스레드를 만들어야 양보하는 상황을 확인할 수 있다. 그래서 이번 예제에서 1000개의 스레드를 실행한 것이다.
이번 학습을 통해 자바 스레드에서 작업 중단 제어 방식의 차이를 명확히 이해할 수 있었다. 단순한 volatile
플래그 방식은 구조는 쉽지만 sleep()
등 대기 상태에선 반응이 느리다는 단점이 있었다. 반면 interrupt()
는 WAITING
, TIMED_WAITING
상태에서 스레드를 깨어나게 하여 빠르게 종료 흐름으로 유도할 수 있었다. 이 과정에서 isInterrupted()
는 상태 확인만 하고 유지하는 반면 Thread.interrupted()
는 상태를 확인하면서 초기화하는 차이점을 익혔다.
또한 스레드가 CPU 사용을 자발적으로 양보하도록 할 수 있는 yield()
메서드의 동작 원리도 실험을 통해 체득할 수 있었다. sleep()
은 상태 전환과 대기 시간을 강제로 발생시키지만 yield()
는 단순한 힌트를 통해 스케줄링 흐름만 바꾸기 때문에 상황에 따라 적절히 선택해야 한다. 이번 학습은 스레드 종료와 CPU 사용 효율 제어의 근본 원리와 실전 적용법을 모두 익히는 데 큰 도움이 되었다.
참고