[Swift] Timer + RunLoop, backgroundQueue (swift-corelibs-foundation)
[Swift] Timer + RunLoop, backgroundQueue (swift-corelibs-foundation)
서비스 및 기능에 따라서 RunLoop.main에서 돌아가게 하면 안되는 케이스도 존재. 잘못 설계할 경우 Timer기반으로 동작하는 로그나 기능 등 예상치 못한 결과를 낼 수 있음.
RunLoop.main에서 타이머가 아예 방출되지 않거나 RunLoop.main이 아닌 다른스레드에서 돌려야 하는 경우에 대하서 자세히 알아볼 예정
위 케이스들을 내부 구현 코드를 통해 근거 있게 정리해보고자 함.
글의순서
- 타이머 알아보기
- swift-corelibs-foundation내에 Timer 구현 알아보기
- Timer 선언부
- Timer scheduledTimer 함수
- RunLoop 간단히 알아보기
- CFRunLoopActivity 정리
- RunLoop.main에서 스크롤 중에 타이머가 방출되지 않는 이유
- Timer 사용 예제를 통해 알아보기 (Timer DispatchQueue.main vs RunLoop.main)
- Timer 사용 예제 (start_timer_default_correct_case)
- Timer 사용 예제 (start_timer_global_queue_wrong_case)
- Timer 사용 예제 (start_timer_global_queue_correct_case)
- 샘플 코드 전체
타이머 알아보기
타이머를 보면 위처럼 몇가지 오픈된 인터페이스를 확인할 수 있음.
swift-corelibs-foundation내에 Timer 구현 알아보기
swift-corelibs-foundation 내부에서 Timer가 구현된 코드를 직접 보자.
Timer 선언부
Timer는 CFRunLoopTimer에 의해서 구현되어 있음
- CFRunLoopTimer에 대한 정의는 RunLoop에 되어 있음
- 이로 인해서 우리가 Timer를 RunLoop에서 써야한다고 흔히 말함.
- 꼭 RunLoop가 아니여도 동작하고, 상황에 따라서 RunLoop를 피해야 하는 경우도 존재
- _timer에서 chicken/egg problem가 언급
- _timer가 get을 통해 _timerStorage에서 값을 가져오고 있으나, 초기화 시점에 _timerStorage에 접근하는 순간 런타임 에러 발생
- _timer를 optinal로 처리하여 초기화가 완료되기 전까지 nil로 처리하고, 나중에 값이 할당되면 그 때 사용할 수 있게 만듦.
Timer scheduledTimer 함수
scheduledTimer 함수를 주로 많이 쓸 건데 내부 구현을 보면 CFRunLoopAddTimer로 되어있음.
즉, RunLoop에 의존적인걸 확인할 수 있음.
CFRunLoopAddTimer는 CFRunLoop에서 C 기반으로 구현되어 있음.
- 해당 코드는 RunLoop에 Timer를 옵저빙 가능하도록 추가함.
RunLoop 간단히 알아보기
RunLoop도 하나의 대주제라서 추후 다른 포스팅에서 정리할 예정이지만, 해당 포스팅에도 필요한 개념들이 있어서 간략하게 소개.
CFRunLoopActivity 정리
CFRunLoopActivity를 먼저 이해해야 RunLoop.main에서 왜 타이머가 방출되지 않는지 알 수 있음.
- kCFRunLoopEntry
- CFRunLoopActivity.entry에 해당하는 값으로, 런루프가 시작될 때의 상태를 나타냄.
- 런루프가 막 시작하여, 어떤 이벤트를 처리하기 위해 진입하는 순간
- kCFRunLoopBeforeTimers
- 타이머가 실행되기 직전의 상태를 나타냄.
- 런루프가 등록된 타이머를 실행하기 직전에 해당 상태.
- kCFRunLoopBeforeSources
- 소스(source)를 처리하기 직전의 상태
- 소스는 입력 소스(키보드 입력, 네트워크 소켓 등)나 사용자 정의 이벤트 처리 코드일 수 있음.
- kCFRunLoopBeforeWaiting
- 런루프가 대기 상태에 들어가기 직전의 상태.
- 런루프는 일정 시간 동안 이벤트가 없으면 대기 상태로 들어가며, 이 대기 상태가 되기 직전 상태를 나타냄.
- kCFRunLoopAfterWaiting
- 대기 상태에서 깨어난 직후의 상태
- 런루프가 이벤트나 타이머에 의해 깨워져서 다시 활동을 재개하는 상태를 나타냄
- kCFRunLoopExit
- 런루프가 종료되는 시점을 나타냄
- 런루프가 완료되고 종료되는 순간의 상태
- kCFRunLoopAllActivities
- 모든 활동 상태를 나타내는 값으로, 런루프의 모든 활동 상태를 나타냄.
- 이를 사용하면 런루프의 모든 상태 변화를 모니터링할 수 있음.
RunLoop.main에서 스크롤 중에 타이머가 방출되지 않는 이유
RunLoop는 특정 시점(이벤트 발생 전후)에 호출되는 observer를 등록할 수 있음
이것은 위에서 본 `CFRunLoopActivity`에 의해서 정의 되어 있음.
CFRunLoopRun 중 특정 코드를 자세히 보면
RunLoop가 `대기` 상태에 들어가기 직전에 등록된 observer를 호출.
즉, RunLoop가 대기 중(즉, 아무 이벤트가 발생하지 않을 때)으로 전환되기 전에 모든 observer가 호출된다.
Timer 사용 예제를 통해 알아보기 (Timer DispatchQueue.main vs RunLoop.main)
스크롤 뷰에 스타트 버튼을 눌러 타이머를 실행시켜서 확인해 볼 예정.
전체 샘플 코드는 포스팅 제일 하단에 첨부.
테스트 케이스
- 일반적인 타이머 사용 (start_timer_default_correct_case)
- `DispatchQueue.global`에서 잘못된 사용(start_timer_global_queue_wrong_case)
- `DispatchQueue.global`에서 올바른 사용(start_timer_global_queue_correct_case)
- 함수 네이밍은 snake_case 사용했는데 초기에 XCTest에 작성해서 포스팅을 위해 SwiftUI로 이전
타이머가 RunLoop.main과 DispatchQueue.main에 따라 어떻게 동작하는지 살펴볼 예정.
Timer 사용 예제 (start_timer_default_correct_case)
스크롤을 한다면 어떻게 동작할까?
Timer는 RunLoop에서 동작하므로 스크롤 중에는 RunLoop waiting 중이 아니므로 Timer 자체에서 방출되는게 없음.
구분 | function | .receive(on: RunLoop.main) | .receive(on: DispatchQueue.main) |
스크롤 하지 않을 때 | ✅ | ✅ | ✅ |
스크롤 중 | ❌ | ❌ | ❌ |
Timer 사용 예제 (start_timer_global_queue_wrong_case)
RunLoop가 아닌 곳에서 사용하려면 DispatchQueue를 활용해보자.
아래처럼 작성하면 잘못 작성한 코드임.
CFRunLoop 내부 코드를 보면 해당 코드의 이유를 발견할 수 있음.
main thread의 경우에서 애플이 암시적으로 RunLoop를 Active된 상태.
background thread에는 RunLoop가 명시적으로 Active가 아니므로 else if 문에 의하여 아예 동작하지 않음.
구분 | function | .receive(on: RunLoop.main) | .receive(on: DispatchQueue.main) |
스크롤 하지 않을 때 | ❌ | ❌ | ❌ |
스크롤 중 | ❌ | ❌ | ❌ |
Timer 사용 예제 (start_timer_global_queue_correct_case)
다른 스레드에서 올바르게 사용하는 방법에 대해서 알아보자.
- runLoop.add하고 run해주고 있음.
- RunLoop.current가 아닌 RunLoop.main으로 사용한다면 동작하지 않음.
- 반드시 타이머 명시적으로 제거해줘야 함.
아래는 RunLoop.current와 RunLoop.main의 차이에 대해서 분석
- current mode 부분에서의 약간의 차이
아래 로그를 보자
- (스크롤 전) 파란색 영역 function, DispatchQueue, RunLoop 순서로 모두 출력
- (스크롤 중) 초록색 영역 function, DispatchQueue 출력 (RunLoop 미출력)
- (스크롤 끝) 빨간색 영역 버퍼에 쌓인 RunLoop 모두 한번에 출력
- (스크롤 전) 이후는 다시 function, DispatchQueue, RunLoop 순서로 출력
결과를 도표로 그려보면
구분 | function | .receive(on: RunLoop.main) | .receive(on: DispatchQueue.main) |
스크롤 하지 않을 때 | ✅ | ✅ | ✅ |
스크롤 중 | ✅ | ❌ (가능할 때 한번에 방출) | ✅ |
위와 같은 결과가 나온 이유는 해당 background 스레드에서 RunLoop를 currnet로 하였음.
- 스크롤을 처리하는건 RunLoop.main
- 타이머가 방출되는건 RunLoop.current
- 바인딩된 영역에서 받는건 RunLoop.main이라서 buffer에 축적.
- RunLoop.main 스케줄러가 방출 가능할 때 한번에 방출
이로 인하여 위와 다르게 이벤트가 한번에 여러번 뱉어내지는 결과 확인 가능.
샘플 코드 전체
class TimerCheckViewModel: ObservableObject {
let runloop = PassthroughSubject<Date, Never>()
let queue = PassthroughSubject<Date, Never>()
var cancellables = Set<AnyCancellable>()
init() {
runloop
.receive(on: RunLoop.main)
.sink { date in
print("RunLoop Timer fired at: \(Date())")
}.store(in: &cancellables)
queue
.receive(on: DispatchQueue.main)
.sink { date in
print("DispatchQueue Timer fired at: \(Date())")
}.store(in: &cancellables)
}
/// 테스트 case 1
func start_timer_default_correct_case() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self else { return }
let date = Date()
print("function \(date)")
self.runloop.send(date)
self.queue.send(date)
}
}
/// 테스트 case 2
func start_timer_global_queue_wrong_case() {
print("start_timer_global_queue_wrong_case start")
DispatchQueue.global(qos: .background).async {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self else { return }
let date = Date()
print("function \(date)")
self.runloop.send(date)
self.queue.send(date)
}
}
}
var global_timer: Timer? = nil
/// 테스트 case 3
func start_timer_global_queue_correct_case() {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self else { return }
global_timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in
guard let self else { return }
let date = Date()
print("function \(date)")
self.runloop.send(date)
self.queue.send(date)
}
let runLoop = RunLoop.current
runLoop.add(global_timer!, forMode: .common)
runLoop.run()
}
}
func stop_global_timer() {
global_timer?.invalidate()
global_timer = nil
}
}
struct TimerCheckView: View {
@StateObject var viewModel = TimerCheckViewModel()
var body: some View {
ScrollView {
Button {
//viewModel.start_timer_global_queue_correct_case()
//viewModel.start_timer_default_correct_case()
//viewModel.stop_global_timer()
} label: {
Text("start")
}
Button {
viewModel.stop_global_timer()
} label: {
Text("stop")
}
ForEach(0...1000, id: \.self) { i in
HStack {
Spacer()
Text("\(i)")
Spacer()
}
}
}
}
}
#Preview {
ContentView()
}