5.7 Java Thread Programming
Java는 언어 차원에서 스레드를 지원하고, API 또한 비교적 간단하여 기반 시스템에 구애받지 않고 비교적 쉽게 간단한 멀티 스레드 프로그래밍을 경험해볼 수 있다. 하지만 언제나 그렇듯이 멀티 스레드 프로그래밍은 결코 만만한 작업이 아니다.
이 글은 멀티 스레드 프로그래밍 시 마주치게 될 여러 문제점들에 대한 소개 수준에서 마무리 지을 것이다. 구제적인 예와 구현 등을 원한다면 참고 자료를 참조하길 바란다. 11.10.3절 'Thread Pool'도 한 예가 될 수 있겠다.
5.7.1 You believe your program is running on single thread?
알게 모르게, 우리가 작성하는 거의 모든 프로그램은 멀티 스레드 환경에서 수행된다.
기본적으로 AWT 기반(Swing 포함) Java 프로그램들은 100% 멀티 스레드를 사용한다면 어느 정도 수긍이 갈 것이다. Java의 AWT 시스템은 자동적으로 이벤트 처리를 위한 별도의 스레드를 생성하기 때문인데, 이 스레드는 이벤트 리스너 객체들과 기반 OS로부터 전달되는 이벤트 처리를 담당한다.
여기서 발생할 수 있는 문제는 크게 두 가지..
첫째, 리스너 객체는 보통 메인 스레드 상에서 접근할 수 있는 내부 객체로 구현된다. 또한 내부 객체는 이를 포함하는 객체의 코드에 접근할 수 있다. 즉, 두 개의 서로 다른 스레드가 동시에 접근할 수 있는 코드들이 생겨나게 되는 것이다.
다음 문제는 리스너 처리와 OS로부터의 이벤트를 처리라는 두 가지 작업을 한 스레드에서 처리하면서 나타난다. 이로 인해 AWT 스레드가 열심히 리스너 객체의 작업을 처리하고 있는 동안에는 모든 OS 레밸의 이벤트가 묶여 있게 된다. OS들이 적절한 이벤트 큐잉 메커니즘을 갖고는 있지만, 큐가 무한정 큰 것도 아니고 이벤트를 전달했는데 응답이 없다면 외부에서는 프로그램 자체가 죽은 것으로 오인할 수 있다. 사용자와의 상호작용이 중요한 프로그램이나 빠른 응답을 요하는 리얼 타임 시스템, 분산 프로그램 등에서 특히 문제될 수 있다.
5.7.2 Java threads are not platform independent
여러 가지 이유로 인해 Java 시스템은 플랫폼 독립적이지 않다. 그 중 대표적이고, 중요한 예가 바로 스레드 지원이다.
이미 수많은 운영체제들은 나름대로의 효율적인 스레드 메커니즘을 갖고 있다. 그리고 Java는 절대 이런 운영체제 나름의 메커니즘을 뜯어 고치게 할 만큼의 영향력은 없다. 만약 Java가 진정한 플랫폼 독립성을 추구했다면 가상 머신 명세에 'Java thread는 이런 식으로 동작한다'고 명시해 놓았을 것이다. 하지만 진짜 그런 일이 있었다면 자바는 세상에 둥지를 트지도 못했거나, SUN Solaris만을 위한 언어로 전락했을 것이다. 다행히도 그런 비참한 최후가 예견되는 도전은 없었고.. 불행히도 Java 플랫폼은 기반 운영체제의 스레드 정책, 또는 구현된 JVM에 따라 다르게 동작하게 되었다.
5.7.3 Thread safety and synchronization
메소드가 스레드에 안전하다는 것은 그 메소드가 멀티 스레드 환경에서 문제 없이 동작할 수 있다는 것을 의미한다.
이를 아주 쉽게 구현하는 방법은 메소드 내부에서 지역 변수만을 사용하고 어떠한 외부 객체나 필드 등에 접근하지 않도록 하면 된다. 하지만 이는 곧 '자기 혼자 뭔가를 하고 끝낸다'는 의미 외에는 없기 때문에 쓸 데 없이 컴퓨팅 타임만 낭비하는 경우로, 실제 프로그램에서는 아주 드물게 일어나는 나타는 진기한 현상일 뿐이다.
또는 이미 스레드에 안전하다고 판명된 외부 메소드나 객체만을 사용해야 한다. 신뢰할만한 소스로부터 인증받은 것이라면 그냥 믿고 사용하면 될 것이고(믿지 못하면 머리 아퍼진다), 그렇지 않다면 계속 순환적인 문제에 빠진다.
아무튼 어떤 메소드가 뭔가 의미 있는 일을 하기 위해서는 외부와의 상호작용이 필요해진다. 여기서 그 외부라는 것이 일반적으로 다른 스레드와 공유하는 자원이 되기 때문에 문제의 소지가 있다. 이 쯤에서 등장해야 하는 것이 바로 '동기화'이다
동기하는 여러 스레드가 동시에 안전하게 동작할 수 있는 매커니즘으로, 다음과 같은 경우에 동기화를 고려해야 한다..
v. 두 개 이상의 스레드가 같은 작업을 동시에 시작해 동시에 처리해야 한다.
v. 같은 객체 또는 같은 코드에 접근할 경우에는 한 스레드씩 차례로 해야 한다.
5.7.3.1 Synchronization is not efficient.
멀티 스레드 환경에서 안전한 프로그램을 개발하기 위해 동기화라는 매커니즘이 제공되지만 동기화가 만능 해결책이 되지는 못한다. 데드락 같은 논리적인 문제를 해결했다 손 치더라도 동기화는 필연적으로 수행 성능 저하라는 기대치 않은 결과를 낳게 된다.
동기화를 어떻게 하느냐에 따라, 그렇지 않은 경우와 비교해 수배에서 수백배까지도 성능 차이를 맛볼 수 있다. 때문에 필요치 않은 곳에서 동기화를 남발하거나 동기화할 영역을 너무 넓게 잡는 등의 행위는 자제해야 한다.
말은 쉽지만 사실 프로그램 코드 전체에서 어느 부분이 동기화가 필요하고, 어느 곳은 필요 없는 지를 알아 내기란 그리 만만치 않다. 멀티 스레드 프로그램은 동적으로 작동될뿐 아니라 일반적으로 개발자가 고려할 수 없는 여타의 프로그램들과 함께 동작하는 등 그 수행 환경이 너무 복잡하기 때문이다.
5.7.3.2 Avoiding synchronization
Atomic operation: 당연한 얘기지만 원자적인 작업은 동기화가 필요 없다. 프로세서에서 한 명령어만에 처리가 끝나므로 그 사이에 다른 무언가가 끼어들 여지가 전혀 없기 때문이다.
현재의 일반적인 자바 가상 머신에서는 long과 double을 제외한 모든 기본형 타입의 변수에 값을 할당하는 작업은 원자적이다. 일부 64비트 시스템에 최적화된 가상 머신의 경우 long과 double도 원자적으로 처리할 수 있다.
Immutable object: 불변 객체는 한 번 생성된 후에는 절대 그 상태가 변하지 않는 객체를 말한다. 쉽게 말해 상수와 같이 취급될 수 있다는 얘기다. 따라서 아무리 많은 스레드들이 동시에 이 객체를 사용한다고 해도 문제될 것이 전혀 없다.
이런 이유로 프로그램 분석/설계 단계에서부터 어떤 객체들이 불변 객체로 사용될 수 있을 지를 고려해보는 것은 좋은 멀티 스레드 프로그래밍 습관이라 할 수 있다.
Java의 기본 클래스 중 대표적인 불변 객체는 String 객체이다.
Synchronization wrappers: 한 객체를 통한 작업에서 대부분의 경우 동기화가 필요 없지만, 어떤 경우는 반드시 동기화해야 하는 경우가 발생할 수 있다. 동기화를 하지 않자니 시스템 안정성에 문제가 있고, 동기화를 강행하자니 엄청난 수행 속도 저하가 뒤따른다.
이런 때 써먹을 수 있는 좋은 해결책으로 동기화 래퍼라는 것이 있다. 이는 GoF의 Decorator 패턴에 해당하는 것으로, 먼저 동기화하지 않은 일반 객체가 있고, 그와 동일한 인터페이스를 갖고 동기화 처리가 된 래퍼 객체가 있다. 그리고 래퍼 객체는 일반 객체에 대한 참조자를 유지하면서, 들어오는 모든 요청을 동기화해 일반 객체에 넘겨주는 방식이다.
래퍼 객체는 동적으로 벗기고 씌우는 게 가능하기 때문에 상황에 따라 동기화 여부를 결정할 수 있다.
5.7.4 Concurrency vs. Parallelism
넓게 본다면 동시성이 병렬성을 포괄한다고 할 수 있다. 하지만 지금은 병렬성과 구분되는 좀 좁은 의미의 동시성을 이야기 하겠다.
동시성이란 일반적으로 말하는 '여러 개의 작업을 동시에 수행하는 듯 보이는 것'을 말한다. 이는 하드웨어의 빠른 프로세싱 능력을 십분 활용, 여러 개의 작업들을 조금씩 조금씩 번갈아 수행시키는 것이다(Interleaving). 하나의 프로세서에서 어느 한 시점에 전혀 상관없는 여러 작업들을 수행할 수는 없지 않은가? 멀티 코어나 인텔이 최근 선보인 하이퍼 스레딩 등의 기법을 이용하지 않고서는 말이다. 이런 이유로 현재까지도 대부분의 개인용 운영체제에서의 멀티 태스팅이니 멀티 스레딩이니 하는 매커니즘들은 이처럼 그럴 듯한 눈속임을 쓰는 것이다. 물론 우리들은 다 알면서도 속아주는 척하며 서로 해피한 삶을 영위하고 있다.
반면 병렬성이라 한다면, 실제 물리적으로 두 가지 이상의 작업들을 동시에 수행시키는 것을 말한다. 이를 구현하기 위해서는 일반적으로 두 개 이상의 프로세서가 필요하며, 만약 하나의 프로세서로 처리하려면 앞서 언급한 멀티 코어 프로세서나 하이퍼 스레딩이 구현된 프로세서가 필요하다. 아무튼 실행 유닛이 두 개 이상이면 당연히 각 유닛들이 별개의 작업들을 처리할 수 있게 된다. 고성능 워크스테이션이나 서버 시스템에서는 이처럼 멀티 프로세서 환경을 쉽게 접할 수 있고, 슈퍼 컴퓨터의 경우 수백에서 수천개의 프로세서를 이용하기도 한다.
갑자기 동시성과 병렬성 이야기를 주저리는 이유는 이것이 자바 스레드 시스템이 플랫폼 종속적이 되는데 지대한 영향을 미쳤기 때문이다. 지금부터 그 이유를 간단히 살펴보겠다.
앞서 살펴본 동시성과 병렬성. 이 중 특히나 서버 환경에서 자바 스레드 프로그램의 성능을 극대화시키려면 당연히 병렬성을 갖춰야 함은 자명하다. 아무리 많은 프로세서가 달린 슈퍼 컴퓨터라 하더라도 자바 스레드 프로그램이 동시적으로만 동작된다면 무슨 쓸모가 있겠는가?
자바 가상 머신은 자바 스레드 프로그램을 병렬적으로 수행되도록 하긴 해야 하는데.. 그러기 위해서는 반드시 운영체제의 스레드 모델을 사용해야만 한다. 자바 가상머신 역시 운영체제 입장에서는 하나의 프로세스에 불과하기 때문에 자기가 아무리 발버둥을 치고 떼를 쓰더라도 운영체제의 도움 없이는 동시성까지밖에 얻을 수 없는 한계를 갖는다. 이런 이유로 병렬성을 추구하는 가상 머신은 모두 자바 스레드를 운영체제 스레드와 매핑시키고 필요한 상태 정보만 유지/관리하도록 만들어지게 되었다.
5.7.5 Thread priority handling
5.7.5.1 Solaris system
현존 상용 Unix의 대표격인 솔라리스의 스레드 모델을 간략히 살펴보자. 솔라리스는 2^31 단계의 스레드 우선순위를 제공한다. 반면 자바의 경우 10 단계까지만 제공한다. 이 같은 환경이라면 자바 스레드 프로그래밍에 있어서는 문제될 것이 전혀 없다. 비록 솔라리스 네이티브 프로그램처럼 세세한 조율을 불가능하지만 자바 명세에 규정된 이상을 펼치기에는 너무나 행복한 환경이다.
5.7.5.2 NT (2000/XP) system
반면 NT 커널 기반 시스템은 우선순위가 6~7단계뿐이다. 즉, 어떻게 해서든 자바 가상 머신은 어플리케이션에서 요청하는 10단계를 우선순위를 NT의 7단계로 적절히 매핑시켜야 하고, 이 때 가상 머신 설계자가 엿장수가 되는 것이다.
이처럼 열악한 환경을 제공하는 운영체제가 점유율 면에서는 최대인 현실 때문에 우리는 자바의 스레드 모델을 마음껏 사용해볼 수 없다.
뿐만 아니라 NT는 우선 순위 증대라는 매커니즘을 사용, 운영체제가 필요에 따라 동적으로 스레드 우선순위를 증대시키고 다시 원위치로 복귀시킬 수 있으며, 스레드에 Idle, Normal, High, Real-Time 등과 같은 계급(class)이 매겨지고, 각 계급 간에는 우선순위가 중복되는 영역이 생겨 Idle 스레드가 Normal 스레드 수행 중에 끼어들거나 Normal 스레드가 High 스레드 수행 중에 끼어들기도 한다.
그리고 무엇보다 자바는 이런 것들을 제어할 수 있는 방법을 제공하지 못한다.
5.7.6 Thread models
5.7.6.1 Cooperative multithreading model
협력형(비선점형) 멀티스레딩 모델에서 각각의 스레드들이 동시에 수행되려면 스레드들 간의 적절한 협력이 이루어져야만 한다. 이 모델의 스레드들은 자신이 직접 양보하지 않는다면 절대 다른 스레드에게 프로세서 사용권이 돌아가지 않기 때문에, 빨리 작업을 끝마치고 사라지거나, 어느 정도 수행을 하고 나서는 반드시 양보를 해야 한다. 공중전화 부스 하나에 여러 사람이 줄 서 있는 상황을 떠올리면 된다.
이 모델의 운영체제는 구현하기가 무척 용이하다. 대표적인 예로 한 때 세상을 풍미하던 DOS가 있다. 물론 이전으로 거슬러 올라갈 수록 협력형 스레드 모델의 예는 찾기 쉬워진다.
이 모델은 절대 병렬적으로 수행될 수 없다는 치명적인 단점을 갖고 있지만, 수행할 작업들이 고정되어 있는 임베디드 시스템에서는 때로 유용하게 사용될 수도 있는 등 여전히 존재 가치는 있다.
5.7.6.2 Preemptive multithreading model
현재 거의 모든 대중적인 운영체제들은 선점형 멀티스레딩 모델을 따른다. 이 모델에서는 각각의 스레드들은 남 생각할 필요 없이 자신의 일만 묵묵히 수행하면 된다. 스레드들 간의 전환은 운영체제 커널의 스케줄러가 맡아 공정히 처리하면 되고, 전환이 이루어져도 스레드들은 자신에게 그런 시련(?)이 있었는지조차 인식하지 못한다.
때문에 복잡한 시스템이 아니라면 오히려 협력형 멀티스레딩 모델에서보다 훨씬 쉽게 프로그래밍할 수 있게 되었다. 그리고 멀티 프로세서 환경을 적극적으로 활용할 수 있다는 점에서 절대적인 지지를 얻고 있다.
하지만 운영체제가 그 만큼 복잡하게 만들고, 무엇보다 동기화 문제로 넘어가면서 고레밸의 머리 쥐어 뜯기 기술을 갖춘 개발자 양성에 일조하고 있다.
- 출처 : http://www.javastudy.co.kr/javastudy/new_bbs/qna_view.jsp?bbs_name=lecadvancebbs&theid=65 -