<Java> 멀티쓰레드 프로그래밍

라모스·2021년 9월 2일
1

Java☕

목록 보기
10/14
post-thumbnail

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

학습할 것

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

Intro

📌 프로세스(Process)?

  • 실행중인 프로그램을 의미
  • 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)를 할당받아 프로세스가 됨.

📌 쓰레드(Thread)?

  • 프로세스라는 작업공간에서 실제로 작업을 처리하는 일꾼
  • 프로세스의 자원을 이용하여 작업을 수행함
  • 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재함
  • 싱글 쓰레드: 쓰레드가 하나
  • 멀티 쓰레드: 쓰레드가 둘 이상

📌 멀티 태스킹(multi-tasking)?

  • 대부분의 OS가 지원
  • 여러 개의 프로세스가 동시에 실행될 수 있는 것을 의미

📌 멀티 쓰레딩(multi-threading)?

하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것.
CPU의 코어(core)가 한 번에 단 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수와 일치함. 대부분 쓰레드의 수는 코어의 개수보다 훨씬 많기 때문에 각 코어가 아주 짧은 시간동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 한다. 따라서 프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니며, 하나의 쓰레드를 가진 프로세스보다 두 개의 쓰레드를 가진 프로세스가 오히려 더 낮은 성능을 보일 수 있다.

📌 멀티 쓰레딩의 장점

  • CPU의 사용률 향상
  • 보다 효율적인 자원 사용
  • 사용자에 대한 응답성 향상
  • 작업이 분리되어 코드가 간결해짐

📌 멀티 쓰레딩에서 주의할 점

  • 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하며 작업을 하기 때문에 발생할 수 있는 동기화(synchronization), 교착상태(deadlock)과 같은 문제들을 고려해서 신중히 프로그래밍 해야 함.

1. Thread 클래스

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

  • Runnable 인터페이스를 사용
  • Thread 클래스를 사용

Thread 클래스는 Runnable 인터페이스를 구현한 클래스이다. Runnable과 Thread는 모두 java.lang 패키지에 포함되어 있다. Thread 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를 구현하고, 그렇지 않은 경우엔 Thread 클래스를 사용하는 것이 더 편하다.

함수형 인터페이스로 추상 메소드 run() 하나만 가지고 있다.

필드

실제 Thread 클래스 안에는 많은 필드가 존재하나, public 접근 제어자인 필드는 단 3개만 존재한다.

쓰레드의 우선 순위에 대한 상수 필드이다.

   /**
     * The minimum priority that a thread can have.
     */
    public static final int MIN_PRIORITY = 1;

   /**
     * The default priority that is assigned to a thread.
     */
    public static final int NORM_PRIORITY = 5;

    /**
     * The maximum priority that a thread can have.
     */
    public static final int MAX_PRIORITY = 10;

생성자

  • String gname : 쓰레드를 (이름을 지정하지 않고) 생성할 때 자동으로 생성되는 이름이다. 자동으로 생성되는 이름은 "Thread-" + n 의 형식을 가진다.
  • String name : 쓰레드 생성자에 인자로 주는 새로운 쓰레드의 이름을 의미한다.
  • Runnable target : target은 쓰레드가 시작될 때 run() 메소드가 호출될 객체이다.
  • ThreadGroup group : group은 생성할 쓰레드를 설정할 쓰레드 그룹이다. group값이 null 이면서 보안 관리자(security manager)가 존재하면, 그룹은 SercurityManager.getThreadGroup()에 의해 결정된다. 보안 관리자가 없거나, SecurityManager.getThreadGroup()이 null을 반환한다면 현재 쓰레드의 그룹으로 설정됨.
  • long stackSize : 새로운 쓰레드의 스택 사이즈를 의미한다. 0이면 이 인자는 없는 것과 같다.

    /**
     * Initializes a Thread.
     *
     * @param g the Thread group
     * @param target the object whose run() method gets called
     * @param name the name of the new Thread
     * @param stackSize the desired stack size for the new thread, or
     *        zero to indicate that this parameter is to be ignored.
     * @param acc the AccessControlContext to inherit, or
     *            AccessController.getContext() if null
     * @param inheritThreadLocals if {@code true}, inherit initial values for
     *            inheritable thread-locals from the constructing thread
     */
    private Thread(ThreadGroup g, Runnable target, String name,
                   long stackSize, AccessControlContext acc,
                   boolean inheritThreadLocals) {
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;

        Thread parent = currentThread();
        SecurityManager security = System.getSecurityManager();
        if (g == null) {
            /* Determine if it's an applet or not */

            /* If there is a security manager, ask the security manager
               what to do. */
            if (security != null) {
                g = security.getThreadGroup();
            }

            /* If the security manager doesn't have a strong opinion
               on the matter, use the parent thread group. */
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }

        /* checkAccess regardless of whether or not threadgroup is
           explicitly passed in. */
        g.checkAccess();

        /*
         * Do we have the required permissions?
         */
        if (security != null) {
            if (isCCLOverridden(getClass())) {
                security.checkPermission(
                        SecurityConstants.SUBCLASS_IMPLEMENTATION_PERMISSION);
            }
        }

        g.addUnstarted();

        this.group = g;
        this.daemon = parent.isDaemon();
        this.priority = parent.getPriority();
        if (security == null || isCCLOverridden(parent.getClass()))
            this.contextClassLoader = parent.getContextClassLoader();
        else
            this.contextClassLoader = parent.contextClassLoader;
        this.inheritedAccessControlContext =
                acc != null ? acc : AccessController.getContext();
        this.target = target;
        setPriority(priority);
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        this.tid = nextThreadID();
    }

메소드

구현과 실행에 관련된 run() 메소드와 start() 메소드

  • public void run(): 쓰레드가 실행되면 run() 메소드를 호출하여 작업을 한다.
  • public synchronized void start(): 쓰레드를 실행시키는 메소드이다. start가 호출되었다고 해서 바로 실행되는 것이 아니라, 일단 실행 대기 상태에 있다가 자신의 차례가 되어야 실행된다.

run()start()의 차이?

쓰레드를 시작할 때 run() 메소드를 호출하면 되는 것 같지만 실제로는 start() 메소드를 호출해서 쓰레드를 실행한다. main 메소드에서 run() 메소드를 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 메소드를 호출하는 것이다. 반면 start() 메소드를 호출하면 새로운 쓰레드가 작업을 실행하는데 필요한 새로운 호출 스택(call stack)을 생성한 다음 run()을 호출한다. 즉, 새로 생성된 호출 스택에 run()이 첫 번째로 올라가게 한다. run() 메소드의 수행이 종료된 쓰레드는 호출 스택이 모두 비워지면서 생성된 호출 스택도 소멸된다.

main 메소드에서 run()을 호출 했을 때의 호출 스택은 위와 같다.


main 메소드에서 start()를 호출하여 쓰레드를 위한 독립된 호출 스택이 생성되어 run()이 실행된 상태는 위와 같다.

한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다. 즉, 하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있다는 의미이다. 하나의 쓰레드 객체에 대해 start() 메소드를 두 번 이상 호출하면 실행 시에 IllegalThreadStateException이 발생한다. 다음은 JUnit5를 통해 만든 테스트 코드이다.

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class ThreadTest {

    @Test
    @DisplayName("start()메소드 중복시 에러 발생")
    void test() {
        Assertions.assertThrows(IllegalThreadStateException.class, () -> {
            MyThread_1 th1 = new MyThread_1();
            th1.start();
            th1.start();
        });
    }

    private class MyThread_1 extends Thread {

    }
}


다음과 같이 start()를 두 번 호출하는 경우엔 정상적으로 실행된다. 첫 번째 쓰레드를 실행한 뒤 또 다른 새로운 쓰레드를 생성해서 실행하기 때문이다.

MyThread th1 = new MyThread();
th1.start();
th1 = new MyThread();
th1.start();

쓰레드의 스케줄링과 관련된 메소드

메소드설명
static void sleep(long millis), static void sleep(long millis, int nanos)지정된 시간(1/1000초 단위)동안 쓰레드를 일시정지 시킨다. 지정한 시간이 지나고 나면, 자동적으로 다시 실행 대기 상태가 된다.
void join(), void join(long millis), void join(long millis, int nanos)지정된 시간동안 쓰레드가 실행되도록 한다. 지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.
void interrupt()쓰레드에게 작업을 멈추라고 요청한다. 쓰레드의 interrupted상태를 false에서 true로 변경.
static boolean interrupted()sleep()이나 join()에 의해 일시정지된 상태인 쓰레드를 깨워서 실행대기상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지상태를 벗어나게 된다.
@Deprecated void stop()쓰레드를 즉시 종료시킨다.
@Deprecated void suspend()쓰레드를 일시정지 시킨다. resume()을 호출하면 다시 실행 대기상태가 된다.
@Deprecated void resume()suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기상태로 만든다.
static void yield()실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행 대기상태가 된다.

2. 쓰레드의 상태

상태의미
NEW쓰레드 객체는 생성되었지만, 아직 시작되지 않은 상태
RUNNABLE쓰레드가 실행중인 상태
BLOCKED쓰레드가 실행 중지 상태이며, 모니터 락(monitor lock)이 풀리기를 기다리는 상태
WAITING쓰레드가 대기중인 상태
TIMED_WAITING특정 시간만큼 쓰레드가 대기중인 상태
TERMINATED쓰레드가 종료된 상태

sleep()

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

  • 밀리세컨드와 나노세컨드의 시간단위로 세밀하게 값을 지정할 수는 있으나 어느정도 오차가 발생할 수 있다는 것은 염두에 둬야함.
  • sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되면 잠에서 깨어나 실행 대기상태가 된다.
  • sleep()을 호출할 때는 항상 try-catch문으로 InterruptedException을 예외처리 해야 함.
  • sleep()은 항상 현재 실행중인 쓰레드에 대해 작동함. 따라서 static으로 선언되어 있으며 참조변수를 이용해서 호출하기 보단 Thread.sleep(2000)과 같이 호출해야 함.

interrupt()

interrupt()isInterrupted()를 활용한 예시는 다음과 같다.

package thread;

import javax.swing.*;

public class Example {
    public static void main(String[] args) throws Exception {
        Example_1 th1 = new Example_1();
        th1.start();
        String input = JOptionPane.showInputDialog("아무 값이나 입력하세요");
        System.out.println("입력 값은 " + input + "입니다.");
        th1.interrupt();
        System.out.println("isInterrupted():" + th1.isInterrupted());
    }
}

class Example_1 extends Thread {
    public void run() {
        int i = 10;

        while (i != 0 && !isInterrupted()) {
            System.out.println(i--);
            for (long x = 0; x < 2500000000L; x++);
        }
        System.out.println("카운트가 종료되었습니다");
    }
}


suspend(), resume(), stop()

쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만, suspend()와 stop()이 deadlock을 일으키기 쉽게 작성되어 있으므로 이 메소드들은 모두 @Deprecated(사용이 권장되지 않음) 되어있다.

yield()

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

예를 들어, 스케줄러에 의해 1초의 실행시간을 할당받은 쓰레드가 0.7초의 시간동안 작업한 상태에서 yield()가 호출되면, 나머지 0.7초는 포기하고 다시 실행 대기 상태가 된다.

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

public class ThreadExample {
    public static void main(String[] args) {
        MyThread_1 th1 = new MyThread_1("쓰레드1");
        MyThread_1 th2 = new MyThread_1("쓰레드2");
        MyThread_1 th3 = new MyThread_1("쓰레드3");
        th1.start();
        th2.start();
        th3.start();

        try {
            Thread.sleep(2000);
            th1.suspend();
            Thread.sleep(2000);
            th2.suspend();
            Thread.sleep(2000);
            th1.resume();
            Thread.sleep(2000);
            th1.stop();
            th2.stop();
            Thread.sleep(2000);
            th3.stop();
        }catch (InterruptedException e) { }

    }
}

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

    Thread th;

    MyThread_1(String name) {
        th = new Thread(this, name); // Thread(Runnable target, String name)
    }

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

        while (!stopped) {
            if (!suspended) {
                System.out.println(name);
                try {
                    Thread.sleep(1000);
                }catch (InterruptedException e) {
                    System.out.println(name + " - interrupted");
                }
            } else {
                Thread.yield(); // suspended 가 true 일때 쓰레드의 남은시간을 양보해줌
            }
            // 만약 Thread.yield()가 없다면 쓰레드는 남은 시간을 아무런 일도 하지않는
            // while 문을 돌며 낭비하게됨
        }
        System.out.println(name + " - stopped");
    }

    public void suspend() {
        suspended = true;
        th.interrupt();
        System.out.println(th.getName() + " - interrupt() by suspend()");
    }

    public void stop() {
        stopped = true;
        th.interrupt();
        System.out.println(th.getName() + " - interrupt() by stop()");
    }

    public void resume() { suspended = false; }
    public void start() { th.start(); }
}

join()

쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용한다. 시간을 지정하지 않으면, 해당 쓰레드가 작업을 모두 마칠 때 까지 기다린다. 작업중에 다른 쓰레드의 작업이 먼저 수행되어야 할 필요가 있을 때 join()을 사용한다. join()sleep() 처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()이 호출되는 부분을 try-catch문으로 감싸 InterruptedException을 처리해야 한다.

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

다음은 garbage collector를 간단히 흉내낸 예제 코드이다.

package thread;

public class Example {
    public static void main(String[] args) throws Exception {
        Example_1 gc = new Example_1();
        gc.setDaemon(true);
        gc.start();

        int requiredMemory = 0;
        for (int i = 0; i < 20; i++) {
            requiredMemory = (int) (Math.random() * 10) * 20;
            // 필요한 메모리가 사용할 수 있는 양보다 크거나 전체 메모리의 60% 이상을
            // 사용했을 경우 gc를 깨운다.
            if (gc.freeMemory() < requiredMemory
                    || gc.freeMemory() < gc.totalMemory() * 0.4) {
                gc.interrupt(); // 잠자고 있는 쓰레드 gc를 깨운다.
            }
            gc.usedMemory += requiredMemory;
            System.out.println("usedMemory: " + gc.usedMemory);
        }
    }
}

class Example_1 extends Thread {
    final static int MAX_MEMORY = 1000;
    int usedMemory = 0;

    public void run() {
        while (true) {
            try {
                Thread.sleep(10 * 1000);    // 10초를 기다린다.
            } catch (InterruptedException e) {
                System.out.println("Awaken by interrpt().");
            }
            gc();   // garbage collection을 수행한다.
            System.out.println("Garbage Collected. Free Memory : " + freeMemory());
        }
    }

    public void gc() {
        usedMemory -= 300;
        if (usedMemory < 0) usedMemory = 0;
    }

    public int totalMemory() { return MAX_MEMORY; }
    public int freeMemory() { return MAX_MEMORY - usedMemory; }

}

3. 쓰레드의 우선순위

쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.
→ 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

📌 쓰레드의 우선순위를 지정하는 필드와 메소드?

코드설명
public static final int MIN_PRIORITY = 1;쓰레드가 가질 수 있는 우선순위의 최소값
public static final int NORM_PRIORITY = 5;쓰레드가 가지는 기본 우선순위 값
public static final int MAX_PRIORITY = 10;쓰레드가 가질 수 있는 우선순위의 최대값
setPriority(int newPriority)쓰레드의 우선순위를 지정한 값으로 변경한다
getPriority()쓰레드의 우선순위를 반환한다

쓰레드가 가질 수 있는 우선순위의 범위는 1~10 이며 숫자가 높을수록 우선순위가 높다.
생성한 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다.

4. Main 쓰레드

'Java의 실행환경인 JVM은 하나의 프로세스로 실행된다.'

이 말은 곧 자바 애플리케이션이 기본적으로 하나의 메인 쓰레드를 가진다는 의미이다. Java 프로그램을 실행하기 위해 Main Thread는 main() 메소드를 실행한다. main() 메소드는 메인 쓰레드의 시작점을 선언하는 것이다.

메인 쓰레드는 자바에서 처음으로 실행되는 쓰레드이자 모든 쓰레드는 메인 쓰레드로부터 생성된다.

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

public class Example {
    public static void main(String[] args) throws Exception {
        Thread main = Thread.currentThread();
        System.out.println(main);
        System.out.println(main.getName());
    }
}
// main 메소드를 실행하고 현재의 쓰레드를 출력하는 예제

데몬 쓰레드?

다른 일반 쓰레드(사용자 쓰레드)의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 사용자 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다. 이런 점을 제외하면 사용자 쓰레드와 차이는 없다.
ex) Garbage Collector, 워드프로세서의 자동저장, 화면자동갱신 등

5. 동기화

여러 개의 쓰레드가 한 개의 리소스를 사용하려고 할 때 사용하려는 쓰레드를 제외한 나머지들을 접근하지 못하게 막는 것이다. 이것을 쓰레드에 안전하다고 한다.

Synchronized 키워드

Java의 예약어 중 하나이며 변수명이나, 클래스명으로 사용이 불가능하다. 메소드 자체를 synchronized로 선언하는 방법과 메소드 내의 특정 문장만 synchronized로 감싸는 방법으로 사용한다.

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

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public synchronized void withdraw(int money) { // synchronized를 풀면 다른 쓰레드로 인해
        if (balance >= money) {                    // balance가 음수 값이 나오게 됨.
            try {
                Thread.sleep(1000);
            }catch (InterruptedException e) {

            }
            balance -= money;
        }
    }
}

class MyThread_1 implements Runnable {
    Account acc = new Account();

    @Override
    public void run() {
        while (acc.getBalance() > 0) {
            // 100, 200, 300중의 한 값을 임의로 선택해서 출금(withdraw)
            int money = (int) (Math.random() * 3 + 1) * 100;
            acc.withdraw(money);
            System.out.println("balance:" + acc.getBalance());
        }
    }
}

wait()notify()

synchronized로 동기화하면 공유 데이터를 보호할 수 있지만 특정 쓰레드가 객체의 락을 가진 상태로 오랜시간을 보내 다른 작업들이 원활히 진행되지 않는 상황을 피하는 것도 중요하다. 이런 상황을 wait()와 notify()를 통해 해결할 수 있다.

동기화된 임계구역의 코드를 수행하다 작업을 더 이상 진행할 상황이 아니라면, wait()를 호출하여 쓰레드가 락을 반납하고 기다리게한다. 이 때, 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 되고 이후 작업을 다시 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있다.

다음 메소드 들은 Object에 정의되어 있는 메소드들로, 보다 효율적인 동기화를 가능하게 하는 메소드 들이며 동기화 블록(synchronized) 내에서만 사용할 수 있다.

메소드설명
wait()쓰레드가 락을 반납하고 기다리게 한다.
notify()객체의 대기실에서 대기중인 모든 쓰레드 중 임의의 쓰레드에게 lock을 얻을 수 있는 상태로 바꿔준다.
notifyAll()기다리고 있는 모든 객체에게 통지하여 lock을 얻을 수 있는 상태로 바꿔준다. notifyAll()이 호출된 객체의 waiting pool에 대기중인 쓰레드만 해당된다.

Lock과 Condition을 이용한 동기화

오래 기다린 쓰레드가 notify()로 인해 락을 얻는다는 보장은 없다. wait()가 호출되면, 실행 중이던 쓰레드는 해당 객체의 waiting pool에서 통지를 기다린다. notify()가 호출되면, 해당 객체의 대기실에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다. notifyAll()을 해서 모든 쓰레드에게 통보를 해도 lock을 얻는 것은 하나의 쓰레드 뿐이기 때문에 다른 쓰레드들은 계속해서 lock을 기다려야 한다.

이처럼 운이 나쁘면 계속 lock을 얻지 못하고 오랫동안 기다리게 되는 현상을 '기아(starvation) 현상'이라 한다.

기아 현상을 막으려면 notifyAll()을 호출해서 모든 쓰레드에게 통지하면 손님 쓰레드는 다시 waiting pool에 들어가더라도 특정 쓰레드는 결국 lock을 얻어서 작업을 진행할 수 있다. 하지만 모든 쓰레드가 통지를 받기 때문에 불필요한 쓰레드까지 lock을 얻으려고 하기 때문에 여러 쓰레드가 lock을 얻기위해 경쟁하게 된다. 이를 경쟁 상태(race condition)라 한다.

Lock과 Condition을 이용하면 선별적인 통지가 가능하다.

ReentrantLock재진입이 가능한 lock. 가장 일반적인 배타 lock
ReentrantReadWriteLock읽기에는 공유적이고, 쓰기에는 배타적인 lock // 읽기 lock, 쓰기 lock을 제공함. 읽기 lock이 걸린 상태에서 동시에 여러 쓰레드가 읽기 lock을 얻는 것은 가능. 읽기 lock이 걸린 상태에서 다른 쓰레드가 쓰기 lock을 거는 것은 불가능(반대의 경우도 마찬가지)
StampedLockReentrantReadWriteLock에 낙관적인 lock의 기능을 추가 // lock을 걸거나 해제할 때 스탬프(long 타입의 정수 값)을 사용하며 읽기와 쓰기를 위한 lock 외에 낙관적 읽기가 추가된 것이다. 읽기 lock이 걸려있으면 쓰기 lock을 얻기 위해선 읽기 lock이 풀릴 때 까지 기다려야 하지만, 낙관적 읽기 lock은 쓰기 lock에 의해 바로 풀린다.

ex) StampedLock을 이용한 낙관적 읽기의 예시

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

synchronized 블럭과 달리 Lock 클래스들은 수동으로 lock을 잠그고 해제해야 한다. lock을 잠그고 푸는 것은 메소드만 호출하면 되지만 lock을 걸고나서 푸는 것을 잊어버리는 실수를 하지 않도록 주의해야 한다.

임계 구역 내에서 예외가 발생하거나 return 문으로 빠져 나가게 되면 lock이 풀리지 않을 수 있으므로 unlock()은 try-finally 문으로 감싸는 것이 일반적이다.

ReentrantLock lock = new ReentrantLock();
...
lock.lock();
try{
    // 임계 구역 작업 코드
} finally {
    lock.unlock();
}

ReentrantLock과 Condition

Condition은 wait()notify()의 단점인 쓰레드를 구분해서 통지하지 못하기 때문에 발생하는 '경쟁 상태'의 단점을 해결해준다. Condition을 이용하면 각각의 waiting pool에서 쓰레드의 종류에 따라 구분하여 넣을 수 있다. 좀 더 세분화하여 waiting pool을 나눌 수록 경쟁상태가 발생할 가능성을 낮게 할 수 있다.

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

// lock으로 condition을 생성
private Condition forCook = lock.newCondition();
private Condition forCustomer = lock.newCondition();

Object의 wait(), notify()에 대응하는 Condition의 메소드는 다음과 같다.

ObjectCondition
void wait()void await(), void awaitUninterruptibly()
void wait(long timeout)boolean await(long time, TimeUnit unit) , long awaitNanos(long nanosTimeout), boolean awaitUntil(Date deadline)
void notify()void signal()
void notifyAll()void signalAll()
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);
        forCust.signal(); // notify();  기다리고 있는 CUST를 깨우기 위함.
        System.out.println("Dished:" + dishes.toString());
    }finally {
            lock.unlock();
    }
}

6. 데드락(deadlock)

2개 이상의 프로세스가 다른 프로세스의 작업이 끝나기만을 기다리며 작업을 더 이상 진행하지 못하는 상태를 교착상태(deadlock) 라 한다. 교착 상태의 가장 좋은 예는 식사하는 철학자 문제가 있다.

이 문제는 철학자들이 둥근 테이블에 앉아 식사를 하는데 각자의 자리의 왼쪽에 있는 젓가락을 잡은 뒤 오른쪽의 젓가락도 잡아야만 식사가 가능하다는 조건이 있다. 모든 철학자들은 왼쪽의 젓가락을 잡고 오른쪽을 쳐다보면 왼손에 젓가락을 들고 오른쪽을 쳐다보고 있는 다른 철학자가 보일 것이다. 결국 오른쪽의 젓가락을 잡지 못해 모두 굶어 죽게 될 것이다. 철학자는 프로세스(쓰레드)를 나타내고 각 젓가락은 공유 자원을 의미한다.

교착 상태가 발생하는 원인?

교착상태의 필요조건 4가지는 다음과 같다.

  • 상호 배제: 철학자들은 서로 포크를 공유할 수 없다. 자원을 공유하지 못하면 교착 상태가 발생한다. 여기서 자원은 배타적인 자원이어야 한다. 배타적인 자원은 임계구역에서 보호되기 때문에 다른 프로세스(쓰레드)가 동시에 사용할 수 없다.
  • 비선점: 각 철학자는 다른 철학자의 포크를 빼앗을 수 없다. 자원을 빼앗을 수 없으면 자원을 놓을 때까지 기다려야 하므로 교착상태가 발생한다.
  • 점유와 대기: 각 철학자는 왼쪽 포크를 잡은 채 오른쪽 포크를 기다린다. 자원 하나를 잡은 상태에서 다른 자원을 기다리면 교착상태가 발생한다.
  • 원형 대기: 자원 할당 그래프가 원형이다. 자원을 요구하는 방향이 원을 이루면 양보를 하지 않기 때문에 교착상태가 발생한다.

교착 상태 해결방법

해결 방법특징
교착 상태 예방교착 상태를 유발하는 네 가지 조건을 무력화 한다.
교착 상태 회피교착 상태가 발생하지 않는 수준으로 자원을 할당한다.
교착 상태 검출자원할당 그래프를 사용하여 교착 상태를 발견한다.
교착 상태 회복교착 상태를 검출한 후 해결한다.

교착 상태는 상호 배제, 비선점, 점유와 대기, 원형 대기라는 네 가지 조건을 동시에 충족해야 발생하기 때문에 이 중 하나라도 막는다면 교착상태가 발생하지 않는다. 그러나 이 방법은 실효성이 적어 잘 사용되지 않는다.

자원 할당량을 조절하여 교착상태를 해결하는 방식은 자원을 얼마나 할당해야 교착 상태가 발생하지 않는지 보장이 없기 때문에 실효성이 적다.

교착 상태 검출은 어떤 제약을 가하지 않고 자원 할당 그래프를 모니터링 하면서 교착상태가 발생하는지 살펴보는 방식으로 만약 교착 상태가 발생하면 교착 상태 회복 단계가 진행된다. 이 방식이 현실적인 접근 방법이다.

fork & join 프레임워크

JDK 1.7부터 추가된 프레임워크로, 하나의 작업을 작은 단위로 쪼개서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다. 수행할 작업에 따라 다음 두 클래스 중 하나를 상속받아 구현하면 된다.

  • RecursiveAction: 반환값이 없는 작업을 구현할 때 사용
  • RecursiveTask: 반환값이 있는 작업을 구현할 때 사용

다음은 RecursiveTask를 상속받아 1부터 n까지의 합을 계산한 결과를 반환하는 예제 코드이다.

class SumTask extends RecursiveTask<Long> {
    long from, to;

    SumTask(long from, long to) {
        this.from = from;
        this.to = to;
    }

    public Long compute() {
        long size = to - from + 1;
        if (size <= 5)    // 더할 숫자가 5개 이하면
            return sum(); // 숫자의 합을 반환
        
        long half = (from + to) / 2; // 범위를 반으로 나눠서 두 개의 작업을 생성
        
        // 범위를 반으로 나눠서 두개의 작업을 생성
        SumTask leftSum = new SumTask(from, half);
        SumTask rightSum = new SumTask(half+1, to);
        
        leftSum.fork(); // 작업(leftSum)을 작업 큐에 넣는다.
        
        return rightSum.compute() + leftSum.join();
    }
    
    long sum() {
        long tmp = 0L;
        
        for (long i = from; i <= to; i++) {
            tmp += i;
        }
        
        return tmp;
    }
}

compute()의 구조를 보면 일반적인 재귀호출 메소드와 같다. compute()는 작업을 반으로 나누고 fork()는 작업 큐에 작업을 담는다. 또한 fork()의 호출로 작업 큐에 담긴 작업 역시 compute()로 인해 목표한 작은 size까지 반으로 작업을 나눈다. 이러한 작업을 반복하다 보면 여러개의 작은 단위로 작업을 나눌 수 있다.

아래 그림은 compute()와 fork()로 인해 작업풀에 담긴 작업이 thread pool의 빈 쓰레드가 작업을 가져와서 작업을 수행하는 것을 나타낸 것이다. 이렇게 빈 쓰레드가 작은 단위의 작업을 가져와 작업을 수행하는 것을 작업 훔쳐오기(work stealing)라 하며, 이 과정은 모두 쓰레드 풀에 의해 자동으로 이루어진다.

이런 과정을 통해 한 쓰레드에 작업이 몰리지 않고 여러 쓰레드가 골고루 작업을 나누어 처리하게 된다.

ForkJoinPoll

fork & join 프레임워크에서 제공하는 쓰레드 풀(thread pool)이다.

다음 장점이 있다.

  • 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복하여 재사용할 수 있게 한다.
  • 쓰레드를 반복해서 생성하지 않아도 된다.
  • 너무 많은 쓰레드가 생성되어도 성능 저하가 발생하는 것을 막아준다.
  • 쓰레드 풀은 기본적으로 코어의 개수와 동일한 개수의 쓰레드를 생성한다.

fork()join()

fork()는 작업을 쓰레드의 작업 큐에 넣는 것이고, join()은 작업의 결과를 반환한다.

  • fork(): 해당 작업을 쓰레드 풀의 작업 큐에 넣는다. 비동기 메소드(asynchronous method)
  • join(): 해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환. 동기 메소드(synchronous method)

비동기 메소드는 일반적인 메소드와 달리 메소드를 호출만 하고 결과를 기다리지 않는다. 내부적으로 다른 쓰레드에게 작업을 수행하도록 지시만 하고 결과를 기다리지 않고 돌아오는 것이다.

References

profile
Step by step goes a long way.

2개의 댓글

comment-user-thumbnail
2021년 9월 8일

꽉 차고 알찬 글 감사합니다! 잘 읽고 갑니다 🙏

1개의 답글