ios
11. 동기화 공부하기
코코넛딩
2023. 12. 13. 16:39
챗지피티와 구글 번역기를 사용했습니다. 틀린 내용있으면 댓글 달아주세요.
참조한 블로그도 첨부합니다!
https://dongkyprogramming.tistory.com/17
Swift - NSLock 간단히 이해하기
어느 언어든지 간에 Thread를 사용하는 프로그램을 개발할 때에는 Thread Safe하게 코드를 구성해야 할 때가 있습니다. Swift에서도 마찬가지입니다. Swift에는 간단히 사용할 수 있는 NSLock라는 클래스
dongkyprogramming.tistory.com
[iOS] 차근차근 시작하는 GCD — 10
DispatchSemaphore 에 대해 알아봅시다
sujinnaljin.medium.com
동시성
-
- 특정 프로세스의 실행 시간이 다른 프로세스의 흐름과 겹치는 상황에서 동시에 실행한다고 말한다.
- 동시에 실행하거나 동시에 실행하는 것처럼 보이게 하는 것을 동시성이라고 한다.
- 싱글코어에서는 2개의 작업을 동시에 실행하는 것 처럼 보이기 위해 context switch가 일어난다.
- context switch
- 프로세스나 스레드의 상태를 저장하여 다른 프로세스의 실행을 전환한 다음 새 프로세스가 실행을 시작하기 전에 마지막으로 저장된 상태가 복원된다.
프로세스 (process)
- 컴퓨터에서 연속적으로 실행되고 있는 컴퓨터 프로그램
- 프로세스는 하드 디스크에 설치 되어 있는 프로그램을 메모리 상에 실행중인 작업입니다.
- 운영체제에서 여러개의 프로세스를 동시에 실행하는 것을 멀티태스킹이라고 한다.
- 어떤 작업을 하나이상의 프로세스에서 병렬로 처리하는 것을 멀티 프로세싱이라고 합니다.
동기화
- 멀티스레딩 환경에서 여러 스레드가 데이터와 자원을 안전하게 공유하도록 조정하는 프로세스이다.
Thread
- 쓰레드는 컴퓨터 프로그램이 작업을 동시에 수행하기 위해 사용하는 실행 단위이다.
- 쓰레드는 프로세스 내에서 실행된다.
- 쓰레드는 프로세스 자원(메모리, 파일 핸들 등)을 공유한다.
- 쓰레드는 프로세스의 가장 작은 실행 단위로, 한 프로세스 내에서 여러 쓰레드가 동시에 실행될 수 있다.
- 프로세스?
- 실행 중인 컴퓨터 프로그램이다. 프로그램 동작 그 자체를 의미한다.
- 쓰레드의 특징
- 경량 프로세스
- 경량 프로세스라 불린다.
- 자신만의 실행 컨텍스트(프로그램 카운터, 레지스터 집합, 스택)를 가진다.
- 자원 공유
- 같은 프로세스 내의 쓰레드들은 힙 메모리와 같은 자원을 공유한다.
- 이로인해 효율적인 리소스 사용이 가능하지만, 데이터 접근을 동기화해야하는 복잡성을 도입한다.
- 독립적 실행
- 각 쓰레드는 독립적으로 실행되며, 하나의 쓰레드가 실패하더라도 다른 쓰레드에게 영향을 미치지 않음
- 멀티 쓰레딩
- 여러 쓰레드를 사용하는 것을 멀티쓰레딩이라고 하며, 이로 인해 동시성(Concurrency) 병렬성(Parallelism)을 달성할 수 있다.
- 경량 프로세스
- 쓰레드의 사용
- 동시성 처리
- 여러 작업을 동시에 수행해야할 때 쓰레드를 사용한다.
- 자원 집약적 작업
- CPU 사용률이 높거나 I/O 작업이 많은 프로그램에서 쓰레드를 사용하여 자원을 효율적으로 활용할 수 있습니다.
- 응답성 향상
- 사용자 인터페이스가 멈추지 않고 부드럽게 작동하도록 하기 위해, 긴 작업을 백그라운드 쓰레드에서 실행하고 메인 쓰레드는 사용자 상호작용에 집중한다.
- 동시성 처리
- 주의 사항
- 스레드 안정성(Thread Safety)
- 여러 쓰레드가 공유 자원에 접근할 때는 동기화 메커니즘이 필요하다.
- 교착 상태(Deadlock)
- 쓰레드 간 자원 경쟁이나 잘못된 잠금 순서로 인해 발생할 수 있으며, 프로그램이 멈출 위험이 있습니다.
- 컨텍스트 스위칭(Context Switching) 비용
- 쓰레드가 많아질수록 컨텍스트 스위칭에 대한 오버헤드가 증가할 수 있습니다.
- 스레드 안정성(Thread Safety)
DispatchQueue
- DispatchQueue란 무엇일까?
- 스레드 관리 코드를 애플리케이션에서 시스템 쪽으로 가져가는 기술이다.
- 개발자는 실행하고자 하는 작업을 정의하고 이를 적절한 Dispatch Queue에 넣기만 하면된다.
- 그러면 GCD가 필요한 스레드를 만들고 이를 자동으로 스케쥴링 해준다.
- 스레드 안정성
- 공유 자원에 접근할 때는 스레드 안정성을 고려해야한다.
- 장점
- 쓰레드 관리를 자동으로 해준다.
- 어셈블리 레벨에서 튜닝되어 빠른 동작을 제공한다.
- 애플리케이션 메모리를 점유하지 않아 메모리면에서 효율적이다.
- 작업중에 예외를 발생시키지 않는다.
- 비동기적으로 작업을 queue에 넣어도 데드락이 생기지 않는다.
- 경쟁상태(Race Condition)하에서도 우아한 확장성을 제공한다.
- Serial DispatchQueue는 lock이나 여러 동기화 연산보다 더 효과적이다.
Dispatch Source
- Dispatch Source는 특정 시스템 이벤트들을 비동기적으로 처리하기 위한 메커니즘이다.
- Dispatch Source는 특정 시스템 이벤트가 발생했을 때 그 정보를 캡슐화하고, 이벤트가 일어날때 마다 특정함수나 클로저를 DispatchQueue를 통해 실행하도록 해준다.
- DispatchSource를 생성할 때 특정큐를 지정해야한다.
- DispatchQueue.main - mainThread
- DispatchQueue.global() - backgroundThread
- 지원하는 이벤트
- Timer
- 정해진 시간 간격으로 반복적으로 실행되어야 하는 작업을 스케줄링한다.
- File Descriptor와 socket
- 파일이나 소켓에 대한 I/O 작업이 가능할 때 알림을 받는다.
- 시스템 이벤트와 신호(Signal)
- UNIX 신호나 다른 시스템 이벤트를 감지합니다.
- Process
- 특정 프로세스의 생명 주기 이벤트(예: 종료)를 감지합니다.
- Custom Event
- 애플리케이션 정의 이벤트를 사용하여 특정 조건에 대한 반응을 커스터마이즈 할수 있다.
- Timer
- 주의 사항
- 스레드 안전성
- 비동기적으로 실행되기 때문에, 이벤트 핸들러 내에서 공유 자원에 접근할 때는 스레드 안정성을 확보해야 한다.
- 필요한 경우 동기화 메커니즘(예: 락, 세마포어)를 사용하여 자원을 안전하게 접근해야 한다.
- 스레드 안전성
- 리소스 관리
- 활성화 및 해제
- DispatchSource 객체는 생성 후에 활성화(activate())해야한다.
- 사용이 끝난 후에는 cancel()메소드를 호출하여 리소스를 해제해야한다.
- 참조 유지
- DispatchSource 객체는 이벤트 핸들러가 실행되는 동안에만 유효하다.
- 객체가 스코프를 벗어나 자동으로 해제되지 않도록 참조를 유지해야한다.
- 활성화 및 해제
OperationQueue
- 멀티쓰레딩 작업을 관리하는데 사용되는 고수준 API
- 이 큐는 Operation 객체를 사용하여 실행할 작업을 관리하며, 작업 실행을 동시에 또는 순차적으로 스케줄링할 수 있습니다.
- 주요특징
- 동시성 관리
- OperationQueue는 내부적으로 쓰레드 풀을 관리하며 개발자는 작업 실행의 동시성을 쉽게 조절할 수 있습니다.
- 쓰레드 풀 : 미리 생성된 스레드의 집합으로, 여러 작업을 효율적으로 처리하기 위해 재사용되는 쓰레드들을 관리하는 시스템이다.
- OperationQueue는 내부적으로 쓰레드 풀을 관리하며 개발자는 작업 실행의 동시성을 쉽게 조절할 수 있습니다.
- 작업 우선순위와 종속성
- Operation 객체들 간에 우선순위를 설정하거나 종속성을 추가하여 작업의 실행순서를 제어할 수 있습니다.
- 취소 및 완료 상태 관리
- 실행 중인 작업을 취소하거나 완료 상태를 확인할 수 있습니다.
- 메인 큐와 사용자 정의 큐
- OperationQueue는 메인큐(메인 스레드에서 작업 실행)와 사용자 정의 큐(백그라운드 스레드에서 작업 실행)를 지원합니다.
- 동시성 관리
- 작업(Operation)의 유형
- BlockOperation
- 단일 블록 또는 여러 블록을 실행하는 간단한 작업이다.
- Custom Operation
- Operation 클래스를 상속받아 커스텀 작업을 정의할 수 있다.
- BlockOperation
- 주의 사항
- 동시 실행 작업 수 제한
- maxConcurrentOperationCount 속성을 설정하여 큐에서 동시에 실행할 수 있는 작업의 최대 수를 제한할 수 있다.
- 스레드 안정성
- Operation 내에서 공유 자원에 접근할 때는 스레드 안정성을 고려해야한다.
- 동기화와 비동기화 작업
- Operation은 동기 또는 비동기 작업을 모두 지원합니다.
- 비동기 작업을 구현할 때는 작업의 상태관리(예: 시작, 완료)를 정확히 해야합니다.
- 동시 실행 작업 수 제한
// OperationQueue 생성
let queue = OperationQueue()
// BlockOperation 사용
let operation = BlockOperation {
// 여기에 실행할 작업을 넣습니다.
}
// 작업 추가
queue.addOperation(operation)
// 작업 우선순위 설정
operation.queuePriority = .high
// 다른 Operation에 대한 종속성 추가
let anotherOperation = BlockOperation {
// 다른 작업
}
operation.addDependency(anotherOperation)
// 작업 취소
operation.cancel()
NSLock
var money = 10000
DispatchQueue.global().async {
buy()
}
DispatchQueue.global().async {
buy()
}
func buy() {
money = money - 1000
print(money)
}
/*
출력값
9000
9000
*/
- money 변수에 대한 접근이 동기화 되지 않아 동시성 문제가 발생합니다.
- 동기화(Synchronization)
- 여러 쓰레드를 사용하는 환경에서 하나의 공유된 자원(예: 변수, 객체)에 동시에 접근할 때 발생할 수 있는 문제들을 방지하기 위해 취하는 조치
- 동기화(Synchronization)
- 문제 상황
- 두 스레드가 동시에 money 변수의 값을 읽는다. 이 경우 두 쓰레드 모두 money의 초기값인 10000을 읽는다.
- 두 스레드가 각각 money - 1000 계산을 수행한다. 두 계산 결과 모두 9000이 된다.
- 두 스레드가 거의 동시에 money 변수에 9000을 저장한다.
- 결과적으로 money는 두번의 구매에도 불구하고 9000으로 출력된다. 실제로는 8000이 되어야 올바른 결과이다.
var money = 10000
let lock = NSLock()
DispatchQueue.global().async {
buy()
}
DispatchQueue.global().async {
buy()
}
func buy() {
lock.lock(); defer {lock.unlock()}
money = money - 1000
print(money)
}
/*
출력문
9000
8000
*/
- 해결
- NSLock이 buy()가 실행됐을 때 lock.lock()으로 잠구고
- defer은 buy()가 끝날 때 실행이 되는데, 그 때 lock.unlock을 통해 잠금을 푼다.
- 과정
- Thread 1 buy() 실행, 잠금
- Thread 2 buy() 대기
- Thread 1 buy() 실행 후 잠금 해제
- Thread 2 buy() 실행, 잠금
- Thread 2 buy() 실행후 잠금해제
Dispatch Semaphore
- 용도가 3가지
- 동시 작업 개수 제한
- 두 스레드의 특정 이벤트 완료 상태 동기화
- 한가지 작업이 완료될 때 까지 기다린후 다음 작업을 시작(비동기 작업을 순차적으로 수행)
// 공유 자원에 접근 가능한 작업 수를 2개로 제한
let semaphore = DispatchSemaphore(value: 2) /// (1)
for i in 1...3 {
semaphore.wait() //semaphore 감소
DispatchQueue.global().async() {
//임계 구역(critical section)
print("공유 자원 접근 시작 \(i) 🌹")
sleep(3)
print("공유 자원 접근 종료 \(i) 🥀")
semaphore.signal() //semaphore 증가
}
}
/*출력
접근 시작 1
접근 시작 2
접근 종료 2
접근 종료 1
접근 시작 3
접근 종료 3
*/
- 동시 작업 개수 제한
- (1)과 같이 공유자원에 접근 가능한(한번에 실행가능한) 작업 수를 명시하고 임계 구역에 들어갈 때는 semaphore의 wait()을, 나올 때는 signal()을 호출한다.
- wait()은 기다림을 의미하고 semaphore를 감소시킨다.
- signal()은 작업이 끝났음을 의미하고 semaphore를 증가시킨다.
- 처음 semaphore를 초기화할 때 전달한 value(여기서는 2)안에서 작업의 개수가 왔다갔다 하는 것이다.
import Foundation
// 세마포어 생성
let semaphore = DispatchSemaphore(value: 0)
// 스레드 A에서 실행할 작업
DispatchQueue.global().async {
print("스레드 A에서 작업 A 실행")
// 여기에 작업 A의 내용을 작성합니다.
sleep(2) // 예시를 위한 2초 지연
print("스레드 A에서 작업 A 완료")
// 작업 A가 완료되면 세마포어 신호 보내기
semaphore.signal()
}
// 스레드 B에서 실행할 작업
DispatchQueue.global().async {
print("스레드 B는 작업 A가 완료될 때까지 대기")
// 스레드 A의 작업 완료 신호를 기다림
semaphore.wait()
// 스레드 A의 작업이 완료된 후에 실행될 작업
print("스레드 B에서 후속 작업 실행")
// 여기에 후속 작업의 내용을 작성합니다.
}
- 두 스레드의 특정 이벤트 완료 상태 동기화
- 상단 코드의 의도
- 스레드 A는 작업 A 실행 중
- 스레드 B는 작업 A가 끝난 후에 무언가를 실행하려고 함
- 상단 코드 설명
- 스레드 B는 예상된 작업을 기다리기 위해 wait를 호출하고 쓰레드 A는 작업이 준비되면 signal을 호출하는 식으로 쓰레드 B가 작업 A의 완료 상태를 동기화할 수 있다.
- DispatchSemaphore(value:0)으로 세마포어를 초기화할 때, 내부 카운트는 0으로 설정된다.
- 이 상태에서 wait()을 호출하면 세마포어의 카운트가 0보다 크지않기 때문에 해당 스레드는 대기 상태에 들어간다.
- 다른 쓰레드에서 작업 A를 수행한 후 semaphore.signal()을 호출하면, 세마포어의 카운트가 1증가한다. 이로 인해 wait()에 의해 대기 중이던 스레드가 다시 활성화 되고, wait() 다음의 코드가 실행된다.
- DispatchSemaphore를 해당 용도로 사용할 때는 초기 값을 0으로 설정한다.
- 작업 A가 끝나지 않았다면 signal()이 실행되지 않았다면 semaphore.wait() 이후의 작업은 실행되지 않을 것이다. 그전 까지 세마포어의 값은 0이기 때문이다.
- 상단 코드의 의도
import Foundation
// DispatchSemaphore 초기화 (value는 0으로 설정하여 초기에 대기 상태로 만듦)
let semaphore = DispatchSemaphore(value: 0)
// 첫 번째 작업을 비동기적으로 실행
DispatchQueue.global().async {
print("첫 번째 작업 시작")
// 첫 번째 작업이 여기에 들어갑니다. 예를 들어, 데이터 로딩, 파일 처리 등
sleep(2) // 예시를 위한 임시 지연
print("첫 번째 작업 완료")
// 첫 번째 작업이 완료되었다는 신호를 보냄
semaphore.signal()
}
// 첫 번째 작업이 완료될 때까지 기다림
semaphore.wait()
// 두 번째 작업을 시작
print("두 번째 작업 시작")
// 두 번째 작업이 여기에 들어갑니다.
// ...
- 한 가지 작업이 완료될 때까지 기다린 후 다음 작업을 시작하는 방법( 비동기 작업을 순차적으로 수행)
- 작동 원리
- 세마포어 초기화
- DispatchSemaphore(value:0)으로 세마포어를 생성한다.
- 초기 값이 0이므로 wait()호출 즉시 대기 상태에 들어감
- 첫번째 작업 실행
- Dispatch Queue.global().sync를 사용하여 첫 번째 작업을 백그라운드 스레드에서 비동기적으로 실행
- 작업이 완료되면 semaphore.signal()을 호출하여 세마포어의 카운트를 1증가시킨다.
- 대기 및 두번째 작업 시행
- semaphore.wait()은 세마포어 카운트가 0보다 커질 때까지 현재 실행중인 스레드를 대기시킨다.
- 첫번째 작업이 완료되고 signal()이 호출되면 wait()는 대기 상태에서 벗어나고 두번째 작업을 시작한다.
- 세마포어 초기화
- DispatchSemaphore를 사용할 때 주의할 점
- 교착상태(Deadlock) 방지
- 잘못 사용하면 교착상태가 발생할 수 있다.
- wait()가 호출된 스레드가 signal()을 호출할 수 없는 상황이면 프로그램이 무한히 대기 상태에 빠진다.
- 메인스레드 차단 주의
- 메인 쓰레드에서 wait()를 호출하면 사용자 인터페이스가 멈출 수 있다.
- 비동기 작업이 메인스레드를 차단하지 않도록 해야한다.
- 성능 저하 주의
- 세마포어는 스레드간 동기화를 위해 대기 시간을 발생시킨다.
- 과도한 동기화는 시스템의 병렬 처리 능력을 저하시킨다.
- 올바른 signal() 호출
- wait()가 호출될 때 마다 대응되는 signal() 호출이 있어야합니다.
- 그렇지 않으면 세마포어의 내부 카운터가 올바르게 관리 되지 않을 수 있습니다.
- 예외 처리
- wait() 중에 예외가 발생하면 signal()이 호출되지 않을 수 있으므로, 예외 처리를 적절히 해야한다.
- 교착상태(Deadlock) 방지
//예외처리 예시 코드
import Foundation
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
do {
print("비동기 작업 시작")
// 여기에서 예외를 발생시킬 수 있는 작업을 수행합니다.
// 예를 들어, 파일 읽기/쓰기, 네트워크 요청 등이 있을 수 있습니다.
// 예시를 위한 예외 발생
throw NSError(domain: "SomeError", code: 1001, userInfo: nil)
} catch {
print("예외 발생: \(error)")
}
// 예외 발생 여부와 관계없이 signal 호출
semaphore.signal()
}
// 메인 스레드에서 비동기 작업이 완료될 때까지 기다립니다.
semaphore.wait()
print("비동기 작업 완료 후의 작업을 계속합니다.")