아티클 목록으로 가기

Android 메인 스레드는 어떻게 동작하는가: Looper, Handler, 그리고 MessageQueue 이벤트 루프

skydovesJaewoong Eum (skydoves)||19분 소요

Android 메인 스레드는 어떻게 동작하는가: Looper, Handler, 그리고 MessageQueue 이벤트 루프

모든 안드로이드 개발자는 날마다 메인 스레드(main thread)를 다루면서도 그 정체를 깊이 생각해 보지는 않습니다. 백그라운드 스레드에서 되돌아오려고 Handler(Looper.getMainLooper()).post { textView.text = result }를 작성하고, "메인 스레드를 막지 말라"는 말을 반사적으로 떠올릴 만큼 반복해서 들으며, 잘못된 스레드에서 View를 건드리는 순간 발생하는 크래시를 경험해 보셨을 것입니다. 표면이 워낙 익숙하다 보니 그 아래의 구조는 시야에서 사라집니다. 순수한 자바(Java)에서 스레드는 자신의 run() 메서드를 한 번 실행하고 나면 종료됩니다. 그런데 앱의 메인 스레드는 프로세스가 살아 있는 내내 함께 살아남아, 깨어나서 터치 이벤트를 처리하고, 이어서 프레임 콜백(frame callback)을, 그다음에는 여러분이 post한 Runnable을 실행한 뒤 다시 잠듭니다. 더 깊은 질문은, 무엇이 실제로 그 스레드를 살아 있게 유지하면서, 기다리는 동안 배터리를 소모하지 않고, 올바른 순서로, 한 번에 하나씩 작업을 먹여 주는가입니다.

이 글에서는 메인 스레드 뒤에 숨은 기계 장치를 깊이 있게 파고듭니다. LooperThreadLocal로 하나의 이벤트 루프(event loop)를 스레드에 묶는 방식, Looper.loop()가 메시지를 꺼내어 디스패치(dispatch)하는 과정, MessageQueue.next()가 CPU를 소비하지 않고 네이티브 폴링(native poll)에서 블로킹되는 원리, Message가 시간순으로 정렬된 침투적 연결 리스트(intrusive linked list)를 이루고 객체 풀(object pool)에서 재활용되는 방식, 다른 스레드에서의 post가 잠들어 있는 큐를 깨우는 과정, Handler.dispatchMessage가 어떤 코드를 실행할지 결정하는 방식, 그리고 동기화 배리어(sync barrier)와 비동기 메시지가 프레임 작업을 줄 앞으로 보내는 원리까지 차례대로 살펴봅니다.

근본적인 문제: 살아남아 시간에 걸쳐 작업을 처리하는 스레드

자바에서 스레드는 하나의 작업을 수행하고 종료하도록 만들어져 있습니다. run() 메서드를 주면 위에서 아래로 실행하고, run()이 반환되면 스레드는 끝납니다. 이 모델은 UI 스레드에는 맞지 않습니다. UI 스레드는 자신이 어떤 작업을 하게 될지 미리 알지 못합니다. 터치 이벤트는 사용자가 화면을 만질 때 도착하고, 프레임 콜백은 초당 60회 이상 도착하며, 백그라운드 스레드는 예측할 수 없는 시점에 결과를 건네줍니다. 스레드는 그 어떤 개별 작업보다도 오래 살아남으면서 계속 새로운 작업을 받아들여야 합니다.

스레드를 살려 두는 순진한 방법은 run()이 결코 반환되지 않게 만드는 것입니다. 대기 중인 작업이 담긴 공유 컬렉션을 검사하고 발견한 것을 무엇이든 실행하는 무한 루프를 작성하면 됩니다.

class BusyThread extends Thread {
    final Queue<Runnable> tasks = new ConcurrentLinkedQueue<>();

    public void run() {
        while (true) {
            Runnable task = tasks.poll();
            if (task != null) {
                task.run();
            }
        }
    }
}

이렇게 하면 스레드는 살아남고 작업도 순서대로 처리됩니다. 문제는 while (true)라는 표현에 있습니다. 큐가 비어 있으면 tasks.poll()은 즉시 null을 반환하고, 루프는 다시 맨 위로 돌아가 또 폴링하고, 또 null을 받고, 이를 CPU가 허용하는 최대 속도로 반복합니다. 할 일이 없는 스레드가 코어 하나를 100%로 점유하면서 배터리를 소모하고 기기를 뜨겁게 만듭니다. 그렇게까지 해서 알아내는 것이라고는 할 일이 없다는 사실뿐입니다. 이것이 바로 바쁜 대기(busy wait)이며, 실제 설계가 반드시 피해야 하는 대상입니다.

그래서 우리에게는 할 일이 없을 때는 잠들고, 작업이 도착하는 즉시 깨어나는 스레드가 필요합니다. 이때의 잠듦은 진짜 잠듦이어야 합니다. 즉, 운영체제가 스레드를 멈춰 세우고, 스케줄러가 CPU를 다른 누군가에게 넘기며, 무언가가 깨우기 전까지 스레드는 단 한 사이클도 소비하지 않아야 합니다. 안드로이드는 바로 이것을 구현합니다. 루프는 Looper이고, 큐는 MessageQueue이며, 잠듦과 깨움은 네이티브 코드 깊은 곳에서 파일 디스크립터(file descriptor)를 통해 일어납니다. 이 글의 나머지는 이 세 조각이 어떻게 맞물리는지에 관한 이야기입니다.

Looper: 스레드마다 하나의 이벤트 루프, ThreadLocal로 고정되다

Looper는 단일 스레드를 위한 이벤트 루프를 소유하는 객체입니다. 이 관계는 일대일입니다. 즉, 하나의 스레드는 많아야 하나의 Looper를 가지며, 하나의 Looper는 정확히 하나의 스레드에 속합니다. 이를 강제하는 메커니즘이 ThreadLocal인데, 그 값이 각 스레드마다 사적으로 유지되는 변수라고 생각하시면 됩니다. 두 스레드가 동일한 ThreadLocal을 읽어도 마치 각 스레드가 자신만의 사본을 가진 것처럼 서로 다른 두 값을 보게 됩니다.

Looper의 정적(static) 상태를 살펴보면, ThreadLocal은 스레드별 인스턴스를 보유하고, 별도의 정적 필드가 메인 스레드의 looper를 보유하므로, 어떤 스레드에서든 여기에 접근할 수 있습니다.

static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
private static Looper sMainLooper;  // Looper.class로 보호됨

final MessageQueue mQueue;
final Thread mThread;

스레드는 Looper를 자동으로 얻지 못합니다. Looper.prepare()를 호출하여 직접 설치해야 하는데, 이 메서드는 Looper를 생성하여 호출한 스레드의 ThreadLocal에 저장합니다. prepare()를 살펴보겠습니다.

public static void prepare() {
    prepare(true);
}

private static void prepare(boolean quitAllowed) {
    if (sThreadLocal.get() != null) {
        throw new RuntimeException("Only one Looper may be created per thread");
    }
    sThreadLocal.set(new Looper(quitAllowed));
}

이 가드(guard)는 한 스레드가 오직 하나의 루프만 가질 수 있다는 규칙입니다. ThreadLocal이 이미 Looper를 보유하고 있다면 두 번째 prepare() 호출은 예외를 던집니다. 큐가 태어나는 곳은 바로 Looper 생성자입니다. 여기서 MessageQueue를 생성하고 자신이 어느 스레드에서 실행되는지를 기록합니다.

private Looper(boolean quitAllowed) {
    mQueue = new MessageQueue(quitAllowed);
    mThread = Thread.currentThread();
}

메인 스레드는 특별한 경우입니다. 메인 스레드를 위해 prepare()를 직접 호출하는 일은 결코 없습니다. 프로세스가 시작되면 프레임워크가 ActivityThread.main()을 실행하고, 이 메서드가 Looper.prepareMainLooper()를 호출한 뒤 최종적으로 Looper.loop()를 호출합니다. 메인 looper 버전은 quitAllowed를 false로 설정한 동일한 prepare()이며, 여기에 더해 인스턴스를 정적 필드 sMainLooper에 기록합니다.

public static void prepareMainLooper() {
    prepare(false);
    synchronized (Looper.class) {
        if (sMainLooper != null) {
            throw new IllegalStateException("The main Looper has already been prepared.");
        }
        sMainLooper = myLooper();
    }
}

여기서 두 가지 사실이 따라 나옵니다. 메인 looper는 quitAllowed = false를 전달하는데, 이것이 바로 메인 스레드의 큐에서 quit()를 호출하면 "Main thread not allowed to quit" 예외가 발생하는 이유입니다. 또한 sMainLooper는 프로세스 전역 참조로서 Looper.getMainLooper()가 반환하는 값이므로, 어떤 백그라운드 스레드든 메인 스레드를 대상으로 하는 Handler를 만들 수 있습니다. 스레드별 조회는 myLooper()이며, 이는 단순히 ThreadLocal을 읽는 것에 불과합니다.

public static @Nullable Looper myLooper() {
    return sThreadLocal.get();
}

prepare()를 한 번도 호출하지 않은 스레드에서 평범한 Handler()를 생성하는 것이 실패하는 이유도 바로 이것입니다. Handler 생성자는 Looper.myLooper()를 호출하여 null을 받고, "Can't create handler inside thread that has not called Looper.prepare()" 예외를 던집니다.

Looper.loop(): 디스패치하고 재활용하는 무한 루프

스레드가 일단 Looper를 갖추고 나면, Looper.loop()를 호출하는 순간 그 스레드는 이벤트 루프로 변신합니다. 이 호출은 큐가 종료되기 전까지 반환되지 않으며, 메인 스레드에서는 ActivityThread.main()의 마지막 의미 있는 줄에 해당합니다. 여러분의 앱이 처리하는 모든 터치 이벤트, 모든 프레임, post된 모든 Runnable은 바로 이 하나의 루프 안에서 실행됩니다.

loop()의 구조는 헬퍼 메서드 loopOnce를 반복적으로 호출하다가, 그 헬퍼가 루프를 멈춰야 한다는 신호를 보내면 반환하는 for (;;)입니다.

public static void loop() {
    final Looper me = myLooper();
    if (me == null) {
        throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
    }
    me.mInLoop = true;
    final long ident = Binder.clearCallingIdentity();
    final int thresholdOverride = getThresholdOverride();
    me.mSlowDeliveryDetected = false;

    for (;;) {
        if (!loopOnce(me, ident, thresholdOverride)) {
            return;
        }
    }
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기