본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] 단일 연산 변수와 넌블로킹 동기화

Semaphore, ConcurrentLinkedQueue와 같이 java.util.concurrent 패키지에 들어있는 다수의 들어 있는 다수의 클래스는 단순하게 synchronized 구문으로 동기화를 맞춰 사용하는 것에 비교하면 속도도 빠르고 확장성도 좋습니다. 이와 같은 클래스의 성능이 좋아진 원인이라고 볼 수 있는 단일 연산 변수(atomic variable)와 대기 상태에 들어가지 않는 넌블로킹 동기화 기법을 살펴볼 예정입니다.

 

병렬 알고리즘과 관련한 연구 결과를 보면 대부분이 넌블로킹 알고리즘, 즉 여러 스레드가 동작하는 환경에서 데이터의 안정성을 보장하는 방법으로 락을 사용하는 대신 저수준의 하드웨어에서 제공하는 비교 후 교환(compare-and-swap) 등의 명령을 사용하는 알고리즘을 다루고 있습니다. 넌블로킹 알고리즘은 운영체제나 JVM에서 프로세스나 스레드를 스케줄링 하거나 가비지 컬렉션 작업, 그리고 락이나 기타 병렬 자료 구조를 구현하는 부분에서 굉장히 많이 사용하고 있습니다.

 

넌블로킹 알고리즘은 락을 기반으로 하는 방법보다 설계와 구현 모두 훨씬 복잡하며, 대신 확장성과 활동성을 엄청나게 높여줍니다. 넌블로킹 알고리즘은 훨씬 세밀한 수준에서 동작하며, 여러 스레드가 동일한 자료를 놓고 경쟁하는 과정에서 대기 상태에 들어가는 일이 없기 때문에 스케줄링 부하를 대폭 줄여줍니다. 더군다나 데드락이나 기타 활동성 문제가 발생할 위험도 없습니다. 락을 기반으로 하는 알고리즘은 특정 스레드가 락을 확보한 상태에서 잠자기 상태에 들어가거나 반복문을 실행하면 다른 스레드는 그 시간 동안 각자의 작업 가운데 락이 필요한 부분을 전혀 실행할 수 없습니다. 반면 넌블로킹 알고리즘을 사용하는 경우에는 개별 스레드에서 발생하는 오류에 의해 영향을 받는 일이 없습니다. 자바 5.0부터는 AtomicInteger나 AtomicReference 등의 단일 연산 변수를 사용해 논블로킹 알고리즘을 효율적으로 구현할 수 있게 됐습니다. 

 

단일 연산 변수는 본격적인 넌블로킹 알고리즘을 구현하는 일이 아니라 해도 '더 나은 volatile 변수'의 역할만으로 사용할 수도 있습니다. 단일 연산 변수는 volatile 변수와 동일한 메모리 유형을 갖고 있으며 이에 덧붙여 단일 연산으로 값을 변경할 수 있는 기능을 갖고 있습니다. 따라서 이런 특성을 사용해 숫자 카운터, 일련번호 생성기, 통계 수치 추출기 등으로 활용하면 락 기반의 구조에 비해 높은 확장성을 얻을 수 있습니다.

 

 

volatile 변수?

 

C/C++ 프로그래밍 언어에서 이 키워드는 최적화 등 컴파일러의 재량을 제한하는 역할을 한다. 개발자가 설정한 개념을 구현하기 위해 코딩된 프로그램을 온전히 컴파일되도록 한다. 주로 최적화와 관련하여 volatile가 선언된 변수는 최적화에서 제외된다. OS와 연관되어 장치제어를 위한 주소체계에서 지정한 주소를 직접 액세스하는 방식을 지정할 수도 있다. 리눅스 커널 등의 OS에서 메모리 주소는 MMU와 연관된 주소체계로 논리주소와 물리주소 간의 변환이 이루어진다. 경우에 따라 이런 변환을 제거하는 역할을 한다. 또한 원거리 메모리 점프 기계어 코드 등의 제한을 푼다.

 

주로 메모리 맵 입출력(MMIO)을 제어할 때, volatile을 선언한 변수를 사용하여 컴파일러의 최적화를 못하게 하는 역할을 합니다.

static int foo;

void bar(void)
{
	foo = 0;
	while (foo != 255);
}

 

foo의 값의 초기값이 0 이후, while 루프 안에서 foo의 값이 변하지 않기 때문에 while의 조건은 항상 true가 나옵니다. 따라서 컴파일러는 다음과 같이 최적화합니다.

var bar_optimized(void)
{
	foo = 0;
	while(true);
}

 

이렇게되면 while의 무한 루프에 빠지게 됩니다. 이런 최적화를 방지하기 위해 다음과 같이 volatile을 사용합니다.

static volatile int foo;

void bar(void)
{
	foo = 0;
	while(foo != 255);
}

 

이렇게 되면 개발자가 의도한 대로, 그리고 눈에 보이는 대로 기계어 코드가 생성됩니다. 이 프로그램만으로는 무한루프라고 생각할 수 있지만, 만약 foo가 하드웨어 장치의 레지스터라면 하드웨어에 의해 값이 변할 수 있습니다. 따라서 하드웨어 값을 폴링(poll)할 때 사용할 수 있습니다.

 

 

락의 단점


공유된 상태에 접근하려는 스레드에 일관적인 락 구조를 적용해 동기화하면 특정 변수를 보호하고 있는 락을 확보한 스레드가 해당 변수에 대한 독점적인 권한을 갖게 되며, 변수의 값을 변경했다고 하면 다음 스레드가 락을 확보했을 때 모든 변경된 사항을 완벽하게 볼 수 있습니다.

 

최근 사용하는 JVM은 스레드 간의 경쟁이 없는 상태에서 락을 확보하는 부분을 최적화하는 기능을 갖고 있으며 락을 해제하는 부분도 굉장히 효율적입니다. 하지만 락 확보 경쟁이 벌어지는 상황에서는 JVM 역시 운영체제의 도움을 받습니다.