
우리가 프로젝트를 만들게 되면 아마 내부적으로는 이렇게 보일거에요
앱이 있고, 앱은 메인 스레드를 가지게 됩니다.
그리고 메인스레드는 User Interface를 power해주는 모든 코드의 실행을 담당하게 돼요.
그렇게 앱에 코드를 많이 추가하기 시작하다보면 앱의 퍼포먼스가 꽤 크게 변하는 걸 발견하게 됩니다.
예를들자면 data를 변환하는 이미지 프로세싱 같은 큰 작업이 메인 스레드에 올려져 있다면 User Interface가 고통 받는 걸 보게 돼요.
mac os에선 이런 경우에 Spinning wheel이 나올거구
iOS에선 앱이 느려지거나 아예 멈추는 경우도 발생합니다.
(linked In 이나 Mandarin 로딩 느려지는 거 보여주기)
그래서!! Concurrency를 앱의 여러 부분들에 적용하게 되고,
GCD를 사용하게 됩니다
GCD에서 사용하게 되는 Dispatch Queue가 어떻게 동작하는지
그림으로 한번 먼저 보겠습니다.
작업 항목들을 해당 Queue에 클로져 형태로 등록하고,
나를 위해서 일해줄 thread까지도 가져와주는 게 Dispatch 큐에요.
( 자막용 WWDC 세션에서 Thread에 대한 이야기가 나올 때마다
항상 이런 실타래가 나오던데 Thread에 또다른 단어 뜻으로 요런 게 있더라구요 그래서 Thread를 이런 실타래로 표현하는 것 같아요 ㅋㅋ)
그리고 Dispatch큐는 thread에서의 모든 일의 실행이 끝나면
우리를 위해서 일해주던 일꾼 thread를 나 대신 종료해주는 것까지도 해줍니다.
앞으로 공부해갈 내용인데
Dispatch Queue의 type에는
메인큐, 글로벌큐, 커스텀 큐가 있어요.
오늘은 메인큐에 대해서 알아볼건데
이 메인 큐를 알기 위해선 이게 동작하게 되는
메인스레드를 잘 알아야합니다
아까 처음에 Main Thread라는 게 나왔었잖아요
이 Main Thread는 조금 특별해요.
Main Queue 뿐만 아니라 Main Run Loop라는 것도 함께 가지게 됩니다.
또 새로운 게 나왔어요..
근데 이런게 있다고만 아시면 될 것 같아요.
메인 런 루프는 사용자의 이벤트, (터치나 키입력) 같은 것도 감지하고, 타이머 관리도 하고, 이외에도 네트워킹과 관련된 작업에서 소켓 이나 포트 처리같은 것도 담당해요.
메인 스레드는 Main Run Loop와 Main Queue를 함께 가진다!는 사실
이 내용을 알고나니까 제가 Combine을 사용할 때 가졌던 의문점이 해소가 되더라구요.
콤바인에서 .receive(on:) 을 쓰게 될 때 DispatchQueue.main을 작성 하기도 하고, 종종 RunLoop.main을 작성해도 똑같이 동작한다는 걸 발견했었거든요.
결국 내부적으로 메인스레드는 이 둘다를 함께 가지기 때문에 가능했던 거였어요.
다시 정리하자면 Main Run Loop는 반복적으로 동작하면서 터치나 유저의 입력 같은 이벤트를 처리하고, 이를 통해서 앱의 UI를 반응적으로 동작하게 해주는 객체 입니다.
디스패치 큐에서, 그러니까 지금은 메인 큐겠죠? 작업을 관리하고 실행할 때, 메인 큐에 추가된 작업은 Main Run Loop에 의해서 "순차적"으로 실행하게 돼요.
방금 뭐라고 했죠? 순차적으로 실행하게 된다고 했어요
순차적이다...
뭐였죠!!
네 맞추셨다면 당신은 백점.
그래서 메인 스레드에서 동작하는 메인큐는 Serial 큐의 특성을 가지게 됩니다.
또 한 가지 알아야 되는 중요한 사실이 있는데
UI를 업데이트하는 작업들은 메인 스레드에서만 이뤄줘야 한다는거에요.
DispatchQueue.main.async { }
요런 코드 사용했던 경험있죠?
백그라운드 스레드에서 네트워킹을 통해 데이터를 가져오는 로직들이 있었다고 해볼게요.
근데 이 로직이 UI를 업데이트 해야될 경우에는
지금처럼 Main Queue로 보내서 Main Thread에서 실행되게 해줬어요.
이런 의문이 들지 않아요?
왜 스레드를 많이 쓸 수 있는데 굳이굳이
앱을 실행할 때 만들어지는 메인스레드에서 UI를 업데이트 시켜줘야하는지?
여러가지 이유들이 있겠지만 첫 시간에 CPU에 대해서 이야기를 했었죠? CPU의 퍼포먼스 관점에서 이야기를 해볼게요.
CPU에는 독립적인 연산장치인 코어가 있었어요.
아 참 M1 CPU 코어에는 (고성능의 P 코어) (전력의 효율을 담당하는 E 코어) 이렇게 구성이 되어있다고해요.
아무튼 이 코어에서 어떤 작업을수행해야된다! 라고 했을 때 최적화를 한다고 생각해보자구요.
이상적인 어떤 초전도체의 세상에선
코어 0에 있는 작업을 4등분 분배해서 각각을 병렬적으로 실행하면 될거에요.
그러면 시간은 4배로 빨리 실행할 수 있게 되겠죠?
근데 현실은 그렇지 않아요.
코어 0에 모든 task가 있었을 때
우리의 코어 1, 2, 3은 아무 작업도 없었겠죠?
그럴 때 에너지를 아끼기위해서 idle 상태로 코어가 잠들게 돼요.
그래서 다시 활성화 시키기 위해서 우선 첫번째로 조금의 시간이 걸리게 됩니다.
이게 첫번째 코어 깨우는 시간에 대한 cost
이렇게 깨어나고 나면 스케줄러는 어떤 프로세스와 스레드가 다음에 실행되어야 할지, 어떤 코어에서 실행되어야할지 정해야겠죠?
여기서 두번째로 조금의 시간이 걸리게 됩니다.
그리고 세번째, 스레드들이 서로 정보를 주고받아야할 경우에 "세마포어" 라는 것을 사용하는데, 간단히 말하면 한 번에 하나의 스레드만 접근 가능하게 하는 제어 기술이에요!
이 세마포어를 사용할 때 한 스레드가 다른 스레드에게 이제 작업을 해도 돼! 라는 신호를 보내게 돼요. 근데 이게 이 신호가 즉시 바로 전달되지는 않는 거죠. 이 사이에 작은 지연이 발생하게 됩니다. 이걸 동기화 지연 시간이라고 이야기해요.
분명 효율적인 작업을 위해서 일을 분배했는데
생각보다 드는 비용이 많죠?
실제 장치로 보면 코어0에서
소프트웨어 관점에서는 메인 스레드에서 UI를 업데이트 하지 않고
다른 스레드에서 UI를 업데이트까지도 하게 된다면
코어 1,2,3이 깨어있어야 하니까 전력의 비효율도 생기구요,
이 에너지를 소모하는데에 있어서 발열도 생길거에요.
그리고 코어0에서 작업을 하다가, 코어1에서 작업을 하다가, 코어2에서 작업을하다가 코어3에서 작업을 하다가... 코어를 직접 일하게 하는 Context Switching이 발생할거구요. 이 나눠진 작업들이 현재 어디까지 진행됐는지 장부도 들고 있어야겠죠.
반면에 Main Thread에서만 UI를 업데이트를 하겠다!
백그라운드 스레드에서 동작하던 작업을 Main Thread로 보내주는
DispatchQueue.main.async로 보내는 경우도 context switching이라고 볼 수 있어요. 어 그럼 똑같은 거 아닌가? 라는 생각이 들죠
지금 같은 경우의 Context Switching에드는 비용은 아까처럼 CPU 코어들이 직접 일하게 하는 Context Switching에 비해서는 훨씬 적다고 해요.
작업을 메인 큐에 추가만 하면 되고, 실제로 작업이 실행되는 것을 메인 스레드가 다음 루프 사이클에서 결정하기 때문에 CPU 코어간의 직접적인 컨텍스트 스위칭보다 효율적입니다.
정리해본다면 메인 스레드에서만 UI 업데이트를 하는 것은 전력소모, 발열, Context Switching 등의 비용을 고려할 때 굉장히 효율적인 방식이라는 걸 알 수 있죠
자 그럼 Main 스레드에서만 UI를 왜 업데이트 해야 되지? 누가 물어본다면
뭐 CPU코어의 cost 같은 것도 얘기하면서 세마포어 이야기도 하면서
그런게 있다!라고 하면되겠죠
Dispatch Queue의 종류중 하나인 Main 큐에 대해 이야기 하기 위해서 메인 스레드에 대한 설명이 필요했구, 왜 메인 스레드가 UI를 업데이트 해야만 하는지 이야기도 해봤어요.
코드로 가볼게요
print("현재 메인스레드인가요?")
if Thread.isMainThread {
print("네 메인스레드에서 동작중입니다")
print("🧵",Thread.current)
} else {
print("아니요 메인스레드 아님!")
}
우리가 평소에 작성하게 되는 코드들은 메인 스레드에서
동작하게 되구요.
그리고 DispatchQueue를 사용해볼건데
메인큐로 보내볼거에요.
디스패치 큐가 큐로 보낸다! 라는 뜻이잖아요?
그럼 이런 의문이 들지 않아요?
누가보내지?
print("🟢 🧵",Thread.current)
DispatchQueue.main.async {
if Thread.isMainThread {
// 메인큐로 작업 예약할 거고, 이 작업이 완료되길 기다리지 않겠음
print("🟢 메인 큐에 예약된 작업 실행")
}
}
print("🙋🏻 메인큐로 보내진 작업이랑 지금 프린트랑 어떤 게 먼저 실행될까..?")
메인 스레드의 입장에서 메인큐로 작업을 보내는데
메인 스레드는 이 작업이 완료되길 기다리지 않겠다~
이번에는 메인 큐가 아니라 Global 큐로 한번 보내볼게요
print("🧵","\(Thread.current)")
DispatchQueue.global().async {
print("🔵 🧵","\(Thread.current)")
if !Thread.isMainThread {
print("🔵 현재 메인 스레드 아님 백그라운드 스레드에서 실행 중")
}
}
print("🙋🏻 글로벌큐로 보내진 작업이랑 지금 프린트랑 어떤 게 먼저 실행될까..?")
메인 스레드가 아닌 스레드에서 작업이 실행이 될때는
백그라운드 스레드라고 이야기를 할게요
어 근데 메인큐에 예약된 작업이 마지막에 찍혔네요?
여기서 조금 헷갈리게 되는데
지금 찍힌 print문을수를 많이 찍어보면 이해가 될거에요
1000번 찍어봅시다잉
현재 메인 큐에 예약되어 있는 작업은 메인스레드에서
글로벌 큐에 예약되어 있는 작업은 백그라운드 스레드 에서
병렬적으로 실행이 된다는 걸 알 수 있어요.
이걸로 알 수 있는 건? 글로벌 큐는 Concurrent 큐이구나!
라는 걸 알 수 있죠?
한번 더가볼게요
메인큐에 async 네모 동그라미로 예약을 해볼게요
그리고 Global 큐로도 작업을 예약을 하나 더 추가해주고,
주황색 동그라미로 표시를 할게요
메인큐는 아까 어떻다고 했었죠?
메인큐는 Serial 큐이다.
그러니까 초록색 동그라미 작업이 먼저 실행이 되고 그다음에 있는 초록색 네모 작업이 실행이 되죠.
반면에 Global 큐는 Concurrent 큐이기 때문에
각각의 백그라운드 스레드를 가지고 병렬적으로 작업을 실행하게 됩니다!!
자 이번에는 글로벌 큐에 sync로,
그러니까 이 작업이 완료되기를 기다리겠다!라고 해볼게요
print("🧵","\(Thread.current)")
DispatchQueue.global().sync {
print("🔵 🧵","\(Thread.current)")
if !Thread.isMainThread {
print("🔵 현재 메인 스레드 아님 백그라운드 스레드에서 실행 중")
}
}
print("🙋🏻 글로벌큐로 보내진 작업이랑 지금 프린트랑 어떤 게 먼저 실행될까..?")
생각해보면 퍼포먼스 최적화의 관점에선 당연합니다
야 어짜피 순서대로 할건데 백그라운드 스레드로 안보내고 메인 스레드에서 할게~
DispatchQueue.global().async {
print("🔵 🧵","\(Thread.current)")
if !Thread.isMainThread {
print("🔵 현재 메인 스레드 아님 백그라운드 스레드에서 실행 중")
}
DispatchQueue.global().async {
print("🟦 🧵","\(Thread.current)")
for _ in 0...1000 {print("🟦 async 코드 실행")}
}
DispatchQueue.global().sync {
print("🔵 🧵","\(Thread.current)")
for _ in 0...1000 {print("🔵 싱크 코드 실행")}
}
print("---> 글로벌큐")
}
그럼 반대로
이번엔 백그라운드 스레드안에서
메인 큐로 작업을 보내볼게요
작업이 완료되길 기다리는 sync 작업과
작업이 완료되길 기다리지 않는 async 작업을 같이 보내볼게요
Dispatch Queue는 뭐를 신경쓰면 된다구요?
주체가 누구인지! 파악하면 된다!!
// global().async 안에서
DispatchQueue.main.async {
print("✅ 🧵","\(Thread.current)")
for _ in 0...1000 {print("✅ a싱크 코드 실행")}
print("----------> a싱크 코드 종료")
}
DispatchQueue.main.sync {
print("🟩 🧵","\(Thread.current)")
for _ in 0...1000 {print("🟩 싱크 코드 실행")}
print("----------> 싱크 코드 종료")
}
print("---> 글로벌큐")
sync는 보내는 작업의 코드 앞뒤를 파악하면 돼요!
누가 기다리죠? 백그라운드 스레드가 기다립니다!
메인 큐로 보내진 sync 작업이 메인 스레드에서
네모를1000번 찍을동안
현재의 백그라운드 스레드는 어떻게 될까요?
기다리게 되겠죠?
현재의 백그라운드 스레드를 메인 스레드에서 이 네모가 실행될 동안
Block하게 됩니다!
이 사실을 알게되면 또 이것과 연관지어서 이야기할 수 있는 게 있죠
DispatchQueue.main.sync 코드...
DispatchQueue.main.sync {
print("메인큐로 싱크 보내기...")
}
print("앞에 보낸거 다음에 실행될겨")
DispatchQueue.main.sync {}
코드는 보내는 주체가 Main Thread일 때
Dead Lock에 걸리게 돼요.
왜 와이,
메인 큐로 보낸 sync 작업은 현재 이걸 보내게 되는 주체가 진행을 함에 있어
보내는 주체가 되는 스레드를 Block 하게 되니까!!
이걸 이해하게 됐을 때의 기쁨 함께 공유하고 싶네요.
그래서 정리해볼게요.
메인 스레드는 메인큐를 가지고 있고 이 메인큐는 Serial Queue의 특성을 가지고 있다.
좀 이해가 되셨을까요? 좀 두서 없이 이야기를 해서 헷갈리실 수도 있을 것 같아요.
한가지 당부드리고 싶은 건 제가 지금 했던 프린트 문 찍는 거 있잖아요
직접 해보시면 바로 감이 오실거에요.