제목: 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() 메소드를 오버리아딩하여 새로운 스레드를 만들 수도 있다.
class MyThread : Thread {
override func main() {
print("Thread started, sleep for 2 seconds...")
sleep(2)
print("Done sleeping, exiting thread")
}
}
그러나 iOS10과 macOS Sierra부터는 마침내 모든 플랫폼에서 스레드가 실행시킬 클로저를 생성자뒤에 붙여 새로운 스레드를 생성할 수 있다. 이 글의 모든 예제는 기본 Thread 클래스를 확장한 것이므로 다른 OS에서 테스트해보지 않아도 된다.
var t = Thread {
print("Started!")
}
t.stackSize = 1024 * 16
t.start() //Time needed to spawn a thread around 100us
우리가 직접 시작시키기위해 필요한 스레드 인스턴스를 만들어보자. 부가적인 단계로 새로운 스레드를 위한 맞춤형 스택 크기도 지정할 수 있다.
exit()를 호출하여 갑자기 스레드를 중단시킬 수 있지만, 현재 작업들을 깔끔하게 끝낼 기회를 잃어버리므로 절때로 추천하지 않는다. 필요에따라 중단 로직을 스스로 구현하거나, cancel() 메소드를 사용하고 스레드가 자연스럽게 현재 작업을 끝내기전에 중단을 요청을 받았는지 메인 클로저 내에서 알기위해 isCancelled 프로퍼티를 확인할 수 있다.
동기화 프리미티브
공유된 데이터를 변경하고 싶은 다른 스레드들이 있을때는, 데이터 오염이나 결정되지 않은 동작을 막기위해 반드시 이런 스레드들을 어떤 방법으로 동기화해주어야한다.
스레드 동기화에 기본적으로 사용되는 것은 락(lock), 세마포어(semaphore), 모니터(monitor)이다.
Foundation은 이 모든것을 제공한다.
곧 보게 될것인데, 이런 구성들을 구현하는 클래스들(그렇다 모두 참조 타입이다)은 스위프트 3에서
접두를 빼진 않았지만 다음 스위프트 배포판 중 하나에서 빠질 수 있다.
NSLock
NSLock은 Foundation이 제공하는 락(lock)의 기본 타입이다.
스레드가 이 오브젝트에 락을 걸려고하면 두가지 일이 일어날 수 있다. 이전 스레드가 락을 걸지 않았다면 이 스레드는 락을 취득할 것이다. 혹은 락이 이미 걸려있다면 락을 건 소유자가 락을 풀때까지 스레드는 실행을 블락하고 기다릴 것이다. 즉 락은 한번에 한번에 한 스레드만 취득할 수 있는 오브젝트이며 이것이 크리티컬 섹션 접근을 완벽하게 감시할 수 있게 만들어준다.
NSLock과 Foundation의 다른 락은 공평하지 않다(unfair). 스레드의 시리즈가 락을 취득하기위해 기다릴때 원래 락은 시도했던 순서대로 락을 취득하지 않을 것이다.
스레드 경쟁이 커지는 경우에는 실행 순서를 예상할 수 없다. 많은 스레드가 자원을 취득하려 할때, 여러분의 스레드는 starvation을 겪을 수 있고, 아무리 기다려도 절때 락을 취득할 수 없을 수도 있다(혹은 적절한 시간안에 취득할 수 없을 것이다).
경쟁 없이 락을 취득하는데 필요한 시간은 100ns로 예상할 수 있겠지만, 하나 이상의 스레드가 락이 걸린 자원을 취득하려고 할때, 그 시간은 급격하게 증가한다. 따라서 성능의 관점에서 볼때 락은 자원 할당을 다루기에 최고의 해결책은 아니다.
두 스레드가 있는 예제를 보자. 락을 취득될 순서가 정해져있지 않으므로 T1이 한 row에 두번 락을 취득하는 일이 일어날 수 있다(일반적인 상황은 아니다).
let lock = NSLock()
class LThread : Thread {
var id:Int = 0
convenience init(id: Int) {
self.init()
self.id = id
}
override func main() {
lock.lock()
print(String(id)+" acquired lock.")
lock.unlock()
if lock.try() {
print(String(id)+" acquired lock again.")
lock.unlock()
} else { // If already locked move along.
print(String(id)+" couldn't acquire lock.")
}
print(String(id)+" exiting.")
}
}
var t1 = LThread(id:1)
var t2 = LThread(id:2)
t1.start()
t2.start()
락을 사용하기로 했을때 한가지 경고하고 싶은게 있다. 나중에 동시성 이슈를 디버깅해야할 것이다. 항상 어떤 종류의 자료구조 범위 안으로 락 사용을 제한하려 하고, 여러분의 코드베이스 여러곳에서 하나의 락 오브젝트를 직접 참조하지 않도록 노력해야한다.
동시성 문제를 디버깅하는동안, 여러분의 코드 어느 부분이 락을 잡고있는지 계속 추적해가면서 여러 함수들의 로컬 상태를 기억하는것보다는 몇가지 입장 지점으로 동기화된 자료구조의 상태를 확인하는 것이 더 좋은 방법이다. 남은 글로 가서(go the extra mile) 여러분의 동시적인 코드 구조를 잘 짜자.
NSRecursiveLock
재귀적인 락(recursive lock)은 이미 락을 건 스레드에서 여러번 락을 취득할 수 있는데, 재귀함수나 시퀀스에서 동일한 락을 확인하는 여러 함수를 호출할 시 유용하다. 이것은 기본 NSLock과는 함께 동작하지 않을 수 있다.
let rlock = NSRecursiveLock()
class RThread : Thread {
override func main() {
rlock.lock()
print("Thread acquired lock")
callMe()
rlock.unlock()
print("Exiting main")
}
func callMe() {
rlock.lock()
print("Thread acquired lock")
rlock.unlock()
print("Exiting callMe")
}
}
var tr = RThread()
tr.start()
NSConditionLock
조건락(condition lock)은 더 복잡한 락 설정(소비자-생산자 시나리오)을 지원하는데, 각자 독립적으로 락과 언락될 수 있도록 추가적인 하위락을 제공한다.
하나의 전역의 락(특정 조건에 상관없이 락을 건다)도 사용할 수 있으며 원래의 NSLock처럼 동작한다.
공유하는 정수를 보호하는 락 예제를 보자. 소비자는 출력하고 생산자는 화면에 나타날 때마다 업데이트한다.
let NO_DATA = 1
let GOT_DATA = 2
let clock = NSConditionLock(condition: NO_DATA)
var SharedInt = 0
class ProducerThread : Thread {
override func main() {
for i in 0..<5 {
clock.lock(whenCondition: NO_DATA) //Acquire the lock when NO_DATA
//If we don't have to wait for consumers we could have just done clock.lock()
SharedInt = i
clock.unlock(withCondition: GOT_DATA) //Unlock and set as GOT_DATA
}
}
}
class ConsumerThread : Thread {
override func main() {
for i in 0..<5 {
clock.lock(whenCondition: GOT_DATA) //Acquire the lock when GOT_DATA
print(i)
clock.unlock(withCondition: NO_DATA) //Unlock and set as NO_DATA
}
}
}
let pt = ProducerThread()
let ct = ConsumerThread()
ct.start()
pt.start()
락을 만들때 시작 조건을 지정해주어야하는데, 정수로 표현한다.
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)?
조건은 이 문제에대해 명확한 솔루션을 제공한다. 한번 취득한 스레드는 그 조건에대해 기다리고 있는 목록에 들어갈 수 있고, 한번 깨어난 다른 스레드는 조건이 만족했다고 신호를 보낸다.
예제를 보자.
let cond = NSCondition()
var available = false
var SharedString = ""
class WriterThread : Thread {
override func main() {
for _ in 0..<5 {
cond.lock()
SharedString = "😅"
available = true
cond.signal() // Notify and wake up the waiting thread/s
cond.unlock()
}
}
}
class PrinterThread : Thread {
override func main() {
for _ in 0..<5 { //Just do it 5 times
cond.lock()
while(!available) { //Protect from spurious signals
cond.wait()
}
print(SharedString)
SharedString = ""
available = false
cond.unlock()
}
}
}
let writet = WriterThread()
let printt = PrinterThread()
printt.start()
writet.start()
NSDistributedLock
분산된 락(distributed lock)은 지금까지 우리가 봤던 것과는 꽤 다르고, 이것이 자주 필요해보이진 않는다.
이것은 여러 어플리케이션 간에 공유되도록 만들어졌고 파일시스템 출입을 지원한다. 이 파일시스템은 이것을 취득해야하는 모든 앱이 분명하게 접근할 수 있어야 할것이다.
이런 종류의 락은 try() 메소드를 사용하여 취득될 수 있는데, 이 논-블락킹 메소드는 락이 취득되있는지 아닌지를 알려주는 boolean을 바로 반환한다. 락을 얻으려면 보통 한번 이상 시도해야 할것이다. 직접 실행시키거나 적절한 딜레이를 두고 연속적으로 시도할 수 있다.
분산될 락은 보통 unlock() 메소드를 사용하여 락을 푼다.
아래 기본 예제를 보자.
var dlock = NSDistributedLock(path: "/tmp/MYAPP.lock")
if let dlock = dlock {
var acquired = false
while(!acquired) {
print("Trying to acquire the lock...")
usleep(1000)
acquired = dlock.try()
}
// Do something...
dlock.unlock()
}
OSAtomic 어디있는가(Where Art Thou)?
OSAtomic가 제공하는 것과 비슷한 atomic 오퍼레이션들은 기존의 락 로직을 사용하지 않고 변수를 set, get, compare-and set 할 수 있게 해주는 간단한 오퍼레이션이다. 이들은 CPU의 특정 기능(종종 네이티브 atomic 인스트럭션)을 이용하여 앞에서 설명했던 락보다 더 좋은 성능을 낸다.
동시성을 다루는데 필요한 오버헤드가 최소한으로 줄기 때문에, 동시성 자료구조를 만들때 극도로 편리하다.
OSAtomic은 macOS 10.12부터 디프리케이트되었고 리눅스에서는 아예 사용할 수 없으나,
이것처럼 스위프트의 유용한 익스텐션을 사용한 오픈소스 프로젝트나
이것은 비슷한 기능을 제공한다.
synchronized 블럭에서
Objective-C에서 했던것처럼 @synchronized 블럭은 스위프트에서는 만들 수가 없는데, 동일한 키워드가 없다.
다윈에서는 objc_sync_enter(OBJ)와 objc_sync_exit(OBJ)를 직접 사용하여, 비슷한 어떤것을 준비할 수 있고, 내부적으로 @synchronized와 비슷하게 동작하는 @objc 오브젝트 모니터도 있다. 그러나 별로 의미는 없고, 이런것이 필요할때는 간단하게 락을 쓰는게 더 낫다.
그리고 Dispatch Queues를 설명할때 보게될 것인데, 동기화 호출을 수행하는 작은 코드로 이 기능을 큐로 이용할 수 있다.
var count: Int {
queue.sync {self.count}
}
GCD: Grand Central Dispatch
이 API에 친숙하지 않은 이들을 위해 Grand Central Dispatch(GCD)를 설명하자면, 이것은 큐 기반 API로 작업자 풀(worker pools)에서 클로저를 실행할 수 있게 해준ㄷ.
실행되야하는 작업을 담은 클로저는 이것을 실행시킨 큐에 담을 수 있는데, 큐의 구성 옵션에따라 순차적으로 할지, 병렬적으로 할지 정한 스레드 시리즈를 이용한다. 그러나 큐의 타입에 상관없이 작업은 항상 먼저 들어온 것이 먼저 나가는(FIFO, First-in First-out) 순서로 시작될 것이다. 즉, 작업은 항상 들어온 순서대로 시작할 것이다. 완료 순서는 각 작업의 지속시간에따라 다르다.
이것은 상대적으로 현대의 언어 런타임이 동시성을 처리할때 일반적으로 발견할 수 있는 패턴이다. 스레드 풀(thread pool)은 일련의 프리 스레드(free thread)나 연결되지 않은 스레드보다 더 쉽게 관리하고 조사하며 컨트롤 할 수 있는 방법이다.
스위프트 3에서 GCD API는 조금 바뀌었다.
SE-0088는 설계를 현대화시키고 더 객체지향적으로 만들었다.
Dispatch Queues
GCD는 커스텀 큐를 생성할 수 있을 뿐만 아니라, 몇몇 미리 선언된 시스템 큐에 접근하게도 해준다.
일련의 기본 큐(이 큐는 여러분의 클로저를 차례로 실행시킬 것이다)를 생성하기 위해서는 큐를 식별하는데 쓰이는 문자열 레이블을 제공해야하며, 스택 트레이스(stack trace)에서 큐의 소유자를 간단히 추적하기위해 이 레이블은 도메인 앞부분을 뒤집어 사용하는 것을 추천한다.
let serialQueue = DispatchQueue(label: "com.uraimo.Serial1") //attributes: .serial
let concurrentQueue = DispatchQueue(label: "com.uraimo.Concurrent1", attributes: .concurrent)
우리가 생성한 두번째 큐는 동시에 된다. 큐는 작업이 실행될때 스레드 풀에 있는 모든 사용가능한 스레드를 사용할 것이다. 이 경우에 실행 순서는 예측할 수 없고, 여러분의 큐를 추가한 순서와 어떤 방법으로도 연관시켜 완료 순서를 가정해서는 안된다.
디폴트 큐는 DispatchQueue 오브젝트에서 찾아볼 수 있다.
let mainQueue = DispatchQueue.main
let globalDefault = DispatchQueue.global()
main 큐는 순차적인 메인 큐인데, 이 큐는 iOS나 macOS에서 그래픽적인 어플리케이션을 위한 메인 이벤트 루프를 처리한다. 이 큐는 이벤트에 응답하고 사용자 인터페이스를 업데이트한다. 우리가 알고 있듯, 사용자 인터페이스에서 일어나는 모든 변경은 이 큐에서 실행되고, 이 스레드에서 오퍼레이션이 길어지면 둔감한 사용자 인터페이스로 렌더링하게 될 것이다.
이 런타임은 다른 프로퍼티로 다른 전역의 큐에 접근할 수 있게 해주는데, Quality of Service(QoS)라는 파라미터로 식별된다.
높은 우선순위부터 낮은 우선순위까지 다양한 우선순위가 DispatchQoS 클래스에 정의되있다.
- .userInteractive
- .userInitiated
- .default
- .utility
- .background
- .unspecified
특정 디폴트 큐를 얻기위해 원하는 우선순위를 지정하는 global(qos:) 게터를 사용하자.
let backgroundQueue = DispatchQueue.global(qos: .background)
동일한 우선순위 명시는 커스텀 큐를 생성할때 다른 속성들과 함께(혹은 없이) 사용될 수 있다.
let serialQueueHighPriority = DispatchQueue(label: "com.uraimo.SerialH", qos: .userInteractive)
Queue 사용하기
클로저 형태의 작업들은 두가지 방법으로 큐에 담긴다. 동기적으로 sync 메소드를 사용하거나 비동기적으로 async 메소드를 사용할 수 있다.
전자를 사용하면 sync 호출은 블락되며 즉 이 클로저가 완료될 때 sync 메소드를 호출할 수 있는 반면(클로저가 끝날때까지 기다려야할 때는 유용하지만, 더 나은 방법이 있다), 후자는 클로저를 큐에 넣고 완료되는대로 계속해서 실행할 수 있게 해준다.
짧은 예제를 보자.
globalDefault.async {
print("Async on MainQ, first?")
}
globalDefault.sync {
print("Sync in MainQ, second?")
}
예제처럼 여러 디스패치 호출은 중첩될 수 있는데, background(낮은 우선순위)가 끝나고 사용자 인터페이스를 갱신하는 오퍼레이션이다.
DispatchQueue.global(qos: .background).async {
// Some background work here
DispatchQueue.main.async {
// It's time to update the UI
print("UI updated on main queue")
}
}
클로저는 지정된 지연 이후에 실행될 수도 있는데, 스위프트 3은 마침내 더 편리한 방법으로 지정할 수 있게 되었다. .seconds(Int), milliseconds(Int), microseconds, nanoseconds(Int)의 네가지 시간 단위를 사용하여 인터벌을 구상할 수 있는 DispatchTimeInterval 열거형을 사용할 수 있다. 이 열거형으로 원하는 인터벌을 지정할 수 있다.
나중에 실행될 클로저의 스케줄을 짜기위해, 시간 인터벌과함께 asyncAfter(deadline:excute:) 메소드를 사용하자.
globalDefault.asyncAfter(deadline: .now() + .seconds(5)) {
print("After 5 seconds")
}
같은 클로저를 여러번 순회하면서 실행시켜야 한다면(dispatch_apply를 사용하는 것과 같이), concurrentPerform(iterations:execute:) 메소드를 사용할 수 있다. 그러나 현재 큐의 맥락중에 가능하다면 이 클로저는 동시에 실행된다는 것을 알고 있어야 한다. 그러니 동시성을 지원하는 큐에서동작하는 sync 호출이나 async 호출에 항상 concurrentPerform(iterations:execute:) 호출을 넣어야 함을 기억하자.
globalDefault.sync {
DispatchQueue.concurrentPerform(iterations: 5) {
print("\($0) times")
}
}
큐가 정상적으로 생성되는데 클로저를 처리할 준비를 하는동안 ,필요한 것을 할 수 있게 설정할 수 있다.
let inactiveQueue = DispatchQueue(label: "com.uraimo.inactiveQueue", attributes: [.concurrent, .initiallyInactive])
inactiveQueue.async {
print("Done!")
}
print("Not yet...")
inactiveQueue.activate()
print("Gone!")
하나 이상의 속성을 지정하는 것은 처음이지만, 여러분도 볼 수 있듯, 필요하면 배열로 여러 속성들을 추가할 수 있다.
작업의 실행은 DispatchObject에서 상속한 메소드로 중단되거나 잠시 멈출 수 있다.
inactiveQueue.suspend()
inactiveQueue.resume()
setTarget(queue:)는 비활성 큐의 우선순위 구성에만 쓰이는데(이것을 활성 큐에 사용하면 크레쉬가 난다), 이 메소드도 사용할 수 있다. 이 메소드를 호출하면, 큐의 우선순위가 파라미터로 주어진 큐의 우선순위와 같아진다.
Barriers
특정 큐에 (서로다른 지속시관과 함께) 일련의 클로저를 넣었지만, 이제 당신은 이전의 비동기 작업이 모두 완료되고나서 그 작업을 실행시키고 싶다. 이를위해 barriers를 사용할 수 있다.
우리가 이전에 만들었던 동시적 큐에 5개의 테스크(1초에서 5초까지 sleep하는 테스크)를 추가하고, 다른 작업이 완료되면 뭔가를 출력하기위해 barrier를 사용해보자. 마지막 async 호출에 DispatchWorkItemFlags.barrier를 지정할 것이다.
globalDefault.sync {
DispatchQueue.concurrentPerform(iterations: 5) { (id:Int) in
sleep(UInt32(id) + 1)
print("Async on globalDefault, 5 times: " + String(id))
}
}
globalDefault.async (flags: .barrier) {
print("All 5 concurrent tasks completed")
}
싱글톤과 Dispatch_once
이미 알고 있을지도 모르겠지만 스위프트 3은 dispatch_once와 동일한 함수가 없다. 이 함수는 싱글톤을 스레드-세이프하게 만드는데 많이 사용되었었다.
운좋게도 스위프트는 전역변수가 atomic하게 초기화됨을 보장한다. 그리고 상수가 한번 초기화되고나면 값을 바꿀 수 없다는 점을 생각해본다면, 이 두 프로퍼티를 이용한 전역 상수는 싱글톤을 쉽게 구현하기에 좋은 후보자가 될 것이다.
final class Singleton {
public static let sharedInstance: Singleton = Singleton()
private init() { }
// ...
}
우리 클래스를 final로 선언하여 상속할 수 없게 만들고, 지정된 생성자를 private으로 만들어서 이 오브젝트의 인스턴스를 추가로 생성하지 못하게 만든다. public static 상수는 싱글톤에서 들어가는 부분이고, 이것은 단 하나의 공유된 인스턴스를 찾는데 사용될 것이다.
한번만 실행될 코드블럭을 만들때도 똑같이하면 된다.
func runMe() {
struct Inner {
staticlet i: () = {
print("Once!")
}()
}
Inner.i
}
runMe()
runMe() // Constant already initialized
runMe() // Constant already initialized
가독성은 떨어지지만 동작은 한다. 한번만 쓰이기위한 코드이면 수용할 수 있는 구현이다.
그러나 만약 그 기능과
dispatch_once API를 정확하게 복제해야한다면,
동기화된 블럭 섹션에서 한 익스텐션으로 표현하듯 처음부터 구현해야한다(But if we need to replicate exactly the functionality and API of dispatch_once we need to implement it from scratch, as described in the
synchronized blocks section with an extension).
import Foundation public extension DispatchQueue {
private static var onceTokens = [Int] ()
private static var internalQueue = DispatchQueue(label: "dispatchqueue.once")
public class func once(token: Int, closure: (Void) -> Void) {
internalQueue.sync {
if onceTokens.contains(token) {
return
} else {
onceTokens.append(token)
}
closure()
}
}
}
let t = 1
DispatchQueue.once(token: t) {
print("only once!")
}
DispatchQueue.once(token: t) {
print("Two times!?")
}
DispatchQueue.once(token: t) {
print("Three times!!?")
}
예상한대로 세개중 첫번째 클로저만 실제 호출될 것이다.
대신 여러분의 플랫폼에서 쓸 수 있다면 objc_sync_enter와 objc_sync_exit를 사용하여 약간 더 좋은 성능을 낼 수도 있다.
import Foundation
public extension DispatchQueue {
private static var _onceTokens = [Int] ()
public class func once(token: Int, closure: (Void) -> Void) {
objc_sync_enter(self);
defer { objc_sync_exit(self) }
if _onceTokens.contains(token) {
return
} else {
_onceTokens.append(token)
}
closure()
}
}
Dispatch Groups
(다른 큐에 추가할지라도) 여러 테스크를 가지고 있으면서 그것들의 완료를 기다린다면 dispatch group으로 그룹화할 수 있다.
예제를 보면 sync나 async 호출로 한 테스크를 직접 특정 그룹에 추가할 수 있다.
let mygroup = DispatchGroup()
for i in 0..<5 {
globalDefault.async(group: mygroup) {
sleep(UInt32(i))
print("Group async on globalDefault:" + String(i))
}
}
이 테스크는 globalDefault에서 실행되지만 mygroup 완료를위한 핸들러를 등록할 수 있다. 이 핸들러는 모든 테스크가 완료되면 우리가 원하는 큐에서 한 클로저를 실행시킬 것이다. wait() 메소드는 블럭킹 지연(blocking wait)을 실행시키는데 사용할 수 있다.
print("Waiting for completion...")
mygroup.notify(queue: globalDefault) {
print("Notify received, done waiting.")
}
mygroup.wait()
print("Done waiting.")
그룹으로 작업을 추적할 수 있는 또다른 방법이 있다. 큐에서 호출할 때 그룹을 지정하는 것 대신, 직전 그룹을 들어가고(enter) 나오도록(leave) 설정하는 것이다.
for i in 0..<5 {
mygroup.enter()
sleep(UInt32(i))
print("Group sync on MAINQ:" + String(i))
mygroup.leave()
}
Dispatch Work Items
클로저는 큐에서 실행될 작업을 지정해주는 일만 하는것이 아니라, 그것의 실행 상태를 계속 추적할 수 있도록 컨테이너 타입도 필요하다. 이를위해 DispatchWorkItem이 사용된다. 클로저를 받는 모든 메소드는 work item 종류를 가진다.
work item은 스레드풀의 큐에의해 실행될 클로저를 캡슐화하는데, perform() 메소드를 호출한다.
let workItem = DispatchWorkItem {
print("Done!")
}
workItem.perform()
그리고 WorkItems은 다른 유용한 메소드를 제공한다. notify는 특정 큐에서 완료될때 클로저를 실행시킨다.
workItem.notify(queue: DispatchQueue.main) {
print("Notify on Main Queue!")
}
defaultQueue.async(execute: workItem)
클로저가 실행될때까지 기다리게 할 수도 있고, cancel 메소드를 써서 큐가 실행하려하기 전에 제거하라고 알릴 수도 있다.
print("Waiting for work item...")
workItem.wait()
print("Done waiting.")
workItem.cancel()
여기서 한가지 알아야할 중요한 사실은 wait()는 완료를 기다리기위해 현재 스래드를 블럭하지 않고 큐 안에서 바로 이전의 work item들의 우선순위를 올려, 이 특정 item을 가능한 빨리 완료시키려 한다는 점이다.
Dispatch Semaphores
dispatch semaphore는 락이다. 이것은 카운터의 현재 값에따라 하나 이상의 스레드가 락을 취득할 수 있게 해준다.
카운터(세마포어를 취득할대마다 감소시킴)가 0이 될때 스레드는 세마포어를 wait한다.
세마포어에 접근하기위한 슬롯은 대기중인 스레드 호출 signal에대해 열리는데, 이
signal은 카운터를 증가시킨다.
예제를 보자.
let sem = DispatchSemaphore(value: 2)
// The semaphore will be held by groups of two pool threads
globalDefault.sync {
DispatchQueue.concurrentPerform(iterations: 10) { (id:Int) in
sem.wait(timeout: DispatchTime.distantFuture)
sleep(1)
print(String(id) + " acquired semaphore.")
sem.signal()
}
}
Dispatch Assertions
스위프트 3은 현재 실행 맥락안에서 assertion을 실행시킬 수 있는 함수를 소개했다. 이것은 원했던 큐에서 크롤저가 실행되고 있는지 확인할 수 있게 한다. 우리는 DispatchPredicate 열거형의 3가지 case로 만들 수 있다. .onQuquq는 특정 큐에 있는지 확인한다. .notOnQueue는 그 반대를 확인한다. .onQueueAsBarrier는 현재 클로저나 work item이 한 큐의 barrier로 동작하고 있는지 확인한다.
dispatchPrecondition(condition: .notOnQueue(mainQueue))
dispatchPrecondition(condition: .onQueue(queue))
Dispatch Sources
dispatch sources는 이벤트 핸들러를 사용하는 이벤트(커널 시그널이나 시스템, 파일, 소켓)와 관련된 시스템단의 비동기 이벤트를 편리하게 처리할 수 있게 해준다.
몇가지 종류의 Dispatch Sources를 사용할 수 있는데, 아래처럼 묶을 수 있다.
-
Timer Dispatch Sources: 시간내에 혹은 주기적인 이벤트에서 특정 시점에 이벤트를 만드는데 사용된다(DispatchSouceTimer).
-
Signal Dispatch Souces: UNIX 시그널을 다루는데 사용된다(DispatchSourceSignal).
-
Memory Dispatch Sources: 메모리 사용 상태 관련 알림을 등록하는데 사용된다(DispatchSourceMemoryPressure).
-
Discriptor Dispatch Sources: 파일이나 소캣 관련 여러 이벤트를 등록하는데 사용된다(DispatchSourceFileSystemObject, DispatchSourceRead, DispatchSourceWrite).
-
Process Dispatch Sources: 어떤 이벤트에대해 그들의 실행 상태 관련 외부 프로세스를 모니터링하는데 사용된다(DispatchSourceProcess)
-
Mach related Dispatch Source: Mach 커널의 IPC 기능과 관련된 이벤트를 처리하는데 사용된다(DispatchSourceMachReceive, DispatchSourceMachSend).
그리고 필요시 여러분만의 Dispatch Sources를 만들 수도 있다. 모든 Dispatch Sources는 DispatchSourceProtocol을 따르며, 이 프로토콜은 핸들러를 등록하고 Dispatch Sources의 활성 상태를 수정하는데 필요한 기본 오퍼레이션을 정의한다.
이 오브젝트를 어떻게 사용하는지에대한 이해를 돕기위해 DispatchSourceTimer 예제를 한번 보자.
Sources는 DispatchSource가 제공하는 실용적인 메소드로 생성할 수 있다. 이 단편코드에서는 makeTimerSource를 사용하여 핸들러 실행하는데 사용하고 싶은 dispatch 큐를 지정할 것이다.
Timer Sources는 다른 파라미터가 없으므로 큐만 지정하면 source를 생성할 수 있다. 곧 보겠지만, 여러 이벤트를 처리할 수 있는 dispatch source는 항상 이벤트 식별자를 지정해야 할 것이다.
let t = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
t.setEventHandler{ print("!") }
t.scheduleOneshot(deadline: .now() + .seconds(5), leeway: .nanoseconds(0))
t.activate()
source가 생성되면 setEventHandler(closure:)로 이벤트 핸들러를 등록하고, 다른 설정이 필요없으면 active()로 dispatch source를 켜자(이전에 libDispatch는 resume()을 사용해야 했다).
Timer Sources는 오브젝트가 전달할 이벤트에 어떤 종류의 시간을 설정할지에대한 추가적인 단계가 필요하다. 위 예제에서 우리는 엄격한 시간제한 등록 후 5초 딜레이될 이벤트를 정의하고 있다.
이벤트를 전달하기위한 오브젝트도 설정할 수 있는데,
Timer 오브젝트로 하는 것처럼 가능하다.
t.scheduleRepeating(deadline: .now(), interval: .seconds(5), leeway: .seconds(1))
dispatch source로 작업을 끝내고나서 이벤트 전달을 완전히 멈추려면, 이밴트 소스를 중지시키는 cancel()을 호출하고, 핸들러를 설정했다면 취소를 호출한 뒤, 핸들러를 등록 해제하는것처럼 최종 정리 작업을 실행한다.
t.cancel()
handleRead() 함수는 소켓에 들어오는 데이터 버퍼에서 새로운 바이트를 사용할 수 있게 되면 전용 큐에서 호출하게 될 것이다.
Kitura도 버퍼로된 쓰기를 위해
WriteSource를 사용하는데
쓰기 속도(pace)를 효율적으로 맞추기위해 dispatch source 이벤트를 사용하며, 소켓 채널이 보낼 준비가 되는대로 새로운 바이트를 쓴다. I/O를 할때 read/write dispatch sources는 보통 *NIX 플랫폼에서 사용하는 저수준 API보다 더 좋은 고수준의 대안이 될 수 있다.
나머지 source 타입들도 비슷하게 동작한다. 사용할 수 있는 모든 항목들은
libDispatch의 문서에서 확인할 수 있지만, 그 중 Mach source나 memory pressure source 같은 몇몇은 다윈 플랫폼에서만 동작한다는 것을 기억하자.
Operations과 OperationQueues
Operation Queues에대해 간단히 이야기해보자. 이것은 GCD의 상위에 탑재된 추가 API이다. 이것은 동시적인 큐와 모델 테스크를 오퍼레이션으로 사용하고, 취소하기도 쉬우며, 다른 오퍼레이션이 완료됨에따라 그들의 실행을 가질 수 있다.
Operations은 실행 순서를 정하는 우선순위를 가질 수 있다. 그리고 이것은 OperationQueues에 추가되어 비동기적으로 실행된다.
기본적인 예제를 보자.
var queue = OperationQueue()
queue.name = "My Custom Queue"
queue.maxConcurrentOperationCount = 2
var mainqueue = OperationQueue.main //Refers to the queue of the main thread
queue.addOperation{
print("Op1")
}
queue.addOperation{
print("Op2")
}
Block Operation 오브젝트를 생성하여 큐에 넣기전에 설절할 수도 있고, 필요하면 이런 종류의 오퍼레이션에 한개 이상의 클로저를 넣을 수도 있다.
target과 selector로 오퍼레이션을 생성하는 NSInvocationOperation은 스위프트에서는 사용할 수 없다.
var op3 = BlockOperation(block: {
print("Op3")
})
op3.queuePriority = .veryHigh
op3.completionBlock = {
if op3.isCancelled {
print("Someone cancelled me.")
}
print("Completed Op3")
}
var op4 = BlockOperation {
print("Op4 always after Op3")
OperationQueue.main.addOperation {
print("I'm on main queue!")
}
}
Operation은 우선순위(priority)와 완료 클로저를 가질 수 있다. 여기서 이 클로저는 메인 클로저가 완료되면 실행될 것이다.
op4에서 op3까지 의존성(dependency)을 추가할 수 있으므로 op4는 op3의 완료를 기다렸다가 실행될 것이다.
op4.addDependency(op3)
queue.addOperation(op4) // op3 will complete before op4, always
queue.addOperation(op3)
removeDependency(operation:)으로 의존성을 제거할 수 있고, 이 의존성들은 public으로 접근할 수 있는 dependencies 배열에 담겨있다.
한 오퍼레이션의 현재 상태는 특정 프로퍼티를 이용해서 알 수 있다.
op3.isReady //Ready for execution?
op3.isExecuting //Executing now?
op3.isFinished //Finished naturally or cancelled?
op3.isCancelled //Manually cancelled?
cancelAllOperations 메소드를 호출하여 한 큐에 있는 모든 오퍼레이션 프레젠트를 취소할 수 있다. 이 메소드는 큐에 남아있는 오퍼레이션의 isCancelled 플레그를 on으로 설정한다. 한 오퍼레이션을 취소할때는 그것의 cancel 메소드를 호출하면 된다.
queue.cancelAllOperations()
op3.cancel()
어느 큐에서 실행될지 스케줄이 잡힌 뒤에 오퍼레이션이 취소되었다면, 그 오퍼레이션 안에서 실행을 스킵하기위해 isCancelled 프로퍼티를 확인할 것을 추천한다.
이제 마침내 당신은 한 오퍼레이션 큐에서 새로운 오퍼레이션 실행도 멈출 수 있게 되었다(현재 실행되고 있는 오퍼레이션에는 영향을 주지 않는다).
queue.isSuspended = true
마지막 생각들
이 글은 오늘날 스위프트에서 사용할 수 있는 외브 프레임워크를 이용하여, 동시성 관점에서 무엇을 할 수 있는지 좋은 정리를 제공할 것이다.
Part2는 언어 기능의 관점에서 외부 라이브러리에 의존하지 않고 "네이티브한" 동시성을 처리하는 것에 대해 초점을 맞출 것이다. 오늘날 이미 있는 몇가지 오픈소스 구현의 도움을 받아 몇가지 흥미로운 패러다임을 설명할 것이다.
이 두개의 글이 동시성의 세계에 입문하기 좋게 만들어주고, 이것이 스위프트 5(희망하길)에서 고려되기 시작할때 swift-evolution 메일링 리스트의 토론을 이해하고 참여하는데 도움이 될 것이다.
이 블로그는 공부하고 공유하는 목적으로 운영되고 있습니다. 번역글에대한 피드백은 언제나 환영이며, 좋은글 추천도 함께 받고 있습니다. 피드백은
으로 보내주시면 됩니다.