스레드 직접 사용 시 문제점
- 실무에서 스레드를 직접 생성해서 사용할 때의 문제점
- 스레드 생성 비용으로 인한 성능 문제
- 각 스레드의 호출 스택을 가지고 있어야 하기에 메모리 할당 문제
- 스레드 생성 작업은 운영체제 커널 수준에서 이루어져 시스템 콜을 통해 처리되는데 이로 인해 CPU와 메모리 리소스를 소모
- 운영체제의 스케줄러가 스레드 관리, 실행 순서 조정을 위해 오버헤드 발생
- 해결 방법 : 스레드는 처음 생성할 때만 비용이 들기에 생성된 스레드를 재사용하는 방법
- 스레드 관리 문제
- CPU, 메모리 자원은 한정되어 있기에 스레드는 무한하게 만들 수 없다.
시스템이 버틸 수 있는 최대 스레드 수까지만 생성할 수 있게 관리해야 한다.
- CPU, 메모리 자원은 한정되어 있기에 스레드는 무한하게 만들 수 없다.
- Runnable 인터페이스의 불편함
- run() 메소드는 반환 값을 가지지 않기에 스레드의 실행 결과를 직접 받을 수 없다.
- run() 메소드는 체크 예외를 던질 수 없고 내부에서 처리해야 한다.
- 스레드 생성 비용으로 인한 성능 문제
- 문제점 1, 2번을 해결하기 위한 스레드 풀이 존재한다.
스레드 풀 (Thread Pool)
- 스레드 풀 : 스레드를 관리하는 용도로 스레드를 미리 필요한만큼 만들어두고 필요할 때마다 스레드 풀에서 대기중인 스레드를 가져다 사용한 후 다시 스레드 풀에 반납한다.
- 스레드 풀 내에 있는 스레드 중 작업이 없는 스레드는 WAITING 상태로 관리하고 작업 요청이 오면 RUNNABLE 상태로 변경한다.
- 스레드 풀의 장점
- 스레드를 재사용할 수 있기에 재사용 시 스레드 생성 시간을 절약할 수 있다.
- 스레드 풀에서 스레드가 관리되기 때문에 필요한 만큼의 스레드만 만들고 관리할 수 있다.
- 스레드 풀을 자바에서 제공하는 것이 Executor 프레임워크이다.
Executor 프레임 워크
- Executor 프레임워크: java.util.concurrent 패키지에 포함되어 있으며 스레드 풀, 스레드 관리, Runnable 문제점 해결, 생산자 소비자 문제를 해결해주는 기술의 총 집합이다.
- 멀티스레딩 및 병렬 처리를 쉽게 사용할 수 있게 돕는 기능의 모음
- 실무에서 스레드를 직접 생성해서 사용하기보다는 Executor 프레임워크를 사용한다.
- 주로 Excutor 인터페이스를 확장한 ExcutorService를 사용하며 구현체로는 ThreadPoolExecutor가 있다.
- 생산자 - execute(작업)을 호출하면 내부에서 BlockingQueue에 작업을 보관한다.
- 소비자 - 스레드 풀에 있는 스레드가 소비자이며 BlockingQueue에 들어있는 작업을 받아서 처리한다.
- ThreadPoolExecutor를 생성한 시점에 스레드 풀에 스레드를 처음부터 생성하지 않고 최초의 작업이 들어올 때 스레드가 생성된다.
- ThreadPoolExecutor 생성자의 속성
- corePoolSize : 스레드 풀에서 관리되는 기본 스레드 수
- maximumPoolSize : 스레드 풀에서 관리되는 최대 스레드 수
- keepAliveTime : 기본 스레드 수를 초과해서 만들어진 스레드가 생존할 수 있는 대기 시간
- 이 시간동안 처리할 작업이 없으면 초과 스레드는 제거된다.
- BlockingQueue workQueue : 작업을 보관할 블로킹
- 스레드 풀에서 관리되는 최대 스레드 수만큼 스레드를 생성하고 이후는 생성되어 있는 스레드를 재사용한다.
- ThreadPoolExecutor의 스레드 풀 관리
- 초기에는 스레드 풀에 스레드가 생성되지 않는다.
- 최초로 작업이 들어오면 스레드가 생성된다.
- corePoolSize (스레드 풀의 기본 스레드 수)보다 작업이 많아지면 블로킹 큐 (버퍼)에 작업을 보관한다.
- 스레드 풀의 corePoolSize와 블로킹 큐의 크기보다 작업의 수가 더 많아지면 maximumPoolSize (스레드 풀의 최대 스레드 수)만큼 스레드를 더 생성해서 작업을 처리한다.
- 큐가 가득차면 요청이 많다는 뜻으로 긴급 상황 -> maximumPoolSize까지 초과 스레드를 만들어서 작업을 수행한다.
- 블로킹 큐도 가득차고 스레드 풀의 maximumPoolSize보다 작업의 수가 많아진다면 작업 요청을 거절하는 예외를 발생시킨다.
- 이후 초과 스레드(기본 스레드 수를 초과해서 만들어진 스레드)가 처리할 작업이 없으면 지정된 시간만큼 대기하다가 제거된다.
- 초과 스레드가 작업을 처리할 때마다 지정된 시간은 계속 초기화가 된다.
- 초과 스레드는 최대 maximumPoolSize - corePoolSize 만큼 생성될 수 있다.
- ThreadPoolExecutor.prestartAllCoreThreads() : 작업을 요청 받기 전 기본 스레드 수만큼 스레드 풀에 스레드를 미리 생성할 수 있다.
Callable / Future
- Callable 인터페이스 : java.util.concurrent에서 제공되는 기능으로 Runnable과 달리 call() 메소드가 있으며 반환 타입은 제네릭으로 값을 반환할 수 있다.
- throws Exception 예외가 선언되어 있기에 해당 인터페이스를 구현하는 모든 메소드는 체크 예외를 던질 수 있다.
//1번 방식
ExecutorService es1 = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
//2번 방식
ExecutorService es2 = Executors.newFixedThreadPool(1);
- Executors.newFixedThreadPool(size) : 스레드의 사이즈만 지정함으로 ExecutorService 객체를 쉽게 생성할 수 있다.
static class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
int value = new Random().nextInt(10);
return value;
}
}
- Callable을 구현할 때 call() 메소드로 반환할 제네릭 타입을 명시해준다.
- Runnable 코드와 대부분 유사하지만 차이점으로는 결과를 필드에 담아두는 것이 아닌 Callable은 결과를 반환한다는 점이다.
- Runnable과 같이 결과를 보관할 별도의 필드를 만들지 않아도 된다.
//인터페이스의 submit() 정의
<T> Future<T> submit (Callable<T> task);
//Callable을 작업으로 전달
Future<Integer> future = es.submit(new MyCallable());
//Runnable을 작업으로 전달
es.execute(new MyRunnable());
- ExecutorService가 제공하는 submit()을 통해 Callable을 작업으로 전달할 수 있다.
- ExecuteService의 execute()로 Runnable을 작업으로 전달.
- Callable의 작업 처리 결과는 직접 반환되는 것이 아닌 Future라는 특별한 인터페이스를 통해 반환된다.
- Future.get()을 통해 Callable의 작업 결과를 반환 받을 수 있다.
- 요청 스레드가 결과를 받아야하는 상황이라면 Callable을 사용하는 방식이 Runnable 보다 훨씬 편리하다.
- 별도로 스레드를 생성하거나 join()으로 스레드를 제어하거나 하지 않아도 되기 때문.
- Thread에 관련된 코드가 전혀 없다.
- Future : 미래의 결과를 받을 수 있는 객체로 전달한 작업의 미래 결과를 담고 있다.
- Callable.call()은 호출 스레드가 직접 실행하는 것이 아닌 스레드 풀의 다른 스레드가 실행하기 때문에 언제 실행되는지, 실행이 언제 완료되서 결과를 반환할지 알 수 없다.
- 그렇기에 submit() 메소드는 결과를 반환하는 대신에 결과를 나중에 받을 수 있는 Future 객체를 반환한다.
- submit()을 통해 Future 객체가 생성되면 Callable 작업의 참조 값, 작업 완료 여부, 작업 결과가 Future 객체 안에 담겨진다.
- 이런 내용을 포함한 Future 객체는 블로킹 큐에 담긴다.
- submit()을 통해 작업을 전달할 때 생성된 Future 객체는 즉시 반환된다.
- Thread.start()와 같이 요청 스레드는 대기하지 않고 본인의 다음 코드를 호출할 수 있게된다.
- Future의 구현체인 FutureTask는 Runnable과 Callable을 함께 구현하고 있다.
- 스레드가 FutureTask의 run() 메소드를 수행하고 run() 메소드가 Callable.call()을 수행하게 된다.
- Future.get()을 통해 작업의 미래 결과를 받을 수 있다.
- Future.get()을 호출했을 때
- Future가 완료 상태라면, 요청 스레드는 대기하지 않고 값을 즉시 반환받을 수 있다.
- Future가 완료 상태가 아니라면, 작업이 아직 수행되지 않았거나 수행 중인 상태이므로 요청 스레드는 결과를 받기 위해서 Future가 완료 상태가 될 때까지 대기한다.
- 블로킹(Blocking) : 스레드가 어떤 결과를 얻기 위해서 대기하는 것.
- 블로킹 메소드: Thread.join(), Future.get()과 같은 메소드로 스레드가 작업을 바로 수행하지 않고 다른 작업이 완료될 때까지 기다리게 하는 메소드이다.
- 지정된 작업이 완료될 때까지 블로킹 메소드를 호출한 스레드는 블록(대기)되어 다른 작업을 수행할 수 없다.
- Future.get()을 호출한 스레드는 대기 상태이며 대기 상태인 스레드는 다른 스레드에 의해서 깨어나야 한다.
- Callable 작업을 완료한 스레드는 Future에 반환 결과를 담고, 상태를 완료로 변경하고, Future.get()을 요청한 스레드를 깨운다.
- Future.get()에서 블로킹이 걸려서 대기하기 때문에 그 전까지 모든 코드는 실행될 수 있고 결과만 기다릴 수 있다.
- 만약 Future가 없어서 submit을 호출한 시점부터 결과 값을 반환 받을 때까지 기다린다면 여러 작업을 수행하지 못하고 한 개의 작업을 수행하고 기다리는 것을 반복하게 되서 단일 스레드와 같이 비효율적으로 될 것이다.
//Future를 잘 사용한 예시
Future<Integer> future1 = es.submit(task1);
Future<Integer> future2 = es.submit(task2);
Integer sum1 = future1.get();
Integer sum2 = future2.get();
//Future를 잘못 사용한 예시 1
Future<Integer> future1 = es.submit(task1);
Integer sum1 = future1.get();
Future<Integer> future2 = es.submit(task2);
Integer sum2 = future2.get();
//Future를 잘못 사용한 예시 2
Integer sum3 = es.submit(task3).get();
- 잘 사용한 예시를 보면 future1,2 모두 작업을 요청한 다음 결과를 받는다.
- 잘 사용하지 못한 예시를 보면 future1의 작업을 요청하고 블로킹 상태로 값을 반환 받기까지 기다리고 future2의 작업을 요청하고 블로킹 상태로 값을 반환받기를 기다린다.
- 만약 한 개의 작업이 2초가 소요된다면 잘 사용한 예시에서는 2초의 시간이 소요된다.
- 잘못 사용한 예시에서는 4초의 시간이 소요된다.
- Future를 용도에 맞게 제대로 사용하려면 submit()으로 모든 작업을 먼저 다 요청하고 마지막에 get()으로 결과를 받을 수 있도록 해야 한다.
- Future 인터페이스 주요 기능
- boolean cancel(boolean) : 아직 완료되지 않은 작업을 취소한다.
- true -> Future를 취소 상태로 변경하고 실행 중이라면 interrupt()를 호출해서 작업을 중단한다.
- false -> Future를 취소 상태로 변경하고 실행 중인 경우 작업은 중단하지 않는다.
- boolean isCancelled() : 작업이 취소되었는지 여부를 확인한다.
- boolean isDone() : 작업이 완료되었는지 여부를 확인한다.
- State state() : Future의 상태를 반환한다.
- RUNNING - 작업 실행 중
- SUCCESS - 성공 완료
- FAILED - 실패 완료
- CANCELLED - 취소 완료
- V get() : 작업이 완료될 때까지 스레드를 대기(블록킹)하고 완료되면 결과를 반환한다.
- InterruptedException - 대기 중에 현 스레드가 인터럽트 되는 경우 발생하는 예외
- ExecutionException - 작업 계산 중에 예외가 발생하는 경우 발생하는 예외
- 매개 변수로 시간을 지정해서 지정된 시간 만큼 대기를 시키고 초과되면 예외를 발생시킬 수 있다.
- boolean cancel(boolean) : 아직 완료되지 않은 작업을 취소한다.
- Future에는 작업 중 발생한 예외도 담아둔다.
- Future.get()을 통해 결과 값을 받을 수도, 예외를 받을 수도 있다.
- ExecutorService의 작업 컬렉션 처리
- invokeAll() : 모든 Callable 작업을 제출하고 모든 작업이 완료될 때까지 기다린다.
- invokeAny() : 모든 Callable 작업을 제출하고 하나의 작업이 완료될 때까지 기다리며 가장 먼저 완료된 작업의 결과만 반환하고 완료되지 않은 나머지는 취소한다.
ExecutorService 우아한 종료
- 우아한 종료 : graceful shutdown이라고도 불리며 문제 없이 종료하는 방식을 의미한다.
- 예를 들어 서버를 재시작해야한다면 새로운 주문은 막고 기존의 처리해야 할 작업은 모두 완료한 후 재시작 하는 것이 가장 좋을 것이다.
이처럼 문제 없이 우아하게 종료하는 방식을 우아한 종료라고 한다.
- 예를 들어 서버를 재시작해야한다면 새로운 주문은 막고 기존의 처리해야 할 작업은 모두 완료한 후 재시작 하는 것이 가장 좋을 것이다.
- ExecutorService의 서비스 종료와 관련된 기능
- void shutdown() : 새로운 작업을 받지 않고, 이미 제출된 작업을 모두 완료한 후에 종료하며 논 블로킹 메소드(이 메소드를 호출한 스레드는 대기하지 않고 즉시 다음 코드를 호출하는 메소드)이다.
- List<Runnable> shutdownNow() : 실행 중인 작업을 인터럽트를 발생시켜 중단하고 대기 중인 작업을 반환하며 즉시 종료하며 논 블로킹 메소드이다.
- boolean isShutdown() : 서비스가 종료되었는지 확인한다.
- boolean isTerminated() : shutdown(), shutdownNow() 호출 후, 모든 작업이 완료되었는지 확인한다.
- boolean awaitTermination() : 서비스 종료 시 모든 작업이 완료될 때까지 지정된 시간만큼 대기하며 블로킹 메소드이다.
- close() : Java 19부터 제공하며 shutdown()과 유사하며 shutdown()을 호출 후 하루를 기다려도 작업이 완료되지 않으면 shutdownNow()를 호출한다.
- 서비스를 종료해야할 때 기본적으로 우아한 종료를 선택하고 되지 않으면 그 다음 강제 종료를 하는 방식으로 접근하는 것이 좋다.
Executor 전략
// 1. 단일 스레드 풀 전략
ExecutorService es = Executors.newSingleThreadExecutor();
// 위 코드와 동일
ExecutorService es = new ThreadPoolExecutor(1, 1, 0L,
TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()));
// 2. 고정 스레드 풀 전략
ExecutorService es = Executors.newFixedThreadPool(nThreads);
// 위 코드와 동일
ExecutorService es = new ThreadPoolExecutor(nThreads, nThreads,0L,
TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
// 3. 캐시 스레드 풀 전략
ExecutorService es = Executors.newCachedThreadPool();
// 위 코드와 동일
ExecutorService es = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L,
TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
- Executors 클래스를 통해 제공하는 3가지 전략
- newSingleThreadPool() : 단일 스레드 풀 전략
- 스레드 풀에 기본 스레드 1개만 사용하며 큐 사이즈에 제한이 없는 LinkedBlockingQueue를 사용한다.
- 간단히 사용하거나 테스트 용도로 사용한다.
- newFixedThreadPool(nThreads): 고정 스레드 풀 전략
- 스레드 풀에 지정한 개수만큼 기본 스레드를 생성하고 초과 스레드는 생성하지 않으며 큐 사이즈에 제한이 없다.
- 스레드 수가 고정되어 있기에 CPU, 메모리 리소스가 예측 가능한 안정적인 방식으로 작업을 많이 담아두어도 문제가 되지 않는다.
- 서버 자원은 여유가 있는데 사용자만 점점 느려지는 문제가 발생한다.
- newCachedThreadPool() : 캐시 스레드 풀 전략
- 기본 스레드를 사용하지 않고 60초 생존 주기를 가진 초과 스레드만 사용하며 초과 스레드 수는 제한이 없다.
- 큐에 작업을 저장하지 않고(SynchronousQueue) 생산자의 요청 (작업 요청)을 스레드 풀의 소비자(스레드)가 직접 바로 처리한다.
- 모든 요청이 대기하지 않기에 매우 빠른 처리가 가능하고 작업 요청 수에 따라 스레드 수가 증가하고 감소하므로 유연한 전략이다.
- 서버의 자원을 최대한 사용하지만 서버가 감당할 수 있는 임계점을 넘는 순간 시스템이 다운될 수 있다.
- newSingleThreadPool() : 단일 스레드 풀 전략
- SynchronousQueue : 아주 특별한 블로킹 큐로 내부 저장 공간이 없기에 생산자의 작업을 소비자 스레드에게 직접 전달한다.
- 생산자와 소비자를 동기화하는 큐이다.
- 중간에 버퍼를 두지 않고 스레드 간 직거래하는 방식과 같다.
ExecutorService es = new ThreadPoolExecutor(100, 200, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000));
- Executor 전략 - 사용자 정의 풀 전략
- 평소에는 안정적으로 운영하다가 사용자 요청이 급증하면 긴급하게 스레드를 더 투입해서 처리하는 방법이다.
- 시스템이 감당할 수 없는 정도로 요청이 폭증하면 처리 가능한 수준의 요청만 처리하고 나머지 요청은 거절한다.
- 위 예제와 같이 평소에는 100개의 스레드로 안정적으로 운영하다가 요청 수가 1100개 초과로 급증하면 초과 스레드를 100개까지 더 투입할 수 있고 큐에는 무제한으로 요청을 담는 것이 아닌 1000개의 요청만 큐에 담을 수 있으며 1201개 이상의 요청은 거절하도록 적용할 수 있다.
- 이때 큐 사이즈를 무제한으로 두면 큐가 가득차지 않기 때문에 초과 스레드를 생성할 수 없기에 주의해야 한다.
Executor 예외 정책
- ThreadPoolExecutor에서 제공하는 작업을 거절하는 다양한 정책으로 shutdown() 이후에 요청하는 작업을 거절할 때도 마찬가지로 같은 정책을 사용한다.
- AbortPolicy : 새로운 작업을 제출할 때 예외를 발생시킨다. (기본 정책)
- DiscardPolicy : 새로운 작업을 조용히 버린다.
- CallerRunsPolicy : 새로운 작업을 제출한 스레드(생산자)가 대신해서 직접 작업을 실행한다.
- 생산자 스레드가 직접 일을 수행하는 덕분에 작업의 생산이 느려지므로 생산 속도를 느리게 조절할 수 있다.
- 사용자 정의 : 개발자가 직접 정의한 거절 정책을 사용한다.
- ThreadPoolExecutor 생성자 마지막에 거절 정책을 인자로 넣어줄 수 있다.
출처 : [인프런 김영한 실전 자바 - 고급편]
김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런
김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?
www.inflearn.com
'Java > [인프런 김영한 실전 자바 - 고급편]' 카테고리의 다른 글
[인프런 김영한 실전 자바 - 고급편] 동시성 컬렉션 (1) | 2024.08.07 |
---|---|
[인프런 김영한 실전 자바 - 고급편] CAS - 동기화와 원자적 연산 (0) | 2024.08.06 |
[인프런 김영한 실전 자바 - 고급편] 생산자 소비자 문제 1, 2 (0) | 2024.08.06 |
[인프런 김영한 실전 자바 - 고급편] concurrent.Lock (1) | 2024.08.02 |
[인프런 김영한 실전 자바 - 고급편] 동기화 - synchronized (0) | 2024.08.02 |