포스트

CAS - 동기화와 원자적 연산

CAS - 동기화와 원자적 연산

원자적 연산

  • 원자적 연산: 해당 연산이 더 이상 나눌 수 없는 단위로 수행 되는 것
1
int i = 0;

위 로직은 둘로 쪼갤 수 없는 원자적 연산입니다.

1
i =i +1;
  1. i의 값을 읽는다.
  2. 읽은 i에 1을 더해서 연산 된 값을 만든다.
  3. 연산 된 값을 i에 할당한다.

위 로직은 3개의 단위로 쪼개어 실행되므로 원자적 연산이 아닙니다.

예시

1000개의 스레드에서 값을 1씩 더하고 조회하는 예시입니다.

1
2
3
4
5
6
7
public interface IncrementInteger {

  void increment();

  int get();

}

기본

우선 기본적인 방법의 더하기 연산입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BasicInteger implements IncrementInteger {

  private int value;

  @Override
  public void increment() {
    value++;
  }

  @Override
  public int get() {
    return value;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class IncrementThreadMain {

  public static final int THREAD_COUNT = 1000;

  public static void main(String[] args) throws InterruptedException {

    test(new BasicInteger());

  }

  private static void test(IncrementInteger incrementInteger) throws InterruptedException {

    Runnable runnable = new Runnable() {

      @Override
      public void run() {
        sleep(10); // 너무 빨리 실행되기 때문에, 다른 스레드와 동시 실행을 위해 잠깐 쉬었다가 실행
        incrementInteger.increment();
      }
    };
    List<Thread> threads = new ArrayList<>();
    for (int i = 0; i < THREAD_COUNT; i++) {
      Thread thread = new Thread(runnable);
      threads.add(thread);
      thread.start();
    }

    for (Thread thread : threads) {
      thread.join();
    }

    int result = incrementInteger.get();
    System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
  }
}

1
BasicInteger result: 988

1000을 기대했지만 기대와 다르게 988이라는 숫자가 나왔습니다. 이는 스레드 충돌이 일어났기 때문입니다.

volatile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class VolatileInteger implements IncrementInteger {

  volatile private int value;

  @Override
  public void increment() {
    value++;
  }

  @Override
  public int get() {
    return value;
  }
}
1
VolatileInteger result: 986

여전히 스레드 충돌로 인해 1000이 안나옵니다. 이 문제는 메모리 가시성 문제가 아니라, 같은 변수에 동시에 접근하기 때문에 생기는 문제라서 그렇습니다.

synchronized

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SyncInteger implements IncrementInteger {

  private int value;

  @Override
  public synchronized void increment() {
    value++;
  }

  @Override
  public synchronized int get() {
    return value;
  }
}

1
SyncInteger result: 1000

스레드 충돌을 해결하기 위해 synchronized를 붙였더니 드디어 생각한대로 1000이 나왔습니다.

AtomicInteger

자바는 멀티 스레드 환경에서 안전하게 증가 연산을 수행할 수 있는 AtomicInteger라는 클래스를 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyAtomicInteger implements IncrementInteger {

  AtomicInteger atomicInteger = new AtomicInteger(0);

  @Override
  public void increment() {
    atomicInteger.incrementAndGet();
  }

  @Override
  public int get() {
    return atomicInteger.get();
  }
}
1
MyAtomicInteger result: 1000

이 방식을 써도 1000이 출력됩니다.

성능

1
2
3
4
BasicInteger: ms = 50
VolatileInteger: ms = 627
SyncInteger: ms = 974
MyAtomicInteger: ms = 700

synchronized 를 썼을 때 보다 AtomicInteger 을 썼을 때가 조금 더 빠른 것을 확인할 수 있습니다.

  • synchronized : 락 기반 방식
  • AtomicInteger: CAS 연산 방식

CAS 연산

락을 걸지 않고 원자적인 연산을 수행할 수 있는데, 이것을 CAS(Compare-And-Swap, Compare-And-Set)이라고 합니다. 락을 사용하지 않기 때문에 락 프리(lock-free) 기법이라고 합니다.

CAS 연산은 락을 완전히 대체하는 것은 아니고, 작은 단위의 일부 영역에 적용

compareAndSet()

자바는 AtomicXxxcompareAndSet() 메서드를 통해 CAS 연산을 지원합니다.

사용 방법

  • compareAndSet(비교할 값, 변경할 값)
1
2
3
AtomicInteger atomicInteger = new AtomicInteger()

atomicInteger.compareAndSet(0, 1)
  • atomicInteger의 현재 값이 0로 변경하고 true를 반환
  • atomicInteger의 현재 값이 0이 아니라면 값을 변경하지 않고 false를 반환

2가지 연산인데 어떻게 원자적 연산?

  1. 값을 확인
  2. 0이면 1로 변경

즉, 연산을 2번하게 됩니다. 하지만 놀랍게도 이는 원자적 연산으로 처리됩니다.

CPU 하드웨어 지원

CAS 연산은 이렇게 원자적이지 않은 두 개의 연산을 CPU 하드웨어 차원에서 특별하게 원자적 연산으로 묶어서 제공하는 기능입니다.

즉, 이 두 연산이 이루어지기 전에 다른 스레드가 연산 못하도록 하드웨어가 막아줍니다.

예시

2개의 스레드가 동시에 값을 증가시키는 로직입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class CasMainV3 {

  private static final int THREAD_COUNT = 2;

  public static void main(String[] args) throws InterruptedException {
    AtomicInteger atomicInteger = new AtomicInteger(0);
    System.out.println("start value = " + atomicInteger.get());

    Runnable runnable = new Runnable() {

      @Override
      public void run() {
        incrementAndGet(atomicInteger);
      }
    };

    List<Thread> threads = new ArrayList<>();
    for (int i = 0; i < THREAD_COUNT; i++) {
      Thread thread = new Thread(runnable);
      threads.add(thread);
      thread.start();
    }

    for (Thread thread : threads) {
      thread.join();
    }

    int result = atomicInteger.get();
    System.out.println(atomicInteger.getClass().getSimpleName() + ": resultValue = " + result);
  }

  private static int incrementAndGet(AtomicInteger atomicInteger) {
    int getValue;
    boolean result;
    do {
      getValue = atomicInteger.get();
      sleep(100); //스레드 동시 실행을 위한 대기
      log("getValue = " + getValue);
      result = atomicInteger.compareAndSet(getValue, getValue + 1);
      log("result = " + result);
    } while (!result);
    return getValue + 1;
  }
}
  • 두 스레드가 동시에 getValue = atomicInteger.get() 접근하여 0을 반환 받음
  • result = atomicInteger.compareAndSet(getValue, getValue + 1) 동시에 실행
  • 둘 중 하나의 스레드가 먼저 접근하여 값을 1 올리고 resulttrue가 할당 되어 while 종료
  • 두 번째로 접근한 스레드가 위에서 받은 값(0)과 atomicInteger의 값(1)을 비교해 보니 일치하지 않아 false를 반환
  • 다시 getValue = atomicInteger.get()를 통해 1을 반환받음
  • result = atomicInteger.compareAndSet(getValue, getValue + 1) 를 실행
  • 이제 일치하니 resulttrue 할당하고 while 종료

즉, 원자적 연산이기에 동시에 연산은 불가능 하니 충돌이 발생하면 다시 실행하는 것입니다.

CAS 락 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SpinLock {

  private final AtomicBoolean lock = new AtomicBoolean(false);

  public void lock() {
    log("락 획득 시도");
    while (!lock.compareAndSet(false, true)) {
      // 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
      log("락 획득 실패 - 스핀 대기");
    }
    log("락 획득 완료");
  }

  public void unlock() {
    lock.set(false);
    log("락 반납 완료");
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SpinLockMain {

  public static void main(String[] args) {
    SpinLock spinLock = new SpinLock();

    Runnable task = new Runnable() {
      @Override
      public void run() {
        spinLock.lock();

        // critical section
        try {
          log("비즈니스 로직 실행");

        } finally {
          spinLock.unlock();
        }
      }
    };

    new Thread(task, "Thread-1").start();
    new Thread(task, "Thread-2").start();
  }

}

스핀 락

스레드가 락이 해제되기를 기다리면서 반복문을 통해 계속 해서 확인하는 모습이 마치 제자리에서 회전하는 것처럼 보여 스핀 락이라고 합니다.

스레드가 락을 획득 할 때 까지 대기하는 것을 스핀 대기(spin-wait) 또는 CPU 자원을 계속 사용하면서 바쁘게 대기한다고 해서 바쁜 대기(busy-wait)라고 합니다.

CAS와 락 방식 비교

CAS

  • 장점
    • 낙관적 동기화: 충돌이 자주 발생하지 않을 것이라 가정
    • 락 프리(Lock-Free): CAS는 락을 사용하지 않기 때문에, 락을 획득하기 위해 대기하는 시간이 없음. 따라서 스레드가 블로킹 되지 않으며, 병렬 처리가 더 효율적일 수 있음
  • 단점
    • 충돌이 빈번한 경우: 충돌이 발생하면 루프를 돌며 재시도 해야 하므로 CPU 자원을 계속 소모할 수 있음

동기화 락

  • 장점
    • 충돌 관리: 락을 사용하면 하나의 스레드만 리소스에 접근 가능
    • 안정성: 복잡한 상황에서도 락은 일관성 있는 동작을 보장
    • 스레드 대기: 락을 대기하는 스레드는 CPU를 거의 사용하지 않음
  • 단점
    • 락 획득 대기 시간: 스레드가 락을 획득하기 위해 대기해야 하므로, 대기 시간이 길어질 수 있음
    • 컨텍스트 스위칭 오버헤드: 락 획득을 대기하는 시점과 락을 획득하는 시점에 스레드의 상태가 변경됨. 이때 컨텍스트 스위칭이 발생할 수 있으며, 이로 인해 오버헤드가 증가할 수 있음

충돌이 빈번하게 발생하지 않는 경우 CAS를 이용하는 것이 좋습니다.

데이터베이스를 기다리거나 다른 서버와의 요청 같은 경우는 시간이 오래걸리기 때문에 이런 경우 동기화 락을 사용하는 것이 좋습니다.

참고

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.