Chapter 12. 동시 성능 기법
Opened this issue · 3 comments
정리
- 무어의 법칙: 칩 하나에 꽂을 수 있는 트랜지스터 개수가 매년 2배 증가. 50년 정도동안은 유효했음.
- 암달의 법칙
- T(N) = S + (1/N) * (T - S)
- S: 순차 실행 파트
- T: 총 태스크 소요 시간
- N: 프로세서 개수
- 동시 작업은 T - S이고, N 개 프로세스에 태스크를 고루 분배한다고 가정하면 전체 태스크 소요 시간은 다음과 같다.
- 낯간지러운 병렬: 병렬 태스크나 다른 순차 태스크 간에 소통할 필요가 전혀 없을 경우 이론적으로 속도는 무한히 높일 수 있다.
- 자바 동시성
- volatile 키워드
- Main memory로부터 최신 값을 가져오도록 한다.
- Thread Safety Case
- 오직 하나의 Thread만 write하는 경우
- 여러 thread가 write하지만 Atomic하게 write하는 경우
- Non-Thread Safety case
- non-atomic/composite operation
- e.g. 증가/감소 와 같은 operation. 증가/감소는 3가지 operation(Read variable, Update, Writing the updated back to memory)으로 이루어져있기 때문에 이 짧은 시간동안 race condition 발생 가능
- https://www.baeldung.com/java-volatile-variables-thread-safety
- volatile 키워드
- JMM(Java Memory Model)의 이해
- JMM의 아주 약한 메모리 모델이다.
- 강한 메모리 모델: 전체 코어가 항상 같은 값을 바라본다
- 약한 메모리 모델: 코어마다 다른 값을 볼 수 있고, 그 시점을 제어하는 특별한 캐시 규칙이 있다
- JMM documents - TODO
- JMM의 아주 약한 메모리 모델이다.
- CAS(Compare-And-Swap): non-blocking algorithms for concurrent environments. 단일 연산으로 Read, Update, Writing the updated back to memory를 처리한다.
- Non-blocking algorithm: 특정 스레드에서 작업이 실패하거나 또는 대기 상태에 들어가는 경우에, 다른 어떤 스레드라도 그로인해 실패하거나 대기 상태에 들어가지 않는 알고리즘을 Non-Blocking 알고리즘이라고 한다. 또한 각 작업 단계마다 일부 스레드는 항상 작업을 진행할 수 있는 경우 Lock-Free 알고리즘이라고 한다.
- JVM에서의 CAS 지원은 Java 5.0에서부터 시작되었고, CAS 연산을 직접 지원하는 하드웨어에서는 해당 부분을 기계어로 변환.
- https://effectivesquid.tistory.com/entry/Lock-Free-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98Non-Blocking-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98
- Intrinsic lock vs spin lock
- Intrinsic lock: 유저 코드에서 OS를 호출함으로써 작동. OS를 이용해 스레드가 따로 신호를 줄 떄까지 무한정 기다리게 만드는 것
- Spin lock: 블로킹된 스레드를 CPU에 활성 상태로 놔두고, 아무 일도 시키지 않은 채 계속 재시도(CPU cycle 소모)
- 동시 라이브러리
- java.util.concurrent.locks.Lock interface
- lock(): 기존 방식대로 락을 획득하고 락을 사용할 수 있을 때까지 블로킹
- tryLock(): 락을 획득하려고 시도하고, true/false를 return. 타임아웃 설정도 할 수 있음.
- ReentrantLock: Lock의 구현체
- ReentrantReadWriteLock의 ReadLock과 WriteLock을 사용하면, 여러 스레드가 읽기 작업을 하는 도중에는 다른 읽기 스레드를 블로킹하지 않게 할 수 있다. 블로킹은 쓰기만 일어남. 읽기가 많이 일어나는 Application에서 좋다.
- Semaphore: ‘최대 O개 객체까지만 액세스를 허용’과 같이 여러 리소스의 액세스를 허용하는 독특한 기술을 제공
- acquire: 사용 가능한 퍼밋 수를 하나씩 줄임. 쓸 수 있는 퍼밋이 하나도 없을 경우 blocking
- release: 퍼밋을 반납하고 대기 중인 스레드 중에서 하나에게 해제한 퍼킷을 전달
- CountDownLatch: Count를 셋팅하고, 각 스레드가 countdown()을 호출할 때마다 카운트 값을 1 감소하고, 0에 이르면 Latch가 열린다.
- ForkJoinPool
- 하위 분할 테스크를 효율적으로 처리 가능
- 작업 빼앗기 알고리즘
- java.util.concurrent.locks.Lock interface
질문
- Q. 왜 약한 메모리 모델이 강한 메모리 모델보다 이식성이 높은 건가? 최근 CPU 아키텍처들이 약한 메모리 모델이라서?
- Q. CountDownLatch는 현재 단계의 모든 일을 처리하고 다음 단계로 넘어가야 할 때 사용? 실제 이런 사례가 있는 게 있을까? 써본적이 없어서...
- Q. 책에서는 경합 중인 리소스가 극히 짧은 시간동안만 사용할 경우 Intrinsic lock 방식은 막대한 오버헤드를 유발할 수 있다고 한다. 이것에 공감하지만, 경합 중인 리소스를 극히 짧은 시간동안 사용하는걸 어떻게 알지?
느낀점
- ‘The Free Lunch Is Over’ 학부 때, 과제로 읽었던 아티클이었는데 다시 만나니 반갑네요ㅎㅎ
- 동시성 라이브러리를 쓰면 ‘내가 안해도 된다’는 점(가장 중요)이 좋은 듯
- 커맨드 + B로 코드 타고들어가다보면
CountDownLatch
많이 보이는데 이번에 그 까닭을 알게됨 Spliterator
가 Split + Iterator인 것 같은데, 이름을 잘지은 것 같기도하고 아닌 것 같기도하고..ㅋㅋ
끄적끄적
- CPU의 처리속도는 매 18~24개월당 2배로 늘어나는 추세를 보이며 계속해서 증가함 (무어의 법칙)
- 하지만, 2003년 즈음부터 처리속도 증가는 한계에 도달했고 무어의 법칙도 깨지게 됨
- 그 이유는 CPU 처리 속도에 3가지 요소가 작용하는데: 클럭 속도, 실행최적화, 캐시
- 이 중 클럭 속도를 늘리는 데에는 물리적으로 한계(대략 3.8GHz)가 있음
- 한계 이상으로 높이게되면 발생하는 열 + 전력 소비가 어마어마하고, 전력 누설 등의 문제가 발생함 (The Free Lunch Is Over의 내용)
- 따라서, 여러 개의 코어를 사용해서 처리하는 병렬처리가 매우 중요해짐
- 하지만, 병렬 처리에도 한계가 있으니..
- 프로세서를 무한히 증가시켜도 순차처리를 해야하는 구간(e.g. 공유 데이터, 상태 공유 등)은 줄어들 수 없기 때문에 오버헤드가 반드시 존재함 (암달의 법칙)
JMM의 이해
- JMM은 순서에 관한 보장, 여러 스레드에 대한 업데이트 가시성 보장을 약속함
- JMM의 기본 개념
- Happens-Before : 한 이벤트는 무조건 다른 이벤트보다 먼저 발생한다.
- Synchronizes-With : 이벤트가 객체 뷰를 메인 메모리와 동기화시킨다.
- As-If-Serial : 실행 스레드 밖에서는 명령어가 순차 실행되는 것처럼 보인다.
- Release-Before-Acquire : 한 스레드에 걸린 락을 다른 스레드가 그 락을 획득하기 전에 해제한다.
- 자바에서 스레드는 객체 상태 정보를 스스로 들고 다님. 스레드 변경 내용은 메인 메모리로 곧장 변영되고, 같은 데이터를 액세스하는 스레드가 다시 읽는 구조
synchronized
키워드로 감싸지 않은 액세스에 대해서는 데이터 일치를 전혀 보장하지 않음synchronized
키워드의 한계- 락이 걸린 객체에서 일어나는 동기화 작업은 모두 균등하게 취급된다.
- 락 획득/해제는 반드시 메소드 수준이나 메소드 내부의 동기화 블록 안에서 이뤄져야한다.
- 락을 얻지 못한 스레드는 블로킹된다.
동시성 라이브러리 구축
java.util.concurrent
패키지로 멀티스레드 애플리케이션 개발을 쉽게해줌- 락, 세마포어, 아토믹스(Atomics), 블로킹 큐, 래치, 실행자(Executor)
- 멀티스레드 애플리케이션을 개발하기 위해서는 저수준(OS 영역)으로 처리해야만 함
- 그렇기 때문에 내부적으로 사용하는 저수준 API를 씀.(
sun.misc.Unsafe
) JDK 9부터는jdk.unsupported
패키지로 이동함 - Unsafe는 일반 Java 코드로는 할 수 없는 일들을 할 수 있음
- 객체를 할당하지만 생성자는 실행하지 않는다
- Raw 메모리에 액스세하고 포인터 수준의 연산을 수행한다.
- 프로세서별 하드웨어 특성(e.g. CAS)을 이용한다.
- 덕분에 잘못 사용하면 매우 위험하지만 잘 사용하면 성능이 뛰어난 기능을 구현할 수 있음
- 업계에서 활용도가 높아 사실상 표준(De facto)라고 함… ← 쓰는 코드를 본적이 없는데..?
- 라이브러리에 구현된 패키지들은 Native 코드를 사용해서 하드웨어 명령어에 직접 액세스함
- 자바가 추구하는 목적(하드웨어의 추상화)과 거리가 멀지만 어쩔 수 없는 선택
스핀락
- 보편적으로 락을 사용할 때, 유저 코드에서 OS를 호출하면서 작동하게됨
- 만약 극히 짧은 시간만 블로킹되는 코드라면 컨텍스트 스위칭에 대한 오버헤드가 매우 크기 때문에 OS 이벤트를 대기하지 않고 CPU 시간을 써가면서 락을 얻으려고 시도하는 편이 효율적임
- 이것이 스핀락(Spinlock)이라고 부르고, Mutex보다 가볍게 사용가능함
Latch
- 처음에 설정한 카운트만큼
countdown()
이 호출될 때까지await()
메소드로 스레드를 블로킹시킬 수 있음
Fork/Join
- Java 7부터 등장한 ForkJoinPool은 손수 스레드를 제어/관리하지 않아도 되도록 지원해줌
- Subdivided task를 효율적으로 처리할 수 있고, Work-stealing 알고리즘을 구현함
ForkJoinTask
로 자기 자신을 더 작은 서브태스크로 분할 시킬 수 있음- Work-stealing은 Idle(노는) 스레드가 다른 스레드의 백로그(할일 큐)에서 잡을 뺏어서 처리하는 알고리즘
- JDK 8부터 등장한 스트림 API의
parallelStream()
은 포크/조인을 사용하기 때문에 활용 범위가 크게 확대됨
최신 자바 동시성
- Java 8부터 람다와 스트림을 써서 함수형 언어에 근접했음
parallelStream()
으로 병렬로 데이터를 작업 후 결과를 재조합 할 수 있음- 내부적으로
Spliterator
를 써서 작업을 분할함
- 내부적으로
- 락-프리 기법을 사용해서 컨텍스트 스위칭 오버헤드를 줄임
- 디스럽터 패턴이라고 불리는 스핀락을 사용해서 오버헤드를 없애서 처리량을 늘림
- 단점은 CPU 타임을 많이 사용하고 전력 소비 측면에서 나쁨
- Akka 프레임워크로 액터 기반 시스템 개발이 가능함
- 불변 메시지로 각 액터끼리 통신을 함
병렬 처리의 모순
- 병렬처리 공식은 T(n) = S + (1/N) * (T - S)
- 즉 프로세서를 무한히 증가시키더라도 ( 프로세서 개수는 N ) 총 소요 시간은 순차 작업 시간 이상으로 줄일 수는 없다.
- 결국 이는 암달의 법칙을 뒷받침하는 결과이다.
- 단순히 데이터를 공유 없이 여러 워커에게 분산시키면 빠른 성능을 얻을 수 있지만, 스레드끼리 상태나 데이터를 공유하기 시작하는 순간 워크로드가 복잡해져서 어쩔 수 없이 일부 태스크를 순차 처리하면서 통신 오버헤드가 발생한다.
- 대학생 때 위와 관련된 코드를 OS 수업 때 짠 적 있는데, 그거를 잘 짜서 시험을 못쳤음에도 불구하고 (?) 좋은 성적을 받았던 기억이 남.
JMM
- JMM에서 넘사벽이란 표현을 쓴게 너무 웃겼다 ㅋㅋㅋ 최고의 번역
- JMM은 자바 내의 메모리 모델
- 강한 메모리 모델 ( 모든 코어가 같은 값을 봄 )과 약한 메모리 모델 ( 코어마다 다른 값을 볼 수 있음, 시점을 제어하는 특별한 캐시 규칙 존재 )가 존재함.
- JMM은 약한 메모리 모델을 채택했음. 이유는 자바가 독립적인 환경으로 설계된 언어이기 때문.
- 자바의 synchronized는 여러 한계를 갖고 있으며 시간이 지날수록 그 증상이 심각해짐
- 최신 자바 버전에서 JMM이 얼마나 성장했는지 궁금... ( 따로 찾아보진 않았음 )
동시성 라이브러리
- Lock, Semaphore, atomics, blocking queue, Latch, Executor
- CAS 알고리즘
래치/배리어
- 스레드 세트의 실행을 제어하는 기법. ( 여러 스레드를 묶어서 관리하기 위한 기능 )
- 모든 스레드가 순차적으로 로직을 수행하는 경우, 래치를 쓰기 적합한 케이스이다.
- 래치 카운트를 설정해서 해당 카운트가 0에 이르면 래치가 열리면서 await()을 호출하여 스레드가 모두 해제되어 처리를 재개하는 방식이다.
- 래치는 기본적으로 재사용이 불가함.
포크/조인
- 자바 7에서 등장한 멀티 프로세스에서 사용할 수 있는 기능. 실무에서 써본적은 없는 것 같음.
- 포크/조인의 Work-stealing 알고리즘은 고루틴의 그것과 매우 흡사한 것으로 보임. ( 코틀린의 코루틴도 이렇게 동작함 )
최신 자바 동시성
- 병렬 스트림은 실제로 여러 문제를 안고 있어서, 실제 사용이 좀 어려움...
- 락-프리는 컨텍스트 스위칭을 낮춘 기법. 기계 연민이라는 표현 마음에 든다! 엔지니어가 가져야될 소양이 아닐까?
- 액터 모델은 리액티브 프로그래밍을 얘기하는 듯 한데, 저자도 말했지만, 불변 메시지 비동기 전송, 가변 상태 공유 금지, 각 메시지 처리가 제한된 시간 동안 실행과 같은 유스케이스를 찾는게 생각보다 어렵다; ( 당장 webclient를 Flux로 사용하는 것만 생각해봐도 사용할 수 있는 건덕지가 많이 안보임 )
느낀 점
- 개인적으로 서비스 개발을 하면서, 동시성에 대한 문제 중 가장 나이스한 방법은, 공유 자원 없이 해당 데이터들을 여러 개의 Task로 쪼개서 실행하는게 가장 나이스한 방법이었던 것 같음.
- 자원이 공유되는 로직이 되는 시점부터 복잡해져서 아무리 자바의 동시성 모델을 사용한다고 해도 쉽지 않았음.
정리
-
Java Concurren�cy in Practice 바로 주문했음
- JMM (Java Memory Model):
-
volatile
: 변수 값을CPU Cache
가 아닌Main Memory
를 참조하여 조회/수정 하도록 해줌-
CPU core마다 CPU Cache를 갖고 있는데, Multi �core 머신에서 Java의 Multi Thread는 여러 core에 스케줄링될 수 있고, 그러다보니 데이터 불일치가 발생할 확률이 있음
-
이런 가능성을 줄여주고자 CPU Cache가 아닌 Main Memory를 참조하도록 함
-
CPU Cache
가서버 애플리케이션단의� Local Cache
라면,Main Memory
는Global Cache
같은 느낌이랄까... -
그런데 증분 연산자가 'atomic'하지 않아,
volatile
을 쓴다고 해도 동시성을 완전히 해결할 순 없다 -
서버 애플리케이션단에서 해결하려면 �
Global Lock
을 사용함 ex)Redis + Redisson
orOptimistic Lock
orPessimistic Lock
-
synchronized
또는Lock
을 서버 애플리케이션의 Global Lock처럼 보면 될 듯
-
-
테스트는 버그가 있다는 사실을 밝히지만, 버그가 없다는 사실을 밝히지는 않는다 - 데이크스트라
명언이다 ㄷㄷ - java 5 이전까지는 synchronized가 유일했음
- java 5 이후에는 JSR133에서 JMM(Java Memory Model)이 배포되면서 개선됨
- JMM
- 보장
- 순서에 관한 보장
- 여러 스레드에 대한 업데이트 가시성 보장
- 두가지 방식
- 강한 메모리 모델: 전체 코어가 항상 같은 값을 바라본다
- 약한 메모리 모델: 코어마다 다른 값을 바라볼 수 있고, 그 시점을 제어하는 특별한 캐시 규칙이 있다
- 보장
- java.util.concurrent 패키지
- 종류
- Lock, Semaphore
- Atomics
- Blocking Queue
- Latch
- Executor
- 일부 라이브러리(Lock, Atomics)는 CAS(Compare And Swap) 기법을 구현해서 저수준 프로세서 명령어 및 OS별 특성 활용
- CAS: 업데이트할 때 기존값을 비교해보고, 일치하면 새로운 값으로 업데이트 (불일치하면 반복)
- Optimistic Lock + Spin Lock과 매우 유사함
- Atomics는 volatile의 확장판이라고 할 수 있지만 더 유연해서 상태 의존적 업데이트를 안전하게 수행 가능
- LockSupport
- Thread에게 Permit 발행
- Binary Semaphore, Mutex 개념
- VirtualThread에서 park할 때 LockSupport 사용함!
- ConcurrentHashMap: 세그먼트별로 자체 락킹 정책(락 세트)를 가짐
- 종류
-
p.386.
가장 흔히 사용하는 지표가 코어 수 대비 풀 스레드 수입니다
-
Netty의 경우 기본적으로
availableProcessors() * 2
-
ForkJoinPool은
availableProcessors() - 1
-
- Actor 기반 기법
- 딱 EDD랑 똑같다
궁금한 점
- p.375.
경합 중인 리소스가 극히 짧은 시간동안만 사용할 경우 이런 방식은 막대한 오버헤드를 유발할 수 있습니다. 블로킹된 스레드를 CPU에 활성 상태로 놔두고 아무 일도 시키지 않은 채 락을 손에 넣을 때까지 'CPU를 태워가며' 계속 재시도하게 만드는 편이 더 효율적이겠죠
-
Thread Busy 가 Context Switching보다 더 효율적인건가? (극히 짧은 시간인 경우에 한해서일 듯)
-
- park vs sleep vs block?
-
park은 Thread 양보하는 거고, block은 잡고있는건가? sleep은 block 하위개념일 것 같기도?
-
알아보려했는데 아직 못 알아봄
-
자바 스레드는 OS 수준의 프로세스와 동등하며
-> OS Thread와 JVM Thread가 1:1 매핑되어서? (Platform Thread)newSingleThreadExecutor() == newFixedThreadPool(1)
인건가?-
코드 보니까 똑같은데 thread 수만 다름 (
nThread -> 1
)
-
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}