본문 바로가기

Book/안드로이드 프로그래밍 Next Step(일부 공개)

[안드로이드 프로그래밍 Next Step] Chapter 3. Background Thread

Chapter 3. Background Thread

Background Thread를 활용하면 앱의 성능을 향상하는데 많은 도움이 된다.

HandlerThread 클래스

HandlerThread(Handler는 가지고 있지 않음)는 Thread를 상속하고, 내부에서 Looper.prepare()와 Looper.loop()를 실행하는 Loop Thread이다.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
package android.os;

import android.annotation.NonNull;
import android.annotation.Nullable;

/**
 * Handy class for starting a new thread that has a looper. The looper can then be 
 * used to create handler classes. Note that start() must still be called.
 */
public class HandlerThread extends Thread {
    int mPriority;
    int mTid = -1;
    Looper mLooper;
    private @Nullable Handler mHandler;

    public HandlerThread(String name) {
        super(name);
        mPriority = Process.THREAD_PRIORITY_DEFAULT;
    }
    
    /**
     * Constructs a HandlerThread.
     * @param name
     * @param priority The priority to run the thread at. The value supplied must be from 
     * {@link android.os.Process} and not from java.lang.Thread.
     */
    public HandlerThread(String name, int priority) {
        super(name);
        mPriority = priority;
    }
    
    /**
     * Call back method that can be explicitly overridden if needed to execute some
     * setup before Looper loops.
     */
    protected void onLooperPrepared() {
    }

    @Override
    public void run() {
        mTid = Process.myTid();
        Looper.prepare();
        synchronized (this) {
            mLooper = Looper.myLooper();
            notifyAll();
        }
        Process.setThreadPriority(mPriority);
        onLooperPrepared();
        Looper.loop();
        mTid = -1;
    }
    
    /**
     * This method returns the Looper associated with this thread. If this thread not been started
     * or for any reason isAlive() returns false, this method will return null. If this thread
     * has been started, this method will block until the looper has been initialized.  
     * @return The looper.
     */
    public Looper getLooper() {
        if (!isAlive()) {
            return null;
        }
        
        // If the thread has been started, wait until the looper has been created.
        synchronized (this) {
            while (isAlive() && mLooper == null) {
                try {
                    wait();
                } catch (InterruptedException e) {
                }
            }
        }
        return mLooper;
    }

    /**
     * @return a shared {@link Handler} associated with this thread
     * @hide
     */
    @NonNull
    public Handler getThreadHandler() {
        if (mHandler == null) {
            mHandler = new Handler(getLooper());
        }
        return mHandler;
    }

    /**
     * Quits the handler thread's looper.
     * <p>
     * Causes the handler thread's looper to terminate without processing any
     * more messages in the message queue.
     * </p><p>
     * Any attempt to post messages to the queue after the looper is asked to quit will fail.
     * For example, the {@link Handler#sendMessage(Message)} method will return false.
     * </p><p class="note">
     * Using this method may be unsafe because some messages may not be delivered
     * before the looper terminates.  Consider using {@link #quitSafely} instead to ensure
     * that all pending work is completed in an orderly manner.
     * </p>
     *
     * @return True if the looper looper has been asked to quit or false if the
     * thread had not yet started running.
     *
     * @see #quitSafely
     */
    public boolean quit() {
        Looper looper = getLooper();
        if (looper != null) {
            looper.quit();
            return true;
        }
        return false;
    }

    /**
     * Quits the handler thread's looper safely.
     * <p>
     * Causes the handler thread's looper to terminate as soon as all remaining messages
     * in the message queue that are already due to be delivered have been handled.
     * Pending delayed messages with due times in the future will not be delivered.
     * </p><p>
     * Any attempt to post messages to the queue after the looper is asked to quit will fail.
     * For example, the {@link Handler#sendMessage(Message)} method will return false.
     * </p><p>
     * If the thread has not been started or has finished (that is if
     * {@link #getLooper} returns null), then false is returned.
     * Otherwise the looper is asked to quit and true is returned.
     * </p>
     *
     * @return True if the looper looper has been asked to quit or false if the
     * thread had not yet started running.
     */
    public boolean quitSafely() {
        Looper looper = getLooper();
        if (looper != null) {
            looper.quitSafely();
            return true;
        }
        return false;
    }

    /**
     * Returns the identifier of this thread. See Process.myTid().
     */
    public int getThreadId() {
        return mTid;
    }
}

개인적인 의문 : 위 코드를 보면 Handler가 있는데 왜 접근이 안될까 찾아 보았는데, compile된 HandlerThread.class에는 Handler가 미포함되어 컴파일이 되어있었다. 그래서 접근이 안됨

HandlerThread.class
12345678910111213141516171819202122232425262728293031323334353637
package android.os;

public class HandlerThread extends Thread {
    public HandlerThread(String name) {
        throw new RuntimeException("Stub!");
    }

    public HandlerThread(String name, int priority) {
        throw new RuntimeException("Stub!");
    }

    protected void onLooperPrepared() {
        throw new RuntimeException("Stub!");
    }

    public void run() {
        throw new RuntimeException("Stub!");
    }

    public Looper getLooper() {
        throw new RuntimeException("Stub!");
    }

    public boolean quit() {
        throw new RuntimeException("Stub!");
    }

    public boolean quitSafely() {
        throw new RuntimeException("Stub!");
    }

    public int getThreadId() {
        throw new RuntimeException("Stub!");
    }
}

Handler를 Looper에 연결하는 방식

  1. Handler를 Thread 안에 두고 사용하는 방식

123456789101112131415161718
class LooperThread extends Thread {
    public Handler mHandler;
    
    @Override
    public void run() {
        Looper.prepare();
        mHandler = new Handler(new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                // message 처리
                return false;
            }
        });
        Looper.loop();
    }
}

  1. Thread에서 Looper를 시작하고 Thread 외부에서 Handler를 생성하는 방식

123456789101112131415161718
private HandlerThread mHandlerThread;

public Processor() {
    mHandlerThread = new HandlerThread("Message Thread");
    mHandlerThread.start();
}

public void process() {
    new Handler(mHandlerThread.getLooper()).post(new Runnable() {
        
        @Override
        public void run() {
            ...
        }
    })
}

HandlerThread는 2번 방식으로 미리 만든것이다. HandlerThread는 내부적으로 prepare(), loop()를 실행하는 것 외에 별다른 내용은 없다.

HandlerThread 프레임워크 소스

HandlerThread 내부 코드에서 run() 메서드에서 Looper.prepare()와 Looper.loop()외의 추가적인 작업이 있다. 멤버 변수인 mLooper에 Looper.myLooper()를 대입하는 것이다. 그리고 quit() 메서드나 quitSafely() 메서드에서는 mLooper를 바로 쓰지 않고 getLooper() 메서드를 거친다. 왜냐하면 getLooper()에서 바로 return mLooper와 같이 한 줄로 끝나지 않는다. HandlerThread를 사용할 때는 start()를 getLooper()를 호출하기 전에 반드시 호출해줘야 한다. 따라서 getLooper() 메서드에 isAlive()과 mLooper의 null 여부(null 여부 체크는 run() 메서드가 실행되는 시점을 정확히 알 수 없기 때문에 mLooper가 초기화가 안되어 있을 경우를 위함이다) 조건으로 체크하고 완료될 때까지 wait() 메서드로 대기한다. mLooper 대입 이후에 notifyAll()을 싱행해서 대기하는 Thread를 깨운다. getLooper()는 public 메서드로 위의 2번 예제같이 외부에서도 사용된다.


Question

그렇다면 HandlerThread가 필요하는 곳은 어디일까?

Answer

바로 UI와 관련없지만 단일 스레드에서 순차적인 작업이 필요할 때이다.
(AsyncTask도 허니콤 이후부터 default로 SERIAL_EXECUTOR를 사용해서 순차적인 Thread 작업을 지원한다)

안드로이드 프레임워크에서는 IntentService가 HandlerThread를 내부적으로 사용한다.


example

항목의 오른 편에 즐겨찾기(favorite) 표시 CheckBox가 있고 선택과 해제 여부를 실시간으로 DB에 반영하는 요구사항이 있다.
UI를 blocking하지 않도록 별도 Thread에서 DB에 반영하기로 한다.


Question

만일 체크 상태가 바뀔 때마다 Thread를 생성하거나 Thread가 Thread를 가져다가 DB에 반영하면 어떤 일이 벌어질까?

result

Thread가 start()를 실행한 순서대로 실행되지 않기 때문에 선택 → 해제 → 선택을 했지만, DB에 반영할 때는 선택 → 선택 → 해제 순으로 반영하여 최종 결과가 잘못될 가능성이 있다.


문제의 코드
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
public class ListActivity extends AppCompatActivity {
    private static final String TAG = ListActivity.class.getName();
    private RecyclerView mRecyclerView;
    
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_list);
        mRecyclerView = findViewById(R.id.recycler_view);
        mRecyclerView.setAdapter(new MessageAdapter());
    }

    private void changeFavoriteStatus(MessageFavorite data) {
        new FavoriteThread().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, data);
    }

    private static class FavoriteThread extends AsyncTask<MessageFavorite, Void, MessageFavorite> {

        @Override
        protected MessageFavorite doInBackground(MessageFavorite... messageFavorites) {
            int position = messageFavorites[0].position;
            long time = messageFavorites[0].getFavoriteTime();
            Log.i(TAG, position + ", time : " + time);
            SystemClock.sleep(time);
            return messageFavorites[0];
        }

        @Override
        protected void onPostExecute(MessageFavorite data) {
            int position = data.position;
            boolean isLike = data.isLike;
            if (isLike) {
                Log.i(TAG, position + " : like ★");
            } else {
                Log.i(TAG, position + " : unlike ☆");
            }
        }
    }

    private class MessageAdapter extends RecyclerView.Adapter<MessageAdapter.ViewHolder> {

        @NonNull
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.contents_list_item, parent, false));
        }

        @Override
        public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
            holder.bind(position);
        }

        @Override
        public int getItemCount() {
            return 20;
        }

        class ViewHolder extends RecyclerView.ViewHolder {
            private TextView mTitle;
            private CheckBox mFavorite;

            ViewHolder(View itemView) {
                super(itemView);
                mTitle = itemView.findViewById(R.id.contents_title);
                mFavorite = itemView.findViewById(R.id.favorite);
                mFavorite.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
                    @Override
                    public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                        MessageFavorite data = new MessageFavorite(getLayoutPosition(), isChecked);
                        changeFavoriteStatus(data);
                    }
                });
            }

            void bind(int position) {
                mTitle.setText(String.format("[#%s] %s", position, itemView.getContext().getString(R.string.app_name)));
            }
        }
    }

    // data class
    class MessageFavorite {
        int position;
        boolean isLike;

        MessageFavorite(int position, boolean isLike) {
            this.position = position;
            this.isLike = isLike;
        }

        long getFavoriteTime() {
            return System.currentTimeMillis() % (10 * 1000);
        }
    }
}


result


solution

실행 순서를 순차적으로 맞춰야 한다. 이 때 HandlerThread를 사용한다. HandlerThread를 사용하지 않고 비슷한 동작을 하려면 Background Thread에서 무한 반복문을 만들고, BlockingQueue를 매개로 하여 반복문 내에서 가져오기를 실행하며, Thread 외부에서 넣기를 실행하면 된다. 그러나 HandlerThread를 사용하면 구조보다 Message에 더 집중할 수 있다.

example code

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
public class ListActivity extends AppCompatActivity {
    private static final String TAG = ListActivity.class.getName();
    private RecyclerView mRecyclerView;
    private Handler mFavoriteHandler;
    private HandlerThread mHandlerThread;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_list);
        mHandlerThread = new HandlerThread("Favorite Processing Thread");
        mHandlerThread.start();
        mFavoriteHandler = new Handler(mHandlerThread.getLooper(), new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                MessageFavorite data = (MessageFavorite) msg.obj;
                changeFavoriteStatus(data);
                return false;
            }
        });
        mRecyclerView = findViewById(R.id.recycler_view);
        mRecyclerView.setAdapter(new MessageAdapter());
    }

    private void changeFavoriteStatus(MessageFavorite data) {
        new FavoriteThread().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, data);
    }

    @Override
    protected void onDestroy() {
        mHandlerThread.quit();
        super.onDestroy();
    }

    private static class FavoriteThread extends AsyncTask<MessageFavorite, Void, MessageFavorite> {

        @Override
        protected MessageFavorite doInBackground(MessageFavorite... messageFavorites) {
            int position = messageFavorites[0].position;
            long time = messageFavorites[0].getFavoriteTime();
            Log.i(TAG, position + ", time : " + time);
            SystemClock.sleep(time);
            return messageFavorites[0];
        }

        @Override
        protected void onPostExecute(MessageFavorite data) {
            int position = data.position;
            boolean isLike = data.isLike;
            if (isLike) {
                Log.i(TAG, position + " : like ★");
            } else {
                Log.i(TAG, position + " : unlike ☆");
            }
        }
    }

    private class MessageAdapter extends RecyclerView.Adapter<MessageAdapter.ViewHolder> {

        @NonNull
        @Override
        public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.contents_list_item, parent, false));
        }

        @Override
        public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
            holder.bind(position);
        }

        @Override
        public int getItemCount() {
            return 20;
        }

        class ViewHolder extends RecyclerView.ViewHolder {
            private TextView mTitle;
            private CheckBox mFavorite;

            ViewHolder(View itemView) {
                super(itemView);
                mTitle = itemView.findViewById(R.id.contents_title);
                mFavorite = itemView.findViewById(R.id.favorite);
                mFavorite.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
                    @Override
                    public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                        MessageFavorite data = new MessageFavorite(getLayoutPosition(), isChecked);

                        Message message = mFavoriteHandler.obtainMessage();
                        message.obj = data;
                        mFavoriteHandler.sendMessage(message);
                    }
                });
            }

            void bind(int position) {
                mTitle.setText(String.format("[#%s] %s", position, itemView.getContext().getString(R.string.app_name)));
            }
        }
    }

    // data class
    class MessageFavorite {
        int position;
        boolean isLike;

        MessageFavorite(int position, boolean isLike) {
            this.position = position;
            this.isLike = isLike;
        }

        long getFavoriteTime() {
            return System.currentTimeMillis() % (10 * 1000);
        }
    }
}


result


Thread Pool 사용

Thread를 만들려면 Thread를 상속하거나 Thread(Runnable) 생성자에 Runnable을 넘기는 방법이 있지만, Thread Pool을 사용하는 방법도 있다.
Thread Pool은 대기 상태의 Thread를 유지해서 Thread 종료/생성 오버헤드를 줄임으로써, 많은 개수의 비동기 작업을 실행할 때 퍼포먼스를 향상시킨다. 게다가 Thread Pool은 Thread를 포함한 리소스를 제한하고 관리하는 방법도 제공한다.


ThreadPoolExecutor 클래스

Java에서는 Thread Pool이 ThreadPoolExecutor 클래스로 구현되어 있다. AsyncTask도 내부적으로 TreadPoolExecutor를 사용하고 있다.

12345678910111213
public abstract class AsyncTask<Params, Progress, Result> {
    ...
        
    static {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
                sPoolWorkQueue, sThreadFactory);
        ...
    }
    ...
}

ThreadPoolExecutor에는 4개의 생성자가 있다. 그 중에서 ThreadFactory 파라미터를 뺀 세 번째 생성자를 위주로 살펴본다.

123456
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
                  long keepAliveTime, TimeUnit unit,
                  BlockingQueue<Runnable> workQueue,
                  RejectedExecutionHandler handler)

  • corePoolSize : Pool에서 Thread의 기본 개수
  • maximumPoolSize : Pool에서 Thread의 최대 개수

혼동할 수 있는 부분이 있는데, ThreadPoolExecutor 생성자에서 corePoolSize만큼 미리 생성하는가 하면 그렇지 않다. 기본적으로 execute()나 submit()을 호출하는 순간에 작업 중인 Thread 개수가 corePoolSize보다 적으면 Thread를 새로 추가하는 형태이다. 미리 생성하려면 prestartCoreThread() 메서드를 호출하면 된다.

  • keepAliveTime & unit : 태스크가 종료될 때 바로 제거하지 않고 대기하는 시간, 보통 unit에는 TimeUnit.SECONDS나 TimeUnit.MINUTES를 사용
  • workQueue : Thread를 corePoolSize 개수만큼 유지하려고 하고 추가로 요청이 들어오면 workQueue에 쌓음

workQueue 파라미터

workQueue에 쓸 수 있는 것은 3가지이다.

ArrayBlockingQueue

Queue 개수에 제한이 있으며, 요청이 들어오면 일단 Queue에 쌓는다. Queue가 꽉차서 더 넣을 수 없는 경우에는 maximumPoolSize가 될 때까지 Thread를 하나씩 추가해서 사용한다.

LinkedBlockingQueue

일반적으로 Queue 개수에 제한이 없다. 들어오는 요청마다 계속해서 쌓는데 이 경우에 maximumPoolSize 값은 의미가 없다. LinkedBlockingQueue도 LinkedBlockingQueue(int capacity) 생성자를 사용해서 Queue 개수를 제한할 수는 있다.

SynchronousQueue

요청을 Queue에 쌓지 않고 준비된 Thread로 바로 처리한다. 결국 Queue를 쓰지 않는다는 의미이다. 모든 Thread가 작업 중이라면 maximumPoolSize까지만 Thread를 생성해서 처리한다.


handler 파라미터

ThreadPoolExecutor가 정지(shutdown)되거나, maximumPoolSize + workQueue 개수를 초과할 때는 태스크가 거부된다. 이때 거부되는 방식을 정하는 것이 ThreadPoolExecutor 생성자의 마지막 파라미터인 RejectedExecutionHandler handler이고, ThreadPoolExecutor의 내부 클래스에 미리 정의된 4개가 있다.

ThreadPoolExecutor.AbortPolicy

default handler로 RejectedExecutionException 런타임 예외를 발생시킨다.

ThreadPoolExecutor.CallerRunsPolicy

Thread를 생성하지 않고 태스크를 호출하는 Thread에서 바로 실행된다.

ThreadPoolExecutor.DiscardPolicy

태스크가 조용히 제거된다.

ThreadPoolExecutor.DiscardOldestPolicy

workQueue에서 가장 오래된 태스크를 제거한다.


AsyncTask에 적용된 ThreadPoolExecutor

AsyncTask.THREAD_POOL_EXECUTOR도 RejectedExecutionHandler를 따로 넣지 않으므로 AbortPolicy가 기본으로 적용된다.

- current version < kitkat current version >= kitkat
maximumPoolSize 128 CPU 개수 * 2 + 1
workQueue 10 128

maximumPoolSize + workQueue 개수를 넘으면 RejectedExecutionException이 발생한다.

1234567
018-09-27 20:49:20.055 17643-17643/com.tistory.gpark.nextstep E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.tistory.gpark.nextstep, PID: 17643
    java.lang.RuntimeException: Unable to start activity ComponentInfo{com.tistory.gpark.nextstep/com.tistory.gpark.nextstep.JavaActivity}: 
    java.util.concurrent.RejectedExecutionException:
            Task com.tistory.gpark.nextstep.JavaActivity$2@889003c rejected from java.util.concurrent.ThreadPoolExecutor@acf91c5[Running, pool size = 10, active threads = 10, queued tasks = 0, completed tasks = 0] 


RejectedExecutionHandler에 DiscardOldestPolicy 적용

앱에서 ThreadPoolExecutor를 사용할 때 가장 쓸모 있는 RejectedExecutionHandler는 DiscardOldestPolicy이다. ListView, ScrollView, ViewFlipper, ViewPager 등에서 화면을 스크롤하면서 이동할 때, 이미 지나가버린 화면보다 새로 보이는 화면이 상대적으로 중요하다. DiscardOldestPolicy를 사용하면 오래된 것을 workQueue에서 제거하고 최신 태스크를 workQueue에 추가한다.

example

ImageView에 표시할 이미지 파일을 다운로드해서 보여줄 때, DiscardOldestPolicy를 사용한 예이다.

1234567891011121314
private static final int FIXED_THREAD_SIZE = 4;
private static final int QUEUE_SIZE = 20;

private ThreadPoolExecutor executor 
        = new ThreadPoolExecutor(FIXED_THREAD_SIZE, FIXED_THREAD_SIZE, 
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>(QUEUE_SIZE),
                                new ThreadPoolExecutor.DiscardOldestPolicy());

private void queueDownload(ImageView imageView, String url) {
    executor.submit(new ImageDownloadTask(imageView, url));
}

기본과 최대 스레드 개수는 4개이고 workQueue 사이즈는 20이므로, 동시에 24개까지는 Thread Pool에 태스크를 넣을 수 있다. 24개 이상으로 submit()을 실행하면 workQueue에서 오래된 태스크를 제거하고 새로운 태스크를 workQueue에 추가한다.

ScheduledThreadPoolExecutor 클래스

지연/반복 작업에 대해서는 ScheduledThreadPoolExcutor를 사용할 수 있다. 화면 갱신이라면 Handler를 쓰는게 적절하지만 Background Thread에서 네트워크 통신이나 DB 작업 등이 지연/반복 실행되는 경우는 ScheduledThreadPoolExecutor를 고려하는게 좋다.

반복/지연 작업의 다른 옵션으로 Timer를 생각할 수도 있지만, Timer API 문서를 보면 ScheduledThreadPoolExecutor를 사용하도록 권장한다. 
Timer는 실시간 태스크 스케줄링을 보장하지 않고(Thread를 하나만 생성해서 사용하기 때문에 앞서 실행되는 작업이므로 예정 시간에 맞지 않게 실행될 수 있음), 
여러 스레드가 동기화 없이 하나의 Timer를 공유하는 문제가 있다.
ScheduledThreadPoolExecutor 생성자(ThreadFactory 파라미터 제외)
1ScheduledThreadPoolExecutor(int corePoolSize, RejectedExecutionHandler handler)

ThreadPoolExecutor와는 다르게 maximumPoolSize, keeepAliveTime, unit, workQueue가 빠져있다. 빠져 있는 4개의 파라미터는 ScheduledThreadPoolExecutor에서 고정되어 있는데, maximumPoolSize에는 Integer.MAX_VALUE, keepAliveTime에는 0, workQueue에는 내부 클래스인 DelayWorkQueue 인스턴스가 전달된다.
DelayWorkQueue의 기본 사이즈는 16인데, 태스크가 많아지면 제한 없이 계속 사이즈가 커진다.


Executors 클래스

ThreadPoolExecutor, ScheduledThreadPoolExecutor는 직접 생성하는 것보다는 Executors의 팩토리 메서드로 생성하는 경우가 많다.

클래스 다이어그램 (Executor, ExecutorService, ScheduledExecutorService, ThreadPoolExecutor, ScheduledThreadPoolExecutor)


Executors에서 자주 쓰이는 팩토리 메소드

newFixedThreadPool(int nThreads)

nThreads 개수까지 Thread를 생성한다. workQueue는 크기 제한이 없다.

1234567
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

newCachedThreadPool()

필요할 때 Thread를 생성하는데, Thread 개수에는 제한이 없다. keepAliveTime이 60초로 길다는 것이 특징이다.

1234567
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

newSingleThreadExecutor()

단일 Thread를 사용해서 순차적으로 처리한다. workQueue는 크기 제한이 없다.

1234567
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}

Question

newFixedThreadPool(1)과 동일한거 아닌가?

Answer

결론은 다르다. 이유는 newSingleThreadexecutor()는 FinalizableDelegatedExecutorService로 다시 wrapping한 것이다. wrapping은 Thread 개수를 1이 아닌 다른 값으로 변결할 수 없게 한다. 단순히 newFixedThreadPool(1)로 return된 것은 ThreadPoolExecutor로 캐스팅해서 setCorePoolSize()나 setMaximumPoolSize() 메서드로 Thread 개수를 변경할 수 있다.


newScheduledThreadPool(int corePoolSize)

corePoolSize 개수의 ScheduledThreadPoolExecutor를 만든다.

12345
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}


AsyncTask 클래스

AsyncTask는 Background Thread에서 작업하는 진행 상태나 결과 데이터를 UI Thread에 전달하고, Background Thread와 UI Thread를 고민하지 않고 구분해서 쓸 수 있도록 만들어진 것이다. AsyncTask는 onPostExecute()를 override할 필요가 없을 때, 즉 UI 작업이 필요하지 않다면 쓰지 않는 게 낫다.

Backgroud Thread와 UI Thread 구분

AsyncTask를 설명하기 위해 Background Thread와 UI Thread를 구분해서 사용하는 여러 방법을 살펴보자.

example

12345678910111213
@Override
public void onClick(View v) {
    new Thread(new Runnable()) {
        
        @Override
        public void run() {
            Bitmap b = loadImageFromNetwork("http://example.com/image.png");
            mImageView.setImageBitmap(b);
        }
    }).start();
}

Background Thread에서 UI를 변경하므로 CalledFromWrongThreadException이 발생한다. 이를 해결하기 위해서는 여러가지 방법이 있다.

Handler 이용(2가지)
  1. sendMessage() 메서드로 Message를 보내고 handleMessage() 메서드에서 UI 작업을 실행

    123456789101112131415161718192021222324252627
    private final static int BITMAP_MSG = 1;
    
    private Handler mHandler = new Handler(new Handler.Callback() {
        
        @Override
        public boolean handleMessage(Message msg) {
            if (msg.what == BITMAP_MSG) {
                mImageView.setImageBitmap((Bitmap) msg.obj);
            }
            return false;
        }
    });
    
    @Override
    public void onClick(View v) {
        new Thread(new Runnable() {
            
            @Override
            public void run() {
                final Bitmap bitmap = loadImageFromNetwork("http://exmaple.com/image.png");
                Message message = Message.obtain(mHandler, BITMAP_MSG, bitmap);
                mHandler.sendMesssage(message);
            }
        }).start();
    }
    
    
  2. post() 메서드를 사용하여 Runnable에서 UI 작업을 실행

    123456789101112131415161718192021
    private Handler mHandler = new Handler();
    
    @Override
    public void onClick(View v) {
        new Thread(new Runnable() {
            
            @Override
            public void run() {
                final Bitmap bitmap = loadImageFromNetwork("http://example.com/image.png");
                mHandler.post(new Runnable() {
                    
                    @Override
                    public void run() {
                        mImageView.setImageBitmap(bitmap);
                    }
                })
            }
        }).start();
    }
    
    
View의 post() 메서드에 Runnable 전달

내부적으로 Handler를 사용한다. Activity의 runOnUiThread() 메서드도 동일한 형태로 사용하면 된다.

12345678910111213141516171819
@Override
public void onClick(View v) {
    new Thread(new Runnable() {
        
        @Override
        public void run() {
            final Bitmap bitmap = loadImageFromNetwork("http://example.com/image.png");
            mImageView.post(new Runnable() {
                
                @Override
                public void run() {
                    mImageView.setImageBitmap(bitmap);
                }
            });
        }
    }).start();
}

AsyncTask 이용
12345678910111213141516171819
@Override
public void onClick(View v) {
    new DownloadImageTask().execute("http://example.com/image.png");
}

private class DownloadImageTask extends AsyncTask<String, Void, Bitmap> {
    
    @Override
    protected Bitmap doInBackground(String... urls) {
        return loadImageFromNetwork(urls[0]);
    }
    
    @Override
    protected void onPostExecute(Bitmap result) {
        mImageView.setImageBitmap(result);
    }
}


AsyncTask의 제네릭 파라미터

AsyncTask는 제네릭 클래스이고 파라미터 타입에는 Params, Progress, Result가 있다. 진행 상태가 필요하지 않은 경우 Progress에 Void가 들어가는 경우는 있지만 Param, Progress, Result가 모두 Void인 것은 권장되지 않는다. Handler를 이용하는게 더 단순할 수 있다.

Activity 종료 시점과 불일치

Question

Activity에서 AsyncTask로 Background 작업을 실행하는 중에 back key를 눌러서 Activity를 종료하면 AsyncTask는 어떻게 되는가?

Answer

메모리에는 Activity가 남아 있어서 onPostExecute()도 정상적으로 실행되고, 그 안에서 setText()와 같은 UI 변경 메서드도 잘 실행된다. 다만 Activity가 보이지 않을 뿐이다. 이렇게 Activity 종료 시점과 AsyncTask가 끝나는 시점이 달라서 발생하는 문제가 몇 가지 있다.


메모리 문제 발생 가능

Activity가 종료될 때는 AsyncTask가 오래 걸리는게 아니라면 일시적인 현상이므로 큰 문제는 아니다. 그러나 화면 회전 등으로 인해 계속 AsyncTask가 쌓여서 실행하는 경우에는 문제가 생긴다. Activity가 화면 방향 고정이거나 android:configChanges 속성에 orientation이 들어 있는게 아니라면, 화면이 회전할 때 Activity는 종료되고 새로 시작된다. 이때 새로 시작되는 Activity는 다른 인스턴스로, AsyncTask가 아직 실행 중인 경우에는 기존 Activity도 메모리에서 제거되지 않는다.
빈번하게 화면을 회전한다면 Activity 인스턴스들이 메모리에 쌓이면서 OutofMemoryError의 원인이 될 수 있다.

순차 실행으로 인한 속도 저하

허니콤 이후에 AsyncTask를 순차 실행한다면(SERIAL_EXECUTOR) 화면을 회전할때마다 작업이 쌓이므로 갈수록 실행이 느려질 수 있다.

Fragment에서 AsyncTask 실행 문제

AsyncTask 실행 도중에 Activity를 종료하면 Fragment는 Activity와 분리되면서 Fragment에서 getContext()나 getActivity() 메서드가 null을 리턴한다. AsyncTask의 onPostExecute()에서 Context를 사용할 때 NullPointerException이 발생하므로, 권장되는 방식은 onPostExecute() 메서드 시작 부분에서 getContext()나 getActivity() 결과가 null이라면 곧바로 리턴하는 것이다.


AsyncTask 취소

AsyncTask에는 cancel() 메서드가 있다. cancel() 메서드를 호출하면 mCancelled 변수를 true로 변경하고, Thread 작업 이후에 onPostExecute() 대신 onCancelled() 메서드가 불린다.
Thread 작업이 오랠 걸리는 경우에 doInBackground() 메서드에서 중간에 isCancelled() 메서드로 체크해서 바로 리턴하는 로직을 권장한다.
따라서 isCancelled() 메서드를 doInBackground() 곳곳에서 체크하고, Activity의 onDestroy()에서 AsyncTask의 cancel() 메서드를 호출하는 것이다.

mayInterruptIfRunning 파라미터

cancel(boolean mayInterruptIfRunning) 메서드의 mayInterruptIfRunning 파라미터는 doInBackground()를 실행하는 Thread에 interrupt()를 실행할지 여부를 나타낸다.
interrupt()를 실행하면 Thread에서 sleep(), join() 메서드가 실행 중이거나 Object의 wait() 메서드가 실행 중이라면 바로 InterruptedException을 발생시킨다.
(Interruptexception은 명시적 예외이기 때문에 코드상에서 try~catch 문이 필요하다)

example

AsyncTask로 서버에서 로컬에 파일을 다운로드
123456789101112131415161718192021222324252627
@Override
protected Boolean doInBackground(String... params) {
    InputStream input = null;
    OutputStream output = null;
    ...
    try {
        URL url = new URL(params[0]);
        URLConnection connection = url.openConnection();
        connection.setConnectTimeout(5000);
        connection.connect();
        input = new BufferedInputStream(url.openStream());
        output = new FileOutputStream(tempFile);
        byte data[] = new byte[1024]; // byte[] data = new byte[1024];와 같음
        while ((count = input.read(data)) != -1 && isCancelled() == false) {
            output.write(data, 0, count);
        }
        output.flush();
    } catch (Exception e) {
        tempFile.delete();
        return Boolean.FALSE;
    } finally {
        // input, output close
    }
    return Boolean.TRUE;
}

while문에서 isCancelled()를 체크한 이유는 read() 메서드가 킷캣 이후 버전에서는 cancel(true) 실행 시 바로 예외(InterruptedIOException)를 발생시켜 빠져나가지만, 킷캣 이전버전은 반복문을 계속 수행하기 때문에 체크해줘야 한다.
별도 클래스나 라이브러리를 쓸 때는 그 안에서 AsyncTask의 취소 여부를 알 수 있는 방법이 없다. 따라서 별도 클래스나 라이브러리를 작성할 때는 interrupted()나 isInterrupted() 메서드로 인터럽트를 체크하는게 좋다. 일부러 interrupt를 하지 않고 내부적으로 background 작업을 계속 진행할 때 mayInterruptIfRunning 파라미터를 false로 하는게 유리하다.

Thread에 interrupt()를 실행하면 Thread는 내부적으로 interrupt flag를 true로 만든다. 
interrupted()나 isInterrupted() 메서드는 interrupt flag를 리턴하는 것은 동일하지만,
interrupted() 메서드는 side effect로 interrupt flag를 다시 false로 만든다. 
Thread에 interrupt() 실행 후 isInterrupted() 메서드는 여러 번 해도 true를 리턴하지만,
interrupted()는 true를 한 번만 리턴하고 이후에는 false를 리턴한다.


예외 처리 메서드 없음

AsyncTask에는 정상적으로 데이터를 처리하기 위한 onPostExecute()와 작업을 취소하기 위한 onCancelled() 메서드는 있는데, 에러를 처리하기 위한 onError() 메서드는 없다. Background Thread에서 하는 작업은 네트워크 문제와 같은 다양한 예외 케이스가 있는데, 이때 문제를 화면에 표시하는 경우가 많다. 즉 예외 케이스에도 UI 작업이 필요하다.

AsyncTask의 기본 패턴 변경

Background Thread와 UI Thread를 분리할 때 Background Thread에서 예외 발생을 고려해야한다. 따라서 AsyncTask의 기본 패턴을 변형해서 사용해야 한다.

  1. 예외가 발생하면 null 리턴
123456789101112131415161718192021222324252627
@Override
public void onClick(View v) {
    new DownloadImageTask().execute("http://example.com/image.png");
}

private class DownloadImageTask extends AsyncTask<String, Void, Bitmap> {
    
    @Override
    protected Bitmap doInBackground(String... urls) {
        try {
            return loadImageFromNetwork(urls[0]);
        } catch (Exception e) {
            return null;
        }
    }
    
    @Override
    protected void onPostExecute(Bitmap result) {
        if (result == null) {
            // 화면에 에러 메시지를 보여준다.
            return;
        }
        mImageView.setImageBitmap(result);
    }
}

위의 경우에는 예외가 발생하지 않을 때에도 null을 리턴하는 경우가 있다면, 에러 메시지가 의도에 맞지 않는다. 예외 상황에만 null이 리턴된다면 가능한 방식이다.

  1. 예외가 발생하면 Boolean.False 리턴
12345678910111213141516171819202122232425262728293031
@Override
public void onClick(View v) {
    new DownloadImageTask().execute("http://example.com/image.png");
}

private class DownloadImageTask extends AsyncTask<String, Void, Boolean> {
    
    private Bitmap mBitmap;
    
    @Override
    protected Bitmap doInBackground(String... urls) {
        try {
            mBitmap = loadImageFromNetwork(urls[0]);
            return Boolean.TRUE;
        } catch (Exception e) {
            return Boolean.FALSE;
        }
    }
    
    @Override
    protected void onPostExecute(Boolean result) {
        if(!result) {
            // 화면에 에러 메시지를 보여준다.
            return;
        }
        
        mImageView.setImageBitmap(mBitmap);
    }
}

위 방식은 DownloadImageTask의 멤버 변수에 대입돼서 onPostExecute()에서 사용한다. 가능한 방식이지만 결과 값을 파라미터로 전달하는 원래 AsyncTask의 사용 패턴과 차이가 생긴다.

대안으로 RxJava 사용

AsyncTask에서는 예외 처리를 위해서 군더더기 코드가 생겨나는데 이에 대한 대안으로 RxJava를 사용하기도 한다.

12345678910111213141516171819202122232425
@Override
public void onClick(View v) {
    Observable<Bitmap> observable = loadImageFromNetwork("http://example.com/image.png");
    observable.subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(new Observer<Bitmap>() {
            
            @Override
            public void onNext(Bitmap bitmap) {
                mImageView.setImageBitmap(bitmap);
            }
            
            @Override
            public void onError(Throwable e) {
                // 화면에 에러 메시지를 보여준다.
            }
            
            @Override
            public void onCompleted() {
                
            }
        });
}

병렬 실행 시 doInBackground() 실행 순서가 보장되지 않음

안드로이드 버전이 올라가면서 AsyncTask를 실행할 때 기본 동작이 '병렬 실행'에서 '순차 실행'으로 바뀐 것이다. '병렬 실행'이 여러 문제를 일으켰기 때문이다. 그럼에도 여전히 AsyncTask는 병렬 실행을 기본으로 해서 개발할 때가 많다.

병렬 실행이 필요한 경우

example

화면에 보여줄 여러 정보를 API로 한번에 가져오지 못할 때가 있다. 도착 지점의 날씨 정보와 주변 주차장 정보를 화면에 보여주고 싶은데 API가 별도라면, 결과를 빠르게 보여주기 위해 API를 병렬로 호출하는게 유리하다.

병렬로 데이터 가져올 때 데이터 간 의존성

성격이 전혀 다른 데이터라면 병렬 실행해서 각각의 위치에서 데이터를 보여주면 된다. 하지만 데이터 같의 의존성이 있을 때는 단순히 병렬 실행만으로는 안된다.

example

로컬 데이터를 가져오는 LocalAsyncTask를 먼저 실행하고 서버 데이터를 가져오는 ServerAsyncTask를 바로 이어서 실행하였다. 로컬 데이터가 결과를 빨리 가져온다는 가정하에 LocalAsyncTask에서는 멤버 변수에 데이터를 저장하고 화면에 먼저 보여주고 ServerAsyncTask의 onPostExecute()에서 조합해서 다시 화면을 갱신했을때, 아주 드물지만 로컬데이터를 먼저 가져온다는 가정이 어긋날 때가 있었다.

CowntDownLatch로 실행 순서 조정

2개의 AsyncTask 간에 병렬 실행도 하면서 실행 순서가 중요한 경우에는 일반적으로 CountDownLatch를 사용하면 된다.

example

sptring, summer, fall, winter, east, south, west, north를 출력하고 싶은 경우

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
@Override
public void onClick(View v) {
    new LocalAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
    new ServerAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}

private class LocalAsyncTask extends AsyncTask<Void, Void, List<String>> {

    @Override
    protected List<String> doInBackground(Void... voids) {
        SystemClock.sleep(2000);
        return Arrays.asList("spring", "summer", "fall", "winter");
    }

    @Override
    protected void onPostExecute(List<String> result) {
        try {
            mComposedList.addAll(result);
            Log.i(TAG, String.format("LocalAsyncTask result = %s", mComposedList));
        } catch (Exception e) {
            Toast.makeText(ExampleActivity.this, String.format("Error = %s", e.getMessage()), Toast.LENGTH_SHORT).show();
        } finally {
            mLatch.countDown();
        }
    }
}

private class ServerAsyncTask extends AsyncTask<Void, Void, List<String>> {

    @Override
    protected List<String> doInBackground(Void... voids) {
        try {
            return Arrays.asList("east", "south", "west", "north");
        } catch (Exception e) {
            return null;
        } finally {
            try {
                mLatch.await();
            } catch (InterruptedException e) {
                Toast.makeText(ExampleActivity.this, String.format("Error = %s", e.getMessage()), Toast.LENGTH_SHORT).show();
            }
        }
    }

    @Override
    protected void onPostExecute(List<String> result) {
        if (result != null) {
            mComposedList.addAll(result);
            Log.i(TAG, String.format("ServerAsyncTask result = %s", mComposedList));
        }
    }
}


result CountDownLatch를 사용하지 않았을 경우


result CountDownLatch를 사용했을 경우

CountDownLatch의 생성자에 들어가는 숫자는 countDown() 실행 횟수를 지정한다. countDown()를 실행하면 생성자 파라미터의 값이 1씩 줄어드는데, 이 값이 0이 될 때 대기 상태가 풀린다. await()는 대기 상태가 풀릴 때까지 대기하고 있는다. await()보다 countDown()이 먼저 실행되어도 상관없다. countDown()을 통해 이미 0이 되었기 때문에 await()는 굳이 대기하지 안하고 다음 라인을 실행한다.
LocalAsyncTask에서는 UI에 반영까지하고 나서 ServerAsyncTask도 UI 반영하라는 의미에서 onPostExecute()에서 countDown() 메서드를 실행한다.