Effective Java 2판 - 10장 정리 (병행성)

 

1. 들어가며

 

병렬 프로그래밍은 단일 스레드보다 어렵고 오류를 재현하기 어려워지기도 합니다. 하지만 암달의 법칙이 한계에 달하고 멀티 코어의 시대가 온 지금!! 멀티 스레드를 이용하는 것을 외면할 수는 없습니다. Effective Java에서 병행성에 대해 어떤 규칙을 세웠는지 살펴보도록 하겠습니다 :)

 

정리하면서 가장 재미있었던 파트라 부득이하게 내용이 많이 길어졌습니다.

이게 뭐가 요약본이야..!라고 하실수도 있겠지만..ㅜ 그만큼 설명할 내용이 많고 유익했습니다.

과거에 좀 더 잘 알았다면 더 좋게 해결할 수 있었겠다~를 통감한만큼 직접 해당 파트를 읽어보시길 강력 추천 드립니다!!!

 

2. 병행성

 

규칙 66: 변경 가능 공유 데이터에 대한 접근은 동기화하라.

synchronized 키워드는 특정 메서드나 코드 블록을 한 번에 한 스레드만 사용하도록 보장합니다. 많은 프로그래머는 동기화를 상호 배제적으로, 즉 다른 스레드가 변경 중인 객체의 상태를 관측할 수 없어야 한다는 관점으로 바라봅니다. 이 관점에 따라 객체는 일관된 상태를 갖도록 생성되며, 해당 객체를 접근하는 객체는 그 객체에 락을 겁니다.

 

하지만 동기화 없이는 한 스레드가 만든 변화는 다른 스레드가 확인할 수 없습니다. 동기화는 스레드가 일관성이 깨진 객체를 관측할 수 없도록 할 뿐 아니라, 동기화 메서드나 동기화 블록에 진입한 스레드가 동일한 락의 보호 아래 이루어진 모든 변경의 영향을 관측할 수 있도록 보장합니다.

 

상호배제성뿐 아니라 스레드 간의 안정적 통신을 위해서라도 동기화는 반드시 필요합니다.

 

Thread.stop은 절대로 이용하지 않아야 합니다. 이 메서드를 이용하면 데이터가 망가질 수 있습니다. false로 초기화되는 boolean 필드를 이용하는 것이 바람직하다고 합니다. 한 스레드는 이 필드의 값이 true로 바뀌는지 계속 검사해서 true로 바뀌면 실행을 스스로 중단하고, 해당 스레드를 중지시켜야 하는 다른 스레드는 피룡할 때 해당 필드의 값을 true로 바꿔주면 됩니다.

다만 boolean 필드는 원자적으로 읽고 쓸 수 있지만 해당 원자적 실행을 수행하는 스레드가 언제 실행되고 그걸 확인하는 스레드가 언제 수행될 지 알 수 없다는 점은 치명적인 문제를 만들어낼 수 있습니다. 또한 JVM hoisting(자체 최적화)로 예상치 못한 동작이 일어날 수 있습니다.

 

동기화는 읽기 연산과 쓰기 연산에 전부 적용해야 합니다.

아니면 volatile을 이용하여 어떤 스레드건 가장 최근에 기록된 값을 읽도록 보장할 수도 있습니다. 하지만 volatile도 주의해야 하는데, 증가연산자(++)처럼 원자적이지 않은 것을 사용할 때입니다.

 

결국 제일 좋은 해결책은 변경 가능 데이터는 한 스레드만 이용하도록 하라는 것입니다. 그리고 변경 가능한 데이터를 공유해야할 때는 해당 데이터를 읽거나 쓰는 모든 스레드는 동기화를 수행해야 합니다.

 

한마디: 동시성 문제는 코드를 짤 때마다 골치 아픈 일이라고 생각합니다.

첫 번째 문제

이 코드는 어떤 동기화 문제가 있을까요?

위 문제는 좀 간단한 편이었을지도 모릅니다.

두 번째 문제

이 코드는 어떤 동기화 문제가 있을까요?

다음 글도 재밌습니다! :)

http://15418.courses.cs.cmu.edu/spring2013/article/46

https://fileadmin.cs.lth.se/cs/education/EDA015F/2013/Herlihy4-5-presentation.pdf

 

규칙 67: 과도한 동기화는 피하라.

그렇다고 동기화를 과도하게 적용한다고 모든 것이 해결되는 것은 아닙니다. 성능 저하, 데드락, 비결정적 동작 등의 문제가 생길 수 있습니다.

모든 곳에 동기화를 적용하려는 것은 좋지 않습니다.

 

동기화 메서드나 블록 안에서 클라이언트에게 프로그램 제어 흐름을 넘기지 않는 것이 좋습니다. 다시 말해, 동기화가 적용된 영역 안에서는 재정의 가능 메서드나 클라이언트가 제공한 함수 객체 메서드(이하 불가해 메서드)를 호출하지 않는 것이 좋습니다. 동기화 영역이 존재하는 클래스에선 그런 메서드는 제어도 불가능하고 내부 내용도 알 수 없습니다. 따라서 문제가 발생할 확률이 높습니다.

 

자바가 제공하는 락은 reentrant lock으로 재진입 가능합니다. 락을 이미 들고 있는 상태여도 다시 락을 획득하려고 해도 성공합니다. 하지만 이로 인해 위의 불가해 메서드를 호출하는 일과 결합되면 락이 제구실을 못하게 될 수 있습니다. 락이 보호하는 데이터에 대해 개념적으로 관련성이 없는 작업이 진행될 수 있기 때문입니다.

 

동기화 영역 바깥에서 불가해 메서드를 호출하는 것은 열린 호출이라고 합니다. 오류를 방지할 뿐만 아니라 병행성도 높여줍니다. 중요한 것은, 동기화 영역 안에서 수행되는 작업의 양을 가능한 한 줄여야 한다는 것입니다.

 

멀티코어에서 동기화의 진짜 비용은 락을 거느라 소비되는 CPU 시간이 아닙니다. 병렬성을 활용할 기회를 잃는다는 것, 그리고 모든 코어가 동일한 메모리 뷰를 보도록 하기 위해 필요한 지연시간이 더 큰 비용입니다. 또한 동기화로 인해 VM이 코드를 제한적으로 최적화할 수 있게 됩니다.

 

또한 static 필드를 변경하는 메서드가 있을 때는 해당 필드에 대한 접근을 반드시 동기화해야 합니다. 보통 한 스레드만 이용하는 메서드라 해도 그렇습니다. 클라이언트 입장에서는 그런 메서드에 대한 접근을 외부적으로 동기화할 방법이 없습니다.

 

규칙 68: 스레드보다는 실행자와 태스크를 이용하라.

자바 릴리즈 1.5부터는 java.util.concurrent가 추가되었습니다. 이 패키지에는 실행자 프레임워크라는 것이 들어 있는데, 유연성이 높은 인터 페이스 기반 태스크 실행 프레임워크입니다.

 

작은 프로그램이거나 부하가 크지 않은 서버를 만들 때는 보통 Executors.newCachedThreadPool이 좋습니다. 설정이 필요 없고, 보통 많은 일을 잘 처리하기 때문입니다. 하지만 부하가 심한 곳에는 적합하지 않습니다. 캐시 기반 스레드 풀의 경우, 작업은 큐에 들어가는 것이 아니라 실행을 담당하는 스레드에 바로 넘겨지기 때문입니다. 서버 부하가 너무 심해서 모든 CPU full일 때 새 태스크가 들어오면 더 많은 스레드가 만들어지고 context switch가 너무 잦아질 것입니다. 이렇게 서버 부하가 심하다면 Executors.newFixedThreadPool을 이용해서 스레드 개수가 고정된 풀을 만들거나 ThreadPoolExecutor 클래스를 사용해서 제어하는 것이 좋습니다.

 

실행자 프레임워크에 대해 궁금하다면 Java Concurrency in Practice를 읽어보는 것이 좋습니다.

 

규칙 69: wait notify 대신 병행성 유틸리티를 이용하라.

릴리즈 1.5부터 자바 플랫폼에는 고수준 병행 유틸리티가 포함되어 이용하는 것이 좋습니다. 이 유틸리는 실행자 프레임워크 (규칙 68 참조), 병행 컬렉션, 동기자 세 가지 범주로 나뉩니다.

 

컬렉션들은 병행성을 높이기 위해 동기화를 내부적으로 처리합니다. 컬렉션 외부에서 병행성을 처리하는 것은 불가능합니다. 락을 걸어봐야 아무 효과가 없을 뿐 아니라 프로그램만 느려집니다.

또한 putIfAbsent와 같은 연산도 원자적으로 묶었기 때문에 더 편하게 구현할 수 있습니다.

또한 컬렉션 인터페이스 가운데 몇몇은 blocking operation이 가능하도록 확장되었습니다. 성공적으로 수행될 수 있을 때까지 대기할 수 있도록 확장된 것입니다. 예를 들어 BlockingQueue Queue를 확장해서 take 같은 연산을 지원합니다.

 

countdown latch는 일회성 배리어로서 하나 이상의 작업을 마칠 때까지 다른 여러 스레드가 대기할 수 있도록 합니다.

 

병행 스레드의 시간을 측정할 때는 System.currentTimeMillis 대신 System.nanoTime을 사용해야 합니다. 그래야 더 정밀할 뿐더러 시스템의 실시간 클락에도 영향을 받지 않습니다.

 

wait이나 notify 대신 병행성 유틸리티를 사용하는 것이 좋습니다. 하지만 wait이나 notify도 알아두면 좋을 것입니다.

wait 메서드는 스레드로 하여금 어떤 조건이 만족되길 기다리도록 하고 싶을 때 사용합니다. 동기화 영역 내에서 호출해야 하며 호출 대상 객체에는 락이 걸립니다.

아래의 표준적 숙어대로 사용하는 것이 좋습니다. 반드시! 순환문 밖에서 wait을 호출하지 않는 것이 좋습니다.

synchronized(obj) {
	while(<이 조건이 만족되지 않을 경우에 순환문 실행>)
		obj.wait();
	... // 조건이 만족되면 그에 맞는 작업 실행

 

또 관련된 이슈로 notify를 사용할 것인가 notifyAll을 사용할 것인가 하는 이슈도 있습니다. 보수적인 관점에서 깨어날 필요가 있는 모든 스레드를 깨우는 notifyAll이 정확합니다. 단 모든 스레드가 동일한 조건이 만족되길 기다리고 있고 그 결과로 실행을 재개할 스레드가 그들 가운데 하나 뿐이라면 notifyAll 대신 notify를 사용하여 최적화 할 수 있습니다.

다만 notify를 사용하면 누군가 악의적으로, 우연히 다른 스레드에서 wait을 호출하는 상황에 곤란할 수 있습니다. notify로 그 스레드가 깨어났다면 해당 락을 영원히 삼켜버릴 수 있습니다.

 

 

규칙 70: 스레드 안전성에 대해 문서로 남겨라.

클래스 사용자 사이의 규약 가운데 중요한 것 하나는, 클래스의 객체나 정적 메서드가 병렬적으로 이용되었을 때 어떻게 동작하느냐 하는 것입니다. 이런 것은 문서화하는 것이 필수적입니다.

 

synchronized 메서드 하나로는 충분치 않습니다. 병렬적으로 사용해도 안전한 클래스가 되려면 어떤 수준의 스레드 안전성을 제공하는지 문서에 명확하게 남겨야 합니다.

  • 변경 불가능: 외부적인 동기화 매커니즘 없이도 작동 가능
  • 무조건적 스레드 안전성: 이 클래스의 객체들은 변경 가능하지만 적절한 내부 동기화 매커니즘을 갖추고 있어서 외부적 동기화 매커니즘을 적용하지 않아도 병렬적으로 사용할 수 있다.
  • 조건부 스레드 안전성: 몇몇 스레드가 외부적 동기화 없이는 병렬적으로 사용할 수 없을 때
  • 스레드 안전성 없음: 변경 가능하고 외부적 동기화 수단으로 감싸야 사용 가능하다.
  • 다중 스레드에 적대적: 외부적 동기화 수단으로 감싸더라도 안전하지 않다. 이런 것은 보통 동기화 없이 static data를 바꾸기 때문에 일어난다.

 

조건부 스레드 안전성에 대한 문서 작성 시 신중해야 합니다. 어떤 락을 사용해야 하는지까지 알려주는 것이 좋습니다.

 

private final 락 객체를 사용하지 않으면 DOS 공격에 취약해집니다. 외부에서 락 객체를 접근하지 못하게 하여 동기화 매커니즘에 개입하지 못하게 하고, final로 선언하여 실수로 lock 필드를 변경하지 못하도록 합시다!

 

규칙 71: 초기화 지연은 신중하게 하라.

초기화 지연은 필드 초기화를 실제로 그 값이 쓰일 때까지 미루는 것입니다. spring에서도 자주 보인다고 생각합니다. 다만 이는 어설프게 사용하면 좋지 않습니다.

초기화 순환성 문제를 해소하기 위해서 초기화를 지연시키는 경우에는 동기화된 접근자를 사용하는 것이 좋습니다.

성능 문제 때문에 정적 필드 초기화를 지연시키고 싶을 때는 초기화 지연 담당 클래스 숙어를 적용하는 것이 좋습니다.

 

초기화 순환성: 예를 들어 A -> B -> C -> A 구조로 객체가 구성되어 있다면 A 객체를 하나만 만들어도 stackOverFlow가 발생한다. 이런 것을 초기화 순환성이라고 한다.
초기화 지연 사용 시 해당 객체가 사용될 때만 초기화를 진행하니 stack overflow가 발생하지 않을 것이다.

 

관용구 1: 인스턴스 필드를 선언할 때의 일반적인 초기화

class Example {
    // final 한정자를 통한 인스턴스 필드 생성
    private final FieldType field = computeFieldValue();
}

 

관용구 2: 인스턴스 필드의 지연 초기화 (synchronized 접근자를 통한 방식)

class Example {
    private final FieldType field;

    private synchronized FieldType getField() {
        if (field == null) {
            field = computeFieldValue();
        }
        return field;
    }
}

 

  • 두 관용구(보통의 초기화와 synchronized 접근자를 사용한 지연 초기화)는 정적 필드에도 똑같이 적용된다.
  • 이때 필드와 접근자 메서드 선언에 static 한정자를 추가해야 한다.

관용구 3: 정적 필드용 지연 초기화 홀더 클래스

  • 성능면에서 정적 필드의 지연 초기화가 필요한 경우 지연 초기화 홀더 클래스 관용구(lazy initialization holder class) 를 사용한다.

  • 클래스는 클래스가 처음 쓰일 때 비로소 초기화된다는 특성을 이용한 관용구

class Example {
    private static class FieldHolder {
        static final FieldType field = computeFieldValue();
    }

    private static FieldType getField() {
        return FieldHolder.field;
    }
}

 

관용구 4: 성능적인 측면에서의 인스턴스 필드 지연 초기화를 위한 이중검사(double-check)

 

  • 이 방법은 초기화된 필드에 접근할 때의 동기화 비용을 없애준다.아이템 79

  • 동작 방식

    • 필드의 값을 두 번 검사하는 방식

    • 한 번은 동기화 없이 검사

    • 두 번째는 동기화하여 검사

    • 두 번째 검사에서도 필드가 초기화되지 않았을 때만 필드를 초기화 한다.

  • 주의 사항

    • 필드가 초기화된 후로는 동기화하지 않으므로 해당 필드는 반드시 volatile 로 선언해야 한다.

class Example {
    private volatile FieldType field;

    private FieldType getField() {
        FieldType result = field; // 초기화 시 한 번만 읽도록 하기 위함
        if (result != null) {
            return result;
        }

        synchronized (this) {
            if (field == null) { // 두 번째 검사 (락 사용)
                field = computeFieldValue();
            }
            return field;
        }
    }
}

 

 

규칙 72: 스레드 스케줄러에 의존하지 마라.

스레드 스케줄러는 공평한 결정을 내리려 애쓰겠지만 그 정책은 바뀔 수 있고 의존해서는 안 됩니다. 정확성을 보장하거나 성능을 높이기 위해 스레드 스케줄러에 의존하는 프로그램은 이식성이 떨어집니다.

 

실행 가능 스레드의 수를 일정 수준으로 낮추는 기법의 핵심은 각 스레드가 필요한 일을 하고 나서 다음에 할 일을 기다리게 만드는 것입니다. 스레드는 필요한 일을 하고 있지 않을 때는 실행 중이어서는 안 됩니다. 즉 스레드 풀의 크기는 적절히, 태스크의 크기는 적당히 작게, 그리고 서로 독립적으로 만들어야 합니다. 태스크를 너무 작게 만들면 곤란합니다.

 

스레드는 busy-wait 해서는 안 됩니다. 즉 무언가 기다리면서 공유 객체를 계속 검사해대서는 안 됩니다.

 

Thread.yield를 호출해서 문제를 해결해서는 안 됩니다. 테스트 가능한 의미가 없습니다. JVM마다 동작이 다를 수 있습니다. 스레드 우선순위는 자바 플랫폼에서 가장 이식성이 낮은 부분 가운데 하나입니다.

 

규칙 73. 스레드 그룹은 피하라.

스레드 그룹은 이제 폐기된 추상화 단위입니다. 스레드 풀을 사용합시다.