Thread (5)

김재익·2023년 6월 19일
0

JAVA

목록 보기
13/18
post-thumbnail

Synchronization

멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스의 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있다. 이로인해 장애나 버그가 발생할 수 있다.

  • 이러한 일을 방지하기 위해 한 쓰레드가 진행중인 작업을 다른 쓰레디가 침범하지 못하도록 막는것을 쓰레드 동기화(Synchronization) 라고한다.
  • 동기화를 하려면 다른 쓰레드의 침범을 막아야하는 코드들을 '임계영역'으로 설정하면 된다.
  • 임계영역에는 Lock을 가진 단 하나의 쓰레드만 출입이 가능하다.
    • 즉, 임계영역은 한번에 한 쓰레드만 사용이 가능하다.

synchronized

  • 실행할 메서드 또는 실행할 코드 묶음 앞에 synchronized를 붙여서 임예영역을 지정하여 다른 쓰레드의 침범을 막을 수 있다. ( = Lock을 걸다)
    public synchronized void asyncSum() {
    	...침범을 막아야하는 코드...
    }
    synchronized(해당 객체의 참조변수) {
    	...침범을 막아야하는 코드...
    }

예제

3개의 쓰레드가 1초에 사과 1개씩 먹는 작업

public class Main {
    public static void main(String[] args) {
        AppleStore appleStore = new AppleStore();

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }

        };

        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        if (storedApple > 0) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            storedApple -= 1;
        }
    }
}

남은 사과의 수가 뒤죽박죽 출력될뿐만 아니라 사과가 3개 미만으로 남아 있을 때 쓰레드 3개 모두 작업을 시작 해버리면 없는 사과를 먹는 경우도 발생한다.

public class Main {
    public static void main(String[] args) {
        AppleStore appleStore = new AppleStore();

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }

        };

        for (int i = 0; i < 3; i++) {
            new Thread(task, "thread" + i).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        synchronized (this) {
            if(storedApple > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                storedApple -= 1;
            }
        }
    }
}

wait(), notify()

침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait()을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있다. (위의 일시정지와 다른 상태)

  • 다른 쓰레드가 Lock을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다.
  • 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출한다.
  • 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 된다.
  • wait
    실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다린다.
  • notify
    해당 객체의 대기실(waiting pool)에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다.
public class Main {
    public static String[] itemList = {
            "MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
    };
    public static AppleStore appleStore = new AppleStore();
    public static final int MAX_ITEM = 5;

    public static void main(String[] args) {

        // 가게 점원
        Runnable StoreClerk = () -> {
                while (true) {
                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.restock(itemList[randomItem]);
                    try {
                        Thread.sleep(50);
                    } catch (InterruptedException ignored) {
                    }
                }
        };

        // 고객
        Runnable Customer = () -> {
                while (true) {
                    try {
                        Thread.sleep(77);
                    } catch (InterruptedException ignored) {
                    }

                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.sale(itemList[randomItem]);
                    System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
                }
        };


        new Thread(StoreClerk, "StoreClerk").start();
        new Thread(Customer, "Customer1").start();
        new Thread(Customer, "Customer2").start();

    }
}

class AppleStore {
    private List<String> inventory = new ArrayList<>();

    public void restock(String item) {
        synchronized (this) {
            while (inventory.size() >= Main.MAX_ITEM) {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                try {
                    wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
                    Thread.sleep(333);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            // 재입고
            inventory.add(item);
            notify(); // 재입고 되었음을 고객에게 알려주기
            System.out.println("Inventory 현황: " + inventory.toString());
        }
    }

    public synchronized void sale(String itemName) {
        while (inventory.size() == 0) {
            System.out.println(Thread.currentThread().getName() + " Waiting!");
            try {
                wait(); // 재고가 없기 때문에 고객 대기중
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        while (true) {
            // 고객이 주문한 제품이 있는지 확인
            for (int i = 0; i < inventory.size(); i++) {
                if (itemName.equals(inventory.get(i))) {
                    inventory.remove(itemName);
                    notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
                    return; // 메서드 종료
                }
            }

            // 고객이 찾는 제품이 없을 경우
            try {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                wait();
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
Inventory 현황: [IPhone]
Inventory 현황: [IPhone, AirPods]
Customer1 Waiting!
Customer2 Purchase Item AirPods
Customer1 Waiting!
Customer2 Waiting!
Inventory 현황: [IPhone, IPhone]
Customer1 Waiting!
Inventory 현황: [IPhone, IPhone, Mac mini]
Customer2 Waiting!
Inventory 현황: [IPhone, IPhone, Mac mini, IPhone]
Customer1 Purchase Item Mac mini
Customer2 Waiting!
Customer1 Waiting!
Inventory 현황: [IPhone, IPhone, IPhone, AirPods]
Customer2 Waiting!
Inventory 현황: [IPhone, IPhone, IPhone, AirPods, Mac mini]
Customer1 Purchase Item AirPods
Customer2 Waiting!
Customer1 Purchase Item IPhone
Inventory 현황: [IPhone, IPhone, Mac mini, MacBook]
Customer2 Purchase Item MacBook
Customer1 Waiting!
Inventory 현황: [IPhone, IPhone, Mac mini, AirPods]
Customer2 Purchase Item Mac mini
Customer1 Purchase Item AirPods
Inventory 현황: [IPhone, IPhone, Mac mini]
Inventory 현황: [IPhone, IPhone, Mac mini, IPhone]
Customer2 Waiting!
Customer1 Waiting!
Inventory 현황: [IPhone, IPhone, Mac mini, IPhone, Mac mini]
Customer2 Waiting!
StoreClerk Waiting!

종료되지 않고 무한 대기 상황에 걸렸다.

고의적으로 데드락을 걸기위해 짜여진 코드지만 요점은 점원이 사용하는 notify와 고객이 사용하는 notify가 서로를 깨워주지 못하고 있다는 것이다.

Lock, Condition

Lock

synchronized 블러으로 동기화 하면 자동적으로 Lock이 걸리고 풀리지만 같은 메서드 내에서만 Lock을 걸 수 있다는 제약이 있다.
이런 제약을 해결하기 위해 Lock 클래스를 사용한다.

  • ReentantLock

    • 재진입 가능한 Lock, 가장 일반적인 배타 Lock
    • 특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계영역으로 진입이 가능하다.
    public class MyClass {
        private Object lock1 = new Object();
        private Object lock2 = new Object();
    
        public void methodA() {
            synchronized (lock1) {
                methodB();
            }
        }
    
        public void methodB() {
            synchronized (lock2) {
                // do something
                methodA();
            }
        }
    }
    • methodA는 lock1을 가지고, methodB는 lock2를 가진다.
    • methodB에서 methodA를 호출하고 있으므로, methodB에서 lock2를 가진 상태로 methodA를 호출하면 lock1을 가지려고 할 것.
    • 이 때, methodA에서 이미 lock1을 가지고 있으므로 lock2를 기다리는 상태가 되어 데드락이 발생할 가능성이 있다.
    • 여기서 ReentrantLock을 사용하면, 같은 쓰레드가 이미 Lock을 가지고 있더라도 Lock을 유지하며 계속 실행할 수 있기 때문에 데드락이 발생하지 않는다.
    • ReentrantLock을 사용하면 코드의 유연성을 높일 수 있다.
  • ReentrantReadWriteLock

    • 읽기를 위한 Lock과 쓰기를 위한 Lock을 따로 제공한다.
    • 읽기에는 공유적이고, 쓰기에는 배타적인 Lock이다.
    • 읽기 Lock이 걸려있으면 다른 쓰레드들도 읽기 Lock을 중복으로 걸고 읽기를 수행할 수 있다.
    • 읽기 Lock이 걸려있는 상태에서 쓰기 Lock을 거는 것은 허용되지 않는다. (데이터 변경 방지)
  • StampLock

    • ReentrantReadWriteLock에 낙관적인 Lock의 기능을 추가한 것.
      • 낙관적인 Lock: 데이터를 변경하기 전에 Lock을 걸지 않는 것. 낙관적인 락은 데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용한다.
      • 낙관적인 락을 사용하면 읽기와 쓰기 작업 모두가 빠르게 처리된다. 쓰기 작업이 발생 했을 때 데이터가 이미 변경된 경우 다시 읽기 작업을 수행하여 새로운 값을 읽어들이고, 변경 작업을 다시 수행한다. 이러한 방식으로 쓰기 작업이 빈번하지 않은 경우에는 낙관적인 락을 사용하여 더 빠른 처리가 가능하다.
    • 낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제 가능하다.
    • 무조건 읽기 Lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock을 건다.

Condition

wait(), notify()의 문제점인 waiting pool 내 쓰레드를 구분하지 못하는 것을 해결한 것이 Condition이다.

wait()과 notify()는 객체에 대한 모니터링 락(lock)을 이용하여 스레드를 대기시키고 깨운다. 그러나 wait()과 notify()는 waiting pool 내에 대기중인 스레드를 구분하지 못하므로, 특정 조건을 만족하는 스레드만 깨우기가 어렵다.

이러한 문제를 해결하기 위해 JDK 5에서는 java.util.concurrent.locks 패키지에서 Condition 인터페이스를 제공한다. Condition은 waiting pool 내의 스레드를 분리하여 특정 조건이 만족될 때만 깨우도록 할 수 있으며, ReentrantLock 클래스와 함께 사용된다. 따라서 Condition을 사용하면 wait()과 notify()의 문제점을 보완할 수 있다.

public class Main {
    public static final int MAX_TASK = 5;

    private ReentrantLock lock = new ReentrantLock();

    // lock으로 condition 생성
    private Condition condition1 = lock.newCondition();
    private Condition condition2 = lock.newCondition();

    private ArrayList<String> tasks = new ArrayList<>();

    // 작업 메서드
    public void addMethod(String task) {
        lock.lock(); // 임계영역 시작

        try {
            while(tasks.size() >= MAX_TASK) {
                String name = Thread.currentThread().getName();
                System.out.println(name+" is waiting.");
                try {
                    condition1.await(); // wait(); condition1 쓰레드를 기다리게 합니다.
                    Thread.sleep(500);
                } catch(InterruptedException e) {}
            }

            tasks.add(task);
            condition2.signal(); // notify();  기다리고 있는 condition2를 깨워줍니다.
            System.out.println("Tasks:" + tasks.toString());
        } finally {
            lock.unlock(); // 임계영역 끝
        }
    }
}

condition1 = 점원
condition2 = 고객

코드 이외 부분
물건이 없어 condition2는 await 상태
addMethod()를 호출하면서 lock.lock()으로 해당 쓰레드가 addMethod에 들어가서 문을 잠금

코드 부분
물건을 하나 등록하고 고객인 condition2를 지정해서 signal()을 실행시켜 점원 과 고객을 구분할 수 있게 되었음.
모든 작업이 끝나고 lock.unlock()으로 해당 쓰레드가 문 잠금을 해제하고 나감

사실 이 코드도 wait notify 코드처럼 고객의 구매를 랜덤상품으로 정하고 물건을 채우는것도 랜덤상품으로 하면 결국 무한 대기에 빠진다.

요점은 waiting pool에 있는 대기 쓰레드를 지정해서 깨울 수 있다는 것.

profile
개발자호소인

0개의 댓글