코어
- 코어는 CPU의 핵심으로 CPU에서 실제로 일을 처리하는 역할을 수행.
- 한 번에 한 가지 일만 처리할 수 있음
- 멀티 코어는 여러 일을 동시에 처리할 수 있음
그렇다면 싱글 코어에서 여러 작업을 수행못함?
- 동시성이라는 개념을 통해 가능하다.
- 동시성이란 시분할로 여러 작업을 반복적으로 옮겨가며 처리함으로써 동시에 작업이 수행되는 것처럼 보이는 것
- 따라서 싱글 코어에서도 동시성을 이용하여 여러 작업을 동시에 실행할 수 있음
그럼 코어의 갯수가 많으면 많을수록 성능이 훨씬 좋겠네?
- 거의 그렇지만, 백퍼센트는 아님
- 멀티 코어에서는 어떤 코어가 어떤 일을 할 지 작업을 나눠야 하는데, 이 과정에서 딜레이가 발생하여 오히려 싱글 코어보다 작업 속도가 줄어들 수 있음
- 또한 소프트웨어가 싱글 코어에 최적화되었다면, 멀티 코어라도 성능이 좋다고 단정지을 수 없음
스레드
- 프로세스 내에서 실행되는 작업의 단위
- 멀티 스레드는 프로세스 내의 스레드가 여러 개라는 것을 의미
- 각 스레드는 작업을 수행함으로 여러 개의 스레드에서 분산적으로 작업을 처리하는 것이 효율적임
- 동시성 프로그래밍 또한 멀티 스레딩을 이용한 프로그래밍임
병렬 프로그래밍
- 동시성은 여러 개의 작업을 시분할로 번갈아가며 처리함으로써 동시에 이루어지는 것처럼 보이는 것이라고 했음
- 그렇다면 병렬은 여러 개의 작업을 진짜 동시에 처리하는 것임
- 실제로 동시에 처리하는 것이기 때문에 병렬 프로그래밍은 물리적으로 멀티 코어일 경우에만 가능하다.
동시성 프로그래밍
- 여러 작업을 동시에 보이는 것처럼 처리하는 것
- 병렬 프로그래밍과 달리 싱글 코어에서도 가능함
- 여러 작업을 번갈아가며 처리하는 것을 컨텍스트 스위칭이라고 함
동시성과 병렬성
- 병렬 프로그래밍은 다중 코어를, 동시성 프로그래밍은 다중 스레드를 활용하는 것임
- 소프트웨어 개발에 있어서 어떤 작업을 어떤 코어에서, 어떤 스레드에서 처리해야 할지 나누는 것은 운영체제가 담당하기 때문에 개발자는 개념만 이해하고 있어도 좋음
동시성이 왜 필요한지?
- 여러 작업을 여러 스레드로 나누어 분담 처리함으로써 효율적으로 작업을 처리하기 위해 -> 사용자 경험 향상
- 예를 들어, 게임을 다운로드 받으면서, 따로 다른 액션을 처리할 수 있게 하는 것
Swift에서는 동시성 프로그래밍을 어떻게 지원하는지?
- GCD, Operation, async/await 이 있음
- GCD는 가장 먼저 나온 API
- Operation은 GCD를 기반으로 만들어진 세부적인 기능이 추가된 API
- async/await은 가장 최근에 나온 API
GCD (Grand Central Dispatch)
- 동시성 작업을 위해서는 여러 개의 스레드를 사용하여, 여러 작업을 분배해야 한다고 했음
- 하지만, 그걸 일일히 분배해주고 있는 것은 굉장히 귀찮고 까다로움
- iOS에서는 작업 대기열에 수행해야 하는 작업들을 넣어주고, 시스템이 알아서 스레드를 통해 작업을 수행.
- 그 작업 대기열이 바로 GCD가 제공하는 DispatchQueue임
DispatchQueue
- Dispatch: 보내다 (파견하다)
- Queue: 대기열
- 즉, DispatchQueue의 정의는 "대기열에 보내다"
- 원하는 작업을 DispatchQueue에 넣어주면, 알아서 필요한 스레드를 생성하여 작업을 분산 처리함
- 기본적으로 FIFO 동작으로 작업을 처리함
DispatchQueue 설정
DispatchQueue에 작업을 넘길 때, 2가지 설정이 필요함
- 단일 스레드를 사용할 것인가 or 다중 스레드를 사용할 것인가? (Serial or Concurrent)
- 동기로 작업을 처리할 것인가 or 비동기로 작업을 처리할 것인가? (sync or async)
DispatchQueue와 GCD의 관계
- GCD는 DispatchQueue보다 더 상위 개념으로 GCD는 Dispatch라는 프레임워크와 동치가 될 수 있음
- Dispatch 프레임워크에는 DispatchQueue, DispatchWorkItem, DispatchGroup, DispatchQoS, DispatchSource, DispatchSemaphore 등 동시성 프로그래밍 작업 처리를 위한 다양한 타입들이 구현되어 있음
- DispatchQueue는 Dispatch 프레임워크에 정의된 하나의 타입일뿐.
Serial or Concurrent
- DispatchQueue는 크게 Serial Queue와 Concurrent Queue로 나눌 수 있음
- Serial Queue는 단일 스레드에서만 작업을 처리함
- Concurrent Queue는 다중 스레드에 작업을 처리함
- DispatchQueue는 기본적으로 Serial이 디폴트
- DispatchQueue를 초기화할 때, attributes를 따로 .concurrent로 설정하면 Concurrent Queue로 작동
// Serial Queue
DispatchQueue(label: "Serial")
DispatchQueue.main // main은 전역적으로 사용되는 Serial DispatchQueue
// Concurrent Queue
DispatchQueue(label: "Concurrent", attributes: .concurrent)
DispatchQueue.global()
헷갈리는 부분 정리
- DispatchQueue()로 새로운 커스텀 큐를 만드는 것은 기본적으로 Serial 큐로 작동함
- 하지만 DispatchQueue.global()로 큐를 만드는 것은 이미 만들어진 큐를 사용하는 것으로 기본적으로 Concurrent 큐로 작동함
main / global
- DispatchQueue.main과 DispatchQueue.global()은 이미 만들어져있는 큐로 각각 Serial, Concurrent 큐임
- main은 일반 Serial 큐와 달리 메모리에 늘 올라와 있으며, 전역적으로 사용가능한 큐
- global()은 새로운 스레드를 생성하여 작업을 사용하게 되는 큐
- main에 작업을 추가하면 Serial 큐인 메인 스레드에서 작업을 처리하게 된다
- 메인 스레드에서 작업을 추가하게 되면, 순차적으로 처리되기 때문에 여러 작업을 동시에 할 수 없음
- global()에 작업을 추가하면 스레드를 생성하여 작업을 처리하게 된다.
- Concurrent Queue이기 때문에 여러 작업을 동시에 처리할 수 있음
- 메인 스레드와 달리 작업을 처리하기 위해 생성된 스레드임. 따라서 메모리에 올라왔다가, 작업을 끝내면 메모리에서 해제된다.
메인 스레드가 뭔데?
- 앱의 기본이 되는 스레드. 앱의 생명주기와 같은 생명주기를 갖는, 앱이 실행되는 동안 메모리에 지속적으로 올라와 있는 스레드임
- 메인 스레드는 UI작업을 담당하기 때문에 다른 작업을 처리하는 동안 사용자는 멈춰있는 UI를 보게 된다.
- 따라서 여러 작업을 메인 스레드가 아닌 다른 여러 스레드로 나눠서 처리할 수 있도록 해야 함
- 우리가 동시성 프로그래밍을 위해 global()로 스레드를 생성하는 것은 모두 메인 스레드에서 파생되어 있는 것임
메인 스레드의 특징
- 전역적으로 존재
- global 스레드들과 다르게 런 루프가 자동으로 설정되고 실행됨. 메인 스레드에서 실행되는 런 루프를 Main run roop라고 함
- UI작업은 메인 스레드에서만 가능함
- 따라서 여러 작업들을 메인 스레드에서 처리하는 것은 앱의 화면을 멈추게하는 것임. -> 사용자 경험 저하
동기 or 비동기
// 동기, sync
DispatchQueue.main.sync {}
DispatchQueue.global().sync {}
// 비동기, async
DispatchQueue.main.async {}
DispatchQueue.global().async {}
- 동기는 작업의 요청과 응답이 동시에 이루어지는 것 -> 다음 요청은 이전 요청의 응답이 오기전까지 실행될 수 없음
- 비동기는 작업의 요청과 응답이 동시에 이루어지지 않음 -> 다음 요청은 이전 요청의 응답이 오지 않아도 실행될 수 있음
main.async
DispatchQueue.main.async {
for _ in 1...5 {
print("11111")
sleep(1)
}
}
DispatchQueue.main.async {
for _ in 1...5 {
print("22222")
sleep(2)
}
}
/*
11111
11111
11111
11111
11111
22222
22222
22222
22222
22222
*/
- 메인 스레드에서 비동기로 작업을 처리하는 코드
- 비동기 작업이라면, 굳이 다른 작업을 기다리지 않고 처리되어야 하지 않나? 하는 의문을 가질 수 있음
- 비동기 코드는 시스템에 의해 잠시 저장됨. 이후, 시스템에 의해 적절하게 비동기적으로 처리되는 것임
- 코드를 보면, main에서 비동기 작업을 호출하고 있음.
- 근데 메인 큐는 Serial 큐이기 때문에 동기, 비동기와 관계없이 순차적으로 들어온 순서대로 작업을 처리함
- 따라서 둘 다 비동기일 경우, 모두 메인 스레드에서 큐에 들어온 순서대로 작업이 처리된다.
sync / async
DispatchQueue.main.async {
for _ in 1...5 {
print("11111")
sleep(1)
}
}
for _ in 1...5 {
print("22222")
sleep(2)
}
- 위 코드도 동일하게 메인스레드에서 호출되기 때문에 순차적으로 큐에 들어온대로 출력될 것이라고 예상할 수 있음
- 하지만 이 코드의 작업 순서는 매번 달라짐. (대개 "22222"가 먼저 출력된다.)
- 이유는 비동기 코드는 시스템에 의해 잠시 저장된다고 했음.
- 따라서 메인스레드는 비동기 코드를 시스템에 맡겨두고, 다음 작업들을 처리하기 때문에 비동기 작업이 미뤄질 수 있는 것임
global().async
DispatchQueue.global().async {
for _ in 1...5 {
print("11111")
sleep(1)
}
}
DispatchQueue.global().async {
for _ in 1...5 {
print("22222")
sleep(2)
}
}
DispatchQueue.main.async {
for _ in 1...5 {
print("33333")
sleep(1)
}
}
- 위 코드의 결과를 예상해보셈.
- 정답은 예측할 수 없다는 것임
- global()은 새로운 스레드를 생성하고, 각 작업은 모두 비동기적으로 호출된다. 이 때, 순서는 시스템에 의해 결정되기 때문에 우리는 결과를 정확하게 예측할 수 없다.
main.sync
DispatchQueue.main.sync { /* error: error: Execution was interrupted, reason: EXC_BREAKPOINT (code=1, subcode=0x18011922c).
The process has been left at the point where it was interrupted, use "thread return -x" to return to the state before expression evaluation. */
- main.sync를 하면 다음과 같은 에러가 등장하는데, 이는 deadlock이 발생하기 때문.
- 우리 일반 코드들은 모두 메인 스레드에서 돌아가고 있음. 즉, 메인 스레드는 계속해서 실행되고 있다.
- sync의 동작 방식을 보면, 해당 요청이 완료될 때까지 다음 코드 블럭으로 이동하지 않음 -> Block - wait 상태를 만든다.
- 여기서 DispatchQueue.main.sync를 한다면?
- 메인 스레드가 호출되고 있는 상황에서 sync로 인해 블락됨
- 메인 큐는 해당 sync 작업을 메인스레드에게 요청함
- 근데 메인 스레드는 블락 상태이기 때문에 해당 요청을 수행할 수가 없음
- 메인 큐 또한 Serial이기 때문에 앞의 작업이 완료되기 전엔 다음 작업을 수행할 수 없음
- 따라서 deadlock이 발생함
다른 SerialQueue에서는?
- 다른 SerialQueue를 생성해서 sync 작업을 하는 것은 문제 없음
무조건 main.sync는 안되나? 그럼 코드가 왜 필요함?
- 무조건은 아님. 이게 필요한 상황들이 있음
- 예를 들어, 백그라운드 스레드에서 작업을 처리하고, 그 결과를 UI에 특별한 순서에 맞게 처리해야 할 경우 main.sync를 사용할 수 있음
그 외의 Dispath 기능들
DispatchWorkItem
let one = DispatchWorkItem {
for _ in 1...5 {
print("11111")
}
}
let two = DispatchWorkItem {
for _ in 1...5 {
print("22222")
}
}
let three = DispatchWorkItem {
for _ in 1...5 {
print("33333")
}
}
DispatchQueue.global().async(execute: one)
DispatchQueue.global().async(execute: two)
DispatchQueue.global().async(execute: three)
- 클로저와 비슷하게 코드 블록을 캡슐화해주는 타입
asyncAfter
- 비동기 코드를 원하는 시간에 호출하도록 하는 메서드
DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: two)
- 위 코드는 지금으로부터 10초 후 two를 비동기적으로 호출하겠다는 메서드
asyncAwait
- 해당 비동기 코드 호출 이후 다음 코드를 호출하는 메서드
DispatchQueue.global().asyncAndWait(execute: one)
DispatchQueue.global().async(execute: two)
- 원래 두 작업 모두 async였다면, 섞여서 실행이 되는 것이 맞음
- 하지만 asyncAndWait을 사용하니, one 작업이 끝난 후, two 작업이 실행된다.
DispatchQueue 초기화
label
- DispatchQueue의 식별자와 같음
- 자세하게는 디버깅 환경에서 추적하기 위해 작성하는 식별자임
qos
- Quality of Service의 약자
- 수행할 작업들의 우선 순위를 지정하는 타입
attributes
- DispatchQueue의 속성을 정해주는 값
- 이 설정을 하지 않았을 경우, 기본 값은 Serial임
- .concurrent로 설정하면 큐가 작업을 Concurrency하게 처리함
- .initiallyInactive로 설정하면 큐가 생성되어도, 작업을 즉시 실행하지 않고, 비활성 상태로 만든다.
- 이후 activate() 메서드를 호출함으로써 원하는 시점에 작업을 활성화시킨다.
let queue = DispatchQueue(label: "dd", qos: .background, attributes: .initiallyInactive)
queue.async(execute: one)
queue.activate()
autoreleaseFrequency
- DispatchQueue에서 객체를 자동으로 해제하는 빈도를 제어하는 열거형
- DispatchQueue는 특정 코드 블록을 수행함.
- 따라서 autoreleaseFrequency는 블록이 끝난 후 객체를 언제 해제할지를 결정함
- inherit: 기본 옵션으로 상위 큐의 설정을 그대로 따름
- workitem: 각 작업 블록이 끝날 때마다 객체를 자동으로 해제
- never: 설정하지 않음
target
- 생성된 DispatchQueue가 실행할 블록들을 어떤 큐에서 실행할지 지정
- 생성된 DispatchQueue가 특정 타겟 큐에 의존하여 작업을 수행하거나, 기본 큐를 사용할 수 있도록 함
- 간단하게, 새로운 DispatchQueue가 작업을 실행할 때, 참조할 target 큐를 설정하는 것임
QoS (Quality of Service)
- QoS를 작업 순서를 지정하는 의미로 생각하는 경우가 있는데, 이는 오해가 조금 있음.
- 여기서 우선 순위란 "어떤 작업에 더 많은 에너지를 쏟을까?" 에 가까움
- 여기서 더 많은 에너지를 쏟는다는 것은 더 많은 스레드를 할당한다는 것을 의미함
- 따라서 일이 처리되는 순서를 결정하는 요소는 아니지만, 어느정도 영향을 미칠 수 있다.
GCD의 스레드 관리는 시스템이 알아서 처리해준다. 따라서 QoS로 우선 순위를 설정해도 자세한건 시스템이 알아서 제어하게 된다. 그리고 시스템은 QoS 정보를 통해 스케줄링, CPU 및 I/O 처리량, 타이머 대기 시간 등의 우선 순위를 조정한다.
우선 순위가 높을수록 더 많은 전력을 소모하고, 각 작업에 대해 QoS를 적절하게 설정해준다면, 좀 더 효율적인 에너지를 사용하는 프로그래밍이 가능하다.
QoS 우선 순위 높은 순으로 나열
- User - interactive
- User - initiated
- Default (기본 값)
- Utility
- Background
- Unspecified
async의 파라미터
- 블록을 비동기적으로 실행하도록 예약하고, 선택적으로 이를 DispatchGroup과 연관시킵니다.
DispatchWorkItemFlags
- 작업 항목(DispatchWorkItem)의 동작 방식을 제어하는 데 사용되는 플래그들의 집합
- 플래그들은 작업 항목이 실행될 때, 어떤 특별한 동작을 할 지 결정하는 역할
- 종류
- assignCurrentContext: 현재 실행 중인 컨텍스트의 속성을 작업 항목에 적용. (현재 설정된 컨텍스트(QoS)에 맞게 작업을 수행)
- barrier: 작업 항목이 병렬 큐에 제출될 때 배리어 블록으로 작동. 배리어 블록은 병렬 큐에서 실행되는 다른 작업 항목들과의 동시 실행을 방지하여, 배리어 블록 앞의 모든 작업이 완료된 후에야 실행됨
- detached: 작업 항목의 속성을 현재 실행 컨텍스트에서 분리. 작업 항목이 독립적으로 실행되도록 하여, 다른 작업의 실행 컨텍스트에 영향을 받지 않도록 함
- enfoceQoS: 작업 항목과 연관된 QoS 클래스를 우선시 함. 작업이 지정된 QoS로 실행되도록 보장
- ingeitQoS: 현재 실행 컨텍스트와 연관된 QoS 클래스를 우선시 함. 작업 항목이 제출된 켄턱스트의 QoS를 상속받도록 함
- noQoS: QoS 클래스를 지정하지 않고, 작업 항목을 실행함. 특정 우선 순위 없이 실행되도록 한다.
DispatchGroup
- 단일 단위로 모니터링할 수 있는 작업 그룹
- 작업을 집합적으로 처리하고, 그룹에서 동기화 동작을 제어할 수 있음
- 여러 작업 항목을 그룹에 첨부하고, 이를 동일한 큐나 다른 큐에서 비동기적으로 실행하도록 예약할 수 있음
- 모든 작업 항목의 실행이 완료되면, 그룹은 completionHandler를 호출합니다.
- 또한 그룹의 모든 작업이 완료될 때까지 동기적으로 대기할 수도 있음
여러 작업을 그룹에 포함시켜 그룹 단위로 작업 상태를 조절해주는 기능임. 이를 사용하면 async 작업들을 묶어서 그룹의 작업이 끝나는 시점을 추적할 수 있음. 이 때, async 작업들이 같은 큐, 스레드에 있지 않더라도 그룹화하는 것이 가능하다. (사진 참고)
DispatchGroup은 async에서 주로 사용된다. 왜냐하면 sync로 동작하는 작업들은 끝나는 시점을 알 수 있는데, async 작업들은 끝나는 시점을 예측할 수 없기 때문임. 따라서 async로 처리되는 작업들의 종료 시점을 추적할 때, DispatchGroup이 유용하게 사용될 수 있다.
DispatchQueue.global().sync
참고로 위와 같은 sync 메서드에는 DispatchGroup 파라미터가 존재하지 않음.
DispatchGroup의 사용 방법
- DispatchGroup은 특별한 초기화 구문이 없음. 바로 인스턴스를 만들어서 사용하며, 그룹화 할 작업들은 같은 인스턴스로 지정해주면 됨.
- async 에서 봤던 방법으로 각 비동기 작업에 DispatchGroup을 할당해주는 방법이 있고,
- enter(), leave() 등의 DispatchGroup 메서드를 사용하여 작업을 관리하는 방법도 있음
let group = DispatchGroup()
// 그룹에 블록이 들어갔음을 알림
group.enter()
DispatchQueue.global().async {
// 비동기 작업 수행
print("비동기 작업 실행 중 1")
}
// 모든 작업이 완료될 때까지 기다림
group.notify(queue: .main) {
print("모든 작업 완료")
}
DispatchQueue.global().async {
// 비동기 작업 수행
print("비동기 작업 실행 중 2")
// 작업 완료 후 그룹에서 나갔음을 알림
group.leave()
}
// 출력
// "비동기 작업 실행 중 1"
// "비동기 작업 실행 중 2"
// "모든 작업 완료"
enter()
- DispatchGroup에 블록이 추가되었음을 명시적으로 알리는 역할을 수행.
- 비동기 작업을 시작하기 전에 그룹에 블록이 들어갔음을 알리기 위해 enter()를 호출함
- 작업이 완료되면, leave()를 호출하여 그룹에서 블록이 나갔음을 알린다.
leave()
- DispatchGroup 내의 블록이 실행 완료되었음을 명시적으로 나타냅니다.
- enter() 메서드와 함께 짝을 이루며 사용되기 때문에, enter()의 호출마다 대응하는 leave() 호출이 필요하다.
notify()
- 현재 그룹의 모든 작업이 완료되었을 때, 파라미터로 지정된 속성에 맞게 작업을 큐에 제출합니다.
- 위 코드에서 사용한 바로는, 그룹 내의 모든 작업이 완료되었을 때 메인 큐로 print("모든 작업 완료")라는 작업을 제출한다는 의미.
- 해당 작업이 호출되면, 그룹 내의 작업은 비어있게 된다.
wait()
- 제출된 작업이 완료될 때까지 동기적으로 대기시킴
- 즉, 그룹 내의 작업이 완료되기 전까지, 현재 wait() 메서드가 할당된 스레드를 차단시킨다.
wait()을 사용하지 않았을 경우
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async {
print("비동기 작업 실행 중 1")
}
DispatchQueue.global().async {
print("비동기 작업 실행 중 2")
group.leave()
}
print("그룹 내의 작업이 완료되면 호출됩니다.")
// 출력
// "그룹 내의 작업이 완료되면 호출됩니다."
// "비동기 작업 실행 중 1"
// "비동기 작업 실행 중 2"
- 위 작업들은 비동기 작업이기 때문에 출력 작업이 먼저 이루어질 수 있음.
wait()을 사용했을 경우
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async {
print("비동기 작업 실행 중 1")
}
DispatchQueue.global().async {
print("비동기 작업 실행 중 2")
group.leave()
}
group.wait()
print("그룹 내의 작업이 완료되면 호출됩니다.")
// 출력
// "비동기 작업 실행 중 1"
// "비동기 작업 실행 중 2"
// "그룹 내의 작업이 완료되면 호출됩니다."
- 실행되는 스레드를 차단하기 때문에 그룹 내의 모든 작업이 완료된 후, 출력이 이루어짐
- 만약 group.leave()를 호출하지 않음으로써 그룹 내의 작업 완료가 명시되지 않을 경우, 출력은 끝까지 이루어지지 않음
만약 그룹 내의 작업이 너무 늦어질 경우?
group.wait(timeout: .now() + 10)
- 위처럼 timeout을 통해 제한된 시간 내에 작업이 완료되지 않으면 차단을 해제함
- 이 때, 시간 내에 완료되지 않은 작업을 멈추는 것이 아니라 다른 스레드에서 진행
- 정의를 보면 알겠지만, 만약 시간 제한 내에 완료되지 않으면 DispatchTimeoutResult 타입을 내보내게 됨
- 이를 통해 해당 작업이 완료되었는지, 안되었는지 여부에 따라 처리도 가능함
let result = group.wait(timeout: .now() + 10)
switch result {
case .success:
case .timedOut:
}
주의할 점 1. 메인 스레드 피하기
- wait() 메서드를 호출할 때, 호출된 스레드를 정지하는 것을 주의해야 한다.
- 만약 이 메서드가 메인 스레드에서 호출되면 앱의 UI가 멈추게 된다.
- 따라서 wait() 메서드는 메인 큐가 아닌 다른 큐에서 호출하도록 해야 한다. (위 사용 예시 코드처럼 사용하면 큰일날 수 있음!)
주의할 점 2. 같은 큐 피하기
- DispatchGroup의 작업과 wait()의 호출이 같은 큐에서 이루어지는 것은 데드락을 발생시킬 수 있음
- 데드락은 두 개 이상의 작업이 서로를 기다리며 영원히 진행되지 않는 상황을 말함
- DispatchGroup의 작업이 수행되기 전까지 wait은 차단하게 된다. 따라서 작업은 아예 시작되지 않고, 기다리게 되기 때문에 데드락이 발생함.
Race Condition
동시성 처리에서는 여러 발생할 수 있는 문제점들이 있음. 그 중 하나가 race condition임
- 여러 스레드 또는 프로세스가 동시에 같은 자원에 접근하여, 자원의 상태가 예기치 않게 변할 수 있는 상황
- 이로 인해 비정상적인 동작이나 예상치 못한 결과를 초래할 수 있음
예시로 은행 계좌가 있음
- 계좌에는 100원이 있음
- A와 B라는 두 사람이 같은 은행 계좌에 접근하여 돈을 인출하려고 함
- A와 B가 동시에 5원을 인출하는 작업을 시작
- A는 잔액을 확인하고, 100원이 있는 것을 확인
- 이와 동시에 B 또한 100원이 있는 것을 확인
- A가 5원을 인출하여 잔액은 5원으로 남게 됨
- 이와 동시에 B 또한 5원을 인출하여 잔액은 5원으로 남게 됨
- 남은 잔액은 0원이어야 하지만, 두 인출 작업이 동시에 이루어져 최종적으로 잔액이 5원이 되는 불상사가 발생함
Race Condition 예시 1
var cards = [1, 2, 3, 4, 5, 6, 7, 8, 9]
DispatchQueue.global().async {
for _ in 1...3 {
let card = cards.removeFirst()
print("짱구: \(card) 카드를 뽑았습니다!")
}
}
DispatchQueue.global().async {
for _ in 1...3 {
let card = cards.removeFirst()
print("철수: \(card) 카드를 뽑았습니다!")
}
}
DispatchQueue.global().async {
for _ in 1...3 {
let card = cards.removeFirst()
print("유리: \(card) 카드를 뽑았습니다!")
}
}
print("최종 카드: \(cards)")
- 1부터 9까지의 카드가 존재하고, 각 인원들은 카드 리스트를 앞에서부터 3개씩 뽑아감
- 예상되는 최종적으로 남은 카드는 []여야 함.
- 하지만 코드 실행 결과는 엉망임
유리: 1 카드를 뽑았습니다!
짱구: 1 카드를 뽑았습니다!
철수: 1 카드를 뽑았습니다!
짱구: 3 카드를 뽑았습니다!
짱구: 5 카드를 뽑았습니다!
유리: 3 카드를 뽑았습니다!
유리: 7 카드를 뽑았습니다!
철수: 8 카드를 뽑았습니다!
철수: 9 카드를 뽑았습니다!
최종 카드: [9]
- 해당 결과는 일관되지 않고, 계속해서 변경된다.
- 이렇게 하나의 배열에 여러 스레드가 동시에 접근하기 때문에 예상치 못한 결과를 낳게 되는 것임
예시 2
var sharedCount = 0
let group = DispatchGroup()
DispatchQueue.global().async(group: group) {
for _ in 1...1000 {
sharedCount += 1
}
}
DispatchQueue.global().async(group: group) {
for _ in 1...1000 {
sharedCount += 1
}
}
DispatchQueue.global().async(group: group) {
for _ in 1...1000 {
sharedCount += 1
}
}
group.notify(queue: .main) {
print(sharedCount)
}
- 각 비동기 작업은 sharedCount를 1000번 반복하여 더해줌
- 그렇다면 3개의 작업이 마무리되었을 경우, sharedCount의 예상 결과 값은 3000이 나와야 함
- 하지만 실행해보면, 결과는 계속 달라짐 e.g. 2888, 2999, 2988 ...
Thread Safe
- Thread-Safe하다는 것은 여러 스레드가 동시에 같은 자원에 접근할 때, 자원의 상태가 일관되도록 작동하기 때문에 스레드로부터 안전하다는 것임
- 따라서 위와 같은 Race Condition의 발생 이유는 Swift의 배열이나 정수 타입이 Thread-Safe 하지 않기 때문임 (이외에도 Swift의 대부분의 타입들은 모두 Non-Thread-Safe 함)
- 이런 동시성과 관련된 문제를 해결할 수 있는 방법에는 NSLock, 세마포어, 뮤텍스 등 여러 가지가 있음
Dispatch Semaphore
- 여러 실행 컨텍스트에 걸쳐 리소스에 대한 접근을 제어하는 카운팅 세마포어를 사용하는 객체
- 호출 스레드가 차단될 필요가 있을 때만 커널에 호출을 함.
- 호출 세마포어가 차단될 필요가 없으면 커널 호출이 이루어지지 않음
- 세마포어 카운트를 증가시키려면 signal() 메서드를 호출하고, 감소시키려면 wait() 메서드를 호출
DispatchSemaphore(value: 3)
- 세마포어는 공유 자원에 접근할 수 있는 스레드의 수를 제한하는 역할을 한다.
- 위 코드에서 value를 3으로 지정했다면, 접근할 수 있는 스레드의 수는 3개인 것임
Dispatch Semaphore 카운팅
let semaphore = DispatchSemaphore(value: 3)
DispatchQueue.global().async(group: group) {
semaphore.wait() // 카운트 감소, 접근 가능한 스레드의 수 3 - 1 = 2
for _ in 1...1000 {
sharedCount += 1
}
semaphore.signal() // 카운트 증가, 접근 가능한 스레드의 수 2 + 1 = 3
}
- 주석을 보면 알 수 있듯이,
- 하나의 스레드가 접근하면 wait()을 통해 카운트를 감소시켜주고
- 작업이 끝나면 signal()을 통해 카운트를 다시 증가시켜준다.
- 반드시 wait()과 signal()의 짝을 맞춰주어야 한다.
DispatchSemaphore를 사용한 문제 해결
let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 1)
var sharedCount = 0
DispatchQueue.global().async(group: group) {
semaphore.wait()
for _ in 1...1000 {
sharedCount += 1
}
semaphore.signal()
}
DispatchQueue.global().async(group: group) {
semaphore.wait()
for _ in 1...1000 {
sharedCount += 1
}
semaphore.signal()
}
DispatchQueue.global().async(group: group) {
semaphore.wait()
for _ in 1...1000 {
sharedCount += 1
}
semaphore.signal()
}
group.notify(queue: .main) {
print(sharedCount)
}
- 위 코드의 우리가 원하는 결과인 3000이 출력됨
DispatchSemaphore를 활용한 동기화
- semaphore는 동기화 작업도 가능함
- 즉, A작업이 완료되기 전까지 B작업이 수행되지 않도록 할 수 있음
//DispatchSemaphore 초기값 0으로 설정
let semaphore = DispatchSemaphore(value: 0)
let work1 = DispatchWorkItem {
print("작업_1 진행중")
print("작업_1 진행완료")
semaphore.signal()
}
let work2 = DispatchWorkItem {
semaphore.wait()
print("작업_2 진행중")
print("작업_2 진행완료")
}
DispatchQueue.global().async(execute: work1)
DispatchQueue.global().async(execute: work2)
/* 출력
작업_1 진행중
작업_1 진행완료
작업_2 진행중
작업_2 진행완료
/*
- 위 작업은 work1이 수행된 이후, work2가 수행된다.
- 이유는 세마포어를 0으로 설정함으로써, wait() 메서드를 호출하게 되면 즉시 스레드가 차단됨
- 이후, work1의 signal() 메서드가 호출되면, 카운트가 증가하면서 wait() 메서드가 해제되는 원리임
이 외의 해결책들
- NSLock
- SerialQueue와 sync
- Actor
개인적으로 Actor 자체가 독립적인 작업을 허용해주는 객체이기 때문에 직관적인 Actor를 사용하는 것이 좋아보임
Reference
'개린이 이야기' 카테고리의 다른 글
면접 후기 (1) | 2024.07.19 |
---|---|
캐시정책에 관하여 (0) | 2024.01.24 |
Downsampling(다운샘플링)에 관하여 (0) | 2024.01.20 |
이미지 캐싱에 관하여 (0) | 2024.01.18 |
MVVM(Model-View-ViewModel)에 관하여 (0) | 2023.07.12 |