다시 하자 기초! 쓰레드#2 (쓰레드 실행제어, 동기화)
쓰레드의 실행제어
쓰레드 프로그래밍이 어려운 이유는 동기화와 스케줄링 때문이다. 앞서 우선순위를 통해 쓰레드간의 스케줄링을 하는 방법을 배웠지만 이것만으로는 한참 부족하다. 효율적인 프로그램을 만들기 위해서는 보다 정교한 스케줄링을 통해 주어진 자원과 시간을 여러 쓰레드가 낭비없게 잘 사용하도록 해야한다.
메서드 | 설명 |
static void sleep(long millis) static void sleep(long millis, int nanos) |
지정된 시간(천분의 일초 단위)동안 쓰레드를 일시정지시킨다. 지정한 시간이 지나고 나면, 자동적으로 다시 실행대기 상태가 된다. |
void join() void join(long millis) void join(long millis, int nanos) |
지정된 시간동안 쓰레드가 실행되도록 한다. 지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다. |
void interrupt() | sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기 상태로 만든다. 해당 쓰레드에서 interruptedException이 발생함으로써 일시정지 상태를 벗어나게 된다. |
void stop() | 쓰레드를 즉시 종료시킨다. |
void suspend() | 쓰레드를 일시정지 시킨다. resume()을 호출하면 다시 실행대기 상태가 된다. |
void resume() | suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기 상태로 만든다. |
static void yield() | 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행대기상태가 된다. |
쓰레드의 상태
상태 | 설명 |
NEW | 쓰레드가 생성되고 아직 start()가 호출되지 않은 상태 |
RUNNABLE | 실행 중 또는 실행 가능한 상태 |
BLOCKED | 동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다림) |
WAITING, TIMED_WAITING | 쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않은 일시정지 상태 |
TERMINATED | 쓰레드의 작업이 종료된 상태 |
쓰레드의 생성부터 소멸
1.쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행 대기열에 저장되어 자신의 차례가
될 때 까지 기다려야한다. 실행대기열은 큐와 같은 구조로 먼저 실행대기 열에 들어온 쓰레드가 먼저 실행
2.실행대기상태에 있다가 자신의 차례까 되면 실행 상태가 된다.
3.주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행상태가 됨
4.실행중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지상태가 될 수 있다.
I/O block은 입출력작업에서 발생하는 지연상태를 말한다. 사용자의 입력을 기다리는 경우를 예로 들 수 있는데,
이런 경우 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기 상태가 된다.
5.지정된 일시정지시간이 다되거나 notify(), resume(), interrupt()가 호출되면 일시 정지상태를 벗어나
다시 실행대기열에 저장되어 자신의 차례를 기다리게 된다.
6.실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.
1. sleep() - 일정시간동안 쓰레드를 멈추게 한다.
밀리세컨드(millis, 1000분의 1초)와 나노세컨드(nanos, 10억분의 일초)의 시간단위로 세밀하게 값을 지정할 수 있으나 어느 정도의 오차가 발생할 수 있다.
ThreadEx12_1 th1 = new ThreadEx12_1();
ThreadEx12_2 th2 = new ThreadEx12_2();
th1.start();
th2.start();
try {
th1.sleep(2000);
}catch(InterruptException e) {}
System.out.print("main종료");
위와 같은 코드를 실행하면 딱 보기엔 th1이 2초 대기하기에 제일 늦게 종료되야 하지만 실제론 main종료가 가장 늦게 출력되고 먼저 실행한 th1이 가장 먼저 작업이 끝난다. 왜그러냐면 th1.sleep(2000)을 해도 실제로 영향을 받는 것은 main메서드에서 실행하는 main쓰레드이다. 그래서 Thread.sleep(2000);과 같이 해야한다.
2. interrupt()와 interrupted() - 쓰레드의 작업을 취소한다.
진행중인 쓰레드의 작업이 끝나기 전에 취소시켜야할 때가 있다. 예를 들어 큰 파일을 다운로드받을 때 시간이 너무 오래 걸리면 ㅈ우간에 다운로드를 포기하고 취소할 수 있어야 한다. interrupt()는 쓰레드에게 작업을 멈추라고 요청한다. 단지 멈추라고할 뿐 종료시키지는 못한다. 그저 쓰레드의 interrupted상태(인스턴스 변수)를 바꾸는 것일 뿐이다.
쓰레드가 sleep(), wait(), join()에 의해 일시정지 상태에 있을 때 해당 쓰레드에 대해 interrupt()를 호출하면 실행대기 상태로 바뀐다. 즉 멈춰있던 쓰레드를 깨워서 실행가능한 상태로 만드는 것이다.
3. suspend(), resume(), stop()
위 3개의 메서드는 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만 suspend()와 stop()이 교착상태를 일으키기 쉽게 작성되어 있으므로 사용이 권장되지 않는다. 그래서 이 메서드들은 모두 'deprecated'되었다.
4. yield() - 다른 쓰레드에게 양보한다.
yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보(yield)한다. 예를 들어 스케쥴러에 의해 1초의 실행시간을 할당받은 쓰레드가 0.5초의 시간동안 작업한 상태
에서 yield()가 호출되면 나머지 0.5초는 포기하고 다시 실행대기상태가 된다. (자바의 정석 762page)
5. join() - 다른 쓰레드의 작업을 기다린다.
쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 join()을 사용한다.
시간을 지정하지 않으면, 해당 쓰레드가 작업을 마칠 때까지 기다리게 된다. 작업중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을 때 join()을 사용한다.
join()도 sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, try-catch문으로 감싸야한다. sleep()과 유사하지만 다른점으로는 static메서드가 아니라는 것.
쓰레드의 동기화
멀티쓰레드의 경우 여러 쓰레드가 같은 프로세스의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게된다. 만일 쓰레드 A가 작업하던 도중에 다른 쓰레드B에게 제어권이 넘어 갔을 때, 쓰레드A가 작업하던 공유데이터를 쓰레드B가 임의로 변경하였다면, 다시 쓰레드 A가 제어권을 받아서 나머지 작업을 마쳤을 때 원래 의도했던 것과는 다른 결과를 얻을 수 있다. 그래서 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요했는데 이때 도입된게 임계영역과 잠금(lock)이다.
Synchronized를 이용한 동기화
아래와 같이 두가지 방식이 있다.
1. 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum() { ... } //임계영역
2. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수) { ... } //임계영역
첫 번째 방식은 메서드 앞에 붙이는 것인데 이러면 메서드 전체가 임계영역이된다. 쓰레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock를 반환한다.
두 번째 방식은 메서드 내의 코드 일부를 블럭으로 감싸고 블럭 앞에 synchronized를 붙이는 것인데, 이때 참조변수는 락을 걸고자 하는 객체를 참조하는 것이어야 한다. 이 블럭을 synchronized블럭이라고 부르며, 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게되고 이 블럭을 벗어나면 lock을 반납한다.
두 방식 모두 lock의 획득과 반납이 모두 자동적으로 이루어지기 때문에 우린 그저 임계영역만 지정해주면 된다. 모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계영역의 코드를 수행할 수 있다. 그리고 다른 쓰레드들은 lock을 얻을 때까지 기다려야한다. (자바의 정석 769page)
wait()과 notify()
synchronized로 동기화해서 공유 데이터를 보호하는 것 까지는 좋은데, 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다. 만일 계좌에 출금할 돈이 부족해서 한 쓰레드가 락을 보유한 채로 돈이 입금될 때까지 오랜 시간을 보낸다면, 다른 쓰레드들은 모두 해당 객체의 락을 기다리느라 다른 작업들도 원활히 진행되지 않을것이다.
이를 위해 고안된 것이 wait()와 notify()다. 동기화된 임계영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니라면 일단 wait()를 호출하여 쓰레드가 락을 반납하고 기다리게 한다. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 한다.
이는 마치 빵을 사려고 빵집 앞에 줄을 서 있는 것과 유사한데, 자신의 차례가 되었음에도 자신이 원하는 빵이 나오지 않았으면 다음 사람에게 순서를 양보하고 기다리다가 자신이 원하는 빵이 나오면 통보를 받고 빵을 사가는 것이다. 차이가 있다면 오래 기다린 쓰레드가 락을 얻는다는 보장이 없다. 객체의 대기실에서 통지를 기다리는 쓰레드들 중 임의의 쓰레드만 통지를 받고 notifyAll()을 해도 결국 lock을 얻을 수 있는건 하나의 쓰레드라 나머진 다시 lock을 기다려야한다.
void wait()
void wait(long timeout)
void wait(long timeout, int nanos)
void notify()
void notifyAll()
매개변수가 있는 wait()는 지정된 시간동안만 기다리고 이후에는 자동적으로 notify()가 호출된다.
그리고 notifyAll()을 한다고 모든 객체의 waiting pool에 있는 쓰레드가 깨워지는게 아니라 notifyAll()이 호출된 객체의 waiting pool에 대기 중인 쓰레드만 해당되는 것을 기억하자.