iOS/Swift - UIKit

[Swift] Modern Concurrency Swift ( 1편 - Thread Class로 Thread 문제 파헤치기 )

Duno 2023. 2. 22. 19:42

Swift Concurrency, 그리고 기존의 GCD, OperationQueue

동시성(Concurrency) 프로그래밍이란 여러 작업을 동시에 여러 스레드에서 처리하는 프로그래밍 방식을 뜻합니다. iOS 개발에서 Concurrency는 어플리케이션의 성능 향상 및 UX 향상에 큰 도움을 주며 없어서는 안될 부분입니다. 기존에는 GCD, OperationQueue를 이용하여 이러한 목표를 이룰 수 있었습니다.

 

WWDC 2021에 소개된 Modern Concurrency Swift, Swift Concurrency는 컴파일 타임에 Thread-Safe한 코드를 작성할 수 있도록 도와주고, 특정 상황에 Thread가 멈추지 않고 다른 작업에 할당될 수 있도록 합니다. Swift Concurrency를 본격적으로 소개하기에 앞서, 기존의 iOS 멀티스레딩 방식에 대해 알아보려고 합니다.

 

iOS에서 멀티 스레딩(Multi Threading)

iOS에서 멀티 스레딩(Multi Threading)은 다음과 같은 의미를 갖습니다.

- 여러 스레드를 사용하여 작업을 처리하기 때문에 빠른 속도로 끝내도록 합니다.

- UI를 메인 스레드에서 그려주는 동시에 background Thread에서 리소스가 많은 일들을 처리하게 하여 유저 입장에서 어플리케이션의 지연을 낮춰줍니다.

- OS에 의해 고도로 최적화되는 threads를 사용하여 최적화된 리소스 소모를 이루도록 합니다.

 

본격적으로 내용에 들어가기 전에, 핵심적인 개념을 살펴보겠습니다.

Serial vs. Concurrent : 직렬과 병렬의 문제. Swift에서는 Serial은 하나의 스레드에서 작업하는 것, Concurrent는 여러 스레드에서 작업하는 것을 말한다.

Sync vs. Async : 동기와 비동기의 문제. 작업을 특정 스레드로 보낸 시점에서 현재 스레드가 작업을 기다리는지(sync), 기다리지 않고 다음 작업을 실행하는지(async)를 말한다.

 

우선 Thread Class를 통해 멀티스레딩에서 흔히 발생하는 문제들에 대해 알아보겠습니다.

Thread, a thread of excution

우선 Thread class에 대해 알아보겠습니다. Thread Class는 유닉스계열 POSIX 시스템에서 병렬 시스템을 작성하기 위한 API인 pthreads의 추상화 계층이라고 합니다. 이는 iOS나 macOS 플랫폼에서 병렬 프로그래밍을 가능하게 하는데, pthreads를 C를 통해 직접 생성할 수 있지만 과정이 복잡합니다.

 

본격적으로 Thread의 기능과 특성에 대해 알아보겠습니다.

 

Thread.current

우선 Thread.current로 현재 스레드의 정보를 알 수 있습니다. number 및 name이 제공됩니다.

 

Thread.detachNewThread(), concurrent한 작업 시행하기

그리고 Thread.detachNewThread 메서드로 새로운 스레드를 만들 수 있습니다.

log(2번 -> 3번 -> 1번)를 보고 알 수 있는 것은, 새 thread를 만들 때 순서가 없고, 동시적으로 실행된다는 것입니다. 이를 통해 기본적인 멀티스레딩을 할 수 있습니다.

 

 

 

Thread.cancel()

Thread는 cancel()을 통해 상태를 cancel로 만들 수 있습니다. 만약 Thread가 sleep 상태라면 cancel 명령을 들을 수 있을까요?

아래 사진을 보면 print문이 그대로 출력된 것을 확인할 수 있습니다. 

Thread 내부의 detach된 Thread는 취소되지 않는다

다음과 같이 guard문을 넣으면 thread의 취소를 감지하여 작업을 중지할 수 있습니다. 그런데 detachNewThread를 통해 새로 만든 thread는 취소되지 않는 것을 확인할 수 있습니다. 이는 Thread를 하나하나 추적하여 control해야 하는 비합리적인 상황을 만듭니다.

 

Thread Explosion: Thread의 포화 문제

만약 Thread를 통해서 많은 수의 Thread를 만들어내고, 이들 각각에 작업을 부여하면 어떻게 될까요? 그리고 그 상태에서 새로운 중요한 작업을 실행하면 어떻게 될까요? 반복문을 통해 실험을 할 수 있습니다.

Thread를 2개 추가
Thread를 1000개 추가

위 실험을 통해, 많은 수의 Thread를 사용하고 있는 상태에서 새로운 작업을 지시할 때에 Thread 할당이 제대로 이루어지지 않고, 작업의 효율이 굉장히 떨어진다는 것을 알 수 있습니다. 예시에서는 단순 O(N)으로 1000번 반복하는 아주 빠른 작업을 실행했지만 유의미한 차이가 보이기 때문에, 무거운 작업을 지시하면 더 큰 차이가 있을 것으로 보입니다.

 

이처럼 작업을 처리하는 스레드의 수가 아주 많아지면 스레드 수 만큼의 context switching이 필요합니다. 너무 많은 context switching은 시스템의 성능 저하를 일으킬 수 있는데, 위의 실험이 하나의 예시가 될 수 있습니다.

이러한 Thread Explosion을 막기 위해 한정된 수의 Thread를 제공하는 Thread Pool을 사용할 수 있습니다.

 

Thread 점유 문제

Thread를 새로 생성하고 sleep 시키면, 5초간 이 스레드는 어디에서도 접근하지 못하게 됩니다. 5초의 휴무 기간 동안 다른 작업을 할 수 있는 것이 아니라 단순히 존재하며 기다리고만 있는 것이며, 리소스를 허비하는 일입니다. 따라서 Thread가 특정 작업을 기다리는 상황이 생기면, 다른 작업을 할 수 있도록 소유권을 포기할 수 있는 API가 필요한데, 이는 추후 설명할 Modern Concurrency에서 해결할 수 있습니다.

 

Race Condition: 경쟁 상태

많은 수의 Thread를 생성하고 동일한 변수의 값을 변형하는 작업을 해보겠습니다.

원하는 값은 1000이지만, 990이 결과로 나왔습니다. 여러 번 실행할 경우 매번 다른 숫자가 나옵니다. 이는 서로 다른 스레드에서 count 값을 불러오고, 값에 1을 더해주는 과정에서 다른 스레드의 결과값을 덮어써버리는 경우가 생기기 때문입니다.

 

이러한 상황을 경쟁 상태, Race Condition이라고 합니다. 여러 개의 cocurrent한 스레드가 하나의 자원(count 변수)에 경쟁적으로 접근하는 상황입니다.

 

올바른 결과값을 얻기 위해서는, NSLock을 사용할 수 있습니다. 변수에 직접 접근하지 않고 method를 통해 접근하여 특정 행위 시 lock()을 걸어 값 획득 및 변형을 수행하고 난 후에 lock을 풀어줍니다. 그러면 여러 스레드들이 경쟁적으로 접근하지만, lock이 걸려 있어 한 번에 하나의 스레드만 행위를 실행할 수 있습니다. NSLock에 대한 정보는 여기에서 확인하실 수 있습니다. 하지만 NSLock은 컴파일 타임에 안전을 보장할 수 없어, 휴먼 에러의 발생 가능성이 여전히 존재합니다.

 

지금까지 Thread 클래스와 그 특성 및 문제점에 대해 알아보았습니다. Thread의 주요한 문제로는 Thread의 계층적 관리 불가능, Thread Explosion, Race Condition, Thread 점유 문제 등이 있었습니다. 다음 편에서는 Apple이 이를 쉽게 관리하기 위해 제공하는 GCD와 Operation Queue를 알아보겠습니다.