Effective Java : 아이템 84. 프로그램의 동작을 스레드 스케쥴러에 기대지 말라

    아이템 84. 프로그램의 동작을 스레드 스케쥴러에 기대지 말라

    • 운영체제 쓰레드 스케쥴러에 의존하지 마라 
      • 자바 어플리케이션의 운영체제에 대한 이식성을 낮춘다. 
    • 쓰레드를 효율적으로 사용하는 방법은 실행 가능한 스레드가 프로세서보다 너무 많지 않도록 유지하는 것.
      • 실행 가능한 스레드를 가능한 적게 유지하는 것은 컨텍스트 스위칭 비용을 적게 가져가고, Busy Waiting 하는 쓰레드를 줄이라는 의미임. 
    • 쓰레드는 바쁜 대기(Busy Waiting)을 하면 안됨.
      • 아무 작업도 하지 않는데 CPU만 점유하는 꼴이 됨. 
    • 쓰레드의 상태는 다음으로 나눌 수 있음.
      • 대기 쓰레드 : Object.wait()로 대기중 / synchronized 모니터락을 얻기 위해 대기중인 쓰레드 / Thread.sleep()을 만나 대기중인 쓰레드.
      • 실행 가능한 쓰레드 : CPU를 얻어서 작업을 진행중임.
    • 참고
      • ExeuctorService는 BlockingQueue에 Task를 보관하고, 쓰레드는 BlockingQueue에서 take()를 호출해 작업을 가져감.
      • BlockingQueue에 Task가 없다면, take()를 호출한 쓰레드는 wait() 메서드가 호출됨. 
      • BlockginQueue에 Task가 들어오면, notify()를 통해 대기중인 쓰레드를 하나 깨워서 작업을 전달함. 

     


    운영체제 스레드 스케쥴러에 기대지마라.

    자바의 스레드는 커널 스레드와 1:1로 매칭된다. 따라서 자바의 스레드 스케쥴링은 운영체제의 스레드 스케쥴러가 처리해준다. 대부분은 스케쥴러가 잘 처리해주겠지만, 스케쥴러에 너무 기대서는 안된다. 왜냐하면 어플리케이션이 항상 같은 운영체제에서 동작하리라는 보장이 없기 때문이다. 

    운영체제마다 성능이 달라지는 어플리케이션이라면 이식성이 떨어진다. 따라서 자바 어플리케이션 그 자체로 스레드를 효율적으로 쓰는 것을 항상 고려해야 한다. 


    쓰레드를 효율적으로 사용하는 방법

    먼저 쓰레드의 상태를 크게 두 가지로 나누고, 정의하자. 쓰레드는 실행 가능한 쓰레드와 대기중인 쓰레드가 있다. 각각은 무엇을 의미할까? 

    대기 쓰레드 (Waiting or Blocked Thread)

    • WAITING : 쓰레드가 다른 쓰레드의 작업을 기다리고 있음. 예를 들면 Object.wait()를 호출함.
    • TIMED_WAITING : 쓰레드가 지정된 시간동안 작업을 기다리고 있음. 예를 들면 Thread.sleep() 
    • BLOCKED: 쓰레드가 동기화 블록 (synchronized)에 들어가려고 하지만, 다른 쓰레드가 블록을 점유해 기다림

     

    실행 가능한 쓰레드 (Runnable Thread)

    • Runnable : 쓰레드가 CPU를 할당받은 상태. 즉, 작업을 받아서 실행하고 있는 상태.

     

    쓰레드는 대기 쓰레드 / 실행 쓰레드가 존재한다는 것을 알았다. 쓰레드를 효율적으로 사용하기 위해서는 다음과 같이 작성해야 한다. 

    • 실행 가능한 평균 스레드의 개수가 프로세서 수보다 지나치게 많아지지 않도록 해야 함. 
      • 너무 많은 쓰레드가 생성되면, 많은 컨텍스트 스위칭 비용이 발생하기 때문임.
      • 너무 많은 쓰레드가 동작한다는 것은 Busy Waiting은 쓰레드가 존재함을 의미할 수도 있음.
    • 실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들어야 함. 

    여기서 구분해야하는 것은 전체 쓰레드는 실행가능한 쓰레드보다 훨씬 많을 수 있다는 점이다. 전체 쓰레드 = 실행가능한 쓰레드 + 대기 쓰레드로 이해를 하면 될 것이다. 대기 쓰레드는 '실행 가능하지 않다'는 점도 중요하다. 

     


    실행 가능한 스레드 수를 적게 유지하는 기법

    실행 가능한 스레드 수를 적게 유지하면, 컨텍스트 스위칭 비용이 적게 발생할 것이고 이 덕분에 운영체제 스케쥴러에게 의존하는 부분이 많이 줄어들 것이다. 이를 위해 가장 중요한 부분은 다음과 같다. 

    쓰레드는 작업을 완료한 후에는 다음 Task가 생길 때까지 대기하도록 하는 것이다. 

    이 말은 작업을 완료한 Runnable을 쓰레드는 다음 Task가 생길 때까지 CPU를 얻으면 안된다는 말이다. 만약, 아무 일도 하지 않는 쓰레드가 CPU를 얻는 경우라면 쓸데없는 컨텍스트 스위칭 비용이 발생하게 될 것이다. 이것을 방지하기 위해 각 쓰레드는 작업이 끝난 후, CPU를 얻지 않도록 대기해야한다. 

    ExeuctorService의 경우에는 작업을 짧게 가져가도록 하고, 쓰레드 풀의 갯수를 너무 크지 않도록 해야한다. ExecutorService는 BlockingQueue에 작업이 있는 경우에는 Task를 분배하고, 그렇지 않은 경우 쓰레드는 take()라는 메서드를 호출했을 때 내부적으로 wait()를 호출하도록 한다. 즉, ExecutorService에서 작업이 없는 서비스는 WAITING 상태가 되어서 '실행가능한 상태' → '대기 상태'로 바뀌게 되는 것이다. 만약 BlockingQueue에 Task가 들어오면, notify()를 호출해 대기중인 쓰레드를 깨워준다. 


    쓰레드는 바쁜 대기(Busy Waiting) 상태가 되면 안됨.

    바쁜 대기(Busy Waiting)은 쓰레드가 받은 작업이 없고, 다음 작업이 있는지 확인하기 위해 CPU를 얻어서 '확인하는 작업'을 무한히 반복하고 있는 것들을 의미한다. 아무런 일만 하지 않고 CPU만 간간히 얻어쓰는데, 컨텍스트 스위칭 비용 때문에 다른 쓰레드에게는 악영향을 미친다. 극단적인 바쁜 대기(Busy Waiting)은 아래 SlowCountDownLatch에서 확인할 수 있다.

    public class SlowCountDownLatch {
        private int count;
    
        public SlowCountDownLatch(int count) {
            if (count < 0) {
                throw new IllegalArgumentException(count + " < 0");
            }
    
            this.count = count;
        }
    
        public void await() {
            while (true) {
                synchronized (this) {
                    if (count == 0) {
                        return;
                    }
                }
            }
        }
    
        public synchronized void countDown() {
            if (count != 0) {
                count--;
            }
        }
    }

    위 코드에서 SlowCountDownLatch를 실행하는 쓰레드는 await() 메서드에서 count가 0이 될 때 까지 무한히 while 문을 돌며 '동작'할 것이다. 이것은 쓰레드가 실제로 하는 일은 없지만 While문을 돌면서 코드가 실행되고, 이것은 쓰레드가 하는 일 없이 CPU를 점유하는 것을 의미한다. 

    댓글

    Designed by JB FACTORY