본문 바로가기
Learnings/Swift & iOS

Swift 지연실행 실험 - NSTimer, asyncAfter, DispatchSourceTimer

by abcdesong 2021. 1. 27.

 

요즘 부쩍 코드를 지연하여 실행시켜야 하는 일이 많아서 Swift Timer, Swift Delay 등의 키워드를 자주 찾아보았다.

 

그러면서 Swift엔 Timer(NSTimer), asyncAfter(DispatchQueue), DispatchSourceTimer 라는 딜레이 방식들이 있다는 걸 알게 되었다.

 

이것들이 어떻게 다른 지 한 번쯤 정리를 하고 싶었는데, 동작 원리까지 상세히 보기엔 내용이 too much라 우선은 패스하고.. 단순 비교를 통해 각각의 이모저모(?)를 살펴보려고 한다.

 


 

* Xcode의 커맨드 라인 툴(macOS 플랫폼 - Command Line Tool)에서 실험하였습니다.

 

1. 기본 실행

우선 각 딜레이의 기본 동작 방식은 아래와 같다. asyncAfter - dispatchSourceTimer - Timer 순으로 작성이 되어있지만, 딜레이 시간이 짧은 건 역 순이라,

Timer - dispatchSourceTimer - asyncAfter 순으로 실행이 될 것이다.

import Foundation

//asyncAfter
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(3)) {
    print("난 asyncAfter")
}

//DispatchSourceTimer
let timer = DispatchSource.makeTimerSource()

timer.schedule(deadline: .now() + .seconds(2))
timer.setEventHandler {
    print("난 dispatchSourceTimer")
}
timer.activate()

//NSTimer
Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { (timer) in
    print("난 timer")
}

print("Hi")

 

실행을 해보면..

 

실행결과1

 

?!! 딜레이를 주지 않은 print("Hi")만 실행이 된 것을 알 수 있다.

딜레이를 기다리지 않고 프로그램이 종료되어 버렸다.



딜레이 기다리기 - RunLoop

구글링 결과, 딜레이 후 작업이 진행될 때까지 프로그램을 run하기 위한 방법 중 하나는 RunLoop였다.

RunLoop.current.run() 

 

애플 공식 문서(Threading Programming Guide)에서는 다음과 같이 설명하고 있다.

thread에게 일거리가 있을 땐 일하도록, 없을 땐 쉬도록 하는 것이 run loop의 목적이다.

The purpose of a run loop is to keep your thread busy when there is work to do and put your thread to sleep when there is none.

current의 current란 현재 스레드를 의미한다. 없으면 생성한다고 함.

(뭔가 주의해야한다는 말이 종종 써있는데 - not thread safe 같은 것들 - 아직 제대로 공부하지 않아서.. 더 자세히 알게 되면 또 다시 정리를 해보는 것으로🙃)

 

실행결과2

이제 제대로 실행이 되는 것을 확인할 수 있다.

 

RunLoop 사용 시 종료 시그널을 따로 보내주지 않는 이상 자동 종료가 되지 않는다.

 

따라서 가장 딜레이가 긴 asyncAfter에 exit(EXIT_SUCCESS)를 추가해주어 모든 태스크가 끝나고 프로그램이 종료될 수 있도록 하였다.

 

딜레이 기다리기 - dispatchMain

비슷한 기능을 하는 것으로, dispatchMain()이라는 것도 종종 눈에 띄었다. RunLoop 대신 dispatchMain()을 써주면,

 

실행결과3

 

😯 Timer가 홀랑 빠졌다! 애플의 설명은 다음과 같다. (링크 들어가셔도 사실 별 게 없을 거긴 합니다,,)

main queue의 블록들을 실행시킨다.

Executes blocks submitted to the main queue.

main queueDispatch 라이브러리에 속해 있는 것이고, 그 라이브러리와 관련이 없는 Timer는 버려지고 만 것. Timer의 태스크는 main queue와 다른 목록에 적재되어 있을 것이다.

 

(다만, asyncAfter은 global()에 추가한 것임에도 실행이 되었다. 의문이 한두가지가 아닌데 우선은 넘어 가는 것으로😅 혹시 누군가 아신다면 댓글 대환영입니다..)

 

그리고 이 역시도 따로 종료 시그널을 주지 않는 이상 자동 종료되지 않는다.



딜레이 기다리기 - 끝낼 수 없는 상태

심플하게 프로그램이 못 끝나는 상황을 만들어서도 위의 딜레이들을 기다릴 수 있다.

예를 들면, 이와 같이 3초 안에 안 끝나는 작업을 맨 아래에 추가하면

var hoho = 0
for i in 1...10000000000000 { hoho += i }

 

실행결과4

 

😯 이번에도 Timer가 홀랑 빠졌다!

Timer는 반드시 RunLoop를 필요로 한다는 결론을 얻으며.. 다음으로 넘어가 봅니다.



2. 실제 딜레이 시간?

문득 이 딜레이들이 얼마나 정확하게 시간을 지키는 지 궁금해졌다.

그래서 시간 계산을 위해 시작 시간을 저장한 뒤, 해당 출력이 실행되는 시간과의 차를 구해 함께 출력해보았다.

 

우선 실행 코드는 다음과 같고,

import Foundation

let startTime = Date().timeIntervalSince1970 //시작 시간 저장

//asyncAfter
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(3)) {
    print("난 asyncAfter ⏰\(Date().timeIntervalSince1970 - startTime)")
    exit(EXIT_SUCCESS)
}

//DispatchSourceTimer
let timer = DispatchSource.makeTimerSource()

timer.schedule(deadline: .now() + .seconds(2))
timer.setEventHandler {
    print("난 dispatchSourceTimer ⏰\(Date().timeIntervalSince1970 - startTime)")
}

timer.activate()

//NSTimer
Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { (timer) in
    print("난 timer ⏰\(Date().timeIntervalSince1970 - startTime)")
}

print("Hi ⏰\(Date().timeIntervalSince1970 - startTime)")

RunLoop.current.run()

 

결과는 다음과 같다.

 

아싱크느림

 

아싱크제대로

 

TimerDispatchSourceTimer는 체감할만큼의 오차는 없었던 반면,

asyncAfter는 버벅인다고 느낄 정도의 오차가 종종 있었다. 현재 캡처본만 봐도 0.3초 내외의 추가 딜레이가 발생했다.

 

3번 중 1번 정도는 0.2초 이상 늦게 출력되었다. 정확도를 위해서는 TimerDispatchSourceTimer를 사용하는 편이 나을 수 있겠다.

 

(스포: DispatchSourceTimer에선 이 딜레이를 조정할 수가 있다는데..? (뒷내용 참고))



3. 지연 반복 실행

마지막은 반복적으로 지연 실행시키는 방법에 대해서다.

이번엔 하나씩 살펴보도록 하겠다.

 

Timer

Timer의 이니셜라이저엔 repeats가 포함되어 있다.

위에서처럼 1회성 사용을 위해서라면 false를, 타이머를 반복하고 싶다면 true를 설정하면 된다.

그러면 설정한 TimeInterval 만큼이 반복적으로 지연된다.

import Foundation

let startTime = Date().timeIntervalSince1970

Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (timer) in
    print("난 timer 🌼\(Date().timeIntervalSince1970 - startTime)")

    if Date().timeIntervalSince1970 - startTime > 7 {
        timer.invalidate() //타이머 종료
        exit(EXIT_SUCCESS)
    }
}

RunLoop.current.run()

 

false의 경우 타이머가 자동으로 사라지지만, true의 경우 invalidate()해주어야만 타이머를 소멸시킬 수 있다. 여기서는 시작 후 7초가 넘어가면 종료하는 것으로 설정하였다.

 

timer

 

1초에 한번 씩 타이머가 실행된 후 프로그램이 종료되는 것을 확인할 수 있다.



DispatchSourceTimer

DispatchSourceTimer 역시 반복 기능을 제공한다. schedule()의 이니셜라이저 중엔 repeating이 포함된 것이 있다.

repeating의 인자 값으로는 Double 또는 DispatchTimeInterval를 지원하는데, 아래 코드의 .seconds 등이 바로 DispatchTimeInterval 중 하나다. enum으로 정의되어 있다.

 

Timer와는 조금 다른 반복 방식을 제공한다. deadline 시점에 처음 실행되고, 이후엔 설정한 repeating 시간마다 실행된다.

let timer = DispatchSource.makeTimerSource()

timer.schedule(deadline: .now(), repeating: .seconds(2))
timer.setEventHandler {
    print("난 dispatchSourceTimer 🥕\(Date().timeIntervalSince1970 - startTime)")
    if Date().timeIntervalSince1970 - startTime > 6 {
        timer.cancel()
        exit(EXIT_SUCCESS)
    }
}

timer.activate()

 

0초대 실행 이후 2초마다 실행되는 것을 확인할 수 있다. 6초 이후엔 타이머를 cancel하고 종료하도록 하였다.

cancel()을 통해 타이머를 취소할 수 있다.

dst

 

추가적으로, leeway라는 것이 눈에 띄어서 애플 문서를 봤더니 딜레이 오차를 조정할 수 있는 인자였다.

deadline 이후, 다음 타이머에 도달하기 전까지 시스템이 발생시킬 수 있는 딜레이의 최댓값을 말한다.

시스템은 소비 전력이나 시스템 퍼포먼스를 개선하기 위해 타이머 이벤트의 실행 시간을 조정할 수 있다.

The maximum amount of time after deadline by which the system may delay the delivery of the timer event (...) The system may defer the deliver of timer events to improve power consumption and system performance.

 

자세한 계산 값 역시 첨부되어 있다.

지연될 수 있는 최댓값은 min(leeway, repeating/2) 이다.

maximum delay is equal to min(leeway, repeating/2)

 

딜레이를 눈에 띄게 확인해보기 위해 leeway의 인자 값으로 5초를 주었다.

timer.schedule(deadline: .now() , repeating: .seconds(3), leeway: .seconds(5))

 

leeway

 

min(leeway, repeating/2)에 맞추어 생각해보면, 일어날 수 있는 추가 딜레이는 min(5, 1.5)로 1.5초가 되며,

0초 대 실행 이후 4.5초에 다음 실행이 발생하였으니 1.5초의 딜레이가 발생한 것을 확인할 수 있다!



asyncAfter

마지막으로 asyncAfter는, 인자를 통하여 반복 설정을 할 수는 없는 것처럼 보인다.

while 등 반복문에 넣어서 반복 실행을 시켜줄 수는 있겠지만, 그럴 경우 루프에서 탈출하지 않는 이상 반복문 이후에 작성된 코드들은 실행될 수가 없다. (비동기인 asyncAfter를 동기로 묶어버리는 셈!)

while

 

흐름 차원에서 최선의 방법은 재귀적으로 호출하는 것이 아닐까 싶다. 이 경우 이후에 위치하는 코드들도 동시 실행이 가능하다.

함수로 만들었기 때문에 타이머를 종료하려면 return 시켜버리면 된다.

asyncAfter()

func asyncAfter() {
    DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(2)) {
        print("난 asyncAfter 🍎\(Date().timeIntervalSince1970 - startTime)")
        if Date().timeIntervalSince1970 - startTime > 6 { return }
        asyncAfter()
    }
}

 

recurtive

6초 이후 재귀 호출이 끝난다.

 

다만, 재귀적으로 호출할 경우 메모리에 부담이 가지 않을까 하는 의심이 있는데... 이 역시도 차차 공부를 해봐야겠다.




 

마치며...

항상 느끼지만 공부는 공부를 낳는다.

그래서 뭐 하나 공부하려다 보면 끝없이 가지가 뻗어 나가서 출발 지점을 잊을 때가 많다.

이번엔 최대한 다른 데로 안 빠지려고 엄청 애쓰다 보니 글 중간 중간에 다음 번에 제대로 가 너무 많다 하하..🙃

 

공부할 게 늘어날 때마다 생각나는 짤을 첨부하며 글을 마무리합니다.

혹시나 여기까지 읽으신 분이 계시다면...

 

R720x0 q80

영원히 같이 공부해요 흐흐😈

댓글