[10주차] 멀티쓰레드 프로그래밍

janjanee·2022년 8월 1일
0
post-thumbnail

2021.02.03 작성글 이전

10. 멀티쓰레드 프로그래밍

학습 목표 : 자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.

프로세스(process)? 실행중인 프로그램

프로세스는 프로그램을 수행하는 데 필요한 데이터, 메모리 등의 자원과 쓰레드로 구성되어있다.
프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드이다.

따라서, 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재하고, 둘 이상의 쓰레드를 가진 프로세스를
멀티쓰레드 프로세스라고 한다.

멀티쓰레딩? 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행

CPU의 코어(core)가 한 번에 단 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수는
코어의 개수와 일치한다.

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

멀티쓰레딩 장단점

멀티쓰레딩의 장점

  • cpu의 사용률을 향상
  • 자원을 보다 효율적으로 사용
  • 사용자에 대한 응답성 향상
  • 작업이 분리되어 코드 간결

카카오톡으로 채팅을 하며, 파일을 다운로드 받고, 영상통화를 할 수 있는 이유가 바로 멀티쓰레드로
작성되어 있기 때문이다.
만약 싱글쓰레드였다면? -> 파일을 다운로드 받는동안 다른일(채팅)을 할 수 없다.

그러나, 멀티쓰레딩에 장점만 존재하는 것은 아니다.
멀티쓰레드 프로세스는 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업하기 때문에
발생할 수 있는 '동기화(synchronization)', '교착상태(deadlock)'와 같은 문제들을 고려해서 프로그래밍 해야한다.

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

쓰레드를 구현하는 방법에는 Thread 클래스를 상속, Runnable 인터페이스를 구현 하는 두 가지 방법이 있다.

Thread 클래스를 상속받으면, 다른 클래스를 상속받을 수 없기 때문에 Runnable 인터페이스를 구현하는 방법이 일반적이다.
또한, run() 메소드만 오버라이딩 할 경우라면 Runnable 인터페이스를 사용하고, Thread의 다른 메소드들을
오버라이딩 할 것이라면 Thread 클래스를 상속하는 방식을 택하면 된다.

1. Thread 클래스 상속
class MyThread extends Thread {
    public void run() { ... }   // Thread 클래스의 run()을 오버라이딩
}
2. Runnable 인터페이스 구현
class MyThread implements Runnable {
    public void run() { ... }   // Runnable 인터페이스의 run()을 구현
}

아래 예제에서 위의 두 가지 방식으로 쓰레드를 구현하는 방법을 작성했다.

public class ThreadEx1 {
    public static void main(String[] args) {

        ThreadEx1_1 t1 = new ThreadEx1_1();
        Thread t2 = new Thread(new ThreadEx1_2());

        t1.start();
        t2.start();

    }
}

class ThreadEx1_1 extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName());
        }
    }
}

class ThreadEx1_2 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

몇 가지 다른 점이 존재하는데 Runnable 인터페이스를 구현한 경우, Runnable 인터페이스를 구현한 클래스의
인스턴스를 생성한 다음, 이 인스턴스를 Thread 클래스 생성자의 매개변수로 제공해야한다. Thread t2 = new Thread(new ThreadEx1_2());

또한, Thread 클래스를 상속받은경우, 자손 클래스에서 조상 클래스 Thread 클래스의 메소드를 직접 호출할 수 있지만,
Runnable을 구현하면 Thread클래스의 static 메소드인 currentThread()를 호출하여 쓰레드에 대한 참조를
얻어와야 호출 가능하다.

쓰레드의 실행 - start()

쓰레드를 생성했다고 해서 자동으로 실행되는 것은 아니다. start()를 호출해야만 쓰레드가 실행된다.
start()를 한다해서 바로 실행되는 것은 아니고, 실행대기 상태에 있다가 자신의 차례가 되면 실행된다.

한 가지 주의할 점이 있다.

ThreadEx1_1 t1 = new ThreadEx1_1();
t1.start();
t1.start(); // 예외발생

위의 코드처럼 한번 실행 종료된 쓰레드는 다시 실행할 수 없다. 하나의 쓰레드에 대해 start()가
한 번만 호출될 수 있다. 위의 코드는 예외가 발생한다.

ThreadEx1_1 t1 = new ThreadEx1_1();
t1.start();
t1 = new ThreadEx1_1(); // 다시 생성
t1.start();     // OK

위 코드처럼 쓰레드를 다시 생성하여 start() 하면된다.

start()와 run()

main 메소드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된
메소드를 호출하는 것 뿐이다.

위의 그림은 새로운 쓰레드를 생성하고 start()를 호출한 후 호출스택의 변화를 나타낸 것이다.

  1. main 메소드에서 쓰레드의 start()를 호출
  2. start()는 새로운 쓰레드를 생성하고, 쓰레드가 작업하는데 사용될 호출스택을 생성
  3. 새로 생성된 호출스택에 run()이 호출되어, 쓰레드가 독립적 공간에서 작업을 수행
  4. 이제는 호출스택이 2개이므로 스케줄러가 정한 순서에 의해서 번갈아 가며 실행

싱글쓰레드와 멀티쓰레드

싱글 코어에서 단순히 CPU만을 사용하는 계산작업이라면 오히려 멀티쓰레드보다 싱글쓰레드로 프로그래밍 하는 것이 효과적이다.

싱글쓰레드와 멀티쓰레드를 예제를 통해서 알아보자.

위 코드는 싱글쓰레드 작업 코드이다.

'-'과 '|'을 출력하는 작업을 하나의 쓰레드가 연속적으로 처리하는 시간을 측정한다.
평균 800 밀리세컨드이다.

다음은 멀티 쓰레드 작업 코드이다.

main쓰레드와 새로 생성한 쓰레드 두 개의 쓰레드가 작업을 하나씩 나누어서 수행한다.
결괄르 보면 싱글쓰레드 예제와 다르게 두 작업이 아주 짧은 시간동안 번갈아가면서 실행되었으며 거의 동시에
작업이 완료되었다. 평균 1200 밀리세컨드가 소요되었다.

두 개의 쓰레드로 작업하는데도 더 많은 시간이 걸린 이유는 두 가지이다.

  1. 쓰레드가 번갈아가면서 작업하므로 쓰레드간의 작업전환시간이 소요
  2. 한 쓰레드가 화면에 출력하는 동안 다른 쓰레드는 출력이 끝나기를 기다림

그럼 어떤 경우에 멀티쓰레드가 유용할까?

두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우 멀티쓰레드가 더 효율적이다.
예를들면, 사용자로부터 데이터를 입력받는 작업, 네트워크로 파일을 주고 받는 작업, 프린터로 파일 출력하는
작업과 같이 외부기기와의 입출력을 필요로 하는 경우가 해당된다.

바로 아래의 예제에서 확인해보자.

싱글쓰레드에서 사용자 입력을 받고 1초마다 10->1까지 출력하는 예제이다.
싱글쓰레드이기 때문에 사용자가 입력을 할 때 까지 화면에 10->1의 숫자가 출력되지 않는다.
사용자가 입력을 하면 그 이후로 10->1 까지의 숫자가 출력된다.

반면에 멀티쓰레드인 경우 사용자가 입력하는 부분과 숫자를 출력하는 부분을 두 개의 쓰레드로 나눠서 진행한다.
따라서, 사용자가 값을 입력하지 않고 대기 상태이더라도, 화면에 10->1까지의 숫자가 출력된다.

10-2. 쓰레드의 상태

상태설명
NEW쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE실행 중 또는 실행 가능한 상태
BLOCKED동기화블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
WAITING,</br> TIMED_WATING쓰레드의 작업이 종료되지 않았지만 실행가능하지 않은(unrunnable) 일시정지 상태.</br> TIMED_WATING은 일시정지시간이 지정된 경우를 의미
TERMINATED쓰레드의 작업이 종료된 상태

image

위의 그림은 쓰레드의 생성부터 소멸까지의 모든 과정을 그린것이다.

sleep(long millis) - 일정시간동안 쓰레드를 멈추게 한다.

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

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

  • sleep()을 호출할 때는 항상 try-catch문으로 예외를 처리해줘야 한다.
public class ThreadEx12 {
    public static void main(String[] args) {
        ThreadEx12_1 th1 = new ThreadEx12_1();
        ThreadEx12_2 th2 = new ThreadEx12_2();
        th1.start();
        th2.start();

        try {
            th1.sleep(2000L);
        } catch (InterruptedException e) {}

        System.out.print("<<main 종료>>");
    }
}

class ThreadEx12_1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++)
            System.out.print("-");
        System.out.print("<<th1 종료>>");
    }
}

class ThreadEx12_2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++)
            System.out.print("|");
        System.out.print("<<th2 종료>>");
    }
}

th1 쓰레드를 2초 동안 멈췄는데, 결과를 보면 main 메소드 종료가 가장 마지막에 찍힌것을 확인할 수 있다.
이유는 sleep()이 항상 현재 실행중인 쓰레드에 대해 작동하기 때문에 'th1.sleep(2000L)'을
호출했어도 실제로 영향 받는 것은 main 쓰레드이다.

따라서 sleep()은 static으로 선언되어 있으며 참조변수를 이용해 호출하기 보다
Thread.slee(2000L);과 같이 사용해야한다.

interrupt()와 interrupted() - 쓰레드의 작업을 취소한다.

  • interrupt()는 쓰레드에게 작업을 멈추라고 요청한다. 멈추라고 요청할 뿐 쓰레드를 강제 종료시키지는 못한다.
  • interrupted()는 쓰레드에 대해 interrupt()가 호출되었는지 알려준다.
void interrupt()            // 쓰레드의 interrupted상태를 false -> true로 변경
boolean isInterrupted()     // 쓰레드의 interrupted상태를 반환
static boolean interrupted()    // 현재 쓰레드의 interrupted상태 반환 후, false로 초기화

쓰레드가 sleep(), wait(), join()에 의해, '일시정지 상태(WATING)'에 있을 때,
해당 쓰레드에 대해 interrupt()를 호출하면 sleep(), wait(), join()에서 InterruptedException이
발생하고 쓰레드는 '실행대기 상태(RUNNABLE)'로 바뀐다. 즉, 멈춰있던 쓰레드를 깨워서 실행가능한 상태로 만든다.

public class ThreadEx13 {
    public static void main(String[] args) {
        ThreadEx13_1 th1 = new ThreadEx13_1();
        th1.start();

        String input = JOptionPane.showInputDialog("아무 값이나 입력!");
        System.out.println("입력하신 값은 " + input + "입니다.");
        th1.interrupt();
        System.out.println("isInterrupted(): " + th1.isInterrupted());
    }
}

class ThreadEx13_1 extends Thread {
    @Override
    public void run() {
        int i = 10;

        while (i != 0 && !isInterrupted()) {
            System.out.println(i--);
            for(long x = 0; x < 2500000000L; x ++); // 시간지연
        }

        System.out.println("카운트가 종료되었습니다.");
    }
}

위 예제는 카운트 다운 도중에 사용자의 입력이 들어오면 interrupt()를 이용하여 카운트 다운을 종료시킨다.

yield() - 다른 쓰레드에게 양보한다.

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

class ThreadEx18_1 implements Runnable {

    boolean suspended = false;
    boolean stopped = false;

    Thread th;

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

    @Override
    public void run() {
        String name = th.getName();

        while(!stopped) {
            if(!suspended) {
                System.out.println(name);
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    System.out.println(name + " - interrupted");
                }
            } else {
                Thread.yield();
            }
        }
        System.out.println(name + " - stopped");
    }

    ...
}

suspended 값이 true라면, 즉 잠시 실행을 멈추게 한 상태라면, 쓰레드는 의미없는 while문을 반복하게된다.
이런 상황을 '바쁜 대기상태(busy-wating)' 이라고 한다.
그러나 yield()를 호출했기 때문에 남은 실행시간을 while문에서 낭비하지 않고 다른 쓰레드에게 양보(yield)하게
되므로 더 효율적이다.

join() - 다른 쓰레드의 작업을 기다린다.

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

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

시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠때까지 기다린다. 작업 중에 다른 쓰레드의 작업이
먼저 수행되어야할 필요가 있을 때 join()을 사용한다.

try {
    th1.join(); //  현재 실행중인 쓰레드가 쓰레드 th1의 작업이 끝날때까지 기다린다.
}

join()도 sleep()처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()이 호출되는 부분을
try-catch문으로 감싸야 한다.

join()은 sleep()과 비슷한 점이 많은데 다른점은, join()은 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작하므로
static 메소드가 아니다.

public class ThreadEx19 {

    static long startTime = 0L;

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

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

        startTime = System.currentTimeMillis();

        try {
            th1.join();
            th2.join();
        } catch (InterruptedException e) {}

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

}

class ThreadEx19_1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print(new String("-"));
        }
    }
}
class ThreadEx19_2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print(new String("|"));
        }
    }
}

join()을 사용하지 않았으면 main 쓰레드는 바로 종료되었겠지만, join()으로 쓰레드 th1, th2의
작업을 마칠 때 까지 main쓰레드가 기다리도록 했다. 그래서 마지막에 main쓰레드가 두 쓰레드의 작업에
소요된 시간을 출력할 수 있다.

10-3. 쓰레드 그룹(thread group)

서로 관련된 쓰레드를 그룹으로 다룬다.

쓰레드를 쓰레드 그룹에 포함시키려면 Thread의 생성자를 이용해야 한다.

  • 모든 쓰레드는 반드시 쓰레드 그룹에 포함되어야 한다.
  • 쓰레드 그룹을 지정하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속한다.
  • 자바 애플리케이션이 실행
  • JVM은 main과 system이라는 쓰레드 그룹을 생성
  • JVM운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함
  • 우리가 생성한 모든 쓰레드 그룹은 main 쓰레드 그룹의 하위 쓰레드 그룹
  • 쓰레드 그룹이 없다면 main 쓰레드 그룹에 속함

쓰레드 그룹과 관련된 메소드는 다음과 같다.

ThreadGroup getThreadGroup()  // 쓰레드 자신이 속한 쓰레드 그룹을 반환

void uncaughtException(Thread t, Throwable e)   // 쓰레드 그룹의 쓰레드가 처리되지 않은 예외에 의해 실행이 종료되었을 때, JVM에 의해 이 메소드 자동 호출
public class ThreadEx9 {
    public static void main(String[] args) {
        ThreadGroup main = Thread.currentThread().getThreadGroup();
        ThreadGroup grp1 = new ThreadGroup("Group1");
        ThreadGroup grp2 = new ThreadGroup("Group2");

        // ThreadGroup(ThreadGroup parent, String name)
        ThreadGroup subGrp1 = new ThreadGroup(grp1, "SubGroup1");

        grp1.setMaxPriority(3); // 쓰레드 그룹 grp1의 최대우선순위를 3으로 변경.

        Runnable r = () -> {
            try {
                Thread.sleep(1000L);    // 쓰레드를 1초간 멈춤.
            } catch (InterruptedException e) {}
        };

        new Thread(grp1, r, "th1").start();
        new Thread(subGrp1, r, "th2").start();
        new Thread(grp2, r, "th3").start();

        System.out.println(">>List of ThreadGroup : " +  main.getName() + ", Active ThreadGroup: " + main.activeGroupCount()
        + ", Active Thread: " + main.activeCount());
        main.list();
    }
}

위의 예제 코드 결과는 아래와 같다.

>>List of ThreadGroup : main, Active ThreadGroup: 3, Active Thread: 5
java.lang.ThreadGroup[name=main,maxpri=10]
    Thread[main,5,main]
    Thread[Monitor Ctrl-Break,5,main]
    java.lang.ThreadGroup[name=Group1,maxpri=3]
        Thread[th1,3,Group1]
        java.lang.ThreadGroup[name=SubGroup1,maxpri=3]
            Thread[th2,3,SubGroup1]
    java.lang.ThreadGroup[name=Group2,maxpri=10]
        Thread[th3,5,Group2]

main.list()를 호출해서 main쓰레드 그룹의 정보를 출력하는 예제이다.
쓰레드 그룹에 포함된 하위 쓰레드 그룹이나 쓰레드는 들여쓰기를 이용해서 구별되어있다.

10-4. 쓰레드의 우선순위

쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.

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

예를 들어, 파일전송이 가능한 메신저의 경우 채팅 내용을 전송하는 쓰레드가 파일 전송 쓰레드 보다
우선순위가 높아야 사용자가 채팅하는데 불편함이 없을 것이다. 이처럼 시각적인 부분이나 사용자에게 빠르게
반응해야하는 작업을 하는 쓰레드의 우선순위는 다른 작업을 수행하는 쓰레드에 비해 높아야 한다.

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이며 숫자가 높을수록 우선순위가 높다.
쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다.
main메소드를 수행하는 쓰레드의 우선순위는 5이므로, main 메소드 내에서 생성한 쓰레드의 우선순위도 5이다.

public class Thread8 {
    public static void main(String[] args) {
        ThreadEx8_1 th1 = new ThreadEx8_1();
        ThreadEx8_2 th2 = new ThreadEx8_2();

        th2.setPriority(10);

        System.out.println("Priority th1(-) : " + th1.getPriority());
        System.out.println("Priority th2(-) : " + th2.getPriority());

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

class ThreadEx8_1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("-");
            for (int j = 0; j < 10000000; j++);
        }
    }
}

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

위의 예제는 쓰레드 우선순위 설정 예제이다. 그런데, 멀티코어에서는 쓰레드의 우선순위에 따른 차이가 거의 없다.

  • 멀티코어라 해도 OS마다 다른 방식으로 스케쥴링 하기 때문에, 어떤 OS에서 실행하느냐에 따라 다른 결과를 얻을 수 있다.

  • 굳이 우선순위에 차등을 두어 실행하려면, 특정 OS의 스케쥴링 정책과 JVM 구현을 직접 확인해봐야 한다.

    그러나 예측만 가능한 정도일 뿐 정확히 알 수는 없다.

쓰레드에 우선순위를 부여하는 대신 작업에 우선순위를 두어 PriorityQueue에 저장해놓고,
우선순위가 높은 작업이 먼저 처리되도록 하는 것이 나을 수 있다.

10-5. Main 쓰레드

main 메소드의 작업을 수행하는 것도 쓰레드이며, 이를 main쓰레드 라고 한다.
지금까지 쓰레드를 몰랐지만 쓰레드를 사용하고 있던것이다.
프로그램이 실행되기 위해서는 작업을 수행하는 일꾼인 최소한 하나의 쓰레드가 필요하다는 것!

main메소드가 수행을 마치면 프로그램이 종료되었으나, main메소드가 수행을 마쳤다 하더라도 다른 쓰레드가
아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않는다.

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

위의 예제는 새로 생성한 쓰레드에서 예외를 일부러 발생시키고 printStackTrace()를 이용해서 예외가 발생한
당시의 호출스택을 출력하는 예제이다.

호출스택의 첫 번째 메소드를 보면 main메소드가 아니라 run메소드이다. 한 쓰레드가 예외가 발생해서 종료되어도
다른 쓰레드의 실행에는 영향을 미치지 않는다. 따라서 main쓰레드는 이미 종료된 상태이다.

이전 예제랑 다르게 쓰레드가 새로 생성되지 않았다. 그저 run()이 호출되었을 뿐이다.
따라서 main쓰레드의 호출스택에서 예외가 발생했으므로 예외결과를 보면 제일 하단에 main메소드가 포함되어있다.

10-6. 데몬 쓰레드(daemon thread)

다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드

일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다.
데몬 쓰레드의 예로는 가비지 컬렉터, 워드프로세서의 자동저장, 화면자동갱신 등이 있다.

boolean isDaemon()          //  쓰레드가 데몬 쓰레드인지 확인한다.
void setDaemon(boolean on)  //  쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경한다.
public class ThreadEx10 implements Runnable{
    static boolean autoSave = false;

    public static void main(String[] args) {
        Thread t = new Thread(new ThreadEx10());
        t.setDaemon(true);
        t.start();

        for (int i = 1; i <= 10; i++) {
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {}

            System.out.println(i);

            if (i==5)
                autoSave = true;

        }

        System.out.println("프로그램을 종료합니다.");
    }

    public void run() {
        while(true) {
            try {
                Thread.sleep(3000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            if (autoSave) {
                autoSave();
            }
        }
    }

    public void autoSave() {
        System.out.println("작업파일이 자동저장 되었습니다.");
    }
}

3초마다 변수 autoSave의 값을 확인해서 값이 true이면, autoSave()를 호출하는 일을
무한 반복하도록 쓰레드를 작성하였다. 이 쓰레드가 데몬 쓰레드로 설정되어있지 않다면, 프로그램을 강제종료
하지 않는 한 무한히 반복될 것이다.

10-7. 동기화

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

한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요하다.
그래서 도입된 개념이 '임계 영역(critical section)''잠금(락, lock)' 이다.

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

synchronized를 이용한 동기화

이 키워드는 임계 영역을 설정하는데 사용된다.

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

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

첫 번째 방법은 메소드 앞에 synchronized 키워드를 붙이는 것이다.
쓰레드는 synchronized 메소드가 호출된 시점부터 해당 메소드가 포함된 객체의 lock을 얻어 작업을 수행하다가
메소드가 종료되면 lock을 반환한다.

두 번째 방법은 메소드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 'synchronized(참조변수)' 를 붙이는 것이다.
이 때, 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 한다.

모든 객체는 lock을 하나씩 갖고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다.
그리고 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다.

임계 영역은 멀티쓰레드 프로그램의 성능을 좌우할 수 있어서 가능하면 전체 보다는 synchronized 블럭으로
임계 영역을 최소화해서 보다 효율적인 프로그램이 되도록 해야한다.

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

위에 출금과 관련된 메소드가 정의되어 있다.
잔고가 출금하려는 money 금액보다 많은 경우 실행하는 메소드 인데, 이 메소드를 동시에 여러 쓰레드에서 호출하면
잔고가 마이너스 (ex -100) 금액이 될 수 있다.

위의 문제를 해결하기 위해 synchronized 키워드를 붙여 동기화를 시켜보자.

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

or

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

메소드 또는 synchronized 블럭을 사용할 수 있다.

wait()와 notify()

특정 쓰레드가 객체의 락을 가진 상태로 오래 있는것은 좋지 않다.
이러한 상황을 개선하기 위해 고안된 것이 wait()notify() 이다.

wait()와 notify()는 특정 객체에 대한 것이므로 Object 클래스에 정의되어 있다.

void wait()
void wait(long timeout)
void wait(long timeout, int nanos)
void notify()
void notifyAll()

정리하자면

  • Object에 정의되어있다.
  • 동기화 블록 내에서만 사용할 수 있다.
  • 보다 효율적인 동기화를 가능하게 한다.
public synchronized void add (String dish) {

    while(dishes.size() >= MAX_FOOD) {
        String name = Thread.currentThread().getName();
        System.out.println(name + " is waiting.");

        try {
            wait();
            Thread.sleep(500L);
        } catch (InterruptedException e) {}
    }

    dishes.add(dish);
    notify();
    System.out.println("Dishes: " + dishes.toString());
}

wait()와 notify()를 이용한 예제이다.
wait()가 호출되면 실행중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.
notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중 임의의 쓰레드만 통지를 받는다.
notifyAll()은 기다리고 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다.

Lock과 Condition을 이용한 동기화

ReentrantLock 재진입이 가능한 lock. 가장 일반적인 배타 lock
ReentrantReadWriteLock 읽기에는 공유적이고, 쓰기에는 배타적인 lock
StampedLock ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가(JDK 1.8)

ReentrantLock의 생성자

ReentrantLock()
ReentrantLock(boolean fair)

생성자의 매개변수를 true로 주면, lock이 풀렸을 때 가장 오래 기다린 쓰레드가 lock을 획득할 수 있게,
공정(fair)하게 처리한다. 그러나, 공정하게 처리하려면 어떤 쓰레드가 가장 오래 기다렸는지 확인해야 해서
성능은 떨어진다.

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

ReentrantLock과 같은 lock클래스들은 수동으로 lock을 잠그고 해제해야 한다.

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

ReentrantLock과 Condition

Condition은 이미 생성된 lock으로부터 newCondition()을 호출해서 생성한다.

private ReentrantLock lock = new ReentrantLock();

private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();
public void add (String dish) {

    lock.lock();

    try {
        while (dishes.size() >= MAX_FOOD) {
            String name = Thread.currentThread().getName();
            System.out.println(name + " is waiting.");

            try {
                forCook.await();        // wait();      COOK 쓰레드를 기다리게 한다.
                Thread.sleep(500L);
            } catch (InterruptedException e) {}
        }   

        dishes.add(dish);
        forCust.signal();   // notify();        기다리고 있는 CUST를 깨우기 위함.
        System.out.println("Dishes: " + dishes.toString());
    } finally {
        lock.unlock();
    }
}

이전 wait()와 notify() 예제를 wait() 대신 forCook.await(), forCust.await()를 이용해서
대기와 통지의 대상을 명확하게 구분할 수 있다.

10-8. 데드락 (교착상태)

두 쓰레드가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행이 멈춰있는 상태

public class LeftRightDeadlock {

    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRight() {
        synchronized (left) {
            synchronized (right) {
                System.out.println("hello");
            }
        }
    }
    public void rightLeft() {
        synchronized (right) {
            synchronized (left) {
                System.out.println("hi");
            }
        }
    }

}
  • 쓰레드 A가 락 left을 확보한 상태에서 락 right을 확보하려 대기
  • 쓰레드 B가 락 right을 확보한 상태에서 락 left을 확보하려고 대기
  • 양쪽 쓰레드 A, B는 서로가 락을 풀기를 영원히 기다림
  • 쓰레드 하나가 특정 락(Lock)을 놓지 않고 계속 잡고 있으면 그 락을 확보하려는 다른 스레드는 락이 풀릴 때까지 기다리는 수 밖에 없다.
  • 쓰레드의 스케쥴링은 예측할 수 없는 경우가 많기 때문에, 데드락이 '언제' 발생할지는 알 수 없다. 그저 '발생할 수 있을' 뿐이다.

번외

동시성과 병렬성

동시성병렬성
동시에 실행되는 것 같이 보이는실제로 동시에 여러 작업이 처리되는 것
싱글 코어에서 멀티 쓰레드(Multi thread)를 동작 시키는 방식멀티 코어에서 멀티 쓰레드(Multi thread)를 동작시키는 방식
한번에 많은 것을 처리한번에 많은 일을 처리
논리적인 개념물리적인 개념


출처: https://seamless.tistory.com/42

위 그림을 보면 명확하게 동시성과 병렬성의 차이를 알 수 있다.

출처: https://seamless.tistory.com/42

다음은 싱글코어와 멀티코어에서의 차이점을 그린 설명인데 싱글코어에서는 2개의 작업이 동시에 실행되는것 처럼
작업을 번갈아 가면서 수행한다. 이때 다른 작업으로 바꾸어 실행하면서 내부적으로 Context switch가 발생한다.

실생활로 예를 들면 첫번째 동시성의 경우 한대의 커피머신에 두 줄로 서서 커피를 받아간다.
두 번째 병렬성의 경우 두 대의 커피머신이 있고 각각 한 줄씩 서서 커피를 받아간다.

References

profile
얍얍 개발 펀치

0개의 댓글