본문 바로가기

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

[병렬프로그래밍] 자바 병렬 프로그래밍 소개

자바에서 제공하는 저수준의 도구와 설계 수준의 정책 간 차이점을 극복할 수 있도록 병렬 프로그램을 작성하는데 필요한 간결한 규칙이 필요합니다.


자바 메모리 모델의 깊숙한 부분까지 이해하면 병렬 프로그램 작성을 잘할수 있는건 맞지만 그런 상세한 내용을 모르는 상태에서도 올바르게 동작하는 병렬 프로그램을 작성할수 있도록 도와주려는 의도가 있습니다. 자바 병렬 프로그래밍과 관련하여 간결한 규칙을 잘 따르면 올바르면서도 유지보수도 간편한 병렬 프로그램을 작성 가능합니다.


동기화에 대한 기본 지식 필요합니다.

자바 동기화 기능에 대한 입문서 The Java Programming Language 스레드 관련 부분.

Concurrent Programming in Java 등을 참고.


기본 원리 / 병렬 프로그램 구조 잡기 / 활동성, 성능, 테스트 / 고급 주제



자바 병렬 프로그래밍 소개


작업을 동시에 실행하는 일에 대한 간략한 역사

운영체제는 여러개의 프로그램을 각자의 프로세스 내에서 동시에 실행할 수 있도록 발전되었습니다. 프로세스는 각자가 서로 격리된 채로 독립적으로 실행되는 프로그램으로서 운영체제는 프로세스마다 메모리, 파일 핸들, 보안권한(security credential) 등의 자원을 할당하게 됩니다. 프로세스끼리는 서로 통신을 할 수도 있는데 소켓, 시그널 핸들러, 공유 메모리, 세마 포어, 파일 등 비교적 큰 단위의 다양한 통신 수단이 제공됩니다.


여러 프로그램을 동시에 실행할 수 있는 운영체제를 개발하게 된 몇 가지 요인.


1) 자원활용: 프로그램은 때로 입출력과 같이 외부 동작이 끝나기를 기다려야 하는 경우가 많은데 기다리는 동안은 유용한 일을 처리하지 못합니다. 따라서 하나의 프로그램이 기다리는 동안 다른 프로그램을 실행하도록 지원하는 편이 더 효율적입니다.


2) 공정성: 여러 사용자와 프로그램이 컴퓨터 내 자원에 대해 동일한 권한을 가질 수 있습니다. 한 번에 프로그램 하나를 끝까지 실행해 종료된 이후에야 다른 프로그램을 시작하는 것보다는 더 작은 단위로 컴퓨터를 공유하는 방법이 바람직합니다.


3) 편의성: 때론 여러 작업을 전부 처리하는 프로그램 하나를 작성하는 것보다 각기 일을 하나씩 처리하고 필요할 때 프로그램 간에 조율하는 프로그램을 여러개 작성하는 편이 더 쉽고 바람직.하기도 합니다.



스레드로 인해 한 프로세스 안에 여러 개의 프로그램 제어흐름이 공존할 수 있습니다. 스레드는 메모리, 파일 핸들과 같이 프로세스에 할당된 자원을 공유합니다. 하지만, 각 스레드는 각기 별도의 프로그램 카운터, 스택, 지역 변수를 갖는다. 


프로그램을 스레드로 분리하면 멀티프로세서 시스템에서 자연스럽게 하드웨어 병렬성 이용 가능합니다. 즉 한 프로그램 내 여러 스레드를 동시에 여러 개의 CPU에 할당해 실행시킬 수 있습니다.



* 쓰레드 (가벼운 프로세스:lightweight process)

현대 운영체제의 대부분은 프로세스가 아니라 스레드를 기본 단위로 CPU 자원의 스케줄을 정합니다. 의도적으로 조율하지 않는 한 하나의 스레드는 다른 스레드와 상관없이 비동기적으로 실행됩니다.


스레드는 자신이 포함된 프로세스의 메모리 주소 공간을 공유하기 때문에 한 프로세스 내 모든 스레드는 같은 변수에 접근하고 같은 힙(heap)에 객체를 할당합니다.


이 때문에 프로세스 때보다 더 세밀한 단위로 데이터를 공유할 수 있습니다. 하지만 공유된 데이터에 접근하는 광정을 적절하게 동기화하지 않으면 다른 스레드가 사용중인 변수를 순간적으로 수정해 예상지 않은 결과가 나올 수 있습니다.



스레드의 이점

스레드를 제대로만 사용하면 유지 보수 비용을 줄이고 복잡한 애플리케이션의 성능을 향상시킬 수 있습니다. 비동기적인 일 흐름을 거의 순차적으로 바꿀수 있어 사람이 일하고 상호 작용하는 방식을 모델링하기 쉬워집니다. 또한 꼬인 코드를 새로 작성해 읽기 쉽고 유지 보수하기도 쉬운 명료한 코드로 만들 수도 있습니다. 또, JVM을 더 단순하게 구현할 수 있도록 도와주기도 합니다. 가비지 컬렉터는 보통 하나 또는 두 개 이상의 전용 스레드에서 실행됩니다.



1) 멀티프로세서 활용

스레드 하나로 동작하는 프로그램은 한 번에 최대 하나의 프로세서만 사용합니다. 프로세서가 두 개인 시스템에서 스레드가 하나뿐인 프로그램을 실행하면 CPU 자원의 50%를 낭비하는 셈입니다. 또, 프로세서가 100개인 경우라면 99%를 낭비하게 됩니다. 제대로 설계하기만 한다면 멀티스레드 프로그램은 가용한 프로세스 자원을 더 효율적으로 이용해서 처리 속도를 높일 수 있습니다.


2) 단순한 모델링

스케줄링, 교차 실행되는 작업, 비동기 I/O, 자원 대기 등의 세부적인 부분과 상위의 비즈니스 로직에 해당하는 부분을 분리할 수 있습니다. 복잡하면서 비동적인 작업 흐름을 각기 별도 스레드에서 수행되는 더 단순하고 동기적인 작업 흐름 몇 개로 나눌 수 있습니다. 이런 작업 흐름에서는 특정한 동기화 시점에서만 상호 작용이 발생합니다. 

이러한 장점은 서블릿이나 RMI(Remote Method Invocation)와 같은 프레임워크에서 종종 활용 됩니다. 프레임워크는 요청 관리, 스레드 생성, 로드 밸런싱, 그리고 작업 흐름 내에서 적절한 시점에 적절한 애플리케이션 컴포넌트에게 요청을 분배하는 등의 상세한 부분을 처리합니다. 서블릿 개발자는 웹 요청이 들어와 서블릿의 service 메소드가 호출될 때 해당 요청을 마치 단일 스레드 프로그램인 것처럼 처리할 수 있습니다.


3) 단순한 비동기 이벤트 처리

여러 클라이언트 프로그램에서 소켓 연결을 받는 서버 애플리케이션의 경우 각 연결마다 스레드를 할당하고 동기 I/O를 사용하도록 하면 개발 작업이 쉬워집니다. 단일 스레드 서버 프로그램의 경우 훨씬 복잡하고 실수하기도 쉬운 넌블로킹 I/O 기능을 써야만 합니다. 하지만 각 요청을 별개 스레드에서 처리하면 대기 상태에 들어가도 다른 스레드가 요청을 처리하는 데는 별 영향을 끼치지 않습니다.

표준 자바 API에서도 대기 상태에 들어가지 않는 I/O를 지원할수 있도록 java.nio 같은 패키지가 추가되었습니다. 하지만 시간이 지나면서 운영체제에서 더 많은 스레드를 지원할 수 있게 됨에 따라, 일부 플랫폼에서는 다수의 클라이언트에 대해서도 클라이언트마다 스레드를 하나씩 생성하는 일이 현실적인 경우가 많아지고 있습니다. 


4) 더 빨리 반응하는 사용자 인터페이스

이벤트 스레드에서 짧은 작업만 실행한다면, 사용자 인터페이스 반응 속도에는 별 영향이 없습니다. 이벤트 스레드가 사용자 입력을 충분히 빨리 처리할 수 있기 때문입니다.



스레드 사용의 위험성


언어 및 라이브러리 측면에서의 스레드 지원과 플랫폼 독립적으로 정형화된 메모리 모델 때문에 병렬 프로그램을 개발하는 일이 쉬워지면서, 개발자에 대한 기대치가 높아졌습니다. 개발자라면 대부분 스레드 안전성(thread safety)에 대해 잘 알아야 합니다.


1) 안전성 위해 요소

UnsafeSequence는 비표준 어노테이션인 @NotThreadSafe를 사용합니다.(같은 방식으로 쓰는 클래스 수준의 어노테이션으로는 @ThreadSafe와 @Immutable도 있습니다) 만약 @ThreadSafe라고 표시하면 클래스를 사용하는 사람은 멀티스레드 환경에서 문제가 없다는 점을 명확히 알 수 있고, 유지 보수하는 개발자는 스레드 안전성이 계속 보장돼야 한다는 점에 주의할 수 있습니다.


스레드는 같은 메모리 주소 공간을 공유하고 동시에 실행되기 때문에 다른 스레드가 사용 중일지도 모르는 변수를 읽거나 수정할 수도 있습니다. 다른 스레드간 통식방식보다 데이터 공유가 훨씬 쉬운 이점이 있습니다. 하지만 동시에 위험요소이기도 합니다. 경쟁 조건(race condition)이라고 하는 흔한 위험성을 보여줍니다. 멀티스레드 프로그램이 동작하는 모습을 예측하려면 스레드간 서로 간섭하지 않도록 공유된 변수에 접근하는 시점에 적절하게 조율해야 합니다. 다행히 자바에서는 공유 변수 접근을 조율하기 위한 동기화 수단이 제공되고 있습니다.


아래는 UnsafeSequenc를 바로 잡으려는 방법 가운데 하나 입니다. getNext를 동기화된 메소드로 만들면 문제가 해결됩니다.


@ThreadSafe

public class Sequence {
@GuardedBy("this") private int value;
// 스레드 안전한 일련번호 생성
public synchronized int getNext() {
return value;
}
}

동기화를 하지 않으면 컴파일러, 하드웨어, 실행 환경 각각에서 명령어의 실행 시점이나 실행 순서를 상당히 자유롭게 조정할 수 있습니다. 레지스터나 다른 스레드에 일시적으로 보이지 않는 프로세서별 캐시 메모리에 변수를 캐시해둘 수도 있습니다.

이런 요령은 성능을 향상하는 데 도움이 되고 바람직하기도 합니다. 하지만 프로그래머 입장에선 이런 최적화 작업 때문에 프로그램에 오류가 발생하지 않도록 스레드 간에 데이터가 공유되고 있는지를 명확하게 구분해줘야 하는 부담이 있습니다.



2) 활동성 위험

스레드를 사용할 때는 단일 스레드 프로그램에서는 나타나지 않는 추가적인 형태의 활동성(liveness) 장애가 생길 수 있습니다.

안전성이 "잘못된 일이 생기지 않는다"는 것을 뜻하는 반면, 활동성은 "원하는 일이 결국 일어난다"는 보완적인 목표에 관한 것입니다. 어떤 작업이 전혀 진전되지 못하는 상태에 빠질때 활동성 장애가 발생했다고 합니다. 데드락, 기아상태, 라이브락 등 여러 가지 활동성 장애 유형이 존재합니다.

활동성 장애를 일으키는 오류는 초기에 파악하기가 무척 어렵습니다. 각기 다른 스레드에서 실행하는 작업의 상대적인 타이밍에 따라 활동성 문제점이 나타나기 때문에 개발이나 테스트 도중에 잘 드러나지는 않습니다.



3) 성능 위험

잘 설계된 병렬 프로그램은 스레드를 사용해서 궁극적으로 성능을 향상시킬 수 있습니다. 하지만 스레드를 사용하면 실행 중에 어느 정도 부하가 생기는 것도 사실입니다. 스레드가 많은 프로그램에서는 컨텍스트 스위칭(다른 스레드가 실행될 수 있게 스케줄러가 현재 실행 중인 스레드를 잠시 멈출 때)이 빈번하고 이때 부담이 생기게 됩니다. 즉, 실행중인 컨텍스트를 저장하고 다시 읽어들여야 하며, 메모리를 읽고 쓰는 데 있어 지역성(locality)이 손실되고, 스레드를 실행하기도 버거운 CPU 시간을 스케줄링하는 데 소모해야 합니다. 또한, 스레드가 데이터를 공유할 때는 동기화 수단도 사용해야 합니다.

이런 동기화는 컴파일러 최적화를 방해하고, 메모리 캐시를 지우거나 무효화하기도 합니다. 그 밖에 공유 메모리 버스에 동기화 관련 트래픽을 유발하기도 합니다.