티스토리 뷰

반응형

1. Thread 클래스와 Runnable 인터페이스

프로세스(Process)

프로세스는 단순히 실행 중인 프로그램을 말한다.

프로그램을 실행하면 운영체제에 의해 메모리 공간을 할당받아 프로세스가 된다.

프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원과 쓰레드로 구성되어 있다.

 

쓰레드(Thread)

쓰레드는 프로세스의 자원을 이용해서 실제로 작업을 수행하는 주체를 말한다.

모든 프로세스는 최소한 하나 이상의 쓰레드가 존재한다.

쓰레드가 하나면 싱글쓰레드 프로세스, 둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스(multi-threaded process)라고 한다.

 

멀티 태스킹(multi-tasking, 다중작업)

여러 개의 프로세스가 동시에 실행될 수 있는 것을 말하며, 대부분의 운영체제에서 지원하고 있다.

 

멀티 쓰레딩(multi-threading)

하나의 프로세스 내에서 여러개의 쓰레드가 동시에 작업을 수행하는 것이다.

CPU의 코어가 한번에 하나의 작업만 수행할 수 있기 때문에, 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치한다.

그러나 처리해야 하는 쓰레드의 수는 항상 코어의 개수보다 훨씬 많기 때문에 아주 짧은 시간동안 여러 작업을 번갈아가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 한다.

따라서 프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니다.

멀티 쓰레딩은 CPU의 사용률을 향상시키고 자원을 보다 효율적으로 사용할 수 있다.

 

자바에서 쓰레드 구현

쓰레드를 생성하는 방법은 두가지가 있다.

1) Runnable 인터페이스를 구현하는 방법

2) Thread 클래스를 상속빋는 방법

 

Thread 와 Runnable 모두 java.lang 패키지에 포함되어 있고,

Thread 클래스는 Runnable 인터페이스를 구현한 클래스이다.

Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없으므로 다른 클래스를 상속받을 일이 있다면, Runnable 인터페이스를 구현하고 그렇지 않은 경우에는 Thread 클래스를 상속받는 것이 편하다.

두가지 방법 모두 Runnable 인터페이스의 추상메서드인 run()을 구현해주어야 한다.

 

Thread 클래스

package java.lang;

class Thread implements Runnable {
    private static native void registerNatives();
    static {
        registerNatives();
    }
    //....
}

 

Runnable 인터페이스

package java.lang;

public interface Runnable {    
    public abstract void run();
}

 

예제

class RunnableExample implements Runnable {

    @Override
    public void run() {
        System.out.println("Runnable Example");
    }
}

class ThreadExample extends Thread {

    @Override
    public void run() {
        System.out.println("Thread Example");
    }
}

class Main {
    public static void main(String[] args) {
        Runnable r = new RunnableExample();
        Thread t1 = new Thread(r); // 생성자 Thread(Runnable Target)

        ThreadExample t2 = new ThreadExample();

        t1.start();
        t2.start();
        System.out.println("end");
    }
}

/* 실행 결과

Runnable Example
end
Thread Example

*/

Runnable 인터페이스를 구현한 경우에는 구현 클래스 인스턴스를 Thread 클래스의 생성자 매개변수로 제공해야 한다.

Thread는 항상 순서대로 동작하는 것은 아니다.

컴퓨터의 성능에 따라 달라지기도 하고 매번 결과가 다르다.

run()가 끝나지 않으면 애플리케이션은 종료되지 않는다.

 

쓰레드 이름

쓰레드의 이름은 생성자나 메서드를 통해서 지정/변경할 수 있다.

쓰레드의 이름을 지정하지 않으면 'Thread-번호' 형식으로 이름이 정해진다.

번호는 0부터 시작해서 생성 시에 1씩 증가한다.

Thread(Runnable target, String name)

Thread(String name)

void setName(String name)

이름은 다음과 같이 확인할 수 있다.

//Thread 클래스의 static 메서드 currentThread()를 호출하여 
//쓰레드에 대한 참조를 얻어온다.
System.out.println(Thread.currentThread().getName());

 

쓰레드 실행

쓰레드를 생성했다고 해서 자동으로 실행 되는 것이 아니고 start()를 호출해야 실행된다.

start()가 호출되었다고 바로 실행되는 것도 아니다.

실행대기 상태에 있다가 차례가 되면 실행된다.

어떤 한 쓰레드가 실행 후 종료되었다면, 다시 실행할 수 없다.

즉 다시 start()를 호출할 수 없다.

하나의 쓰레드를 두번 이상 start() 호출하면 IllegalThreadStateException 이 발생한다.

 

다시 수행하고 싶다면, 새로운 쓰레드를 생성한다음 start()를 호출해야한다.

ThreadExample t1 = new ThreadExamepl();
t1.start();
t1 = new ThreadExample();  //다시 생성
t1.start();

start() 메소드를 호출하면 새로운 쓰레드가 작업을 실행하는데 필요한 새로운 호출 스택(call stack)을 생성한 다음에 run()을 호출한다.

즉, 새로 생성된 호출 스택에 run()이 첫 번째로 올라가게 한다.

run() 메소드의 수행이 종료된 쓰레드는 호출스택이 모두 비워지면서 생성된 호출 스택도 소멸된다.

 


2. 쓰레드의 상태

효율적인 멀티쓰레드 프로그램을 만들기 위해서 정교한 스케쥴링을 통해 프로세스에게 주어진 자원과 시간을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야한다.

쓰레드의 상태는 말 그대로 현재 상태를 말한다.

 

Thread.State

1) NEW : 쓰레드 객체는 생성되었지만, 아직 start()가 호출되지 않은 상태

2) RUNNABLE : 쓰레드가 실행중이거나 실행 가능한 상태

3) BLOCKED : 동기화 블럭에 의해서 쓰레드가 일시정지 상태이며, 모니터 락(monitor lock)이 풀리기를 기다리는 상태

4) WAITING : 쓰레드 작업이 종료되지는 않았지만 실행 가능하지 않은(unrunnable)일시정지 상태. 쓰레드가 대기중인 상태.

5) TIMED_WAITING : 특정 시간만큼 쓰레드가 대기중인 상태

6) TERMINATED : 쓰레드가 종료된 상태

① 쓰레드를 생성하고 start()를 호출하면 실행 대기열에 저장되어 실행 대기 상태가 된다. (실행 대기열은 큐(queue)와 같은 구조로 먼저 들어온 쓰레드가 먼저 실행된다.)

 

② 실행 대기열에 있다가 자신의 차례가 되면 실행 상태가 된다.

 

③ 할당된 실행시간이 다 되거나 yield() 메서드를 만나면 다시 실행 대기 상태가 되고 다음 쓰레드가 실행 상태가 된다.

 

④ 실행 중에 suspend, sleep(), wait(), join(), I/O block에 의해서 일지정지 상태가 될 수 있다. I/O block은 입출력 작업에서 발생하는 지연 상태를 말한다. 예를 들면 사용자 입력을 받는 경우이다.

 

⑤ 지정된 일시정지 시간이 다 되거나(time-out), notify(), resume(), interrupt()가 호출되면 일시정지 상태를 벗어나 다시 실행대기열에 저장되어 자신의 차례를 기다린다.

 

⑥ 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 종료(소멸)된다.

번호 순서대로 수행되는 것은 아니다.

 

쓰레드의 스케쥴링과 관련된 메서드

1) sleep()

지정된 시간동안 쓰레드를 멈추게 한다.

// millis, 1/1000초 단위
// nanos 1/1000000000초 단위 = 10억분의 1초

static void sleep(long millis)
static void sleep(long millis, int nanos)

밀리세컨드와 나노세컨드 시간 단위로 지정할 수 있어 세밀한 값을 지정할 수 있지만 어느정도의 오차가 발생할 수 있다.

sleep()에 의해서 일지정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되어 InterruptedException이 발생하면, 잠에서 깨어나 실행 대기 상태가 된다.

 

그래서 sleep()을 호출할 때는 항상 try-catch문으로 예외를 처리해주거나 새로운 메서드를 만들어 처리해주어야 한다.

sleep()은 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문에 static으로 선언되어 있다. 그래서 참조변수를 이용해서 호출하는 것이 아니라 Thread.sleep(3000)과 같이 호출해야 한다.

th01.start();
th02.start();

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

System.out.println("end");

 

 

2) interrupt()

현재 진행 중인 쓰레드의 작업을 취소한다.

interrupt()는 진행 중인 쓰레드의 작업이 끝나기 전에 멈춰야할 때 사용한다.

강제로 종료시키는 것이 아니라 단순히 멈추라고 요청하는 것이다.

interrupt()는 쓰레드의 interrupted 상태를 바꾸는것일 뿐이다.

 

- void interrupt() : 쓰레드의 interrupted상태를 false에서 true로 변경.

- boolean isInterrupted() : 쓰레드의 interrupted 상태를 반환.

- static boolean interrupted() : 현재 쓰레드의 interrupted 상태를 반환 후, false로 변경.

 

일시정지 상태에 있는 쓰레드에 대해서 interrupt()를 호출하면, InterruptedException이 발생한다.

그 후에 쓰레드는 실행대기 상태로 바뀐다.

 

예제를 보자.

import javax.swing.*;

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

        String value = JOptionPane.showInputDialog("값을 입력하세요");
        System.out.println("입력한 값은 " + value + "입니다.");
        t1.interrupt();  //interrupted 상태를 true로 변경.
        System.out.println("interrupted 상태 : " + t1.isInterrupted());
    }
}

class ThreadExample extends Thread {
    @Override
    public void run(){
        int i = 10;
        while(i > 0 && !isInterrupted()){
            System.out.println(i--);
            try {
                Thread.sleep(1000);  //sleep()로 1초 시간 지연
            } catch (InterruptedException e){
            }
        }    
        System.out.println("종료");
    }
}

/* 실행결과

10
9
8
7
6
입력한 값은 1111입니다.
interrupted 상태 : true
5
4
3
2
1
종료

*/

사용자가 입력을 완료하고 interrupt()가 발생한다.

ThreadExample에서 카운트를 하다가 sleep()에 의해 멈춰있을 때 interrupt()가 발생했기 때문에 InterruptedException가 발생한다.

InterruptedException가 발생하면 쓰레드의 interrupted 상태는 false로 자동 초기화된다.

그래서 카운트가 종료되지 않는다.

사용자의 입력을 받았을 때 카운트를 종료하기 위해서는 InterruptedException의 발생 catch 블록에 interrupt()를 추가로 넣어서 interrupted 상태를 true로 다시 바꿔줘야 한다.

 

 

3) suspend(), resume(), stop()

suspend()는 sleep()처럼 쓰레드를 일시정지한다.

suspend()에 의해 정지된 쓰레드는 resume()를 호출하여 다시 실행대기 상태가 될 수 있다.

stop()은 호출되는 즉시 쓰레드가 종료되는데, suspend()와 stop()이 교착상태(deadlock)을 일으키기 쉽게 작성되어있어서 사용이 권장되지 않는다.

그래서 이 메서드들은 모두 deprecated되었다.

 

 

4) yield()

yield()는 쓰레드 자신에게 주어진 실행 시간을 다음 차례의 쓰레드에게 양보한다. y

ield()는 sleep()처럼 항상 현재 실행 중인 쓰레드에 대해 작동하기 때문에 static으로 선언되어 있다. 그래서 참조변수를 이용해서 호출하는 것이 아니라 Thread.yield()과 같이 호출해야 한다.

 

즉, 1초의 실행시간을 할당받은 쓰레드가 0.5초 시간동안 작업한 상태에서 yield()가 호출되면 나머지 0.5초는 포기하고 다시 실행 대기 상태가 된다.

 

yield()와 interrupt()를 적절히 잘 사용하면, 프로그램의 응답성을 높이고 효율적인 실행을 가능하게 할 수 있다.

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

    Thread th;

    ThreadExample(String name){
        th = new Thread(this, name);
    }

    public void run(){
        while(!stopped){
            if(!suspended ){
                /* 작업 수행 */
                try {
                    Thread.sleep(1000);
                } catch(InterruptedException e){
                }
            }
            else {  
                //suspended가 true일때 쓰레드의 남은 시간을 양보함으로써
                //의미없이 while문을 도는 busy-waiting을 방지할 수 있음.
                Thread.yield();  
            }
        }
    }

    public void start(){
        th.start();
    }

    public void resume(){
        suspended = false;
    }

    public void susepnd(){
        suspended = true;
        th.interrupt();
    }

    public void stop(){
        stopped = true;
        th.interrupt();
    }
}

 

 

5) join()

join()은 자신의 작업 도중에 다른 쓰레드의 작업을 참여시킨다는 의미다.

쓰레드가 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용한다.

작업 도중에 다른 쓰레드의 작업이 먼저 수행되어야할 때 join()을 사용하는 것이 좋다.

void join()
void join(long millis)
void join(long millis, int nanos)

join()도 sleep()처럼 예외처리를 해줘야한다.

interrupt()에 의해서 InterruptedException이 발생하고 대기상태에서 벗어날 수 있다.

join()은 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작하므로 static 메소드가 아니다.

class Main {
    static long startTime = 0;

    public static void main(String[] args){
        ThreadExample01 th1 = new ThreadExample01();
        ThreadExample02 th2 = new ThreadExample02();

        th1.start();
        th2.start();
        startTime = System.currentTimeMillis();

        try{
            th1.join();  //main 쓰레드가 th1의 작업이 끝날 때까지 기다린다.
            th2.join();  //main 쓰레드가 th2의 작업이 끝날 때까지 기다린다.
        } catch(InterruptedException e) {
        }

        System.out.println("소요 시간 : " + (System.currentTimeMillis() - startTime));
    }
}

class ThreadExample01 extends Thread {
    public void run(){
        for(int i=0; i<300; i++){
            System.out.println(new String("1"));
        }
    }
}

class ThreadExample02 extends Thread {
    public void run(){
        for(int i=0; i<300; i++){
            System.out.println(new String("2"));
        }
    }
}

th1.join() 과 th2.join()을 통해 메인 쓰레드는 th1과 th2의 작업이 끝날 때까지 기다린다.

두 쓰레드의 작업이 모두 끝나면 소요 시간을 계산하여 출력하게 된다.



 


3. 쓰레드의 우선순위

쓰레드는 우선순위라는 멤버변수를 가지고 있다.

이 우선순위 값에 따라 쓰레드가 얻는 실행 시간이 달라진다.

즉, 쓰레드가 수행하는 작업의 중요도에 다라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

 

다음은 쓰레드의 우선순위 관련 필드와 메서드다.

void setPriority(int newPriority) //쓰레드의 우선순위를 지정한 값으로 설정
int getPriority()  //쓰레드의 우선순위를 반환

public static final int MAX_PRIORITY  = 10   // 최대 우선순위
public static final int MIN_PRIORITY  = 1    // 최소 우선순위
public static final int NORM_PRIORITY = 5    // 기본 우선순위

쓰레드는 1~10의 우선순위를 가질 수 있으며, 숫자가 높을수록 우선수위가 높다.

생성한 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는데,

메인 쓰레드는 우선순위가 5이기 때문에 메인 메서드에서 생성하는 쓰레드의 우선수위는 기본적으로 5가 된다.

그리고 우선순위는 비례적인 절댓값이 아닌 상대적인 값일 뿐이다.

 

 

예제로 살펴보자.

public class Main{
    public static void main(String[] args) {
        ThreadExample01 th1 = new ThreadExample01();
        ThreadExample02 th2 = new ThreadExample02();

        th1.setPriority(1);
        th2.setPriority(3);

        System.out.println("th1 우선순위 : " + th1.getPriority());
        System.out.println("th2 우선순위 : " + th2.getPriority());

        th1.start();
        th2.start();
    }
}

class ThreadExample01 extends Thread {
    @Override
    public void run() {
        for (int i=0; i < 300; i++) {
            System.out.print("1");
            for (int x = 0; x < 100000000; x++);
        }
    }
}

class ThreadExample02 extends Thread {
    @Override
    public void run() {
        for (int i=0; i < 300; i++) {
            System.out.print("2");
            for (int x = 0; x < 100000000; x++);
        }
    }
}

여기서 주의할 점은 우선순위를 높게한 쓰레드가 항상 먼저 끝나는 것은 아니다.

아래 결과화면을 보면 th2의 우선순위가 더 높은데도 불구하고 th1이 더 먼저 완료되었다.

 

아래 결과화면은 위 결과와 다르게 th2가 먼저 완료되었다.

이러한 결과에서 알 수 있듯이 우선순위는 절대적으로 지켜지는 것이 아니다.

다만 우선순위가 높은 쓰레드에게 상대적으로 많은 양의 실행시간이 주어지는 것일 뿐임을 주의해야한다.




4. Main 쓰레드

Main 쓰레드

자바는 실행환경인 JVM에서 돌아가고 이것이 하나의 프로세스이다.

자바의 main() 메서드가 메인 쓰레드이다.

따로 쓰레드를 실행하지 않고 main() 메서드만 실행하는 것을 싱글쓰레드 애플리케이션이라고 한다.

반면에 메인 쓰레드에서 쓰레드를 생성하여 실행하는 것을 멀티쓰레드 애플리케이션이라고 한다.

메인 쓰레드가 종료되더라도 생성된 쓰레드가 실행중이라면 모든 쓰레드가 작업을 완료하고 종료될 때 까지 프로그램은 종료되지 않는다.

즉, 실행 중인 사용자 쓰레드가 하나도 없어야 프로그램은 종료된다.

 

Daemon 쓰레드

데몬 쓰레드는 다른 일반 쓰레드(사용자 쓰레드) 의 작업을 돕는 보조적인 역할을 하는 쓰레드이다.

사용자 쓰레드가 종료되면 데몬 쓰레드는 어디까지나 메인 쓰레드의 보조적인 역할을 수행하기 때문에 의미가 없어지므로 강제적으로 자동 종료된다.

데몬 쓰레드의 예로는 Garbage Collector, 워드프로세서의 자동저장, 화면자동갱신 등이 있습니다.

 

관련 메서드

- isDaemon() : 쓰레드가 데몬 쓰레드이면 true, 아니면 false 반환.

- setDaemon() : 쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경한다. true면 데몬 쓰레드가 된다.




5. 동기화

싱글쓰레드 프로세스의 경우 프로세스 내에서 단 하나의 쓰레드만 작업하기 때문에 프로세스의 자원을 가지고 작업하는데 별 문제가 없다.

그러나 멀티 쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 줄 수 있다.

이러한 일을 방지하기 위해서 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화(synchronization) 라고 한다.

 

동기화를 위해서 한 쓰레드가 특정 작업을 마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 해야한다.

그래서 임계 영역(critical section)잠금(락, lock)이 도입되었다.

 

공유 데이터를 사용하는 코드 영역을 임계영역으로 지정해놓고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다.

그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 나면 lock을 반납한다.

그러면 다른 쓰레드가 반납된 lock을 획득하여 임계 영역 내의 코드를 수행할 수 있게 된다.

 

 

동기화를 할 수 있는 방법을 살펴보자.

Synchronized 키워드

자바 예약어로 가장 간단한 동기화 방법이다.

이 키워드를 사용한 동기화 방식은 두가지이다.

 

① 메서드 전체를 임계영역으로 지정

첫번째 방법은 메서드 앞에 synchronized를 붙이는 것이다.

public synchronized void method(){
  //...
}

이렇게 하면 이 메서드 전체가 임계 영역으로 지정된다.

쓰레드는 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반납한다.

 

 

② 특정한 영역을 임계영역으로 지정

메서드 내의 코드 일부를 블록으로 감싸고 블록 앞에 synchronized(참조변수) 를 붙여 synchronized 블록을 지정한다.

이 때 참조변수는 lock을 걸고자 하는 객체를 참조하는 것이어야 한다.

synchronized(객체의 참조변수) {
    //...
}

이 블록 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납하게 된다.

 

 

예제를 통해 살펴보자.

은행 계좌에서 잔액을 확인하고 임의의 금액을 출금하는 예제이다.

일단, 동기화가 되지 않은 코드를 살펴보자.

class Main {
    public static void main(String[] args) {
        Runnable r = new RunnableExample();
        new Thread(r).start();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
        if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {

            }
            balance -= money;
        }
    }
}

class RunnableExample implements Runnable {
    Account acount = new Account();

    public void run() {
        while (acount .getBalance() > 0) {
            // 100, 200, 300 중 임의의 한 값으로 출금(withdraw)
            int money = (int) (Math.random() * 3 + 1) * 100;
            account .withdraw(money);
            System.out.println("balance : " + account.getBalance());
            System.out.println("출금되었습니다.");
        }
    }
}

 

위 코드를 실행해보면

출금하려는 금액이 계좌의 잔액보다 작아야 출금할 수 있게 코드를 작성했는데도 불구하고, 잔액이 마이너스가 되는 현상이 발생한다.

그 이유는 한 쓰레드가 withdraw() 메서드의 if문의 조건식은 통과하고 출금하기 직전에 다른 쓰레드가 끼어들어서 출금을 먼저 했기 때문이다.

즉, 한 쓰레드가 출금 수행 코드를 모두 수행하기 전에는 다른 쓰레드가 수행해서는 안된다.

그래서 이런 상황에 synchronized를 사용해야한다.

 

 

아래 코드는 동기화를 적용하도록 withdraw() 메서드를 수정한 코드이다.

public synchronized void withdraw(int money) {
    if (balance >= money) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {

        }
        balance -= money;
    }
}

/* 또는 synchronized 블록으로 수정할 수도 있다.

public void withdraw(int money) {
        synchronized(this){
            if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {

            }
            balance -= money;
        }
        }
}

*/

 

이제 잔액이 음수값이 되는 현상은 나타나지 않는것을 볼 수 있다.

한 쓰레드가 withdraw()를 수행할 때 다른 쓰레드에 의해 간섭을 받지 않는다.

 

 

wait()과 notify()

동기화를 이용하여 공유 데이터를 보호하는 것은 좋으나 어떤 한 쓰레드가 객체의 lock을 가진 상태로 상황이 해결될 때 까지 오랜 시간을 보내게 된다면, 다른 작업들이 원활히 진행되지 않을 것이다.

이런 상황을 막기 위한 것이 wait()notify() 이다.

 

동기화된 임계 영역 내부의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니라면, 일단 wait()를 호출하여 쓰레드가 lock을 반납하고 기다리게 한다.

그러면 다른 쓰레드가 lock을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다.

 

나중에 다시 작업을 진행할 수 있는 상황이 되면 notify()를 호출하여 작업을 중단했던 쓰레드가 다시 lock을 얻어 작업을 진행할 수 있게 된다.

 

wait() 과 notify()는 Object 클래스에 정의되어 있다.

아래 메서드들은 synchronized 블록 내에서만 사용할 수 있다.

//쓰레드가 락을 반납하고 notify() 나 notifyAll()이 호출될 때 까지 기다리게 한다.
void wait() 

//위의 wait()과 다르게 매개변수가 있는 wait()은 지정된 시간동안 기다린다.
void wait(long timeout)
void wait(long timeout, int nanos)

//객체의 waiting pool에서 대기중인 모든 쓰레드 중 임의의 쓰레드를 
//락을 얻을 수 있는 상태로 바꿔준다.
void notify()

//notifyAll()이 호출된 객체의 waiting pool에 대기중인 쓰레드가 
//락을 얻을 수 있는 상태로 바꿔준다.
void notifyAll()

오래 기다린 쓰레드가 notify()로 락을 얻는다는 보장은 없다.

notify()가 호출되면, 해당 객체의 대기실에 있는 모든 쓰레드 중 임의의 쓰레드만 연락을 받는다.

 

어떤 쓰레드는 운이 나빠서 계속해서 락을 얻지 못하고 오랫동안 기다리게 될 수도 있다.

이런 현상을 기아(starvation) 현상이라고 한다.

 

이 현상을 막기 위해서는 notify() 대신 notifyAll()을 사용하여 모든 쓰레드에게 통지할 수 있다.

그러나 notifyAll()로 원하는 쓰레드의 기아현상은 막았지만, 모든 쓰레드가 연락을 받기 때문에 불필요한 쓰레드까지 락을 얻으려고 하기 때문에 여러 쓰레드가 락을 얻기 위해 경쟁하게 된다.

 

이렇게 여러 쓰레드가 락을 얻기 위해서 서로 경쟁하는 것을 경쟁 상태(race condition)라고 한다. 이것을 막기 위해서는 쓰레드를 구별해서 연락하는 것이 필요하다. 이 때 LockCondition을 이용한다.

 

 

Lock과 Condition을 이용한 동기화

동기화할 수 있는 방법은 synchronized 블록 외에도 java.util.concurrent.locks 패키지가 제공하는 Lock 클래스들을 이용하는 방법이 있다.

이 패키지는 JDK1.5에 추가된 것이다.

synchronized 블록으로 동기화하면 자동으로 lock이 잠기고 풀린다.

또, 예외가 발생해면 lock이 자동으로 풀리기 때문에 편하다.

그러나 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 때로는 불편하다.

그럴 때 Lock클래스를 사용한다. 종류는 세가지이다.

 

1) ReentrantLock

재진입이 가능한 락으로 가장 일반적인 배타 락이다.

wait()과 notify()처럼 특정 조건에서 락을 풀고 나중에 다시 락을 얻어 이후의 작업을 수행할 수 있기 때문에 재진입이 가능하다.

ReentrantLock()
ReentrantLock(boolean fair)

ReentrantLock의 생성자의 매개변수를 true로 하면, 락이 풀렸을 때 가장 오래 기다린 쓰레드가 락을 획득할 수 있도록 공정한 처리를 한다. 처리를 위해서 성능은 좀 떨어지게 된다.

synchronized 블록은 자동으로 락이 잠기고 풀리는데 ReentrantLock과 같은 락 클래스들은 수동으로 락을 잠그고 해제해야 한다.

void lock()         // lock을 잠근다.
void unlock()       // lock을 해제한다.
boolean isLocked()  // lock이 잠겼는지 확인한다.

 

임계 영역 내에서 예외가 발생하거나 return문으로 빠져 나가면 락이 풀리지 않을 수도 있다.

그래서 일반적으로 unlock()은 try-finally 문으로 감싼다.

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

 

 

tryLock()은 lock()과 다르게 다른 쓰레드에 의해 락이 걸려 있으면 락을 얻으려고 기다리지 않거나 지정된 시간만큼만 기다린다.

락을 얻으면 true, 얻지 못하면 false를 반환한다.

 

lock()은 락을 얻을 때 까지 쓰레드를 블락시키기 때문에 쓰레드의 응답성이 나빠질 수 있다.

응답성이 중요할 경우에는 tryLock()으로 지정된 시간동안 락을 얻지 못하면 다시 작업을 시도할 지 포기할 지를 사용자가 결정하게 하여 응답성이 좋은 쓰레드를 만들게 하는 것이 좋다.

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

이 메서드는 InterruptedException을 발생시킬 수 있는데, 이것은 지정된 시간 동안 락을 얻으려고 기다리는 중에 interrupt()에 의해 작업이 취소될수 있도록 코드를 작성할 수 있다는 것을 말한다.

 

 

2) ReentrantReadWriteLock

읽기를 위한 락과 쓰기를 위한 락을 제공한다.

읽기에는 공유적이고 쓰기에는 배타적인 락이다.

 

읽기 락이 걸려있으면, 다른 쓰레드가 읽기 락을 중복해서 걸고 읽기를 수행할 수 있다.

읽기는 내용을 변경하지 않기 때문에 동시에 여러 쓰레드가 읽어도 문제가 되지 않는다.

그러나 읽기 락이 걸린 상태에서 쓰기 락을 거는 것은 허용되지 않고 반대의 경우도 마찬가지이다.

 

3) StampedLock

StampedLock은 락을 걸거나 해지할 때 스탬프(long 타입의 정수값)를 사용한다.

읽기와 쓰기를 위한 락 외에 낙관적 읽기 락(optimisitc reading lock)이 추가되었다.

읽기 락이 걸려있으면 쓰기 락을 얻기 위해서는 읽기 락이 풀릴 때까지 기다려야 하지만, 낙관적 읽기 락은 쓰기 락에 의해 바로 풀린다.

 

따라서 낙관적 읽기에 실패하면, 읽기 락을 얻어서 다시 읽어와야 한다.

무조건 읽기 락을 걸지 않고, 쓰기와 읽기가 충돌할때만 쓰기가 끝난 후에 락을 거는 것이다.

int getBalance() {
    long stamp = lock.tryOptimisticRead();  // 낙관적 읽기 lock을 건다.

    int curBalance = this.balance;    // 공유 데이터인 balance를 읽어온다.

    if(!lock.validate(stamp)) {   // 쓰기 lock에 의해 낙관적 읽기 lock이 풀렸는지 확인
        stamp = lock.readLock(); // lock이 풀렸으면, 읽기 lock을 얻으려고 기다린다.

        try {
            curBalance = this.balance;    // 공유 데이터를 다시 읽어온다.
        } finally {
            lock.unlockRead(stamp);     // 읽기 lock을 푼다.
        }
    }
    return curBalance;    // 낙관적 읽기 lock이 풀리지 않았으면 곧바로 읽어온 값을 반환
}

 

 

ReentrantLock과 Condition

Condition은 wait()과 notify()의 단점인 쓰레드를 구분해서 연락하지 못해서 발생하는 경쟁 상태를 해결해준다.

wait()과 notify()는 쓰레드의 종류를 구분하지 않고 공유 객체의 waiting pool에 같이 몰아 넣었었다.

각각의 쓰레드의 Condition을 만들어서 각각의 waiting pool에서 기다리도록 하면 문제를 해결할 수 있다.

그냥 사용한다고 경쟁 상태가 완전히 없어지는 것은 아니지만 좀 더 세분화하여 waiting pool을 나눌수록 경쟁 상태가 발생할 가능성을 낮출 수 있다.

 

그리고 기존에 사용하였던 wait()과 notify() 대신에 await()signal()을 사용하면 된다.

- void await()

  - void await()

  - void awaitUninterruptibly()

 

- void await(long timeout)

  - boolean await(long time, TimeUnit unit)

  - long awaitNanos(long nanosTimeout)

  - boolean awaitUntil(Date deadline)

 

- void notify()

  - void signal()

 

- void notifyAll()

  - void signalAll()

 

 

요리사와 손님, 테이블을 이용한 코드를 예제로 살펴보자.

private ReentrantLock lock = new ReentrantLock(); // lock을 생성

// lock으로 condition을 생성
private Condition forCook = lock.newCondition();
private Condition forCustomer = lock.newCondition();
public add(String dish) {
    lock.lock();

    try {
        while (dished.size() >= MAX_FOOD) {  //테이블이 꽉찼을 경우
            String name = Thread.currentThread().getName();
            System.out.println(name+" is waiting.");
            try {
                forCook.await(); // wait(); COOK 쓰레드를 기다리게 한다.
            } catch (InterruptedException e) { }
        }

                //테이블이 비었기 때문에 음식 추가
        dished.add(dish);
        forCustomer.signal(); // notify();  기다리고 있는 CUSTOMER를 깨우기 위함.
        System.out.println("Dished:" + dishes.toString());
    }finally {
            lock.unlock();
    }
}

이전에는 wait()과 notify()만 사용하면서 기다릴 대상과 연락을 받을 대상이 무엇인지 확실하지 않았지만

await()과 signal()은 대기와 통지의 대상이 명확하게 보이는 장점이 있다.



 


6. 데드락

교착상태(데드락, deadlock)는 두 개 이상의 작업이 서로 상대방의 작업이 끝나기를 기다리고 있어서 아무것도 완료되지 못하는 상태를 말한다.

 

교착 상태의 조건

아래 4개의 조건이 모두 만족해야 교착 상태가 발생한다.

 

1) 상호 배제(Mutual exclusion) : 프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구한다. 배타적인 자원은 임계구역에서 보호되기 때문에 다른 프로세스(쓰레드)가 동시에 사용할 수 없다.

2) 점유 대기(Hold and wait) : 프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다린다.

3) 비선점(No preemption) : 프로세스가 어떤 자원의 사용을 끝낼 때까지 그 자원을 뺏을 수 없다.

4) 순환 대기(Circular wait) : 각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다. 자원을 요구하는 방향이 원을 이루면 양보를 하지 않기 때문에 교착상태가 발생할 수 있다.

 

 

교착 상태 해결방법

1) 예방

- 상호배제 조건의 제거 : 교착 상태는 두 개 이상의 프로세스가 공유가능한 자원을 사용할 때 발생하는 것이므로 공유 불가능한, 즉 상호 배제 조건을 제거하면 교착 상태를 해결할 수 있다.

 

- 점유와 대기 조건의 제거 : 한 프로세스에 수행되기 전에 모든 자원을 할당시키고 나서 점유하지 않을 때에는 다른 프로세스가 자원을 요구하도록 하는 방법이다. 자원 과다 사용으로 인한 효율성, 프로세스가 요구하는 자원을 파악하는 데에 대한 비용, 자원에 대한 내용을 저장 및 복원하기 위한 비용, 기아 상태, 무한대기 등의 문제점이 있다.

 

- 비선점 조건의 제거 : 비선점 프로세스에 대해 선점 가능한 프로토콜을 만들어 준다.

 

- 환형 대기 조건의 제거 : 자원 유형에 따라 순서를 매긴다.

 

이 해결 방법들은 자원 사용의 효율성이 떨어지고 비용이 많이 드는 문제점으로 잘 사용되지 않는다.

 

 

2) 회피

자원 할당량을 조절하여 교착 상태를 해결하는 방식이다.

즉, 자원을 할당하다가 교착 상태를 유발할 가능성이 있다고 판단하면 자원 할당을 중단하고 지켜보는 것이다.

그러나 자원을 얼마만큼 할당해야 교착 상태가 발생하지 않는다는 보장이 없기 때문에 실효성이 적다.

교착 상태 회피 알고리즘은 크게 자원 할당 그래프 알고리즘은행원 알고리즘이 있다.

 

 

 

3) 무시

예방과 회피 방법을 활용할 수는 있지만, 성능 상 이슈가 발생한다.

데드락 발생 상황을 고려하는 것의 비용이 낮다면 별다른 조치를 하지 않을 수도 있다.

반응형

'Java > 개념정리' 카테고리의 다른 글

[Java] 애노테이션(annotation)  (1) 2021.10.18
[Java] enum(열거형)  (0) 2021.10.14
[Java] 예외 처리  (1) 2021.10.06
[Java] 인터페이스(interface)  (0) 2021.10.06
[Java] 자바 package  (0) 2021.09.28
댓글
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday