changed 2 years ago
Published Linked with GitHub

main.swift 에서 비동기 처리 시 발생한 문제

문제점 확인

이번 은행 창구 매니저 console App의 main 파일에서 concurrent DispatchQueue를 통해 async하게 Task를 넘기면 실행되지 않고 종료되는 것을 확인할 수 있었다.

또한, Brody와 실험해본 결과 DispatchGroup의 메서드인 notify도 정상적으로 동작하지 않는 것도 확인했다.
(DispatchGroup.notify(queue: .main)일 경우)

Playground에서와는 전혀 다른 실행 양상을 보이는 이유를 알아보기 위해 Run Loop에 대해 공부해보도록 하자!



Run Loop

Apple 개발자 문서에 RunLoop라는 클래스가 있다!!

입력 소스를 관리하는 객체에 대한 programmatic 인터페이스.

Declaration

class RunLoop : NSObject

Overview

RunLoop 객체는 윈도우 시스템 및 Port 객체로부터의 마우스 및 키보드 이벤트와 같은 소스에 대한 입력을 처리한다. RunLoop 객체는 타이머 이벤트도 처리한다.

어플리케이션은 RunLoop 객체를 생성하거나 명시적으로 관리하지 않는다. 시스템은 어플리케이션의 기본 스레드를 포함하여 각 스레드 객체에 대해 필요에 따라 RunLoop 객체를 생성한다. 현재 스레드의 RunLoop에 접근해야 하는 경우 클래스 메서드 current를 사용해야 한다.

RunLoop의 관점에서 Timer 객체는 "Input(입력)"이 아니다. 즉, 특별한 타입이며 실행될 때 Run Loop가 반환되지 않도록 한다.

❗️ 주의
RunLoop 클래스는 일반적으로 thread-safe하지 않으며 현재 스레드의 컨텍스트 내에서만 해당 메서드를 호출해야 한다. 다른 스레드에서 실행 중인 RunLoop 객체의 메서드를 호출하지 말 것. 예상치 못한 결과가 발생할 수 있다.


Topics

class var current: RunLoop { get }
  • current type property
    현재 스레드에 대한 RunLoop를 반환하는 RunLoop 타입 프로퍼티로 현재 스레드에 대한 NSRunLoop 객체를 반환한다.

    스레드에 대한 Run Loop가 아직 존재하지 않으면, 하나가 생성되고 반환된다.

  • Loop 실행하기
    • run()
      연결된 모든 input source의 데이터를 처리하는 동안 receiver(현재 context의 thread로 생각됨)를 영구적인 loop에 넣는다.
    • run(mode: RunLoop.Mode, before: Date) -> Bool
      loop를 한 번 실행하고 지정된 날짜까지 지정된 모드에서 입력을 차단한다.
    • run(until: Date)
      연결된 모든 input source의 데이터를 처리하는 동안 지정된 날짜까지 loop를 실행한다.
    • acceptInput(forMode: RunLoop.Mode, before: Date)
      loop를 한 번 또는 지정된 날짜까지 실행하고 지정된 모드에 대한 입력만 허용한다. (async 사용 불가)

더 알아보기

current 프로퍼티를 사용하면 run loop가 없는 스레드의 run loop를 만들어 줄 수 있다. 하지만 run loop는 자동으로 실행되지 않는다고 한다. 더 알아보기 위해 Apple Developer Documentation의 Threading Programming Guide의 Run Loops 내용을 참고해보자..



Threading Programming Guide

Run Loops

Run Loop는 스레드와 관련된 기본 infrastructure의 일부이다. Run Loop는 작업을 예약하고 들어오는 이벤트 수신을 조정하는 데 사용하는 이벤트 처리 loop이다. Run Loop의 목적은 처리할 일이 있을 때만 스레드가 동작하도록(busy하게) 유지하고 처리할 일이 없을 때 스레드를 정지(sleep)하는 것이다.

Run Loop 관리는 자동으로 이루어지지 않는다. 우리는 적절한 시간에 run loop를 시작하고, 들어오는 이벤트에 응답하도록 스레드의 코드를 설계해야만 한다. Cocoa와 Core Foundation 모두 스레드의 run loop를 구성하고 관리하는 데 도움이 되는 run loop 객체를 제공한다. 어플리케이션은 이러한 객체를 명시적으로 만들 필요가 없다. 어플리케이션의 main 스레드를 포함한 각 스레드는 관련된 run loop 객체를 가지고 있다. 그러나 secondary 스레드만은 run loop를 명시적으로 실행할 필요가 있다. 앱 프레임워크는 어플리케이션 시작 프로세스의 일부로 main 스레드에서 run loop를 자동으로 설정하고 실행한다.

다음 섹션에서는 run loop와 어플리케이션에 대해 run loop를 구성하는 방법에 대한 자세한 정보를 제공한다.


Run Loop 분석

Run loop는 스레드가 들어가서 해당 스레드가 수신하는 이벤트에 대한 응답으로 event handler를 실행하는 데 사용하는 loop이다. 우리의 코드는 run loop의 실제 반복되는 부분을 구현하는 데 사용되는 제어문을 제공해야 한다. 즉, 코드는 run loop를 구동하는 while 또는 for loop를 제공해야 한다. 해당 loop 내에서 run loop 객체를 사용하여 이벤트를 수신하고, 해당 이벤트를 처리하기 위해 설정된 handler를 호출하는 이벤트 처리 코드를 "실행"한다.

Run loop는 두 가지 다른 타입의 소스에서 이벤트를 수신한다. 입력 소스(input source)는 비동기적 이벤트(일반적으로 다른 스레드 또는 다른 어플리케이션의 메세지)를 전달한다. 타이머 소스(Timer source)는 예약된 시간 또는 반복 간격으로 발생하는 동기적 이벤트를 제공한다. 두 타입의 소스 모두 어플리케이션별 handler 루틴을 사용하여 이벤트가 도착하면 이벤트를 처리한다.

아래 그림은 run loop와 다양한 소스의 개념적 구조를 보여준다. 입력 소스는 비동기적 이벤트를 해당 handler에 전달하고 runUntilDate: 메서드(스레드의 연결된 NSRunLoop 객체에서 호출되는 메서드)가 종료되도록 한다. 타이머 소스는 handler 루틴에 이벤트를 전달하지만 run loop가 종료되도록 하지는 않는다.

입력 소스를 처리하는 것 외에도 run loop는 run loop의 동작에 대한 알림(notification)도 생성한다. 등록된 run loop observer는 이러한 알림을 수신하고 스레드에서 추가 처리를 수행하는 데 사용할 수 있다. 스레드에 run loop observer를 설치하기 위해 Core Foundation을 사용하자.


Run loop는 언제 사용해야 할까?

유일하게 Run loop를 명시적으로 실행해야 하는 시기는 어플리케이션에 대한 보조 스레드를 생성할 때이다. 어플리케이션의 main 스레드를 위한 run loop(Main Run Loop)는 infrastructure의 중요한 부분이다. 결과적으로, 앱 프레임워크는 기본 어플리케이션 loop를 실행하기 위한 코드를 제공하고 해당 loop를 자동으로 시작한다. iOS에서 UIApplication(또는 OS X에서 NSApplication)의 run 메서드는 일반적인 시작 시퀀스의 일부로 어플리케이션의 main loop를 시작한다. Xcode 템플릿 프로젝트를 사용하여 어플리케이션을 만드는 경우 이러한 루틴을 명시적으로 호출할 필요가 없다.

보조 스레드의 경우 run loop가 필요한지 여부를 결정하고 필요한 경우 직접 구성하고 시작해야 한다. 보조 스레드를 사용하는 모든 경우에 스레드의 run loop를 시작할 필요는 없다. 예를 들어, 스레드를 사용하여 장기 실행 및 미리 결정된 작업을 수행하는 경우 run loop 실행을 피할 수 있다. Run loop는 스레드와 더 많은 상호 작용을 원하는 상황을 위한 것이다. 아래 내용 중 하나를 수행하려는 경우 run loop를 실행해야 한다.

  • 포트 또는 custom input source를 사용하여 다른 스레드와 소통할 때.
  • 스레드에서 타이머를 사용할 때.
  • Cocoa 어플리케이션에서 performSelector... 메서드를 사용할 때.
  • 주기적인 작업을 수행하기 위해 스레드를 유지할 때.

Run loop를 사용하기로 선택한 경우, run loop에 대한 구성 및 설정은 간단하다. 그러나 모든 스레드 프로그래밍과 마찬가지로 적절한 상황에서 보조 스레드를 종료하기 위한 계획이 있어야 한다. 스레드를 강제로 종료하는 것보다 스스로 종료되도록 하여 스레드를 깔끔하게 종료하는 것을 항상 지향해야 한다.



문제점 분석

야곰닷넷의 예제는 playground에서 잘 실행된다.
하지만 콘솔 프로젝트의 main.swift 파일에서 async 메서드로 보내지는 blue, red DispatchWorkItem은 실행되지 않는다.

// main.swift

import Foundation

let red = DispatchWorkItem {
    for _ in 1...5 {
        print("🥵🥵🥵🥵🥵")
        sleep(1)
    }
}

let yellow = DispatchWorkItem {
    for _ in 1...5 {
        print("😀😀😀😀😀")
        sleep(1)
    }
}

let blue = DispatchWorkItem {
    for _ in 1...5 {
        print("🥶🥶🥶🥶🥶")
        sleep(2)
    }
}

DispatchQueue.global().async(execute: blue)
DispatchQueue.global().async(execute: red)

// 아무것도 실행되지 않음

위 공식 문서 내용을 토대로 생각해보면, 이번 문제점은 아래 흐름대로 발생한 것으로 생각된다.

  1. 우리가 만든 프로젝트는 Xcode 템플릿을 이용하고 있으므로 어플리케이션의 main run loop는 앱 프레임워크 차원에서 자동으로 설정하고 실행되고 있다.
  2. main 스레드는 main run loop에 들어가있으며 실행될 코드가 없다면 loop가 종료되고 프로그램이 종료될 것이다.
  3. 코드에서 보이는 DispatchQueue.global()에 의해 생성된 보조 스레드들은 main 스레드와 input source(async)를 사용해 소통하고 있다.
  4. 따라서, 생성된 보조 스레드의 run loop를 수동으로 설정할 필요가 있다.
  5. 따로 run loop를 설정하지 않았으므로 async 메서드로 전달된 WorkItem은 실행되지 않는다. (보조 스레드가 sleep 상태로 유지됨)
  6. main 스레드는 더 이상 실행할 코드가 없으므로 loop가 종료되어 프로그램이 종료된다.

문제 해결할 수 있을까?

위의 내용은 보조 스레드에 관한 run loop의 설명이다
우리 프로젝트에서 발생한 문제는 과연 보조 스레드가 맞는 걸까..?
확신이 서지 않는다.

그래서 currentrun()을 사용하지 않고 DispatchGroup으로 시도해보니 정상적으로 작동하였다..

이렇게 되면 생성된 각 스레드는 각자의 run loop를 갖고 있는 것이 맞는 것 같다

import Foundation

let red = DispatchWorkItem {
    for _ in 1...5 {
        print("🥵🥵🥵🥵🥵")
        sleep(1)
    }
}

let yellow = DispatchWorkItem {
    for _ in 1...5 {
        print("😀😀😀😀😀")
        sleep(1)
    }
}

let blue = DispatchWorkItem {
    for _ in 1...5 {
        print("🥶🥶🥶🥶🥶")
        sleep(2)
    }
}

let group = DispatchGroup()

DispatchQueue.global().async(group: group, execute: blue)
DispatchQueue.global().async(group: group, execute: red)

group.wait()
group.notify(queue: .global()) {
    print("모든 작업이 끝났습니다.")
}

// 정상적으로 출력됨!!

위에서 설명한 복잡한 흐름이 아니라 단지 main 스레드의 코드가 async 코드보다 먼저 끝나기 때문에 프로그램이 종료된 것이 아닐까 하는 생각이 든다.

notify 메서드의 queue를 main으로 하면 안되는 이유는 나중에 더 알아봐야 할 것 같다..🤔

[참조 문헌]

  1. Apple Developer Documetation - Threading Programming Guide
  2. 개발자소들이 - iOS 런 루프 이해하기
  3. Apple Developer Documetation - RunLoop
Select a repo