BOOK 5 - 자바 병렬 프로그래밍(1)
1장 - 개요
작업을 동시에 실행하는 일에 대한 역사
- 초기 컴퓨터에는 운영체제 자체가 없었기 때문에 컴퓨터는 처음부터 끝까지 하나의 프로그램을 실행하기만 했다.
- 운영체제는 여러 개의 프로그램을 각자의 프로세스 내에서 동시에 실행할 수 있도록 발전됐다.
- 이렇게 운영체제가 개발된 몇가지 요인은 다음과 같다.
- 자원 활용 : 프로그램은 때로 I/O와 같이 외부 동작이 끝나기를 기다려야 하는 경우가 많다. 하나의 프로그램이 기다리는 동안 다른 프로그램을 실행하도록 하는 것이 더 효율적이다.
- 공정성 : 여러 사용자와 프로그램이 컴퓨터 내 자원에 대해 동일한 권한을 가질 수 있다.
- 편의성 : 때로는 여러 작업을 전부 처리하는 프로그램 하나를 작성하는 것보다 각각 일을 하나씩 처리하고, 필요할 때 프로그램 간에 조율하는 프로그램을 여러개 작성하는 편이 더 쉽고 바람직하다.
- 이렇게 운영체제가 개발된 몇가지 요인은 다음과 같다.
- 위와 같이 프로세스 개념을 만들어낸 것과 같은 동기로 스레드가 만들어졌다.
- 즉, 스레드로 인해 하나의 프로세스 안에 여러개의 프로그램 제어 흐름이 공존할 수 있고, 스레드는 메모리, 파일 핸들과 같이 프로세스에 할당된 자원을 공유한다.
- 하지만 각 스레드는 각각 별도의 프로그램 카운터, 스택, 지역변수를 갖는다.
- 프로그램을 스레드로 분리하면 멀티프로세서 시스템에서 동시에 여러개의 CPU에 스레드를 할당해 실행시킬 수 있다.
- 현대 운영체제의 대부분은 프로세스가 아니라 스레드를 기본 단위로 CPU 자원의 스케줄을 정한다.
- 한 프로세스 내 모든 스레드는 같은 변수에 접근하고 같은 heap에 객체를 할당한다.
- 따라서 공유된 데이터에 접근하는 과정을 적절하게 동기화하지 않으면 예상치 못한 결과를 얻을 수도 있다.
스레드의 이점
멀티프로세서 활용
- 프로세서 스케줄링의 기본 단위는 스레드이기 때문에 스레드 하나로 동작하는 프로그램은 한 번에 최대 하나의 프로세서만 사용한다.
- 예를 들어 프로세서가 10개인 시스템에서 스레드가 하나 뿐인 프로그램을 실행하면 CPU 자원의 90%를 낭비하는 것이다.
- 활성상태인 스레드가 여러개인 프로그램은 여러 프로세서에서 동시에 실행되어 효율적으로 처리될 수 있다.
- 또한, 프로세서가 하나라고 해도 여러개의 스레드를 사용했을 때 처리 속도를 높일 수 있다.
- 단일 스레드 프로그램에서는 동기 I/O 작업이 완료될 때까지 기다리는 동안 프로세서가 놀게 된다.
- 멀티 스레드 프로그램에서는 하나의 스레드가 I/O 작업을 기다리는 동안 다른 스레드가 진행될 수 있기 때문이다.
단순한 모델링
- 보통 여러 종류의 일을 처리하는 것보다 한 종류의 일을 여러개 처리하는 것이 더 쉽다.
- 소프트웨어에서도 한 종류 일을 순차적으로 처리하는 프로그램은 작성하기도 쉽고 오류도 별로 생기지 않는다.
- 종류별 작업마다 스레드를 하나씩 할당하면, 마치 순차적인 작업처럼 처리할 수 있다.
- 또한, 스케줄링, 교차 실행되는 작업, 비동기 I/O, 자원 대기 등의 세부적인 부분과 상위 비즈니스 로직을 분리할 수 있다.
- 즉, 복잡하면서 비동기적인 작업 흐름을 각기 별도 스레드에서 수행되는 더 단순하고 동기적인 작업 흐름 몇개로 나눌 수 있다.
- 이런 장점은 servlet 같은 framework에서 종종 활용된다.
- 프레임워크가 요청 관리, 스레드 생성, 로드 밸런싱, workflow에서 적절한 시점에 적절한 컴포넌트에게 요청을 분배하는 등의 상세 부분을 처리해주기 때문에, 개발자는 동시에 다른 요청이 얼마나 많이 처리되고 있는지 등에 대해서는 고려할 필요가 없다.
- 웹 요청이 들어와서 servlet의 service 메서드가 호출될 때 해당 요청을 마치 단일 스레드 프로그램인 것처럼 처리할 수 있다.
- 컴포넌트 개발 작업이 훨씬 단순해지고 프레임워크를 쉽게 익힐 수 있다.
단순한 비동기 이벤트 처리
- 여러 클라이언트에서 요청을 받는 서버 어플리케이션의 경우, 각 연결마다 스레드를 할당하고 동기 I/O를 사용하도록 하면 개발 작업이 쉬워진다.
- 단일 스레드 프로그램의 경우에는 추가 데이터가 들어올 때까지 read 연산에서 대기하게 되면 해당 요청에 대한 작업이 멈추는 것 뿐만 아니라 다른 모든 요청도 처리하지 못하고 멈추게 된다.
- 따라서 훨씬 복잡하고 실수하기 쉬운 non-blocking I/O 기능을 써야만 한다.
- 하지만 각 요청을 별개 스레드에서 처리하면 대기 상태에 들어가도 다른 스레드가 요청을 처리하는데는 별 영향을 끼치지 않는다.
- 이전의 운영체제는 하나의 프로세스가 생성할 수 있는 스레드 개수에 제약이 심해 최대 몇백 개 정도만 생성할 수 있었다.
- 그에 따라 표준 자바 API에서도 효율적인 I/O를 위해 대기 상태에 들어가지 않는 I/O를 지원하는
java.nio같은 패키지가 추가됐었다.
- 그에 따라 표준 자바 API에서도 효율적인 I/O를 위해 대기 상태에 들어가지 않는 I/O를 지원하는
- 시간이 지나면서 운영체제에서 더 많은 스레드를 지원할 수 있게 됨에 따라, 클라이언트마다 스레드를 하나씩 생성하는 일이 많아졌다.
스레드 사용의 위험성
안정성 위해 요소
-
동기화를 충분히 해두지 않으면 여러 스레드에서 실행되는 연산의 순서를 예측하기 어렵다.
-
일련의 유일한 정수를 생성하는 예제 코드를 살펴보자.
public class UnsafeSequence { private int value; public int getNext() { return value++; } }- 타이밍이 좋지 않은 시점에 두개의 스레드가
getNext메소드를 동시에 호출했을 때 같은 값을 얻을 가능성이 있다. - 즉, 여러 스레드에서 실행되는 연산은 서로 간에 무작위로 끼어들 수 있으므로, 스레드 두개가 동시에 같은 값을 읽고 각자 1을 더할 가능성이 있는 것이다.
- race condition 이라고 하는 흔한 위험성을 보여주는 예제다.
- 타이밍이 좋지 않은 시점에 두개의 스레드가
-
스레드는 서로 같은 메모리 주소 공간을 공유하고 동시에 실행되기 때문에 다른 스레드가 사용 중일지도 모르는 변수를 읽거나 수정할 수도 있다.
- 멀티 스레드 프로그램이 동작하는 모습을 예측하려면 스레드가 서로 간섭하지 않도록 공유된 변수에 접근하는 시점에 적절하게 조율해야 한다.
- 자바에서는 공유 변수 접근을 조율하기 위한 동기화 수단이 제공된다.
-
Unsafesequence를 제대로 동작하게 하기 위해서는getNext메서드를 동기화된 메소드로 만드는 방법이 있다.public class Sequence { @GuardedBy("this") private int value; public synchronized int getNext() { return value++; } }
활동성 위험
- 어떤 작업이 전혀 진전되지 못하는 상태에 빠질 때 활동성 장애가 발생했다고 한다.
- 스레드를 사용하면 활동성 관련 문제의 위험성이 더 높아진다.
- 예를 들어, 스레드 A에서 스레드 B가 독점하고 있는 자원을 기다리고 있는데, 스레드 B가 해당 자원을 절대 내놓지 않는다면 스레드 A는 영영 기다리기만 하고 실행되지 못할 것이다.
- deadlock, starvation, livelock 등 여러가지 활동성 장애 유형들이 존재한다.
성능성 위험
- 잘 설계된 병렬 프로그램은 궁극적으로 성능을 향상시킬 수 있지만, 스레드를 사용하면 어느정도 부하가 생기는 것도 사실이다.
- 스레드가 많은 프로그램에서는 context switching이 빈번하고, 그 때문에 부담이 생길 수 있다.
- 즉, 실행중 컨텍스트를 저장하고, 다시 읽어들여야 하며, 스레드를 실행하기도 버거운 CPU 시간을 스케줄링하는 데 소모해야 한다.
- 또한 스레드가 데이터를 공유할 때는 동기화 수단도 사용해야 한다.
- 이런 모든 요인은 서비스 시간, 반응성, 처리율, 자원 소모 같은 성능 측면에서 추가적인 손실을 유발한다.
스레드는 어디에나
- 프로그램을 작성할 때 스레드를 직접 생성하지 않더라도 프로그램이 사용하는 프레임워크에서 스레드를 생성할 수도 있다.
- 모든 자바 프로그램은 기본적으로 스레드를 사용한다.
- JVM을 시작시키면
main메소드를 실행할 메인 스레드 뿐만 아니라 가비지 컬렉션같은 JVM 내부 작업을 담당할 스레드도 생성한다.
- JVM을 시작시키면
- servlet이나 RMI(Remote Method Invocation) 같은 컴포넌트 프레임워크 역시 스레드를 관리하는 풀을 여러개 생성하고, 이 스레드를 사용해 컴포넌트의 메소드를 호출한다.
- 프레임워크는 프로그램 컴포넌트를 호출할 때 프레임워크 내부의 스레드에서 호출하기 때문에, 자동으로 프로그램이 스레드를 활용하는 것과 동일한 효과를 준다.
- 컴포넌트는 언제나 프로그램 내부의 상태에 접근하기 때문에 해당 상태에 접근하는 모든 코드 경로에 해당하는 컴포넌트 역시 Thread-safe 해야한다.
- 프레임워크는 프로그램 컴포넌트를 호출할 때 프레임워크 내부의 스레드에서 호출하기 때문에, 자동으로 프로그램이 스레드를 활용하는 것과 동일한 효과를 준다.