Skip to content

Instantly share code, notes, and snippets.

@benelog
Last active August 27, 2021 16:15
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save benelog/5954649 to your computer and use it in GitHub Desktop.
Save benelog/5954649 to your computer and use it in GitHub Desktop.
AsyncTask 분석

변경이력

  • 2013/10/15: Dianne Hackborn의 언급에 대한 번역은 안세원님이 교정해주신 내용으로 교체합나디.

AsyncTask는 API Level 13이상 버전이 설치된 기기에서 android:targetSdkVersion가 13이상 일 때 여러 개의 AsyncTask가 동시에 실행되어도 순차적으로 호출됩니다.

기기의 버전뿐만 아니라 targetSDK 설정에도 영향을 받으므로 target SDK 설정을 변경할 때 유의해야 합니다. 그리고 가능하다면 목적별로 스레드풀을 분리하고, 스레드의 갯수가 늘어나는 것에 대비해 무작정 큰 최대값을 주는것보다는 Timeout과 RejectionPolicy로 관리를 하는 편이 바람직합니다.

주석에 나온 설명

AsyncTask의 Javadoc주석에는 아래와 같이 HONEYCOMB이상에서는 순차적(Serial)으로 실행되고, 병렬로 실행하려면 executeOnExecutor() 메서드를 쓰라고 합니다.

When first introduced, AsyncTasks were executed serially on a single background thread. Starting with DONUT, this was changed to a pool of threads allowing multiple tasks to operate in parallel. Starting with HONEYCOMB, tasks are executed on a single thread to avoid common application errors caused by parallel execution.

If you truly want parallel execution, you can invoke executeOnExecutor(java.util.concurrent.Executor, Object[]) with THREAD_POOL_EXECUTOR.

Build.VERSION_CODES의 주석에 따르면 "public static final int HONEYCOMB_MR2" 변수의 설명에 이 버전부터 AsyncTask는 SerialExecutor를 사용한다고 합니다.

AsyncTask will use the serial executor by default when calling android.os.AsyncTask#execute

AsyncTask의 Javadoc설명에서는 대충 HONEYCOMB이상이라고 적었는데, Build.VERSION_CODES의 주석에는 HONEYCOMB_MR2 라고 명시했습니다. 그러나 Mark Murphy의 글에 따르면 HONEYCOMB_MR2도 Patch 버전에 따라서 동작이 다른 듯합니다. API Level 14(ICS, 4.0)이상부터는 확실히 Default가 SerialExecutor인 코드가 들어갔고, API Level 11 (HONEYCOMB, 3.0)부터 AsyncTask.SERIAL_EXECTOR와 AsyncTask.executeOnExecutor(..) 코드가 추가된 것은 명확하다고 인식합니다. ('AsyncTask 정책 변경을 고심한 흔적'단락에서 Commit 이력을 분석하면서 한번더 언급합니다)

그리고,이 설명으로는 충분하지 않은 여지가 있습니다. 앱이 실행되기는 기기의 버전이 3.2이상이라도 AndroidManifest.xml에 설정된 android:targetSdkVersion가 13(HONEYCOMB_MR2, 3.2)미만이라면 SerialExecutor가 사용되지 않습니다.

ActivityThread에서 DefaultExecutor 변경

android.app.ActivityThread는 Activity와 Service의 생성 및 스케쥴링을 담당하는 클래스입니다. 앱의 론치 과정에서 ActivityThread.handleBindApplication()이 호출되는데, 여기서 targetSDK에 따라서 defaultExecutor를 다시 바꿔주는 로직이 들어가있었습니다.

if (data.appInfo.targetSdkVersion <= android.os.Build.VERSION_CODES.HONEYCOMB_MR1) {
    AsyncTask.setDefaultExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}

( ActivityThread.java 소스 참조 )

이 때문에 targetSDK가 9인 앱은 ICS버전이 설치된 기기에서 실행되더라도 SERIAL_EXECUTOR를 쓰지 않고 AsyncTask.THREAD_POOL_EXECUTOR를 바로 씁니다. 따라서 ICS에서도 AsynTask.execute(..)가 스레드 풀을 넘치게하는 에러가 발생할 수 있습니다.

이는 AsyncTask소스만 봐서는 알수 없기 때문에 AsyncTask.setDefaultExecutor(..)를 다른 클래스에서 호출했다면 여러 곳에 상세하게 설명이 있어야 바람직해보입니다. Android Developers Groups에서 AsyncTask에 대한 토론에서도 문서가 개선되어야한다는 의견이 있었고, 여기에서 구글 개발자 Dianne Hackborn은 개발Branch에는 업데이트했다고 답변했지만, 현재까지 달린 최신 주석으로도 targetSDK의 의존성 설명은 부족해보입니다.

참고로 setDefaultExecutor(..) 메소드는 아래처럼 /** hide */ 선언이 붙어있습니다.

/** @hide */
public static void setDefaultExecutor(Executor exec) {
    sDefaultExecutor = exec;
}

이 메서드를 안드로이드 프레임워크 내부에서만 호출하려고 외부에는 숨겼습니다. AsyncTask.setDefaultExecutor(..)를 앱에서 호출하면 IDE에서는 빨간 줄이 그이고, 이를 무시하고 컴파일을 시도해도 빌드가 실패합니다.

다만 아래와 같이 reflection으로 앱에서 DefaultExecutor를 바꾸는 편법은 가능하기는 한데, 앱 전체에 의도하지 않은 부작용을 미칠수 있으므로 바람직해보이지는 않습니다.

Method method = AsyncTask.class.getMethod("setDefaultExecutor", Executor.class);
method.invoke(null, AsyncTask.THREAD_POOL_EXECUTOR);

AsyncTask.setDefaultExecutor(..)를 호출하는 클래스를 안드로이드 framework_base 컴퍼넌트 소스를 뒤져서 찾아보았지만, ActivityThread가 유일했습니다. 아래와 같이 git으로 소스를 받아서 find + grep을 이용했습니다.

git clone https://android.googlesource.com/platform/frameworks/base.git
find . -name "*.java" | xargs grep -i "AsyncTask.setDefaultExecutor"

AsyncTask 정책 변경을 고심한 흔적

AsyncTask는 1.5(Cupcake, API Level 3)까지 직렬실행였다가 1.6(DONUT, API level 4)부터 3.1(HONEYCOMB_MR1, API level 12)까지는 병렬, 그 이후로는 다시 직렬로 바꾸고 targetSDK까지 보는 등 크게 봐서도 초기의 결정을 번복했습니다. 그런데 흥미롭게도 AsyncTask의 change history 를 보면 허니콤버전 내에서도 이 결정을 왔다갔다한 Commit들이 보입니다.

  • 2011/01/16 Commit 81d : SerialExecutor 도입. 직렬 실행.
  • 2011/01/25 Commit 964 : 다시 THREAD_POOL_EXECUTOR를 쓰는 것으로 변경
  • 2011/03/17 Commit d63 : SerialExecutor를 다시 Default로 사용. targetSDK에 따라서 AsyncTask.THREAD_POOL_EXECUTOR사용.

Commit 81d 에서는 디폴트를 SerialExecutor로 지정하고 executeOnExecutor(..)메소드를 추가합니다. Commit 964에 다시 SERIAL_EXECUTOR 변수는 public으로 공개했지만, 디폴트는 AsyncTask.THREAD_POOL_EXECUTOR으로, 다시 병렬실행으로 되돌립니다. 이 2개의 commit이 API Level 11에 포함된 것으로 보입니다. AsyncTask의 Javadoc를 보면 executeOnExecutor 등은 API level 11부터 포함된것으로 나옵니다. 이 때의 의도는 필요하다면 AsyncTask.executeOnExcutor(AsyncTask.SERIAL_EXECUTOR)로 선택적으로 병렬로 실행하라는 의도였던것 같습니다.

따라서 API level 11이상이라면 아래 코드로 TARGET_SDK에 관련없이 THREAD_POOL_EXECUTOR를 사용하는 것이 가능합니다.

if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.HONEYCOMB) {
  myTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
} else {
  myTask.execute();
}

3번째 commit인 Commit d63에서 현재와 같이 Default가 AsyncTask.SERIAL_EXECUTOR, 그리고 targetSDK가 12이하라면 AsyncTask.THREAD_POOL_EXECUTOR를 쓰는 코드가 들어갑니다. 이 Commit이 API level 13, HONEYCOMB_MR2에 들어간 것으로 여러 주석을 통해 파악됩니다. 그러나 그것이 HONEYCOMB_MR2 전체가 아닌 일부 Patch판일 가능성도 있습니다.

이렇게 Commit이 포함버전을 명확하게 확인할 수 없는 이유는, 안드로이드 소스 저장소에서 허니컴대 버전에 대한 태그가 잘 관리되어 있지 않기 때문입니다. https://android.googlesource.com/platform/frameworks/base.git/+refs 를 찾아보면 2.x대, 4.x대에는 알려진 릴리즈 버전이 다 태그로 따져있지만 허니컴시절인 3.x대에는 android-3.2.4_r1밖에 존재하지 않습니다.

Android Developers Groups에서 AsyncTask에 대한 토론에서 Daniel Hackborn은 AsyncTask의 동작순서 때문에 안드로이드 플랫폼 자체에서도 버그가 많았고, 디폴트로는 안전한 정책을 지정하려는 의도로 이런 변경을 가했다고 언급합니다. '난 니가 이 메일 쓰레드에 참여한 다른 사람보다 얼마나 더 똑똑한진 모르겠다만, 난 대다수를 위한 가장 좋은 선택은 안전한 옵션을 기본으로 택하고, 필요할 경우에만 기본 동작을 스스로 바꿔서 구현하는 것이라고 확신한다'고 다소 독하게 이야기합니다. 이 메일스레드에서 그의 답변을 다 찾아보면 다소 감정이 상한 듯 보입니다. 멀티스레드는 정말 어렵다는 교훈을 얻었다고도 덧붙입니다.

This change was made because we realized we had a lot of bugs in the platform itself where async tasks where created that had implicit ordering dependences, so with the default parallel execution there were subtle rare bugs that would result.

I can't address how much smarter you are than the rest of us, but I am pretty convinced that for the vast majority of us the best thing to do is have the default here be safe, allowing developers to change it for the specific cases where they don't want that behavior.

This is a lesson I seem to learn every few years: multithreading is hard. Once you think you now understand it and are an expert, you are heading soon to another painful lesson that multithreading is hard.

그리고 이전 버전의 호환성을 위해서 targetSDK에 따른 분기로직을 넣었다고 답변도 하는데, 바람직한 동작일지는 몰라도 문서화는 아쉽습니다.

바람직한 스레드풀 사용 정책은?

병렬 실행을 위해 executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)를 사용하는 것만으로도 안심할 수는 없습니다. 우선 하는 일의 특성에 따라서 스레드 풀을 분리하는 것을 고려해야 합니다. 풀의 크기가 크다고 해도 로깅을 위한 HTTP API호출과 이미지 로딩을 위한 HTTP호출이 같은 풀을 사용한다면 사용자입장에서는 중요하지 않은 로깅 때문에 이미지 로딩 부분의 성능이나 안정성이 영향을 받습니다. 만약 로깅을 위한 API서버가 다운되었을 때 적절한 timeout이 지정되지 않아서 전체 스레드풀을 고갈시킨다면 전체 앱의 크래스가 일어날수도 있습니다.

그리고 풀의 크기보다는 RejectionPolicy와 timeout으로 조정하는 편이 좋습니다. AsyncTask.THREAD_POOL_EXECUTOR는 최대 크기는 너무 크고, 몇십개의 Thread가 생성되었다는것 자체가 이미 문제의 신호일 수 있습니다. 이 신호를 무시하다가 Out of memory 같은 더 큰 문제를 만날지도 모릅니다. 디폴트인 풀의 크기는 작게 잡고, 실패한 요청은 버려도 되는 성격이라면 DiscardPolicy, DisoldestPolicy, UI스레드에서 호출되는 Task가 아니라면 CallerRunsPolicy도 고려해볼만합니다. ThreadPoolExecutor 클래스의 API를 잘 파악해서 정교하게 쓸 수 있으면 더 크래쉬 확률이 적고 성능이 뛰어난 앱을 만드는데 도움이 됩니다.

API level 11이하에서는 AsyncTask.executeOnExecutor로 스레드풀을 직접 지정할 수가 없기에 스레드 풀이 넘치는 에러를 완전히 막기가 어렵습니다. 로깅처럼 호출결과를 UI thread에 전달할 필요가 없는 작업이라면 AsyncTask를 쓰지 않고 Runnable 인터페이스로 구현한 후 Executor에 직접 전달하는 방식이 좋겠습니다.

버전별 RejectedExecutionException 에러 스택과 재현

RejectedExecutionException이 일어났을 때 버전별로 에러스택을 정리해봅니다. 에러 스택만 확보해도 호출경로와 라인 번호를 보면 어떤 버전인지 확인할 수 있습니다.

RejectedExecutionException은 아래와 같이 ThreadPoolExecutor의 정보를 보여주는데, pool size와 active threads가 128개이면 AsyncTask.THREAD_POOL_EXECUTOR가 실행되었을 확률이 높습니다.

java.util.concurrent.RejectedExecutionException: Task android.os.AsyncTask$3@42212328 rejected from java.util.concurrent.ThreadPoolExecutor@414b86b8[Running, pool size = 128, active threads = 128, queued tasks = 10, completed tasks = 475]

2.x대 버전에서는 AsyncTask.execute(..)에서 바로 ThreadPoolExecutor.execute(..)로 이어지고, 3.x대 버전에서는 AsyncTask.execute(..) -> .AsyncTask.executeOnExecutor(..)를 거쳐서 ThreadPoolExecutor를 호출합니다. 중간에 SerialExecutor에 대한 호출이 없는 것으로도 defaultExecutor가 바뀌었음을 확인할 수 있습니다.

SerialExecutor도 내부적으로 AsyncTask.THREAD_POOL_EXECUTOR를 사용하지만, 하나의 작업이 끝나야 다음작업을 ThreadPoolExecutor.workQueue에 집어넣기 때문에 Active thread가 항상 1개일 수밖에 없습니다.

첨부한 AsyncTaskTest.java를 실행하고 targetSdk 를 조정하면 에러를 쉽게 재현할 수 있습니다. 참고로 Active한 스레드가 128개 넘어서야 에러가 나므로 실제로 요청된 Task는 128개가 훨씬 넘어야지 에러가 발생합니다.

API Level 10 (2.3.3 ~ 2.3.7)

at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:1961)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:794)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1315)
at android.os.AsyncTask.execute(AsyncTask.java:394)

API Level 15~ 16 (4.0.3 ~ 4.0.4, 4.1.1 ~ 4.1.2)

at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:1967)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:782)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1303)
at android.os.AsyncTask.executeOnExecutor(AsyncTask.java:564)
at android.os.AsyncTask.execute(AsyncTask.java:511)

API Level 17 (4.2.2)

at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:1979)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:786)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1307)
at android.os.AsyncTask.executeOnExecutor(AsyncTask.java:589)
at android.os.AsyncTask.execute(AsyncTask.java:534)

Concurrency 테스트 방식

당연한 이야기지만 Android의 Thread 문제는 JVM에서 재현하기가 어렵습니다. 원래 Java에서도 Multi-thread 이슈는 서버장비나 JDK버전에 따라서도 많이 다르게 나타납니다. 그리고 다른 JDK클래스들이 다 그렇지만, 안드로이드에 포함된 java.lang.Thread나 java.util.concurrent.ThreadPoolExecutor는 원래 Java와는 전혀 다른 구현입니다. JDK의 클래스를 수정해서 만들었겠지만, 안드로이드에 맞도록 최적화를 시도한 흔적이 많이 보이고, 현재는 DIFF로도 비교할 수 없을만큼 많이 다릅니다.

그리고 Robolectric으로 테스트를 할 때는 ShadowAsyncTask가 AsyncTask를 대체해서 실행되는데, 이 클래스의 excuteOnExcutor(..)메소드는 첫번째 파라미터로 넘긴 Executor를 무시합니다. 따라서 스레드풀을 따로 만들어서 AsyncTask.executeOnExecutor(..)에서 실행해서 RejectionPolicy 동작을 검증하는 테스트는 Robolectric에서는 할 수 없습니다.

참고자료

  1. AsyncTask의 change history
  2. http://stackoverflow.com/questions/10995281/what-change-did-really-happen-in-async-task-after-android-gingerbread
  3. Mark Murphy의 글 : http://commonsware.com/blog/2012/04/20/asynctask-threading-regression-confirmed.html
  4. Android Developers Groups에서 AsyncTask에 대한 토론 : https://groups.google.com/forum/#!topic/android-developers/8M0RTFfO7-M
  5. Android Application Launch 동작분석 : http://daluham.cafe24.com/wp/?p=69

강윤구 대리님, 우재민 사원이 분석한 사례

현상

AsyncTask가 UI 스레드가 아닌 곳에서 처음으로 호출된다면 아래와 같은 에러스택이 발생할 수 있습니다.

android.view.ViewRoot$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
    at android.view.ViewRoot.checkThread(ViewRoot.java:3011)
    at android.view.ViewRoot.requestLayout(ViewRoot.java:634)
    at android.view.View.requestLayout(View.java:8284)
    at android.view.View.setFlags(View.java:4658)
    at android.view.View.setVisibility(View.java:3133)
    at android.app.Dialog.hide(Dialog.java:254)

같은 원인으로 아래와 같은 예외 메시지가 발생할 수도 있습니다.

 Handler{40797d88} sending message to a Handler on a dead thread
 java.lang.RuntimeException: Handler{40797d88} sending message to a Handler on a dead thread
     at android.os.MessageQueue.enqueueMessage(MessageQueue.java:196)
     at android.os.Handler.sendMessageAtTime(Handler.java:457)
     at android.os.Handler.sendMessageDelayed(Handler.java:430)
     at android.os.Handler.sendMessage(Handler.java:367)
     at android.os.Message.sendToTarget(Message.java:349)
     at android.os.AsyncTask$3.done(AsyncTask.java:214)
     at java.util.concurrent.FutureTask$Sync.innerSet(FutureTask.java:253)
     at java.util.concurrent.FutureTask.set(FutureTask.java:113)
     at java.util.concurrent.FutureTask$Sync.innerRun(FutureTask.java:311)
     at java.util.concurrent.FutureTask.run(FutureTask.java:138)
     at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1088)
     at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:581)
     at java.lang.Thread.run(Thread.java:1019)

원인

AsyncTask 내에는 sHandler라는 static 멤버 변수가 있습니다.

private static final InternalHandler sHandler = new InternalHandler();
private static class InternalHandler extends Handler {
     ...
}

sHandler는 앱에서 AsyncTask를 최초 선언한 순간 객체가 할당되고, UI작업을 처리하는 onPostExecute()가 호출될 때 사용됩니다.

앱을 실행하고 최초로 AsyncTask를 선언한 부분이 메인쓰레드가 아니라면 InternalHandler는 메인쓰레드가 아닌 쓰레드의 Handler를 가지고 있고 이 Handler로는 UI 작업을 하지 못하기 때문에 "android.view.ViewRoot$CalledFromWrongThreadException" 오류를 냅니다.

이 문제는 API Level 16미만 에서만 발생합니다. API Level 16이상에서는 메인스레드를 관리하는 ActivityThread 클래스가 시작할 때 main 메소드에서 static 메소드인 AsyncTask.init()을 호출하여 AsyncTask 클래스를 로드하고 있습니다. 관련 Commit은 다음 링크에서 확인하실 수 있습니다.

https://github.com/android/platform_frameworks_base/commit/5e9120d4adfb07aeeadb0e0de1de2eb9ebbd80e0

해결방법

Activity나 Application 등 UI스레드 아래와 같이 AsyncTask를 한번 호출합니다.

Class.forName("android.os.AsyncTask");

메인스레드에서 단순히 클래스 로딩을 한번만 해도 AsyncTask내의 static 멤버 변수가 정상적으로 초기화됩니다.

참고 자료

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment