[Java] Concurrent Programming - Thread Control

Swim Lee·2021년 5월 7일
0

Java

목록 보기
3/3

Thread Control

참고링크 : https://widevery.tistory.com/27?category=859973

운영체제의 프로세스 상태와 스레드 상태를 잘 엮어서 설명되어있음

프롤로그

가시성과 원자성을 이해해야 문제없는 Thread 프로그램을 작성할 가능성이 높다. Thread 프로그램을 한다는 것은 나혼자 목공소를 운영하다가 조수라도 한명 더들어와 그의 작업까지 내가 정의해야하고, 서로 업무에 방해가 되지 않도록 동선을 고려하여 업무를 분장해야하는 상황에 비유할 수 있다.

그러기 위해선 적절한 시기와 방법으로 Thread는 생성되어야하고, 쉬어야(WAIT)하며 때로는 멈춰야(BLOCK)한다. 그리고 제일 중요한 것은 완벽하게 Task를 완수해야한다.(데이터 정합성 보장) 필자는 이런 일련의 Thread 운영을 Thread Control이라 부르기로 했다.

그리고 이번 글에서는 소위 Thread Pool을 이용한 Thread Control이 아닌 전통적인 방법으로 Thread를 생성하고 사용하는 과정을 먼저 다룰 것이다.

Thread의 상태 (State)

개발자 입장에서는 Thread로 처리할 Task를 정의하고 Thread를 생성하고 출발(start) 시키는 책임을 가지고있다. 이후 JVM을 포함한 시스템의 Thread Scheduler는 출발요청이 된 RUNNABLE Thread를 진짜 출발 시킨다. RUNNING 상태가 되는 것이다. 이 이후 별 문제 없이, 그리고 개발자 코드의 추가적인 제어없이 Task를 완료하게 되면 Thread는 종료상태(TERMINATED)가 된다. 이 흐름이 가장 엘레강스한 Thread의 흐름이지만, 현실은 그렇지 못하다.) 좁디 좁은 목공소에서 나와 신입 조수의 동선이 충돌하지 않으리라 생각하는 것은 낙관적이다.

프로그램 세계에서도 좋든 싫든 Thread가 쉬거나 다른 Thread의 처리를 대기해야하고, 이로 인해 다양한 상태전이가 일어난다. 아래 그림은 Java Thread Class 내에 정의된 State 열거형의 상태전이도이다. 각 흐름 위에는 각 상태로의 전이에 필요한 함수가 기술되어있다.

위의 상태도에서 RUNNING 상태는 Java의 Thread Class에서는 실존하지 않는 상태이다. 이 상태는 RUNNABLE 통합되어있다.

헌데 이를 굳이 구분해서 도식화한 이유는 RUNNABLE 통합되어있지만, 내부에서는 엄연히 실제 동작중인 상태와 리소스(CPU)를 대기하는 상태가 나누어져있기 때문에 이를 구분하여 설명하려는 목적이다.

아래는 Java 소스상에 기술되어있는 RUNNABLE 주석이다. (아래 주석에서 waiting이라는 용어는 Thread 상태인 WAIT와는 다른 OS자원 할당의 기다림이다 오해하지말자)

/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/

Thread Task 정의와 Thread 생성

Runnable Interface 구현

가장 순순하게 Thread Task를 정의하는 방법이다. Thread class의 인스턴싱(객체화)를 위한 생성자의 인자가 Runnable이기 때문이다.

Runnable Interface는 구현해야할 추상메서드가 하나인 추상메서드이다.

// 중략
@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

// Runnable을 구현한 Class 
class SimpleRunnalbe implements Runnable {
    @Override
    public void run() {
        // 여기에 적절한 Task를 정의 한다.
    }
}

Thread Class의 확장 또는 Runnable Wrapping

Runnable을 대신해서 이와같이 구현해도 된다. 위에서 설명했듯이 Thread Class또한 Runnable을 구현했기 때문에 run을 오버라이딩 할 수 있다.

사실 위와 같이 명시적으로 Runnable을 구현하기보다는 아래와 같이 익명 객체로 구현하는 경우가 흔하다.

능동적인 Thread Control

Thread의 시작

NEW (Thread 생성) 까지 왔으니 이제 출발(START) 시켜야한다. 엄밀히 말하면 시스템의 Thread Scheduler에게 Thread를 출발시켜달라고 요청해야한다. 이는 Thread 인스턴스 함수로 start()를 호출하여 이루어진다.

start()는 먼저 Thread를 RUNNABLE로 상태전이시킨다. 그야말로 실행할 수 있음이 된 것이지, 단 한줄도 시작하지 못했다. Thread Scheduler는 여유가 있는 CPU가 보이면 Thread를 start시킬 것이다. 그러면 드디어 RUNNING이 된다. 이는 run()의 수행을 의미한다.

이후 별일이 없다면 (wait()를 만나거나, lock을 만나지 않는다면 혹은 잘못된 프로그램 코드로 인해 무한 Loop에 빠지지 않는다면) Task가 완료되어 Thread의 상태는 TERMINATED가 될 것이다.

이제부터 그렇지 않은 경우를 논해보자

Wait Thread

현재 확보한 Lock을 반환하고, 현재 Thread는 휴게실에 들어가 잠시 쉬어라

확보한 Lock을 반환해야하기 때문에 wait() 함수를 사용할 때는 함수가 synchronized 블럭 내에 존재해야한다. 그리소 쉴시간을 줄 수도 안줄수도 있다. 무한정 쉬게 한다면 누군가는 이를 깨워야한다.

깨우는 방법은 다른 스레드가 notify()나 notifyAll()를 호출하는 것이다.

헌데 원글쓴이가 이 함수를 처음봤을 때 어색했던 점은 이런 함수들이 Thread Class가 아닌, Java의 super Class인 Object에 정의되어있다는 점이다.

지금은 이렇게 이해하고 있다. 이 Object를 Object로 보지 않고 Lock 객체로 보면 이해가 쉽다. 위에서 비유한 휴게실이라고 표현한 개념이 Thread 별로 있는 것이 아니고 Lock객체별로 있는 것이다. 그래서 notify()나 notifyAll()이 성립하는 것이다.

즉, notify()를 호출하면 Lock객체 내에 휴식을 하고 있는 여러 Thread중 하나, notifyAll()을 하면 Thread 전부가 휴식모드에서 일어나는 것이다. 만약 이 휴게실에 여러 Thread가 쉬고 있는데, notify()가 호출되면 누구를 깨울지는 명시할 수 없다. (다만 FIFO라는 설이 있는데 실험해보지 않아 장담할 수 없다.)

위 예제는 2개의 사용자 정의 Thread와 Main Thread 총 3개의 Thread를 가지고 wait-notifyAll을 테스트하는 예제이다.

Main Thread와 thread[1] Thread는 새로운 Thread들이 출발하자마다 wait에 들어간다. 모두 같은 휴게실 (Lock 객체)이다.

thread[0]에서 10회 현재시간을 포함하는 메시지를 출력하고 Lock객체.notifyAll()을 호출한다. 그러면 Main Thread와 thread[1]이 휴게실에서 나온다. 그리고 다시 RUNNABLE 상태가 되어 Scheduler의 CPU 배당을 기다린다. (쉬기 직전까지 일을 기억하고 있다. 고로 CPU를 배당 받으면 그 다음부터 수행한다. 컨텍스트 스위칭)

Sleep Thread

이번에는 Thread를 재운다.

원글쓴이는 자신이 Java를 만들었다면 sleep과 wait의 용어를 바꾸었을 것 같다고한다. 개념상 wait가 오히려 더 푹 쉬기 때문이다. Lock까지 반납하면서 쉰다. 이게 바로 sleep과 가장 크게 다른점이다. wait()는 가지고 있는 lock을 반납해야하기 때문에, 임계영역 안에서 호출되지 않으면 IllegalMonitorStateException 예외가 발생한다.

sleep은 설령 lock 구간내에 있어도 lock을 반납하지 않기 때문에 이런 제약이 없다.

(내 개인적인 의견은 wait은 공유자원에 대한 권한인 lock을 할당받기 위해 스레드가 "대기"하는 것이고, sleep은 아예 스레드를 재워버리는 것 (lock과 관계없이 스레드의 작업을 멈춤) 이기 때문에 용어를 맞게 작명했다고 생각함)

sleep을 멈추는 방법은 시간이 다 되어 멈추는 것과 interrupt에 의해 이 기다림을 임의의 시점에 중단시키는 방법밖에 없다.

Interrupt Thread 그리고 InterruptedException

sleep을 사용하려고하면 아래 코드와 같이 try catch 블럭으로 감싸야한다. 이는 interrupt의 존재 때문이다.

interrupt()함수의 기능을 정의하자면, wait()혹은 sleep()함수로 자고있거나, 대기하고 있는 thread를 깨움과 동시에 InterruptedException을 유발시켜 개발자로 하여금 누군가에게 그만 하자는 신호가 왔음을 알리는 것이다. 여기까지다. 오버하면 안된다. thread의 isInterrupted를 true로 만들어주지는 않는다는 사실을 명시해야한다.

그럼 누가하냐 그건? 개발자가 판단해서 해야한다. 그리고 나중에 설명하겠지만, 이 interrupt 함수는 명시적인 Lock (ReentrantLock)의 lockInterruptibly() 혹은 tryLock(시간)에 의해 BLOCKED 된 lock에 InterruptedException을 유발시키는 방법이기도 하다.

이는 Dead Lock 문제를 방지할 수 있는 원글쓴이가 알고있는 유일한 방법이기도 하다.

위 테스트 코드의 콘솔 출력 결과는 아래와 같다.

Current thread is Interrupted? false 

Current thread is Interrupted? true

testThreads[1].interrupt()의 호출이 isInterrupted() == true를 만들어주지 않음을 증명하고 있다.

isInterrupted() == true가 되는 시점은 catch 블럭 내에서 현재 thread의 interrupt()를 호출한 시점이다. 결국 두번에 걸친 interrupt의 호출이 Thread의 상태를 변경시킨다. (Interrupt 처음 발생했을 때는 catch 블럭으로 들어가고, 또 catch 블럭 안에서 interrupt를 호출함으로써 예외를 발생시켜서 예외 처리로 잡히지 않아야 비로소 스레드 상태가 interrupted가 된다.)

그러나 wait(), sleep() 등으로인해 thread가 쉬지 않고, thread가 계속 동작 중에는 isInterrupted가 바로 true로 된다. (쉬는중에는 Exception 터지는데, 아닌 경우 isInterrupted가 true로 변한다?)

Join

Thread를 출발(start())시키면 출발시킨 Thread(그것이 main Thread가 아닐 수도 있다.) 그리고 새로이 출발한 Thread(들)은 서로 갈길 찾아서 떠난다.

할일을 다하면 위에서 설명했듯이 Thread는 TERMINATED되어 그 생명을 다한다. 그런데 다 각자 할일을 하러 갔는데, Main Thread가 자기 할일 없다고 메인함수의 마감 블럭을 빠져나가는 순간, 작업중인 나머지 Thread들은 처리의 결과를 보고할 Main Thread를 잃어버리게 된다.

이런 상황에서 사용하는 함수가 join()이다. Thread객체.join()을 하면 해당 Thread의 객체의 처리가 완료될 때까지 현재 Thread는 그 자리에서 대기를하게 된다. (블럭킹 연산)

위 코드는 3초를 시작하기 전후에 메시지와 현재시각을 찍는 Task를 정의했고, 호출 Thread에서는 이렇게 만들어진 Task로 새로운 Thread를 start() 시킨다. 그리고 현재 Thread인 이 테스트 Thread는 t1.에 join한다. 이를 여러개 할 수 있다. 그렇게 하면 모든 join된 Thread를 기다리는 것이다.

단지 작업 Thread의 결과를 기다려야하는 이런 상황이라면 지저분하게 wait()를 할 필요학없다. (wait시켜놓고 notify로 깨울필요없다.)

그리고 앞에서 놓쳤는데, join도 sleep과 같이 thread인스턴스.interrupt() 메서드 호출에 의해 해당 예외가 유발될 수도 있다.

Yeild Thread

Thread라는 것은 결국 제한된 CPU자원을 서로 잘 나눠쓰는 개념으로 구현되어있는데, 이런 측면에서 보면 시작된 Thread가 무언가를 처리할 조건이 못되어 대기해야하는 경우에 타 Thread에게 기회를 양보하는 것도 효율을 위해 고려해봐야한다.

개발자가 이런 조건에서 임의로 wait()를 하고 타 Thread의 notify를 기다리는 것도 좋은 방법일 수도 있으나, 잘못된 프로그램 코드로 인해 기아(starvation)상태가 되어 영원히 휴게실(대기상태)에 머무는 산송장이 될 수도 있다.

yeild()는 이럴때 쓰면 된다.

지금은 당장 할 거 없고, RUNNING이 아닌 RUNNABLE 상태로 갈꺼니까 Scheduler 니가 알아서 다시 실행시켜줘

해서 아래와 같이 코드를 해놓으면 해당 Thread의 상태는 RUNNABLE과 RUNNING 사이를 계속 오가면서 실행할 수 있는 상태를 기다린다. (Busy waiting 느낌?)

실제 실험을 해보면 그냥 Loop를 회전하는 듯한데, 정말 부하가 걸리는 시스템에서는 효과가 있을 듯하다. (실험을 통한 검증은 못했다.)

타의에 의한 Thread Control

지금까지 설명한 Thread Control은 특정 함수를 통해 Thread의 상태를 변경시켜 기다리게 하거나(wait, sleep), 양보하거나(yield), 시간 또는 타 Thread의 알림(notify) 또는 간섭(interrupt)을 통해 다시 실행가능한 상태(runnable)로 돌리는 흐름이었다.

lock에 의한 Thread의 상태변화는 이에 비해 수동적이라고 할 수 있다.

예를들어 아래 코드와 같이 synchronized 블럭이 있고, t1 Thread가 먼저 이 블럭에 진입하여 lock을 획득했다고 가정해보자. 이 상황에서 t2가 이 블럭에 가면 t2는 BLOCKED가 되는 것이다.

따라서 원글쓴이는 BLOCKED Thread state를 수동적이라고 해석한다. (메서드 호출을 통해 능동적으로 상태 변경하는게 아니어서 그렇게 표현하는듯)

위 코드는 먼저 lock을 획득한 Thread는 자신의 Thread이름과 상태를 출력하고, 다른 Thread이름과 상태를 다음줄에 출력한다.

아래는 실행 결과이다.

Current thread name is T1, state is RUNNABLE 
Other thread name is T2, state is BLOCKED 
Current thread name is T1, state is RUNNABLE 
Other thread name is T2, state is BLOCKED 
Current thread name is T1, state is RUNNABLE 
Other thread name is T2, state is BLOCKED 
Current thread name is T2, state is RUNNABLE 
Other thread name is T1, state is TERMINATED 
Current thread name is T2, state is RUNNABLE 
Other thread name is T1, state is TERMINATED 
Current thread name is T2, state is RUNNABLE 
Other thread name is T1, state is TERMINATED

위에서 설명했듯이 Java의 Thread 상태는 RUNNING이 존재하지 않는다. 대신 실행대기와 실행중을 통합하는 RUNNABLE로 출력될 것이다.

synchronized 블럭에 진입하지 못한 Thread는 BLOCKED 이다. T1의 수행이 끝나고 T2가 실행될 때 T1은 할일이 다 끝났기 때문에 TERMINATED이다.

출처: https://rightnowdo.tistory.com/entry/JAVA-concurrent-programming-Thread-Control?category=396739

profile
백엔드 꿈나무 🐥

0개의 댓글