자바의 concurrency API에서 제공하는 Atomic Type에 대해 정리해보려고 한다.

Atomic Type을 사용하면 멀티 쓰레드 환경에서 최신 데이터임이 보장되는 변수를 선언할 수 있다.

 


Atomic Access

API를 소개하기 전 먼저 Atomicity(원자성)의 개념을 알아보자.

Atomic은 한글로 번역하면 '원자'라는 의미를 가지고 있다. 원자는 '더 쪼갤 수 없는 가장 작은 단위'를 뜻한다.
화학에서나 봤던 원자의 개념이 프로그래밍에서도 적용되고 있다.
Oracle 자바 레퍼런스에 나와있는 Atomic Access의 정의는 다음과 같다.

 

In programming, an atomic action is one that effectively happens all at once.
An atomic action cannot stop in the middle: it either happens completely, or it doesn't happen at all.

 

모 아니면 도로 처음부터 끝까지 완전히 수행되던가, 아니면 아예 아무것도 수행되지 않아야 하는 Action이라고 한다.
글로만 읽었을 땐 중간에 멈춰선 안 되는 연산이 왜 필요한건가 싶다.

 

쇼핑몰에서 물건을 주문하는 경우를 생각해보자. '결제'와 '상품 수량 변경'은 서로 다른 작업이지만 반드시 한 세트로 진행되어야 한다. 될 거면 둘 다 되던가, 안될 거면 둘 다 안돼야 한다.

 

어떤 상품의 재고가 1개밖에 없는데, 해당 상품을 사려는 고객이 2명 존재하는 상황을 생각해 보자.

아래와 같은 순서로 요청이 들어오면 어떻게 될까?

1) 1번 회원 결제 성공

2) (상품 수량 업데이트가 아직 안 끝났는데!) 2 회원도 결제 성공

3) 상품 수량 변경 (재고가 0개가 되었다)

4) 상품 수량 변경 (2번 회원은 이미 돈을 지불했는데 재고가 없다?!)

 

회원이 딱 한명밖에 없어서 '1번 회원 결제 → 상품 수량 변경  1번 회원 결제  상품 수량 변경'처럼 작업이 하나씩 순서대로 진행되면 좋겠지만, 현실에서는 많은 사람들이 동시다발적으로 동일한 제품을 구입하려는 상황이 부지기수다.

 

이 작업이 정상적으로 수행되려면 1번 회원 결제 결과가 재고 수량에 반영되기 전 2번 회원의 결제 시도는 잠시 미뤄놔야 한다. 그리고 2번 회원의 작업이 시작되려 할 때 재고를 확인하고 품절이 되었다면 아예 결제부터 불가능하게 막아야 한다.

이 외에도 '수강 신청→출석부 갱신', '결제→좌석 예약'처럼 실행 결과가 다른 작업에 영향을 줄 수 있는 작업이 일부만 수행될 경우 데이터에 결함이 생길 수 있다.

 

하나의 쓰레드가 모든 작업을 순차적으로 처리한다면, 작업 간 공유하는 데이터가 틀어지는 일은 생기지 않을 것이다. 하지만 많은 양의 요청을 단일 쓰레드로 느긋하게 처리하면 사용자는 인내심을 시험받게 되므로... 보통은 멀티쓰레드가 사용된다.

여러 개의 작업을 쪼개서 번갈아가며 실행하는 멀티 쓰레드 환경에서 비 원자 연산이 돌아가면 위와 같은 문제가 생길 수 있다. 이처럼 작업 단위가 분리되면 안되는 연산 Atomic operation이 필요하다.


멀티쓰레드 환경에서 동시성 문제를 제어할 수 있도록 Java는 여러 가지 형태로 Atomic operation을 지원한다.

대표적으로 volatile, synchronization, Atomic 세 가지가 있는데, Atomic Type의 경우 예약어에 해당하는 앞의 두 개와 달리 java.util.concurrent.atomic 패키지에 정의된 클래스다. 이번 글에선 바로 요 Atomic Type을 다뤄볼 예정이다.

 

Atomic Type

Atomic Type 단일 변수에 대해서 Atomic Operations을 지원한다.

Wrapping 클래스의 일종으로, 참조 타입과 원시 타입 두 종류의 변수에 모두 적용이 가능하다. 사용 시 내부적으로 Compare-And-Swap(CAS) 알고리즘을 사용해 lock 없이 동기화 처리를 할 수 있다. 사용법은 매우 간단하다. 변수를 선언할 때 타입을 Atomic 타입으로 선언 해주면 된다.

 

주요 Class

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong
  • AtomicIntegerArray
  • AtomicDoubleArray

 

주요 Method

  • get() : 현재 값을 반환한다.
  • set(newValue) : newValue로 값을 업데이트한다.
  • getAndSet(newValue) : 원자적으로 값을 업데이트하고 원래의 값을 반환한다.
  • compareAndSet(expect, update) : 현재 값이 예상하는 값(=expect)과 동일하다면 값을 update 한 후 true를 반환한다. 예상하는 값과 같지 않다면 update는 생략하고 false를 반환한다.
  • Number 타입의 경우 값의 연산을 할 수 있도록 addAndGet(delta), getAndAdd(delta), getAndDecrement(), getAndIncrement(), incrementAndGet() 등의 메서드를 추가로 제공한다.

사용 예제

사용법을 알아보기 위해 가장 흔하면서도 이해하기 쉬운 예제를 작성해보자.
MyLock 클래스에는 여러 개의 쓰레드가 lock을 얻기 위해 경쟁할 때 lock의 상태를 관리하는 로직이 정의되어 있다. 아래의 코드에서 개발자가 의도한 동작은 여러개의 쓰레드 중 오직 하나의 쓰레드만 lock을 얻는 것이다.

class MyLock {
    private boolean locked = false;

    public boolean tryLock() {
        if (!locked) {
            locked = true;
            return true;
        }
        return false;
    }
}

실행되는 순간 lock 획득을 시도하는 Runnable의 구현체를 정의했다.

class TestRunnable implements Runnable {
    private final MyLock myLock;

    public TestRunnable(MyLock myLock) {
        this.myLock = myLock;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " - " + myLock.tryLock());
    }
}

main에선 하나의 Lock 객체를 공유하는 10개의 쓰레드를 생성하고 실행한다.

public class Main {
    public static void main(String[] args) {
        final MyLock myLock = new MyLock(); // shared resource

        for (int i = 0; i < 10; i++) {
            new Thread(new TestRunnable(myLock)).start();
        }
    }
}

그런데 실행해보니 코드가 워낙 간단하기도 하고, 요즘 컴퓨터는 CPU 성능이 좋아서 그런가 예상과 달리 동작이 잘 된다..? 🧐 tryLock이 더 값비싼 작업이 되도록 10만 번 돌아가는 반복문을 한 줄 추가해보았다.

class MyLock {
    private boolean locked = false;

    public boolean tryLock() {
        if (!locked) {
            // 비용이 큰 작업을 수행한다
            for (int i = 0; i < 100_000; i++) { }

            locked = true;
            return true;
        }
        return false;
    }
}

실행결과는 아래와 같다. 실행할 때마다 결과가 매번 다르고, 코드를 작성했을 때의 의도와 달리 lock을 획득한 쓰레드가 여러 개 존재한다.

  • 첫 번째 실행 결과

    Thread-0 - true
    Thread-2 - true
    Thread-3 - false
    Thread-1 - true
    Thread-6 - false
    Thread-7 - false
    Thread-5 - false
    Thread-4 - false
    Thread-8 - false
    Thread-9 - false
  • 두 번째 실행 결과

    Thread-0 - true
    Thread-5 - false
    Thread-4 - false
    Thread-3 - true
    Thread-1 - true
    Thread-2 - true
    Thread-6 - false
    Thread-7 - false
    Thread-8 - false
    Thread-9 - false

     

이 문제를 해결하려면 tryLock의 원자성이 보장되어야 한다.

변수를 선언할 때 타입을 boolean에서 AtomicBoolean으로 바꿔보자.

locked가 false일 때만 값을 true로 변경하고, 이미 값이 true라면 set은 생략한다.

compareAndSet 내부에서 값을 원자적으로 갱신하기 때문에 동시성 문제가 심플하게 해결된다.

class MyLock {
    private AtomicBoolean locked = new AtomicBoolean();

    public boolean tryLock() {
        if (!locked.get())  {
            // 비용이 큰 작업을 수행한다
            for (int i = 0; i < 100_000; i++) { }
        }

        return locked.compareAndSet(false, true);
    }
}

 

다시 실행해보면 하나의 쓰레드만 lock을 획득할 수 있다는 것을 확인할 수 있다.

Thread-0 - true
Thread-1 - false
Thread-2 - false
Thread-3 - false
Thread-4 - false
Thread-5 - false
Thread-6 - false
Thread-7 - false
Thread-8 - false
Thread-9 - false

 

Compare-And-Swap(CAS)

위에서 Atomic은 CAS 알고리즘을 기반으로 동작한다는 얘기를 했다.

이 CAS가 뭣이길래 동시성을 보장할 수 있다는건지 정리하고 글을 마치려고 한다. 이번엔 위키백과의 설명을 찾아보자.

 

It compares the contents of a memory location with a given value and, only if they are the same, modifies the contents of that memory location to a new given value.

 

현재 주어진 값(=현재 쓰레드에서의 데이터)과 실제 메모리에 저장된 데이터를 비교해서 두 개가 일치할때만 값을 업데이트 한다고 한다. 이 역할을 하는 메서드가 바로 compareAndSet()이다. 즉, synchronized처럼 임계영역에 같은 시점에 두개 이상의 쓰레드가 접근하려 하면 쓰레드 자체를 blocking 시키는 메커니즘이 아니다. AtomicInteger의 연산 메서드들이 어떻게 구현된건지 코드를 타고 들어가다 보면 이렇게 유사한 형식으로 do-while문이 쓰이는 것을 볼 수 있다.

 

현재 연산에서 기대하는 값과 메모리 상에서의 값이 일치하지 않는다면 '중간에 다른 쓰레드가 끼어들었군!'으로 판단해 write를 실패시키고 재시도를 하게된다. lock-free 방식으로 루프를 돌기 때문에 block<->unblock 상태 변경 처리에 쓰이는 비용을 절감할 수 있게된다.

 

참고 자료