김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 : synchronized

jkky98·2024년 9월 3일
0

Java

목록 보기
38/51

메모리 가시성으로 해결되지 않는 문제

public class BankMain {

    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccountV1(1000);

        Thread t1 = new Thread(new WithdrawTask(account, 800), "t1");
        Thread t2 = new Thread(new WithdrawTask(account, 800), "t2");

        t1.start();
        t2.start();

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();

        log("최종 잔액: " + account.getBalance());
    }
}

public class BankAccountV1 implements BankAccount {

    private int balance;

    public BankAccountV1(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());
        // 잔고가 출금액 보다 적으면, 진행하면 안됨
        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }
        // 잔고가 출금액 보다 많으면, 진행
        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000); // 출금에 걸리는 시간으로 가정
        balance = balance - amount;

        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        return balance;
    }
}

위의 코드에서,

두 쓰레드(출금 작업)가 하나의 BankAccount 인스턴스를 사용한다. 쓰레드의 run()은 단순히 withdraw를 실행하는 것이다. withdraw 로직은 balance보다 amount가 같거나 작을 경우에만 출금이 진행되는 로직이다. 즉 출금 로직 내부에는 balance보다 amount가 같거나 작을 경우를 검증하는 로직이 존재한다.

두 쓰레드가 동시에 withdraw를 실행하면, 두 쓰레드는 매우 작은 시간차에 한 쓰레드가 먼저 혹은 두 쓰레드가 동시에 검증 로직을 거친다.

검증 로직은 balance를 조회한다, 이 때 BankAccount의 balance는 두 쓰레드의 출금로직 내부에서 먼저 1000으로 인식한다.

  • A쓰레드의 출금 로직 : 기존 잔금 1000원 인식
  • B쓰레드의 출금 로직 : 기존 잔금 1000원 인식
  • A쓰레드의 출금 : 1000원에서 800원 출금 -> 200원 남음
  • B쓰레드의 출금 : 200원에서 800원 출금 -> -600원 남음

즉 위와 같은 상황이 발생 가능하다.

매우 작은 기간에 벌어질 수 있는 일이다. 헬스장에서 A 헬스 기구(공유 변수)의 사용자가 없는 것을 인식하고 A 헬스 기구를 사용할 것을 판단하고 실행에 옮기려는 순간 누군가가 그것을 실행해버린 것이다. 사용자가 없는 것이라는 정보는 그 순간 유효하지 않은 정보가 된다. 인간은 이에 대해 유연하게 생각을 할 수 있지만 코드는 그렇지 않다. 코드라면 A 헬스 기구 사용자가 여전히 없다 판단하고 실행에 옮긴다.

공유변수 문제가 발생하는 것이다. 위의 코드에서 sleep(1000)을 통해 검증과 출금의 간격을 두었다. 정상적인 프로세스에서 의도했던 바는 스레드1이 입출금을 시도하고, 스레드2가 입출금을 시도하는 것이다.

balance에 volatile을 부여해서 이 문제를 해결할 수 있을 것처럼 보일 수도 있지만 그렇지 않다.

volatile의 사용목적은 A쓰레드가 B쓰레드의 상태변화를 곧바로 인지하게 하는 것에 있다.

volatile은 메모리 가시성 문제로, 캐시 메모리가 아닌 일반 메모리를 참조하도록 하는것이다. 즉 어떤 스레드가 공유 변수를 변화시켰을 때 다른 스레드에서 이것을 즉각적으로 인식하느냐에 대한 문제이다.

하지만 동시성 문제는 동시에 접근하는 것 자체가 개발자의 의도를 망치는 것에 의미가 있다.

객체A가 가진 true라는 속성 값을 객체 B가 의존하고 있는데 객체 A가 true를 false로 바꿨음에도 객체 B가 인지하지 못하는 것이 메모리 가시성 문제고 이를 volatile로 해결가능하다는 것이다.

우리가 예시로 든 출금문제는 이렇게 즉각적으로 상태변화를 인지하더라도 발생하는 문제인 것이다.

volatile은 공유변수가 바뀌면 바뀐 상태가 곧바로 다른 곳에서도 확인되도록 하는 해결책이며, 동시성 문제는 개발자의 로직이 공유변수에 동시에 접근하는 문제 때문에 의도와 달라지는 문제를 일컫는다.

임계 영역(critical section)

여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 중요한 코드 부분을 말한다.

위 코드의 withdraw() 로직에서는 balance를 다루는 모든 영역이 임계 영역에 해당한다. 이러한 공유 변수를 사용하는 메서드가 있다면 그 메서드를 어떤 쓰레드가 호출 도중에는 다른 쓰레드의 접근을 막아야 한다.

자바는 synchronized 키워드를 통해 임계영역에 해당하는 메서드를 보호하도록 한다.

synchronized

@Override
    public synchronized boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());
        // 잔고가 출금액 보다 적으면, 진행하면 안됨
        log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }
        // 잔고가 출금액 보다 많으면, 진행
        log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
        sleep(1000); // 출금에 걸리는 시간으로 가정
        balance = balance - amount;

        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        log("거래 종료");
        return true;
    }

    // 누군가 getBalance()중일 때 다른 스레드가 getBalance()할 수 없음.
    @Override
    public synchronized int getBalance() {
        return balance;
    }

balance를 사용하는 두 메서드에 synchronized 키워드를 붙여 사용하면 끝이다.

자바의 인스턴스(객체)는 힙 영역에 보관된다.

이 인스턴스는 db에서의 개념과 비슷한 자신만의 lock을 가진다.

synchronized 키워드는 인스턴스에 lock이 존재한다면(누구도 호출하지 않은 시점엔 lock이 존재한다.) 그것을 가져와 로직을 태운 후 메서드 로직이 끝나면 이것을 인스턴스에 돌려준다. 그때까지 다른 스레드의 해당 클래스의 synchronized 메서드는 실행이 불가능하다.(lock을 얻지 못하니까) 쉽게 말해 synchronized 키워드는 lock을 얻어야 실행이 가능하다는 조건이며 lock이 인스턴스에 없다면(누군가가 쓰고 있다면) 무제한 대기한다.

또한 synchronized는 메모리 가시성 문제도 같이 해결해준다.

synchronized는 분명 동시성 문제를 해결해주지만 엄밀히 말하면 멀티쓰레드의 행동을 잠시 중단하는 것이므로 속도가 느려진다. 8차선 고속도로를 달리는 자동차들이 일시적인 1차선 고속도로를 들어오는 것과 마찬가지이다. 그러므로 정말 필요한 곳에만 사용해야 한다.

락을 획득하는 순서

8차선 도로에서 1차선 도로를 통과하기 위해 차들이 밀린다. 보통은 더 가까운 차들부터 빠져나가지만 자바의 synchronized는 이러한 순서를 보장하지 않고 운영체제의 스케줄링에 의존한다. 락을 획득한 스레드는 RUNNABLE상태로, 락을 기다리는 스레드는 BLOCKED 상태가 된다.

@Override
 public boolean withdraw(int amount) {
 	log("거래 시작: " + getClass().getSimpleName());
 	synchronized (this) {
 		log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
 		if (balance < amount) {
 		log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
 		return false;
 		}
 	log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
	sleep(1000);
	balance = balance - amount;
 	log("[출금 완료] 출금액: " + amount + ", 변경 잔액: " + balance);
 	}
 log("거래 종료");
 return true;
 }

정말 필요한 곳에서만 사용하기 위해 위의 코드처럼 synchronized 블록을 이용할 수 있다.

바보같이 synchronized를 적용하는 경우

public class SyncTest2Main {
    public static void main(String[] args) throws InterruptedException {
        MyCounter myCounter = new MyCounter();
        Runnable task = new Runnable() {
            @Override
            public void run() {
                myCounter.count();
            }
        };
        Thread thread1 = new Thread(task, "Thread-1");
        Thread thread2 = new Thread(task, "Thread-2");
        thread1.start();
        thread2.start();
    }
    static class MyCounter {
        public void count() {
            int localValue = 0;
            for (int i = 0; i < 1000; i++) {
                localValue = localValue + 1;
            }
            log("결과: " + localValue);
        }
    }
}

두 쓰레드가 존재하고 얼핏 보면 int localValue를 공유하는 것 처럼 보이지만 그렇지 않다. 두 쓰레드의 run()은 count()를 실행한다. 어떤 메서드를 동시에 실행하는 것에 문제점은 없으며 localValue는 지역변수에 해당한다. 지역변수는 서로의 쓰레드의 스택영역에 존재하기 때문에 synchronized를 적용할 필요가 없다.

만약 localValue가 객체의 필드라면 이야기가 달라지지만 말이다.

두 쓰레드가 동시에 0부터 1000까지 1씩 더해서 결과를 찍는 행위이기 때문에 두 쓰레드가 동시에 실행하여 하나의 쓰레드에서 두 번 실행하는 것 보다 빠르게 결과를 낼 수 있다.

즉 위의 count메서드는 임계영역에 해당하지 않으므로 동시성 문제가 존재하지 않는다.

profile
자바집사의 거북이 수련법

0개의 댓글