DevSprout/optimizing-java

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
  • JMM(Java Memory Model)의 이해
  • 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
      • 하위 분할 테스크를 효율적으로 처리 가능
      • 작업 빼앗기 알고리즘

질문

  • 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 MemoryGlobal Cache 같은 느낌이랄까...

    • 그런데 증분 연산자가 'atomic'하지 않아, volatile을 쓴다고 해도 동시성을 완전히 해결할 순 없다

    • 서버 애플리케이션단에서 해결하려면 �Global Lock을 사용함 ex) Redis + Redisson or Optimistic Lock or Pessimistic 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));
}