JAVA

쓰레드 실행 제어

mukom 2022. 10. 13. 18:42

1. 쓰레드의 우선 순위 : priority

 

쓰레드는 우선 순위(priority)라는 static final 변수를 가지고 있다.

이 우선 순위는 최소 1 부터 최대 10 까지 지정할 수 있으며, 

별도의 우선 순위를 지정하지 않았다면 우선 순위는 default 값으로 5 가 배정된다.

 

또한 기본적으로 쓰레드의 우선 순위를 해당 쓰레드를 생성하는 쓰레드의 우선 순위를 상속받게 된다.

즉, main 쓰레드는 우선 순위가 5 인 쓰레드인데 여기에서 쓰레드를 생성한다면

생성된 쓰레드의 우선 순위 또한 5 가 되는 것이다.

 

쓰레드에 우선 순위를 두게 되면 기본적으로 우선 순위가 높은 쓰레드가 먼저 실행되고, 

비교적 우선 순위가 높지 않은 쓰레드는 좀 더 이후에 실행되게 된다.

 

하지만 이는 우선 순위에 차이가 없는 쓰레드끼리의 결과와 크게 다르지 않다.

이 결과로 우선 순위로는 쓰레드의 실행을 제어할 수 없다는 것을 알게 된다.

 

그렇다면 쓰레드를 제어하기 위해서는 어떻게 해야 할까?

바로 쓰레드의 상태에 관여하는 메서드를 적절히 활용하는 것이다.

 

 

2. 쓰레드의 실행 제어

 

모든 쓰레드는 life-cycle(생애주기) 이 있다.

 

쓰레드는 생성이 되고 실행 대기 상태를 거쳐 실행이 되거나, 일시 정지 상태로 전환되고 연산이 모두 끝나면 소멸되는 과정을 겪는다.

생성(NEW)         👉       실행 대기(RUNNABLE)         👉         실행(RUN)           👉       소멸(TERMINATED)
                                                                👆                              👇
                                                           일시 정지(WAITING, BLOCKED)      

 생성 후 start() 메서드로 호출하게 되면 쓰레드는 바로 실행하는 것이 아니라 실행이 가능한 실행 대기 상태로 전환한다.

생성   👉   start() 메서드 호출   👉    실행 대기

이 때 실행 대기열에는 여러 개의 쓰레드가 존재할 수 있고 큐(queue) 와 같은 구조로 되어 있어,

먼저 들어 온 쓰레드가 먼저 실행되는 형태이다.

 

이 실행 대기열에서 이제 실행 상태로 전환이 될 때

해당 쓰레드는 메서드에 따라 다양한 상태로 전환이 될 수 있다.

자기 차례가 되었을 경우   👉   실행
yield() 메서드를 만났을 경우   👉   양보 (뒤의 쓰레드에게 실행 순서를 양보하고 본인은 다시 실행 대기열 맨 뒤로)
sleep(), suspend(), wait(), join(), I/O block 등의 메서드를 만났을 경우   👉   일시 정지

쓰레드가 일시 정지의 상태로 전환이 되고 일정 시간이 지난 후

다시 실행시키기 위하여 실행 대기열로 불러와야 할 때가 있다.

resume(), notify(), notifyAll(), interrupt() 등을 통해 실행 대기열에 추가

그리고 쓰레드가 모든 작업을 마치게 되거나, stop() 메서드가 호출되면 비로소 쓰레드는 소멸된다.

 

이것이 쓰레드의 일련의 생애 주기이고,

우리는 쓰레드를 제어하기 위하여 이들의 상태를 전환시키는 메서드를 활용할 수 있다.

 

 

 

3. 쓰레드의 동기화

 

싱글 쓰레드 환경에서는 쓰레드가 하나만 실행되기 때문에 프로세스의 자원을 작업하는 데에 큰 문제가 없다.

하지만 멀티 쓰레드 환경에서는 이러한 자원이 문제가 될 수 있다.

 

하나의 자원을 쓰레드의 실행에 따라 공유하기 때문에

1번 쓰레드가 작업 중이던 부분을 2번 쓰레드가 변경할 수도 있다는 문제가 생긴다.

변경된 자원을 가지고 다시 1번 쓰레드가 작업하려 할 때 이로 인하여 원하지 않는 작업의 결과물이 나타날 수 있게 된다.

 

이와 같은 문제가 발생하지 않게 하기 위해서는 1번 쓰레드가 작업을 마칠 때까지 2번 쓰레드의 방해가 없어야 한다.

쓰레드 간의 공유되는 자원은 임계 영역'critical section' 으로 지정하고,

이 공유 자원에 접근이 가능한 것은 잠금'lock'  을 가지고 있는 쓰레드에 한하는 방식으로 제어한다.

그리고 이러한 제어 방식을 바로 쓰레드의 동기화'synchronization' 이라고 한다.

 

🔷 임계 영역 : synchronized 를 통한 동기화

 

synchronized 키워드로 임계 영역을 지정할 수 있는데 그 방식은 아래와 같다.

1. 메서드 전체를 임계 영역으로 지정하기
    public synchronized void methodName() {
    }

2. 특정한 영역을 임계 영역으로 지정하기
    synchronized (객체의 참조변수) {
    }

이 두 가지 방식의 공통점은 쓰레드가 synchronized 키워드를 만나게 됐을 때 

lock 즉 해당 블록에 대한 제어권을 자동으로 획득하게 되고,

블록이 끝나면 제어권을 자동으로 반납하게 된다는 특징을 가진다.

 

임계 영역의 설정 범위에 따라 메서드로 지정할 것인지 synchronized 블록으로 지정할 것인지의 차이가 있을 뿐이다.

class Counter {
	long count;
	void increase() {
		count++;
	}
}

public class ThreadTest extends Thread {

	Counter counter;
    int start;
    int end;
    
    public ThreadTest(Counter counter, int start, int end) {
    	this.counter = counter;
    	this.start = start;
		this.end = end;
    }
    
    @Override
	public void run() {
		for(int i = start ; i <= end ; i++) {
			counter.increase();
		}
	}
    
    public static void main(String[] args) {
    	Counter counter = new Counter();
        
    	ThreadTest thead1 = new ThreadTest(counter, 1, 50_000);
        ThreadTest thead2 = new ThreadTest(counter, 50_001, 100_000);
        
        thead1.start();
        thead2.start();
        
        try{
        	thead1.join();
            thead2.join();
        } catch(InterruptedException e) {
        	e.printStackTrace();
        }
        
        System.out.println(thead1.result + thead2.result);
    }
}

여기에서 공통 자원으로 사용하는 것은 Counter 가 된다.

for 문이 돌 때마다 counter 의 increase() 메서드가 실행되게 하였는데, 

 

이 과정에서 synchronized 블록을 사용하지 않으면 실행되는 쓰레드 간의 작업 보장이 안 되기 때문에

기대 값인 10만번이 출력되지 않는 것을 확인할 수 있다.

class Counter {
	long count;
	void increase() {
		synchronized (this) {
			count++;
		}
	}
}

public class ThreadTest extends Thread {

	Counter counter;
    int start;
    int end;
    
    public ThreadTest(Counter counter, int start, int end) {
    	this.counter = counter;
    	this.start = start;
		this.end = end;
    }
    
    @Override
	public void run() {
		for(int i = start ; i <= end ; i++) {
			counter.increase();
		}
	}
    
    public static void main(String[] args) {
    	Counter counter = new Counter();
        
    	ThreadTest thead1 = new ThreadTest(counter, 1, 50_000);
        ThreadTest thead2 = new ThreadTest(counter, 50_001, 100_000);
        
        thead1.start();
        thead2.start();
        
        try{
        	thead1.join();
            thead2.join();
        } catch(InterruptedException e) {
        	e.printStackTrace();
        }
        
        System.out.println(thead1.result + thead2.result);
    }
}

바뀐 코드로 실행을 해보면 각 쓰레드에서 5만번씩 실행되어 합계 10만이 출력되는 것을 확인할 수 있다.

synchronized 블록으로 인하여 각 쓰레드의 작업이 서로의 영역을 침범하지 않게 되어 

작업에서의 값을 보장하게 되는 것이다.

 

 

🔷 lock : wait() 와 notify()

 

앞서 임계 영역과 관련한 키워드를 알아 보았으니 이번에는 제어권에 관련하여 이야기 해보겠다.

 

쓰레드에게 제어권은 절대적이기 때문에 상황에 따라 제어권이 적절하게 쥐어져야 한다.

 

예를 들어 1번 쓰레드가 빵집에서 피자빵을 사기 위해 대기하고 있다고 해보자.

드디어 1번 쓰레드의 차례가 되었지만(제어권을 갖게 됨), 피자빵이 아직 나오지 않은 상황이다.

1번 쓰레드는 이 피자빵을 사러 왔기 때문에 피자빵이 나오는 것을 기다리게 된다.

이러한 상태가 지속되면 뒤에 있는 다른 쓰레드들은 빵을 사기 위해 계속 기다리게 된다.

 

이 상황을 개선하려면 1번 쓰레드는 제어권을 다음 차례의 쓰레드에게 넘겨주고,

자신은 피자빵이 나올 때까지 대기실에서 기다리면 된다.

wait() : 쓰레드를 일시 정지 상태로 전환시킨다. (제어권 반납을 자연스럽게 유도함)

 

하지만 이 때에도 문제가 발생할 수 있다.

만일 피자빵을 사러온 사람이 많다고 해보자.

그래서 현재 대기실에도 피자빵을 기다리는 쓰레드가 꽤 많은 상태이다.

이 때 주방장이 피자빵이 나왔다고 알려준다면, 누가 먼저 피자빵을 살 권리(제어권)를 갖게 되는 것일까?

notify() :  일시 정지 상태로 있는 임의의 쓰레드 하나를 호출한다.
notifyAll() : 일시 정지 상태로 있는 모든 쓰레드를 호출한다.

 

불려 나온 쓰레드가 반드시 내가 원하는 쓰레드가 아닐 수 있는 것이다.

이렇게 되면 주방장 입장에서는 계속 피자빵을 팔기 위해 대기해야 할 수도 있고,

1번 쓰레드는 피자빵을 사기 위해 계속 대기해야 할 수도 있게 되는 것이다.

기아 현상(starvation) : 계속 기다리는 현상

 

이러한 현상은 notifyAll()  호출로 어느 정도 막을 수는 있지만, 

피자빵이 아닌 빵을 기다리는 쓰레드에게는 불필요한 호출이 될 수 있다.

 

즉 애초에 구별해서 호출할 수 있도록 개선하는 것이 좋다.

 

🔷 lock : lock 과 condition 을 이용한 동기화

 

 

제어권의 종류는 다음과 같다.

ReentrantLock                   : 재진입이 가능한 lock, 가장 일반적인 배타 lock
ReentrantReadWriteLock  : '읽기' 기능은 중복 수행이 가능하지만, '쓰기'에는 배타적인 lock
StampedLock                    : ReentrantReadWriteLock  에 낙관적 읽기 추가

 

기본적으로 이 제어권들은 수동으로 잠그고(lock) 푸는(unlock) 과정을 해야 한다.

void lock()               :  제어권을 받는다. (잠근다.)     
void unlock()           :  제어권을 반납한다. (푼다.)
boolean isLocked() :  제어권을 가지고 있는지 확인한다.

 

보통 예외가 발생할 상황을 대비하여 try - finally 문으로 구현하는 것이 일반적이다.

📌 대부분의 경우 synchronized 블럭을 사용할 수 있다.

ReentrantLock lock = new ReentrantLock();

lock.lock();

try {
	// 임계 영역
} finally {
	lock.unlock();
}

 

또한 lock() 메서드를 걸게 되면 다른 쓰레드가 제어권을 얻지 못하게 block 상태로 만들기 때문에

쓰레드 간의 응답성이 나빠질 수 있다.

 

때문에 응답성이 중요한 코드에서는 lock() 메서드 대신 tryLock() 메서드를 활용하는 것이 좋다.

tryLock() 메서드는 제어권을 갖기 위해 대기하는 다른 쓰레드의 행동에 대해 결정권을 준다.

 

지정된 시간 동안 대기하였으나 제어권을 아직 갖지 못했다면

다른 작업을 시도할 것인지, 또는 포기할 것인지 등을 결정할 수 있도록 한다.

boolean tryLock()
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException

이 메서드는 제어권을 얻으면 true 를, 얻지 못하면 false 를 반환하며, InterruptedException 이 발생할 수 있다.

이는 제어권을 갖기 위해 대기하는 도중 interrupt() 메서드를 통해 작업을 중지할 수 있다는 것이다.

 

이번엔 앞서 wait() 와 notify() 메서드의 문제점을 해결해보자.

기존의 문제점은 대기실에 누가 들어가서 누가 불릴지 알 수 없다는 것이었다.

 

그래서 쓰레드에 Condition 을 만들어 구분을 할 수 있도록 한다.

// lock 생성
private ReenreantLock lock = new ReenreantLock();

// lock 으로부터 condition 생성
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();

이렇게 생성한 condition 에 wait() 와 notify() 대신 await() 와 signal() 을 사용하면 된다.

await()  : 쓰레드를 일시 정지 상태로 만든다.
signal() : 쓰레드를 대기열로 불러 온다.