Post

동시 성능 기법

12. 동시 성능 기법

12.1 병렬성이란?

요즘의 멀티코어 세상에서는 암달의 법칙이 연산 태스크의 실행 속도를 향상 시키는 핵심 요소

어떤 연산 태스크가 병렬 실행이 가능한 파트와 반드시 순차 실행해야 하는 파트로 구성된다면

순차 실행 파트를 총 S, 총 태스크 소요 시간은 T

필요한 프로세서는 얼마든지 자유롭게 쓸 수 있다는 가정하게 프로세서 개수가 N이라고 하면, T는 프로세서 개수의 함수, 즉 T(N)으로 표기 가능

동시 작업은 T - S이고 N개 프로세스에 태스크를 고루 분배한다고 가정하면 전체 태스크 소요 시간은 다음과 같음

T(N) = S + (1/N) * (T - S)

프로세서를 무한지 증가시켜도 총 소요 시간은 순차 작업 시간 이상 줄일 수 없다

암달의 법칙에 따르면 병렬 태스크나 다른 순차 태스크 간에 소통할 필요가 전혀 없을 경우 이론적으로 속도는 무한히 높일 수 있다.

보통은 데이터 공유 없이 워크로드를 나누어 여러 워커 스레드에 분산

스레드끼리 상태나 데이터를 공유하기 시작하면 워크로드는 점차 복잡해지면서 결국 어쩔 수 없이 일부 태스크를 순차 처리하게 되고 통신 오버헤드가 발생

다시 말해, 상태를 공유하는 워크로드는 무조건 정교한 보호/제어 장치가 필요

자바 플랫폼은 JVM에서 실행되는 워크로드에 JMM이라는 메모리 보증 세트를 제공

12.1.1 자바 동시성 기초

1
2
3
4
5
6
7
public class Counter {
	private int i = 0;
	
	public int increment() {
		return i = i + 1;
	}
}

카운터를 락으로 적절히 보호하지 않은 상태로 멀티스레드 환경에서 이 코드를 실행하면, 다른 스레드가 저장하기 이전에 로드 작업이 일어날 가능성이 있다.

OS 스케줄러는 때를 가리지 않고 스레드를 컨텍스트 교환하므로, 스레드가 둘 뿐인 상황에서도 바이트코드는 다양항 순서로 실행 가능

volaltie을 추가하면 안전하게 증분 연산을 할 수 있을 것처럼 보이지만 그건 오산

무조건 값을 캐시에서 다시 읽어들여 다른 스레드가 수정된 값을 바라보게 할 수는 있지만, 증분 연산자의 복합적인 특성 탓에 업데이트 소실 문제를 막을 수 없음

synchronized나 락으로 감싸지 않은 채 카운터가 무방비로 노출돼 있어서 프로그램을 돌리 때마다 두 스레드는 갖가지 형태로 서로 엮일 공산이 큼

동기화를 사용할 때에는 아주 신중하게 설계하고 미리 잘 따져봐야 한다는 부담이 큼

아무 생각 없이 synchronized만 달랑 추가했다간 프로그램이 빨라지기는 커녕 더 느려질 수도 있다

이처럼 처리율 향상은 동시성을 부여하는 전체 목표와 상충됨

따라서 코드 베이스를 병렬화하는 작업을 진행할 때에는 복잡도가 늘어난 대가로 얻은 혜택을 충분히 입증할 수 있도록 성능 테스트가 수반되어야 함

동기화 블록을 추가하는 비용은 특히 스레드 경합이 없을 경우 요즘은 JVM 옛 버전보다 많이 저렴해짐

12.2.2 JMM의 이해

JMM은 다음 질문에 답을 찾는 모델

  • 두 코어가 같은 데이터를 액세스하면 어떻게 되는가?
  • 언제 두 코어가 같은 데이터를 바라본다고 장담할 수 있는가?
  • 메모리 캐시는 위 두 질문의 답에 어떤 영향을 미치는가?

자바 플랫폼은 공유 상태를 어디서 액세스하든지 JMM이 약속학 내용을 반드시 이행

순서에 관한 보장과 여러 스레드에 대한 업데이트 가시성 보장

고수준에서 JMM 같은 메모리 모델은 두 가지 방식으로 접근

강한 메모리 모델

전체 코어가 항상 같은 값을 바라봄

약한 메모리 모델

코어마다 다른 값을 바라볼 수 있고 그 시점을 제어하는 특별한 캐시 규칙 존재

하드웨어에 강한 메모리 모델을 구현하려면 사실살 메모리를 후기록(write-back, 캐시에만 반영하고 메모리는 쓰지 않음)하는 것과 같음

코어 수를 늘리는 건 상황을 더욱 악화시킬 뿐이라서 이런 방법은 근본적으로 멀티코어 체제에는 맞지 않음

또 자바는 아키텍처에 독립적인 환경으로 설계된 플랫폼

만약 JVM이 강한 메모리 모델 기반으로 설계됐다면, 네이티브 수준에서 강한 메모리 모델을 지원하지 않는 하드웨어에서 소프트웨어를 실행하기 위해서는 JVM에 별도 구현 작업이 필요

사실, JMM은 아주 약한 메모리 모델이라서 MESI를 비롯한 실제 CPU 아키텍처 추세와 잘 어울림

또 JMM은 보장하는 내용이 거의 없어서 이식 작업이 더 쉬움

JMM은 다음 기본 개념을 기반으로 애플리케이션을 보호

Happens-Before (~보다 먼저 발생)

한 이벤트는 무조건 다른 이벤트보다 먼저 발생

Synchronized-With (~와 동기화)

이벤트가 객체 뷰를 메인 메모리와 동기화시킴

As-If-Serial (순차적인 것처럼)

실행 스레드 밖에서는 명령어가 순차 실행되는 것처럼 보인다

Release-Before-Acquire (획득하기 전에 해제)

한 스레드에 걸린 락을 다른 스레드가 그 락을 획득하기 전에 해제한다.

동기화를 통한 락킹은 가변 상태를 공유하는 가장 중요한 기법으로, 동시성을 다루는 자바의 근본적인 관점을 대변

자바에서 스레드는 객체 상태 정보를 스스로 들고 다니며, 스레드가 변경한 내용은 메인 메모리로 곧장 반영되고 같은 데이터를 액세스하는 다른 스레드가 다시 읽는 구조

JVM에는 저수준 메모리 액세스를 감싸놓은 구현 코드가 상당히 많음

synchronized는 ‘모니터를 장악한 스레드의 로컬 뷰가 메인 메모리와 동기화 되었다’는 뜻

따라서 동기화 메서드, 동기화 블록은 스레드가 반드시 동기를 맞춰야 할 접점에 해당하며, 다른 동기화 메서드/블록이 시작되기 전에 반드시 완료되어야 할 코드 블록을 정의해 놓은 것

최근 자바 동시성 기술이 선보이기 전에는 synchronized 키워드가 멀티스레드 순서와 가시성을 보장하는 유일한 장치

JMM은 이런 일도 강제하면서 자바와 메모리 안전에 대해 추정 가능한 다양한 보장

기존 자바 synchronized 락은 여러 한계점이 노출됨

  • 락이 걸린 객체에서 일어나는 동기화 작업은 모두 균등하게 취급됨(쓰기 작업에만 synchronized를 적용하면 소실된 업데이트 현상이 나타남)
  • 락 획득/해제는 반드시 메서드 수준이나 메서드 내부의 동기화 블록 안에서 이루어져야 함
  • 락을 얻지 못한 스레드는 블로킹됨. 락을 얻지 못할 경우, 락을 얻어 처리를 계속하려는 시도조차 불가능

12.3 동시성 라이브러리 구축

JMM은 아주 성공적인 작품이긴 하지만 이해하기가 어렵고 실제로 응용하는 건 훨씬 어려움

그래서 자바 5부터는 언어 수준에서 지원하는 기능에서 탈피해서 고급 동시성 라이브러리와 툴을 자바 클래스 라이브러리의 일부로 표준화하려는 움직임이 확산되는 추세

java.util.concurrent 패키지는 멀티스레드 애플리케이션을 자바로 더 쉽게 개발할 수 있게 세심하게 설계된 라이브러리

이 라이브러리를 구성하는 핵심 요소

  • 락, 세마포어(semaphore)
  • 아토믹스(atomics)
  • 블로킹 큐
  • 래치
  • 실행자(executor)

일부 라이브러리(락, 아토믹스)는 비교해서 바꾸기(compare and swap, CAS)라는 기법을 구현하기 위해 저수준 프로세서 명령어 및 OS별 특성 활용

CAS는 ‘예상되는 현재 값(expected current value)’과 ‘원하는 새 값(wanted new value)’, 그리고 메모리 위치(포인터)를 전달받아 다음 두 가지 일을 하는 아토믹 유닛

  1. 예상되는 현재 값을 메모리 위치에 있는 컨텐츠와 비교
  2. 두 값이 일치하면 현재 값을 원하는 새 값으로 교체

CAS는 여러 가지 중요한 고수준의 동시성 기능을 구성하는 기본 요소

12.3.1 Unsafe

sun.misc.Unsafe는 내부 구현 클래스

Unsafe는 공식적으로 지원하지 않는 내부 API라서 언제라도 유저 애플리케이션을 배려하지 않은 채 없어지거나 변경될 수 있음

unsafe로 할 수 있는 일들

  • 객체는 할당하지만 생성자는 실행하지 않음
  • 원메모리(raw memory, 자료형이 따로 없이 바이트 배열 단위로 취급하는 메모리 블록)에 액세스하고 포인터 수준의 연산을 함
  • 프로세서별 하드웨어 특성(예, CAS)를 이용

덕분에 다음과 같은 고수준의 프레임워크 기능을 구현 가능

  • 신속한 (역)직렬화
  • 스레드-안전한(thread-safe) 네이티브 메모리 액세스
  • 아토믹 메모리 연산
  • 효율적인 객체/메모리 레이아웃
  • 커스텀 메모리 펜스(memory fence, 연산의 실행 순서를 CPU나 컴파일러가 함부로 바꾸지 못하도록 강제하는 기능)
  • 네이티브 코드와의 신속한 상호작용
  • JNI에 관한 다중 운영체제 대체물
  • 배열 원소에 volatile하게 액세스

12.3.2 아토믹스와 CAS

아토믹스는 값을 더하고 증감하는 복합 연산을 하며 get()으로 계산한 결괏갑슬 돌려받음

아토믹스는 자신이 감싸고 있는 베이스 타입을 상속하지 않고 직접 대체하는 것도 혀용되지 않음

Unsafe 내부에서 루프를 이용해 CAS 작업을 반복적으로 재시도

아토믹윽 락-프리하므로 데드락은 있을 수 없음

비교 후 업데이트하는 작업이 샐패할 경우를 대비해 내부적인 재시도 루프가 동반됨

변수를 업데이트하기 위해 여러 차례 재시도를 해야 할 경우, 그 횟수만큼 성능이 나빠짐

12.3.3 락과 스핀락

인트린직 락은 유저 코드에서 OS를 호출함으로써 작동

OS를 이용해 스레드가 따로 신호를 줄 때까지 무한정 기다리게 만듦

경합 중인 리소스가 극히 짧은 시간 동안만 사용할 경우 이런 방식은 막대한 오버헤드 유발

블로킹된 스레드를 CPU에 활성 상태로 놔두고 아무 일도 시키지 않은 채 락을 손에 넣을 때까지 계속 재시도하게 만드는 편이 더 효율적

-> 스핀락(spinlock)

스핀락의 핵심 개념은 동일

  • ‘테스트하고 세팅’하는 작업은 반드시 아토믹해야 함
  • 스핀락에 경합이 발생하면 대기 중인 프로세서는 빽뺵한 루프를 실행

12.4 동시 라이브러리 정리

12.4.1 java.util.concurrent 락

락은 자바 5부터 전면 개편되어 좀 더 일반화한 락 인터페이스가 java.util.concurrent.locks.lock에 추가

lock()

기존 방식대로 락을 획득하고 락을 사용할 수 있을 때까지 블로킹

newCondition()

락 주위에 조건을 설정해 좀 더 유연하게 락을 활용. 락 내부에서 관심사 분리 가능(예. 읽기와 쓰기)

tryLock()

락을 획득하려고 시도(타임아웃 옵션 설정 가능). 덕분에 스레드가 락을 사용할 수 없는 경우에도 계속 처리 진행 가능

unlock()

락을 해제

여러 종류의 락을 생성할 수 있고 여러 메서드에 걸쳐 락을 걸어놓는 것도 가능

한 메서드에서 락을 걸고 동시에 다른 메서드는 락을 해제 가능

ReentrantLock은 Lock의 주요 구현체로, 내부적으로는 int 값으로 compareAndSwap()을 함

즉, 경합이 없는 경우에는 락을 획득하는 과정이 락-프리

스레드가 동일한 락을 다시 획득하는 것을 재집입 락킹이라고 함. 스레드가 스스로를 블로킹하는 현상 방지

LockSupport 클래스는 스레드에게 퍼밋(permit, 허가증)을 발급

발급한 퍼밋이 없으면 스레드는 기다려야 함

퍼밋을 발급하는 개념 자체는 세마포어와 비슷하지만, LockSupport 클래스는 오직 한 가지 퍼밋만 발급

스레드는 퍼밋을 받지 못한 경우 잠시 파킹되었다가, 유효한 퍼밋을 받을 수 있을 때 다시 언파킹됨

pseudocode

1
while(!canProceed()) {... LockSupport.park(this); }

park(Object blocker)

다른 스레드가 unpark()을 호출하거나, 스레드가 인터럽트되거나, 또는 스퓨리어스 웨이크업(spurious wakeup)이 발생할 때까지 블로킹됨

parkNanos(Object blocker, long nanos)

park()와 같고 나노초 단위로 지정한 시간이 지나면 그냥 반환

parkUntil(Object blocker, long deadline)

park()와 같고 ms 단위로 지정한 시간이 지나면 그냥 반환

12.4.2 읽기/쓰기 락

여러 읽기 스레드가 하나의 쓰기 스레드에 달려드는 상황에서는 어느 한 읽기 스레드 때문에 나머지 읽기 스레드를 블로킹하느라 불필요한 시간을 허비할 가능성이 있음

ReentrantReadWriteLock 클래스의 ReadLock과 WriteLock을 활용하면 여러 스레드가 읽기 작업을 하는 도중에도 다른 읽기 스레드를 블로킹하지 않게 할 수 있다

블로킹은 쓰기 작업을 할 때에만 일어남

락을 ‘공정 모드(fair mode, 어느 정도 스레드 간 공정성이 보장되도록 FIFO에 가까운 방식으로 락을 획들할 수 있게 함으로써 각 스레드가 대기하는 시간을 최대한 균등하게 배분)’로 세팅하면 성능은 떨어지지만 스레드를 반드시 순서대로 처리하게 할 수 있음

12.4.3 세마포어

세마포어는 풀 스레드나 DB 접속 객체 등 여러 리소스의 액세스를 허용하는 독특한 기술을 제공

‘최대 O개 객체까지만 액세스를 허용한다’는 전제하에 정해진 수랑의 퍼밋으로 액세스를 제어

1
private Semaphore poolPermits = new Semaphore(2, true); // 퍼밋 2개, 공정모드

Semaphore 클래스의 acquire() a메서드는 사용 가능한 퍼밋 수를 하나씩 줄이는데 , 더 이상 쓸 수 있는 퍼밋이 없을 경우 블로킹

release() 메서드는 퍼밋을 반납하고 대기 중인 스레드 중에서 하나에게 해제한 퍼밋을 전달

세마포어를 사용하면 리소스가 블로킹되거나 리소스를 기다리는 큐가 형성될 가능성이 커서 스레드 고갈을 막기 위해 처음부터 공정모드로 초기화하는 경우가 많음

퍼밋이 하나뿐이 세마포어(이진 세마포어)는 뮤텍스와 동등

그러나 뮤텍스는 뮤텍스가 걸린 스레드만 해제할 수 있는 반면, 세마포어는 비소유 스레드도 해제 가능

데드락을 강제로 해결해야 할 경우 필요함

세마포어의 강점은 여러 퍼밋을 획득/해제할 수 있는 능력

퍼밋을 여러 개 쓸 경우, 불공정 모드에선 스레드가 고갈될 가능성이 크기 때문에 공정 모드는 필수

12.4.4 동시 컬렉션

자바 동시 컬렉션은 시간이 지나면서 스레드 핫 성능을 최고로 뽑아낼 수 있는 방향으로 조금씩 수정/보완됨

ConcurrentHashMap은 버킷 또는 세그먼트로분할된 구조를 최대한 활용하여 실질적인 성능 개선 효과를 얻음

각 세그먼트는 자체 락킹 정책을 가질 수 있음

따라서 읽기/쓰기 락을 둘 다 소유한 상태에서 여러 읽기 스레드가 ConcurrentHashMap 곳곳을 읽는 동안, 쓰기가 필요한 경우 어느 한 세그먼트만 락을 거는 행위도 가능

이터레이터는 일종의 스냅샷으로 획득하기 때문에 ConcurrentModificationException이 발생하는 일은 없음

자바 5부터 CopyOnWriteArrayList, CopyOnWriteArraySet이 새로 도입돼서 어떤 사용 패턴에서는 멀티스레드 성능이 향상될 수 있다.

이 두 클래스에서 자료 구조를 변경하면 배킹 배열 사본이 하나 더 생성됨

덕분에 기존 이터레이터는 예전 배열을 계속 탐색할 수 있고, 레퍼런스가 하나도 없게 되면 이 예전 배열 사본은 가비지 수집 대상이 됨

이 방식은 카피-온-라이트(copy-on-write) 자료 구조를 변경하는 횟수보다 읽는 횟수가 월등히 많은 시스템에서 잘 작동함

12.4.5 래치와 배리어

래치와 배리어는 스레드 세트의 실행을 제어하는 유용한 기법

모든 스레드가 순서로 진행되는 것이 이상적이라면 래치를 쓰기에 딱 좋은 경우

래치 카운트는 각 스레드가 countdown()을 호출할 때마다 1만큼 감소

카운트가 결국 0에 이르면 래치가 열리고 await()함수 때문에 매여 있던 스레드가 모두 해제되어 처리를 재개

이런 유형의 래치는 단 한번밖에 사용할 수 없음

파이프라인 각 단계마다 하나의 배리어/래치를 적용하는 것이 일반적인 베스트 프랙티스

12.5 실행자와 테스트 추상화

일반 자바 프로그래머는 저수준의 스레드 문제를 직접 처리하려고 하기 보다는, java.util.concurrent 패키지에서 적절한 수준으로 추상화된 동시 프로그래밍 지원 기능을 골라 쓰는 편이 좋음

스레딩 문제가 거의 없는 추상화 수준은 동시 태스크(concurrent task), 즉 현재 실행 컨텍스트 내에서 동시 실행해야 할 코드나 작업 단위로 기술 가능

일의 단위를 테스크로 바라보면 동시 프로그래밍을 단순화 가능

12.5.1 비동기 실행이란?

자바에서 태스크를 추상화하는 방법은, 값을 반환하는 태스크를 Callable 인터페이스로 나타내는 것

Callable은 call() 메서드 하나밖에 없는 제네릭 인터페이스로, call() 메서드는 V형 값을 반환하되 결괏값을 계산할 수 없으면 예외르 ㄹ던짐

Callable은 Runnable과 비슷하나, Runnable은 결과를 반환하거나 예외를 던지지 않음

Runnable에서 결과를 가져오는 방식은 다른 스레드를 상대로 실행 반환을 조정해야 하기 때문에 복잡도가 한층 가중될 수 있음

Callable형은 태스크를 아주 멋지게 추상화하는 수단을 제공

ExecutorService는 관리되는 스레드 풀에서 태스크 실행 메커니즘을 규정한 인터페이스

풀에 담긴 스레드를 어떻게 관리하고 그 개수는 몇개까지 둘지, 이 인터페이스를 실제로 구현한 코드가 정의

ExecutorService는 submit() 메서드를 통해 Runnable 또는 Callable 객체를 받음

Executors는 헬퍼 클래스로, 선택한 로직에 따라서 서비스 및 기반 스레드 풀을 생성하는 new*팩토리 메서드 시리즈 제공

newFixedThreadPool(int nThreads)

크기가 고정된 스레드 풀을 지닌 ExecutorService를 생성

풀 안의 스레드는 재사용되면서 여러 태스크를 실행

스레드가 전부 사용 중일 경우, 새 태스크는 일단 큐에 보관

newCachedThreadPool()

필요한 만큼 스레드를 생성하되 가급적 스레드를 재사용하는 ExecutorService를 만든다

생성된 스레드는 60초 간 유지되며, 그 이후에는 캐시에서 삭제됨

이 스레드 풀을 이용하면 소규모 비동기 태스크의 성능을 향상 가능

newSingleThreadExecutor()

스레드 하나만 가동되는 ExecutorService를 생성

새 태스크는 스레드를 이용할 수 있을 때까지 큐에서 대기

동시 실행 태스크의 개수를 제한해야 할 경우 유용

newScheduledThreadPool(int corePoolsize)

미래 특정 시점에 태스크를 실행시킬 수 있도록 Callable과 지연 시간을 전달받는 메서드들이 있음

일단 태스크가 제출되면 비동기로 처리되며, 태스크를 제출한 코드는 스스로를 블로킹할지, 결과를 폴링할지 선택 가능

submit() 메서드를 호출하면 Future가 반환되고, 여기서 get() 또는 타임아웃을 명시한 get()으로 블로킹하거나, 일반적인 방식대로 isDone()으로 논블로킹 호출

12.5.2 ExecutorService 선택하기

올바른 ExecutorService를 선택하면 비동기 프로세스를 적절히 잘 제거 가능, 풀 스레드 개수를 정확히 잘 정하면 성능이 뚜렷이 향상 가능

ThreadFactory를 이용하면 개발자가 직접 이름, 데몬 상태, 우선순위 등의 속성을 스레드에 설정하는 커스텀 스레드 생성기를 작성 가능

가장 흔히 사용하는 지표가 코어 수 대비 풀 스레드 수

동시 실행 스레드 개수를 프로세서 개수보다 높게 잡으면 경합이 발생하는 문제 발생

OS가 스레드들을 실행 스케줄링해야 하는 부담을 안게 되어 결국 컨텍스트 교환이 더 자주 일어남

경합이 어느 한계치에 이르면 동시 처리 모드로 전환하더라도 효과는 반감될 수 있다

12.5.3 포크/조인

자바는 개발자가 손수 스레드를 제어/관리하지 않아도 되도록 다양한 방식으로 동시성 문제를 처리

자바 7부터 등장한 포크(fork, 분기)/조인(join, 병합) 프레임워크는 멀티 프로세서 환경에서 효율적으로 작동하는 새로운 API 제공

ForkJoinPoll이라는 새로운 ExecutorService 구현체에 기반

ForkjoinPool 클래스는 관리되는 스레드 풀을 제공하며, 두 가지 특성이 있담

  • 하위 분할 태스크(subdivided task)를 효율적으로 처리 가능
  • 작업 빼앗기(work-stealing) 알고리즘을 구현

하위 분할 태스크는 표준 자바 스레드보다 가벼운, 스레드와 비슷한 엔티티로, ForkJoinTask가 지원하는 기능

ForkjoinPool 실행자에서 적은 수의 실제 스레드가 아주 많은 태스크/서브태스크를 담당해야 하는 유스케이스에 주로 사용

ForkjoinTask의 핵심은 자신을 더 작은 서브태스크로 분할하는 능력

그래서 이 프레임워크는 순수 함수 계산같은 특정 부류의 작업에 한하여 잘 맞음

그러나, 포크/조인 프레임워크의 작업 빼앗기 알고리즘은 태스크 하위 분할과 독립적으로 응용 가능

어느 스레드가 자신이 할당받은 작업을 모두 마쳤는데 다른 스레드에 아직 백로그가 남아 있으면 바쁜 스레드의 큐에서 작업을 몰래 가져와 실행 가능

ForkjoinPool에 있는 commonPool()이라는 정적 메서드는 전체 시스템 풀의 레퍼런스를 반환

덕분에 개발자가 직접 자체 풀을 생성해서 공유할 필요가 없고, 공용 풀은 지연 초기화(lazy initialization)되므로 필요한 시점에 생성됨

풀 크기는 Runtime.getRuntime().availableProcessors() -1로 정해짐

그러나 항상 기대한 결과값을 반환하지 않음

개발자가 원하는 병렬도를 프로그램으로 세팅 가능

1
-Djava.util.concurrent.ForkJoinPool.common.parallelism=128

parallelStream() 도 내부적으로 포크/조인 풀 사용

12.6 최신 자바 동시성

원래 자바 동시성은 실행 시간이 긴 블로킹 태스크(예. I/O)를 다른 스레드와 함께 실행할 수 있게 인터리빙하는 환경을 염두에 두고 설계

오늘날에는 가용한 CPU 리소스를 효율적으로 사용하는 문제가 더욱 중요하게 부각됨

현대 자바는 언어 및 표준 라이브러리에 내장된 추상화를 이용해 성능을 크게 높일 수 있는 환경 제공

12.6.1 스트림과 병렬 스트림

람다/스트림은 함께 사용하면 자바 개발자도 함수형 프로그래밍의 혜택을 누릴 수 있는, 일종의 ‘마법 스위치’

자바 스트림은 데이터 소스에서 원소를 퍼 나르는 불변 데이터 시퀀스로, 모든 타입의 데이터 소스에서 추출 가능

스트림은 람다 표현식, 또는 데이터를 가공하는 함수 객체를 받는 map() 같은 함수를 교묘히 잘 활용

외부 이터레이션을 내부 이터레이션으로 변경했디 때문에 데이터를 병렬화하거나 복잡한 표현식의 평가를 지연시킬 수 있음

parallelStream()을 이용하면 병렬로 데이터를 작업 후 그 결과를 재조합 가능

이 메서드를 호출하면 내부적으로 Spliterator를 써서 작업을 분할하고 공용 포크/조인 풀에서 연산을 수행

스트림은 처음부터 불변이므로 병렬 실행하더라도 상태 변경으로 생기는 문제를 예방 가능

문제를 데이터 관점으로 표현하는 건, 개발자가 저수준의 스레드 역학 및 데이터 변경 문제를 신경 쓰지 않을 수 있게 도와주는 일종의 태스크 추상화

paralleStream()은 여느 병렬 연산처럼 태스크를 찢어 여러 스레드에 분배하고 그 결과를 다시 취합해야 함

그래서 컬렉션이 작을수록 직렬 연산이 병렬 연산보다 훨씬 빠름

12.6.2 락-프리 기법

락-프리 기법은 블로킹이 처리율에 악영향을 미치고 성능을 저하시킬 수 있다는 전제하에 시작

디스럽터 패턴(desruptor pattern)은 락-프리한 기동시성이 얼마나 성능을 향상시킬 수 있는지 강조

놀라운 결과의 일등 공신은 바로 스핀락

두 스레드 간 동기화는 (모든 스레드가 바라볼 수 있도록) volatile 변수를 통해 효과적으로 수동 제어

CPU 코어를 계속 스피닝한다는 건, 데이터를 받자마자 컨텍스트 교환 없이 즉시 해당 코어에서 작업할 준비를 한다는 뜻

CPU 코어를 차지하는 건 사용률, 전력 소비 측면에서 비용이 듬

12.6.3 액터 기반 기법

태스크를 스레드 하나보다 더 작게 나타내려는 접근 방식 중 하나가 액터(actor, 실행기)

액터는 그 자체로 고유한 상태와 로직을 갖고 있다

동시에 다른 액터와 소통하는 메일박스 체계를 갖춘, 작고 독립적인 처리 단위

액터는 가변적인 상태는 일체 공유하지 않고 오직 불변 메시지를 통해서만 상호 통신함으로써 상태를 관리

액터 간 통신은 비동기적이며, 액터는 메시지 수신에 반응하여 정해진 일을 함

액터는 병렬 시스템 내부에서 하나의 네트워크를 형성하고 그 속에서 각자 나름대로 작업을 수행함으로써 하부 동시 모델을 완전히 추상화한 모습을 바라봄

액터는 동일한 프로세스 내부에서 존재하지만 꼭 그래야 한다는 법은 없다

그래서 다중 처리가 가능하며 심지어 멀티 머신에 걸쳐 있는 상태로도 작동 가능

멀티 머신과 클러스터링 덕분에 액터 기반 시스템은 어느 정도 내고장성(fault tolerant)이 필요한 상황에서 효과적으로 작동

협동 체제에서 액터를 제대로 작동시키기 위해 대부분 fail-fast 전략 사용

아카와 액터 기반 시스템의 주목적은 동시 프로그래밍을 곤란하게 만드는 제반 문제들을 해결하는 것

전통적인 락킹 체계보다 아카를 쓰는 것이 더 좋은 이유

  • 도메인 모델 내에서 가변 상태를 캡슐화하는 건 의외로 까다로운 일
  • 상태를 락으로 보호하면 처리율이 크게 떨어질 수 있다
  • 락을 쓰면 데드락을 비롯한 별별 문제가 유발될 수 있다

저수준 스레딩 API에는 스레드 실패/복원을 처리할 표준 방법이 없다

액터 방식이 잘 맞는 유스케이스(불변 메시지를 비동기 전송, 가변 성태 공유 금지, 각 메시지 처리기가 제한된 시간 동안 실행)에서는 최상이겠지만, 설계 요건상 요청-응답의 비동기 처리, 가변 상태 공유, 무제한 실행 등을 고려해야 하는 상황이라면 다른 방법으로 시스템을 추상화하는 것이 현명함

12.7 마치며

싱글 스레드 애플리케이션을 동시성 기반의 설계 방식으로 전환 시 다음을 고려

  • 순서대로 죽 처리하는 성능을 정확히 측정할 수 있어야 함
  • 변경을 적용한다면 다음 진짜 성능이 향상됐는지 테스트
  • 성능 테스트는 재실행하기 쉬워야 함

다음과 같은 유혹은 이겨내세요

  • 병렬 스트림을 곳곳에 갖다쓴다
  • 수동으로 락킹하는 복잡한 자료 구조를 생성
  • java.util.concurrent에 이미 있는 구조를 다시 만든다

목표는 이렇게 정하세요

  • 동시 컬렉션을 이용해 스레드 핫 성능을 높인다
  • 하부 자료 구조를 최대한 활용할 수 있는 형태로 액세스를 설계
  • 애플리케이션 전반에 걸쳐 락킹을 줄임
  • 가급적 스레드를 직접 처리하지 않도록 태스크/비동기를 적절히 추상화함

동시성 특징

  • 가변 상태 공유는 어렵다
  • 락은 정확하게 사용하기가 만많찮다
  • 동기/비동기 상태 공유 모델 모두 필요
  • JMM은 저수준의 유연한 모델
  • 스레드 추상화는 아주 저수준

참조

  1. Optimizing Java(자바 최적화)
This post is licensed under CC BY 4.0 by the author.