제목: All about Concurrency in Swift - Part 1: The Present
현재 배포된 스위프트 언어에서는 Go나 Rust가 한것 처럼 아직 네이티브 동시성 기능을 가지지 않는다.
작업들을 동시에 실행시키고 싶을때 경쟁상태의 결과를 다뤄야 한다면, 여러분이 할 수 있는 선택지는 몇개가 없다. libDispatch같은 외부 라이브러리를 사용하던지, 아니면 Foundation이나 OS가 제공하는 동기화 프리미티브(primitives)를 사용하는 것이다.
이 시리즈물의 첫번째 파트는, 스위프트3에서 우리가 처한 상황을 보고, Foundation의 락, 스레드, 타이머부터 언어의 게런티에대한 모든것과 최근에 만들어진 Grand Central Dispatch와 Operation Queues를 다룬다.
몇가지 기본 동시성 이론과 일반적인 동시성 패턴도 설명하게 될 것이다.
크리티컬 섹션과 동시 실행
스위프트가 돌아가는 모든 플랫폼에서 pthread를 사용할 수 있을지라도 이 라이브러이의 기능과 프리미티브는 이 글에서 설명하지 않을 것이며, 그것보다 더 높은 수준의 대안에대해 이야기할 것이다. NSTimer 클래스도 역서 이야기 하지 않으니 스위프트 3에서 어떻게 이것을 사용하는지 여기서 확인하자.
이미 여러번 발표했듯, 스위프트 4 이후의 주요 배포중 하나(꼭 스위프트 5는 아닐 것임)에서 더 나은 메모리 모델(memory model)을 정의하고, 새로운 네이티브 동시성 기능을 넣기위해 이 언어를 확장할 것이다. 새로운 동시성 기능은 외부 라이브러리없이 동시성 및 병렬처리를 다룰 수 있게 해주며, 동시성에대해 스위프트스러운 이상적인 방법을 정의할 것이다.
이것은 이 시리즈물의 다음 글의 주제가 될 것인데, 다른 언어에서 구현한 몇가지 대안의 방법과 패러다임을 토론하고, 이것들이 어떻게 스위프트로 구현될 수 있는지 이야기하게 된다. 그리고 오늘달에 이미 사용할 수 있는 몇가지 오픈소스의 구현을 분석하여 현재 배포된 스위프트로 Actors 패러다임, Go의 CSP 채널, Software Transactional Memory등을 이용할 수 있게 해줄 것이다.
이 두번째 글은 완전히 추측적인 것이다. 글의 주된 목표는, 이 주제에대해 소개해주어서 당신이 동시성을 어떻게 다룰지 정의하는 미래의 스위프트 배포에서 토론에 참여할 수 있게 해주는 것이다.
목차
멀티스레딩과 동시성 입문
오늘날 어떤 어플리케이션을 만들든 상관없이, 곧(혹은 훗날) 당신의 앱은 멀티스레드 실행의 환경에서 동작할 것이라는 사실을 고려해주어야한다.
하나 이상의 프로세서를 가진 컴퓨팅 플랫폼. 혹은 하나 이상의 하드웨어 실행 코어를 가진 프로세서는 10여년동안 우리 주변에 바짝 다가왔고 스레드나 프로세스 같은 개념은 나이를 먹어버렸다.
운영체제는 다양한 방법으로 사용자 프로그램에게 이 기능들을 제공해왔고, 모든 현대의 프레임워크나 앱은 유연성과 성능을 높히기위해 몇가지 잘 알려진 디자인 패턴들을 구현할 것이다. 그 중에는 다중 스레드도 포함되있다.
스위프트에서 어떻게 동시성을 다루는지 구체적으로 들어가보기전에, Dispatch Queues와 Operation Queues를 사용할 때 필요한 기본 개념을 간단하게 설명하려 한다.
먼저 애플 플랫폼과 프레임워크가 스레드를 사용할지라도 왜 이것을 여러분의 어플리케이션에 넣으려하는지 먼저 질문해보아야한다.
일반적인 상황에서 다중스레드가 해결책이 될 수 있는 몇가지가 있다.
- 작업 그룹 분리: 스레드는 실행 플로우의 관점에서 여러분의 어플리케이션을 모듈화하는데 사용할 수 있고, 각 스레드들은 예측할 수 있는 방법으로 같은 타입의 작업 그룹을 실행시키는데 사용할 수 있다. 여러분의 프로그램을 다른 실행 플로우로부터 고립시켜 앱의 현재 상태에대해 더 쉽게 만든다.
- 데이터-독립의 컴포넌트들의 병렬화: 하드웨어 스레드를 지원받거나 아닌(다음에 보자) 다중 소프트웨어 스레드는 원래 입력 데이터 구조의 하위집합에서 작동하는 여러 동일한 작업 본사본들을 병렬화하는데 사용될 수 있다.
- 조건이나 I/O를 기다리는데 깔끔한 방법: I/O를 블럭킹하거나 다른 종류의 오퍼레이션 블럭할때, 백그라운드 스레드는 이 오퍼레이션을 완료하기까지 깔끔하게 기다리는데 사용될 수 있다. 스레드의 이런 사용은 앱의 전반적인 설계를 증진하고 블럭된 호출 trivial을 다룰 수 있게 한다.
그러나 여러분의 코드를 단일 스레드의 관점에서 볼 때 이해했던 몇가지 가정이 다중 스레드가 실행있을때는 더이상 유효하지 않을 것이다.
각 스레드의 실행이 독립적으로 이루어지고, 데이터공유가 없는 이상적인 세계라면 단일 스레드에서 실행되는 코드처럼 그렇게까지 복잡하지는 않을 것이다. 그러나 보통의 경우처럼 같은 데이터에 동작하는 다중 스레드를 가진다면 이런 자료구조에 접근을 규제해야하고, 이 데이터에대한 모든 오퍼레이션이 다른 스레드의 오퍼레이션과 원치않은 인터렉션이 없도록 만드는 방법이 필요할 것이다.
동시성 프로그래밍은 그 언어나 운영체제로부터 추가적인 보증이 필요한데, 여러 스레드가 동시에 접근하려할때 변수("자원")는 어떻게 행동할지 명시적인 지정이 필요하다.
이런 언어는 메모리모델(Memory Model)을 정의해야한다. 메모리모델의 기본 진술서(basic statments)에는 동시성 스레드에서 어떻게 행동할지 명시적으로 지정해놓은 규칙들을 담아야하고. 메모리가 어떻게 공유될 수 있고 어던 종류의 메모리 접근이 유효한지 정의해야한다.
덕분에 사용자는 예상한대로 동작하는 언어를 가지게 되며, 컴파일러는 메모리 모델에 정의된 것만 반영하여 최적화를 수행할 것이라는 점을 우리는 알 것이다.
너무 엄격한 모델은 컴파일러가 발전할 것을 제안하기 때문에 메모리 모델을 정의하는 것은 언어의 발전에서 정교하게 해야한다. 독창적인 최적화는 메모리모델에서 과거의 결정에 유효하지 않을 수도 있다.
메모리모델을 정의하는 예시이다.
- 어떤 언어의 진술서에는 atomic이 고려될 수 있는데, 어떤 스레드도 부분적인 결과를 결과를 내지 않는 완전한 곳에서만 오퍼레이션을 실행시킬 수 있다. 예를들어 필수적으로 변수들이 atomic하게 초기화될 수 있는지 없는지 알아야한다.
- 공유된 변수를 어떻게 스레드에의해 다룰지, 디폴트로 캐싱을 할지, 특정 언어 변경자로 캐시 동작에 영향을 줄 수 있게 할지
- 크리티컬 섹션(critical section, 공유된 자원에서 동작하는 코드 영역)에 접근을 표시하고 규제하는데 사용되는 동시성 연산자가 있다. 예로서 이것은 한번에 특정 한 코드 패스를 따르기위해 한 스레드만 허용한다.
이제 여러분의 프로그램의 동시성 사용 이야기로 돌아가자.
동시성을 올바르게 다루기위해 여러분 프로그램에서 크리티컬 섹션을 판단해야하고, 다른 스레드간에 공유된 데이터의 접근을 규제하기위해 동시성 프리미티브나 동시성을 인지하는 자료구조를 사용해야 할 것이다.
코드나 자료구조의 이런 영역에 접근 규칙을 만들면 또다른 문제들을 만들게된다. 모든 스레드가 실행하여 공유된 데이터를 수정할 기회를 제공하는 것이 바라는 결과겠지만, 어떤 환경아래 어떤 것들은 아예 실행되지 않을 수도 있고, 그 데이터는 예상하지 못했던 방법으로 변경될지도 모른다.
당신은 추가적인 과제들을 직면하게 될 것이고 어떤 일반적인 문제들과 함께 작업해야 할 것이다.
- Race Conditions: 같은 데이터에 실행되는 여러 스레드(예를들면 동시에 읽기, 쓰기를 하는)는 오퍼레이션 시리즈의 실행 결과를 예측하기 힘들거나 스레드 실행 순서에 따라 다른 결과가 나올 수 있다.
- Resources Contention: 다른 작업들을 실행시킬 수 있는 멀티 스레드가 같은 자원에 접근하려고하면, 요청했던 자원을 안전하게 얻는데 시간이 더 많이 요구될 것이다. 여러분이 필요한 자원을 얻는데 이런 지연은 기대하지 않았던 동작이 되버리거나, 아니면 이런 자원 접근을 규제하는 구조를 짜야한다.
- Deadlocks: 여러 스레드에서 자원에 락을 걸었는데 서로 그 락이 풀리기를 기다리게된다. 이 스레드 그룹은 영원히 실행을 블락시킨다.
- Starvation: 한 스레드가 절때 특정 순서에서 자원들을 얻지 못할 수 있다. 다양한 이유가 필요하며 영원히 성공하지 못할 자원 취득을 계속해서 시도한다.
- Priority Inversion: 시스템에의해 할당된 우선순위 전환으로 높은 우선순위의 스레드가 필요로하는 자원을 낮은 우선순위의 스레드가 계속해서 취득하고 있을 수 있다.
- Non-determinism과 Fairness: 우리는 언제 어느때의 순서에따라 스레드가 공유된 자원을 취득할 수 있을지 가정할 수 없다. 이런 지연은 우선순위를 결정할 수 없고 경쟁의 양에 크게 영향을 받는다. 그러나 크리티컬 섹션을 보호하는데 사용되는 동시성 프리미티브는 공평하게 만들어지거나, 공평을 지원하게 만들 수도 있다(used to guard a critical section can also be built to be fair or to support fairness). 기다리고 있는 모든 스레드가 크리티컬 섹션에 접근할 수 있게 보장하면서, 요청했던 명령을 침해하지 않는다.
언어 게런티
당장 스위프트 자체가 동시성과 관련된 기능을 가지고 있지 않더라도, 스위프트는 프로퍼티를 어떻게 접근할지와 관련된 몇가지 게런티를 제공한다.
예를들어 전역변수는 atomic하게 초기화되므로, 여러 스레드가 한 전역변수를 동시에 초기화하려는 상황을 직접 처리하지 않아도 되고, 초기화가 여전히 진행중일때 누군가 부분적으로 초기화된 모습을 볼 걱정을 할 필요가 없다.
아래에 싱글톤 구현을 이야기할때 이 동작에대해 다시 생각해볼 것이다.
그러나 레이지 프로퍼티(lazy property) 초기화는 atomic하게 수행되지 않는다는 것을 꼭 기억해줘야한다. 게다가 스위프트는 이제 이것을 바꾸기위한 지시자나 변경자를 제공하지 않는다.
클래스 프로퍼티에 접근도 atomic이 아니다. 만약 그렇게 만들어야 한다면, 락이나 다른 비슷한 메커니즘을 사용해서 직접 독점적 접근을 구현해야한다.
스레드
Foundation은 Thread 클래스를 제공하는데, 이 클래스는 내부적으로 pthread를 기반으로 하며, 새로운 스레드를 생성하고 클로저를 실행시키는데 사용할 수 있다.
Thread 클래스의 detachNewThreadSelector:toTarget:withObject: 메소드를 이용하여 스레드를. 생성하거나, 커스텀 Thread 클래스를 선언하고 main() 메소드를 오버리아딩하여 새로운 스레드를 만들 수도 있다.
그러나 iOS10과 macOS Sierra부터는 마침내 모든 플랫폼에서 스레드가 실행시킬 클로저를 생성자뒤에 붙여 새로운 스레드를 생성할 수 있다. 이 글의 모든 예제는 기본 Thread 클래스를 확장한 것이므로 다른 OS에서 테스트해보지 않아도 된다.
우리가 직접 시작시키기위해 필요한 스레드 인스턴스를 만들어보자. 부가적인 단계로 새로운 스레드를 위한 맞춤형 스택 크기도 지정할 수 있다.
exit()를 호출하여 갑자기 스레드를 중단시킬 수 있지만, 현재 작업들을 깔끔하게 끝낼 기회를 잃어버리므로 절때로 추천하지 않는다. 필요에따라 중단 로직을 스스로 구현하거나, cancel() 메소드를 사용하고 스레드가 자연스럽게 현재 작업을 끝내기전에 중단을 요청을 받았는지 메인 클로저 내에서 알기위해 isCancelled 프로퍼티를 확인할 수 있다.
동기화 프리미티브
공유된 데이터를 변경하고 싶은 다른 스레드들이 있을때는, 데이터 오염이나 결정되지 않은 동작을 막기위해 반드시 이런 스레드들을 어떤 방법으로 동기화해주어야한다.
스레드 동기화에 기본적으로 사용되는 것은 락(lock), 세마포어(semaphore), 모니터(monitor)이다.
Foundation은 이 모든것을 제공한다.
곧 보게 될것인데, 이런 구성들을 구현하는 클래스들(그렇다 모두 참조 타입이다)은 스위프트 3에서 접두를 빼진 않았지만 다음 스위프트 배포판 중 하나에서 빠질 수 있다.
NSLock
NSLock은 Foundation이 제공하는 락(lock)의 기본 타입이다.
스레드가 이 오브젝트에 락을 걸려고하면 두가지 일이 일어날 수 있다. 이전 스레드가 락을 걸지 않았다면 이 스레드는 락을 취득할 것이다. 혹은 락이 이미 걸려있다면 락을 건 소유자가 락을 풀때까지 스레드는 실행을 블락하고 기다릴 것이다. 즉 락은 한번에 한번에 한 스레드만 취득할 수 있는 오브젝트이며 이것이 크리티컬 섹션 접근을 완벽하게 감시할 수 있게 만들어준다.
NSLock과 Foundation의 다른 락은 공평하지 않다(unfair). 스레드의 시리즈가 락을 취득하기위해 기다릴때 원래 락은 시도했던 순서대로 락을 취득하지 않을 것이다.
스레드 경쟁이 커지는 경우에는 실행 순서를 예상할 수 없다. 많은 스레드가 자원을 취득하려 할때, 여러분의 스레드는 starvation을 겪을 수 있고, 아무리 기다려도 절때 락을 취득할 수 없을 수도 있다(혹은 적절한 시간안에 취득할 수 없을 것이다).
경쟁 없이 락을 취득하는데 필요한 시간은 100ns로 예상할 수 있겠지만, 하나 이상의 스레드가 락이 걸린 자원을 취득하려고 할때, 그 시간은 급격하게 증가한다. 따라서 성능의 관점에서 볼때 락은 자원 할당을 다루기에 최고의 해결책은 아니다.
두 스레드가 있는 예제를 보자. 락을 취득될 순서가 정해져있지 않으므로 T1이 한 row에 두번 락을 취득하는 일이 일어날 수 있다(일반적인 상황은 아니다).
락을 사용하기로 했을때 한가지 경고하고 싶은게 있다. 나중에 동시성 이슈를 디버깅해야할 것이다. 항상 어떤 종류의 자료구조 범위 안으로 락 사용을 제한하려 하고, 여러분의 코드베이스 여러곳에서 하나의 락 오브젝트를 직접 참조하지 않도록 노력해야한다.
동시성 문제를 디버깅하는동안, 여러분의 코드 어느 부분이 락을 잡고있는지 계속 추적해가면서 여러 함수들의 로컬 상태를 기억하는것보다는 몇가지 입장 지점으로 동기화된 자료구조의 상태를 확인하는 것이 더 좋은 방법이다. 남은 글로 가서(go the extra mile) 여러분의 동시적인 코드 구조를 잘 짜자.
NSRecursiveLock
재귀적인 락(recursive lock)은 이미 락을 건 스레드에서 여러번 락을 취득할 수 있는데, 재귀함수나 시퀀스에서 동일한 락을 확인하는 여러 함수를 호출할 시 유용하다. 이것은 기본 NSLock과는 함께 동작하지 않을 수 있다.
NSConditionLock
조건락(condition lock)은 더 복잡한 락 설정(소비자-생산자 시나리오)을 지원하는데, 각자 독립적으로 락과 언락될 수 있도록 추가적인 하위락을 제공한다.
하나의 전역의 락(특정 조건에 상관없이 락을 건다)도 사용할 수 있으며 원래의 NSLock처럼 동작한다.
공유하는 정수를 보호하는 락 예제를 보자. 소비자는 출력하고 생산자는 화면에 나타날 때마다 업데이트한다.
락을 만들때 시작 조건을 지정해주어야하는데, 정수로 표현한다.
lock(whenCondition:) 메소드는 조건이 만족될때 락을 취득하거나 다른 스레드가 unlock(withCondition:)을 이용해서 값을 세팅할때까지 기다릴 것이다.
기본 락보다 조금 개선된 점은 좀 더 복잡한 시나리오를 만들 수 있게 해준다는 점이다.
NSCondition
조건락과 헷갈리지 말자. 한 조건(condition)은 발생 조건을 기다리기위한 명확한 방법을 제공한다.
락을 취득했던 스레드가 동작을 수행하는데 필요한 추가조건이 아직 만족되지 않았다면, 잠시 잡아두고 조건이 만족할때 작업을 계속하게 하는 방법이 필요하다.
끊임없이나 주기적으로 조건을 확인하도록 구현할 수도 있지만(busy waiting), 그렇게하면 스레드가 잡고있는 락에서 무슨일이 일어날까? 조건이 만족할때 다시 이들을 취득하길 바라면서 기다리거나 풀어주는 동안 잡아둬야 하는가(Should we keep them while we wait or release them hoping that we’ll be able to acquire them again when the condition is met)?
조건은 이 문제에대해 명확한 솔루션을 제공한다. 한번 취득한 스레드는 그 조건에대해 기다리고 있는 목록에 들어갈 수 있고, 한번 깨어난 다른 스레드는 조건이 만족했다고 신호를 보낸다.
예제를 보자.