이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/amdahls-law.html

!! 쉬운 이해를 위한 의역이 다소 포함되었다.



암달의 법칙은 연산의 병행 처리로 연산 속도를 얼마나 올릴 수 있는지 계산하는 데 쓰일 수 있다. 암달의 법칙은 1967년 이 이론을 발표한 Gene Amdahl 의 이름을 따서 명명되었다. 병행 또는 동시성 시스템으로 작업하는 대부분의 개발자들은 암달의 법칙을 알지 못하더라도 잠재적인 속도 향상에 대한 직관적인 느낌을 가진다. 어쨌든 암달의 법칙은 여전히 알아둘 가치가 있다.


먼저 수학적인 설명 후 다이어그램을 통해 암달의 법칙을 보여주도록 하겠다.



암달의 법칙 정의


병렬화 가능한 프로그램은 두 부분으로 나뉜다.


  • 병렬화 불가능한 부분
  • 병렬화 가능한 부분


디스크의 파일을 처리하는 프로그램이 있고 가정하자. 프로그램에는 디렉토리를 스캔하여 내부 메모리에 파일의 목록을 생성하는 부분이 있다. 각 파일 정보는 파일을 처리하는 쓰레드로 전달된다. 디렉토리를 스캔하여 파일 목록을 생성하는 부분은 병렬화 불가능한 부분이지만 파일을 처리하는 부분은 병렬화 가능한 부분이다.


순차적으로 실행되는 프로그램의 전체 수행 시간을 T 라고 한다. T 에는 병렬화 가능한 부분과 병렬화 불가능한 부분의 수행 시간이 모두 포함되어 있다. 병렬화 불가능한 부분은 B 라고 한다. 이제 병렬화 가능한 부분은  T - B 라 할 수 있다. 요약하자면 다음과 같다.


  • T = 순차적 실행의 전체 수행시간
  • B = 병렬화 불가능한 부분 실행의 전체 수행시간
  • T - B = 병렬화 가능한 부분의 전체 수행시간 (순차적으로 실행되었을 경우)


그리고 다음과 같이 표현된다.


T = B + (T-B)


병렬화 가능한 부분에 대한 부호는 없다는 점에서 위 식은 어딘가 이상해 보이기도 한다. 그러나 식에서 병렬화 가능한 부분은 T 와 B 로 표현될 수 있고, 부호의 수를 줄이면서 식은 개념적으로 축소되었다. 


병렬화 가능한 부분을 나타내는 T - B 는 프로그램을 병렬로 실행했을 때 기대할 수 있는 속도 상승 값이다. 속도가 얼마나 향상되는가는 쓰레드나 CPU 를 얼마나 사용하느냐에 달려있다. 쓰레드 또는 CPU 의 수를 N 이라 한다. 병렬화 가능한 부분의 최대 실행 속도는 다음과 같이 계산된다.


(T - B) / N


다르게 쓸 수도 있다.


(1/N) * (T - B)


위키피디아의 암달의 법칙 문서에는 위 식이 등장한다.


암달의 법칙에 의하면, 프로그램의 병렬화 가능한 부분이 N 개의 쓰레드 혹은 CPU 를 사용하여 실행될 경우 전체 수행시간은 다음과 같이 계산된다.


T(N) = B + (T - B) / N


T(N) 은 병렬 처리의 수와 전체 수행시간을 의미한다. 따라서 T 는 T(1) 으로 표현될 수 있다. 병렬 처리 1 에 대한 프로그램 전체 수행시간이다. T(1) 을 사용하면 암달의 법칙은 다음과 같이 표현된다.


T(N) = B + ( T(1) - B ) / N


의미는 여전히 같다.



계산 예제


암달의 법칙을 보다 잘 이해하기 위해 계산 예제를 하나 보도록 한다. 프로그램 전체 수행 시간은 1 으로 설정한다. 병렬화 불가능한 부분은 프로그램의 40% 을 자치한다. 수행 시간으로는 0.4 이다. 병렬화 가능한 부분은 1 - 0.4 = 0.6 이 된다.


병렬 처리의 수를 2 라고 하면(2 쓰레드 혹은 CPU) 수행 시간은 다음과 같다.


T(2) = 0.4 + ( 1 - 0.4 ) / 2
     = 0.4 + 0.6 / 2
     = 0.4 + 0.3
     = 0.7


같은 식으로 인자를 5 로 바꾸면 다음과 같다.


T(5) = 0.4 + ( 1 - 0.4 ) / 5
     = 0.4 + 0.6 / 5
     = 0.4 + 0.12
     = 0.52



그림으로 보는 암달의 법칙


쉬운 이해를 위해 암달의 법칙을 그림으로 설명해본다.


먼저, 프로그램의 병렬화 가능하지 않은 부분을 B 로 두고, 가능한 부분을 1-B 로 둔다.


Amdahls law illustrated.


상단의 선은 프로그램 전체 수행시간 T(1) 이다.


병렬 처리의 수가 2 라면 수행 시간을 다음과 같이 표현할 수 있다.


Amdahls law illustrated with a parallelization factor of 2.


병렬 처리의 수가 3 이라면 다음과 같다.


Amdahls law illustrated with a parallelization factor of 3.



알고리즘 최적화


암달의 법칙에 의하면 병렬화 가능한 부분은 하드웨어에 맡김으로써 더 빠르게 실행될 수 있다. 더 많은 수의 쓰레드/CPU 를 사용하는 것이다. 하지만 병렬화 가능하지 않는 부분의 속도 향상은 코드 최적화를 통해서 이루어질 수 있다. 따라서 병렬화 가능하지 않은 부분의 코드를 최적화 함으로써 프로그램의 속도와 병렬성을 높일 수 있다. 가능하다면 병렬화 가능하지 않는 부분의 처리를 병렬화 가능한 부분으로 옮겨서 병렬화 가능하지 않은 부분을 더 작게 만들 수도 있다.



순차적 수행의 최적화


프로그램에서 순차적으로 수행되는 부분을 최적화한다면, 최적화 후 암달의 법칙을 사용햐ㅐ 프로그램의 실행 시간을 계산해낼 수 있다. 병렬화 가능하지 않은 부분 B 와 병렬 처리의 수 O 으로 계산한다면암달의 법칙은 다음과 같다.


T(O,N) = B / O + (1 - B / O) / N


병렬화 가능하지 않은 부분의 실행은 이제 B / O 만큼의 시간을 소모한다. 고로 병렬화 가능한 부분의 실행 시간은 1 - B / O 가 된다.


B 가 0.4 이고 O 가 2, N 은 5 라면 계산은 다음과 같다.


T(2,5) = 0.4 / 2 + (1 - 0.4 / 2) / 5
       = 0.2 + (1 - 0.4 / 2) / 5
       = 0.2 + (1 - 0.2) / 5
       = 0.2 + 0.8 / 5
       = 0.2 + 0.16
       = 0.36



실행 시간 vs 향상된 속도


지금까지는 암달의 법칙을 프로그램의 최적화 또는 병렬화 후 실행 시간을 계산하는 데에 사용해왔다. 암달의 법칙은 향상된 속도, 즉 새로운 알고리즘이나프로그램이 이전 버전보다 얼마나 빨라졌는지를 계산하는 데에도 사용될 수 있다.


올드 버전의 실행 시간을 T 라고 하면, 향상된 속도는 다음과 같다.


Speedup = T / T(O,N)


실행 시간이나 향상된 속도를 계산할 때 T 은 주로 1 로 설정한다. 


Speedup = 1 / T(O,N)


T(O,N) 에 암달의 법칙을 넣으면 식은 다음과 같이 된다.


Speedup = 1 / ( B / O + (1 - B / O) / N )


B = 0.4, O = 2, N = 5 라면 계산은 다음과 같이 된다.


Speedup = 1 / ( 0.4 / 2 + (1 - 0.4 / 2) / 5)
        = 1 / ( 0.2 + (1 - 0.4 / 2) / 5)
        = 1 / ( 0.2 + (1 - 0.2) / 5 )
        = 1 / ( 0.2 + 0.8 / 5 )
        = 1 / ( 0.2 + 0.16 )
        = 1 / 0.36
        = 2.77777 ...


이 계산이 의미하는 것은, 병렬화 가능하지 않은 부분(순차적 실행)을 인자 2 로 최적화하고 병렬화 가능한 부분을 인자 5 로 병렬화하면 프로그램의 실행은 최대 2.77777 배로 빨라진다는 것이다.



계산에 그치지 말고 측정하라


암달의 법칙을 통해 프로그램의 향상된 속도를 계산해낼 수 있기는 하지만, 이 계산에 지나치게 의존해선 안된다. 실제로 알고리즘을 최적화 또는 병렬화 할 때는 다른 많은 요인들이 작용할 수 있다.


메모리의 속도, CPU 캐시 메모리, 디스크, 네트워크 카드 기타 등등의 것들이 그 제한 요인이 된다. 만약 새 알고리즘이 병렬화 되었으나 CPU 캐시 미스를 더 많이 일으킨다면 CPU 의 수 만큼의 기대했던 속도 향상을 얻을 수 없다. 알고리즘이 메모리 버스나 디스크 혹은 네트워크 카드 또는 커넥션을 지나치게 사용하게 된다면 역시 같은 결과가 발생한다.


암달의 법칙은 프로그램의 어디를 최적화 할 것인지에 대한 아이디어를 얻기 위해 사용하고, 실제로 얼마만큼의 최적화 속도 향상이 있을지는 측정을 통해 알아보아야 한다. 종종 높은 순차적 실행 알고리즘(단일 CPU)은 병렬 알고리즘보다 나은 성능을 보인다. 이유는 간단하다. 순차적 실행은 동시 실행에 따르는 (작업을 중단하고 다시 시작하는 등의)오버헤드가 없고, 단일 CPU 알고리즘은 하드웨어의 작업에 에 더 친화적일 수 있다. (CPU 파이프라인, CPU 캐시 등)







이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/non-blocking-algorithms.html



동시성에서의 논 블로킹 알고리즘이란 쓰레드간의 공유된 상태(자원)로의 접근을 서로의 중단 없이 수행하도록 하는 것이다. 보다 일반적인 용어로 말하자면, 어떤 알고리즘에서 한 쓰레드의 정지가 여기에 관련된 다른 쓰레드의 정지를 유발하지 않는다면 이 알고리즘을 논 블로킹 알고리즘이라 한다.


블로킹과 논 블로킹 동시성 알고리즘의 차이를 보다 잘 이해하기 위해서 먼저 블로킹 알고리즘을 다루고 이어서 논 블로킹 알고리즘을 다루도록 한다.



블로킹 동시성 알고리즘


다음과 같은 알고리즘을 블로킹 동시성 알고리즘이라 한다.


  • A: 쓰레드에 의해 요청된 동작을 수행 - 또는
  • B: 쓰레드가 동작을 수행할 수 있을 때까지 대기


많은 종류의 블로킹 알고리즘과 동시성 블로킹 자료구조가 있다. 예를 들어, java.util.concurrent.BlockingQueue 인터페이스의 구현체들은 모두 블로킹 자료구조이다. 쓰레드가 BlockingQueue 에 엘레멘트를 삽입하려 하는데 큐에 공간이 없다면 쓰레드는 공간이 생길 때까지 대기한다.


다음 다이어그램은 공유된 자료구조를 보호하는 블로킹 알고리즘의 동작을 보여준다.




논 블로킹 동시성 알고리즘


다음과 같은 알고리즘을 논 블로킹 동시성 알고리즘이라 한다.


  • A: 쓰레드에 의해 요청된 동작을 수행 - 또는
  • B: 요청된 동작이 수행될 수 없는 경우 이를 요청 쓰레드에게 통지


자바는 몇몇 논 블로킹 자료구조를 가지고 있다. AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference 클래스들은 모두 논 블로킹 자료구조의 예이다.


다음 다이어그램은 공유된 자료구조를 보호하는 논 블로킹 알고리즘의 동작을 보여준다.




논 블로킹 vs 블로킹


블로킹과 논 블로킹 알고리즘의 주된 차이점은 위 각 알고리즘의 동작 설명 중 두 번째 단계에 있다. 다시 말해서, 블로킹 알고리즘과 논 블로킹 알고리즘의 차이는 요청된 동작이 수행될 수 없을 때 어떻게 대응하느냐에 있다.


블로킹 알고리즘은 쓰레드가 요청한 동작이 수행 가능해질 때까지 대기한다. 논 블로킹 알고리즘은 요청된 동작이 현재 수행 불가능하다는 사실을 쓰레드에게 알린다.


블로킹 알고리즘을 사용하면 쓰레드는 요청 동작이 수행 가능해질 때까지 대기하게 될 수 있다고 했는데, 이것은 일반적으로 첫번째 스레드가 요청된 작업을 수행할 수 있게 하는 다른 쓰레드의 동작이 된다. 어떤 이유로 쓰레드가 어플리케이션의 다른 곳에서 동작이 중단되어 첫번째 스레드가 요청한 작업을 수행할 수 없는 경우 첫번째 쓰레드는 무기한, 혹은 다른 쓰레드가 필요한 동작을 수행할 때까지 중단된다.


예를 들어, 쓰레드가 꽉 찬 BlockingQueue 안에 엘레멘트를 삽입하려 하면 이 쓰레드는 다른 쓰레드가 큐에서 엘레멘트를 꺼내갈 때까지 동작이 중단된다. 만일 어떤 이유로 큐에서 엘레멘트를 꺼내가야 할 쓰레드의 동작이 어플리케이션의 다른 영역에서 중단된다면, 큐에 엘레멘트를 삽입하기 위해 대기중인 쓰레드의 중단 상태는 해제되지 못한다. 대기 상태는 무기한 지속되거나, 혹은 다른 쓰레드가 큐에서 엘레멘트를 꺼내갈 때까지 지속된다.



논 블로킹 동시성 자료구조


멀티쓰레드 시스템에서 쓰레드들은 일반적으로 어떤 종류의 자료구조를 통해 서로 협업한다. 단순한 형태의 다양한 자료구조에서부터 큐, 맵, 스택과 같은 보다 발전된 형태의 자료구조들 중 어느 것이든 이러한 자료구조로 사용될 수 있다. 다수의 쓰레드의 동시 접근에 제대로 기능하기 위해, 자료구조는 반드시 동시성 알고리즘에 의해 보호되어야 한다. 이런 알고리즘이 자료구조를 동시성 자료구조로 만드는 것이다.


동시성 자료구조를 보호하는 알고리즘이 쓰레드를 중단시키는 방법을 통한다면 이 알고리즘을 블로킹 알고리즘이라 한다. 고로, 자료구조는 블로킹 동시성 자료구조가 된다.


동시성 자료구조를 보호하는 알고리즘이 쓰레드를 중단시키지 않는 방법을 통한다면 이 알고리즘을 논 블로킹 알고리즘이라 한다. 고로, 자료구조는 논 블로킹 동시성 자료구조가 된다.


이런 각 동시성 자료구조는 특정한 방법의 쓰레드 간 협업을 지원하기 위해 설계되었다. 어떤 동시성 자료구조를 사용할 것이냐는 쓰레드 간 협업이 어떤 방식으로 이루어진 것이냐에 달려있다. 다음 섹션에서 몇가지 논 블로킹 자료구조를 다루면서 어떤 상황에 이 자료구조들이 사용될 수 있는지 설명한다. 논 블로킹 자료구조의 동작 방식에 대한 설명은 논 블로킹 자료구조의 설계와 구현에 대한 방안을 제공할 것이다.



volatile 변수


자바 volatile 변수는 변수의 값을 항상 메인 메모리에서 직접 읽히도록 한다. volatile 변수에 새 값이 대입되면 이 값은 언제나 메인 메모리로 즉시 쓰여진다. 이 동작은 다른 CPU 에서 동작하는 다른 쓰레드에게 항상 volatile 변수의 최신 값이 읽히도록 보장한다. volatile 변수의 값은 CPU 캐시가 아닌 메인 메모리로부터 직접 읽히게 된다.


volatile 변수는 논 블로킹이다. volatile 변수로의 쓰기는 원자적 연산이고, 다른 쓰레드가 끼어들 수 없다. 하지만 volatile 변수라 할지라도 연속적인 읽기-값 변경-쓰기 동작은 원자적이지 않다. 다음 코드는 둘 이상의 쓰레드에 의해 수행된다면 여전히 경합을 유발한다.


volatile myVar = 0;

...
int temp = myVar;
temp++;
myVar = temp;


처음에 volatile 변수인 myVar 의 값은 메인 메모리로부터 읽어들여 temp 변수로 대입된다. 다음으로 temp 변수의 값은 1 증가된다. 그리고 temp 변수의 값은 myVar 로 다시 대입되어, 값은 메인 메모리로 저장된다.


두 쓰레드가 이 코드를 실행하여 똑같이 myVar 을 읽고, 값을 1 증가시키고, 값을 메인 메모리로 저장한다면, myVar 의 증가되는 값은 2 가 아닌, 1 이 될 여지가 있다. (예: 두 쓰레드가 값 19 를 읽어 각각 값을 증가시키고 저장하지만 결과값은 21 이 아닌 20 이 된다.) 


위와 같은 코드를 작성할 일은 아마도 없겠지만, 위 코드는 사실 아래 코드와 동일하다.


myVar++;


위 코드가 실행되면 myVar 의 값은 CPU 레지스터 또는 CPU 캐시로 읽히고, 1 증가되고, CPU 레지스터나 CPU 캐시에서 다시 메인 메모리로 저장된다.



The Single Writer Case


공유 변수로 쓰기를 수행하는 쓰레드가 하나이고 이 변수를 읽는 쓰레드가 다수일 때는 어떠한 경합도 발생하지 않는다. 읽기 쓰레드가 몇 개이든 아무런 문제가 되지 않는다. 따라서 쓰기 쓰레드가 하나인 경우라면 언제든 volatile 변수를 사용해도 좋다.


경합 조건은 다수의 쓰레드가 같은 공유 변수에 대해 읽기-값 변경-쓰기 순서의 연산을 수행할 때 발생한다. 읽기-값 변경-쓰기 연산을 수행하는 쓰레드가 하나 뿐이고, 나머지 모든 쓰레드는 읽기 연산만을 수행한다면 경합 조건은 발생하지 않는다.


다음은 synchronized 를 사용하지 않고도 동시성을 유지하는 single writer counter 이다.


public class SingleWriterCounter { private volatile long count = 0; /** * 오직 한 쓰레드만 이 메소드를 호출해야 함. * 그렇지 않으면 경합 조건을 유발할 수 있음. */ public void inc() { this.count++; } /** * 다수의 읽기 쓰레드가 이 메소드를 호출할 수 있음. * @return */ public long count() { return this.count; } }


inc() 메소드를 호출하는 쓰레드가 하나라는 전제에 한하여 SingleWriterCounter 클래스의 인스턴스는 다수의 쓰레드가 접근할 수 있다. 이 말은 inc() 메소드를 호출하는 쓰레드가 한 시점에 하나여야 한다는 뜻이 아니다. 시점을 막론하고 오직 한 쓰레드만이 inc() 를 호출하여야 한다. count() 메소드는 다수의 쓰레드에 의해 호출될 수 있으며, 이는 어떠한 경합 조건도 유발하지 않는다.


다음 다이어그램은 다수의 쓰레드가 volatile 변수 count 로 어떻게 접근하는지 보여준다.




volatile 기반의 더 발전된 자료구조


volatile 변수로의 쓰기 쓰레드가 하나이고 읽기 쓰레드가 둘 이상인 경우에 volatile 변수의 조합을 사용하는 자료구조를 만들 수 있다. 각 volatile 변수는 각각의, 서로 다른 쓰기 쓰레드가 접근할 수 있다(volatile 변수 하나당 하나의 쓰기 쓰레드이다. 즉 한 volatile 변수로의 쓰기 쓰레드는 역시 반드시 하나여야 한다.) 이런 구조를 가진 자료구조를 사용하면 쓰레드들이 논 블로킹 방식으로 서로 정보를 주고받을 수 있다. 


다음은 두 개의 쓰기 카운터를 가진 간단한 클래스이다. 어떻게 구현되었나 보자.


public class DoubleWriterCounter { private volatile long countA = 0; private volatile long countB = 0; /** * 오직 한 쓰레드만이 이 메소드를 호출해야 함. * 그렇지 않으면 경합 조건을 유발할 수 있음. */ public void incA() { this.countA++; } /** * 오직 한 쓰레드만이 이 메소드를 호출해야 함. * 그렇지 않으면 경합 조건을 유발할 수 있음. */ public void incB() { this.countB++; } /** * 다수의 읽기 쓰레드가 이 메소드를 호출할 수 있음. */ public long countA() { return this.countA; } /** * 다수의 읽기 쓰레드가 이 메소드를 호출할 수 있음. */ public long countB() { return this.countB; } }


DoubleWriterCounter 클래스는 이제 두 volatile 변수를 가진다. 그리고 각각의 증가, 읽기 메소드를 따로 가진다. incA() 메소드를 호출하는 쓰레드는 하나여야 하고, 마찬가지로 incB() 를 호출하는 쓰레드도 하나여야 한다. incA() 호출 쓰레드와 incB() 호출 쓰레드는 달라도 된다. countA() 와 countB() 메소드 호출은 어떤 쓰레드든 상관없이 가능하다. 여기에 경합 조건은 없다.


DoubleWriterCounter 클래스는 두 쓰레드간의 협업에 사용될 수 있다. 두 카운트 변수는 생산-소비 형태로 사용된다. 다음 다이어그램은 위와 같은 자료구조를 이용한 두 쓰레드간의 협업을 나타낸다.




눈치 빠른 독자들은 두 개의 SingleWriterCounter 인스턴스를 사용함으로 DoubleWriteCounter 를 사용하는 것과 같은 효과를 얻을 수 있다는 사실을 알 것이다. 필요에 따라 SingleWriterCounter 인스턴스의 수를 얼마든지 늘려서 사용할 수 있다.



컴페어 스왑을 통한 낙관적 락


다중 쓰기 쓰레드들의 공유 변수로의 접근이 꼭 필요한 상황이라면 volatile 변수만으로는 충분하지 못하다. 타 쓰레드의 접근을 배제할 수 있는 방법이 필요하다. 일단 synchronized 블록을 통한 상호 배제를 보자.


public class SynchronizedCounter {
    long count = 0;

    public void inc() {
        synchronized(this) {
            count++;
        }
    }

    public long count() {
        synchronized(this) {
            return this.count;
        }
    }
}


inc() 와 count() 메소드는 synchronized 블록을 사용한다. 우리는 이러한 방식(synchronized 블록과 wait()-notify() 호출 등등)의 동기화를 피하고자 한다.


synchronized 블록을 사용하는 대신 우리는 자바의 원자적 변수를 사용한다. 여기서는 AtomicLong 이 그것이다. 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();
    }
}


AtomicCounter 는 SynchronizedCounter 와 마찬가지로 쓰레드 세이프하다. 여기서 흥미로운 점은 inc() 메소드의 구현이다. inc() 메소드는 더이상 synchronized 블록을 사용하지 않는다. 대신 다음과 같은 코드가 있다.


boolean updated = false;
while(!updated){
    long prevCount = this.count.get();
    updated = this.count.compareAndSet(prevCount, prevCount + 1);
}


위 코드는 원자적 연산은 아니다. 즉 두 개의 서로 다른 쓰레드가 동시에 inc() 메소드를 호출하여 long prevCount = this.count.get(); 코드를 실행하는 일이 가능하다. 두 쓰레드 모두 count 가 증가하기 전의 값을 얻을 수 있다. 여기까진 어떠한 경합도 발생하지 않는다.


여기서 중요한 코드는 while 루프 안의 두 번째 라인에 있다. compareAndSet() 메소드 호출은 원자적 연산이다. 이 메소드는 AtomicLong 내부의 기존 값과 내부의 값이라 예상되는 값을 비교하여 두 값이 일치한다면 새로운 값을 세팅한다. 이 동작의 수행은 일반적으로 CPU 의 컴페어 스왑 명령을 직접 사용한다. 때문에 synchronized 블록은 필요치 않으며, 쓰레드를 중단할 일도 없다. 덕분에 쓰레드 중단에 따르는 오버헤드는 발생하지 않는다.


AtomicLong 의 내부 값이 20 이라고 하면, 두 쓰레드는 이 값을 읽는다. 그리고 compareAndSet(20, 20 + 1) 을 호출한다. compareAndSet() 메소드는 원자적 연산이므로, 실행은 순차적으로 이루어진다(한 시점에 한 번).


첫 번째 쓰레드는 예상 값 20(counter 의 이전 값)을 AtomicLong 의 내부 값과 비교한다. 두 값은 일치하고, AtomicLong 은 내부 값을 21 로 세팅한다(20 + 1). updated 변수는 true 가 되고 while 루프는 정지된다.


이제 두 번째 쓰레드를 보자. 쓰레드는 compareAndSet(20, 20 + 1) 을 호출한다. AtomicLong 의 내부 값은 더이상 20 이 아니다. 이 호출은 실패한다. AtomicLong 의 내부 값은 21 로 세팅되지 않는다. updated 변수는 false 가 되고, while 루프는 계속된다. 이번엔 내부 값 21 을 읽어서 1 증가된 값인 22 를 세팅하려 시도한다. 이 과정에서 다른 쓰레드의 개입이 없다면 이 시도는 성공하고 AtomicLong 의 값은 22 가 된다.



왜 낙관적 락인가?


이전 섹션의 코드는 낙관적 락이라 불린다. 낙관적 락은 종종 비관적 락이라 불리는, 전통적인 락과는 차이가 있다. 전통적인 락은 synchronized 블록이나 기타 락을 사용해 공유 메모리로의 접근을 차단한다. 이런 방식은 쓰레드의 중단을 일으킨다.


낙관적 락은 쓰레드의 접근을 차단하지 않고, 모든 쓰레드가 공유 메모리의 복제본을 생성한다. 쓰레드는 자신이 가진 본사본의 데이터를 수정할 수 있으며, 수정된 데이터를 공유 메모리로 쓰기 시도할 수 있다. 다른 쓰레드가 이 데이터를 변경하지 않았다면 컴페어 스왑 연산을 통해 쓰기 쓰레드는 공유 메모리의 데이터를 변경할 수 있다. 만약 다른 쓰레드가 먼저 데이터를 변경했다면, 쓰기 쓰레드는 변경된 새로운 값을 읽어서 다시 변경을 시도한다.


이것이 낙관적 락이라 불리는 이유는, 쓰레드가 데이터의 복제본을 얻어서 데이터를 변경하고 이 변경을 적용하는 동작이, 이 과정에서 다른 쓰레드가 데이터를 변경하지 않았다는 낙관적인 추정 하에 이루어지기 때문이다. 이 추정이 맞는다면 쓰레드는 락 없이 공유 메모리의 데이터를 변경하는 작업에 성공한다. 추정이 틀린다면 쓰레드의 변경 작업은 실패하지만, 여전히 락은 없다.


낙관적 락은 공유 메모리의 경합이 낮거나 중간 수준일 때 가장 잘 동작하는 경향이 있다. 경합이 매우 높으면, 쓰레드는 많은 CPU 싸이클을 공유 메모리를 복제하고 변경하는 데에 낭비하게 되고 공유 메모리로 데이터의 변경을 쓰는 데에 실패하게 된다. 이런 경우에는 경합을 낮추는 방향으로 코드를 재설계할 것을 권한다.



낙관적 락은 논 블로킹


여기서 소개한 낙관적 락 메카니즘은 논 블로킹이다. 한 쓰레드가 공유 메모리의 복제본을 얻어 데이터를 변경하는 과정에서 어떤 이유로 정지상태가 되더라도 같은 공유 메모리로 접근하는 다른 쓰레드들이 정지되는 일은 없다.


전통적인 락/언락 패러다임에서는 한 쓰레드가 락을 걸면 이 락은 락을 건 쓰레드에 의해 해제될 때까지 다른 쓰레드들의 접근을 차단한다(정지상태). 락을 건 쓰레드가 어떤 이유로 정지상태가 되면, 락은 매우 오랜 시간동안 해제되지 않은 상태로 유지된다. -무기한 지속될 수 있다.



교체될 수 없는 자료구조(Non-swappable Data Structures)


컴페어 스왑을 통한 낙관적 락은 공유 자료구조에 알맞게 동작한다. 이 공유 자료구조는 단일 컴페어 스왑 연산에서 전체 자료구조가 새로운 자료구조로 교체될 수 있는(swappable) 성질을 가진다. 그러나 전체 자료구조를 변경된 복제본으로 교체하는 일이 언제나 가능한 것은 아니다.


공유 자료구조가 큐라고 해보자. 이 큐에 엘레멘트를 넣거나 빼려고 하는 각 쓰레드는 먼저 큐의 전체 복제복을 가지고 이 복제본의 데이터를 변경한다. 이 동작은 AtomicReference 를 통해 이루어진다. 참조를 복제하고, 큐를 복제하여 수정하고, AtomicReference 안의 참조를 새로 생성된 큐와 교체한다.


하지만 자료구조의 크기가 클 경우 이러한 복제 동작은 많은 메모리와 CPU 싸이클을 요구한다. 어플리케이션의 메모리 사용량을 증가시키고, 많은 시간이 낭비된다. 이는 결국 어플리케이션의 성능에 영향을 미치는데, 특히 자료구조에 대한 경합이 높을 경우 이 영향은 더욱 심해진다. 뿐만 아니라, 쓰레드가 자료구조를 복제하고 수정하는 시간이 길수록 다른 쓰레드가 중간에 자료구조를 수정했을 가능성이 높아진다. 알다시피 다른 쓰레드가 공유 자료구조를 복제하여 수정하면 자료구조가 복제된 이후로 다른 쓰레드들은 복제-수정 작업을 다시 시작해야 한다. 그리고 이것은 성능과 메모리 소비량에 더욱 큰 영향을 미친다.


다음 섹션에서는 복제-수정이 아닌, 동시에 수정할 수 있는 논 블로킹 자료구조의 구현 방안에 대하여 소개한다.



변경 예정 공유하기


쓰레드는 전체 자료구조를 복제하고 수정하는 대신, 공유 자료구조의 변경 예정을 공유할 수 있다. 공유 자료구조를 변경하려는 쓰레드의 프로세스는 다음과 같다.


  1. 다른 쓰레드가 변경 예정을 공유 자료구조로 전달했는지 확인한다.
  2. 공유 자료구조로 변경 예정을 전달한 쓰레드가 없다면, 변경 예정을 만든다. 그리고 이 예정을 공유 자료구조로 전달한다(컴페어 스왑)
  3. 공유 자료구조의 변경을 수행한다.
  4. 다른 쓰레드에게 예정된 변경이 수행되었음을 알리기 위해 변경 예정으로의 참조를 제거한다.


보다시피 두 번째 스탭에서 다른 쓰레드의 변경 예정 전달은 차단된다. 이로 인해 두 번째 스탭은 공유 자료구조에 락을 건 것과 같이 동작한다. 한 쓰레드가 변경 예정을 성공적으로 전달했다면, 공유 자료구조에 변경이 수행될 때까지 다른 쓰레드는 변경 예정을 전달할 수 없다.


쓰레드가 변경 예정을 전달한 뒤 다른 작업을 수행하던 중 동작이 정지된다면, 공유 자료구조는 락이 걸린다. 공유 자료구조는 자료구조를 사용하는 쓰레드를 직접 정지시키지 않는다. 공유 자료구조에 변경 예정을 전달할 수 없음을 인지한 쓰레드는 다른 작업을 수행한다. 물론 이 동작은 필요에 따라 바꿀 수 있다.



완료될 수 있는 변경 예정


공유 자료구조로 전달된 변경 예정은 자료구조에 락을 걸 수 있다. 변경 예정 객체는 다른 쓰레드가 변경을 완료했음을 알 수 있는 충분한 정보를 가지고 있어야 한다. 따라서 변경 예정을 전달한 쓰레드가 변경을 완료하지 못하면 다른 쓰레드가 이를 대신하여 그 변경을 완료하고 다른 쓰레드가 공유 자료구조를 사용할 수 있도록 유지할 수 있다.


다음 다이어그램은 여기서 설명한 논 블로킹 알고리즘의 설계를 보여준다.




변경은 반드시 하나 이상의 컴페어 스왑 연산으로 수행되어야 한다. 두 쓰레드가 예정된 변경을 완료하려 시도하면 그 중 한 쓰레드만이 컴페어 스왑 연산을 완료할 수 있다. 그리고 컴페어 스왑 연산이 완료된 직후의 다음 시도는 실패한다.



A-B-A 문제


위 다이어그램의 알고리즘은 A-B-A 문제에 부딪힐 수 있다. A-B-A 문제란 어떤 변수의 값이 A 에서 B 로 변경되었는데 다시 A 로 돌아가는 상황을 가리킨다. 다른 쓰레드는 변수가 실제로 변경되었는지 감지할 수 없다.


쓰레드 A 가 진행중인 변경을 확인하고 데이터를 복제한 뒤 쓰레드 스케쥴러에 의해 동작이 중단되면, 그 사이 쓰레드 B 는 공유 자료구조에 접근할 수 있다. 쓰레드 B 가 공유 자료구조를 완전히 변경한 뒤 변경 예정을 삭제하면, 쓰레드 A 에게는 자신이 데이터를 복제한 이후로 공유 자료구조에 어떠한 변경도 발생하지 않았던 것처럼 보이게 된다. 그러나 변경은 발생하였고, 쓰레드 A 는 자신이 복제했었던 데이터(쓰레드 B 가 데이터를 변경하기 전)를 기준으로 자신의 변경을 수행한다.


다음 다이어그램이 위와 같은 A-B-A 문제를 보여준다.




A-B-A 문제의 해결책


A-B-A 문제의 해결책으로 널리 알려진 방법은 데이터 변경 시에 변경 예정 객체의 포인터만 교체하는 것이 아니라, 포인터와 카운터를 함께 교체하는 것이다. 이 교체는 단일 컴페어 스왑 연산을 사용한다. 이 방법은 C 와 C++ 과 같은 포인터를 지원하는 언어에서 구현 가능하다. 현재 변경 객체의 포인터가 '진행중인 변경 없음' 을 나타낸다 해도, 포인터+카운터에서 카운터가 증가되어 변경 사실을 인지시켜준다. 


자바에서 참조와 카운터를 한 변수로 묶는 일은 불가능하다. 대신 자바는 AtomicStampedReference 클래스를 제공한다. 이 클래스는 컴페어 스왑 연산을 사용해서 참조와 스탬프를 교체할 수 있다.



논 블로킹 알고리즘 템플릿


아래 코드는 논 블로킹 알고리즘의 구현 방법을 보여주는 코드 템플릿이다. 이 템플릿은 본문에서 설명된 내용을 근거로 한다.


NOTE: 나는 논 블로킹 알고리즘에 있어 전문가가 아니기 때문에, 이 템플릿에는 몇 가지 오류가 있을 수 있다. 이 템플릿을 당신의 논 블로킹 알고리즘 구현물의 기반으로 삼지 않도록 하라. 이 템플릿은 논 블로킹 알고리즘이 어떤 식으로 구현될 수 있는지에 대한 방향을 제공할 뿐이다. 논 블로킹 알고리즘을 스스로 구현하고 싶다면 실제 논 블로킹 알고리즘 구현체를 두고 연구해야 한다.


import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicStampedReference; public class NonblockingTemplate { public static class IntendedModification { public AtomicBoolean completed = new AtomicBoolean(false); } private AtomicStampedReference<IntendedModification> ongoingMod = new AtomicStampedReference<IntendedModification>(null, 0); // 자료구조의 상태를 선언한다. public void modify() { while(!attemptModifyASR()); } public boolean attemptModifyASR(){ boolean modified = false; IntendedModification currentlyOngoingMod = ongoingMod.getReference(); int stamp = ongoingMod.getStamp(); if(currentlyOngoingMod == null){ //변경 예정을 만들기 위한 자료구조 상태를 복제한다. //변경 예정을 준비한다. IntendedModification newMod = new IntendedModification(); boolean modSubmitted = ongoingMod.compareAndSet(null, newMod, stamp, stamp + 1); if(modSubmitted){ //컴페어 스왑 연산을 통해 변경을 완료한다. //note: 다른 쓰레드가 컴페어 스왑 연산의 완료를 도울 수 있기 때문에, //CAS 는 실패할 수 있음. modified = true; } } else { //진행중인 변경 작업을 완료하려고 시도하므로, 이 쓰레의 접근을 허용하기 위해 //자료구조는 해제된다. modified = false; } return modified; } }



논 블로킹 알고리즘 구현의 어려움


논 블로킹 알고리즘을 제대로 설계하고 구현하기란 난이도가 상당한 일이다. 논 블로킹 알고리즘을 구현하려 한다면, 직접 해보기에 앞서 다른 사람이 구현한 기존의 논 블로킹 알고리즘을 참고하기 바란다.


자바에는 이미 몇가지 논 블로킹 구현체가 있고(ex: ConcurrentLinkedQueue), 앞으로 선보일 자바에서 더 추가될 예정이다.


자바의 내장 논 블로킹 자료구조에 더하여 당신이 사용할 수 있는 몇가지 오픈소스 논 블로킹 자료구조가 있다. 예로, LMAX Disrupter(큐 성격의 자료구조), Cliff Click 의 논 블로킹 해시맵이 그것이다. 자바 컨커런시 레퍼런스 페이지에서 더 많은 자료를 찾아볼 수 있다.



논 블로킹 알고리즘의 이점


블로킹 알고리즘과 비교하여 논 블로킹 알고리즘의 몇가지 이점이 있다.



선택


논 블로킹 알고리즘의 첫 번째 이점은, 쓰레드가 요청된 작업을 수행할 수 없는 경우 어떤 동작을 취할 지 선택할 수 있다는 것이다. 그저 동작을 정지하고 대기하는 대신, 쓰레드는 무엇을 할지 선택할 수 있다. 물론 쓰레드가 할 일이 없는 경우도 있을 수 있다. 이런 경우에는 스스로 동작을 정지하고 대기하도록 할 수 있다. 이렇게 함으로 CPU 자원은 는 다른 작업을 위해 쓰일 수 있을 것이다. 어쨌든 적어도 쓰레드는 선택권을 갖는다.


단일 CPU 시스템에서 요청된 동작을 수행할 수 없는 쓰레드를 정지시키는 일은 합리적이다. 이렇게 하면 다른 쓰레드가 CPU 를 사용하여 작업을 수행할 수 있다. 하지만 단일 CPU 시스템에서도 블로킹 알고리즘은 데드락이나 기아상태, 기타 다른 동시성 문제를 야기할 수 있다.



노 데드락


논 블로킹 알고리즘의 두 번째 이점은, 한 쓰레드의 정지가 다른 쓰레드의 정지를 일으키지 않는다는 것이다. 이 말은 데드락이 발생하지 않음을 의미한다. 두 쓰레드가 서로의 락이 해제되기를 기다리는 상황은 발생하지 않는다. 요청된 동작을 수행할 수 없는 쓰레드라도 정지되어 대기 상태로 들어가지 않기 때문이다. 논 블로킹 알고리즘에서도 라이브 락은 발생할 수 있다. 두 쓰레드가 요청된 동작을 수행하려 하지만 다른 쓰레드의 동작 수행으로 인해 이 동작이 수행 불가능한 상황이다. 두 쓰레드는 수행 불가능을 통보받지만 계속해서 수행을 시도한다.



노 쓰레드 서스펜션


쓰레드를 정지하고 활성화하는 일에는 비용이 든다. 운영체제와 쓰레드 라이브러리가 발전해가면서 줄어들어들긴 했지만, 쓰레드 정지와 활성화를 위한 비용은 여전히 크다.


쓰레드는 동작이 차단되면서 정지 상태가 된다. 따라서 쓰레드 정지-활성화 오버헤드를 일으킨다. 논 블로킹 알고리즘에서 쓰레드는 정지되지 않기 때문에, 이 오버헤드는 발생하지 않는다. CPU 는 컨택스트 스위칭에 사용될 잠재적인 시간 비용을 실제 비즈니스 로직을 수행하는 데에 사용할 수 있다는 뜻이다.


멀티 CPU 시스템에서 블로킹 알고리즘은 전체적인 성능에 있어 큰 영향을 미칠 수 있다. CPU A 에서 동작하는 쓰레드가 CPU B 의 쓰레드를 기다리며 정지될 수 있다. 이는 어플리케이션의 병행성을 낮추게 된다. 물론 CPU A 는 다른 쓰레드가 동작하도록 조정할 수 있을 것이다. 하지만 쓰레드의 정지-활성화라는 컨텍스트 스위칭 비용은 비싸다. 쓰레드가 정지되지 않는 편이 낫다.



쓰레드 지연 감소


여기서 지연이란 요청된 동작이 수행 가능해진 시점부터 실제로 쓰레드가 동작을 수행하는 시점까지의 시간을 의미한다. 논 블로킹 알고리즘에서 쓰레드는정지되지 않기 때문에 쓰레드 활성화에 드는 비싸고 느린 비용을 지불하지 않는다. 이는 어떤 요청 동작이 수행 가능해지면 쓰레드는 보다 빠르게 응답을 줄 수 있다는 의미이다. 따라서 응답 지연은 감소한다.


논 블로킹 알고리즘은 요청 동작이 수행 가능해질 때까지 바쁜 대기(busy-waiting)를 통해 지연을 낮춘다. 물론 논 블로킹 자료구조에 대한 쓰레드 경합이 높아지면 CPU 는 바쁜 대기에 많은 싸이클을 소모하게 된다. 이 사실은 염두에 두어야 한다. 당신의 자료구조가 높은 쓰레드 경합을 가진다면 논 블로킹 알고리즘은 최선이 아닐 수 있다. 그러나 쓰레드 경합을 낮추도록 어플리케이션을 재설계하는 방법도 존재한다.



'Java > Concurrency' 카테고리의 다른 글

암달의 법칙(Amdahl's Law)  (1) 2017.06.17
동기화 장치 해부(Anatomy of a Synchronizer)  (0) 2017.05.25
컴페어 스왑(Compare and Swap)  (0) 2017.05.24
쓰레드 풀(Thread Pools)  (0) 2017.05.22
블로킹 큐(Blocking Queues)  (3) 2017.05.22
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/anatomy-of-a-synchronizer.html



많은 동기화 장치(락, 세마포어, 블로킹 큐, 기타 등등)에 기능적 차이가 있더라도, 이 동기화 장치들의 내부 설계는 모두 유사하게 이루어져 있다. 다시 말해서 이 장치들은 내부적으로 같은(또는 유사한) 기본 부분들로 구성되어 있다. 이 기본 부분들에 대한 지식은 동기화 장치를 설계할 때 큰 도움이 될 수 있다. 이 글은 이 기본 부분들에 대해 다룬다.


※ 본문의 내용은 2004년 봄 Jakob Jenkov, Toke Johansen, Lars Bjørn 코펜하겐 IT 대학 이학 석사과정 프로젝트 결과물의 일부분이다. 이 프로젝트를 진행하는 동안 우리는 Doug Lea 에게 프로젝트 관련 연구에 대한 자문을 구했다. 흥미롭게도 그는 이 프로젝트와는 독립적으로, Java 5 의 동시성 유틸리티 개발 과정에서 비슷한 결론을 내놓았다.  Doug Lea 의 연구는 책 'Java Concurrency in Practice' 에 설명되어 있다. 또한 이 책은 '동기화 장치 해부' 챕터를 담고 있는데, 챕터의 내용은 본문의 그것과 유사하지만 완전히 같지는 않다.


모두는 아닐지라도, 대부분의 동기화 장치의 목적은 코드의 특정한 부분(크리티컬 섹션)을 쓰레드들의 동시 접근으로부터 보호하는 데에 있다. 이를 위해 동기화 장치는 다음의 각 부분들을 필요로 한다.


  1. 상태
  2. 접근 조건
  3. 상태 변경
  4. 통지 전략
  5. 확인-설정 메소드
  6. 설정 메소드


이 모든 부분들을 정확히 똑같이 가지고 있는 동기화 장치가 있을 수도 있지만, 모든 동기화 장치가 이 부분들을 모두 가지고 있는 것은 아니다. 보통 동기화 장치에는 이들 중 하나 이상의 부분들이 있다.



상태


동기화 장치의 상태는 쓰레드가 접근 권한을 획득할 수 있는지 결정하기 위한 접근 조건에 사용된다. Lock 에서의 상태는 boolean 타입으로, Lock 이 잠겼는지 잠기지 않았는지를 나타낸다. BoundedSemaphore 는 내부에 카운터(int) 와 최대치(int) 라는 상태를 둔다. 카운터는 세마포어를 획득한 쓰레드의 수이고, 최대치는 쓰레드가 획득 가능한 최대(제한) 세마포어의 수이다. 블로킹 큐에서는 큐의 엘레멘트 목록과 큐 최대 크기(int) 를 상태로 둔다.


다음은 Lock 과 BoundedSemaphore 의 코드 일부이다. 상태 코드는 볼드 처리되었다.


public class Lock{

  //state is kept here
  private boolean isLocked = false; 

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  ...
}
public class BoundedSemaphore {

  //state is kept here
      private int signals = 0;
      private int bound   = 0;
      
  public BoundedSemaphore(int upperBound){
    this.bound = upperBound;
  }

  public synchronized void take() throws InterruptedException{
    while(this.signals == bound) wait();
    this.signal++;
    this.notify();
  }
  ...
}



접근 조건


접근 조건은 확인-설정 메소드를 호출하는 쓰레드가 상태를 설정할 수 있도록 허용할지를 판단하는 조건이다. 접근 조건은 일반적으로 동기화 장치의 상태에 기반을 둔다. 보통 while 루프 안에서 접근 조건을 이용하여 상태가 조건에 맞는지를 확인하는 방법으로 Spurious Wakeups 에 대응한다. 접근 조건의 확인 결과는 true 또는 false 로 나타낸다.


Lock 에서의 접근 조건은 간단히 isLocked 멤버 변수의 값을 확인하는 것이다. BoundedSemaphore 에서의 접근 조건은 세마포어를 '획득하는지' 혹은 '해제하는지' 에 따른 두 가지 접근 조건이 있다. 쓰레드가 세마포어를 획득하려 하면 signals 변수의 값이 획득 상한값에 다다르지 않았는지 확인한다. 쓰레드가 세마포어를 해제하려 하면 signals 변수의 값이 0 이 아닌지 확인한다.


다음은 LockBoundedSemaphore 의 코드 일부이다. 접근 조건은 볼드 처리되었다. while 루프 안에서 접근 조건이 어떤식으로 작동하는지 알 수 있다.


public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    //access condition
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  ...
}
public class BoundedSemaphore {
  private int signals = 0;
  private int bound   = 0;

  public BoundedSemaphore(int upperBound){
    this.bound = upperBound;
  }

  public synchronized void take() throws InterruptedException{
    //access condition
    while(this.signals == bound) wait();
    this.signals++;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    //access condition
    while(this.signals == 0) wait();
    this.signals--;
    this.notify();
  }
}



상태 변경


쓰레드가 크리티컬 섹션으로의 접근을 획득하면 다른 쓰레드들의 접근을 막기 위해 동기화 장치의 상태가 변경되어야 한다. 다시 말해서, 상태는 현재 쓰레드가 크리티컬 섹션 안에서 실행되고 있음을 나타내야 하고, 이것은 동일한 크리티컬 섹션에 접근하려 하는 다른 쓰레드들의 접근 조건에 영향을 미치도록 한다.


Lock 에서의 상태 변경은 isLocked = true; 코드이다. 세마포어에서의 상태 변경은 signals--; 또는 signals++; 코드가 있다.


다음은 상태 변경을 보여주는 코드 일부이다. 상태 변경은 볼드 처리되었다.


public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    //state change
    isLocked = true;
  }

  public synchronized void unlock(){
    //state change
    isLocked = false;
    notify();
  }
}
public class BoundedSemaphore {
  private int signals = 0;
  private int bound   = 0;

  public BoundedSemaphore(int upperBound){
    this.bound = upperBound;
  }

  public synchronized void take() throws InterruptedException{
    while(this.signals == bound) wait();
    //state change
    this.signals++;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    while(this.signals == 0) wait();
    //state change
    this.signals--;
    this.notify();
  }
}



통지 전략


쓰레드가 동기화 장치의 상태를 변경하면, 때로는 대기 상태에 있는 다른 쓰레드들에게 변경 사실을 알릴 필요가 있다. 아마 이 상태 변경은 다른 쓰레드들의 접근 조건을 true 로 만들어 접근이 가능하게끔 해줄 것이다.


일반적인 통지 전략은 세 가지로 분류된다.


  1. 모든 대기 쓰레드에게 통지
  2. N 개의 대기 쓰레드 중 임의의 1 개 쓰레드에게 통지
  3. N 개의 대기 쓰레드 중 지정된 1 개 쓰레드에게 통지


모든 대기 쓰레드에게 통지하기는 상당히 쉽다. 대기 쓰레드들은 동일한 객체의 wait() 메소드를 호출한다. 이 객체의 notifyAll() 을 호출함으로써 대기 쓰레드들을 모두 깨울 수 있다.


임의의 1 개 쓰레드를 깨우기 또한 어렵지 않다. 대기 쓰레드들이 wait() 을 호출한 객체의 notify() 를 호출하면 된다. notify() 메소드는 대기중인 쓰레드들 중 어떤 쓰레드를 깨울지에 대한 보장이 전혀 없기 때문에 '임의의 1 개 쓰레드' 라는 요건을 충족한다.


때때로 대기 쓰레드 중 임의의 쓰레드가 아닌, 특정한 쓰레드를 깨워야 할 필요가 있다. 가령, 대기 쓰레드들의 접근 획득에 있어 특정한 순서가 보장되어야 할 때이다. 대기 상태로 들어간 순서 또는 별도로 지정된 우선순위로 쓰레드들의 접근 획득 순서를 조정해야 하는 것이다. 이런 경우에는 대기 쓰레드는 자신만의 구분된 객체의 wait() 를 호출해야 한다. 그리고 쓰레를 깨울 때는 대상 쓰레드가 wait() 을 호출한 객체의 nofity() 를 호출하여 정확히 해당 쓰레드를 깨우도록 한다. 관련 예제는 기아상태와 공정성 에서 찾을 수 있다.


다음은 통지 전략 코드의 일부이다. 통지 코드(임의의 1 개 쓰레드에게 통지)는 볼드 처리되었다.


public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      //wait strategy - related to notification strategy
      wait();
    }
    isLocked = true;
  }

  public synchronized void unlock(){
    isLocked = false;
    notify(); //notification strategy
  }
}



확인-설정 메소드


대부분의 경우 동기화 장치는 두 가지 유형의 메소드가 있다. 첫 번째 유형은 확인-설정 메소드이다(두 번째는 설정 메소드인데, 다음 섹션에서 다룬다.). 확확인-설정이란 이 메소드를 호출하는 쓰레드는 동기화 장치의 내부 상태가 접근 조건을 충족하는지 확인한다. 상태가 조건을 충족한다고 확인되면, 쓰레드는 동기화 장치의 내부 상태에 쓰레드가 접근을 획득했음을 나타내도록 설정한다.


주로 이 상태 설정은 다른 쓰레드들의 접근을 막기 위해 접근 조건이 false 가 되도록 한다. 간혹 다른 경우도 있는데, 예를 들자면 읽기/쓰기 락이 그렇다. 읽기/쓰기 락에서 읽기 접근 쓰레드는 락의 상태를 변경하여 자신의 접근을 표시하지만, 이것이 다른 쓰레드들의 접근을 막지는 않는다. 쓰기 접근이 없는 한 다른 쓰레드들의 읽기 접근도 허용된다.


확인-설정 메소드에서 반드시 지켜져야 할 점은 확인-설정 연산이 반드시 원자적으로 실행되어야 한다는 것이다. 확인-설정 메소드가 실행될 때 '확인' 과 '설정' 사이에 다른 쓰레드가 끼어들어서는 안된다.


확인-설정 메소드의 흐름은 주로 다음 순서를 따른다.


  1. 필요하다면 확인 전에 상태를 설정한다.
  2. 상태를 접근 조건에 대해 확인한다.
  3. 접근 조건을 충족하지 못한다면, 대기한다.
  4. 접근 조건을 충족한다면, 상태를 설정한다. 그리고 필요하다면 대기 쓰레드에게 통지한다.


ReadWriteLock 클래스의 lockWrite() 메소드가 확인-설정 메소드의 예시가 될 수 있다. lockWrite() 호출 쓰레드는 확인 전에 상태를 설정한다(writeRequest++). 다음으로 canGrantWriteAccess() 메소드에서 내부 상태를 접근 조건에 대해 확인한다. 조건을 충족한다면 내부 상태를 설정하고 메소드는 종료된다. 아래 예제에서는 대기 쓰레드에게 통지하는 부분은 없다.


public class ReadWriteLock{
    private Map<Thread, Integer> readingThreads =
        new HashMap<Thread, Integer>();

    private int writeAccesses    = 0;
    private int writeRequests    = 0;
    private Thread writingThread = null;

    ...

    
        public synchronized void lockWrite() throws InterruptedException{
        writeRequests++;
        Thread callingThread = Thread.currentThread();
        while(! canGrantWriteAccess(callingThread)){
        wait();
        }
        writeRequests--;
        writeAccesses++;
        writingThread = callingThread;
        }
    

    ...
}


아래 BoundedSemaphore 클래스에는 두 개의 확인-설정 메소드가 있다. take() 와 release() 가 그것이다. 이 두 메소드는 저마다 내부 상태를 확인하고 설정한다.


public class BoundedSemaphore {
  private int signals = 0;
  private int bound   = 0;

  public BoundedSemaphore(int upperBound){
    this.bound = upperBound;
  }

  
      public synchronized void take() throws InterruptedException{
      while(this.signals == bound) wait();
      this.signals++;
      this.notify();
      }

      public synchronized void release() throws InterruptedException{
      while(this.signals == 0) wait();
      this.signals--;
      this.notify();
      }
  
}



설정 메소드


설정 메소드는 동기화 장치에서 자주 등장하는 메소드의 두 번째 유형이다. 설정 메소드는 동기화 장치의 내부 상태를 설정하는데, 여기에 확인 작업은 없다. 설정 메소드의 전형적인 예제는 Lock 클래스의 unlock() 메소드이다. 락을 소유한 메소드는 Lock 이 해제되었는지 확인할 필요 없이, 언제나 락을 해제할 수 있다. 


설정 메소드의 흐름은 주로 다음 순서를 따른다.


  1. 내부 상태를 설정한다.
  2. 대기 쓰레드에게 이를 알린다.


다음은 unlock() 메소드 예제이다.


public class Lock{

  private boolean isLocked = false;
  
      public synchronized void unlock(){
      isLocked = false;
      notify();
      }
  
}


'Java > Concurrency' 카테고리의 다른 글

암달의 법칙(Amdahl's Law)  (1) 2017.06.17
논 블로킹 알고리즘(Non-blocking Algorithms)  (2) 2017.05.27
컴페어 스왑(Compare and Swap)  (0) 2017.05.24
쓰레드 풀(Thread Pools)  (0) 2017.05.22
블로킹 큐(Blocking Queues)  (3) 2017.05.22
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/compare-and-swap.html



컴페어 스왑은 동시성 알고리즘을 설계할 때 사용되는 기술이다. 기본적으로 컴페어 스왑은 변수의 예상 값과 실제 값을 비교하여 둘의 값이 일치하면 실제 값을 새로운 값으로 교체한다. 약간 복잡하게 들릴 수 있지만 사실 한 번 이해하면 그리 어렵지 않다. 자세히 다가가보자.



컴페어 스왑은 어디에 쓰이는가


여러 프로그램들과 동시성 알고리즘에서 대단히 널리 발견되는 패턴은 '체크-액트' 이다. 체크-액트 패턴은 변수의 값을 확인하여 이 값을 전제로 다음 동작을 수행한다. 


class MyLock {

    private boolean locked = false;

    public boolean lock() {
        if(!locked) {
            locked = true;
            return true;
        }
        return false;
    }
}


실제 멀트쓰레드 어플리케이션에서 이 코드를 사용하려면 많은 문제가 있겠지만 여기서는 넘어가도록 하자.


코드에서 보이듯 lock() 메소드는 먼저 locked 멤버변수의 값을 확인하여 값이 false 라면(체크) 값을 locked 을 true 로 바꾸고 true 를 반환한다(액트).


다수의 쓰레드가 같은 MyLock 인스턴스에 접근한다면 lock() 메소드는 제대로 동작하지 않을 것이다. 쓰레드A 가 변수 locked 의 값이 false 인 것을 확인했다. 그리고 그와 동시와 쓰레드B 가 똑같이 변수 locked 의 값이 false 인 것을 확인했다. 따라서 쓰레드A 와 쓰레드B 모두 locked 를 false 로 확인했고, 두 쓰레드 모두 이 값을 전제로 다음 동작을 수행할 것이다.


멀티쓰레드 어플리케이션에서 '체크-액트' 연산이 제대로 수행되려면 연산의 원자성이 필요하다. 여기서의 원자성이란 '체크' 와 '액트' 의 동작이 분리되지 않은 하나의 원자적인 코드 블록으로 실행되어야 함을 의미한다. 원자적인 코드 블록을 실행하는 쓰레드는 코드 실행의 시작과 끝까지 다른 쓰레드의 간섭을 받지 않는다. 다른 쓰레드들은 원자적인 코드 블록의 실행에 개입할 수 없다(끼어들거나 동시에 실행할 수 없다).


다음 코드는 위 예제의 lock() 메소드에 synchronized 를 사용했다. 이제 lock() 메소드는 원자성을 갖는다.


class MyLock {

    private boolean locked = false;

    public synchronized boolean lock() {
        if(!locked) {
            locked = true;
            return true;
        }
        return false;
    }
}


이제 lock() 메소드는 동기화되었다. 하나의 MyLock 인스턴스의 lock() 메소드는 한 시점에 오직 한 쓰레드만이 실행할 수 있다. lock() 메소드는 사실상 원자적이다.


이 원자적인 lock() 메소드는 실제로 '컴페어 스왑' 의 한 예라고 볼 수 있다. lock() 메소드는 변수 locked 의 예상값 false 로 비교하여 실제 값이 false 이면 변수의 값을 true 로 바꾼다.



원자적 연산으로서의 컴페어 스왑


현대의 CPU는 원자적 컴페어 스왑 연산을 빌트인으로 지원한다. Java 5 부터 java.util.concurrent 패키지의 새로운 원자적 연산 클래스들을 통해 이러한 기능을 사용할 수 있다.


다음 코드는 AtomicBoolean 클래스를 사용하여 lock() 메소드를 구현하였다.


public static class MyLock {
    private AtomicBoolean locked = new AtomicBoolean(false);

    public boolean lock() {
        return locked.compareAndSet(false, true);
    }

}


변수 locked 의 타입은 더이상 boolean 이 아닌, AtomicBoolean 이다. 이 클래스는 AtomicBoolean 인스턴스의 값을 예상값과 비교하여 두 값이 일치하면 실제 값을 새 값으로 세팅하는 compareAndSet() 메소드를 가지고 있다. 예제에서는 AtomicBoolean locked 의 값을 false 로 비교하여 false 가 맞다면 AtomicBoolean locked 의 값을 새 값인 true 로 세팅한다.


compareAndSwap() 메소드는 값이 새 값으로 바뀌었으면 true 를, 바뀌지 않았으면 false 를 반환한다.


컴페어 스왑을 직접 구현하기보다는 Java 5 부터 추가된 컴페어 스왑 기능을 사용하는 것이 좋다. Java 5+ 에 내장된 컴페어 스왑 클래스들은 어플리케이션이 구동되는 CPU의 기본 컴페어 스왑 기능을 사용하기 때문에 보다 빠른 컴페어 스왑 연산이 가능하다.


이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/thread-pools.html




쓰레드 풀은 동시에 가동하는 쓰레드 수의 최대치에 제한을 둘 때 유용하다. 새 쓰레드를 생성하는 일은 성능에 있어 오버헤드가 따른다. 그리고 각 쓰레드에는 각각의 스택을 위한 메모리가 할당되어야 한다. 


작업을 실행할 때마다 새 쓰레드를 생성하여 시작하는 일 대신, 쓰레드 풀에 작업을 넘기는 방법으로 작업을 실행할 수 있다. 쓰레드 풀은 놀고 있는 쓰레드가 생기면 즉시 작업을 쓰레드에 할당하여 실행되도록 한다. 작업은 내부적으로 블로킹 큐에 저장되며, 쓰레드 풀의 쓰레드들은 여기서 작업을 빼서 실행한다. 큐에 새로운 작업이 들어오면 놀고 있는 쓰레드가 큐에서 작업을 빼서 실행한다. 이를 두고 쓰레드에 작업이 할당되었다고 하며, 작업이 할당되지 않은 쓰레드들은 큐에 새 작업이 들어올 때까지 대기 상태로 유지된다.


스레드 풀은 멀티쓰레드 서버에 주로 사용된다. 네트워크를 통해 서버로 도착하는 커넥션들은 각각 하나의 작업으로 포장되어 쓰레드 풀에 넘겨진다. 쓰레드들은 이렇게 넘겨받은 커넥션들의 요청을 동시에 처리한다. 이에 대해서는 나중에 자바로 멀티쓰레드 서버를 구현하는 방법에 대해 자세히 살펴볼 것이다. 


Java 5 는 java.util.concurrent 패키지에 쓰레드 풀을 포함한다. 이 쓰레드 풀 클래스에 대한 정보는 java.util.concurrent.ExecutorService(원문) 에서 읽어볼 수 있다. 쓰레드 풀을 직접 구현할 필요는 없지만, 여기서도 역시 쓰레드 풀 구현에 대해 자세히 알아보는 것은 도움이 되리라 본다.


아래는 간단한 쓰레드 풀 구현이다. 여기서의 BlockingQueue 블로킹 큐 튜토리얼에서 등장한 구현체를 사용했음을 알아두기 바란다. 실제로 쓰레드 풀을 구현할 때는 자바의 빌트인 블로킹 큐를 사용하도록 한다.


public class ThreadPool {

    private BlockingQueue taskQueue = null;
    private List<PoolThread> threads = new ArrayList<PoolThread>();
    private boolean isStopped = false;

    public ThreadPool(int noOfThreads, int maxNoOfTasks){
        taskQueue = new BlockingQueue(maxNoOfTasks);

        for(int i=0; i<noOfThreads; i++){
            threads.add(new PoolThread(taskQueue));
        }
        for(PoolThread thread : threads){
            thread.start();
        }
    }

    public synchronized void  execute(Runnable task) throws Exception{
        if(this.isStopped) throw
            new IllegalStateException("ThreadPool is stopped");

        this.taskQueue.enqueue(task);
    }

    public synchronized void stop(){
        this.isStopped = true;
        for(PoolThread thread : threads){
           thread.doStop();
        }
    }

}
public class PoolThread extends Thread {

    private BlockingQueue taskQueue = null;
    private boolean       isStopped = false;

    public PoolThread(BlockingQueue queue){
        taskQueue = queue;
    }

    public void run(){
        while(!isStopped()){
            try{
                Runnable runnable = (Runnable) taskQueue.dequeue();
                runnable.run();
            } catch(Exception e){
                //log or otherwise report exception,
                //but keep pool thread alive.
            }
        }
    }

    public synchronized void doStop(){
        isStopped = true;
        this.interrupt(); //break pool thread out of dequeue() call.
    }

    public synchronized boolean isStopped(){
        return isStopped;
    }
}


쓰레드 풀 구현은 두 파트로 나뉜다. ThreadPool 클래스는 쓰레드 풀 사용을 위한 공용 인터페이스이다. PoolThread 클래스는 작업을 실행하기 위한 쓰레드를 구현한다.


작업을 실행하기 위해서는 ThreadPool.execute(Runnable r) 이 호출된다. 이 메소드는 Runnable 구현체를 파라미터로 받는다. 이 Runnable 은 내부의 블로킹 큐에 삽입되고, 작업 쓰레드가 가져가기를 기다린다.


Runnable 은 작업이 할당되지 않는 PoolThread 에 의해 큐에서 빠져나와 실행된다. 이 과정은 PoolThread.run() 메소드에서 볼 수 있다. 실행이 완료되면 PoolThread 는 루프를 돌며 큐에 실행할 작업이 있는지 계속 확인한다. 이 루프는 사용자에 의해 쓰레드 풀이 정지될 때까지 지속된다.


ThreadPool 을 정지하기 위해서는 ThreadPool.stop() 이 호출된다. 이 메소드는 풀 내부의 isStopped 변수에 쓰레드 풀이 정지되었음을 표시한다. 그리고 작업 쓰레드 각각에 doStop() 을 호출하여 더이상 새 작업을 실행하지 않도록 한다. 풀이 정지된 다음 execute() 메소드가 호출되면 IllegalStateException 을 던져 작업이 실행될 수 없음을 알린다.


작업 쓰레드들은 자신이 현재 실행중인 작업이 완료된 다음 정지된다. PoolThread.doStop() 메소드를 보면 this.interrupt() 호출이 있다. 이 호출은 taskQueue.dequeue() 호출 대기 상태로 들어간 쓰레드의 대기 상태를 풀어버리고 InterruptedException 과 함께 dequeue() 호출을 빠져나가도록 한다. 이 예외는 PoolThread.run() 에서 캐치된다. 그리고 while 루프의 조건문에서 isStopped 의 값을 확인한다. 쓰레드 풀이 정지되어 isStopped 는 이제 true 이다. PoolThread.run() 은 종료되고 쓰레드는 죽게 된다.




'Java > Concurrency' 카테고리의 다른 글

동기화 장치 해부(Anatomy of a Synchronizer)  (0) 2017.05.25
컴페어 스왑(Compare and Swap)  (0) 2017.05.24
블로킹 큐(Blocking Queues)  (3) 2017.05.22
세마포어(Semophores)  (0) 2017.05.22
재진입 락아웃(Reentrance Lockout)  (0) 2017.05.21
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/blocking-queues.html



블로킹 큐란 특정 상황에 쓰레드를 대기하도록 하는 큐이다. 큐에서 엘레멘트를 빼려고 시도했는데(디큐) 큐가 비어있다거나, 큐에 엘레멘트를 넣으려 했는데(인큐) 큐에 넣을 수 있는 공간이 없다거나 할 때 디큐/인큐 호출 쓰레드를 대기하도록 한다. 비어있는 큐에서 엘레멘트를 빼려고 시도하는 쓰레드의 대기 상태는 다른 쓰레드가 이 큐에 엘레멘트를 넣을 때까지 지속된다. 꽉 찬 큐에 엘레멘트를 넣으려 시도하는 쓰레드의 대기 상태는 다른 쓰레드가 큐에서 엘레멘트를 빼거나 큐를 정리하여(clean) 큐의 공간이 확보될 때까지 지속된다. 


아래는 한 블로킹 큐를 둔 두 쓰레드의 상호 동작을 보여준다.


Java 5 는 java.util.concurrent 패키지에 블로킹 큐를 포함한다. 이 블로킹 큐 클래스에 대한 내용은 java.util.concurrent.BlockingQueue(원문) 튜토리얼에서 볼 수 있다. Java 5 가 블로킹 큐 구현체를 제공하긴 하지만, 역시 그 기반 이론을 아는 일은 가치가 있다.



블로킹 큐 구현


블로킹 큐의 구현은 바운디드 세마포어와 유사하다. 간단한 블로킹 큐 구현을 보자.


public class BlockingQueue {

  private List queue = new LinkedList();
  private int  limit = 10;

  public BlockingQueue(int limit){
    this.limit = limit;
  }


  public synchronized void enqueue(Object item)
  throws InterruptedException  {
    while(this.queue.size() == this.limit) {
      wait();
    }
    if(this.queue.size() == 0) {
      notifyAll();
    }
    this.queue.add(item);
  }


  public synchronized Object dequeue()
  throws InterruptedException{
    while(this.queue.size() == 0){
      wait();
    }
    if(this.queue.size() == this.limit){
      notifyAll();
    }

    return this.queue.remove(0);
  }

}
    


큐 크기가 크기 제한에 다다르면 enqueue() 와 dequeue() 에서 notifyAll() 이 호출된다. 큐 크기가 제한에 다다르지 않은 상태로 enqueue() 나 dequeue() 가 호출되면 쓰레드는 대기하지 않는다.

'Java > Concurrency' 카테고리의 다른 글

컴페어 스왑(Compare and Swap)  (0) 2017.05.24
쓰레드 풀(Thread Pools)  (0) 2017.05.22
세마포어(Semophores)  (0) 2017.05.22
재진입 락아웃(Reentrance Lockout)  (0) 2017.05.21
읽기/쓰기 락(Read/Write Locks in Java)  (0) 2017.05.16
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/semaphores.html



세마포어는 락과 마찬가지로 쓰레드간 신호 실종을 방지하기 위한 신호를 보내거나, 크리티컬 섹션을 보호하는 등의 목적을 위해 사용되는 쓰레드 동기화 구조이다. Java 5 는 java.util.concurrent 패키지에 세마포어 구현체를 포함하고 있기 때문에 직접 세마포어를 구현할 필요는 없다. 하지만 역시나 세마포어 구현의 기반 이론을 배우는 일은 충분히 가치있을 것이다.


Java 5 에는 Semaphore 클래스가 있다. 이 클래스에 대해서는 java.util.concurrent 튜토리얼의 java.util.concurrent.Semophore(원문) 을 읽어보기 바란다.



간단한 세마포어


아래는 간단한 Semaphore 구현이다.


public class Semaphore {
  private boolean signal = false;

  public synchronized void take() {
    this.signal = true;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    while(!this.signal) wait();
    this.signal = false;
  }

}


take() 메소드는 Semaphore 내부에 신호 저장된 신호를 보낸다. release() 메소드는 신호를 기다린다. 신호가 도착하면 release() 메소드는 종료된다.


이러한 세마포어를 사용함으로써 신호 실종을 방지할 수 있다. notify() 대신 take() 를 호출하고, wait() 대신 release() 를 호출한다. release() 호출 전에 take() 호출이 발생하면, 신호는 내부적으로 signal 변수 안에 저장되기 때문에 release() 를 호출하는 쓰레드는 take() 이 호출되었었음을 알 수 있다. wait() 과 notify() 호출과는 다르다.


신호를 위해 세마포어를 사용할 때, take() 과 release() 라는 메소드 이름이 이상하게 보일 수 있다. 이 이름들은 세마포어를 락으로 사용함에서 나온 것이다. 이에 대한 설명은 후에 이어진다. 



신호를 위한 세마포어 사용


아래는 두 쓰레드 간의 신호를 위해 Semaphore 를 사용하는 간단한 예제이다.


Semaphore semaphore = new Semaphore();

SendingThread sender = new SendingThread(semaphore);

ReceivingThread receiver = new ReceivingThread(semaphore);

receiver.start();
sender.start();
public class SendingThread {
  Semaphore semaphore = null;

  public SendingThread(Semaphore semaphore){
    this.semaphore = semaphore;
  }

  public void run(){
    while(true){
      //do something, then signal
      this.semaphore.take();

    }
  }
}
public class RecevingThread {
  Semaphore semaphore = null;

  public ReceivingThread(Semaphore semaphore){
    this.semaphore = semaphore;
  }

  public void run(){
    while(true){
      this.semaphore.release();
      //receive signal, then do something...
    }
  }
}



카운팅 세마포어


앞서 선보인 Semaphore 구현에는 task() 메소드 호출로 발생한 신호의 수를 세지 않는다. 이 수를 세기 위해 코드를 조금 수정한다.이 클래스는 카운팅 세마포어라 부른다. 


public class CountingSemaphore {
  private int signals = 0;

  public synchronized void take() {
    this.signals++;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    while(this.signals == 0) wait();
    this.signals--;
  }

}



바운디드 세마포어


CountingSemaphore 는 신호의 최대치에 대한 제한이 없다. 이 제한을 위해 코드를 수정한다.


public class BoundedSemaphore {
  private int signals = 0;
  private int bound   = 0;

  public BoundedSemaphore(int upperBound){
    this.bound = upperBound;
  }

  public synchronized void take() throws InterruptedException{
    while(this.signals == bound) wait();
    this.signals++;
    this.notify();
  }

  public synchronized void release() throws InterruptedException{
    while(this.signals == 0) wait();
    this.signals--;
    this.notify();
  }
}


take() 메소드를 보자. 저장된 신호의 수가 한도에 다다르면 호출 쓰레드는 블록된다. release() 가 호출될 때까지 take() 호출 쓰레드는 신호를 보낼 수 없다. 



세마포어를 락으로 사용하기


바운디드 세마포어를 락으로 사용할 수 있다. 이를 위해서는 신호 최대치를 1 으로 세팅해야 한다. 그리고 take() 와 release() 호출을 크리티컬 섹션에 사용한다.


BoundedSemaphore semaphore = new BoundedSemaphore(1);

...

semaphore.take();

try{
  //critical section
} finally {
  semaphore.release();
}


세마포어를 신호를 주고받기 위해 사용할 때와는 달리, take() 와 release() 메소드는 이제 같은 쓰레드에 의해 호출된다. 오직 한 쓰레드만이 세마포어를 획득할 수 있기 때문에, take() 를 호출하는 다른 모든 쓰레드들은 release() 가 호출될 때까지 블록된다. release() 호출은 절대 블록되지 않는데, 이는 언제나 take() 호출이 먼저 발생하기 때문이다.


바운디드 세마포어는 어떤 영역으로 동시에 진입 가능한 쓰레드의 수를 제한하는 데에 사용될 수 있다. 예를 들어, 위 예제에서 BoundedSemaphore 의 신호 최대치를 5 로 세팅하면 어떨까? 5 개의 쓰레드가 동시에 크리티컬 섹션으로 진입할 수 있다. 물론 이 경우에는 5 개 쓰레드가 크리티컬 섹션에 동시에 접근해서 어플리케이션에 문제가 발생하지 않아야 한다는 전제가 필요하다.


finally 절에서의 release() 메소드 호출은 크리티컬 섹션에서 예외가 던져지더라도 반드시 releas() 가 호출되도록 보장하기 위함이다.





'Java > Concurrency' 카테고리의 다른 글

쓰레드 풀(Thread Pools)  (0) 2017.05.22
블로킹 큐(Blocking Queues)  (3) 2017.05.22
재진입 락아웃(Reentrance Lockout)  (0) 2017.05.21
읽기/쓰기 락(Read/Write Locks in Java)  (0) 2017.05.16
자바의 락(Locks in Java)  (0) 2017.05.09
이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/reentrance-lockout.html



재진입 락아웃은 데드락중첩 모니터 락아웃과 유사한 현상이다. 자바의 락읽기/쓰기 락에서 다뤄지기도 했다.


재진입 락아웃은 한 쓰레드가 재진입이 불가능한 Lock 이나 ReadWriteLock, 이외 다른 동기화에 재진입 요청을 시도할 때 발생한다. 재진입이란 이미 락을 획득한 쓰레드가 자신이 보유한 락과 동일한 락을 다시 요청하는 동작을 의미한다. 자바의 synchronized 블록은 재진입 락이다. 때문에 아래의 코드는 문제 없이 수행된다.


public class Reentrant{

  public synchronized outer(){
    inner();
  }

  public synchronized inner(){
    //do something
  }
}


outer() 와 inner() 는 synchronized 로 선언되었다. 자바에서 메소드 선언부의 synchronized 는 synchronized(this) 블록과 동일한 의미를 가지므로, outer() 와 inner() 메소드는 같은 모니터 객체('this') 에 대한 동기화를 수행한다. 한 쓰레드가 모니터 객체의 락을 획득하면, 같은 모니터 객체에 대해 선언된 모든 synchronized 블록으로의 진입권을 갖는다. 이것이 재진입이다. 쓰레드는 자신이 소유한 락에 대한 모든 synchronized 블록으로 진입할 수 있다.


다음 Lock 구현은 비 재진입 락이다.


public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  public synchronized void unlock(){
    isLocked = false;
    notify();
  }
}


한 쓰레드가 unlock() 호출 없이 lock() 을 두 번 호출하면, 두 번째 lock() 호출에 의해 쓰레드는 블록된다. 재진입 락아웃이 발생하는 것이다.


재진입 락아웃을 방지하는 방법은 두 가지가 있다.


  1. 재진입을 시도하지 않는다.
  2. 재진입 락을 사용한다.

두 방법 중 어느 쪽이 알맞느냐는 구체적인 상황에 달려있다. 재진입 락 종종 비 재진입 락만큼 제대로 동작하기 않기도 하고, 구현하기도 더 어렵지만, 꼭 필요한 것이 아닐 수도 있다. 재진입 락이 필요할지 필요하지 않을지는 구체적인 상황에 따라 결정되어야 한다.





'Java > Concurrency' 카테고리의 다른 글

블로킹 큐(Blocking Queues)  (3) 2017.05.22
세마포어(Semophores)  (0) 2017.05.22
읽기/쓰기 락(Read/Write Locks in Java)  (0) 2017.05.16
자바의 락(Locks in Java)  (0) 2017.05.09
슬립 상태(Slipped Conditions)  (0) 2017.04.23

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/read-write-locks.html



읽기/쓰기 락은 자바의 락에서 선보인 락 구현보다 더욱 세련된 형태의 락이다. 이런 상황을 생각해보자. 자원에 대해 읽기/쓰기 작업을 수행하는 어플리케이션이 있다. 그런데 쓰기 작업은 읽기 작업만큼 자주 수행되지 않는다. 그리고 두 쓰레드가 동일한 자원에 읽기 작업을 수행해도 서로에게 어떤 문제도 발생하지 않기 때문에, 다수의 쓰레드가 동시에 자원을 읽는 작업에는 아무런 제한이 없다. 하지만 한 쓰레드가 자원에 대해 쓰기 작업을 수행하려 한다면, 이 때에는 다른 어떤 쓰레드도 이 자원에 읽기 또는 쓰기 작업을 수행중이어서는 안된다. 이처럼 다수의 읽기-하나의 쓰기를 가능케 하기 위해 읽기/쓰기 락이 필요하다.


자바5 부터 java.util.concurrent 패키지에 읽기/쓰기 락 구현체가 포함되었다. 때문에 직접 이 락을 구현할 필요는 없지만, 이 구현 뒤에 존재하는 배경 이론을 이해하는 일은 여전히 유용할 것이다.



자바 읽기/쓰기 락 구현


먼저 자원에 대한 읽기와 쓰기 접근을 획득하기 뒤한 조건을 정리해보자.


읽기 접근: 쓰기 작업을 수행중인 다른 쓰레드가 없고, 쓰기 접근을 요청한 다른 쓰레드도 없을 때

쓰기 접근: 쓰기와 읽기 작업을 수행중인 다른 쓰레드가 없을 때


한 쓰레드가 자원을 읽으려 할 때, 이 지원에 쓰기 작업을 수행중이거나 쓰기 작업을 수행하기 위한 접근을 요청한 다른 쓰레드가 존재하지 않는다면 이 읽기 작업에는 아무런 문제가 없다. 접근의 우선순위를 고려할 때, 쓰기 접근은 읽기 접근보다 더 중요하다고 가정한다. 또한, 대부분의 요청이 읽기 접근인 상황에서 쓰기 접근에 높은 우선순위를 부여하지 않는다면 기아 상태가 발생할 수 있다. 쓰기 접근을 시도하는 쓰레드는 모든 읽기 요청이 ReadWriteLock 을 해제할 때까지 블록된다. 만약 새로운 쓰레드들이 거듭 읽기 접근을 획득한다면, 쓰기 작업을 위해 대기중인 쓰레드는 무기한 블록 상태로 남게 될 것이다. 따라서 쓰기 접근을 위해 ReadWriteLock 에 락을 걸거나(이미 걸었거나) 락을 요청한 쓰레드가 없는 경우에만 읽기 접근이 허용된다.


자원에 대한 쓰기 접근을 요하는 쓰레드는 다른 쓰레드들이 이 자원에 읽기나 쓰기 작업중이지 않을 때 접근이 허용된다. 여기서 쓰기 접근 요청에 대해 공정성을 보장할 필요가 없다면, 쓰기 접근을 요청하는 쓰레드의 수나 순서는 문제가 되지 않는다.


이러한 규칙들을 전제로 아래와 같은 ReadWriteLock 을 구현할 수 있다.


public class ReadWriteLock{

  private int readers       = 0;
  private int writers       = 0;
  private int writeRequests = 0;

  public synchronized void lockRead() throws InterruptedException{
    while(writers > 0 || writeRequests > 0){
      wait();
    }
    readers++;
  }

  public synchronized void unlockRead(){
    readers--;
    notifyAll();
  }

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;

    while(readers > 0 || writers > 0){
      wait();
    }
    writeRequests--;
    writers++;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    writers--;
    notifyAll();
  }
}


ReadWriteLock 은  두 개의 락 메소드와 두 개의 언락 메소드를 가진다. 한 쌍의 락-언락 메소드는 읽기 접근에 대한 것이며, 다른 한 쌍의 락-언락 메소드는 쓰기 접근에 대한 것이다.


읽기 접근에 대한 규칙은 lockRead() 메소드에 구현되어 있다. 쓰기 접근중이거나 쓰기 접근을 요청한 쓰레드가 존재하지 않는다면 읽기 작업을 요청하는 모든 쓰레드들에게 접근이 허용된다.


쓰기 접근에 대한 규칙은 lockWrite() 메소드에 구현되어 있다. 쓰기 접근은 쓰기 요청에 의해 시작된다(writeRequest++). 다음으로 쓰기 접근이 가능한 상태인지 확인한다. 자원에 대해 읽기/쓰기 접근중인 쓰레드가 존재하지 않는다면 쓰기 접근은 허용된다. 쓰기 접근 요청을 하는 쓰레드의 수는 상관없다.


unlockRead() 와 unlockWrite() 메소드에서 notify() 가 아닌 notifyAll() 을 호출하는 이유는 알아둘 가치가 있다. 아래와 같은 경우를 생각해보자.


ReadWriteLock 안에서 쓰기/읽기 접근을 위해 대기중인 다수의 쓰레드들이 있다. 이 때 notify() 가 호출되어 읽기 접근 대기중이던 쓰레드가 깨어나면 이 쓰레드는 다시 대기 상태로 돌아갈 것이다. 왜냐하면 쓰기 접근으로 대기중인 쓰레드가 존재하기 때문이다. 하지만 여기서 쓰기 대기중인 쓰레드는 깨어나지 못한다. 그리고 이후로 아무 일도 일어나지 않는다. 읽기나 쓰기 접근 중 어느 쪽도 허용되지 못하게 된다. 이러한 현상을 방지하기 위해 일단 notifyAll() 호출을 통해 모든 대기중인 쓰레드를 깨운 뒤, 쓰레드들이 자신이 요청한 접근 권한을 획득할 수 있는지 확인하게끔 하는 것이다.


notifyAll() 호출에는 또다른 이점이 있다. 다수의 쓰레드들이 읽기 접근 대기중이고 쓰기 접근 요청이 없는 경우에, 한 번의 unlockWrite() 호출만으로 모든 대기중인 쓰레드를 깨워 읽기 접근을 허용할 수 있다. 쓰레드를 하나하나 깨울 필요가 없는 것이다.



읽기/쓰기 락 재진입


위의 ReadWriteLock 클래스는 재진입 락이 아니다. 이미 쓰기 접근을 획득한 쓰레드가 다시 락을 호출하면 이 쓰레드는 자기 자신이 소유한 락에 의해 블록(대기) 상태가 된다. 더 나아가서 다음의 경우를 생각해보자.


  1. 쓰레드1이 읽기 접근을 획득한다.
  2. 쓰레드2이 쓰기를 요청하지만 쓰레드1이 획득한 읽기 접근 권한에 의해 블록된다.
  3. 쓰레드1이 다시 읽기 접근을 요청한다(재진입). 그러나 쓰레드2의 쓰기 요청에 의해 블록된다.


이 상황에서 ReadWriteLock 클래스를 데드락과 유사한 상황에 놓인다. 읽기나 쓰기 접근을 요청하는 쓰레드 모두 권한을 획득하지 못한다.


ReadWriteLock 을 재진입 락으로 만들기 위해 몇가지 수정이 필요하다. 읽기와 쓰기 재진입을 각각 분리하여 다뤄본다.



읽기 재진입


ReadWriteLock 을 읽기 재진입 락으로 만들기 위해, 먼저 읽기 재진입 규칙을 세운다.


한 쓰레드는 읽기 재진입은 읽기 접근을 획득할 수 있거나(쓰기 접근중인 쓰레드가 쓰기 접근을 요청한 쓰레드가 없을 때), 혹은 이미 읽기 접근을 획득한 경우(이 때는 쓰기 요청 존재 유무는 상관없다)에 허용된다.


쓰레드의 읽기 접근 획득 여부를 판단하기 위해, 읽기 접근을 획득한 각 쓰레드로의 참조를 맵에 저장한다. 쓰레드가 획득한 읽기 접근 횟수를 맵의 값으로 둔다. 이 맵은 쓰레드가 읽기 접근을 획득할 수 있는지 판단할 때 사용될 것이다. lockRead() 와 unlockRead() 메소드가 어떻게 수정되었는지 보자.


public class ReadWriteLock{ private Map<Thread, Integer> readingThreads = new HashMap<Thread, Integer>(); private int writers = 0; private int writeRequests = 0; public synchronized void lockRead() throws InterruptedException{ Thread callingThread = Thread.currentThread(); while(! canGrantReadAccess(callingThread)){ wait(); } readingThreads.put(callingThread, (getReadAccessCount(callingThread) + 1)); } public synchronized void unlockRead(){ Thread callingThread = Thread.currentThread(); int accessCount = getAccessCount(callingThread); if(accessCount == 1){ readingThreads.remove(callingThread); } else { readingThreads.put(callingThread, (accessCount -1)); } notifyAll(); } private boolean canGrantReadAccess(Thread callingThread){ if(writers > 0) return false; if(isReader(callingThread) return true; if(writeRequests > 0) return false; return true; } private int getReadAccessCount(Thread callingThread){ Integer accessCount = readingThreads.get(callingThread); if(accessCount == null) return 0; return accessCount.intValue(); } private boolean isReader(Thread callingThread){ return readingThreads.get(callingThread) != null; } }


코드에서 보이듯, 읽기 재진입은 오직 쓰기 접근을 획득한 다른 쓰레드가 없을 때만 허용된다. 그리고 쓰기 요청에 대해서는, 이미 읽기 접근을 획득한 쓰레드가 더 높은 우선순위를 가지게 된다.



쓰기 재진입


쓰기 재진입은 오직 해당 쓰레드가 이미 쓰기 접근을 소유한 경우해만 허용된다. 여기서는 lockWrite() 와 unlockWrite() 메소드를 보자.


public class ReadWriteLock{

    private Map<Thread, Integer> readingThreads =
        new HashMap<Thread, Integer>();

    private int writeAccesses    = 0;
    private int writeRequests    = 0;
    private Thread writingThread = null;

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;
    Thread callingThread = Thread.currentThread();
    while(! canGrantWriteAccess(callingThread)){
      wait();
    }
    writeRequests--;
    writeAccesses++;
    writingThread = callingThread;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    writeAccesses--;
    if(writeAccesses == 0){
      writingThread = null;
    }
    notifyAll();
  }

  private boolean canGrantWriteAccess(Thread callingThread){
    if(hasReaders())             return false;
    if(writingThread == null)    return true;
    if(!isWriter(callingThread)) return false;
    return true;
  }

  private boolean hasReaders(){
    return readingThreads.size() > 0;
  }

  private boolean isWriter(Thread callingThread){
    return writingThread == callingThread;
  }
}


쓰레드의 쓰기 접근 보유 여부를 어떤 방식으로 등록하는지, 그리고 이것이 다른 쓰레드의 쓰기 접근 획득 여부를 판단할 때 어떻게 활용되는지 보자.



읽기에서 쓰기로의 재진입


때때로 읽기 접근 쓰레드가 쓰기 접근을 획득할 필요성이 생기기도 한다. 이 경우에도 단 하나의 읽기 쓰레드만이 쓰기 접근을 획득할 수 있도록 하여야 한다. 이 처리를 위해 writeLock() 메소드를 수정한다.


public class ReadWriteLock{

    private Map<Thread, Integer> readingThreads =
        new HashMap<Thread, Integer>();

    private int writeAccesses    = 0;
    private int writeRequests    = 0;
    private Thread writingThread = null;

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;
    Thread callingThread = Thread.currentThread();
    while(! canGrantWriteAccess(callingThread)){
      wait();
    }
    writeRequests--;
    writeAccesses++;
    writingThread = callingThread;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    writeAccesses--;
    if(writeAccesses == 0){
      writingThread = null;
    }
    notifyAll();
  }

  private boolean canGrantWriteAccess(Thread callingThread){
    if(isOnlyReader(callingThread))    return true;
    if(hasReaders())                   return false;
    if(writingThread == null)          return true;
    if(!isWriter(callingThread))       return false;
    return true;
  }

  private boolean hasReaders(){
    return readingThreads.size() > 0;
  }

  private boolean isWriter(Thread callingThread){
    return writingThread == callingThread;
  }

  private boolean isOnlyReader(Thread thread){
      return readers == 1 && readingThreads.get(callingThread) != null;
      }
  
}


이제 ReadWriteLock 클래스는 읽기-쓰기 재진입이 가능한다.



쓰기에서 읽기로의 재진입


위와 반대로 쓰기 접근 쓰레드가 읽기 접근을 필요로 하는 상황도 생긴다. 쓰기 접근을 보유한 쓰레드는 어느 때나 읽기 접근이 가능해야 한다. 안전한 구현을 위해, 한 쓰레드가 읽기 접근을 보유중이라면 다른 쓰레드들은 읽기나 쓰기 접근 모두 불가능하도록 한다. 수정된 canGrantReadAccess() 메소드를 보자.


public class ReadWriteLock{

    private boolean canGrantReadAccess(Thread callingThread){
      if(isWriter(callingThread)) return true;
      if(writingThread != null)   return false;
      if(isReader(callingThread)  return true;
      if(writeRequests > 0)       return false;
      return true;
    }

}



재진입 읽기/쓰기 락의 최종 버전


아래 코드가 최종적으로 완성된 ReadWriteLock 구현이다. 가독성과 쉬운 이해를 위해 리펙토링이 조금 들어갔다.


public class ReadWriteLock{

  private Map<Thread, Integer> readingThreads =
       new HashMap<Thread, Integer>();

   private int writeAccesses    = 0;
   private int writeRequests    = 0;
   private Thread writingThread = null;


  public synchronized void lockRead() throws InterruptedException{
    Thread callingThread = Thread.currentThread();
    while(! canGrantReadAccess(callingThread)){
      wait();
    }

    readingThreads.put(callingThread,
     (getReadAccessCount(callingThread) + 1));
  }

  private boolean canGrantReadAccess(Thread callingThread){
    if( isWriter(callingThread) ) return true;
    if( hasWriter()             ) return false;
    if( isReader(callingThread) ) return true;
    if( hasWriteRequests()      ) return false;
    return true;
  }


  public synchronized void unlockRead(){
    Thread callingThread = Thread.currentThread();
    if(!isReader(callingThread)){
      throw new IllegalMonitorStateException("Calling Thread does not" +
        " hold a read lock on this ReadWriteLock");
    }
    int accessCount = getReadAccessCount(callingThread);
    if(accessCount == 1){ readingThreads.remove(callingThread); }
    else { readingThreads.put(callingThread, (accessCount -1)); }
    notifyAll();
  }

  public synchronized void lockWrite() throws InterruptedException{
    writeRequests++;
    Thread callingThread = Thread.currentThread();
    while(! canGrantWriteAccess(callingThread)){
      wait();
    }
    writeRequests--;
    writeAccesses++;
    writingThread = callingThread;
  }

  public synchronized void unlockWrite() throws InterruptedException{
    if(!isWriter(Thread.currentThread()){
      throw new IllegalMonitorStateException("Calling Thread does not" +
        " hold the write lock on this ReadWriteLock");
    }
    writeAccesses--;
    if(writeAccesses == 0){
      writingThread = null;
    }
    notifyAll();
  }

  private boolean canGrantWriteAccess(Thread callingThread){
    if(isOnlyReader(callingThread))    return true;
    if(hasReaders())                   return false;
    if(writingThread == null)          return true;
    if(!isWriter(callingThread))       return false;
    return true;
  }


  private int getReadAccessCount(Thread callingThread){
    Integer accessCount = readingThreads.get(callingThread);
    if(accessCount == null) return 0;
    return accessCount.intValue();
  }


  private boolean hasReaders(){
    return readingThreads.size() > 0;
  }

  private boolean isReader(Thread callingThread){
    return readingThreads.get(callingThread) != null;
  }

  private boolean isOnlyReader(Thread callingThread){
    return readingThreads.size() == 1 &&
           readingThreads.get(callingThread) != null;
  }

  private boolean hasWriter(){
    return writingThread != null;
  }

  private boolean isWriter(Thread callingThread){
    return writingThread == callingThread;
  }

  private boolean hasWriteRequests(){
      return this.writeRequests > 0;
  }

}



finally 절에서의 unlock() 호출


ReadWriteLock 을 이용해 크리티컬 섹션을 보호하고 이 크리티컬 섹션에서 예외가 던져질 수 있는 경우에 finally 절에서 readUnlock 과 writeUnlock() 메소드를 호출하도록 해야한다. 


lock.lockWrite();
try{
  //do critical section code, which may throw exception
} finally {
  lock.unlockWrite();
}


이 사소해 보이는 구조가 크리티컬 섹션에서 예외가 던져지더라도 ReadWriteLock 의 락이 반드시 풀리도록 보장한다. finally 절에서 unlockWrite() 을 호출하지 않는 상태로 크리티컬 섹션에서 예외가 던져지면 ReadWriteLock 은 영원히 쓰기 접근이 획득된 상태로 남게 되고, lockRead() 나 lockWrite() 를 호출하는 쓰레드들은 무기한 중단 상태가 된다. 이 상태를 벗어나는 유일한 방법이 있기는 하다. 쓰기 접근을 보유했었던 쓰레드가 다시 이 코드에 접근하여 쓰기 접근을 다시 획득하고, 크리티컬 섹션의 코드를 무사히(예외 없이) 수행한 뒤, unlockWriter() 를 호출한다면 중단된 쓰레드들은 정상적인 상태로 돌아올 수 있다. 하지만 프로그램을 이런식으로 처리할 이유는 없을 것이다. 훨씬 간단하고 안전하게 finally 절에서 반드시 unlockWrite() 를 호출하도록 하자.








'Java > Concurrency' 카테고리의 다른 글

세마포어(Semophores)  (0) 2017.05.22
재진입 락아웃(Reentrance Lockout)  (0) 2017.05.21
자바의 락(Locks in Java)  (0) 2017.05.09
슬립 상태(Slipped Conditions)  (0) 2017.04.23
중첩 모니터 락아웃(Nested Monitor Lockout)  (0) 2017.04.16

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

자바 컨커런시와 자바 메모리 모델에 관한 자료를 찾던 중 발견한 이 튜토리얼의 깔금한 이미지와 예제, 명료한 설명에 반하여 번역-소개한다. 자바 컨커런시 외에도 유익한 자료가 많으니 관심이 있다면 꼭 들러보길 바란다(특히 자바 아키텍처 기반 대용량 웹서비스와 관련이 있다면).

원문 URL : http://tutorials.jenkov.com/java-concurrency/locks.html

!! 쉬운 이해를 위한 의역이 다소 포함되었다.



락은 synchrnonized 블록과 유사한 쓰레드 동기화 메카니즘이다. 락은 synchronized 블록보다 더 정교하고 세련된 방식의 동기화를 가능하게 한다. 락은 synchronized 블록을 이용해 구현되며, 때문에 락을 사용한다고 해서 synchronized 블록이 완전히 사라지는 것은 아니다.

Java 5 부터 추가된 java.util.concurrent.locks 패키지는 몇 가지 락 구현을 포함하고 있기 때문에 락을 스스로 구현할 필요는 없어졌다. 하지만 락을 어떻게 사용하는지는 알아야 하고, 락 구현이 어떤 이론을 바탕으로 하는지 안다면 도움이 될 것이다. 더 자세한 정보를 원한다면 여기를 보는 것이 좋다.



간단한 락

synchronized 블록을 이용한 락에서 시작해보자.

public class Counter{

  private int count = 0;

  public int inc(){
    synchronized(this){
      return ++count;
    }
  }
}


inc() 메소드 안의 synchronized 블록을 보자. 이 블록은 코드 return ++count; 의 실행이 한 시점에 오직 한 쓰레드에 의해서만 수행될 수 있음을 보장한다. synchronized 블록 안에는 더 나은 코드가 있었을 수도 있겠지만, 여기서 synchronized 블록의 의미를 이해하기 위해서는 ++count 만으로 충분하다.

Counter 클래스는 synchronized 블록 대신 Lock 을 이용하여 다음과 같이 작성되었다.

public class Counter{

  private Lock lock = new Lock();
  private int count = 0;

  public int inc(){
    lock.lock();
    int newCount = ++count;
    lock.unlock();
    return newCount;
  }
}


lock() 메소드는 Lock 인스턴스에 락을 걸어 lock() 을 호출하는 모든 쓰레드가 unlock() 메소드가 호출될 때까지 블록되도록 한다.

다음은 간단한 Lock 구현이다.

public class Lock{

  private boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  public synchronized void unlock(){
    isLocked = false;
    notify();
  }
}

여기서 while(isLocked) 루프는 '스핀 락' 이라 불린다. 스핀 락과 wait(), notify() 메소드에 대해서는 쓰레드 시그널링에서 보다 자세히 다뤄진다. isLocked 이 true 인 동안은 lock() 을 호출하는 쓰레드는 wait() 메소드 호출을 통해 대기 상태가 된다. 쓰레드가 notify() 가 호출되지 않았음에도 예상치 못하게 깨어나는 경우, isLocked 의 값으로 쓰레드가 다시 대기 상태로 들어가야 할지, 아니면 다음 동작을 수행해야 할지 결정하도록 한다. isLocked 이 false 라면 쓰레드는 while(isLocked) 루프에서 벗어나 isLocked 를 true 로 세팅하여 Lock 인스턴스에 락이 걸렸음을 표시한다.

크리티컬 섹션(lock() 과 unlock() 사이)의 코드 실행을 완료한 쓰레드는 unlock() 을 호출하여 isLocked 을 false 로 세팅하고 lock() 메소드에서 대기중인 쓰레드를 깨운다.


락 재진입

자바의 synchronized 블록은 재진입이 허용된다. 한 쓰레드가 synchronized 블록에 진입하며 모니터 객체의 락을 획득하면, 쓰레드는 자신이 획득한 모니터 객체에 대한 다른 synchronized 블록으로도 진입할 수 있다. 

public class Reentrant{

  public synchronized outer(){
    inner();
  }

  public synchronized inner(){
    //do something
  }
}

outer(), inner() 메소드 모두 synchronized 로 선언되어 있다. 한 쓰레드가 outer() 를 호출했다면, outer() 메소드 안에서는 inner() 마음대로 호출할 수 있다. 두 메소드의 synchronized 블록은 같은 모니터 객체, 'this' 에 대한 동기화를 의미하기 때문이다. 한 쓰레드가 한 모니터 객체에 대한 락을 획득하면 이 쓰레드는 자신이 획득한 모니터 객체에 대한 모든 synchronized 블록으로 진입할 수 있다. 이를 재진입이라 한다. 

먼저 선보인 락 구현은 재진입이 불가능하다. Reentrant 클래스를 아래와 같이 다시 작성하면, outer() 를 호출하는 쓰레드는 inner() 메소드 안의 lock.lock() 에서 블록된다.

public class Reentrant2{

  Lock lock = new Lock();

  public outer(){
    lock.lock();
    inner();
    lock.unlock();
  }

  public synchronized inner(){
    lock.lock();
    //do something
    lock.unlock();
  }
}


outer 를 호출하는 쓰레드는 먼저 Lock 인스턴스에 락을 걸고 inner() 를 호출한다. inner() 메소드 안에서 쓰레드는 다시 Lock 인스턴스에 락을 시도하는데, 이 시도는 실패하며 쓰레드는 블록된다. Lock 인스턴스는 이미 outer() 메소드에서 락이 걸렸기 때문이다.

쓰레드가 두 번째 lock() 을 호출하면서 블록되는 이유는 lock() 메소드 구현을 보면 간단히 알 수 있다.

public class Lock{

  boolean isLocked = false;

  public synchronized void lock()
  throws InterruptedException{
    while(isLocked){
      wait();
    }
    isLocked = true;
  }

  ...
}

이유는 쓰레드가 lock() 메소드를 벗어날지 말지를 결정하는 while 루프(스핀 락) 내부의 조건에 있다. 현재의 조건은 어떤 쓰레드가 락을 걸었느냐와 관계없이 lock() 을 벗어나기 위해서는 isLocked 은 반드시 false 여야 한다.

Lock 클래스를 재진입 가능한 락으로 만들기 위해 약간의 수정이 필요하다.

public class Lock{

  boolean isLocked = false;
  Thread  lockedBy = null;
  int     lockedCount = 0;

  public synchronized void lock()
  throws InterruptedException{
    Thread callingThread = Thread.currentThread();
    while(isLocked && lockedBy != callingThread){
      wait();
    }
    isLocked = true;
    lockedCount++;
    lockedBy = callingThread;
  }


  public synchronized void unlock(){
    if(Thread.curentThread() == this.lockedBy){
      lockedCount--;

      if(lockedCount == 0){
        isLocked = false;
        notify();
      }
    }
  }

  ...
}

이제 while 루프(스핀 락)가 Lock 인스턴스에 락을 건 쓰레드를 가지고 어떻게 동작하는지 보자. 락이 걸리지 않았거나(isLocked = false), 쓰레드가 락을 건 쓰레드와 동일하다면 while 루프는 실행되지 않는다. 그리고 쓰레드는 메소드를 종료한다. 


이에 더하여, 같은 쓰레드에 의해 락이 걸린 횟수를 저장해둘 필요가 있다. 그렇지 않으면 락을 여러번 호출하더라도 한 번의 unlock() 호출로 모든 락이 풀려버리게 된다. 락을 해제하려는 쓰레드가 lock() 호출과 동일한 횟수의 unlock() 을 호출하기 전까지는 락은 풀리지 않아야 한다. 

이제 Lock 클래스는 재진입 가능한 락이 되었다. 


락 공정성

자바의 synchronized 블록은 쓰레드들의 진입 순서에 대한 어떠한 보장도 하지 않는다. 때문에 다수의 쓰레드들이 동일한 synchronized 블록에의 접근을 계속 시도한다면, 하나 이상의 쓰레드가 영영 접근 권한을 부여받지 못하게 될 위험이 있다. 이 경우, 접근 권한은 언제나 다른 쓰레드들에게만 부여되는데, 이러한 현상을 기아상태라 한다. 이 현상을 피하기 위해 Lock 은 공정성을 가져야 한다. 이 글에서 등장한 Lock 구현은 내부적으로 synchronized 블록을 사용하고, 때문에 공정성을 보장하지 않는다. 기아상태와 공정성은 기아상태와 공정성에서 보다 자세히 다뤄진다. 


finally 절에서의 unlock() 호출

Lock 을 이용해 크리티컬 섹션을 보호할 때는 크리티컬 섹션에서 예외가 발생할 수 있음을 기억해야 한다. finally 절에서 unlock() 을 호출하는 일은 중요하다. 다른 쓰레드가 락을 걸 수 있도록 Lock 인스턴스의 락은 반드시 해제되도록 한다. 

lock.lock();
try{
  //do critical section code, which may throw exception
} finally {
  lock.unlock();
}

이 구조는 크리티컬 섹션의 코드에서 예외가 발생할 경우에도 락은 해제되도록 한다. finally 절에서 unlock() 을 호출하지 않는 상태로 크리티컬 섹션에서 예외가 발생한다면 락은 영원히 유지되어 lock() 을 호출하는 모든 쓰레드를 무한정 멈춰버릴 것이다. 








+ Recent posts