Language Study/Java

multi-thread 에서 두 가지 task를 번갈아가도록

지미닝 2024. 3. 18. 21:07

공유 객체

여러 개의 스레드가 동일한 객체를 참조 및 공유하고 있을때, 해당 객체를 공유객체

공유 변수 사용하기

💡 ReentrantLock이나 Synchronized키워드 없이 번갈아가려고 하면 ”공유변수” 를 사용하면 된다.

  • Busy Waiting이나 Spinlock과 같은 형태를 포함할 수 있기 때문에 CPU 자원을 비효율적으로 사용할 수도 있다.
  • 또한 코드의 복잡성을 증대시키고, 잘못할 경우 데드락(Deadlock)이나 라이브락(Livelock)과 같은 문제를 일으킬 수 있다.
public class WorkObject {
    private static int counter = 0;
    private volatile int turn = 1;

    public void methodA() {
        for (int i = 0; i < 10; i++) {
            while (turn != 1) {
                // Busy waiting
            }
            counter++;
            System.out.println("methodA");
            turn = 2;
        }
    }

    public void methodB() {
        for (int i = 0; i < 10; i++) {
            while (turn != 2) {
                // Busy waiting
            }
            counter++;
            System.out.println("methodB");
            turn = 1;
        }
    }
}

여기서 volatile키워드는 다양한 스레드에 의해 액세스될 때 메모리 일관성 오류를 방지하기 우해서 사용된다. (실제 프로덕션 환경에서는 권장하지 않는다.)

volatile변수를 사용할 경우에 변수를 메인 메모리에 저장하게 된다. 따라서 매번 변수의 값을 CPU cache가 아니라 Main Memory에서만 읽게 된다. 또한 매번 write도 메인 메모리에 하게 된다.

이 키워드를 사용하지 않는 MultiThread어플리케이션은 Task를 수행하는 동안 성능 향상을 위해서 메인메모리에 읽은 변수의 값을 CPU Cache에 저장한다. 그럿다보니, 멀티 스레드 환경에서는 변수 값 불일치 문제가 발생할 수 있다.

ReentrantLock

💡 ava.util.concurrent.locks 패키지에 속하는 Lcok인터페이스 구현체를 이용해서 동기화시킨다.

synchronized키워드에 비해서 더 세밀한 제어를 가능하게 한다. 또한, 재진입이 가능하므로 동일한 스레드가 이미 획득한 락을 다시 획득할 수 있게 해준다.

특정 조건 하에서 스레드가 블록되지 않고 다른 스레드에 의해 실행 순서가 변경될 수 있도록 하는 등 유연한 동기화가 가능하다.

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class WorkObject {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private boolean turnA = true; // 처음에는 A의 차례

    public void methodA() {
        lock.lock(); // 락 획득
        try {
            for (int i = 0; i < 10; i++) {
                while (!turnA) { // A의 차례가 아니면 대기
                    condition.await();
                }
                System.out.println("methodA");
                turnA = false; // B의 차례로 변경
                condition.signal(); // 대기 중인 스레드 중 하나를 깨움
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); // 락 해제
        }
    }

    public void methodB() {
        lock.lock(); // 락 획득
        try {
            for (int i = 0; i < 10; i++) {
                while (turnA) { // B의 차례가 아니면 대기
                    condition.await();
                }
                System.out.println("methodB");
                turnA = true; // A의 차례로 변경
                condition.signal(); // 대기 중인 스레드 중 하나를 깨움
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); // 락 해제
        }
    }
}

주의할 점은, RettrantLock 을 사용할 때는 항상 finally 블록에서 락을 해제해야한다. 왜냐하면 예외를 발생하더라도 락이 정상적으로 해제되도록 보장하기 위함이다.

Semaphore

💡 java.util.concurrent 패키지에 있는 동시성 유틸리티로, 한 번에 한개 이상의 스레드가 자원에 접근할 수 있도록 제어하는 메커티즘을 제공한다.

리소스의 제한된 수의 허가를 관리하는데 사용되는데, 리소스에 접근하기 전에 허가를 획득해야하며, 작업을 마친 후에는 허가를 반납하도록 한다.

  • acquire(): 허가를 획득한다. 만약 사용 가능한 허가가 없다면, 현재 스레드는 허가가 사용 가능해질 때까지 대기한다.
  • release(): 허가를 반납한다. 이 연산은 다른 스레드가 허가를 획득할 수 있도록 만든다.
import java.util.concurrent.Semaphore;

public class WorkObject {
    private final Semaphore semA = new Semaphore(1); // methodA 시작 허가
    private final Semaphore semB = new Semaphore(0); // methodB 시작 대기

    public void methodA() {
        try {
            for (int i = 0; i < 10; i++) {
                semA.acquire(); // methodA 실행 허가를 얻음
                System.out.println("methodA");
                semB.release(); // methodB 실행을 허가함
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public void methodB() {
        try {
            for (int i = 0; i < 10; i++) {
                semB.acquire(); // methodB 실행 허가를 얻음
                System.out.println("methodB");
                semA.release(); // methodA 실행을 다시 허가함
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Semaphore 인스턴스를 사용한다.

하나는 methodA의 실행을 제어하고, 다른 하나는 methodB의 실행을 제어한다. methodA는 시작할 수 있지만, methodBmethodA가 먼저 실행되어 semB에 허가를 제공하기 전까지 실행될 수 없다. 이러한 방식으로, 두 메소드가 번갈아 가며 실행되도록 할 수 있다.

Synchronized

💡 sysnchronized키워드를 사용해서 할 수 있다.

특정 객체에 대한 동기화된 블록 또는 메소드 내에서 작업을 수행하며, 해당 블록이나 메소드는 한 번에 하나의 스레드만 실행할 수 있다.

public class WorkObject {
    private static int counter = 0;

    public synchronized void methodA() {
        for (int i = 0; i < 10; i++) {
            counter++;
            System.out.println("methodA");
            notify();
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }
                notifyAll(); // 모든 대기 중인 스레드 깨우기
    }

    public synchronized void methodB() {
        for (int i = 0; i < 10; i++) {
            counter++;
            System.out.println("methodB");
            notify();
            try {
                wait();
            } catch (InterruptedException e) {
            }
        }
                notifyAll(); // 모든 대기 중인 스레드 깨우기
    }
}

methodAmethodB에서 사용된 synchronized 키워드는 메소드 전체를 동기화 블록으로 만들어, 해당 메소드가 속한 객체의 모니터 락을 획득합니다. 메소드가 실행될 때, 스레드는 이 락을 획득하고, 메소드 실행이 완료되면 락을 해제한다. 만약 다른 스레드가 동시에 접근하려고 시도하면, 락이 해제될 때까지 대기해야 한다.

여기서 notify() 메소드 호출은 해당 객체의 대기 중인 다른 스레드 중 하나를 깨워 실행을 계속하도록 한다. 그리고 wait() 메소드 호출은 현재 스레드를 대기 상태로 만들어, 다른 스레드가 실행을 계속할 수 있도록 한다. methodAmethodB에서 이러한 메커니즘을 사용하여, 두 메소드가 번갈아 가며 실행될 수 있도록 구현되어 있다.