[Java] 쓰레드 세이프(Thread-safe)와 동기화

sewonK·2022년 3월 29일
2
post-custom-banner

0. Thread-Safe란?

자바의 Hashmap, HashTable, CuncurrentHashmap의 차이점을 공부하다가 "Thread-safe"라는 개념을 접하였습니다.

싱글쓰레드 프로세스의 경우 프로세스 내에서 단 하나의 쓰레드만 작업하기 때문에 프로세스의 자원을 가지고 작업하는데 문제가 없지만, 멀티쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 됩니다. Thread-safe란 멀티 스레드 프로그래밍에서 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 없는 것을 의미하며, 이를 위한 방법으로 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 "임계 영역"과 "잠금"의 개념이 도입되었습니다.

  1. 공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정하고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 영역 내의 코드를 수행할 수 있게 합니다.
  2. 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역 내의 코드를 수행할 수 있도록 합니다.

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

1. Synchronized 를 이용한 동기화

synchronized 키워드를 이용한 동기화 방법에는 두 가지가 있습니다.

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

쓰레드는 synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환합니다.

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

메서드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 synchronized를 붙이는 것입니다. 이 때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 합니다. 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블럭을 벗어나면 lock을 반납합니다.

두 방법 모두 lock의 획득과 반납이 모두 자동적으로 이루어지므로 우리는 임계 영역만 설정해주면 됩니다. 임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에, 가능하다면 메서드 전체에 락을 거는 것보다 synchronized블럭으로 임계 영역을 최소화하여 보다 효율적인 프로그램이 되도록 노력해야합니다.

1-1. Synchronized 예제

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

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
    	//잔고(balance)가 출금하려는 금액(money)보다 큰 경우에만 출금하도록 함
        if(balance >= money) {
        	//Thread.sleep(1000)을 사용하여 if문을 통과하자마자 다른 쓰레드에게 제어권을 넘기도록 함
            try { Thread.sleep(1000);} catch(InterruptedException e) {}
            balance -= money;
        }
    }
}

class RunnableExample implements Runnable {
    Account acc = new Account();

    public void run() {
        while(acc.getBalance() > 0) {
        	//100, 200, 300 중 random 값 출금
            int money = (int)(Math.random() * 3 + 1) * 100;
            acc.withdraw(money);
            System.out.println("balance:"+acc.getBalance());
        }
    }
}

다음은 위의 코드를 실행한 결과 값입니다.

balance:700
balance:600
balance:400
balance:400
balance:100
balance:-100

Random한 값을 출금하기 때문에 실행할 때마다 결과는 달라지겠지만, 주의 깊게 볼 부분은 balance 값이 음수가 되는 부분입니다. 분명 withdraw 함수에서 잔고가 출금하려는 금액보다 큰 경우에만 출금이 가능하도록 하였는데 어떻게 된 일일까요?

if 조건식을 통과하고 출금하기 바로 직전에 다른 쓰레드가 끼어들어 먼저 출금을 했기 때문입니다.

쓰레드 A가 if문을 조건식을 계산할 때에는 잔고가 400이고 출금하려는 금액이 200이었습니다. 그러나 출금 로직을 수행하려는 순간 쓰레드 B에게 제어권이 넘어가 300을 출금하여 잔고가 100이 되었습니다. 다시 쓰레드 A로 제어권이 넘어오면 잔고가 100인 상태로 200을 출금하게 되기 때문에 결국 -100이 되는 것입니다.

이러한 문제는 잔고를 확인하는 if문과 출금하는 문장을 하나의 임계 영역으로 묶어 해결할 수 있습니다.

1. 메서드 전체를 임계 영역으로 지정
    public synchronized void withdraw(int money) {
        if(balance >= money) {
            try { Thread.sleep(1000);} catch(InterruptedException e) {}
            balance -= money;
        }
    }

메서드 전체를 임계 영역으로 지정하면, 이 메서드가 종료되어 lock이 반납될 때까지 다른 쓰레드는 withdraw()를 호출하더라도 대기 상태에 머물게 됩니다.

2. 특정한 영역을 임계 영역으로 지정
    public void withdraw(int money) {
        synchronized (this) {
            if (balance >= money) {
                try {Thread.sleep(1000);} catch (InterruptedException e) {}
                balance -= money;
            }
        }
    }

이를 실행해보면, 전과 달리 결과에 음수 값이 나타나지 않는 것을 확인할 수 있습니다.

balance:700
balance:500
balance:400
balance:300
balance:100
balance:100
balance:100
balance:100
balance:0
balance:0

2. Volatile 을 이용한 동기화

synchronized 키워드를 남용할 경우 lock 이 걸리는 쓰레드가 많아지고, synchronized 메서드 혹은 로직에 대한 병목현상이 발생하기 쉬워 성능상 이슈가 발생할 수 있습니다. volatile은 원자성은 보장할 수 없지만, 가시성을 보장하는 방법입니다.

💡 원자성? 가시성?

  • 원자성이란 소스코드가 한번에 실행된다는 것을 보장하는 것입니다. 명령의 최소 단위임을 의미하며 해당 명령이 수행되는 동안 다른 쓰레드에서 접근이 불가능하여 동시 접근 문제를 보장할 수 있습니다.
  • 가시성이란 한 쓰레드에서 공유 자원을 변경한 결과가 다른 쓰레드에서 확인할 수 있는 것을 의미합니다.

volatile 키워드를 사용하면 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오게 됩니다. 따라서 캐시와 메모리 간의 불일치가 해결되며, 같은 공유자원에 대해 같은 값을 가지게 되어 가시성을 보장하게 됩니다.

그러나 동시 접근 문제는 해결할 수 없습니다. 여러 쓰레드에서 Main Memory 에 있는 공유 자원에 동시에 접근 할 수 있기 때문에, 여러 쓰레드에서 수정이 발생할 경우 연산이 느린 쓰레드의 계산 값으로 덮어씌워지는 등의 문제가 발생할 수 있습니다.

volatile은 Multi Thread 환경에서 하나의 Thread만 read & write하고 나머지 Thread가 read하는 상황에서 사용하기에 적절한 키워드입니다.

3. Atomic 을 이용한 동기화

Atomic 변수는 원자성을 보장하는 변수라는 의미로, 기존에 원자성을 보장하였던 synchronized 키워드의 성능 저하 문제 를 해결하기위해 고안된 방법입니다. Atomic 변수의 경우 CAS (Compare And Swap)알고리즘을 통해 동작합니다.

CAS 알고리즘이란 현재 쓰레드가 존재하는 CPU 의 CacheMemory 와 MainMemory 에 저장된 값을 비교하여, 일치하는 경우 새로운 값으로 교체하고, 일치하지 않을 경우 기존 교체가 실패되고, 이에 대해 계속 재시도를 하는 방식입니다. 자세한 내용은 Hash자료구조를 통해 동시성 알아보기를 참고해주세요.

volatile 키워드로 가시성을 보장하고, CAS 알고리즘을 통해 원자성을 보장하도록 만든 비동기 방식이 Atomic변수입니다. 따라서 Atomic 변수는 synchronized 키워드처럼 동시접근 문제와 가시성 문제 모두 해결할 수 있는 방법입니다.

참고

Java의 정석 3rd Edition
synchronized 와 volatile 그리고 Atomic
Atomic연산, CAS(CompareAndSwap)에 대하여, ABA문제
[Java] 동기화 - synchronized와 volatile, Atomic Class에 대하여

post-custom-banner

0개의 댓글