[Java 멀티스레드] 원자적 연산, CAS 그리고 동시성 컬렉션

벼랑 끝 코딩·2025년 3월 9일

Java MultiThread

목록 보기
4/6
post-thumbnail

멀티스레드를 학습하면서 발생하는 동시성 문제에 대해 학습하고
동시성 문제를 해결하기 위한 락 기능을 학습했다.

동시성 문제를 해결할 수 있는 또 다른 방식인 원자적 연산에 대해 알아보자.

원자적 연산

int number = 1;

위 코드는 단순히 변수에 값을 입력하는 연산으로, 원자적 연산이다.

number++;

위 코드는 다음과 같다.

number = number + 1;

위 코드는 number의 값을 읽는 것이 선행되어야 1을 더한 값을 도출해낼 수 있다.
따라서 연산이 크게 2회로 나뉘게 되므로 원자적 연산이 아니다.
이처럼 원자적 연산이란 더 이상 나눌 수 없는 단위로 수행되는 연산,
멀티스레드 상황에서 안전하게 처리되는 연산을 의미한다.

멀티스레드 상황에서 동일한 변수에 접근하여
동시에 값을 수정하는 경우에 동시성 문제가 발생했다.
코드가 원자적 연산이 아닌 경우, 연산 도중에 다른 스레드가 접근하여
함께 값을 수정하는 일이 발생할 수 있기 때문에 우리는 임계 영역을 설정하고, 락 기능을 사용했다.

하지만 락 기능 없이도 동시성 문제를 해결할 수 있는 방법이 있다!

CAS(compareAndSet)

락 기능 없이 동시성 문제를 해결하는 방법은
compareAndSet(observedValue, changeValue) 메서드를 호출하는 것이다.

  • observedValue : 관측한 값
  • changeValue : 변경하려는 값
int value;
boolean result;

do {
	observedValue = getValue();  // ** 초기 관측 값 획득 **
    value = changeValue();  // ** 값 변경 **
    
    // ** 초기 관측 값과 변경 값이 현재 관측 값과 일치하는지 확인 **
    result = compareAndSet(observedValue, value);
} while(!result);
	
  1. 로직은 do-while문 블럭 내에서 수행된다.
  2. 먼저 수정하고자 하는 데이터를 관측하여 얻는다.
  3. 수정하고자 하는 데이터를 변경하려는 값으로 변경한다.
  4. 관측한 데이터와 변경하려는 값이 처음 관측 시점과 일치하는지 확인한다.
  5. 일치하는 경우 변수를 안전하게 수정하고, 일치하지 않으면 다시 do-while문을 수행한다.

만약 동시에 변수에 접근하여 누군가 변수를 이미 수정했을 경우,
관측한 데이터가 변경되었기 때문에 do-while문을 빠져나갈 수 없다.
이 경우, 다시 데이터를 관측하여 얻고 값을 수정한 후 일치하는지 재확인한다.

관측하여 처음에 획득한 값이 수정한 시점에도 일치해야만 변수를 안전하게 수정할 수 있다!

락과 CAS 차이

락과 CAS, 왜 동일한 목적으로 두 기능이 만들어졌을까?
무엇을 사용해야 하는걸까?

락(Lock)

락은 특정 스레드가 임계 영역에 진입하는 경우, 다른 스레드가 대기해야만 하는
락 기능을 사용하기 때문에 안전하게 운영할 수 있다.
하지만 추가적인 기능이 필요하여 성능 저하와 Trade-Off다.

비관적 접근법

이와 같은 특성 때문에 락을 비관적 접근법이라고 부른다.
특정 스레드가 임계 영역에 진입한 순간,
동시에 다른 스레드가 진입할 것이라는 비관적인 관점에서 시스템을 운영하기 때문이다.

CAS

CAS는 충돌하면 while문으로 다시 코드를 반복하는 방식으로,
락 기능 없이 운영되기 때문에 더 우수한 성능을 지닌다.

낙관적 접근법

락 기능과는 반대로 CAS는 낙관적 접근법이라고 부른다.
특정 스레드가 임계 영역에 진입하더라도,
다른 스레드가 접근하지 않을 것이라는 낙관적인 시선으로 시스템을 운영하기 때문이다

그렇다면 성능이 더 우수한 CAS를 사용하면 되는걸까?

CAS : 연산이 짧은 경우에 사용하자

루프 문에서 주의해야 할 점은 바로 오랜 기간, 또는 무한 루프에 빠지는 것이다.
그렇다면 CAS의 do-while 블럭의 루프에서 어떨 때 루프의 심연에 빠지게 되는 것일까?

바로 충돌이다.

CAS에서 초기 관측 값과 연산 후 관측한 값이 달라 충돌할 경우 do-while문을 반복한다.
연산이 길어질 경우 루프에 갇히는 시간은 길어지고
그 시간 동안 CPU를 계속 사용하면서 스핀 락 상태에 빠진다.
락은 락인데 가만히 대기하도록 하는 락이 아닌,
do-while에서 CPU를 계속 사용하면서 락 상태에 빠지는 스핀 락으로
오히려 일반 락 기능보다 효율이 떨어지는 상황이 발생하는 것이다.

그렇기 때문에 CAS충돌 자체가 적은 곳, 그리고
연산이 짧은 임계 영역에서만 사용해야 일반 락보다 더 좋은 성능으로 활용할 수 있다!

Atomic

자바에서 제공하는 타입의 연산은 원자적 연산이 아니다.
하지만 멀티스레드 상황에서 안전하게 사용하기 위해,
이 모든 타입에 CAS를 적용하여 구현하는 것은 번거로운 일이 아닐 수 없다.

다행히 자바는 락 기능을 사용하는 것이 아닌 CAS 기반으로
멀티스레드 상황에서 안전하게 연산을 수행하는 Atomic 클래스를 지원한다.

int → AtomicInteger
long → AtomicLong
boolean → AtomicBoolean

...

멀티스레드 상황에서는 Atomic 클래스를 참고하여 사용하자.

동시성 컬렉션

자바에서 제공하는 타입 뿐만 아니라, 컬렉션도 마찬가지다.
컬렉션에 데이터를 추가, 수정하는 등 다양한 기능 역시 원자적 연산이 아니다.
마찬가지로 멀티스레드 상황에서 안전하게 사용하기 위해,
사용하는 모든 컬렉션에 CAS를 적용하여 구현하는 것은 매우 번거롭다.

다행히 자바는 CAS 기반으로 멀티스레드 상황에서 안전하게 연산을 수행하는
동시성 컬렉션를 지원한다.

List → CopyOnWriteArrayList
Set → CopyOnWriteArraySet, ConcurrentSkipListSet
Map → ConcurrentHashMap, ConcurrentSkipListMap
Queue → ConcurrentLinkedQueue
Deque → ConcurrentLinkedDeque
BlockingQueue → ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue,
  SynchronousQueue, DelayQueue

LinkedHashSet, LinkedHashMap은 없다.
두 컬렉션은 다음의 Collections 메서드를 사용해야 한다.

Collections.synchronized()

자바는 CAS가 아닌 락 기능을 사용한 동시성 컬렉션도 지원한다.
Collections의 메서드를 활용하여 기존의 컬렉션을 동시성 컬렉션으로 변경한다.

Collections.synchronizedList(list)
Collections.synchronizedCollection(collection)
Collections.synchronizedMap(map)
Collections.synchronizedSet(set)
Collections.synchronizedNavigableMap(navigableMap)
Collections.synchronizedNavigableSet(navigableSet)
Collections.synchronizedSortedMap(sortedMap)
Collections.synchronizedSortedSet(sortedSet)

CAS 기반의 동시성 컬렉션을 지원하지 않는 LinkedHashset과 LinkedHashMap,
그리고 복잡한 연산의 경우에는 Collections 메서드를 활용하자.

마무리

락 기능에 이어 원자적 연산과 CAS,
그리고 그것을 활용한 동시성 컬렉션에 대해 알아봤다.

멀티스레드 상황에서는 동시성 컬렉션을 사용하여 안전성을 확보하자.

profile
복습에 대한 비판과 지적을 부탁드립니다

0개의 댓글