논 블로킹 알고리즘(Non-blocking Algorithms)
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.원문 URL : http://tutorials.jenkov.com/java-concurrency/non-blocking-algorithms.html 동시성에서의 논 블로킹 알고리즘이란 쓰레드간의 공유된 상태(자원)
parkcheolu.tistory.com
동시성에서 스레드간 공유 상태(자원)로의 접근이 발생할 때...
블로킹과 논블로킹 알고리즘이 어떻게 동작하는지 알아보자.
1. 블로킹 동시성 알고리즘
Thread B가 lock한 데이터에 Thread A가 접근하면 blocked되어 대기한다.
Thread B가 unlock을 해주면 대기 중이던 Thread A가 다시 lock을 걸고 접근/사용한다. (완료 후 unlock)
예시:
- java.util.concurrent.BlockingQueue 인터페이스의 구현체들은 모두 블로킹 자료구조
- synchronized
2. 논 블로킹 동시성 알고리즘
Thread B가 데이터에 접근한다. Thread A가 같은 데이터에 접근을 시도하면 reject당한다.
예시:
- AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference 등 클래스
3. volatile의 ++연산
3-1. 그냥 ++ 하면 안된다.
자바의 volatile 변수는 변수의 값을 항상 메인 메모리에서 읽히도록 한다.
새 값이 대입되어도 언제나 메인 메모리에 즉시 쓰여진다.
volatile 변수는 논 블로킹이다. volatile 변수로의 쓰기는 원자적 연산이고, 다른 스레드가 끼어들 수 없다.
하지만 volatile 변수라고 해도 (변수)++ 와 같은 연속적인 읽기-값 변경-쓰기 동작은 원자적이지 않다.
왜냐면... 아래를 보자.
volatile myVar = 0;
// 01.
myVar++;
// 02.
int temp = myVar;
temp++;
myVar = temp;
01과 02는 내부적으로 같은 연산이다. 그러니 좀 더 상세한 02번으로 보자.
01과 같이 작성하면 원자적인 연산이 되지 않느냐? 라고 질문한다면:
01의 코드가 실행되면 myVar의 값이 CPU 레지스터 또는 CPU 캐시로 읽히고, 1이 증가되고, CPU 레지스터나 CPU 캐시에서 다시 메인 메모리로 저장된다. 즉, 02와 같은 상황이 연출된다는 것이다.
따라서 01과 02는 동일하다.
두 스레드가 거의 동시에 이 코드를 실행한다고 했을 때,
즉 첫 스레드가 myVar = temp 를 실행하기 전에 두 번째 스레드가 int temp = myVar 를 실행했다면?
두 스레드 모두 myVar에 temp+1인 1을 저장하려고 할 것이기 때문에 결과적으로 한 번 실행된 것과 같은 결과를 낳게 된다.
3-2. synchronized 블록을 통한 상호 배제
쓰기 스레드가 하나라면 위와 같이 volatile을 사용해도 괜찮다. (출처 블로그에 보면 volatile 변수를 갖는 클래스를 만들어서 increase(쓰기) 하는 메서드를 한 스레드만 접근할 수 있게 하면 된다고는 한다.)
하지만 다중 쓰기 스레드들의 공유 변수로의 접근이 필요한 상황이라면, volatile 만으로는 충분하지 않다.
다른 스레드의 접근을 배제할 수 있는 방법이 필요하다.
우선 synchronized를 통한 상호 배제를 보자.
public class SynchronizedCounter {
long count = 0;
public void inc() {
synchronized(this) {
count++;
}
}
public long count() {
synchronized(this) {
return this.count;
}
}
}
의도된 대로 잘 작동은 하겠지만, 이러한 방식은 피하는 편이 좋다. (스레드 중단에 따르는 오버헤드의 발생이 있을 수 있고, 잘못 사용하면 무한 block을 유발하는 dead lock 이슈가 있을 수 있다.)
3-3. 컴페어 스왑을 통한 낙관적 락 (+ AtomicLong)
synchronized 블록을 사용하는 대신 자바의 원자적 변수인 AtomicLong 클래스를 사용한다.
그러면 아래와 같이 된다.
import java.util.concurrent.atomic.AtomicLong;
public class AtomicCounter {
private AtomicLong count = new AtomicLong(0);
public void inc() {
boolean updated = false;
while(!updated){
long prevCount = this.count.get();
updated = this.count.compareAndSet(prevCount, prevCount + 1);
}
}
public long count() {
return this.count.get();
}
}
이렇게 구현하면 스레드 세이프하다.
inc() 메서드를 보자.
메서드의 코드는 원자적 연산이 아니다.
즉 두 개의 서로 다른 스레드가 동시에 inc()를 호출해서 long prevCound = this.count.get() 코드를 실행하는 일이 가능하다.
두 스레드 모두 count가 증가하기 전의 값을 얻을 수 있다는 것이다.
여기까지는 어떤 경합도 발생하지 않는다.
여기서 중요한 건 while 루프 안 두 번째 라인에 있다.
compareAndSet() 메서드는 원자적 연산이다.
이 메서드는 AtomicLong 내부의 기존 값과 내부의 값이라 예상되는 값을 비교하여 두 값이 일치하면 새 값을 세팅해준다.
이 동작의 수행은 일반적으로 CPU의 컴페어 스왑 명령을 직접 사용한다. 때문에, synchronized 블록은 필요치 않으며, 스레드를 중단할 일도 없다. 덕분에 스레드 중단에 따르는 오버헤드도 없다.
AtomicLong으로 선언된 count의 내부 값이 20이라고 하면, 두 스레드는 이 값을 읽는다. 그리고 compareAndSet(20, 20+1)을 호출한다. compareAndSet() 메서드는 원자적이므로, 실행은 순차적으로 이루어진다. (한 시점에 한 번씩)
첫 스레드는 예상 값 20(counter의 이전 값)을 count의 내부 값과 비교한다. 두 값은 일치한다. 따라서 count의 내부 값을 21로 세팅한다(20+1). updated 변수는 true가 되고 while 루프를 탈출한다.
이제 두 번째 스레드다. compareAndSet(20, 20+1)을 호출한다. count의 내부 값은 더이상 20이 아니다. 때문에 21로 세팅하지도 못하고, false를 반환한다. 이로 인해 while 루프를 다시 돌게 된다. 이번엔 내부 값을 다시 읽어 21이 되고, 1을 더해 22를 세팅하려 시도한다. 이 과정에서 다른 스레드의 개입이 없다면 무사히 count에 22를 쓸 수 있게 된다.
왜 낙관적 락인가?
3-3의 코드는 낙관적 락이라 불린다. 이는 비관적 락이라고도 불리는 전통적 락과 차이가 있다.
전통적 락은 synchronized 블록이나 기타 락을 사용해 공유 메모리로의 접근을 차단한다. 이런 방식은 스레드의 중단을 일으킨다.
낙관적 락은 스레드의 접근을 차단하지 않고, 모든 스레드가 공유 메모리의 복제본을 생성한다.
스레드는 자신이 가진 복사본의 데이터로 수정하고, 그 데이터를 공유 메모리에 쓰기를 시도할 수 있다.
쓰기 시도에서 다른 스레드가 이 데이터를 변경한 적이 없다면 스왑 연산을 통해 공유 메모리의 데이터를 변경할 수 있는 것이고, 만약 다른 스레드가 먼저 변경했다면, 쓰기 스레드는 변경된 새로운 값을 다시 읽어서 다시 변경을 시도하게 되는 것이다.
이게 낙관적 락이라고 불리는 이유는, 스레드가 복제본을 얻어서 데이터를 변경하고 이 변경을 적용하는 동작이, 이 과정에서 다른 스레드가 데이터를 변경하지 않았다는 낙관적 추정 하에 이루어지기 때문이다. 추정이 맞다면 스레드는 락 없이 공유 메모리의 데이터를 변경하는 작업에 성공한다. 추정이 틀렸다면 스레드의 변경 작업은 실패하지만, 여전히 락은 없다.
낙관적 락은 공유 메모리의 경합이 낮거나 중간 수준일 때 가장 잘 동작하는 경향이 있다.
경합이 매우 높으면, 스레드는 많은 CPU 사이클을 공유 메모리 복제/변경에 낭비하게 되고, 공유 메모리로 데이터릐 변경을 쓰는 데에 실패하게 된다. 이런 경우 경합을 낮추는 방향으로 코드를 재설계하는 것이 권고된다.
위에서 소개한 낙관적 락 메커니즘은 논 블로킹이다.
쓰기 과정에서 어떤 이유로 정지상태가 되더라도 공유 메모리로 접근하는 다른 스레드들이 정지되는 일은 없기 때문이다.
반면 전통적 락/언락 패러다임에서는 한 스레드가 락을 걸면 이 락은 락을 건 스레드에 의해 해제될 때까지 다른 스레드들의 접근을 차단한다. 락을 건 스레드가 어떤 이유로 정지상태가 되면, 락은 매우 오랜 시간동안 해제되지 않은 상태로 유진되고, 무기한 지속될 수도 있다.
- 다음에 이어서...
'코딩 > Java' 카테고리의 다른 글
Java - Long과 AtomicLong의 차이, AtomicLong에 대해 (0) | 2023.06.21 |
---|---|
Java - 동시성 환경에서의 블로킹 vs 논 블로킹 (2/2) (0) | 2023.06.21 |
Java - synchronized와 volatile (0) | 2023.06.20 |
Java - JVM 구조와 메모리 영역 (Method, Heap, Stack) (0) | 2023.06.11 |
Java - Static에 대하여... (0) | 2023.06.11 |