iOS의 동시성은 엄청난 주제입니다. 따라서 이 기사에서는 대기열 및 GCD(Grand Central Dispatch) 프레임워크와 관련된 하위 주제를 확대하려고 합니다.
특히 직렬 대기열과 동시 대기열의 차이점과 동기 실행과 비동기 실행의 차이점을 살펴보고 싶습니다.
이전에 GCD를 사용한 적이 없다면 이 기사를 시작하는 것이 좋습니다. GCD에 대한 경험이 있지만 위에서 언급한 주제에 대해 여전히 궁금하다면 여전히 유용할 것입니다. 그리고 그 과정에서 한두 가지 새로운 사실을 알게 되길 바랍니다.
이 기사의 개념을 시각적으로 보여주기 위해 SwiftUI 컴패니언 앱을 만들었습니다. 이 앱에는 이 기사를 읽기 전후에 시도해 볼 수 있는 재미있는 짧은 퀴즈도 있습니다. 여기에서 소스 코드를 다운로드하거나 여기에서 공개 베타를 받으세요.
GCD에 대한 소개로 시작하여 동기화, 비동기, 직렬 및 동시성에 대한 자세한 설명이 이어집니다. 이후에는 동시성으로 작업할 때의 몇 가지 함정에 대해 설명하겠습니다. 마지막으로 요약과 몇 가지 일반적인 조언으로 마무리하겠습니다.
소개
GCD 및 디스패치 대기열에 대한 간략한 소개부터 시작하겠습니다. 동기화 대 비동기화로 건너뛰어도 됩니다. 해당 주제에 대해 이미 알고 있는 경우 섹션을 참조하십시오.
동시성 및 그랜드 센트럴 디스패치
동시성을 사용하면 장치에 여러 CPU 코어가 있다는 사실을 활용할 수 있습니다. 이러한 코어를 사용하려면 여러 스레드를 사용해야 합니다. 그러나 스레드는 저수준 도구이며 효율적인 방식으로 스레드를 수동으로 관리하는 것은 매우 어렵습니다.
Grand Central Dispatch는 개발자가 스레드 자체를 수동으로 생성 및 관리하지 않고도 다중 스레드 코드를 작성할 수 있도록 지원하는 추상화로 10년 이상 전에 Apple에서 만들었습니다.
Apple은 GCD를 통해 비동기식 설계 접근 방식을 채택했습니다. 문제에. 스레드를 직접 생성하는 대신 GCD를 사용하여 작업 작업을 예약하면 시스템이 리소스를 최대한 활용하여 이러한 작업을 수행합니다. GCD는 필수 스레드 생성을 처리하고 해당 스레드에 대한 작업을 예약하여 스레드 관리 부담을 개발자에서 시스템으로 전가합니다.
GCD의 가장 큰 장점은 동시 코드를 작성할 때 하드웨어 리소스에 대해 걱정할 필요가 없다는 것입니다. GCD는 스레드 풀을 관리하며 단일 코어 Apple Watch에서 다중 코어 MacBook Pro까지 확장됩니다.
배치 대기열
이들은 정의한 매개변수 세트를 사용하여 임의의 코드 블록을 실행할 수 있게 해주는 GCD의 주요 빌딩 블록입니다. 디스패치 대기열의 작업은 항상 FIFO(선입 선출) 방식으로 시작됩니다. 내가 시작됨이라고 말했습니다. , 작업 완료 시간은 여러 요인에 따라 달라지며 FIFO가 보장되지 않기 때문입니다(나중에 자세히 설명).
대체로 다음과 같은 세 가지 종류의 대기열을 사용할 수 있습니다.
- 기본 디스패치 대기열(직렬, 사전 정의)
- 전역 대기열(동시, 사전 정의)
- 비공개 대기열(직렬 또는 동시 가능, 직접 생성)
모든 앱은 연속인 기본 대기열과 함께 제공됩니다. 메인 스레드에서 작업을 실행하는 큐. 이 대기열은 애플리케이션의 UI를 그리고 사용자 상호작용(터치, 스크롤, 팬 등)에 응답하는 역할을 합니다. 이 대기열을 너무 오래 차단하면 iOS 앱이 정지된 것처럼 보이고 macOS 앱에 악명 높은 해변이 표시됩니다. 공/회전 바퀴.
장기 실행 작업(네트워크 호출, 계산 집약적인 작업 등)을 수행할 때 백그라운드 대기열에서 이 작업을 수행하여 UI 정지를 방지합니다. 그런 다음 기본 대기열의 결과로 UI를 업데이트합니다.
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
DispatchQueue.main.async { // UI work
self.label.text = String(data: data, encoding: .utf8)
}
}
}
경험상 모든 UI 작업은 기본 대기열에서 실행되어야 합니다. Xcode에서 Main Thread Checker 옵션을 켜면 백그라운드 스레드에서 UI 작업이 실행될 때마다 경고를 받을 수 있습니다.
기본 대기열 외에도 모든 앱에는 다양한 수준의 서비스 품질(GCD의 우선 순위에 대한 추상적 개념)이 있는 미리 정의된 여러 동시 대기열이 함께 제공됩니다.
예를 들어 다음은 사용자 대화형에 비동기식으로 작업을 제출하는 코드입니다. (가장 높은 우선 순위) QoS 대기열:
DispatchQueue.global(qos: .userInteractive).async {
print("We're on a global concurrent queue!")
}
또는 기본 우선순위를 호출할 수 있습니다. 다음과 같이 QoS를 지정하지 않음으로써 전역 대기열:
DispatchQueue.global().async {
print("Generic global queue")
}
또한 다음 구문을 사용하여 고유한 개인 대기열을 만들 수 있습니다.
let serial = DispatchQueue(label: "com.besher.serial-queue")
serial.async {
print("Private serial queue")
}
개인 대기열을 생성할 때 설명 레이블(예:역 DNS 표기법)을 사용하면 Xcode의 내비게이터, lldb 및 Instruments에서 디버깅하는 동안 도움이 됩니다.
기본적으로 개인 대기열은 직렬입니다. (이것이 곧 의미하는 바를 설명하겠습니다. 약속합니다!) 비공개 동시 대기열에 있는 경우 선택적 속성을 통해 수행할 수 있습니다. 매개변수:
let concurrent = DispatchQueue(label: "com.besher.serial-queue", attributes: .concurrent)
concurrent.sync {
print("Private concurrent queue")
}
선택적 QoS 매개변수도 있습니다. 생성한 개인 대기열은 주어진 매개변수에 따라 궁극적으로 글로벌 동시 대기열 중 하나에 도착합니다.
작업에는 무엇이 있습니까?
대기열에 작업을 디스패치하는 것을 언급했습니다. 작업은 sync
을 사용하여 대기열에 제출하는 모든 코드 블록을 참조할 수 있습니다. 또는 async
기능. 익명 마감 형식으로 제출할 수 있습니다.
DispatchQueue.global().async {
print("Anonymous closure")
}
또는 나중에 수행되는 디스패치 작업 항목 내부:
let item = DispatchWorkItem(qos: .utility) {
print("Work item to be executed later")
}
동기 또는 비동기로 디스패치하는지 여부와 직렬 또는 동시 대기열을 선택하는지 여부에 관계없이 단일 작업 내의 모든 코드는 한 줄씩 실행됩니다. 동시성은 여러를 평가할 때만 관련이 있습니다. 작업.
예를 들어 같은 안에 3개의 루프가 있는 경우 이 루프는 항상 순서대로 실행:
DispatchQueue.global().async {
for i in 0..<10 {
print(i)
}
for _ in 0..<10 {
print("?")
}
for _ in 0..<10 {
print("?")
}
}
이 코드는 클로저를 전달하는 방법에 관계없이 항상 0에서 9까지의 10자리 숫자, 10개의 파란색 원, 10개의 깨진 하트를 출력합니다.
개별 작업도 자체 QoS 수준을 가질 수 있습니다(기본적으로 대기열의 우선 순위를 사용합니다.) 대기열 QoS와 작업 QoS 간의 이러한 구분은 우선 순위 반전 섹션에서 논의할 몇 가지 흥미로운 동작으로 이어집니다.
지금쯤이면 연속이 무엇인지 궁금하실 것입니다. 및 동시 에 대한 모든 것입니다. sync
의 차이점에 대해서도 궁금하실 것입니다. 및 async
작업을 제출할 때. 이것은 우리를 이 기사의 핵심으로 가져오므로 자세히 살펴보겠습니다!
동기화 vs 비동기화
작업을 대기열에 보낼 때 sync
및 async
디스패치 기능. 동기화 및 비동기는 주로 소스에 영향을 미칩니다. 제출된 작업의 from .
코드가 sync
에 도달하면 명령문을 실행하면 해당 작업이 완료될 때까지 현재 대기열을 차단합니다. 작업이 반환/완료되면 제어가 호출자에게 반환되고 sync
뒤에 오는 코드가 반환됩니다. 작업은 계속됩니다.
sync
를 생각해 보세요. '차단'과 동의어입니다.
async
반면에 명령문은 현재 큐에 대해 비동기적으로 실행되며 async
의 내용을 기다리지 않고 즉시 호출자에게 제어를 반환합니다. 실행할 폐쇄. 해당 비동기 클로저 내부의 코드가 정확히 언제 실행되는지에 대한 보장은 없습니다.
현재 대기열?
출처 또는 현재 , 큐는 코드에 항상 명시적으로 정의되어 있지 않기 때문입니다.
예를 들어 sync
viewDidLoad 내부의 문에서 현재 대기열은 기본 디스패치 대기열이 됩니다. URLSession 완료 핸들러 내에서 동일한 함수를 호출하면 현재 대기열이 백그라운드 대기열이 됩니다.
동기화와 비동기로 돌아가서 다음 예를 들어보겠습니다.
DispatchQueue.global().sync {
print("Inside")
}
print("Outside")
// Console output:
// Inside
// Outside
위의 코드는 현재 큐를 차단하고 클로저를 입력한 다음 "Outside"를 인쇄하기 전에 "Inside"를 인쇄하여 전역 큐에서 해당 코드를 실행합니다. 이 주문은 보장됩니다.
async
을 시도하면 어떻게 되는지 봅시다. 대신:
DispatchQueue.global().async {
print("Inside")
}
print("Outside")
// Potential console output (based on QoS):
// Outside
// Inside
우리 코드는 이제 전역 대기열에 클로저를 제출하고 즉시 다음 줄을 실행합니다. 가능성 "Inside" 앞에 "Outside"를 인쇄하지만 이 순서는 보장되지 않습니다. 소스 및 대상 대기열의 QoS와 시스템이 제어하는 기타 요소에 따라 다릅니다.
스레드는 GCD의 구현 세부 사항입니다. — 우리는 스레드를 직접 제어할 수 없으며 대기열 추상화를 사용해서만 처리할 수 있습니다. 그럼에도 불구하고 GCD에서 발생할 수 있는 몇 가지 문제를 이해하기 위해 스레드 동작을 '숨겨서 엿보기'하는 것이 유용할 수 있다고 생각합니다.
예를 들어 sync
를 사용하여 작업을 제출할 때 , GCD는 현재 스레드(호출자)에서 해당 작업을 실행하여 성능을 최적화합니다.
그러나 한 가지 예외가 있습니다. 동기화 작업을 메인 큐에 제출할 때입니다. 이렇게 하면 항상 호출자가 아닌 메인 스레드에서 작업이 실행됩니다. 이 동작은 우선 순위 반전 섹션에서 살펴볼 몇 가지 파급 효과를 가질 수 있습니다.
어떤 것을 사용할 것인가?
대기열에 작업을 제출할 때 Apple은 동기 실행보다 비동기 실행을 사용할 것을 권장합니다. 그러나 sync
경쟁 조건을 처리할 때나 아주 작은 작업을 수행할 때와 같이 더 나은 선택이 될 수 있습니다. 이러한 상황을 곧 다루겠습니다.
함수 내에서 작업을 비동기적으로 수행하는 한 가지 큰 결과는 함수가 더 이상 값을 직접 반환할 수 없다는 것입니다(수행 중인 비동기 작업에 의존하는 경우). 대신 결과를 전달하기 위해 클로저/완료 핸들러 매개변수를 사용해야 합니다.
이 개념을 설명하기 위해 이미지 데이터를 받아들이고 이미지를 처리하기 위해 값비싼 계산을 수행한 다음 결과를 반환하는 작은 함수를 사용하겠습니다.
func processImage(data: Data) -> UIImage? {
guard let image = UIImage(data: data) else { return nil }
// calling an expensive function
let processedImage = upscaleAndFilter(image: image)
return processedImage
}
이 예에서 함수 upscaleAndFilter(image:)
몇 초가 걸릴 수 있으므로 UI가 정지되는 것을 방지하기 위해 별도의 대기열로 오프로드하려고 합니다. 이미지 처리를 위한 전용 대기열을 만든 다음 값비싼 함수를 비동기식으로 전달해 보겠습니다.
let imageProcessingQueue = DispatchQueue(label: "com.besher.image-processing")
func processImageAsync(data: Data) -> UIImage? {
guard let image = UIImage(data: data) else { return nil }
imageProcessingQueue.async {
let processedImage = upscaleAndFilter(image: image)
return processedImage
}
}
이 코드에는 두 가지 문제가 있습니다. 첫째, return 문은 비동기 클로저 내부에 있으므로 더 이상 processImageAsync(data:)
값을 반환하지 않습니다. 기능을 수행하며 현재는 아무런 역할을 하지 않습니다.
하지만 더 큰 문제는 processImageAsync(data:)
함수가 async
에 들어가기 전에 본문의 끝에 도달하기 때문에 함수는 더 이상 값을 반환하지 않습니다. 폐쇄.
이 오류를 수정하기 위해 더 이상 값을 직접 반환하지 않도록 함수를 조정합니다. 대신 비동기 함수가 작업을 완료하면 호출할 수 있는 새로운 완료 핸들러 매개변수가 있습니다.
let imageProcessingQueue = DispatchQueue(label: "com.besher.image-processing")
func processImageAsync(data: Data, completion: @escaping (UIImage?) -> Void) {
guard let image = UIImage(data: data) else {
completion(nil)
return
}
imageProcessingQueue.async {
let processedImage = self.upscaleAndFilter(image: image)
completion(processedImage)
}
}
이 예제에서 분명히 알 수 있듯이 함수를 비동기식으로 만들기 위한 변경 사항은 호출자에게 전파되었으며 호출자는 이제 클로저를 전달하고 결과도 비동기식으로 처리해야 합니다. 비동기 작업을 도입하면 잠재적으로 여러 기능의 체인을 수정할 수 있습니다.
동시성 및 비동기식 실행은 방금 관찰한 것처럼 프로젝트에 복잡성을 추가합니다. 이 간접화는 또한 디버깅을 더 어렵게 만듭니다. 그렇기 때문에 설계 초기에 동시성에 대해 생각하는 것이 정말 가치 있는 일입니다. — 설계 주기가 끝날 때 추가하고 싶은 사항이 아닙니다.
대조적으로 동기 실행은 복잡성을 증가시키지 않습니다. 오히려 이전처럼 return 문을 계속 사용할 수 있습니다. sync
를 포함하는 함수 작업은 해당 작업 내부의 코드가 완료될 때까지 반환되지 않습니다. 따라서 완료 핸들러가 필요하지 않습니다.
작은 작업(예:값 업데이트)을 제출하는 경우 동기식으로 수행하는 것이 좋습니다. 이렇게 하면 코드를 단순하게 유지하는 데 도움이 될 뿐만 아니라 성능도 향상됩니다. — Async는 완료하는 데 1ms 미만이 걸리는 작은 작업을 비동기식으로 수행하는 것보다 오버헤드가 더 많이 발생하는 것으로 알려져 있습니다.
그러나 위에서 수행한 이미지 처리와 같이 큰 작업을 제출하는 경우 호출자를 너무 오랫동안 차단하지 않도록 비동기식으로 수행하는 것이 좋습니다.
동일한 대기열에서 디스패치
작업을 대기열에서 자체로 비동기식으로 전달하는 것은 안전하지만(예:현재 대기열에서 .asyncAfter를 사용할 수 있음) 작업을 동기식으로 전달할 수는 없습니다. 대기열에서 동일한 대기열로 그렇게 하면 앱이 즉시 충돌하는 교착 상태가 발생합니다!
이 문제는 원래 대기열로 다시 연결되는 동기 호출 체인을 수행할 때 나타날 수 있습니다. 즉, sync
작업을 다른 대기열로 옮기고 작업이 완료되면 결과를 원래 대기열로 다시 동기화하여 교착 상태가 발생합니다. async
사용 이러한 충돌을 피하기 위해.
기본 대기열 차단
에서 동기적으로 작업 디스패치 기본 대기열은 해당 대기열을 차단하여 작업이 완료될 때까지 UI를 정지시킵니다. 따라서 정말 가벼운 작업을 수행하지 않는 한 메인 큐에서 동기적으로 작업을 디스패치하는 것을 피하는 것이 좋습니다.
직렬 대 동시
연속 및 동시 목적지 에 영향 — 작업이 실행되도록 제출된 대기열입니다. 이는 동기화와 대조됩니다. 및 비동기 , 소스에 영향을 미쳤습니다. .
직렬 대기열은 해당 대기열에서 디스패치하는 작업의 수에 관계없이 한 번에 둘 이상의 스레드에서 작업을 실행하지 않습니다. 결과적으로 작업은 선입선출 순서로 시작될 뿐만 아니라 종료됩니다.
또한 직렬 대기열을 차단하면(sync
호출, 세마포어 또는 기타 도구), 해당 대기열의 모든 작업은 블록이 끝날 때까지 중지됩니다.
동시 대기열은 여러 스레드를 생성할 수 있으며 시스템은 생성되는 스레드 수를 결정합니다. 작업은 항상 시작합니다. FIFO 순서이지만 대기열은 다음 작업을 시작하기 전에 작업이 완료될 때까지 기다리지 않으므로 동시 대기열의 작업은 어떤 순서로든 완료될 수 있습니다.
동시 대기열에서 차단 명령을 수행하면 이 대기열의 다른 스레드는 차단되지 않습니다. 또한 동시 대기열이 차단되면 스레드 폭발 위험이 있습니다. . 나중에 더 자세히 다루겠습니다.
앱의 기본 대기열은 직렬입니다. 사전 정의된 전역 대기열은 모두 동시적입니다. 생성하는 모든 개인 디스패치 대기열은 기본적으로 직렬이지만 앞에서 설명한 대로 선택적 속성을 사용하여 동시 처리되도록 설정할 수 있습니다.
여기서 직렬 대 동시 특정 대기열을 논의할 때만 관련이 있습니다. 모든 대기열은 서로에 대해 동시에 발생합니다. .
즉, 기본 대기열에서 비공개 직렬로 비동기식으로 작업을 디스패치하는 경우 대기열, 해당 작업은 동시에 완료됩니다. 메인 큐에 대해. 그리고 두 개의 서로 다른 직렬 대기열을 만든 다음 그 중 하나에서 차단 작업을 수행하면 다른 대기열은 영향을 받지 않습니다.
여러 직렬 대기열의 동시성을 보여주기 위해 다음 예를 들어보겠습니다.
let serial1 = DispatchQueue(label: "com.besher.serial1")
let serial2 = DispatchQueue(label: "com.besher.serial2")
serial1.async {
for _ in 0..<5 { print("?") }
}
serial2.async {
for _ in 0..<5 { print("?") }
}
여기서 두 대기열은 직렬이지만 서로 관련하여 동시에 실행되기 때문에 결과가 뒤죽박죽입니다. 그것들이 각각 직렬(또는 동시)이라는 사실은 이 결과에 영향을 미치지 않습니다. 그들의 QoS 수준은 누가 일반적으로 먼저 완료하십시오(주문은 보장되지 않음).
두 번째 루프를 시작하기 전에 첫 번째 루프가 먼저 완료되도록 하려면 호출자로부터 동기적으로 첫 번째 작업을 제출할 수 있습니다.
let serial1 = DispatchQueue(label: "com.besher.serial1")
let serial2 = DispatchQueue(label: "com.besher.serial2")
serial1.sync { // <---- we changed this to 'sync'
for _ in 0..<5 { print("?") }
}
// we don't get here until first loop terminates
serial2.async {
for _ in 0..<5 { print("?") }
}
첫 번째 루프가 실행되는 동안 이제 호출자를 차단하기 때문에 이것이 반드시 바람직한 것은 아닙니다.
호출자를 차단하는 것을 피하기 위해 두 작업을 비동기식으로 제출할 수 있지만 동일한 직렬 대기열:
let serial = DispatchQueue(label: "com.besher.serial")
serial.async {
for _ in 0..<5 { print("?") }
}
serial.async {
for _ in 0..<5 { print("?") }
}
이제 우리의 작업은 호출자와 관련하여 동시에 실행됩니다. , 주문도 그대로 유지합니다.
선택적 매개변수를 통해 단일 대기열을 동시에 만들면 예상대로 뒤죽박죽된 결과로 돌아갑니다.
let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
concurrent.async {
for _ in 0..<5 { print("?") }
}
concurrent.async {
for _ in 0..<5 { print("?") }
}
때때로 동기 실행을 직렬 실행과 혼동할 수 있지만(적어도 저는 그랬습니다), 그것들은 매우 다릅니다. 예를 들어, 3행의 첫 번째 발송을 이전 예제에서 sync
로 변경해 보십시오. 전화:
let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
concurrent.sync {
for _ in 0..<5 { print("?") }
}
concurrent.async {
for _ in 0..<5 { print("?") }
}
갑자기 결과가 완벽한 순서로 돌아왔습니다. 그러나 이것은 동시 대기열인데 어떻게 그런 일이 일어날 수 있습니까? sync
명령문이 어떻게 든 직렬 대기열로 바뀌나요?
대답은 아니요!입니다.
이것은 약간 교활합니다. async
에 도달하지 못한 일이 발생했습니다. 첫 번째 작업이 실행을 완료할 때까지 호출합니다. 대기열은 여전히 매우 동시적이지만 코드의 이 확대된 섹션 내부에 있습니다. 마치 직렬인 것처럼 보입니다. 이는 호출자를 차단하고 첫 번째 작업이 완료될 때까지 다음 작업을 진행하지 않기 때문입니다.
앱의 다른 큐가 sync
을 실행하는 동안 동일한 큐에 작업을 제출하려고 시도한 경우 해당 작업은 할 여기에서 실행한 것과 동시에 실행합니다. 여전히 동시 대기열이기 때문입니다.
어떤 것을 사용할 것인가?
직렬 대기열은 CPU 최적화 및 캐싱을 활용하고 컨텍스트 전환을 줄이는 데 도움이 됩니다.
Apple은 앱의 하위 시스템당 하나의 직렬 대기열로 시작할 것을 권장합니다. 대기열을 작성할 때 매개변수입니다.
성능 병목 현상이 발생하면 앱의 성능을 측정한 다음 동시 대기열이 도움이 되는지 확인합니다. 측정 가능한 이점이 없다면 직렬 대기열을 유지하는 것이 좋습니다.
위험
우선 전환 및 서비스 품질
우선 순위 반전은 우선 순위가 높은 작업이 우선 순위가 낮은 작업에 의해 실행되는 것을 방지하여 상대적인 우선 순위를 효과적으로 반전시키는 것입니다.
이 상황은 높은 QoS 대기열이 낮은 QoS 대기열과 리소스를 공유하고 낮은 QoS 대기열이 해당 리소스를 잠글 때 자주 발생합니다.
그러나 우리 논의와 더 관련이 있는 다른 시나리오를 다루고 싶습니다. 낮은 QoS 직렬 대기열에 작업을 제출한 다음 동일한 대기열에 높은 QoS 작업을 제출하는 경우입니다. 높은 QoS 작업은 낮은 QoS 작업이 완료될 때까지 기다려야 하기 때문에 이 시나리오에서는 우선 순위가 반전됩니다.
GCD는 우선 순위가 높은 작업보다 '앞에' 있거나 차단하는 낮은 우선 순위 작업이 포함된 대기열의 QoS를 일시적으로 높여 우선 순위 반전을 해결합니다.
자동차가앞에 갇힌 것과 같습니다. 의 구급차. 갑자기 그들은 구급차가 움직일 수 있도록 빨간 신호를 건너도록 허용됩니다 (실제로 자동차는 옆으로 이동하지만 좁은 (직렬) 거리 또는 무언가를 상상해 보면 요점을 알 수 있습니다 :-P)
반전 문제를 설명하기 위해 다음 코드부터 시작하겠습니다.
enum Color: String {
case blue = "?"
case white = "⚪️"
}
func output(color: Color, times: Int) {
for _ in 1...times {
print(color.rawValue)
}
}
let starterQueue = DispatchQueue(label: "com.besher.starter", qos: .userInteractive)
let utilityQueue = DispatchQueue(label: "com.besher.utility", qos: .utility)
let backgroundQueue = DispatchQueue(label: "com.besher.background", qos: .background)
let count = 10
starterQueue.async {
backgroundQueue.async {
output(color: .white, times: count)
}
backgroundQueue.async {
output(color: .white, times: count)
}
utilityQueue.async {
output(color: .blue, times: count)
}
utilityQueue.async {
output(color: .blue, times: count)
}
// next statement goes here
}
시작 대기열을 만듭니다(여기서 from에서 작업을 제출합니다. ), QoS가 다른 두 개의 대기열. 그런 다음 이 두 대기열 각각에 작업을 발송하고 각 작업은 동일한 수의 특정 색상의 원을 인쇄합니다(유틸리티 대기열 파란색, 배경 흰색입니다.)
이러한 작업은 비동기식으로 제출되기 때문에 앱을 실행할 때마다 약간 다른 결과를 보게 됩니다. 그러나 예상대로 QoS(백그라운드)가 낮은 대기열은 거의 항상 마지막에 끝납니다. 사실, 마지막 10-15개의 원은 일반적으로 모두 흰색입니다.
하지만 동기화를 제출하면 어떻게 되는지 지켜보세요. 마지막 비동기 문 이후에 백그라운드 대기열로 작업을 보냅니다. sync
안에 아무 것도 인쇄할 필요도 없습니다. 문, 다음 줄만 추가하면 충분합니다.
// add this after the last async statement,
// still inside starterQueue.async
backgroundQueue.sync {}
콘솔의 결과가 뒤집혔습니다! 이제 우선 순위가 더 높은 대기열(유틸리티)은 항상 마지막에 완료되며 마지막 10-15개의 원이 파란색입니다.
왜 그런 일이 일어나는지 이해하려면 동기 작업이 호출자 스레드에서 실행된다는 사실을 다시 살펴볼 필요가 있습니다(메인 큐에 제출하지 않는 한).
위의 예에서 호출자(starterQueue)는 최고의 QoS(userInteractive)를 가지고 있습니다. 따라서 겉보기에 무해한 sync
작업은 스타터 대기열을 차단할 뿐만 아니라 스타터의 높은 QoS 스레드에서도 실행됩니다. 따라서 작업은 높은 QoS로 실행되지만 백그라운드가 있는 동일한 백그라운드 대기열에 두 개의 다른 작업이 있습니다. QoS. 우선 순위 반전이 감지되었습니다!
예상대로 GCD는 높은 QoS 작업과 일시적으로 일치하도록 전체 대기열의 QoS를 높여 이 반전을 해결합니다. 결과적으로 백그라운드 대기열의 모든 작업은 결국 사용자 대화형에서 실행됩니다. 유틸리티보다 높은 QoS QoS. 이것이 유틸리티 작업이 가장 늦게 끝나는 이유입니다!
참고:해당 예제에서 시작 대기열을 제거하고 대신 기본 대기열에서 제출하면 기본 대기열에도 사용자 대화형이 있으므로 유사한 결과를 얻을 수 있습니다. QoS.
이 예에서 우선 순위 반전을 방지하려면 sync
으로 시작 대기열을 차단하지 않아야 합니다. 성명. async
사용 그 문제를 해결할 것입니다.
항상 이상적인 것은 아니지만 개인 대기열을 생성하거나 글로벌 동시 대기열로 디스패치할 때 기본 QoS를 고수하여 우선 순위 역전을 최소화할 수 있습니다.
스레드 폭발
동시 대기열을 사용할 때 주의하지 않으면 스레드 폭발의 위험이 있습니다. 이것은 현재 차단된 동시 대기열에 작업을 제출하려고 할 때 발생할 수 있습니다(예:세마포어, 동기화 또는 기타 방법). 작업은 할 실행되지만 시스템은 이러한 새 작업을 수용하기 위해 새 스레드를 스핀업하게 될 가능성이 높으며 스레드는 저렴하지 않습니다.
각 직렬 대기열은 하나의 스레드만 사용할 수 있기 때문에 Apple이 앱의 하위 시스템당 직렬 대기열로 시작하도록 제안하는 이유일 수 있습니다. 직렬 대기열은 관계에서 동시적임을 기억하십시오. 다른 따라서 동시 작업이 아니더라도 작업을 대기열로 오프로드하면 성능상의 이점을 얻을 수 있습니다.
경주 조건
Swift Arrays, Dictionaries, Structs 및 기타 값 유형은 기본적으로 스레드로부터 안전하지 않습니다. 예를 들어 액세스 및 수정하려는 스레드가 여러 개인 경우 동일한 어레이에서 문제가 발생하기 시작합니다.
잠금 또는 세마포어 사용과 같은 독자-작성자 문제에 대한 다양한 솔루션이 있습니다. 하지만 여기서 논의하고 싶은 관련 솔루션은 격리 대기열을 사용하는 것입니다.
정수 배열이 있고 이 배열을 참조하는 비동기 작업을 제출하려고 한다고 가정해 보겠습니다. 우리의 작업이 읽기만 하는 한 배열을 수정하지 않고 안전합니다. 그러나 비동기 작업 중 하나에서 배열을 수정하려고 하자마자 앱에 불안정성이 도입됩니다.
앱이 문제 없이 10번 실행되고 11번 만에 다운되기 때문에 까다로운 문제입니다. 이 상황에 매우 편리한 도구 중 하나는 Xcode의 Thread Sanitizer입니다. 이 옵션을 활성화하면 앱에서 잠재적 경쟁 조건을 식별하는 데 도움이 됩니다.
문제를 설명하기 위해 다음과 같은 (물론 인위적인) 예를 들어보겠습니다.
class ViewController: UIViewController {
let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
var array = [1,2,3,4,5]
override func viewDidLoad() {
for _ in 0...1 {
race()
}
}
func race() {
concurrent.async {
for i in self.array { // read access
print(i)
}
}
concurrent.async {
for i in 0..<10 {
self.array.append(i) // write access
}
}
}
}
async
중 하나 작업은 값을 추가하여 배열을 수정합니다. 시뮬레이터에서 이것을 실행하려고 하면 충돌하지 않을 수 있습니다. 그러나 충분한 시간을 실행하면(또는 7행에서 루프 주파수를 높이면) 결국 충돌하게 됩니다. 스레드 살균기를 활성화하면 앱을 실행할 때마다 경고가 표시됩니다.
이 경쟁 조건을 처리하기 위해 barrier 플래그를 사용하는 격리 대기열을 추가할 것입니다. 이 플래그는 대기열에 있는 모든 미해결 작업이 완료되도록 허용하지만 차단 작업이 완료될 때까지 추가 작업이 실행되지 않도록 차단합니다.
장벽을 공중 화장실을 청소하는 청소부(공유 리소스)와 같이 생각하십시오. 화장실 내부에는 사람들이 사용할 수 있는 여러 (동시) 포장 마차가 있습니다.
도착하면 청소부가 청소가 끝날 때까지 새 사람들이 들어오는 것을 막는 청소 표지판(방벽)을 설치하지만 청소부는 안에 있는 사람들이 모두 일을 마칠 때까지 청소를 시작하지 않습니다. 그들이 모두 나가면 관리인은 공중화장실 청소를 따로 진행한다.
마지막으로 작업이 끝나면 관리인이 간판(방벽)을 제거하여 외부에 줄을 선 사람들이 드디어 입장할 수 있도록 합니다.
코드로 보면 다음과 같습니다.
class ViewController: UIViewController {
let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
let isolation = DispatchQueue(label: "com.besher.isolation", attributes: .concurrent)
private var _array = [1,2,3,4,5]
var threadSafeArray: [Int] {
get {
return isolation.sync {
_array
}
}
set {
isolation.async(flags: .barrier) {
self._array = newValue
}
}
}
override func viewDidLoad() {
for _ in 0...15 {
race()
}
}
func race() {
concurrent.async {
for i in self.threadSafeArray {
print(i)
}
}
concurrent.async {
for i in 0..<10 {
self.threadSafeArray.append(i)
}
}
}
}
새로운 격리 대기열을 추가하고 어레이를 수정할 때 장벽을 배치하는 getter 및 setter를 사용하여 개인 어레이에 대한 액세스를 제한했습니다.
getter는 sync
이어야 합니다. 값을 직접 반환하기 위해. setter는 async
일 수 있습니다. , 쓰기가 진행되는 동안 호출자를 차단할 필요가 없기 때문입니다.
경쟁 조건을 해결하기 위해 장벽 없이 직렬 대기열을 사용할 수 있었지만 어레이에 대한 동시 읽기 액세스를 갖는 이점을 잃게 됩니다. 아마도 그것이 귀하의 경우에 합리적일 수 있습니다. 귀하가 결정해야 합니다.
결론
여기까지 읽어주셔서 감사합니다! 이 기사에서 새로운 것을 배웠기를 바랍니다. 요약과 몇 가지 일반적인 조언을 남겨 드리겠습니다.
요약
- 대기열은 항상 시작합니다. FIFO 순서로 작업
- 대기열은 기타에 대해 항상 동시 발생 대기열
- 동기화 대 비동기 출처 관련
- 직렬 대 동시 목적지 관련
- 동기화는 '차단'과 동의어입니다.
- 비동기는 즉시 호출자에게 제어권을 반환합니다.
- 시리얼은 단일 스레드를 사용하며 실행 순서를 보장합니다.
- 동시에 다중 스레드를 사용하고 스레드 폭발 위험이 있음
- 설계 주기 초기에 동시성 고려
- 동기 코드는 더 쉽게 추론하고 디버그할 수 있습니다.
- 가능한 경우 전역 동시 대기열에 의존하지 마십시오.
- 서브시스템당 직렬 대기열로 시작하는 것을 고려
- 측정 가능이 표시되는 경우에만 동시 대기열로 전환 성능 이점
나는 '동시의 바다에 직렬화의 섬'이 있다는 Swift Concurrency Manifesto의 은유를 좋아합니다. 이 감정은 Matt Diephouse의 이 트윗에서도 공유되었습니다.
동시 코드 작성의 비결은 대부분의 코드를 직렬화하는 것입니다. 동시성을 작은 외부 계층으로 제한합니다. (직렬 코어, 동시 쉘.)
— Matt Diephouse(@mdiep) 2019년 12월 18일
예를 들어 잠금을 사용하여 5개의 속성을 관리하는 대신, 이를 래핑하고 잠금 내부의 단일 속성을 사용하는 새 유형을 생성합니다.
그 철학을 염두에 두고 동시성을 적용하면 복잡한 콜백에 헤매지 않고 추론할 수 있는 동시성 코드를 달성하는 데 도움이 될 것이라고 생각합니다.
질문이나 의견이 있는 경우 Twitter에서 저에게 연락해 주세요.
베셔 알 말레
Unsplash의 Onur K 표지 사진
여기에서 컴패니언 앱을 다운로드하십시오.
almaleh/DispatcherCompanion 앱을 동시성에 대한 내 기사에 추가합니다. GitHub에 계정을 만들어 almaleh/Dispatcher 개발에 기여하세요. almalehGitHub다른 기사를 확인하세요:
Fireworks — Swift용 시각적 파티클 편집기 파티클 효과를 디자인하고 반복할 때 macOS 및 iOS용 Swift 코드를 즉시 생성 베셔 알 말레무결한 iOS [약한 자아]는 (항상) 필요하지 않습니다. 이 기사에서는 약한 자아에 대해 이야기하겠습니다. inside of Swift closures to avoid retain cycles &explore cases where it may or may not be necessary to capture self weakly. Besher Al MalehFlawless iOSFurther reading:
IntroductionExplains how to implement concurrent code paths in an application.Concurrent Programming:APIs and Challenges · objc.ioobjc.io publishes books on advanced techniques and practices for iOS and OS X development Florian Kugler Low-Level Concurrency APIs · objc.ioobjc.io publishes books on advanced techniques and practices for iOS and OS X development Daniel Eggerthttps://khanlou.com/2016/04/the-GCD-handbook/
Concurrent vs serial queues in GCDI’m struggling to fully understand the concurrent and serial queues in GCD. I have some issues and hoping someone can answer me clearly and at the point.I’m reading that serial queues are created... Bogdan AlexandruStack Overflow