[Android] Executors와 병렬 처리 이야기

Jay·2021년 2월 26일
2

Android

목록 보기
18/39
post-thumbnail

Intro

Android에서의 스레드는 크게 2가지로 나뉜다.

UI를 업데이트 할 수 있기에 UI 스레드라고 이름 붙여진 메인 스레드.
UI 업데이트 이외에 네트워크 작업 등 무거운 작업을 하게 되는 워크 스레드.

메인 스레드는 하나의 어플에서 단 하나만 존재하고 워크 스레드는 n개 이상 존재한다.

ANR 파트에서 다뤘지만 UI스레드에서 너무 많은 작업을 하는걸 안드로이드는 지양한다.
📍 그말은 즉, 워크 스레드를 적절히 이용해서 Main Thread에 무거운 작업을 시키지 않고 뷰에 업데이트를 잘 시키는게 또 하나의 숙제이다.

스레드를 잘 관리하는 게 그만큼 중요한데 이전에 동시성 문제라던가 여러 문제를 스레드로부터 지켜내기 위해 다뤘었다.

오늘은 스레드를 병렬 처리 하는 방법에 대해 이야기 하고자 한다.

Executors !?

  • 스레드를 직접적으로 다루는 가장 최상위 API이다.
  • Java에선 java.util.concurrent.Executors와 java.util.concurrent.ExecutorService를 제공하며 이를 이용하면 간단하게 스레드 풀을 생성하여 병렬 처리 할 수 있다.
  • Executors를 사용하면 개발자가 직접 스레드를 만들 필요가 없다는 것이다.
  • Executors가 Factory Method를 제공함으로 ExecutorService Instance를 얻는 것은 메서드 호출만 하면 된다.
  • Runnable처럼 Executors는 Callable을 제공한다.
  • Executors의 리턴값은 Future를 이용해서 받을 수 있다.

🖐 모두 java 라이브러리의 concurrent package에 생성되어 있다.
🔍 근데 그럼 concurrent를 뭐라고 생각하면 될까?
사전의미 그대로 라면 동시적으로, 동시성 을 의미한다.
concurrent는 parrallel과 보통 같이 이야기가 된다.
동시성과 병렬에 대한 이야기를 여기서 하면 너무 길어질 것이다.

아주 간단하게만 이야기 해보자.


Concurrent Programming

  • 동시성을 목표로 한다. 같은 종류의 작업이 가능한 많이 동시에 이뤄지는 것을 의미한다.
  • "개별 클라이언트에 대한 응답은 빠르지 않지만 동시 처리 가능한 클라이언트의 수가 많은 것"

Parallel Programming

  • 계산의 병렬성을 목표로 한다. 어떤 계산을 병렬적으로 수행하여 빠르게 끝낼 수 있는 것이다.
  • "동시 처리 가능한 요청 클라이언트 수가 많진 않지만 개별 클라이언트에 대한 응답은 빠른 것"

그래서 일반적으로 Concurrent Programming을 많이 한다.
CPU가 좋아지는 요즘 시대에는 Parallel Programming은 선택이 아니라 필수가 되어가고 있다.


Concurrent 단어를 보고 잠깐 병렬/동시성 프로그래밍을 이야기 했다.

다시 Executors를 보자.

"Java에선 java.util.concurrent.Executors와 java.util.concurrent.ExecutorService를 제공하며 이를 이용하면 간단하게 스레드 풀을 생성하여 병렬 처리할 수 있다."

"Executors 클래스에서의 여러 static 메서드를 사용하여 ExecutorService 인터페이스의 구현 객체를 만들 수 있는데, 이러한 객체가 스레드 풀이다."

📝 ExecutorService 인터페이스의 구현 객체 = 스레드 풀

위의 그림과 같이 Executor Service는 스레드 풀과 Queue로 구성되어 있다.
각각의 task들은 큐에 들어가게 되고 순차적으로 스레드에 할당된다.
스레드가 없다면 큐에서 대기하게 된다.
스레드를 생성하는 것은 비용이 큰 작업이기에 이를 최소화 하기 위해 미리 스레드 풀 안에 스레드를 생성해놓고 관리한다.

ExecutorService 객체 생성

  • newFixedThreadPool(int) : 인자 갯수 만큼 고정된 스레드 갯수를 스레드 풀안에 생성.
  • newCachedThreadPool() : 필요할 때, 필요한 만큼 스레드 풀 생성.
  • newSingleThreadExecutor() : 스레드가 1개인 ExecutorService 리턴. 싱글 스레드에서 동작해야 하는 작업 처리 시 사용 한다.

Sample Code

fun main() { 
	val executor = Executors.newFixedThreadPool(2) 
    	(0..9).forEach { _ -> 
	        executor.execute { 
          try { 
              TimeUnit.MILLISECONDS.sleep(1000) 
              val threadName = Thread.currentThread().name println("hello $threadName") 
          } catch (e: InterruptedException) { 
              e.printStackTrace() 
          } 
        } 
        } 
        executor.shutdown() 
     
     	if(executor.awaitTermination(10, TimeUnit.SECONDS)) { 
     	   	println("작업 종료") 
	} else { 
    		println("작업 종료 X") 
        	executor.shutdownNow() 
            } 
}
  • newFixedThreadPool(2)로 스레드가 2개인 스레드 풀을 생성한다.
  • 0~9까지 반복하는데 1초마다 thread name을 찍게 된다.
  • 위 과정을 하나의 스레드가 담당한다.
  • executor.execute로 실행하게 되는데 void이기에 반환 값이 없고 실행만 한다. 반환 값을 받으려면 executor.submit<반환타입>()으로 불러야 한다.
  • executor.shutdown()은 스레드 작업이 모두 끝나면 스레드 풀을 종료시킨다.
  • executor.awaitTermination() 메서드는 이미 수행 중인 task가 있다면 인자값만큼 기다리는 것이다. 10초를 기다리게 된다.
  • 10초를 기다려도 끝나지 않을 경우, executor.shutDownNow()가 호출되는데 이는 강제 종료와 같다. 실행중인 모든 task를 강제 종료시킨다.

Callable & Future

  • 위에서 Executors는 Callable과 Future를 사용한다고 했다.

우선, Callable
Runnable과 비슷하지만 리턴 타입을 갖는다는 특징이 있다.

Callable<Integer> task = () -> {
	try {
        TimeUnit.SECONDS.sleep(1);
        return 123;
    }
    catch (InterruptedException e) {
        throw new IllegalStateException("task interrupted", e);
    }
};
출처: https://emong.tistory.com/221

1초 이후에 123을 리턴하는 코드 이다.

그렇다면 Executor service에서 Callable은 어떻게 리턴 값을 받는가?
해답은 아래의 코드에 있다.

ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(task);

System.out.println("future done? " + future.isDone());

Integer result = future.get();

System.out.println("future done? " + future.isDone());
System.out.print("result: " + result); 

출처: https://emong.tistory.com/221

submit()을 쓰면 리턴 값을 받을 수 있다고 했다.
🖐 어 근데?? Future에 받네?? 그래 이제 Future가 등장한다.

Future는 executor service의 리턴값을 특정 시간 이후에 돌려 받을 수 있다. 리턴값 가져 오는 애라고 생각하면 된다.

근데 그럼 첫 번째 System.out.println()에서 true/false 중 뭘 출력할까??
정답은 false이다. 😛

future.get()를 써야 비로소 리턴 값을 기다리는 future가 되는 것이다.
리턴 받을 때까지 코드의 실행은 멈추게 된다.👐

그럼 아래 System.out.println을 통해 true와 123을 리턴한다.


그럼 결국 Executor는 왜 쓰는가?

  • task를 만드는 것과 실행 사이의 분리를 해주기에 기본제공 thread interface보다 자주 사용된다.
  • 디커플링, 확장성, 메모리 참조 감소 등의 장점이 있다.
  • 능동적으로 task 큐잉, 테스트 실행 순서 조절, 테스크 실행 유형(직렬, 동시) 등을 컨트롤 하기가 쉽다.

물론 Executor를 더 알아보기 위해선 task, thread pool 등을 더 자세하게 다뤄야 할 것이다.
이 한 페이지에 담기엔 너무 많아질 것 같아서 오늘은 간단하게 Executor를 알아본 것이다.

스레드 풀에 대해 좀 더 알고 싶다면 이 블로그를 보면 좋다.

Reference

profile
developer

0개의 댓글