출처 :http://network.hanbitbook.co.kr/view.php?bi_id=1003&pg=1
자바 스레드 고급 동기화 1 | 프린트 |
등록: 한빛미디어(주)(2004-10-28 11:57:20) | |
저자:Scott Oaks and Henry Wong, 역 한동훈 원문:http://www.onjava.com/pub/a/onjava/excerpt/jthreads3_ch6/index1.html 편집자 노트: J2SE 5.0에는 새로운 점들이 있습니다: wait()와 notify()를 이용한 스레드 간의 흐름 제어(coordinating)에 대한 이전 버전의 선택사항들은 이제 스레드 작업을 위한 새롭고 복잡한 전략들을 표현하고 있는 클래스들로 보강되었습니다. Scott Oaks와 Henry Wong의 Java Threads, 3판의 첫번째 발췌는 java.util.concurrent 패키지에 대한 것입니다. 6장. 고급 동기화 문제들 이 장에서는 데이터 동기화와 관련된 보다 깊이 있는 이해를 요구하는 문제들을 살펴볼 것입니다. 이 주제는 주로 데이터 동기화와 관련된 시간제어(timing) 문제입니다. 스레드를 여러 개 사용하는 자바 프로그램을 작성하는 경우 데이터 동기화와 관련된 문제들 때문에 프로그램 디자인에 어려움이 생기는 경우가 많습니다. 게다가, 데이터 동기화와 관련된 오류는 특정 순서대로 이벤트가 발생할 때 생기기 때문에 발견하기도 매우 어려운 경우가 대부분입니다. 데이터 동기화에 대한 문제는 주로 시간제어 의존성(timing dependencies) 때문에 밝혀지지 않는 경우가 빈번합니다. 프로그램이 정상적으로 실행되는 동안에 발생하는 데이터 손상 문제를 발견한다해도 디버거나 코드에 디버깅 문장을 추가하여 실행할 경우 프로그램의 시간제어(timing)이 완전히 바뀌어버렸기 때문에 데이터 동기화 오류가 더 이상 발생하지 않는 경우도 있습니다. 이들 문제는 간단히 해결되지 않습니다. 대신에, 개발자는 이러한 문제들을 고려하여 프로그램을 설계(design)해야 합니다. 개발자들은 무엇이 원인인지, 무엇을 찾아봐야 하는지, 문제 발생을 피하기 위해 어떤 테크닉을 사용할 수 있는지와 같은 다양한 스레드 관련 문제들을 이해하고 있어야 합니다. 또한, 개발자들은 프로그램에서 필요한 유형의 동기화를 제공하는 도구, 스레드 안정성(threadsafe)로 알려진 도구들과 같은 고급 수준의 동기화 도구를 사용하는 것을 고려해야 합니다. 이 장에서는 이러한 아이디어들을 함께 살펴볼 것입니다. 동기화 용어 특정 스레드 시스템에 대해 알고 있는 프로그래머들은 이 장에서 논의할 몇 가지 개념들을 해당 시스템에서 사용하는 용어로 사용하는 경향이 있으며, 이러한 스레드 시스템에 대한 배경지식이 없는 프로그래머들은 여기서 사용하는 용어들을 꼭 이해할 필요가 있는 것은 아닙니다. 그래서, 여기에서는 여러분이 알고 있는 용어와 이 장에서 사용하는 특정 용어들을 비교할 것입니다.
독자들은 위 용어 목록을 읽는 동안 강력한 패턴을 알아챘을 겁니다. J2SE 5.0부터 위에서 언급한 거의 모든 것들이 자바의 핵심 라이브러리로 포함되어 있다는 것입니다. 이제, J2SE 5.0 클래스들에 대해서 간략하게 살펴볼 것입니다. 세머포어(Semaphore) 자바에서 세머포어는 기본적으로 카운터가 있는 잠금입니다. 잠금이 있는 경우에 액세스를 금지하기 위해 사용된다는 점에서는 Lock 인터페이스와 유사하지만, 차이점은 카운터입니다. 세머포어는 중첩될 수 없지만 잠금은 구현에 따라 잠금이 중첩될 수 있다는 점을 제외하면, Lock 인터페이스에 대한 카운터가 있는 세머포어는 잠금과 동일합니다. Semaphore 클래스는 발행할 수 있는 허가권 숫자를 유지합니다. 이 같은 정책을 사용하면 여러 스레드가 하나 이상의 허가권을 가지는 것을 가능하게 합니다. 허가권에 대한 실질적인 사용은 개발자에 달려 있습니다. 따라서, 세머포어는 허용할 수 있는 잠금의 수를 표현하기 위해 사용됩니다. 마찬가지로, 네트워크 연결이나 디스크 공간과 같은 리소스 제한 때문에 병렬로 작업할 수 있는 스레드 수를 제어하기 위해 사용될 수 있습니다. Semaphoreinterface를 살펴봅니다.
Semaphore 인터페이스는 Lock 인터페이스와 매우 유사합니다. 허가권을 얻거나 반환하기 위해 사용하는 acquire()와 release() 메서드는 Lock 인터페이스의 lock(), unlock()과 비슷합니다. tryAcquire() 메서드는 개발자가 잠금이나 허가권을 얻기 위해 사용하는 tryLock() 메서드와 비슷합니다. 이들 메서드는 허가권을 바로 얻을 수 없는 경우의 대기 시간과 잠금을 획득하거나 반환할 허가권의 수(기본값은 1)를 지정할 수 있습니다. Semaphore는 Lock과 몇 가지 다른 점이 있습니다. 첫째, 생성자는 허용할 수 있는 허가권 수를 지정해야 합니다. 총 허가권 수나 남아있는 허가권을 반환하는 메서드가 있습니다. 이 클래스는 잠금을 획득하거나 반환하는 알고리즘만을 구현하고 있습니다. Lock 인터페이스와 달리 Semaphore에서는 어떤 상태 변수도 사용하지 않습니다. 중첩의 개념이 없습니다. 동일한 스레드가 여러번 획득하는 것은 세머포어로부터 허가권을 여러번 얻는 것입니다. 세머포어가 fair 플래그가 true로 설정하여 생성된다면 세머포어는 요청이 만들어지는 순서대로 허가권을 할당합니다. ? 이것은 선착순과 매우 유사합니다. 이 선택사항의 단점은 속도입니다. 이는 가상머신에서 허가권을 순서대로 얻는 것은 임의의 스레드가 허가권을 획득하는 것 보다 더 많은 시간이 걸리기 때문입니다. 장벽(Barrier) 모든 스레드 동기화 도구들 가운데 장벽은 아마도 가장 이해하기 쉬운 것이면서 가장 적게 사용되는 것이라 생각합니다. 동기화를 생각할 때 첫번째 고려사항은 전체 작업의 일부분을 실행하는 스레드 그룹, 스레드들의 결과를 동기화해야 하는 위치에 대한 것입니다. 장벽은 단순히 결과를 모으거나 다음 작업으로 안전하게 이동하기 위해 모든 스레드들을 동기화하기 위한 대기장소라 할 수 있습니다. 응용 프로그램이 단계별로 수행될 때 장벽을 사용할 수 있습니다. 예를 들어, 대다수의 컴파일러들은 소스를 읽어들이는 것과 실행 파일을 생성하는 작업 사이에 많은 임시 파일과 다양한 경로를 만들어냅니다. 이런 경우에 장벽을 사용하여, 모든 스레드가 같은 단계에 머무르는 것을 보장할 수 있습니다. 이런 단순함에도 불구하고, 장벽은 왜 널리 사용되지 않는가? 기능은 단순하기 때문에 자바에서 제공되는 저수준(low-level) 도구로도 수행할 수 있습니다. 우리는 장벽을 사용하지 않고 이 문제를 두가지 방법으로 해할 수 있습니다. 첫번째는 상태 변수에 따라 대기하는 스레드를 만드는 것입니다. 마지막으로 수행되는 스레드는 다른 스레드들에게 작업이 완료되었음을 알리기 위해 장벽을 반환합니다. 두번째 방법은 join() 메서드를 사용하여 대기 종료 스레드를 사용하는 것입니다. 모든 스레드가 연결될 때 프로그램의 다음 단계를 위한 새 스레드를 시작하는 방법입니다. 그러나, 어떤 경우에는 장벽을 사용하는 것이 더 바람직합니다. join() 메서드를 사용할 때 스레드가 모두 종료되고, 우리는 새 스레드를 시작합니다. 따라서, 스레드는 이전 스레드 객체가 저장했던 상태 정보를 잃어버립니다. 따라서, 스레드는 종료되기 전에 상태를 저장해야 합니다. 뿐만아니라, 항상 새 스레드를 생성해야 한다면, 논리 연산자를 함께 사용할 수 없습니다. 왜냐하면, 각각의 하위작업에 대해 새 스레드를 생성해야하고, 하위 태스크에 대한 코드는 각각의 run() 메서드에 있어야 하기 때문입니다. 이런 경우에 모든 로직을 하나의 메서드로 작성하는 것이 더 쉬울 수 있습니다. 특히, 하위 작업이 매우 작은 경우에는 더욱 그렇습니다. 장벽 클래스의 인터페이스입니다.
장벽의 핵심은 await() 메서드입니다. 이 메서드는 기본적으로 상태변수의 await() 메서드와 비슷하게 동작합니다. 여기에는 장벽이 스레드를 풀어줄 때까지 대기하는 것과 만료시간 제한까지 대기하는 선택사항이 있습니다. 정확한 스레드 수가 대기중일 때 장벽이 통지를 수행하기 때문에 signal() 메서드가 있을 필요가 없습니다. 장벽을 생성할 때 개발자는 장벽을 사용하는 스레드 수를 지정해야 합니다. 이 숫자는 장벽을 동작시키는데 사용됩니다. 장벽에서 대기중인 스레드 수가 지정된 스레드 수와 일치할 경우에만 스레드가 모두 해제됩니다. 이 뿐만 아니라, run() 메서드를 구현하는 객체의 동작까지 지정할 수 있는 방법도 있습니다. 장벽이 해제되기 위한 조건을 만족하는 경우 스레드를 해제하기 전에 barrierAction 객체의 run() 메서드가 호출됩니다. 이를 이용하여 스레드 안정성이 없는 코드를 실행할 수 있습니다. 일반적으로, 이것을 이전 단계의 정리 코드 또는 다음 단계를 위한 준비(setup) 코드라 합니다. 장벽에 도달한 마지막 스레드가 동작을 실행하는 스레드가 됩니다. await() 메서드를 호출하는 각 스레드는 고유한 반환값을 돌려 받습니다. 이는 장벽에 도달한 스레드 순서와 관련있는 값입니다. 이 값은 개별 스레드가 프로세스의 다음 단계를 수행하는 동안 작업을 어떤식으로 분배할지 결정하는 경우에 필요합니다. 첫번째로 도착한 스레드는 총 스레드 수 보다 1 작은 값이 되고, 마지막으로 도착한 스레드는 0이 됩니다. 일반적인 사용에서, 장벽은 매우 간단합니다. 모든 스레드는 필요한 수 만큼의 스레드가 도착할 때까지 기다립니다. 마지막 스레드가 도착하자마자 동작(action)이 실행되고, 스레드는 해제되고, 장벽은 재사용할 수 있습니다. 그러나, 예외 조건이 발생하고, 장벽이 실패할 수 있습니다. 장벽이 실패할 때 CyclicBarrier 클래스는 장벽을 없애고, BroenBarrierException과 함께 await() 메서드에서 대기중인 스레드를 모두 해제합니다. 장벽은 다양한 이유로 깨질 수 있습니다. 대기 스레드가 중지(interrupted)될 수도 있으며, 스레드가 제한시간 조건 때문에 깨질 수도 있으며, barrierAction에 발생한 예외 때문에 깨질 수 있습니다. 모든 예외 조건에서 장벽은 간단히 깨집니다. 따라서 개별 스레드는 이 문제를 해결해야 합니다. 게다가, 장벽은 다시 초기화 될 때까지 초기화되지 않습니다. 즉, 이 상황을 해결하는 복잡한 알고리즘은 장벽을 재초기화하는 경우도 포함해야 합니다. 장벽을 다시 초기화하기 위해 reset() 메서드를 사용하지만, 장벽에 이미 대기중인 스레드가 있다면 장벽은 초기화되지 않습니다. 즉, 장벽은 동작하지 않게 됩니다. 장벽을 재초기화하는 것은 상당히 복잡하기 때문에 새로운 장벽을 생성하는 것이 보다 더 쉬울 수 있습니다. 마지막으로 CyclicBarrier 클래스는 몇 가지 보조 메서드를 제공합니다. 이들 메서드는 장벽에 대기중인 스레드 수에 대한 정보라든가 장벽이 이미 깨졌는지를 알려줍니다. 카운트다운 래치(Countdown Latch) 카운트다운 래치는 장벽과 매우 유사한 동기화 도구입니다. 실제로, 래치는 장벽 대신 사용할 수 있습니다. 자바를 제외한 일부 스레드 시스템은 세머포어를 지원하는 기능들을 구현하기 위해 카운트다운 래치를 사용하기도 합니다. 장벽 클래스와 마찬가지로 스레드가 어떤 상태를 대기하는 기능을 제공합니다. 차이점은 대기 해제 조건이 대기중인 스레드 수가 아니라는 것입니다. 대신에, 지정된 숫자가 0이 될 때 스레드가 해제됩니다. CountDownLatch 클래스는 카운트를 감소시키는 메서드를 제공합니다. 동일한 스레드가 이 메서드를 여러 번 호출할 수 있습니다. 뿐만아니라, 대기중이 아닌 스레드도 이 메서드를 호출할 수 있습니다. 카운터가 0이 되면 모든 대기 스레드가 해제됩니다. 경우에 따라 대기중인 스레드가 없는 경우도 가능하며, 지정된 수 보다 많은 스레드가 대기하는 것도 가능합니다. 래치가 발생한 다음에도 대기를 시도하는 스레드는 즉시 해제됩니다. 래치는 초기화(reset)되지 않습니다. 뿐만 아니라, 래치가 발생한 후에는 카운트를 감소시키는 어떠한 시도도 동작하지 않습니다. 카운터다운 래치의 인터페이스는 다음과 같습니다.
인터페이스는 매우 간단합니다. 생성자에서 초기화할 숫자를 지정합니다. 오버로드 메서드 await()는 카운트가 0이 될 때까지 스레드를 대기시킵니다. countDown()과 getCount() 메서드는 카운트를 제어하는 기능을 제공합니다. ? countDown()은 카운트를 감소시키고, getCount()는 카운트를 조회합니다. timeout 변수가 있는 await() 메서드의 반환값이 boolean인 것은 래치가 발생했는지의 여부를 나타내기 위한 것입니다. ? 래치가 해제된 경우 true를 반환합니다. 익스체인저(Exchanger) 익스체인저는 다른 스레드 시스템에서 해당하는 것을 찾아볼 수 없는 동기화 도구를 구현한 것이다. 이 도구를 설명하는 가장 쉬운 방법은 데이터 전달을 하는 장벽의 조합이라고 얘기하는 것이다. 이것은 스레드 쌍이 서로를 만나도록(랑데뷰) 하는 장벽이다. 스레드가 쌍으로 만나게 되면 서로 데이터를 교환하고 각자의 작업을 수행한다. *** 역주: 현재 C#의 다음 버전을 위해 시험중인 Comega에 포함된 Polyphony C#을 기억한다면 익스체인저가 자바에서만 찾아볼 수 있는 것이 아님을 알 것이다. 다만, 상용 언어들중에 적용된 것이 드물뿐이다. Polyphony C#을 기억한다면 다음 코드를 살펴보기 바란다.
Get()이 스레드이며 Put()이 스레드이다. Put()이 여러번 수행되어도 Get()이 수행되지 않는 한 어떤 일도 발생하지 않는다. Get()이 1회 실행되면 처음 실행된 Put()과 쌍이 되어 처리가 발생하고 데이터를 교환할 수 있다. 위 예제는 Polyphony C#(또는 C# 3.0에 포함되리라 알려진)으로 구현할 수 있는 하나의 예이며 자바의 Exchanger 클래스는 Polyphony의 특정 구현에 해당한다. *** 익스체인저 클래스는 스레드간 데이터를 전달하는데 주로 사용되기 때문에 동기화 도구라기 보단 컬렉션 클래스에 가깝습니다. 스레드는 반드시 짝을 이뤄야하며, 지정된 데이터 타입이 교환되어야 합니다. 이럼에도 불구하고, 이 클래스는 나름대로의 장점을 갖고 있습니다.
exchange() 메서드는 데이터 객체와 함께 호출되어 다른 스레드와 데이터를 교환합니다. 다른 스레드가 이미 대기중이라면 exchange() 메서드는 다른 스레드의 데이터를 반환합니다. 대기 스레드가 없으면 exchange() 메서드는 대기 중인 스레드가 생길때까지 대기합니다. 만료시간(timeout) 옵션은 호출하는 스레드가 얼마나 오랜동안 대기할 것인가를 제어합니다. 장벽 클래스와 달리 Exchanger 클래스는 깨지는 경우가 발생하지 않기 때문에 매우 안전합니다. 얼마나 많은 스레드들이 Exchanger 클래스를 사용하는가는 중요하지 않습니다. 왜냐하면, 스레드가 들어오는 대로 짝을 이루기 때문입니다. 스레드는 간단하게 예외 조건을 생성할 수 있습니다. 익스체인저는 예외 조건까지 스레드들을 짝짓는 과정을 계속 수행합니다. 읽기/쓰기 잠금(Reader/Writer Locks) 때때로 시간이 오래 걸리는 작업에서 객체로부터 정보를 읽어올 때가 있습니다. 읽어들이는 정보가 변경되지 않게 잠금을 할 필요는 있지만, 다른 스레드가 정보를 읽는 것까지 막아버릴 필요는 없습니다. 모든 스레드가 데이터를 읽을 때는 각 스레드가 데이터를 읽는 것에 영향을 주지 않기 때문에 방해할 필요가 없습니다. 실제로, 데이터 잠금이 필요한 경우는 데이터를 변경할 때 뿐입니다. 즉, 데이터에 쓰기를 하는 경우입니다. 데이터를 변경하는 것은 데이터를 읽고 있는 스레드가 변경된 데이터를 읽게 할 가능성이 있습니다. 지금까지는 스레드가 읽기나 쓰기 작업을 하는 것에 관계없이 하나의 스레드가 데이터에 액세스하는 것을 허용하는 잠금이었습니다. 이론적으로 잠금은 매우 짧은 시간동안만 유지되야 합니다. 만약 잠금이 오랜 시간동안 유지된다면 다른 스레드들이 데이터를 읽는 것을 허용하는 것을 생각해볼 필요가 있습니다. 이렇게 하면 스레드들간에 잠금을 얻기 위해 경쟁할 필요가 없습니다. 물론, 우리는 데이터 쓰기에 대해서는 하나의 스레드만 잠금을 획득하게 해야 하지만, 데이터를 읽는 스레드들에 대해 그렇게 할 필요는 없습니다. 하나의 쓰기 스레드만 데이터의 내부 상태를 변경합니다. J2SE 5.0에서 이러한 형태의 잠금을 제공하는 클래스와 인터페이스는 다음과 같습니다.
ReentractReadWriteLock 클래스를 사용하여 읽기-쓰기 잠금을 생성할 수 있습니다. ReentrantLock 클래스와 마찬가지로 이 선택사항은 잠금을 정당하게(fair) 분배합니다. "Fair"라는 의미대로 선착순, 먼저 도착한 스레드에게 먼저 잠금을 허용하는 것과 매우 가깝게 동작합니다. 잠금이 해제될 때 읽기/쓰기에 대한 다음 집합은 도착 시간을 기준으로 잠금을 얻습니다. 잠금의 사용은 예상할 수 있습니다. 읽기 스레드는 읽기 잠금을 획득하는 반면 쓰기 스레드는 쓰기 잠금을 획득합니다. 이 두 가지 잠금은 모두 Lock 클래스의 객체입니다. 그러나, 한기지 주요한 차이점은 읽기/쓰기 락은 상태 변수에 대한 다른 지원을 한다는 것입니다. newCondition() 메서드를 호출하여 쓰기 잠금과 관련된 상태 변수를 획득할 수 있으며, 읽기 잠금에 대해 newCondition() 메서드를 호출하면 UnsupportedOperationException이 발생합니다. 이들 잠금은 중첩이 가능합니다. 즉, 잠금의 소유자가 필요에 따라 반복적으로 잠금을 획득할 수 있습니다. 이러한 특성 때문에 콜백이나 다른 복잡한 알고리즘을 안전하게 수행할 수 있습니다. 뿐만아니라, 쓰기 잠금을 가진 스레드는 읽기 잠금도 획득할 수 있습니다. 그러나, 그 반대는 성립하지 않습니다. 읽기 잠금을 획득한 스레드가 쓰기 잠금을 획득할 수 없으며, 잠금을 업그레이드하는 것도 허용되지 않습니다. 그러나 잠금을 다운그레이드하는 것은 허용됩니다. 즉, 이것은 쓰기 잠금을 해제하기 전에 읽기 잠금을 획득하는 경우에 수행됩니다. 이 장의 뒤에서는 궁핍현상(lock starvation)에 대해 자세히 살펴볼 것입니다. 읽기-쓰기 잠금은 궁핍현상에서 특별한 의미를 가집니다. 이 절에서는 J2SE 5.0에서 제공하는 보다 높은 수준의 동기화 도구를 살펴볼 것입니다. 이러한 기능들은 이전 버전의 자바에서 제공하는 기능들로 직접 구현해야 했던 것입니다. 또는 서드 파티 제품에서 작성한 도구들을 사용해야 했습니다. 이들 클래스들은 과거에는 수행할 수 없었던 새 기능들을 제공하지 않지만 완전히 자바로 작성되었습니다. 그런 점에서는 편리한 클래스입니다. 다시 말해서, 개발을 보다 쉽게 하고 보다 높은 수준에서 응용 프로그램 개발을 할 수 있게 하기 위해 설계되었습니다. 이들 클래스들간에는 중복되는 부분이 상당히 많습니다. Semaphore는 하나의 허가권을 가진 세머포어를 선언하는 것으로 부분적으로는 Lock을 시뮬레이트하는데 사용할 수 있습니다. 읽기-쓰기 잠금의 쓰기 잠금은 부분적으로 상호 배제 잠금(mutually exclusive lock - 약자로 뮤텍스)을 구현하고 있습니다. 세머포어도 읽기-쓰기 잠금을 시뮬레이트하기 위해 사용될 수 있습니다. 카운트다운 래치도 각 스레드가 대기 하기전에 카운트를 감소시키는 장벽처럼 사용될 수 있습니다. 이들 클래스를 사용하여 얻을 수 있는 주된 장점은 스레드와 데이터 동기화 문제를 해결해 준다는 것입니다. 개발자들은 가능한한 높은 수준에서 프로그램을 설계하고, 낮은 수준의 스레드 문제에 대해 걱정하지 않아도 됩니다. 교착상태의 가능성, 잠금과 CPU 궁핍현상을 비롯한 복잡한 문제들에 대한 것들을 어느 정도 벗어버릴 수 있습니다. 그러나, 이러한 라이브러리를 사용하는 것이 개발자로 하여금 이러한 문제에 대한 책임을 완전히 제거하는 것은 아닙니다. |
'Java' 카테고리의 다른 글
[펌]자바성능을 향상시키는 코딩 (0) | 2005.06.15 |
---|---|
[펌] jar파일의 힘 (0) | 2005.01.20 |
[펌] [Collection Framework ] List, Set and Map (0) | 2005.01.20 |