언어(Language)/Java

[Java] 자바 스레드 동기화 개념 정리 및 활용 (Thread Synchronization)

잇트루 2022. 9. 29. 06:00
반응형

스레드 동기화란? (Thread Synchronization)

프로세스는 자원과 데이터, 스레드로 구성되어 있다. 따라서 프로세스는 스레드가 운영체제로부터 자원을 할당받아 소스 코드를 실행하여 데이터를 처리한다.

만약, 싱글 스레드 프로세스라면, 공유 데이터에 단 하나의 스레드만이 접근하므로 문제가 될 것이 없다. 하지만, 멀티 스레드 프로세스의 경우, 두 개 이상의 스레드가 공유 데이터에 동시에 접근하게 되면 예상과 벗어난 결과가 타나날 수 있다.

이러한 문제를 해결해 주는 것이 바로 스레드 동기화다.

 

스레드 동기화를 안하는 경우

의도적으로 계좌에 동시 접근이 가능하도록 만든 코드이다.

출금할 금액이 계좌 잔액보다 크면, 출금을 못하도록 설정하였으나,

실행 과정에서 의도적으로 스레드가 동시에 접근할 수 있도록 하였다.

public class ThreadSyncEx {
    public static void main(String[] args) {
        Runnable thread = new CreateThread();
        // 2개의 작업 스레드 생성
        Thread thread1 = new Thread(thread);
        Thread thread2 = new Thread(thread);

        thread1.setName("스레드1");
        thread2.setName("스레드2");

        thread1.start();
        thread2.start();
    }
}

class Money {
    // 현재 가지고 있는 금액
    private int myMoney = 10000;

    public int getMyMoney() {
        return myMoney;
    }

    public boolean withdraw(int money) {
        // 가지고 있는 금액이 출금할 금액보다 크거나 같을 때만 출금
        if (myMoney >= money) {
            // 스레드 동시 접근을 위한 코드
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                System.out.println(e);
            }

            // 출금
            myMoney -= money;

            return true;
        }
        return false;
    }
}

class CreateThread implements Runnable {
    Money myMoney = new Money();

    public void run() {
        while (myMoney.getMyMoney() > 0) {
            // 1000 ~ 5000원씩 출금
            int money = (int)(Math.random() * 5 + 1) * 1000;

            // 출금 실행 코드. 실패시 true 반환
            boolean denied = !myMoney.withdraw(money);

            // 출금 과정 출력
            if (denied) {
                System.out.println("출금 거부");
            } else {
                System.out.printf("스레드: %s 출금: %d원  남은금액: %d원\\n",
                    Thread.currentThread().getName(), money, myMoney.getMyMoney());
            }
        }
    }
}
// 출력 (실행할 때마다 결과는 달라진다.)
스레드: 스레드1 출금: 5000원  남은금액: 5000원
스레드: 스레드2 출금: 2000원  남은금액: 5000원
스레드: 스레드2 출금: 1000원  남은금액: 4000원
출금 거부
스레드: 스레드1 출금: 1000원  남은금액: 3000원
스레드: 스레드2 출금: 2000원  남은금액: -2000원
스레드: 스레드1 출금: 3000원  남은금액: -2000원

결과를 살펴보면 스레드가 출금을 동시에 실시하여 남은 금액이 3000원임에도 불구하고, 2000원 출금과 3000원 출금이 동시에 발생하여 -2000원이 되는 상황이 발생했다.

 

스레드 동기화를 하지 않으면 실제 서비스나 프로그램을 이용할 때, 이러한 상황이 실제로 일어날 수 있다.

 

Synchronized(임계 영역 설정과 락 권한)

자바에서는 synchronized 키워드를 통해 메서드나 코드 블록에 작성하여 스레드 동기화를 할 수 있다.

먼저, 스레드 동기화를 하기 위해서는 임계 영역(Critical section) 락(Lock)에 대한 이해가 필요하다.

임계 영역은 둘 이상의 스레드가 동시에 접근해서는 안되는 코드 영역을 뜻한다. 다르게 표현하면 하나의 스레드만이 코드를 실행할 수 있는 영역이다.

락은 임계 영역을 포함하고 있는 객체에 접근할 수 있는 권한을 의미한다.

 

먼저, synchronized 키워드를 통해 동시 접근이 가능한 영역을 임계 영역으로 설정하여 동시 접근을 못하도록 설정한다.

스레드가 임계 영역에 접근하게 되면, 해당 스레드는 Lock을 얻게 된다. 이후 해당 스레드가 Lock을 반납하기 이전에는 다른 스레드는 해당 임계 영역에 접근하지 못하게 된다.

sychronized는 메서드 전체를 임계 영역으로 설정하는 방법과 특정 코드 블록을 임계 영역으로 설정하는 방법이 있다.

 

메서드 전체를 임계 영역으로 설정

synchronized 키워드를 임계 영역으로 지정할 메서드의 반환 타입 앞에 입력하여 메서드 전체를 임계 영역으로 설정할 수 있다.

설정한 메서드가 호출되었을 때, 메서드를 실행할 스레드는 메서드가 포함된 객체의 락(Lock)을 얻으며, 다시 락(Lock)을 반납하기 전까지는 다른 스레드는 해당 메서드를 실행하지 못한다.

class Money {
    private int myMoney = 10000;

    public int getMyMoney() {
        return myMoney;
    }

    // 메서드 전체를 임계영역으로 설정
    public synchronized boolean withdraw(int money) {
        if (myMoney >= money) {
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                System.out.println(e);
            }
            myMoney -= money;
            return true;
        }
        return false;
    }
}

 

특정 영역을 임계 영역으로 설정

임계 영역으로 지정할 코드 상단에 synchronized 키워드를 작성하고 소괄호() 안에 해당 영역이 포함된 객체의 참조를 입력하여 지정할 코드까지 중괄호{}로 묶으면 해당 영역으로 설정된다.

synchronized (객체의 참조) {
    ...
}

설정한 코드 블록에 스레드가 접근하면, 해당 스레드는 객체에 대한 락(Lock)을 얻고, 배타적으로 임계 영역 내의 코드를 실행하게 된다.

class Money {
    private int myMoney = 10000;

    public int getMyMoney() {
        return myMoney;
    }

    public boolean withdraw(int money) {
        // 메서드 전체를 임계영역으로 설정
        synchronized (this) {
            if (myMoney >= money) {
                try {
                    Thread.sleep(1000);
                } catch (Exception e) {
                    System.out.println(e);
                }
                myMoney -= money;
                return true;
            }
            return false;
        }
    }
}

 

이제 문제가 발생했던 예제 코드를 synchronized 키워드를 통해 동기화하여 개선할 수 있다.

public class ThreadSyncEx {
    public static void main(String[] args) {
        Runnable thread = new CreateThread();
        Thread thread1 = new Thread(thread);
        Thread thread2 = new Thread(thread);

        thread1.setName("스레드1");
        thread2.setName("스레드2");

        thread1.start();
        thread2.start();
    }
}

class Money {
    private int myMoney = 10000;

    public int getMyMoney() {
        return myMoney;
    }

    public synchronized boolean withdraw(int money) {
        if (myMoney >= money) {
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                System.out.println(e);
            }
            myMoney -= money;
            return true;
        }
        return false;
    }
}

class CreateThread implements Runnable {
    Money myMoney = new Money();

    public void run() {
        while (myMoney.getMyMoney() > 0) {
            int money = (int)(Math.random() * 5 + 1) * 1000;

            boolean denied = !myMoney.withdraw(money);

            if (denied) {
                System.out.println("출금 거부");
            } else {
                System.out.printf("스레드: %s 출금: %d원  남은금액: %d원\\n",
                    Thread.currentThread().getName(), money, myMoney.getMyMoney());
            }
        }
    }
}
// 출력
스레드: 스레드1 출금: 1000원  남은금액: 9000원
스레드: 스레드2 출금: 2000원  남은금액: 7000원
스레드: 스레드1 출금: 4000원  남은금액: 3000원
출금 거부
출금 거부
스레드: 스레드1 출금: 3000원  남은금액: 0원

잔액이 음수가 발생할 경우가 생기더라도 스레드 동기화로 인해 출금이 거부된다.

반응형