선생님, 개발을 잘하고 싶어요.

자바 병렬 프로그래밍 - 1부 5장 - 구성 단위 본문

일상/책 리뷰

자바 병렬 프로그래밍 - 1부 5장 - 구성 단위

알고싶은 승민 2022. 6. 14. 23:27
  • 자바 기본 라이브러리를 보면 병렬 프로그램 작성 시 필요한 여러 가지 동기화 도구가 마련되어있다.
    • 스레드 안전한 컬렉션 클래스
    • 동시 동작 스레드 간 작업 조율하는 동기화 클래스

동기화된 컬렉션 클래스

  • Vector, Hashtable, Collections.synchronizedXxx
  • 스레드 안정성 확보 방법 👉🏻 클래스 내부에 캡슐화 해 내부 값을 한 번에 한 스레드만 사용할 수 있도록 제어

동기화된 컬렉션 클래스의 문제점

  • 클라이언트 코드에서 두 개 이상의 연산을 묶어서 사용하는 경우
    • 반복, 컬렉션 내부의 모든 항목을 차례로 가져다 사용
    • 이동, 특정한 순서에 맞춰 지금 항목의 다음 항목 위치로 이동
    • 없는 경우에만 추가하는 기능
  • 개별 연산에 대한 스레드 안전은 지켜주지만, 그 연산을 조합한 복합 연산은 적절한 lock을 클라이언트 코드가 호출해야함. 스레드 안전 켈렉션의 상태는 정상적인데 클라이언트 코드는 깨질 염려가 있음
  • 스레드 안전 클래스가 제공하는 클라이언트 측 락을 사용해서 락을 걸어야함. (e.g. 컬렉션 클래스 자체)
  • 이는 반복문도 마찬가지라서 반복문 전체를 락으로 감싸야함. 문제는? 반복문 실행되는 동안 다른 스레드는 해당 컬렉션에 일체 접근할 수 없기 때문에 병렬 프로그래밍의 장점을 잃는 셈이다.

Iterator와 ConcurrentModificationException

  • 즉시 멈춤(fail-fast) 👉🏻 반복문을 실행하는 도중 컬렉션 클래스 내부의 값을 변경하는 상황 포착시 ConcurrentModificationException 예외 발생 및 멈추는 방법
  • 멀티스레드 관련 오류가 있다는 경고 정도에 해당한다.
  • 이를 미연에 방지하는 방법은 반복문 전체를 적절한 락으로 동기화 시키는 것 🤔
  • 반복문 내부에서 다른 작업을 수행할 때 또 다른 락이 필요하다면 deadlock이 발생할 가능성도 높다.
  • 이런 위험이 있는 상태에서 컬렉션 클래스를 오랜 시간 동안 락으로 맊는 건 전체 확장성을 해칠 수 있다.
  • 컬렉션을 clone 하고 해당 로컬 복제본으로 반복문을 수행하는 식으로 우회로를 떠올릴 수 있다.

숨겨진 Iterator

  • Collection.toString 과 같은 연산 내부에서 반복문(Iterator)를 내부적으로 사용하고 있다.
  • 디버깅 메시지 출력하려 했을 뿐인데 ConcurrentModificationException가 발생할 수도 있다는 뜻 🤔
  • 🌟 동기화 기법을 클래스 내부에 캡슐화해야 동기화 정책을 적용하기 쉽다.

병렬 컬렉션

  • 동기화된 컬렉션 클래스는 내부 변수에 접근하는 통로를 일련화해서 스레드 안전성을 확보 한 것이라서 동시 사용성은 상당히 손해본다.
  • 병렬 컬렉션 👉🏻 여러 스레드에서 동시에 사용할 수 있도록 설계했다.
    • ConcurrentHashMap 👉🏻 HashMap 대치 및 병렬성 확보
    • CopyOnWriteArrayList 👉🏻 추가된 객체 목록 반복 열람 연산의 성능을 최우선 구현한 List
  • ConcurrentMap 인터페이스에는 putIfAbsent, replace, conditionalRemove 연산 등 자주 쓰이는 복합 연산을 단일 연산으로 내부적으로 제공하고 있다.
  • 🌟 동기화 컬렉션을 병렬 컬렉션으로 교체만 해도 전체적인 성능을 상당히 끌어 올릴 수 있다.

ConcurrentHashMap

  • 동기화 컬렉션과 다른 동기화 기법을 채택하면서 병렬성과 확장성이 나아졌다.
    • 락 스트라이핑(lock striping, 11.4.3절)
  • 다음과 같은 특성이 있다.
    • 읽기 연산은 멀티 스레드 얼마든지 동시 처리
    • 읽기와 쓰기 동시 처리
    • 쓰기 연산 제한된 개수만큼 동시 처리
    • 단일 스레드 환경에서도 성능상 손실이 거의 없음
  • Iterator는 즉시 멈춤 대신 미약한 일관성 적략을 취한다.
  • 미약한 일관성 전략 👉🏻 반복문 중 컬렉션 내용 변경해도 Iterator 만든 시점 상황대로 반복 계속 가능, 변경 내용 반영해 동작할 수도 있다(보장되진 않음).
  • 병렬성을 위해 희생한 것
    • size, isEmpty 메소드 의미가 약해짐, 추정 값일 뿐임
    • get, put, containsKey, remove 등 핵심 연산 병렬성과 성능을 높이기 위한 선택
  • 맵을 독점적으로 사용하도록 막는 기능 제공 ❌

Map 기반의 또 다른 단일 연산

  • 독점적으로 사용할 수 있는 락이 없기 때문에 어라 개의 단일 연산을 모아 새로운 단일 연산을 만들고자 할 때 클라이언트 측 락 기법은 사용할 수 없다.
  • 대신 putIfAbsent, removeIfEqual, replaceIfEqual 연산 등 자주 필요한 몇 가지의 연산을 미리 구현 제공한다.

CopyOnWriteArrayList

  • 스레드 안정성 확보 방법 👉🏻 불벽 객체는 외부에 공개해도, 별다른 동기화 과정 없어도 스레드 안전, 컬렉션 내용 변경 마다 복사본 새로 생성해서 불변 객체 처럼 처리
  • List에 들어 있는 값을 Iterator로 접근할 때 List 전체에 락을 걸거나 복제할 필요 ❌
  • 변경할 때마다 복사라는 개념은 불변 객체를 외부에 공개하면 스레드 안전하다는 개념 도입했기 때문에 가능
  • 주의 사항
    • 데이터가 변경될 때마다 복사본을 만들기 때문에 성능 상 손해 가능
    • 컬렉션에 데이터가 많다면 재생성에 큰 손실
  • 🌟 변경 작업보다 반복문으로 읽어내는 일이 훨씬 빈번한 경우에 효과적

블로킹 큐와 프로듀서-컨슈머 패턴

  • 🌟 BlockingQueue 핵심 메소드
    • put 👉🏻 값을 추가할 공간이 있을 때까지 블락
    • take 👉🏻 뽑아낼 값이 있을 때까지 블락
    • offer 👉🏻 값을 추가할 때 공간이 없으면 오류 (대기하지 않음, 혹은 대기 시간 지정)
    • pool 👉🏻 뽑아낼 값이 없으면 오류 (대기하지 않음, 혹은 대기 시간 지정)
  • 프로듀서-컨슈머(producer-consumer) 패턴
    • 해야 할 일 목록을 가운데에 두고 작업을 만들어 내는 주체와 작업을 처리하는 주체를 분리하는 설계 방법
    • BlockingQueue를 사용하면 이 패턴의 해야 할 일 목록역할을 한다.
    • 값이 들어올 때까지 take 메소드가 알아서 멈추고 대기하기 때문에 consumer 코드 작성이 쉽다.
      • producer가 consumer가 감당하지 못할 만큼 일을 많이 만들지 않는 한, consumer는 다음 작업이 들어올 때까지 대기
    • 큐의 크기에 제한을 두면 큐에 빈 공간이 생길 때까지 put 메소드가 대기하기 때문에 producer 코드 작성이 쉽다.
    • offer를 사용하면 큐가 다 찼을 때 에러로 그 정보를 받아볼 수 있는데, 이를 통해서 producer가 작업을 많이 만들어 과부하에 이르는 상태를 좀더 효과적으로 처리할 수 있다.
      • 부하 분배, 작업 내용 직렬화 및 임시 디스크 저장, producer 스레드 갯수 동적 조절 … 등으로 producer 작업 생산 양을 조정할 수 있다.
  • 실제 구현체는
    • LinkedBlockingQueue, ArrayBlockingQueue: FIFO 형태의 큐
    • PriorityBlockingQueue: 우선 순위 기준 동작 큐
    • SynchronousQueue: 큐에 항목을 쌓지 않고 producer가 consumer에게 직접 작업 전달
      • 충분한 수의 consumer가 대기하고 있는 경우 사용하기 좋다.

직렬 스레드 한정

  • 객체의 소유권을 producer → consumer 로 넘기는 과정에서 직렬 스레드 한정(serial thread confinement) 기법 사용
    1. 스레드 한정 객체는 특정 스레드만 소유권을 가진다.
    2. 객체를 안전 공개시 객체에 대한 소유권을 이전(transfer) 가능
    3. consumer가 객체에 대한 유일한 소유권 획득
    4. producer는 소유권 완전히 잃는다.
    5. 새로운 소유자 스레드는 객체 상태를 완벽하게 볼 수 있다.
    6. 원래 소유권을 갖고 있던 스레드는 전혀 상태를 알수 없게 된다.
  • 객체 풀(object pool)은 직렬 스레드 한정 기법 잘 활용하는 예
    • 객체의 소유권을 빌려주고 (안전하게 공개)
    • 풀에 반납
  • 가변 객체의 소유권을 이전해야 한다면?

Deque, 작업 가로채기(work stealing)

  • BlockingDeque 인터페이스 존재 (기능은 우리가 아는 그 Deque + Blocking)
  • 작업 가로채기 패턴
    1. 모든 consumer가 각자의 덱을 가진다.
    2. 자신의 덱에 들어있는 작업을 다 처리하면, 다른 consumer 덱에 쌓인 작업중 맨 뒤에 추가된 작업을 가로챈다.
  • 규모가 큰 시스템 구현 적합
  • 다른 consumer 작업을 가져올 때도 맨 뒤에서 작업을 가져오니 원래 소유자와 경쟁이 일어나지 않는다.
  • 자신의 덱이 비었다고 쉬는 스레드가 없도록 관리한다.

블로킹 메소드, 인터럽터블 메소드

  • 스레드는 다양한 이유로 블록 당하거나, 멈춰질 수 있다. (block, interrupt)
  • 블로킹 연산 👉🏻 멈춘 상태에서 특정한 신호를 받아야 계속해서 실행할 수 있는 연산
    • InterruptedException을 발생시키는 메소드는 블로킹 메소드라는 의미
  • 인터럽트는 스레드가 서로 협력 실행하기 위한 방법
  • 다른 스레드에게 실행을 멈추라고 요청할 뿐 멈추도록 강제 가능 ❌
  • 일반적으로 특정 작업을 중간에 멈추게 하려는 경우에 사용

동기화 클래스

  • 동기화 클래스(synchronizer) 👉🏻 상태 정보를 활용, 스레드 간 작업 흐름 조절을 위한 클래스
    • 블로킹 큐, 세마포어, 배리어, 래치 …
  • 동기화 클래스 구조적 특징
    • 스레드가 언제 통과, 대기 하는지 결정하는 상태 정보 관리
    • 상태 변경 메소드 제공
    • 특정 상태에 진입할 때까지 효과적으로 대기할 수 있는 메소드 제공
  • 🌟 어떤 조건을 만족할 때까지 대기하고 싶으면? 동기화 클래스를 살펴보자.

래치 (latch)

  • 래치(latch) 👉🏻 terminal 상태에 이를 때까지 스레드가 멈추도록 해주는 동기화 클래스
  • 스레드의 관문으로 생각할 수 있다. 래치가 terminal 상태에 다다르면 관문이 열리고 모든 스레드가 통과한다.
  • 특정한 단일 동작 완료 전에 어떤 기능도 동작하지 않도록 막아야 하는 경우 유용
    • 특정 자원 확보 전에는 작업을 시작하지 말아야 하는 경우
    • 의존성 가진 다른 서비스가 시작하기 전에는 특정 서비스가 실행되지 않도록 막아야 하는 경우
    • 특정 작업에 필요한 모든 객체가 준비될 때까지 기다리는 경우

FutureTask

  • 반환 값을 가지는 Callback 인터페이스를 구현 해야함
  • 총 세가지 상태를 가짐 시작 전 대기, 시작됨, 종료됨
  • 실제로 연산을 실행한 스레드에서 만든 객체를 안전한 공개 방법을 통해서 실행시킨 스레드에게 넘겨준다.
  • 다음 상황에 유용
    • 비동기적인 작업을 실행하고자 할 때
    • 시간이 많이 필요한 모든 작업에 실제 결과가 필요한 시점 이전에 미리 작업해 두는 용도
  • 사용 방법 시점에
    • 테스크를 시작해야한다.
    • get 호출 시 로드 중이면 블락
    • 이미 결과를 받아온 상태면 바로 반환

세마포어 (semaphore)

  • 카운팅 세마포어는 특정 자원, 연산을 동시에 사용하거나 호출할 수 있는 스레드 수 제한시 사용
  • 풀이나 컬렉션 크기에 제한을 두고자 할 때 유용
  • 내부에 가상의 퍼밋(permit)을 만들어 상태 관리
  • 외부 스레드는
    • acquire 👉🏻 퍼밋을 요청해 확보 (남는 퍼밋이 생길 때 까지 대기)
    • release 👉🏻 퍼밋을 반납
  • 이진 세마포어는 비재진입 락 역할 하는 뮤텍스로 활용가능

배리어 (barrier)

  • 배리어(barrier) 👉🏻 다른 스레드를 기다리 위한 동기화 클래스
  • 특정 이벤트가 발생할 때까지 여러 스레드를 대기 상태로 잡아둘 수 있다.
  • 모든 스레드가 배리어 위치에 이르러야 관문이 열리고 계속 실행할 수 있다.
  • 동작은 다음처럼
    1. 스레드 각자가 배리어 포인트에 다다르면 await
    2. await 메소드는 모든 스레드가 배리어 포인트에 도달할 때까지 대기
  • 실제 작업 병렬 처리하고, 다음 단계로 넘어가기 위해선 이전 단계의 모든 계산이 끝나야 할 때, 이전 단계 내용을 취합할 때 유용하다.
Comments