Java & Spring

변수 동기화 하기 - atomic, volatile, synchronized

밍 끄적 2023. 7. 4. 20:59
728x90

공유 자원

여러 스레드가 동시에 접근할 수 있는 자원

임계 영역

공유 자원들 중 여러 스레드가 동시에 접근했을 때 경쟁 상태 문제가 생길 수 있는 영역


경쟁 상태

둘 이상의 스레드가 공유 자원을 동시에(병행적으로) 읽거나, 쓰는 동작을 수행할 때,
실행하거나 접근했을 때의 타이밍이나 접근 순서에 따라 실행 결과가 달라지는 문제

 

 

경쟁 상태 대표적으로 두가지의 패턴의 상황에서 발생한다.

패턴 1. Read - Modify - Write

아래 예제를 보면, cnt라는 변수의 값이 increase() 메소드를 통해 1씩 증가한다.

 

이 메소드를 100개의 요청이 동시에 수행된다면 우리가 기대하는 값은 100이다.

public int cnt;

public void increase(){
    cnt++;
}

하지만, 실제 수행 결과는 100보다 작은 값이 된다.

 

원인은 각 Read, Modify, Write한 사이 사이에 다른 스레드가 Read, Modify, Write를 수행했기 때문이다.

단순히 cnt++;를 수행했지만, 내부적으로는 기존의 값을 읽고(Read), + 1 하여 변경하고(Modify), 그 값을 반영하는(Write) 과정을 거친다.

 

그리고 여러 요청이 동시에 수행되면서, 먼저 수행된 스레드의 변경사항이 반영되기 전에 다른 스레드가 값을 읽었기 때문에, 경쟁상태가 발생하였고, 의도하지 않은 값이 도출된 것이다.

 

패턴 2. Check - then - act

아래 예제를 보면, cnt라는 변수의 값이 increaseAndCheck() 메소드를 통해 1씩 증가하고, cnt 값이 50 이하라면 그때의 cnt 값을 리턴한다. 50을 초과했다면 0을 리턴한다.

 

이 메소드를 100개의 요청이 동시에 수행된다면 우리는 50개의 요청은 0~50을 리턴받는 것을 예상할 것이다.

public int cnt;

public int increaseAndCheck(){
    cnt++;
    if(cnt <= 50){
        Thread.sleep(1);
        return cnt;
    }
    return 0;
}

하지만, 실제 수행 결과를 보면 50을 초과하는 값을 리턴받는 경우가 있다.

 

원인은 각 Check, then act한 사이 사이에 다른 스레드가 값을 변경했기 때문이다.

 

어떠한 스레드에서 Read,Modify,Write을 거쳐 cnt의 값을 변경했고, 그 값은 if문을 통해 검증과정을 거쳐(Check) if문 블럭 내에서 처리하게 되었다.

이때, Check 후 cnt를 리턴하기(then act) 전에 다른 스레드에서 cnt의 값이 변경되었다면 변경된 cnt의 값을 리턴하게된다.

 

먼저 수행된 스레드가 Check 후 then act하기 전에 다른 스레드가 값을 변경했기 때문에, Check 후 무결성이 깨져서, 의도하지 않은 값이 도출된 것이다.

이러한 경쟁 상태 문제를 해결하기 위해서는, 원자성과 가시성이 보장되어야한다.


원자성과 가시성

원자성

원자성을 보장해야한다

⇒ 공유 자원에 대한 작업의 단위가 더이상 쪼갤 수 없는 하나의 연산인 것처럼 동작해야한다.

 

Read, Modify, Write은 원자성이 보장되지 않은 것이다. 내부적으로 작업의 단위가 3개로 쪼개지기 때문이다.

Check, then act도 원자성이 보장되지 않은 것이다. 내부적으로 작업의 단위가 2개로 쪼개지기 때문이다.

 

원자성이 보장되면, 어떠한 스레드에 대해 다른 스레드가 개입할 수 가 없다. ( 개입할 틈이 없다! )

따라서 경쟁 상태를 방지할 수 있다.

가시성

가시성을 보장해야한다

⇒ 멀티 스레드 환경에서 하나의 스레드가 변수 값을 변경하면, 다른 스레드들이 변수 값을 변경한 것을 즉시 알 수 있어야한다.

 

visibility라는 의미에서 생각해보면, 메모리가 항상 최신 값을 볼 수 있게 하자는 것이다.

 

각 스레드는 메인 메모리로부터 변수 값을 읽어서, 각 스레드에 할당되어있는 CPU 캐시에 값을 담아둔 채 변경하는 등의 연산을 모두 수행한 뒤에, 최종적인 CPU 캐시의 값을 메인 메모리에 반영한다.

이 때, 어떤 스레드에서 CPU 캐시에 저장된 값을 변경했다면, 다른 스레드도 변경된 값을 사용해야한다. 그러면 변수의 값은 일관되게 된다.

따라서 경쟁 상태를 방지할 수 있다.

 

아래 예제는, 가시성이 보장되지 않아, 다른 스레드가 변경된 값을 사용하지 못해 경쟁상태 문제가 발생한 경우를 나타낸다.


volatile : 변수의 가시성을 보장하는 방법

이런 가시성문제를, volatile 으로 변수를 선언함으로써 보장할 수 있다.

 

volatile 키워드를 사용한 변수는 별도의 CPU 캐시로 옮겨서 연산하는게 아니라, 메인 메모리에서 값을 읽어 CPU에서 연산하고 바로 메인 메모리에 반영한다.

flush라는 CPU Cache에서 메인메모리로 반영하는 과정 없이, 바로 변경 사항이 메인 메모리로 반영된다.

 

따라서 메인 메모리에서는 항상 최신 값을 볼 수 있기에, 가시성을 보장할 수 있다.

volatile을 써도 경쟁 상태 문제는 발생한다.

volatile을 쓰면 최신 값을 항상 사용할 수 있지만, 동시에 값을 읽어서 동시에 반영하는 경우를 생각해보면 경쟁 상태 문제는 여전히 발생하는 것을 알 수 있다.

 

아래 예제와 같은 경우인 것이다.

cnt 라는 변수에 volatile을 적용했다.

public volatile int cnt;

그리고 두개의 스레드가 동시에 cnt 변수에 접근해 값을 변경했는데, 2의 결과를 예상했지만 동시에 결과를 반영했기에 1이라는 결과가 도출되었다.

 

즉, volatile 키워드는 스레드 안정성을 보장하지 않으므로, 여러 스레드가 동시에 값을 읽고 쓸 때 발생하는 문제를 해결할 수 없다.

 

이 경우에는, synchronized 블록을 사용해야한다.

synchronized를 통해 해결하기

synchronized 키워드는 특정 블록을 동기화하여 여러 스레드가 동시에 접근하지 못하도록 한다.

synchronized 키워드를 사용하면 오직 한 스레드만이 해당 블록에 접근할 수 있으며, 다른 스레드들은 블록에 접근하기 위해 무한정 기다려야 한다.

 

아래 예제는 volatile로도 경쟁 상태 문제가 해결되지 않은 경우를 나타낸다.

count 변수는 volatile 키워드로 선언되어 있다.

따라서, count 변수의 값을 읽고 쓰는 메서드는 항상 메인 메모리에서 값을 읽고 쓰게 된다.

public class Example {
    private volatile int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

여러 스레드가 increment() 메서드를 동시에 호출하는 경우를 생각해보자.

먼저, 어떤 스레드가 비정상적으로 count 변수의 값을 증가시켜, READ, MODIFY, WRITE하는 과정이 느려졌다고 가정하자.

그 스레드는 그럼에도 불구하고, volatile로 인해 다른 스레드로 인해 반영된 최신의 값을 받을 수 있다.

 

그러나, 여러 스레드가 increment() 메서드를 동시에 호출해, 동시에 수행을 마친다면, count 변수의 값이 정상적으로 증가하지 않을 것이다.

10개의 스레드가 동시에 메소드를 호출해 동시에 수행을 마친다면, count의 값은 1일 수도 있다.

 

이때 synchronized 키워드를 사용하면, increment() 메서드에 대한 동기화를 보장하여 count 변수의 값이 항상 정확하게 증가하도록 할 수 있다.

public class Example {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

위 코드에서 increment() 메서드는 synchronized 키워드로 선언되어 있다.

이 경우, 오직 한 스레드만이 increment() 메서드에 접근할 수 있으며, 다른 스레드들은 해당 메서드에 접근하기 위해 무한정 기다려야 한다.

따라서 count 변수의 값이 항상 정확하게 증가할 수 있다.

 

하지만, volatile은 락을 걸거나 다른 스레드를 blocking하지 않으므로 synchronized 보다 성능상 훨씬 우수하다.

따라서, 스레드의 안정성이 보장되지 않아도 되는 조건이라면, volatile을 쓰는 것이 좋다.


atomic : 변수의 원자성을 보장하는 방법

atomic은 원자성을 보장하는 일련의 연산들을 한 덩어리로 묶어서 처리해주는 방식이다.

 

이 방식은 하드웨어 수준에서 지원되며, 즉 컴파일러에서 구현되지 않는다.

 

자바에서는 java.util.concurrent.atomic 패키지에서 제공하는 클래스를 사용해 atomic 연산을 구현할 수 있다.

atomic 연산의 원리 : CAS

atomic 클래스에서 제공하는 연산들은 메모리에서 값을 읽어올 때, 다른 스레드가 해당 값을 변경하는 것을 막기 위해 락을 거는 것이 아니라, 하드웨어에서 지원하는 원자성 연산을 사용한다.

 

이 원자성 연산이 바로 CAS ( Compare And Swap ) 방식이다.

 

변수의 값을 변경하기 전에, 기존에 가지고 있던 값이 예상하는 값과 같을 경우에만 새롭게 반영할 값으로 할당하는 방법이다.

public class AtomicExample {
    int val;

    public boolean compareAndSwap(int oldVal, int newVal) {
        if(val == oldVal) {
            val = newVal;
            return true;
        } else {
            return false;
        }
    }
}

 

이러한 CAS를 원리를 하드웨어에서 지원하기 때문에, 다른 스레드가 해당 값을 변경하는 경우, 변경된 값을 무시하고 실행하게 된다.

 

따라서 원자성을 보장하면서 다른 스레드에 의한 값의 변경을 막을 수 있다.


atomic vs volatile vs synchronized

atomic

원자성을 보장하는, 일련의 연산들을 한 덩어리로 묶어서 처리해주는 방식

  • 컴파일러에서 구현되지 않으며 하드웨어에서 지원됨
  • java.util.concurrent.atomic 패키지에서 제공하는 클래스 사용

volatile

가시성을 보장하는, 항상 최신의 값으로 사용할 수 있게 하는 방식

  • 변수가 volatile로 선언될 경우, write 시 flush를 하여 다른 스레드에서 값이 변경되었는지 인지할 수 있도록 함
  • thread-safe를 보장하지 않음

synchronized

  • thread-safe를 보장하는 방법
  • 동기화된 블록 내에서의 작업은 오직 하나의 thread만이 수행할 수 있음
  • 다른 thread는 해당 블록에 진입하지 못하고 대기하게 됨
  • 다중 thread에서의 성능 문제가 발생할 수 있음

요약

atomic은 성능면에서 뛰어나지만, 단순한 변수의 값을 읽어오거나 대입하는 경우에만 사용하는 것이 좋다.

 

volatile은 성능면에서 뛰어나지만, thread-safe를 보장하지 않으므로 변수의 값을 읽어오거나 대입하는 경우에 사용하는 것이 좋다.

 

synchronized는 변수의 값을 읽어오거나 대입하는 것뿐만 아니라, thread-safe를 보장하는 경우에 사용하는 것이 좋다.


레퍼런스

https://readystory.tistory.com/53

https://rightnowdo.tistory.com/entry/JAVA-concurrent-programming-Visibility가시성?category=396739

https://www.youtube.com/watch?v=-E54FmEr95A

728x90