Java/병렬 프로그래밍

자바 병렬 프로그래밍 - 단일 연산 변수와 넌블로킹 동기화

kwon92 2021. 1. 21. 20:55

Semapore ,ConcurrentLinkedQueue 같이 java.util.concurrent 패키지에 들어 있는 다수의

클래스는 단순하게 synchronized 구문으로 동기화를 맞춰 사용하는 것에 비교하면

속도도 빠르고 확장성도 좋다.

 

단일 연산 변수와 대기 상태에 들어가지 않는 넌블로킹 동기화 기법을 사용해서 그렇다.

병렬 알고리즘의 최근 연구결과를 보면 대부분 

넌블로킹 알고리즘

즉, 멀티 스레드 환경에서 데이터의 안전성을 보장하는 방법으로 락을 사용하는 대신

하드웨어에서 제공하는 비교 후 교환 등의 명령을 사용하는 알고리즘이다.

 

넌블로킹 알고리즘은 운영체제나, JVM 에서 프로세스나 스레드를 스케줄링 하거나

GC 작업 등 굉장히 많이 사용한다.

 

락을 기반으로 하는 방법보다 설계와 구현 모두 복잡하지만 확장성과 활동성이 엄청 높다.

 

- 여러 스레드가 대기 상태에 들어가지 않아서 스케줄링 부하가 대폭 줄어든다.

- 데드락이나 활동성 문제도 발생하지 않는다.

 

락을 기반으로 하는 방법은 특정 스레드가 락을 확보한 상태에서 잠자기 상태에 들어가면

다른 스레드는 그 시간동안 각자 작업 가운데 락이 필요한 부분을 쓸 수 없다.

반면 넌블로킹은 개별 스레드에 오류에 영향 받지 않는다.

 

자바 5부터는 AtomicInteger나 AtomicReference 등의 단일 연산 변수를 사용해 넌블로킹 알고리즘을 구현할 수 있다.

 

 

락의 단점

스레드에 락 구조를 적용해 동기화 하면 

락을 확보한 스레드가 독점적 권한을 갖게 되고 변수의 값을 변경하면 다음 스레드가 락을 확보했을때 모든 변경된 사항을 볼 수 있다.

 

락을 확보하지 못한 스레드는 실행을 멈춰야 하고 나중에 다시 실행해야한다.

스레드의 실행을 중단했다가 계속 실행하는 작업은 상당한 부하를 발생시킨다.

 

경쟁이 심한 상황에서는 컨텍스트 스위칭 부하와 함께 스케줄링 지연이 발생해서 성능이 떨어진다.

 

스레드가 락을 확보하기 위해 대기 상태에서는 대기 중인 스레드가 다른 작업을 할 수 가 없다.

이럴 때 락을 확보하고 있는 스레드가 지연되면 전체 대기 스레드가 지연된다.

대기 상태에 있는 스레드가 우선순위가 높으면 심각한 영향을 미치기도 한다.

 

 

병렬 연산을 위한 하드웨어적인 지원

배타적 락은 보수적인 동기화 기법이다.

락을 확보하면 다른 스레드가 절대 간섭 못하는 기법이다.

 

이거보단 낙관적인 방법이 있는데

충돌 검출 방법을 사용해 공유 변수의 값을 변경하고 변경하는 동안 다른 스레드가 간섭이 있었는지 확인하고

있었으면 실패하거나 나중에 재시도 하거나 하는 방법을 쓴다.

 

멀티 프로세서들은 공유된 변수를 놓고 동시에 여러 작업을 할 수 있게 명령어들을 제공한다.

 

비교하고 치환, LL(load-linked), SC(store-conditional) 등의 연산이 있다.

운영체제와 JVM 모두 이와 같은 연산을 사용해 병렬 자료 구조를 작성했다.

 

 

- 비교 후 치환

비교 후 치환은 3개의 인자를 넘겨주는데,

작업할 대상 메모리의 위치 V 와 예상하는 기존 값 A 와 새로 설정할 값 B 를 넘겨준다.

비교 후 치환은 V 위치에 있는 값이 A와 같은 경우에 B로 변경하는 단일 연산이다.

 

만약 여러 스레드가 동시에 비교 후 치환 연산을 사용해 한 변수의 값을 변경하려고 하면,

스레드 가운데 하나만 성공하고 나머지는 실패한다.

대신 값을 변경하지 못했어도 다시 시도할 수 있다고 통보 받는셈이다.

 

비교 후 치환 에 실패한 스레드도 대기 상태에 들어가지 않기 때문에

다시 시도할지 다른방법을 취할지 아예 아무것도 안할지 결정할 수 있다.

 

이처럼 비교 후 치환은 유연성 덕에 락을 사용하면서 발생 할 수 밖에 없었던

여러가지 활동성 문제를 미연에 방지 할 수 있다.

비교후치환(CAS) 연산을 구현한 코드

 - 넌블로킹 카운터

 

CAS 기반으로 넌블로킹 카운터 클래스 구현

대기 상태에 들어가지 않으면서 스레드 경쟁에 안전한 카운터 클래스다.

대기 상태에 들어가진 않지만 다른 스레드가 계속 업데이트 하면 여러차례 재시도 할 수 도 있다.

 

CAS 기반 클래스가 락 기반 클래스보다 훨씬 성능이 좋고

경쟁이 없는 경우에도 락 기반의 방법보다 나은 경우가 있다.

 

락 기반의 프로그램은 언어적인 문법은 간단하지만 ,

JVM 과 운영체제가 그 락을 처리하기는 간단하지 않다.

JVM 내부에서 복잡한 코드 경로를 실행하게 되고 운영체제 수준의 락이나 스레드 대기 , 컨텍스트 스위칭 등의 기능을 쓰기도 한다.

 

반면, CAS 연산을 직접 사용하면 JVM 에서 특별한 루틴을 실행하지 않아도 되고 ,

운영체제 함수를 호출 할 필요도 없다.

 

CAS연산은 프로세서마다 차이가 있다.

 

- JVM 에서의 CAS 연산 지원

 

그럼 자바 프로그램에서 어떻게 CAS 연산을 호출 할 수 있을까?

단일 연산 변수 클래스 , 즉 AtomicInterger 와 같이 java.util.concurrent.atomic 패키지의 AtomicXXX 클래스를 통해 

제공된다.

 

단일 연산 변수 클래스

단일 연산 변수(atomic variable)은 락보다 훨씬 가벼우면서 세밀한 구조를 갖고 있다.

멀티 프로세서에서 병렬 프로그래밍을 작성할때 핵심적인 역할을 한다.

 

스레드가 경쟁하는 범위를 하나의 변수로 좁혀주는 효과가 있다.

락을 사용할때보다 스레드 스케줄링같은 문제가 생기지 않기 때문에 단일 연산 변수를 사용하는게 더 빠르게 실행된다.

따라서 단일 연산 변수를 쓴 코드는 스레드 지연되는 현상이 거의 없고 스레드 경쟁이 발생해도 쉽게 헤쳐 나간다.

 

AtomicInter 클래스의 경우 int 값을 나타내며 , volatile 변수로 사용할때 변수의 값을 읽거나 쓰는데 동일한 기능을 하는

get 과 set 을 제공한다.

또 단일 연산으로 제공되는 compareAndSet 메소드를 제공하며 그 외의 단일 연산으로 값을 더하거나 증가하거나 감소 시키는 등의 메소드를 제공한다.

동기화를 위한 하드웨어 기능을 직접적으로 활용하기 때문에 훨씬 높은 확장성을 제공한다.

CAS 연산을 그대로 제공하며 AtomicInteger 나 AtomicLong 은 간단한 산술 기능도 제공한다.

 

- 더 나은 voliatile 변수로의 단일 연산 클래스

메모리 가시성과 더불어 동기화에 대한 보장도 받을 수 있다.

 

- 성능 비교, 락과 단일 연산 변수

 

하나는 ReentrantLock 을 사용해 구현했고, 하나는 AtomicInteger를 사용해 구현했다.

 

경쟁이 많은 상태에서 Lock 과 AtomicInteger 비교

경쟁이 많은 상황에서는 단일 연산 변수보다 락이 더 빠르게 처리 되는 모습을 볼 수 있다.

락을 두고 경쟁이 발생하면 대기상태에 들어가는 스레드가 생기고

스레드가 대기상태에 들어가면 전체적인 CPU 사용률과 트래픽이 줄어드는 효과로처리 속도가 높아진다.

 

반면 단일 연산 변수는 경쟁 조건에 대한 책임이 경쟁하는 스레드에게 넘어간다.

CAS 연산 알고리즘으로 경쟁이 발생하면 재시도해서 경쟁이 심해지면 계속 심하게 만드는 요인이 된다.

 

경쟁 수준이 아주 높은 상황에서는 락을 사용하는 쪽이 더 잘 대응하는 모습을 보인다.

 

 

넌블로킹 알고리즘

락 기반 알고리즘은 대기 상태에 들어가면 다른 모든 스레드가 대기 상태에 들어갈 가능성이 있다.

다른 어떤 스레드라도 실패하거나 대기 상태에 들어가지 않는 알고리즘을 대기 상태에 들어가지 않는 

알고리즘 , 즉 넌블로킹 알고리즘이라고 한다.

락대신 CAS 를 사용한 알고리즘이라고 보자

 

넌블로킹 알고리즘은 데드락이나 우선순위 역전등의 문제점이 발생하지 않는다.