멀티 쓰레드 프로그래밍

파이 ఇ·2023년 7월 23일
1
post-thumbnail

💡 목표 : 자바의 상속에 대해 학습해보자.

⚡️ 목차

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

이 글을 시작하기 전 process와 thread에 대해 알아보자

✔️ Process

  • 단순히 실행중인 프로그램이라고 볼 수 있다.
  • 사용자가 작성한 프로그램이 운영체제에 의해 메모리 공간을 할당 받아 실행 중인 것을 말한다. 이러한 프로세스는 프로그램에 사용되는 데이터와 메모리 등의 자원 그리고 쓰레드로 구성이 된다.

✔️ Thread

  • 프로세스 내에서 실제로 작업을 수행하는 주체를 의미한다.
  • 모든 프로세스는 1개 이상의 쓰레드가 존재하여 작업을 수행한다.
  • 두개 이상의 쓰레드를 가지는 프로세스를 멀티 쓰레드 프로세스라고 한다.
  • 경량 프로세스라고도 불리며 가장 작은 실행 단위이다.

Thread 클래스와 Runnable 인터페이스

쓰레드를 생성하는 방법은 크게 2가지가 있다.
1. Runnable 인터페이스를 사용한다.
2. Thread 클래스를 사용한다.

  • Thread 클래스는 Runnable 인터페이스를 구현한 구현체로 어떤 것을 적용 하느냐의 차이이다.
  • Runnable과 Thread 모두 java.lang 패키지에 포함되어 있다.

✔️ 그럼 어떤걸 사용해야 할까?
Thread 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를, 그렇지 않다면 Thread 클래스를 사용하는 것이 편하다.

Thread는 순서대로 동작할까 ?

예제로 확인해보자
✔️ ThreadSample

public class ThreadSample extends Thread {
	@Override
    public void run() {
    	System.out.println("This is ThreadSample's run() method");
	}
}

✔️ RunnableSample

public class RunnableSample extends Runnable {
	@Override
    public void run() {
    	System.out.println("This is RunnableSample's run() method");
	}
}

✔️ RunMultiThreads

public class RunMultiThreads {
	public staic void main(String[]args) {
    	runMultiThread();
	}
    
    public static void runMultiThread() {
    	RunnableSample runnable[] = new Runnable[5];
        ThreadSample thread[] = new Thread[5];
        
        for (int i = 0; i < 5; i++) {
        	runnable[i] = new RunnableSample();
            thread[i] = new ThreadSample();
           
            new Thread(runnable[i]).start();
            thread[i].start();
		}
	System.out.println("RunMultiThreads.runMultiThread() method is ended");
    }
}

Output
This is RunnableSample's run() method.
This is ThreadSample's run() method.
This is RunnableSample's run() method.
This is ThreadSample's run() method.
This is ThreadSample's run() method.
This is ThreadSample's run() method.
This is ThreadSample's run() method.
This is RunnableSample's run() method.
This is RunnableSample's run() method.
RunMultiThreads.runMultiThread() method is ended
This is RunnableSample's run() method.

  • 실행하면 순서대로 실행하지 않는다. 컴퓨터의 성능에 따라 달라질 수도 있으며 매번 결과가 다르다.
  • run() 메서드가 끝나지 않으면 애플리케이션은 종료되지 않는다.

Thread sleep 메서드

sleep 메서드는 주어진 시간만큼 대기하게 된다.

예제를 한번 보자

public class EndlessThread extends Thread {
	public void run() {
    	while (true) {
        	try {
            	System.out.println(System.currentTimeMillis());
                Thread.sleep(1000);
			} catch (InterruptedException e) {
            	e.printStackTrace();
			}
		}
	}
}

이 예제를 실행하면 무한으로 실행하게 되니 직접 실행을 중지 해줘야 한다. Thread.sleep() 메서드는 사용할 때 InterruptedException이 발생할 수 있기 때문에 try-catch로 묶어 예외를 처리해줘야 한다.

쓰레드의 상태


쓰레드의 현재 상태를 나타낸다.

✔️ Thread.join()

  • join(), join(0)
    • 해당 쓰레드가 종료될 때까지 기다린다.
  • join(60000)
    • 60초 동안 기다린다.

✔️ interrupt()

  • 현재 수행중인 쓰레드를 중단시킨다.
    그냥 중단시키지는 않고 InterruptedException 예외를 발생시키면서 중단시킨다. sleep()과 join() 메서드에서 발생한다고 했던 예외이다.
public void checkJoin() {
	SleepThread thread = new SleepThread(2000);
    try {
    	thread.start();
        thread.join(5000);
        thread.interrupt();
        System.out.println("thread state(after join)=" + thread.getState());
	} catch (InterruptedException e) {
    	e.printStackTrace();
	}
}

Output
Sleeping Thread-0
Stopping Thread-0
thread state(after join)=TERMINATED


Object 클래스에 선언된 쓰레드와 관련있는 메서드들


예제를 한번 보자
✔️ StateThread

public class StateThread extends Thread {
	private Object monitor;
    
    public StateThread(Object monitor) { // 1
    	this.monitor = monitor;
	}
    
    public void run() {
    	try {
        	for (int i = 0; i < 10000; i++) { // 2
            	String a = "A"; 
			}
            synchronized (monitor) {
            	monitor.wait(); // 3
			}
            
            System.out.println(getName() + "is notified.");
            Thread.sleep(1000); // 4
		} catch (InterruptedException e) {
        	e.printStackTrace();
		}
	}
}
  1. monitor라는 이름의 객체를 매개변수로 받아 인스턴스 변수로 선언해 두었다
  2. 쓰레드를 실행중인 상태로 만들기 위해서 간단하게 루프를 돌면서 String 객체를 생성한다.
  3. synchronized 블록 안에서 monitor 객체의 wait() 메서드를 호출했다.
  4. wait() 상황이 끝나면 1초간 대기했다가 이 쓰레드는 종료된다.
Object monitor = new Object();
StateThread thread = new StateThread(monitor); // 1
try {
	System.out.println("thread state = " + thread.getState());
    thread.start(); // 2
    System.out.println("thread state(after start) = " + thread.getState()); 
    
    Thread.sleep(100);
    System.out.println("thread State(after 0.1 sec) = " + thread.getState());
    
    synchronized (monitor) {
    	monitor.notify(); // 3
	}
    
    Thread.sleep(100);
    System.out.println("thread state(after notify) = " + thread.getState());
    
    thread.join(); // 4
    System.out.println("thread state(after join) = " + thread.getState());
} catch (InterruptedException e) {
	e.printStackTrace();
}
  1. StateThread의 매개변수로 넘겨줄 monitor라는 Object 클래스 객체를 생성한다.
  2. 쓰레드 객체를 생성하고 시작한다.
  3. monitor 객체를 통하여 notify() 메서드를 호출한다.
  4. 쓰레드가 종료될 때까지 기다린 후 상태를 출력한다.

    Output
    thread state = NEW
    thread state(after start)=RUNNABLE
    thread state(after 0.1 sec)=WAITING
    Thread-0 is notified.
    thread state(after notify)=TIMED_WAITING
    thread state(after join)=TERMINATED

wait() 메서드가 호출되면 WAITING 상태가 된다. 누군가가 이 쓰레드를 깨워줘야만 WAITING 상태에서 풀린다. interrupt() 메서드를 호출하여 대기 상태에서 풀려날 수도 있겠지만, notify() 메서드를 호출해서 풀어야 InterruptedException도 발생하지 않고, wait() 이후의 문장도 정상적으로 수행하게 된다.

Object monitor = new Object();
StateThread thread = new StateThread(monitor);
StateThread thread2 = new StateThread(monitor);
try {
    System.out.println("thread state = " + thread.getState());
    thread.start();
    thread2.start();
    System.out.println("thread state(after start)=" + thread.getState());

    Thread.sleep(100);
    System.out.println("thread state(after 0.1 sec)=" + thread.getState());

    synchronized (monitor) {
        monitor.notify();
    }

    Thread.sleep(100);
    System.out.println("thread state(after notify)=" + thread.getState());

    thread.join();
    System.out.println("thread state(after join)=" + thread.getState());
    thread2.join();
    System.out.println("thread2 state(after join)=" + thread2.getState());

} catch (InterruptedException e) {
    e.printStackTrace();
}

Output
thread state = NEW
thread state(after start)=RUNNABLE
thread state(after 0.1 sec)=WAITING
Thread-0 is notified.
thread state(after notify)=TIMED_WAITING
thread state(after join)=TERMINATED

결과가 이상 없다고 생각할 수도 있지만 thread2는 notify되지 않았고 끝나지도 않았다. 왜냐하면 자바에서 notify() 메서드를 호출하면 먼저 대기하고 있는 것부터 그 상태를 풀어주기 때문이다. 좀 무식하게 풀어주려면 다음과 같이 synchronized 블럭을 수정해주면 된다.

synchronized (monitor) {
    monitor.notify();
    monitor.notify();
}

이렇게하면 두개의 쓰레드 모두 wait() 상태에서 풀린다. 그런데 monitor 객체를 통해서 wait() 상태가 몇개인지 모르는 상태에서는 이와같이 구현하는 것은 별로 좋은 방법은 아니다. notifyAll() 메서드를 사용하는 것을 권장한다.

synchronized (monitor( {
	monitor.notifyAll();
}

Output
thread state = NEW
thread state(after start)=RUNNABLE
thread state(after 0.1 sec)=WAITING
Thread-1 is notified.
Thread-0 is notified.
thread state(after notify)=TIMED_WAITING
thread state(after join)=TERMINATED
thread2 state(after join)=TERMINATED

쓰레드의 우선순위

Java에서 각 쓰레드는 우선순위(priority)에 관한 자신만의 필드를 가지고 있다. 이러한 우선순위에 따라 특정 쓰레드가 더 많은 시간동안 작업을 할 수 있도록 설정한다.

getPriority()와 setPriority() 메서드를 통해 쓰레드의 우선순위를 반환하거나 변경할 수 있다. 쓰레드의 우선순위가 가질 수 있는 범위는 1부터 10까지이며, 숫자가 높을수록 우선순위 또한 높아진다. 하지만 쓰레드의 우선순위는 비례적인 절대값이 아닌 어디까지나 상대적인 값일 뿐이다. 우선순위가 10인 쓰레드가 1인 쓰레드보다 10배 더 빨리 수행되는 것이 아니라 단지 우선순위가 10인 쓰레드가 1인 쓰레드보다 좀 더 많이 실행 큐에 포함되어, 좀 더 많은 작업 시간을 할당받을 뿐이다.
✔️ ThreadWithRunnable

class ThreadWithRunnable implements Runnable {
	public void run() {
    	for (int i = 0; i < 5; i++) {
        	// 현재 실행중인 쓰레드의 이름을 반환함.
             System.out.println(Thread.currentThread().getName()); 
            try {
            	Thread.sleep(10);
			} catch (InterruptedException e) {
            	e.printStackTrace();
			}
		}
	}
}

✔️ Thread02

public class Thread2 {
	public static void main(String[]args) {
    	Thread thread1 = new Thread(new ThreadWithRunnable());
        Thread thread2 = new Thread(new ThreadWithRunnable());
        thread2.setPriority(10); // Thread-2의 우선순위를 10으로 변경.
        
        thread1.start(); // Thread-0의 실행
        thread2.start(); // Thread-1의 실행
        System.out.println(thread1.getPriority());
        System.out.println(thread2.getPriority());    
	}
}

Output
5
10
Thread-1
Thread-0
Thread-1
Thread-0
Thread-1
Thread-0
Thread-1
Thread-0
Thread-1
Thread-0

main() 메서드를 실행하는 쓰레드의 우선순위는 언제나 5이다. main() 메서드 내에서 생성된 쓰레드 Thread-0의 우선순위는 5로 설정되는 것을 확인할 수 있다.

Main 쓰레드

Main Thread

Java는 실행 환경인 JVM에서 돌아가게 된다. 이것이 하나의 프로세스이고 Java를 실행하기 위해 우리가 실행하는 main() 메서드가 메인 쓰레드 이다.

public class MainMethod {
	public static void main(String[] args) { 
    /* ⬆️ 이 부분이 메인 쓰레드이고 메인 쓰레드의 시작점을 선언하는 것이다.
		따로 쓰레드를 실행하지 않고 main() 메서드만 실행하는 것을 싱글 쓰레드 어플리케이션 이라고 한다. */
	}
}

멀티 쓰레드 애플리케이션

아래 그림과 같이 메인 쓰레드에서 쓰레드를 생성하여 실행하는 것을 멀티 쓰레드 애플리케이션이라고 한다.

✔️ Daemon Thread

  • Main 쓰레드의 작업을 돕는 보조적인 역할을 하는 쓰레드이다.
  • Main 쓰레드가 종료되면 데몬 쓰레드는 강제적으로 자동 종료가 된다. (어디까지나 Main 쓰레드의 보조 역할을 수행하기 때문에 Main 쓰레드가 없어지면 의미가 없어지기 때문)

✔️ Daemon Thread의 사용

  • Main 쓰레드가 Daemon이 될 쓰레드의 setDaemon(true)를 호출해주면 Daemon 쓰레드가 된다.

예제를 한번 보자
✔️ DaemonThread

public class DaemonThread extends Thread {
	public void run() {
    	try {
        	Thread.sleep(Long.MAX_VALUE);
		} catch (InterruptedException e) {
        	e.printStackTrace();
		}
	}
}

public void runCommonThread() {
	DaemonThread thread = new DaemonThread();
    thread.start();
}

이렇게 실행하면 Long의 최대값 만큼 대기하게 된다.

✔️ runDaemonThread

public void runDaemonThread() {
	DaemonThread thread = new DaemonThread();
    thread.setDaemon(true);
    thread.start();
}

프로그램이 대기하지 않고 그냥 끝나버린다. 즉 데몬 쓰레드는 해당 쓰레드가 종료되지 않아도 다른 실행중인 일반 쓰레드가 없다면 멈춰버리게 된다.

데몬 쓰레드를 만든 이유
예를 들어 모니터링하는 쓰레드를 별도로 띄워 모니터링 하다가, Main 쓰레드가 종료되면 관련된 모니터링 쓰레드도 종료되어야 프로세스가 종료될 수 있다. 이렇게 모니터링 쓰레드를 데몬 쓰레드로 만들지 않으면 프로세스가 종료할 수 없게 되는 것처럼 부가적인 작업을 수행하는 쓰레드를 선언할 때 데몬 쓰레드를 만든다.

동기화 (Synchronize)

멀티 쓰레드 프로세스에서는 여러 프로세스가 메모리를 공유하기 때문에, 한 쓰레드가 작업하던 부분을 다른 쓰레드가 간섭하는 문제가 생길 수 있다. 어떤 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 하는 작업을 동기화라고 한다. 동기화를 하려면 다른 쓰레드가 간섭해서 안되는 부분을 임계영역(critical section)으로 설정해주어야 한다. 임계영역 설정은 synchronized 키워드를 사용한다.

// 메서드 전체를 임계영역으로 설정
public synchronized void method1() {
	........
}
// 특정한 영역을 임계영역으로 설정
synchronized(객체의 참조변수) {
	........
}

먼저 쓰레드의 반환 타입 앞에 synchronized 키워드를 붙여서 메서드 전체를 임계영역으로 설정 할 수 있다. 쓰레드는 synchronized 키워드가 붙은 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다. 두번째로 메서드 내의 코드 일부를 블록으로 감싸고 블록 앞에 synchronized(참조변수)를 붙이는 방법이 있다. 이때 참조변수는 락을 걸고자 하는 객체를 참조하는 것이어야 한다. 이 영역으로 들어가면서부터 쓰레드는 지정된 객체의 lockd을 얻고 블록을 벗어나면 lock을 반환한다.

lock

lock은 일종의 자물쇠 개념이다. 모든 객체는 lock을 하나씩 가지고 있다. 해당 객체의 lock을 가지고 있는 쓰레드만 임계영역의 코드를 수행할 수 있다. 한 객체의 lock은 하나밖에 없기 때문에 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다. 임계영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 lock을 거는 것보다 synchronized 블록으로 임계영역을 최소화하는 것이 좋다.

✔️ 동기화 하지 않아서 문제가 생기는 경우

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

	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 ThreadEx_1 implements Runnable {
	Account account = new Account();
    
    @Override
    public void run() {
    	while(account.getBalance() > 0) {
        //100,200,300중 임의의 값을 선택해서 출금
        int money = (int)(Math.random() * 3 + 1) * 100;
        account.withdarw(money);
        System.out.println("balance: " + account.getBalance());
	}
  }
}

Output
balance: 800
balance: 800
balance: 700
balance: 700
balance: 500
balance: 600
balance: -100
balance: -100

분명 잔고는 음수가 되지 않도록 설계했는데 음수가 나왔다. 왜냐하면 쓰레드 하나가 if문을 통과하면서 balance를 검사하고 순서를 넘겼는데 그 사이에 다른 쓰레드가 출금을 해버리면서 실제 balance가 if문을 통과할 때 검사했던 값보다 작아지게 된다. 하지만 이미 if문을 통과했기 때문에 출금은 이루어지게 되고 음수가 나오는 것이다. 이 문제를 해결하려면 출금하는 로직에 동기화를 해서, 한 쓰레드가 출금 로직을 실행하고 있으면 다른 쓰레드가 출금 블록에 들어오지 못하도록 막아줘야 한다.

public void withdraw(int money) {
	// synchronized block 추가
	synchronized(this) {
		if (balance >= money) {
			try {
			//문제 상황을 만들기 위해 고의로 쓰레드를 일시정지
			Thread.sleep(1000);
			} catch (InterruptedException e) {}
		balance -= money;
	  }
   }
}

synchronized 블록만 추가해 실행한다면 음수가 나오지 않는다.

DeadLock (교착상태)


Process1과 Process2는 모두 자원 A,B가 필요한 상황이라고 가정하자. Process1은 A에 먼저 접근하고 Process2는 B에 먼저 접근했다. Process1과 Process2는 각각의 A와 B의 Lock을 가지고 있는 상태이다. 이제 Process1은 B에 접근하기 위해 B의 락이 풀리기를 대기한다. 동시에 Process2는 A에 접근하기 위해 A의 락이 풀리기를 대기한다. 서로 원하는 리소스가 상대방에게 할당되어 있기 때문에 두 프로세스는 무한히 대기 상태에 있게 되는데, 이를 데드락이라고 한다. 데드락은 한 시스템 내에서 다음의 네가지 조건이 동시에 성립할 때 발생한다. 아래 네가지 조건 중 하나라도 성립하지 않도록 만든다면 교착 상태를 해결할 수 있다.

  • 상호배제 (Mutual exclusion)
    • 자원은 한번에 한 프로세스만이 사용할 수 있어야 한다.
  • 점유 대기 (Hold and wait)
    • 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용하고 있는 자원을 추가로 점유하기 위해 대기하는 프로세스가 있어야 한다.
  • 비선점 (No preemption)
    • 다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼았을 수 없어야 한다.
  • 순환 대기 (Circular wait)
    • 프로세스의 집합 {P0, P1, ...Pn}에서 P0은 P1이 점유한 자원을 대기하고 P1은 P2가 점유한 자원을 대기하고 P2...Pn-1은 Pn이 점유한 자원을 대기하며 Pn은 P0가 점유한 자원을 요구해야 한다.

wait() & notify()

동기화를 하게 되면 하나의 작업을 하나의 쓰레드밖에 하지 못하기 때문에 작업 효율이 떨어질 수 밖에 없다. 이때 동기화의 효율을 높이기 위해서 wait(), notify()를 이용한다.

구분설명
void wait()
void wait(long timeout)객체의 락을 풀고 쓰레드를 해당 객체의 waiting pool에 넣는다.
void wait(long timeout, int nanos)
void notify()waiting pool에서 대기 중인 쓰레드 하나를 깨운다.
void notifyAll()waiting pool에서 대기 중인 모든 쓰레드를 깨운다.

wait과 notify는 Object클래스에 정의되어 있으며, 동기화 블럭 내에서만 사용할 수 있다. 동기화된 임계코드 영역의 작업을 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출하여 쓰레드가 락을 반납하고 기다리게 한다. 그러면 다른 쓰레드가 락을 얻어서 해당 객체에 대한 작업을 수행할 수 있게 된다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 된다.

class Account {

    private int balance = 1000;
    
    public synchronized void withdraw(int money) {
    	// 잔고가 출금액보다 적어 인출할 수 없다.
        while (balance < money) {
        	try {
            	wait();
			} catch (InterruptedException e) {}
		}
        balance -= money;
	}
    
    public synchronized void deposit(int money) {
    	// 돈을 입금하고 waiting pool의 쓰레드에 통보
        balance += money;
        notify();
	}
}

만약 잔고가 모자라서 출금을 할 수 없는 경우, 다른 쓰레드가 입금을 할 수 있도록 객체에 대한 락을 풀고 waiting pool에서 기다린다. deposit을 수행하는 쓰레드는 해당 객체의 락을 얻어 잔고를 채우고 waiting pool에서 대기중인 쓰레드에게 다시 작업을 수행하라고 명령한다. 대기하던 쓰레드는 다시 락을 얻어 인출 로직을 수행한다.

java.util.concurrent.locks

JDK 1.5부터 synchronized 외에 동기화를 구현할 수 있는 방법이 추가되었다. java.util.concurrent.locks 패키지의 Lock 클래스들을 이용하는 방법이다. synchronized로 동기화를 하면 자동으로 락이 걸리고 풀리지만 같은 메서드 내에서만 Lock을 걸 수 있다는 불편함이 있다. 그럴 때 Lock 클래스를 이용한다.

✔️ Lock 클래스의 종류

  • ReentrantLock
    • 재진입이 가능한 Lock, 가장 일반적인 배타 Lock
  • ReentrantReadWriteLock
    • 읽기에는 공유적이고, 쓰기에는 배타적인 Lock
  • StampedLock
    • ReentrantReadWriteLock에 낙관적인 Lock의 기능을 추가

기본적인 메서드

  • void lock()
    • lock을 잠근다.
  • void unlock()
    • lock을 해지한다.
  • boolean isLocked()
    • lock이 잠겨있는지 확인한다.

ReentrantLock가장 일반적인 락이다. '재 진입할 수 있는' 이라는 말이 붙는 이유 는 wait() & notify()에서 살펴봤듯이 특정 조건에서 락을 풀었다가 나중에 다시 와서 락을 걸 수 있기 때문이다. ReentrantReadWriteLock 읽기를 위한 락(Read Lock)과 쓰기를 위한 락(Write Lock)을 제공한다 (static class로 구현되어 있음.) ReentrantLock은 무조건 락이 걸려 있어야만 임계영역의 코드를 수행할 수 있지만, ReentrantReadWriteLock은 읽기 락이 걸려있으면, 다른 쓰레드가 읽기 락을 중복해서 걸고 읽기를 수행할 수 있다. 그러나 읽기 락이 걸린 상태에서 쓰기 락을 거는 것은 허용되지 않는다. 반대의 경우도 마찬가지이다. StampedLock락을 걸거나 해지할 때 '스탬프(long 타입의 정수 값)'를 사용하며, ReentrantReadWriteLock에 "낙관적 읽기 락(optimistic reading lock)"이 추가된 형태이다. 읽기 락이 걸려있으면 쓰기 락을 얻기 위해서는 읽기 락이 풀릴 때까지 기다려야 하는데 낙관적 읽기 락은 쓰기 락에 의해 바로 풀린다. 코드로 살펴보면 다음과 같다.

StampedLock lock = new StampedLock();
........
int getBalance() {
	
    long stamp = lock.tryOptimisticRead(); // 낙관적 읽기 락을 건다.
    int currentBalance = this.balance; // 공유 데이터인 balance를 읽어온다.
    
    if (!lock.validate(stamp)) { // 쓰기 락에 의해 낙관적 락이 풀렸는지 확인.
    	stamp = lock.readLock(); // 락이 풀렸으면, 읽기 락을 얻으려고 기다린다.
        try {
        	currentBalance = this.balance; // 공유 데이터를 다시 읽어온다.
		} finally {
        	lock.unlockedRead(stamp); // 읽기 락을 푼다.
		}
	}
    return currentBalance; // 낙관적 읽기 락이 풀리지 않았으면 곧바로 읽어온 값을 반환
}

이렇게 무조건 읽기 락을 거는게 아니라 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 락을 건다. 추가적으로, 임계영역 내에서 리턴을 할 경우 unlock이 되지 않기 때문에 Lock 클래스를 사용하는 경우 try-finally문을 이용해서 코드 수행이 끝나면 무조건 unlock을 하도록 만들어준다.

Lock 클래스들은 생성자에 boolean을 추가해서 공정(fair) 처리를 해줄 수 있다.

ReentrantLock()
ReentrantLock(boolean fair)

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

Condition

synchronized로 동기화를 구현한 후 wait() & notify()를 사용하면, 해당 객체의 waiting pool에서 대기중인 임의의 쓰레드 혹응 쓰레드 전체를 깨우는 방법밖에 없다. 그러나 ReentrantLock으로 동기화를 구현하고 Condition을 이용하면 내가 원하는 쓰레드를 깨울 수 있다. 예를 들어, 음식을 만드는 요리사(COOK) 쓰레드와 음식을 소비하는 손님(CUST) 쓰레드가 있고 두 쓰레드가 음식이 올라가는 테이블(TABLE)을 공유한다고 할 때, 테이블에 음식을 추가하는 코드를 다음과 같이 구현할 수 있다.

......
public synchronized void add(String dish) {
	while (dishes.size() > MAX_FOOD) { // 테이블에 올라갈 수 있는 최대 음식 수
    	try { 
    		wait(); // COOK 쓰레드를 기다리게 한다.
    	    Thread.sleep(500);
		} catch (InterruptedException e) {}
	}
  dishes.add(dish);
  notify(); // 기다리고 있는 CUSTOMER를 깨운다.
}
.......
public static void main(String[]args) {
	Table table = new Table();
    
    new Thread(new Cook(table), "COOK1").start();
    new Thread(new Customer(table), "CUST1").start();
    new Thread(new Customer(table), "CUST2").start();
}

테이블 위에 음식이 최대 숫자로 올라가 있으면 더 이상 음식을 올릴 수 없으므로 wait()을 호출해 COOK 쓰레드를 기다리게 한다. 또 테이블에 음식이 없으면 손님이 음식을 먹을 수 없으므로 CUST를 기다리게 한 후 음식을 채워 넣으면 CUST를 깨우도록 구현했다. 이렇게 하면 Table 객체의 waiting pool에는 요리사와 손님 모두 대기할 수 있기 때문에 notify() 메서드 만으로는 내가 원하는 쓰레드(손님 or 요리사)를 선택해서 깨울 수 없다. 이 때 ReentrantLockCondition을 이용하면 된다.

private ReentrantLock lock = new ReentrantLock(); // Lock을 생성
// 생성된 Lock으로 Condition 생성
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();
......
public void add(String dish) {
	lock.lock();
    try {
    	while(dishes.size > MAX_FOOD) {
        	try {
            	forCook.await();
			} catch (InterruptedException e) {}
		}
      dishes.add(dish);
      forCust.signal(); // 기다리고 있는 CUST를 깨운다.
	} finally {
    	lock.unlock();
	}
}

이렇게 lock으로부터 new Condition을 생성하고, wait()과 notify() 대신 await()과 signal()을 이용하면 원하는 쓰레드를 기다리게 하고 깨울 수 있다.

끝 !


10주차 내용 너무너무 길다,,, 팀 플젝과,,, 멀티쓰레드,, 결과는 모르겠고 ,,,
어쨋든,, 나는 최선을 다했답니다,,~! ^0^~~,,

[ 참고 ]
https://wisdom-and-record.tistory.com/48
https://sujl95.tistory.com/63

profile
⋆。゚★⋆⁺₊⋆ ゚☾ ゚。⋆ ☁︎。₊⋆

2개의 댓글

comment-user-thumbnail
2023년 7월 23일

좋은 글 감사합니다.

1개의 답글