Java Adv 21 - 스레드를 직접 사용할 때의 문제점

스레드 생성 비용으로 인한 성능 문제

  • 메모리 할당
    • 각 스레드는 자신만의 호출 스택(call stack)을 가지고 있다. 이 호출 스택은 스레드가 실행되는 동안 사용하는 메모리 공간이다.
    • 스레드를 생성할 땐 이 호출 스택을 위한 메모리를 할당해야 한다.
  • 운영체제 자원 사용
    • 스레드를 생성하는 작업은 운영체제 커널 수준에서 일어난다.
      • 스레드를 관리하고 실행순서를 조정해야 한다.
      • 참고로 스레드 하나는 보통 1MB 이상의 메모리를 사용한다.
  • 스레드를 생성하는 작업은 생각보다 무겁다. 단순히 자바 객체를 하나 생성하는 것과는 비교할 수 없을 정도로 큰 작업이다. 예를 들어서 어떤 작업 하나를 수행할 때 마다 스레드를 각각 생성하고 실행한다면, 스레드의 생성 비용 때문에 많은 시간이 소모 된다. 아주 가벼운 작업이라면, 작업의 실행 시간보다 스레드의 생성 시간이 더 오래 걸릴 수도 있다. 이러한 문제를 해결하기 위해서 이미 생성한 스레드를 재활용한다.

스레드 관리 문제

  • 서버의 CPU, 메모리 자원은 한정되어 있기 때문에, 스레드는 무한하게 만들 수 없다.
  • 예를 들어서 서버의 사용자의 주문을 처리하는 서비스라고 가정하자. 그리고 사용자는 갑자기 몰려들 수 있다. 평소에는 100개 정도의 스레드 정도면 충분했는데, 갑자기 10000개의 스레드가 필요한 상황이 된다면, CPU 메모리 자원이 버틸 수 없을 것이다.

Runnable 인터페이스의 불편함

public interface Runnable {
	void run();
}
  • 반환 값이 없다: run() 메서드는 반환값을 가지지 않는다. 따라서 실행 결과의 값을 얻으려면 별도의 메커니즘을 사용해야 한다. 쉽게 이야기해서 스레드의 실행 결과를 직접 받을 수 없다. 스레드가 실행한 결과를 멤버 변수에다가 넣고, join() 등을 사용해서 스레드가 종료되기를 기다린 다음에 멤버 변수의 보관한 값을 받아야 한다.
  • 예외 처리: run() 메서드는 체크 예외(checked exception)를 던질 수 없다. 체크 예외의 처리는 메서드 내부에서 처리해야 한다.
package thread.collection.simple.list;

public class SumTaskExample {
    private int result;

    public void calculateSum(int a, int b) throws InterruptedException {
        class SumTask implements Runnable {
            @Override
            public void run() {
                result = a + b;
            }
        }
        SumTask sumTask = new SumTask();
        Thread thread = new Thread( sumTask );
        thread.start();
        thread.join();
    }

    public int getResult() {
        return result;
    }

    public static void main(String[] args) throws InterruptedException {
        SumTaskExample sumTaskExample = new SumTaskExample();
        sumTaskExample.calculateSum( 5, 10 );
        System.out.println( "sumTaskExample = " + sumTaskExample.getResult() );
    }
}
// 보통은 이렇게 결과값을 리턴받는다.

Thread pool

  • 스레드를 관리하고 생성하는 스레드 풀이 필요하다.
  • 스레드를 관리하는 스레드 풀(스레드가 모여서 대기하는 수영장 풀 같은 개념)에 스레드를 필요한 만큼 미리 만들어 둔다.
  • 스레드는 스레드 풀에서 대기하며 쉰다.
  • 작업 요청이 온다.
  • 스레드 플에서 이미 만들어진 스레드를 꺼낸다.
  • 조회한 스레드1로 작업을 처리한다.
  • 스레드 1은 작업을 완료한다.
  • 작업을 완료한 스레드는 종료하는게 아니라, 다시 스레드 풀에 반납한다. 스레드 1은 이후로 다시 재사용이 될 수 있다.

Excutor

사실 스레드 풀이라는 것이 별것이 아니다. 그냥 컬렉션에 스레드를 보관하고 재사용할 수 있게 하면 된다. 하지만 스레드 풀에 있는 스레드는 처리할 작업이 없다면, 대기(WAITING ) 상태로 관리해야 하고, 작업 요청이 오면 RUNNABLE상태로 변경해야 한다. 막상 구현하려고 하면 생각보다 매우 복잡하다는 사실을 알게될 것이다. 여기에 생산자 소비자 문제까지 겹친다. 잘 생각해보면 어떤 생산자가 작업(task)를 만들 것이고, 우리의 스레드 풀에 있는 스레드가 소비자가 되는 것이다. 이런 문제를 한방에 해결해주는 것이 바로 자바가 제공하는 Executor 프레임워크다. Executor 프레임워크는 스레드 풀, 스레드 관리, Runnable 의 문제점은 물론이고, 생산자 소비자 문제까지 한방에 해결해주는 자바 멀티스레드 최고의 도구이다. 지금까지 우리가 배운 멀티스레드 기술의 총 집합이 여기에 들어있다. 참고로 앞서 설명한 이유와 같이 스레드를 사용할 때는 생각보다 고려해야 할 일이 많다. 그래서 실무에서는 스레드를 직접 하나하나 생성해서 사용하는 일이 드물다. 대신에 지금부터 설명할 Executor 프레임워크를 주로 사용하는데, 이 기술을 사용하면 매우 편리하게 멀티스레드 프로그래밍을 할 수 있다.

Reference

김영한님의 자바 강의

https://docs.oracle.com/javase/tutorial/essential/concurrency/sync.html



© 2022. by taewoo

Powered by taewoo