[Java] 15. Thread Synchronization(스레드 동기화)


멀티쓰레드는 한개의 같은 프로세스 내에서 자원을 공유해서 작업하기 때문에 서로의 작업을 영향을 주게 된다. 공유되는 데이터를 동시에 쓰레드들이 처리하면 의도하지 않는 데이터가 나올 수가 있다.



01. 쓰레드의 동기화(Synchronization)

멀티쓰레드는 한개의 같은 프로세스 내에서 자원을 공유해서 작업하기 때문에 서로의 작업을 영향을 주게 된다. 공유되는 데이터를 쓰레드A와 쓰레드B가 각각 작업을 처리하면 의도하지 않는 데이터가 나올 수가 있다.

이러한 문제를 방지하기 위해 하나의 쓰레드가 작업을 끝마치기 전까지 다른 쓰레드에게 작업을 기다리게 하기 위해서 나온 개념이 임계영역(crititcal section)잠금(lock) 이다.

공유 데이터를 사용하는 코드 영역(로직)을 임계영역으로 지정해놓고, 공유데이터가지고 있는 lock 을 획득한 단 하나의 쓰레드만 이 임계영역 내의 코드를 수행할 수 있게 한다.

해당 쓰레드가 작업이 끝나면 lock을 반납하고 다른 쓰레드가 다시 lock을 획득하고 임계영역의 코드를 수행할 수 있게 한다.

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

해당 동기화의 사용되는 개념들은 운영체제에서 프로세스 동기화에서도 동일하기 때문에
알고 자바 언어로 프로그램을 처음 입문하면 알고 가면 좋은듯 하다.


자바에서는 synchronization 블럭을 이용해서 쓰레드의 동기화를 지원하다 JDK1.5이상부터 java.util.concurrent 패키지에서 lock과 atomic 을 통해서 다양한 동기화방식을 지원한다.



01-1. 쓰레드의 동기화 개념 정리

  • 멀티 쓰레드 프로세스에서는 다른 쓰레드의 작업에 영향을 미칠 수 있다.
  • 진행 중인 작업이 다른 쓰레드에게 간섭받지 않게 하려면 ‘동기화’가 필요


쓰레드의 동기화

  • 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하게 막는 것
  • 동기화하려면 간섭 받지 않아야 하는 것을 "임계 영역(critical section)"으로 설정
  • 쓰레드 한 개당 락(lock)을 한개를 가지고 있으며, 락이 없으면 임계영역에 들어 갈 수 없음
  • 임계영역은 락(lock)을 얻은 단 하나의 쓰레드만 출입가능(객체 1개에 락 1개)



01-2. synchronized 를 이용한 동기화

쓰레드 동기화는 성능이 중요하기 때문에 최대한 동기화가 필요한 블럭에만
최대한 synchronized 로 임계영역을 정해서 처리해야 한다.


synchronized 로 임계영역(lock이 걸리는 영역)을 설정하는 방법 2가지

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

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


동기화 메서드(메서드 전체를 임계 영역)

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

        balance -= money;
    }
}

동기화 블럭(특정 영역을 임계 영역)

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

            balance -= money;
        }
    }
}



02. 동기화 예제

아래 예제 코드 쓰레드 run 메서드에 코드의 일부분이다.
동기화 없는 출금처리와 동기화(synchronized)가 있을때 메서드이다.

통장에 10000원 밖에 없는데 카드사에서 5500웜, 6500원, 7500원을 synchronized 처리가 되어 있는 경우와 아닌 경우를 출금해서 가져간다고 생각해보자.

withdraw 와 syncWithdraw 결과에 대한 설명이다.

/**
* 1. 동기화가 없는 출금처리
* - synchronized 로 임계영역을 설정하지 않은 경우
* - 쓰레드가 동시에 들어온 경우 로직을 같이 타기에 원치 않게 공유데이터의 원자성이 깨진다.
* - 카드사들이 한번에 동시에 출금처리하는 경우 
* - 통장잔고 없는데도 카드사에서 출금해서 가서 통장이 마이너스가 되는 상황이 된다.
*/
myPay.withdraw(this.withdrawMoney, this.getName());

/**
* 2. 동기화 출금처리
* - synchronized 로 임계영역으로 선언하여 한번에 한개의 쓰레드만 들어오게 한다.
* - A라는 쓰레드가 들어올 경우 Lock을 가지고 잠궈서
* - 다른 쓰레드가 못 들어오게 막는다.
* - 그리고 A쓰레드가 임계영역을 벗어나는 경우 Lock을 반납한다.
* - 다음 쓰레드는 다시 Lock을 가지고 들어와서 잠군다.
* - 카드사에서 출금해야 할 돈이 통장잔고에 없으면 아래와 같이 메시지 발생
* - "B 카드사에서 잔고 부족으로 출금에 실패했습니다."
*/
myPay.syncWithdraw(this.withdrawMoney, this.getName());
public class ExSyncSimple {

    public static void main(String... args) {
        //공유 객체
        MyPay pay = new MyPay(10000);

        //A 라는 기기
        CalcThread calc1 = new CalcThread();
        calc1.setName("A 카드사");
        calc1.setWithdrawMoney(5500);
        calc1.setMyPay(pay);

        //B 라는 기기
        CalcThread calc2 = new CalcThread();
        calc2.setName("B 카드사");
        calc2.setWithdrawMoney(6500);
        calc2.setMyPay(pay);

        //C 라는 기기
        CalcThread calc3 = new CalcThread();
        calc3.setName("C 카드사");
        calc3.setWithdrawMoney(7500);
        calc3.setMyPay(pay);


        calc1.start();
        calc2.start();
        calc3.start();


        try {
            Thread.sleep(3000);//비즈니스 로직 처리 시간 개념
        } catch (InterruptedException e) {
        }

        System.out.println("# 현재 잔고금액: " + pay.getTotalMoney());
    }
}

class MyPay {
    int totalMoney = 0;

    public MyPay(int totalMoney) {
        this.totalMoney = totalMoney;
    }

    /**
     * 잔고 금액
     * @return
     */
    public int getTotalMoney() {
        return totalMoney;
    }

    /**
     * 동기화가 없는 출금처리
     * @param withdrawMoney 출금금액
     * @param company 출금하는 카드사
     */
    public void withdraw(int withdrawMoney, String company) {

        if (this.totalMoney >= withdrawMoney) {

            try {
                Thread.sleep(2000);//비즈니스 로직 처리 시간 개념
            } catch (InterruptedException e) {
            }

            this.totalMoney -= withdrawMoney;
            System.out.println(company + "에서 " + withdrawMoney + " 원을 출금했습니다. 남은 잔고는 " + this.totalMoney + " 원 입니다.");
        } else {
            System.out.println(company + "에서 잔고 부족으로 출금에 실패했습니다. 출금요청금액=[" + withdrawMoney + "], 현재 잔고=[" + this.totalMoney + "]");
        }
    }

    /**
     * 동기화 출금처리
     * @param withdrawMoney 출금금액
     * @param company 출금하는 카드사
     */
    public synchronized void syncWithdraw(int withdrawMoney, String company) {

        if (this.totalMoney >= withdrawMoney) {

            try {
                Thread.sleep(2000);//비즈니스 로직 처리 시간 개념
            } catch (InterruptedException e) {
            }

            this.totalMoney -= withdrawMoney;
            System.out.println(company + "에서 " + withdrawMoney + " 원을 출금했습니다. 남은 잔고는 " + this.totalMoney + " 원 입니다.");
        } else {
            System.out.println(company + "에서 잔고 부족으로 출금에 실패했습니다. 출금요청금액=[" + withdrawMoney + "], 현재 잔고=[" + this.totalMoney + "]");
        }
    }
}

class CalcThread extends Thread {
    private MyPay myPay;
    private int withdrawMoney;

    public void setMyPay(MyPay myPay) {
        this.myPay = myPay;
    }

    public void setWithdrawMoney(int withdrawMoney) {
        this.withdrawMoney = withdrawMoney;
    }

    @Override
    public void run() {

        while(true) {
            if (this.withdrawMoney >= myPay.getTotalMoney()) {
                break;
            }
            /**
             * 1. 동기화가 없는 출금처리
             * - synchronized 로 임계영역을 설정하지 않은 경우
             * - 쓰레드가 동시에 들어온 경우 로직을 같이 타기에 원치 않게 공유데이터의 원자성이 깨진다.
             * - 통잔고 없는데도 카드사장에서 출금해서 가서 통장이 마이너스가 되는 상황이 된다.
             */
            //myPay.withdraw(this.withdrawMoney, this.getName());

            /**
             * 2. 동기화 출금처리
             * - synchronized 로 임계영역으로 선언하여 한번에 한개의 쓰레드만 들어오게 한다.
             * - A라는 쓰레드가 들어올 경우 Lock을 가지고 잠궈서
             * - 다른 쓰레드가 못 들어오게 막는다.
             * - 그리고 A쓰레드가 임계영역을 벗어나는 경우 Lock을 반납한다.
             * - 다음 쓰레드는 다시 Lock을 가지고 들어와서 잠군다.
             */
            myPay.syncWithdraw(this.withdrawMoney, this.getName());
        }
    }
}




[출처]

  • 자바의정석 (저자: 남궁성)