자바의 동시성

4 분 소요

업데이트:


동시성?

  • 자바는 멀티 쓰레드를 지원하는 언어이다.
  • 멀티 쓰레딩 프로그래밍에서는 기본적으로 고려해야 할 것이 바로 동시성 문제!
    • 즉, 동기화를 꼭 해주어야 한다.


Thread safe?

  • 멀티 쓰레드 프로그래밍에서 특정 자원에 대해 여러 쓰레드로부터 동시 접근이 이루어져도 프로그램의 실행에 문제가 없음을 의미.
  • 자바에서는 JVM내 Method 영역 내의 데이터나 인스턴스 변수와 같이 Heap 영역 내에 저장되는 데이터들은 여러 스레드들 간에 공유되는 자원이기 때문에 Thread safe한 자원이 아님.
    • 즉, Thread safe하지 않은 자원에 대해 동시성 문제를 발생시키지 않기 위한 동기화가 필요하다.
    • 그렇지 않으면, 해당 데이터의 안정성과 신뢰성을 보장할 수 없다.
  • 반면, 메서드 내에 선언되는 로컬변수는 호출 시, thread의 stack frame 내에 생성되는 데이터이며, 여러 쓰레드들간 공유할 수 없는 자원이기 때문에 Thread safe한 자원이다.


자바에서의 동기화 방법

  • synchronized
  • volatile
  • Atomic


Synchronized

  • 여러개의 쓰레드가 특정 자원을 사용하고자 할 때, 현재 데이터를 사용하고 있는 해당 쓰레드를 제외하고 나머지 쓰레드들은 데이터에 접근 할 수 없도록 하는 자바의 예약어.
  • 사용방법
    • synchronized methods
      • 메서드 자체를 synchronized로 선언하는 방법
      public synchronized void plus(int value) {
          amount += value;
      }
      
      • 동일한 객체에 대한 위의 메서드에 2개의 쓰레드가 접근하든 10개의 쓰레드가 접근하든 한 순간에는 하나의 쓰레드만 해당 메서드를 수행할 수 있다.
    • synchronized statements
      • 메서드 내의 특정 문장만 synchronized로 감싸는 방법
      public void plus(int value) {
          synchronized(this) {
              amount += value;
          }
      }
      
      • 이전 방식처럼 메서드에 synchronized를 추가하면 성능상 문제가 발생할 가능성이 크다.
        • 만약 메서드의 길이가 100줄인데 1줄에 대해서만 thread safe를 보장해 주면 되는 상황이라고 가정해보자.
        • thread safe를 보장해야할 1줄을 위해 나머지 99을 처리할 때, 필요없는 대기시간이 발생할 수 있다.
      • 그렇기 때문에 thread safe를 보장해야할 코드만 synchronized 블럭으로 감싸주면 보다 더 효율적인 프로그래밍이 가능하다.

Synchronized의 한계

  • 다만, synchronized는 synchronized에 해당하는 자원에 대해서는 병렬 프로그래밍을 제공하지 않기 때문에, 남발하면 그만큼 성능이슈를 발생시킬 수 있다.
  • synchronized는 대상 자원에 대해 Lock을 잡는 것이기 때문에 오버헤드가 있고, dead-lock의 문제를 일으킬 가능성 이 존재할수 있다는 치명적인 단점이 있다.
  • 그렇기 때문에 실무에서는 거의 사용되지 않는 방식이다.


volatile

  • Java에서 변수값을 메인메모리에 저장하겠다고 명시하는 키워드.
  • 매번 변수의 값을 read 할때마다 CPU의 cache에 저장된 값이 아닌 메인 메모리에서 읽는 것.
  • 또한, 변수 값을 write 할때마다 메인 메모리에까지 작성하는 것.
  • 이를 이해하기 위해서는 아래와 같이 CPU가 메인메모리에 접근하기 전에 성능향상을 위해서 cache를 사용한다는 것을 알아야 한다.

  • 즉, volatile 변수를 사용하고 있지 않는 multi thread 어플리케이션에서는 task를 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU cache에 저장한다.
    • 한 thread 작업 시 한번 main memory에서 read 해온 값은 CPU cache에 저장해두고, 이후 read 시 cache에 저장된 값을 read함.
  • 만약, 한 쓰레드가 변경된 값을 cache memory에서 메인메모리로 데이터를 저장하기 전에 다른 쓰레드에서 메인메모리의 해당 값을 읽어 변경되기 이전의 값을 처리한다면 data 불일치 문제가 발생한다.
    • 이러한 상황을 가시성 문제 라고 한다.
  • 이러한 가시성 문제를 해결할 수 있는 방법이 바로 volatile!
    • 즉, 가시성이 보장되어야하는 변수를 cache memory에서 읽는 것이 아니라, 메인메모리 에서만 읽도록 보장하는 것이다.

그렇다면 volatile로 가시성을 보장하면 모든 동시성 문제를 해결할 수 있을까?

  • 정답은 아니요! 다.
  • 다음과 같은 예시를 가정해보자.
    • 내가 만든 쇼핑몰 서비스(멀티 쓰레드를 이용함)가 있는데, 상품을 구매할때마다 해당 상품에 대한 puchaseCount를 늘려주어야 한다고 가정해보자.
    • 두명의 고객이 동시에 해당 상품을 구매했다고 가정해보자.
    • 이를 CPU와 메인메모리의 측면에서 보면 다음과 같을 것이다.

    • 메인메모리에서 puchaseCount가 0일때 두개의 쓰레드가 동시에 purchaseCount를 read하여 각각의 CPU cache에 저장한다.
    • 이후, 각각의 쓰레드에서 CPU cache에 저장된 purchaseCount를 +1을 해주는 로직을 수행한다.
    • 이후, main memory에 purchaseCount를 저장한 후 쓰레드가 종료된다.
    • 이 과정이 끝난이후 예상했던 purchaseCount는 2지만 1로 counting될 것이다.
      • 동시성 문제로 인해 데이터 일관성 깨지는 문제가 발생한다.
    • 이러한 동시성 문제는 애플리케이션의 심각한 장애로 이어질 수 있다.

그렇다면 volatile은 언제 사용할 수 있을까?

  • 한 스레드만 '쓰기'하고, 나머지 스레드는 '읽기'만 하는 상황에서는 volatile을 이용한 동시성 보장이 가능하다.
  • 다만, 여러 스레드가 동시에 데이터를 ‘쓰기’하는 경우는 다른 방법을 이용해서 아예 서로 다른 스레드가 동시에 실행되는 상황을 막아야한다.


Atomic과 CAS

  • Atomic은 CAS 방식을 기반으로 하며, multi thread 환경에서의 동시성 문제를 해결한다.
  • CAS는 Compare And Swap

그렇다면 CAS는 무엇일까?

  • CAS는 변수의 값을 변경하기 전에 기존에 가지고 있던 값이 내가 예상하던 값과 같을 경우에만 새로운 값을 할당하는 방법이다.
  • 코드로 예를 들면 다음과 같다.

        
    ...
        
    public class AtomicEx {
      int val;
          
      public boolean compareAndSwap(int oldVal, int newVal) {
          if(val == oldVal) {
              val = newVal;
              return true;
          } else {
              return false;
          }
      }
    }
        
    ...
        
    
  • 즉, CAS는 값을 변경하기 전에 한 번 더 확인하는 것이라고 볼 수 있다.
  • Java에서 제공하는 Atomic Type들은 이러한 CAS를 하드웨어(CPU)의 도움을 받아 한순간에 단 하나의 스레드만 변수의 값을 변경할 수 있도록 제공하고 있다.

CAS는 왜 쓰나?

  • CAS를 이용하면 synchronized와 달리 병렬성을 해치지 않으면서 동시성을 보장하기 때문에 더 좋은 성능을 가져올 수 있다.
  • 또한, volatile에서 발생할 수 있었던 가시성 문제도 해결할 수 있다.

CAS 사용 예

  • 자바의 concurrent 패키지의 타입들은 이렇게 현재 스레드에서 사용되는 값이 메인메모리의 값과 같은지 비교하고 불일치한다면 업데이트된 값(main memory의 값)을 가져와 계산하는 CAS 알고리즘을 이용해 데이터의 원자성을 보장하고, 좋은 성능을 보장한다.
    • 대표적으로 ConcurrentHashMap, AtomicInteger, AtomicBoolean등이 CAS를 이용하는 type들이다.

JAVA에서의 CAS 동작 예시

  • 위의 그림과 같이 JVM내의 thread scheduler에 의해서 각각의 core에 thread-1과 thread-2가 선점된 상태라고 가정해보자.
  • 각 쓰레드는 동시에 수행되면서, heap 내에 있던 동일한 count 값(0)을 read하여 각 cache에 로딩시킨다.
  • 그리고 각 쓰레드는 코드를 한줄씩 실행하면서 count 값을 1씩 증가시킨다.
    • count를 1씩 증가시키는 로직이 있다고 가정하자.
  • 이후, threaed-1에서 업데이트한 count 값(1)을 heap에 write 하려고 시도한다.
  • 이 과정에서 thread-1에서 처음 읽었을 때 heap의 count 값이었던 0과, 해당 시점에 heap에 저장돼있는 count 값을 비교한다.
  • 만약 같으면, heap에 1값을 write하고, 다르면 해당 시점에서 heap내의 count를 다시 새로 가져와서 또 다시 같은지 비교한다.
    • 무한 loop와 volatile 사용.
  • 값이 다르면 위 과정을 반복하고, 같으면 heap에 실제로 업데이트된 데이터를 write 한다.
  • thread-2에서도 마찬가지로 위의 과정들을 수행한다.


참고

댓글남기기