지난 포스트에서 스레드의 기본에 대해서 알아보았습니다. 이번에는 스레드가 외부 이벤트를 받아들이는 방법에 대해서 알아보겠습니다.

이 포스트는 지난 포스트와 마찬가지로 다음 가이드를 참고로 하여 작성되었습니다.

Threading Programming Guide

  • RunLoop란?
    지난 포스트에서 RunLoop에 대해서 간단하게 언급했습니다. RunLoop는 작업을 스케쥴링하거나, 전달되는 이벤트를 조정하기 위해 사용하는 이벤트 처리 루프입니다. RunLoop는 스레드가 일해야 할 때는 일하고, 일이 없으면 쉬도록 하는 목적으로 고안되었습니다.

    RunLoop의 관리는 프로그래머의 몫입니다. 그리고 Apple에서는 이 RunLoop 관리를 위한 인터페이스를 RunLoop 객체를 통해 제공하고 있습니다. 이 RunLoop 객체는 따로 만들 필요가 없이, RunLoop의 타입 프로퍼티인 current 를 참조하는 것만으로도 생성 및 사용이 가능합니다. 애플리케이션의 메인 스레드는 애플리케이션이 실행될 때 프레임워크 차원에서 자동으로 RunLoop를 설정하고 실행시키지만, 프로그래머가 만든 스레드에서는 이를 프로그래머가 명시적으로 설정하고 실행시켜야 합니다.

  • RunLoop의 원리
    RunLoop는 스레드가 잠시 들어가서 도착한 이벤트에 대한 이벤트 핸들러를 수행하는 루프 입니다. 하지만 RunLoop 객체가 내부적으로 반복을 수행하는 것은 아닙니다. 즉, 스레드의 메인 루틴 안에서 명시적으로 for,while등으로 명시적으로 반복을 수행하고, 이 안에서 RunLoop를 수행해야 합니다. 이 RunLoop가 실행되면 RunLoop안의 이벤트 처리 코드가 이벤트를 받아서, 내장된 이벤트 핸들러를 사용해 작업을 처리하게 됩니다.

    RunLoop가 이벤트를 받는 소스는 두가지로 나누어집니다. 이 두 소스에서 발생한 이벤트 모두 애플리케이션 측에서 정의한 이벤트 핸들러를 사용하여 발생한 이벤트를 처리합니다.

    1. Input Source : 비동기적으로 다른 스레드나 애플리케이션으로부터 온 이벤트를 전달합니다. 여기서 이벤트가 RunLoop로 전달될 경우, RunLoop를 빠져 나오게 됩니다.

    2. Timer Source : 동기적인 이벤트(예정된 시간에 발생하는 이벤트, 일정 주기로 발생하는 반복적인 이벤트)를 전달합니다. 여기서 이벤트가 RunLoop로 전달된 경우는 RunLoop가 종료되지 않습니다.

    RunLoop

    RunLoop 단순히 이벤트를 처리하는 것 뿐 아니라, RunLoop의 행위에 따라 Notification을 발생시킵니다. 이 Notification을 RunLoop에 Observer를 등록하는 것으로 받아볼 수 있고, 이에 따른 추가적인 처리를 해줄 수 있습니다.

  • RunLoop 모드
    RunLoop 모드는 모니터링되는 이벤트 소스와, 알림을 받는 RunLoop 옵저버의 집합입니다. RunLoop를 실행할 때 모드를 지정하면, 이 모드에 해당하는 이벤트 소스만 이벤트를 보낼 수 있습니다. 마찬가지로 옵저버도 모드에 해당하는 옵저버만 알림을 받을 수 있습니다. 다른 모드에 해당하는 이벤트 소스들은 이후에 적절한 모드로 RunLoop가 실행될 때 까지 새로운 이벤트들을 가지고 있게 됩니다.

    RunLoop모드는 이름(문자열)으로 서로 구분되며, Cocoa와 CoreFoundation은 자주 쓰이는 모드들에 대한 프리셋을 제공하기 때문에 간단하게 사용할 수 있습니다. 별도의 모드가 필요하다면, 새로운 모드 이름을 제공해주면 됩니다. 이렇게 새로 만든 모드가 유용하게 쓰이려면 하나 이상의 이벤트 소스, 옵저버가 포함되어야 합니다.

    모드를 이용하면 특정 작업을 할 때 원하지 않는 소스에서 발생한 이벤트를 필터링할 수 있습니다. 보통은 default모드 에서 작업을 하지만, modal panel이 떠있는 경우, modal 모드로 실행이 되면서 modal panel에 관련된 이벤트만 받게 됩니다. 사용자가 직접 만든 스레드에서 정확한 시간이 중요한 작업을 할 경우에는, 중요하지 않은 이벤트 소스로부터의 이벤트를 막기 위해 커스텀 모드를 사용할 수도 있습니다.

    모드는 이벤트가 발생하는 소스를 기준으로 구별하기 위함이지, 이벤트의 타입을 구별하는 용도가 아닙니다. 예를 들어 전체 마우스 이벤트 중에서 마우스 클릭 이벤트만 필터링 한다던지 하는 기능은 모드만으로는 구현할 수 없습니다.

모드 이름 설명
Default RunLoop.Mode.default(Cocoa)
kCFRunLoopDefaultMode(Core Foundation)
대부분의 연산에 대한 기본 모드입니다.
Modal RunLoop.Mode.modalPanel(Cocoa) Modal Panel에서 발생한 이벤트만 처리합니다.
macOS에서만 사용 가능합니다.
Event Tracking RunLoop.Mode.eventTracking(Cocoa) 특정 이벤트 루프 중에 다른 이벤트 발생을 막아야 할 때 사용합니다. (마우스 드래깅 이벤트 등)
macOS에서만 사용합니다.
Tracking RunLoop.Mode.tracking(Cocoa) 컨트롤을 추적하고 있는 상황에서 사용합니다.
iOS,tvOS에서 사용할수 있습니다.
Common modes RunLoop.Mode.common(Cocoa)
kCFRunLoopCommonModes(Core Foundation)
여러 Mode의 집합입니다. 이 옵션으로 추가된 이벤트 소스나 옵저버는 common 모드에 속한 모든 모드에 속하게 됩니다.
기본 값으로 Cocoa는 default, modal, eventTracking(혹은 tracking) 모드가 속하게 되고, Core Foundation은 default모드만 속해있습니다. 여기에 새로운 모드를 추가하려면 CFRunLoopAddCommonMode(::) 함수를 사용하면 됩니다. (제거 기능은 없습니다.)
  • Input Source
    Input Source는 이벤트를 비동기적으로 스레드에 전달합니다. Input Source는 두가지 타입으로 나눌 수 있습니다.
  1. Port-Based Source : 애플리케이션의 Mach Port를 감시합니다. Mach Port는 커널 레벨의 기술이기 때문에, 커널에서 자동으로 이벤트 신호를 발생시켜줍니다. Cocoa와 Core Foundation이 Port의 서브클래스 객체를 통해 지원을 해주고 있습니다. 일례로 Cocoa에서는 직접 Input Source를 만들지 않고, Port 객체를 만들어 RunLoop에 추가하는 것으로 input source의 생성과 설정을 다 해줍니다.

  2. Custom Input Source : 사용자가 지정한 소스의 이벤트를 감사합니다. 다른 스레드에서 수동으로 이벤트 신호를 발생시켜줍니다. 이를 만들기 위해서는 CFRunLoopSource 의 관련함수들을 사용합니다. Core Foundation은 이 함수들을 통해 받은 콜백 메소드를 통해 input source를 설정하고, 이벤트를 처리하고, input source를 제거하게 됩니다. 이벤트 처리 로직 뿐아니라, 이벤트 전달 매커니즘도 정의해야 하는데, 이 매커니즘은 다른 스레드에서 동작하면서 input source에 데이터와 이 데이터가 준비되었음을 알리는 역할을 하게 됩니다.

    Cocoa에서는 임의의 셀렉터를 어떠한 스레드에서도 실행할 수 있도록 해주는 Custom Input Source를 NSObject 객체를 통해 제공해줍니다. 이를 Perform Selector Source라고 합니다. Perform Selector Source는 여러 요청들을 스레드에 직렬적으로 제공해주기 때문에, 여러 메소드가 하나의 스레드에서 돌아갈 때의 동기화 문제를 줄여줍니다. 하지만 perform selector는 셀렉터의 수행이 끝나면 런루프에서 제거된다는 특징이 있습니다.

    OS X 10.5 이전 버전에서는 perform selector source가 메인스레드에 메시지를 보내는 용도로만 사용했으나, 이후 버전의 OS X와 iOS에서는 모든 스레드를 대상으로 하는 메소드가 추가되었습니다.

    Method Description
    performSelector(onMainThread:with:waitUntilDone:)
    performSelector(onMainThread:with:waitUntilDone:modes:)
    주어진 셀렉터를 메인 스레드의 다음번 RunLoop 에서 실행하도록 합니다. waitUntilDone 옵션을 통해 셀렉터를 실행하는 동안 현재 스레드를 블록시킬수도 있습니다.
    perform(_: on:with:waitUntilDone:)
    perform(_: on: with: waitUntilDone: inModes)
    주어진 셀렉트를 지정한 스레드의 다음 RunLoop에 동작실행하도록 합니다.
    perform(_:with:afterDelay:)
    perform(_:with:afterDelay:inModes:)
    주어진 셀렉터를 현재 스레드에서 일정 시간 후에 실행하도록 합니다. 다음 RunLoop가 되어야 실행이 되기 때문에, 지정된 시간과 실제 실행 타이밍에는 약간의 딜레이가 있습니다.
    cancelPreviousPerformRequests(withTarget:)
    cancelPreviousPerformRequests(withTarget:selector:object:)
    perform(_:with:afterDelay:) 으로 보낸 메시지를 취소합니다.

    Input Source를 만들때는 하나 이상에 모드에 Input Source를 할당해야 합니다. 대부분의 경우는 default모드에 할당하지만, 커스텀 모드에 할당하는 것도 가능합니다. 만약 Input Source가 현재 RunLoop 모드에 속해있지 않다면, 자신이 속한 모드로 RunLoop가 실행될 때 까지 발생한 이벤트를 붙잡고 있게 됩니다.

  • Timer Source
    Timer Source는 지정된 시간에 동기적으로 이벤트를 전달합니다. 이는 스레드에게 무언가를 하도록 알림을 주는 방법 중 하나입니다. 예를 들어서, Search Field에서 일정 시간 동안 키입력이 없으면 자동 검색을 해주는 것을 생각해 볼 수 있습니다.

    Timer Source가 비록 시간을 기준으로 알림을 보내지만, timer source가 실제 시간을 그대로 반영하는 것은 아닙니다. Timer Source는 Input Source 처럼 특정 모드에 속해 있으며, timer가 속한 모드가 현재 RunLoop 모드에 해당하지 않을 경우 timer는 이벤트를 발생시키지 못하고 대기하게 됩니다. 마찬가지로, RunLoop가 돌아가는 중간에 timer 이벤트가 발생한 경우는 다음번 RunLoop까지 대기하게 되며, RunLoop가 실행되지 않으면 timer 이벤트도 발생하지 않습니다.

    사용자는 Timer Source를 한 번 또는 반복적인 이벤트를 발생하도록 구성할 수 있습니다. 반복되는 Timer 이벤트는 실제 이벤트 발생 시간이 아닌 본래의 스케쥴된 타임을 기준으로 자기 자신을 다시 스케쥴링 합니다. 만약 타이머가 지나치게 딜레이 되어서 하나 또는 그 이상의 이벤트 발생이 밀렸다면, 이 밀린 시간 동안의 이벤트는 마지막 이벤트 단 한번만 발생되고, 이 이벤트를 기준으로 다시 스케쥴링 됩니다.

  • RunLoop Observer
    이벤트 소스들과는 다르게, RunLoop Observer는 RunLoop의 특정 지점에서 알림이 발생합니다. 이를 이용해서 스레드가 이벤트를 처리하기 전에 준비 작업을 하거나, 스레드가 잠자기 전에 데이터를 정리하거나 할 수 있습니다. Observer를 적용할 수 있는 지점은 다음과 같습니다.

    1. RunLoop가 시작할 때
    2. Timer 이벤트를 시작하기 직전
    3. input 이벤트를 처리하기 직전
    4. 잠자기 상태로 들어가기 직전
    5. 어떤 이벤트로 인해 스레드가 깨어나서, 그 이벤트를 처리하기 직전에
    6. RunLoop가 종료될 때

    RunLoop에 옵저버를 붙이기 위해서는, Core Foundation의 CFRunLoopObserver 인스턴스를 만들어야 합니다.

    Timer 이벤트와 마찬가지로, RunLoop Observer는 한 번만 쓸 수도 있고, 반복적으로 쓸 수도 있습니다. 일회용 Observer는 자신이 실행된 후에 RunLoop에서 자신을 제거하고, 다회용 Observer는 한번 실행된 이후에도 그대로 남아 있게 됩니다. 이는 Observer 인스턴스를 생성할 때 지정할 수 있습니다.

    • RunLoop의 이벤트 처리 순서

      1. observer에게 RunLoop가 시작한다고 알립니다.
      2. observer에게 곧 Timer 이벤트가 발생할 것을 알립니다.
      3. observer에게 곧 port-based가 아닌 input 이벤트가 발생할 것을 알립니다.
      4. Port-Based가 아닌 input 이벤트 중에서 준비된 이벤트가 있다면, 이를 발생시킵니다.
      5. 만약 준비된 Port-Based input가 있다면 해당 이벤트를 바로 처리한 후 9번으로 갑니다.
      6. observer에게 thread가 곧 잠들 것을 알립니다.
      7. 다음 상황 중 하나가 발생할 때 까지 스레드가 잠자기 상태가 됩니다.
        • Port-Based input 이벤트가 들어왔을 때
        • Timer 이벤트가 발생했을 때
        • RunLoop가 타임아웃 되었을 때
        • RunLoop가 명시적으로 깨워졌을 때
      8. observer에게 스레드가 깨어났음을 알립니다.
      9. 남은 이벤트들을 처리합니다.
        • 사용자 지정 Timer 이벤트가 발생할 경우, 타이머 이벤트를 처리하고 루프를 다시 시작합니다. 2번으로 돌아갑니다.
        • input 이벤트가 발생한 경우, 이벤트가 전달됩니다.
        • RunLoop가 명시적으로 깨어났는데, 아직 타임아웃 되지 않은 경우, 루프를 다시 시작합니다. 2번으로 돌아갑니다.
      10. observer에게 RunLoop가 종료됨을 알립니다.

    이 순서는 macOS의 오픈소스 코드를 기준으로 합니다. iOS의 경우 다른 결과가 나온다는 이야기가 있고, 실제 코드가 공개되지 않았기 때문에,사용을 원한다면 충분한 실험이 필요합니다.

    observer에게 알림이 실제 이벤트 발생 이전에 전달되기 때문에, observer이 알림을 받는 시간과 실제 이벤트 발생 시간사이에 차이가 존재할 수 있습니다. 만약 이벤트 사이의 타이밍이 중요하다면, 잠자기 알림과(sleep notification) 깨어남 알림(awake-from-slepp notification)을 실제 이벤트 사이의 간격을 구하는 데 사용할 수 있습니다.

    Timer 이벤트 및 주기적으로 일어나는 이벤트들이 RunLoop가 실행될 때에야 실제로 전달되기 때문에, RunLoop를 우회하는 것은 이 이벤트들이 전달되는 것을 방해합니다. 이러한 일은 보통 마우스 이벤트를 추적하는 코드를 직접 만들거나 할 때 일어납니다. 사용자 코드가 이벤트를 애플리케이션을 통하지 않고 직접 처리하기 때문에, 스레드의 RunLoop가 이 마우스 이벤트 추적 로직이 끝나야만 일어나게됩니다.

    RunLoop는 명시적으로 깨어날 수도 있고, 다른 이벤트를 통해서도 깨어날 수 있습니다. 예를 들어 Port-Based가 아닌 Input Source를 추가하는 것은 RunLoop가 이벤트를 바로 처리할 수 있게 하기 위해 RunLoop를 바로 깨우게 됩니다.

  • 언제 RunLoop를 사용할 것인가?
    RunLoop를 명시적으로 사용하는 경우는 직접 스레드를 정의해서 사용하는 경우 뿐입니다. 메인 스레드의 RunLoop는 앱의 중요한 기반이기 때문에, 애플리케이션 프레임워크가 직접 시동 단계에서 RunLoop를 구성하고 자동으로 시작하게 됩니다. Xcode 템플릿 프로젝트를 사용한다면, 절대로 메인 스레드의 RunLoop 시동 루틴을 직접 호출해서는 안됩니다.

    직접 스레드를 정의할 경우, RunLoop가 필요한 지를 결정하고, 필요하다면 직접 만들어야 합니다. 모든 경우에 RunLoop를 동작시킬 필요는 없습니다. 만약 오랫동안 동작하고, 미리 지정된 일만을 하는 스레드라면 RunLoop를 동작시킬 필요는 없습니다. RunLoop가 필요한 경우는 다음과 같은 경우입니다.

    • input source를 통해서 다른 스레드와 통신해야 할 때
    • timer를 사용해야 할 경우
    • Perform Selector Source를 사용해야 할 경우
    • 주기적인 일을 계속 수행해야 하는 경우

    RunLoop를 사용하기로 했다면, 설정하는 것은 쉽습니다. 모든 스레드 프로그래밍이 그렇지만, 스레드를 적절히 종료하기 위한 계획은 반드시 있어야 합니다. 스레드는 강제로 종료시키는 것보다, 자연스럽게 종료될 수 있도록 하는 것이 좋습니다.


스레드에 대한 내용이 생각보다 많아서, 4부작으로 구성을 해야할 것 같습니다. 다음 포스트에서는 실제 RunLoop를 사용하는 법에 대해서 알아보도록 하겠습니다.

RunLoop를 실제로 구성하는 것은 Swift 예제를 만드는 것이 제 수준에서는 아직은 어려워, 부득이하게 뒤로 미룹니다. 또한 동기화 기법도 현재 Swift에서 직접 적용하기 어려운 것이 많아 뒤로 미뤄야 할 것 같습니다… 다음 포스트는 이 스레드를 대체할 수 있는 여러 기법들에 대해서 이야기해보겠습니다.