스레드 풀


Thread pool은 task 큐와, 작업자 스레드 집합의 조합.


작업자 스레드는 생산자-소비자 구조를 형성한다.


생산자는 큐에 Task를 추가하고, 작업자 스레드는 새로운 백그라운드 실행을

수행할 준비가 된 유휴 스레드가 있을 때마다 Task를 소비한다.




작업자 스레드 풀은, 태스크를 실행하는 활성 스레드와 실행을 기다리는 유휴 스레드를 모두포함할 수 있다.



스레드 풀의 장점

- 작업자 스레드는 실행할 다음 태스크를 기다리기 위해서, 살아 있을 수 있음. 이는

스레드가 매 태스크를 위해 생성과 파괴(오버헤드 증가)될 필요 없다는 것을 의미한다.


- 스레드 풀은 스레드의 최대개수로 정의된다. 이는 응용프로그램 메모리를 소비하는

백그라운드 스레드 수가 많아져서 스레드 풀에 과부하가 걸리는것을 막기 위해서다.


-모든 작업자 스레드의 생명주기는 스레드 풀 생명주기에 의해 제어된다.



미리 정의된 스레드 풀

Executor 프레임워크는 Executors 팩토리 클래스에서 만들어진 미리 정의된 스레드 풀 유형을 포함한다.


- 고정 크기

고정크기 스레드 풀은, 사용자가 정의한 개수의 작업자 스레드를 유지한다.

종료된 스레드는 작업자 스레드의 일정 스레드 수를 유지하기 위해 새로운 스레드로 대체딘다.

고정된 풀 유형은 Executors.nexFixedThreadPool(n) 으로 생성되며 n은 스레드 수다.


이 스레드 풀 유형은 무한한 태스크 큐를 사용한다. 즉 새로운 태스크가 추가로 더해지면 큐가 자유롭게 증가할수있다.

그러므로 생산자는 태스크를 삽입하는데 실패하지 않는다.



- 동적 크기

동적 크기의 스레드 풀, 즉 캐시된 스레드 풀은 처리할 태스크가 있을 때 새로운

스레드를 만든다. 유휴 스레드는 실행할 새로운 태스크를 60초간 기다리고, 태스크 큐가 비어있는

경우 종료된다. 따라서 스레드 풀은 실행할 태스크 수와 함꼐 늘어나고 줄어든다.

Executors.newCachedThreadPool()로 생성한다.




- 싱글스레드 실행자

이 풀은 큐에서 task를 처리하기위해 하나의 작업자 스레드를 가진다.

태스크는 차례대로 실행되고 스레드 안전이 침해될수없다.

Executors.newSingleThreadExecutor()로 생성한다.

Executor 프레임워크를 사용하면 다음과 같은 일들을 처리할 수있습니다.



 - 스레드에서 실행되기를 기다리는 task 수를 제어하는 작업자 스레드 pool과 큐를 설정

- 비정상적으로 종료되는 스레드를 초래하는 에러 확인

- 완료되는 스레드를 기다리고 스레드로부터 결과를 가져온다.

- 스레드의 일괄처리를 실행하고, 고정된 순서로 결과를 가져온다.

- 사용자가 더 빨리 결과를 확인할 수 있도록 알맞은 시간에 백그라운드 스레드를 시작한다.




Excutor 프레임워크의 기본 구셩요소는 Executor 인터페이스다.


이것의 주요목표는 태스크의 생성을 분리하여(Runnnalbe) 앞에서 나열한 응용프로그램 동작들을 가능하게 하는 것이다.



이 인터페이스는 단하나의 메서드를 포함한다.

 public interface Executor{

void execute(Runnalbe command);
}




Executor 로 제어할 수 있는 동작으로는 다음과 같다.


- 태스크 큐잉

- 태스크 실행순서

- 태스크 실행 유형(직렬 또는 동시)




Executor 예제

AsyncTask에서 사용된 직렬 Task 실행자를 구현. SerialExecutor는 생산자-소비자 패턴으로 구현


Thread는 Runnable Task를 생성하고, 큐에 Task를 배치하며, 소비자 스레드는 큐에서 Task 를 제거하고 처리한다.



private static class SerialExecutor implemtents Executor{
final ArrayDeque mTasks = new ArrayDeque();
Runnable mActive;

public synchronized void execute(final Runnable r){

mTasks.offer(new Runnable(){
public void run{
try{
r.run();
}
finally{
scheduleNext();
}
}
});
if( mActive == null){
scheduleNext();
}
protected sysnchronized void scheduleNext(){
if((mActive=mTasks.poll())!= null){
THREAD_POOL_EXECUTOR.excute(mActive);
}
}

}
}





실행자 실행동작특징은 다음과 같다.


- 태스크 큐잉

ArrayDeque - 데큐, 양방향으로 꺼낼 수 있는 큐.

             스레드에 의해 처리될떄까지 삽입된 Task들을 보유한다.


- 태스크 실행순서

모든 태스크는 mTask.offer()를 통해 데큐의 끝에 넣어진다. 따라서

결과는 삽입된 태스크들 fifo 순서다.



- 태스크 실행 유형

Task는 직렬로 실행되지만, 같은 스레드에서 실행될 필요없다.

Task가 실행완료할떄 마다 r.run()이 완료할떄마다, scheduleNext()가 호출된다.

이것은 큐에서 다음 Task를 가져와서 스레드가 Task그를 실행할 수 있는 스레드 풀안의 다른 Executor로 보낸다.



요컨대 SerialExecutor는 각각 다른 스레드에서 Task를 처리할 수 있으며 Task들의 순차적인 실행을 보장하는 실행환경을 

구성한다.


<순차실행이지, 동시실행은 아님>





암시적 잠금 사용


자바에서 암시적 잠금은 synchronized를 사용하면된다. 하지만 사용하는 방식에따라 다른 방법으로 공유자원을 접근 읽기쓰기를 수행할 수 있습니다. synchronized 키워드는 다양한 암시적 잠금을 제공하게됩니다.


*1.) 객체 인스턴스를 둘러싸는 암시적 잠금으로 작동하는 메서드 레벨

 synchronized void writeVal(){

     sharedVal++;

}

객체의 인스턴스 메서드가 여러스레드에서 호출될때, 동기화문제를 해결합니다.


*2.)객체 인스턴스를 둘러싸는 암시적 잠금으로 작동하는 블록 레벨

  void writeVal(){

    synchronized(this){

       sharedVal++;

   }

}

1.)과 거의 비슷하나 강점이 있습니다.

위의 코드의 장점은, 임계영역에 포함된 코드의 정확한 블록을 제어할 수 있고, 실제로 보호될 상태와 관련된 코드만 줄여서 다룰 수 있게 됩니다. 일표이상으로 원자영역(atomic) 을 크게 만들지 않습니다. 


*3.)다른객체의 암시적 잠금을 가지는 블록 레벨

private final Object myLockKey= new Object(); 

 void writeVal(){

  synchronized(myLockKey){

     sharedVal++;

  }

}

클래스내에서 여러개의 잠금을 사용할 수 있게 됩니다. 


*4.)클래스 인스턴스를 둘러싸는 암시적 잠금으로 작동하는 메서드 레벨

 synchronized static void writeVal(){

     sharedVal++;

}


*5.)클래스 인스턴스를 둘러싸는 암시적 잠금으로 작동하는 블록레벨

 static void writeVal(){

   synchronized(MyClass.class){  //static 변수.

      sharedVal++;

   }

}





예제:소비자와 생산자


스레드끼리 공동으로 작업할때, 순서를 의도적으로 조율해야할 경우가 있습니다. 보통 이러한 패턴을 소비자 생성자 패턴이라 부릅니다.



다음클래스는, 생산부분과, 소비부분이 있습니다. 리스트에 변수를 추가하고 삭제를하는 시나리오를 생각해보겠습니다.

생산메서드는, 리스트의 변수를 늘리고, 소비리스트는 변수를 줄일겁니다. 생산메서드는 용량이 꽉차면 소비가 이뤄질때까지 소유권을 포기하고(열쇠를 포기) 대기해야되며, 소비메서드는 용량이 0이면 소비를멈추고 자바 모니터 소유권을 포기하고(열쇠를 포기하고) 대기해야할 것입니다.


public class ConAndPro{

  private LinkedList<String> mList = new LinkedList<Steing>();

  private Object lock = new Object();


 //생산

  public void produce(){

     while(true){

    synchronized(lock){

      while(mList.size() == 10){

          lock.wait();  //자바모니터에서 소유권을 포기하고 넘겨줍니다.(notify가올때까지 여기서 대기됨)

       }

      list.add("supply");

      lock .notify();

    }


     }

  


 //소비

  public void consume(){

    while(true){

    synchronized(lock){

      while(mList.size() == 0){

          lock.wait();  //자바모니터에서 소유권을 포기하고 넘겨줍니다.(notify가올때까지 여기서 대기됨)

       }

      list.removeFirst();

      lock .notify();

    }


     }

  


.


lock 에 대해 동기화가 되었습니다. lock.wait은, lock에 의해 동기화된부분에서 열쇠,자바모니터를 포기한다는 의미입니다. 소유권을 내어놓게됩니다.

lock.notify는 lock임계영역에서 열쇠를 포기해서 대기중인 스레드에게 다시 소유권을 주어 lock 임계영역에서 스레드 활동을 할수있도록 해줍니다.


물론 sychonized(lock) 이부분은 어떤 스레드가 동기화변수 "lock"을가지고있는지 확인하고 없으면 자신이 들어가고, 있으면 밖에서 대기를 하다가 synchronized안에 들어가게 됩니다. 


들어가게 되어도, 생산 소비할때 조건이 안된다면 wait을 만나서 잠시 lock을 내어놓고, 다른 lock의 임계영역에 들어간 스레드가 notify 해줄때까지 기다리게 됩니다. notify를 받으면 다시 lock임계영역에소 소유권을 가지게되어 다시 스레드가 synchronized(lock) 에서 멈췄던 부분에서부터 시작되게 됩니다.



아래 코드는 생산과 소비동작을 실행하는 두개의 Thread 예시입니다.


 final ConAndPro cp = new ConAndPro();


new Thread(new Runnable(){

 @Override

 public voice run(){

     cp.produce();

  

  }

}.start();


new Thread(new Runnable(){

 @Override

 public voice run(){

     cp.consume();

  

  }

}.start();







스레드 안전 Intro

여러 Thread에서 객체에 접근할때, 객체가 항상 정확한 상태를 유지해야 스레드안전이 보장됩니다. 동기화는 하나의 Thread에 의해 변경되는 도중에 다른 스레드의 접근이 가능한 모든 변수를 읽거나 쓰는 코드에 적용되어야 합니다. 이러한 코드 영역을 임계여역이라고 하며, 임계영역은 원자적으로 실행되어야합니다. 결론적으로 말하자면, 한번에 하나의 스레드만 접근을 허용하도록 실행되어야 합니다.



암시적 잠금과 JAVA Monitor

 synchronized 키워드는 모든 자바 객체에서 사용하는 암시적 잠금으로 동작하게 됩니다.  임계영역에서 스레드의 실행이 한스레드에 독점적임을 의미하게됩니다. 

 하나의 스레드가 임계영역을 점유하는 동안 다른 스레드의 접근은 차단되고, 잠금이 해제될때까지 실행할수 없게됩니다. 


Java monitor에는 3가지 상태가 있습니다.


차단된 스레드, 실행중인 스레드, 대기스레드가 있습니다.


JAVA Monitor 3가지 모델링


1. 차단된 스레드

다른스레드에 의해 해제될 모니터를 기다리는 동안, 일시 중단된 스레드입니다.


2. 실행 중 스레드

모니터를 소유하고 현재 임계영역에서 코드를 실행중인 스레드입니다.


3. 대기 스레드

임계영역의 끝에 도달하기 전에 자발적으로 모니터의 소유권을 포기한 스레드입니다. 이 스레드는 사기 소유권을 얻을 때까지 스레드의 신호를 기다립니다.


스레드에 접근(1) -> 차단된스레드(2) -> 실행중인스레드(3) -> 대기스레드(4)   -> 다시 실행중인 스레드(5), (상황에 따라서)

위의 그림에있는 상태설명.


1. 모니터에 진입

스레드가 암시적 잠금에 의해 보호된 영역에 접근을 시도합니다. 이 스레드는 모니터에 들어가게됩니다. 만약 다른 스레드가, 이미 잠금을 차지하고 있으면 스레드의 잠금 획득이 연기되게 됩니다.


2. 잠금 획득

모니터를 소유하고 있는 다른 스레드가 없는경우, 차단된 스레드는 소유권을 획득하고 임계영역에서 실행되게 됩니다. 


3. 잠금 해제 및 대기

스레드는, 계속 실행하기 전에 충족해야할 조건을 기다려야 하는 경우도있습니다. (생산자 소비자문제처럼, 다른 스레들간의 순서를 조율하거나, 조건등에따라 스레드 실행순서를 제어하고자할때).

그럴때는 Object.wait()를 통해서 스레드 자신의 실행을 일시 중단합니다.


Object.wait()을 하게되면 Object (주로, synchronized에 쓰이는 열쇠? 동기화 구분의 변수명가 되겠습니다.) 를 synchronized하며, 실행중인 스레드영역에있던 스레드는, 열쇠를 내어놓고, 대기 스레드에 들어가서 notify가 다른 스레드에서 호출되기전까지 대기하게 됩니다. 

다른 실행중인 스레드에서 notify가 호출되면 대기스레드에있던 스레드는 다시 열쇠를획득하고 실행중인 스레드(4.과정)으로 들어가게됩니다.



4. 신호 후 잠금 획득

대기 스레드가 Object.notify(), Object.notifyAll()을 통해, 다른스레드로부터(실행중이었던 스레드) 신호를 받고, 스케줄러에 의해 선택되면, 다시 모니터의 소유권을 가지게됩니다. 하지만, 대기스레드가, 모니터를 소유할 가능성은, 잠재적으로 차단된 스레드보다는 앞설수 없다고합니다.



5. 잠금 해제 및 모니터 종료

임계영역의 끝에서 스레드는 모니터를 종료하고, 다른 스레드가 모니터를 소유할 수 있도록 자리를 떠나게 됩니다.



위의 다섯가지 순서는 아래의 코드와도 같습니다.

synchronized(this) { //(1) 

   // 코드 실행 (2)

   wait(); (3)

   // 코드 실행 (4)

}(5)




다음 포스팅에서는, 암시적 잠금사용의 예제와 소비자와 생산자 코드를 살펴보도록 하겠습니다.


다음 포스팅으로 가기 <= 클릭





들어가기 앞서

안드로이드 앱은, 자바의 멀티스레드 프로그래밍 모델을 준수해야합니다. 자바가 편하긴하지만 동시프로그램 처리, 데이터 일관성 유지, Task 시퀀스 설정등 복잡성이 증가하는 단점이 있습니다. 이는 개발자가 고려하여 최대한 복잡성은 줄이고 성능을 높일 수 있도록 구현해야할 것입니다.



스레드 기본사항

보통 안드로이드에서는, 하나의 스레드에서 실행하는 작업의 단위를 TASK라고 지칭합니다. 

스레드는 순차적으로 하나 또는 다수의 태스크를 실행할 수 있습니다.



실행

자바에서는 run method 안에서 태스크를 정의함으로 구현할 수 있다.


private class MyThreadTask implements Runnable{

   public void run(){

int j = 0; // 스레드에서의 지역스택에 저장됨.

   }

}


run() 메서드 안에서 호출되는 지역변수는 스레드의 지역 메모리 스택에 저장됩니다. 태스크의 실행은 Thread 객체생성과 start() 메서드 호출로 

실핼할 수 있습니다.


Thread myThread = new Thread(new MyThreadTask ());

myThread.start();


스레드의 지역데이터를 저장하는 전용메모리가 있습니다. 


CPU는 한번에 하나의 스레드 명령어를 처리할 수 있습니다.


스레드의 변경을 문맥교환, Context Switch라고 합니다.

하나의 프로세서에 동시에 실행되는 두개의 스레드를 코드와 그림으로 보시겠습니다.

Thread T1 = new Thread(new MythreadTask());

T1.start();

Thread T2 = new Thread(new MythreadTask());

T2.start();



스레드 T1 ------                   ------------

                        ||||(문맥교환시간)

스레드 T2                  ------                  -----


      시간


T1과 T2사이에는 CPU가 사용하는 문맥교환시간이 존재해서 T1이 끝나고 바로 T2가 실행되는게 아니라 약간의 문맥교환 오버헤드가 드는것을 확인하실 수 있습니다.(위에 |||| ) 




싱글 스레드 애플리케이션

각 앱은, 하나이상의 스레드를 가집니다. 또한, 사용자의 반응성을 고려해서 멀티스레드 환경으로 구현해야하는 경우도 존재합니다.



멀티스레드 애플리케이션

동작이 동시에 실행되는것으로 인식되도록 앱 코드를 여러코드 경로로 분할하는 애플리케이션입니다. 실행 스레드 수가 프로세서의 수를 초과하면 완벽한 동시성이 될수는 없습니다. 

멀티스레드는 필수적이긴하나 그에따른 복잡성 증가는 개발자가 풀어야 할 과제입니다.



자원소비증가

각 스레드는 지역변수등을 저장하는 전용 메모리영역을 할당받습니다. 프로세서 측면에서는 스레드 설정 및 해제, 문맥 교환에서 스레드를 저장하고 복원시, 오버해드가 생기게됩니다. 스레드가 많아질수록, 더많은 문맥교환이 일어나고 성능이 저하되게 됩니다.



복잡성 증가

코드를 분석하기 어려워집니다. 또한 실행에 불확실성을 가져다 줍니다.



데이터 불일치

두개의 스레드가 공유자원을 사용할 경우, 어떤 순서로 데이터를 접근하는지 알수없게됩니다. 하나의 공유변수를 두개의 스레드가 접근할때 경쟁조건에 노출되게 됩니다. 

그래서 공유변수에접근할때 다른스레드가 접근할수없도록, 원자영역을 만드는것이 필수적입니다. 자바에서 가장 동기화 메커니즘은 synchronized 키워드를 사용하면 되겠습니다.


ex_)

synchronized(this){

  sharedVal++;

}


성능을 위해 구조화된 Application

 안드로이드 디바이스는 동시 여러 작업을 처리할 수 있는 멀티프로세서 시스템입니다. 하지만 응용애플리케이션의 성능을 최적화하기 위해 작업을 동시에 실행하도록 보장하는것은 각각의 애플리케이션에 달려있습니다.


만약 앱이 동시적으로 분할된 동작을 하지않고, 길게 하나의 작업을 실행하는 것을 선호한다면, 하나의 CPU만을 이용할 수 있고 이는 차선의 성능으로 이어지게 됩니다. 분할되지 않은 작업은 동기적으로 실행해야하지만, 분할된 작업은 비동기적으로 실행할 수 있습니다.


*동기적(동시에), 비동기적(순서대로)


 여러개의 독립적인 Task가 가능한 앱은 비동기 실행을 사용하도록 구성해야합니다.


 한가지 방법은, 응용프로그램 실행을 여러 프로세스에 분리시키는 방법입니다. 

여러개의 독립된 작업이 동시에 실행될 수 있기 때문입니다. 그러나 모든 프로세스는 자신의 기본적 자원을 위해 메모리를 할당하므로 여러프로세스에서 응용프로그램을 실행하는 것은 하나의 프로세스에서 응용프로그램을 실행하는 것보다 많은 메모리를 사용하게 됩니다.


 또한, 프로세스 간의 시작과 통신이 느리며, 비동기 실행을 하는 효과적인 방법이 아닙니다. 물론 여러 프로세스가 유효한 디잔일 수 있지만, 이 결정은 성능과는 독립적이어야 합니다. 

 

 높은 처리량과 더 나은 성능을 만들기 위해 어플리케이션은 각각의 프로세스내에서 여러 스레드를 사용해야 합니다.



스레드를 통한, 반응성 있는 앱 구현

 앱은 여러개의 CPU에서 높은 처리량을 가진 비동기 실행이 가능하지만, 반응성 있는 앱을 보증하지는 않습니다. 


*반응성: 상호작용하는 동안, 사용자가 앱을 인식하는 방식을 말합니다.


 UI가 버튼 클렉이 신속하게 반응 혹은, 부드러운 애니메이션등, 신속하게 반응하는것 등에 주안점을 둡니다. 기본적으로 사용자 경험의 관점에서 성능은 앱이 얼마나 빠르게 UI구성요소를 업데이트하는지로 결정됩니다.


 UI 컴포넌트를 update 하는 역할은 시스템이 UI구성요소 업데이트를 유일하게 허용하는 UI Thread가 존재합니다.

 

 반응성있는 앱을 위해서라면, 오래걸리는 태스크를 UI스레드에서 실행하면 안됩니다. 만약 UI스레드에서 그런 테스크를 실행하면, 해당 스레드의 다른 모든 실행이 지연되게 됩니다. 일반적으로 UI스레드에서 오래 걸리는 테스크를 실행하면, 생기는 첫번째 증상은 UI가 응답하지않고, ANR을 내게 되는것입니다.


 런타임은 네트워크 내려받기와 같이 시간이 오래 걸리는 특정작업을 UI스에드에서 실행하는 것을 금지합니다.


그래서 아래와 같이 긴 작업은 백그라운드 스레드에서 처리되어야 합니다. 오래 걸리는 태스크는 보통 다음아래와 같습니다.

- DB 생성/삭제/수정

- 이미지 처리

- File 읽기/쓰기

- Text Parsing

- 네트워크 통신

- SharedPreferences 읽기/쓰기



안드로이드 앱에서, 스레드는 구성요소중 어느 것만큼이나 핵심적입니다. 안드로이드 컴포넌트(구성요소)와 시스템 콜백은 다른 언급이 없는한, UI스레드에서 실행되며, 더 오래 걸리는 태스크는 Background의 Thread를 사용해야 합니다.



오래걸리는 Task?

사실 명확한 오래걸리는 테스크에대한 기준은 없습니다. 사용자가 UI를 느리다고 인식하다면, 테스크를 UI스레드에서 실행하기에는 너무 길다는 신호입니다. 인간이 부자연스럽다고 생각하는 부분은 백그라운드 테스크에서 처리하는게 바람직합니다.




마무리

 안드로이드 앱은, 리눅스 프로세스에 포함된 달빅 런타임 내의 리눅스 OS위에서 실행됩니다. 

안드로이드는 가장 낮은 우선순위의 앱을 종료시키시 위해, 실행중인 각 응용프로그램의 중요성을 따져 프로세스 순위 시스템을 적용합니다.


성능향상을 위해 앱은 코드를 동시실행 하기 위해 여러 Thread 작업을 나눠야 합니다. 

모든 리눅스 프로세스는 UI Update를 책임지는 특정한 스레드인 UI스레드를 포함합니다.


오래 걸리는 작업은 모두 UI Thread에서 분리하여 다른 스레드에서 실행해야 합니다.





+ Recent posts