ios

11. 동기화 공부하기

코코넛딩 2023. 12. 13. 16:39

챗지피티와 구글 번역기를 사용했습니다. 틀린 내용있으면 댓글 달아주세요.

참조한 블로그도 첨부합니다!

https://dongkyprogramming.tistory.com/17

 

Swift - NSLock 간단히 이해하기

어느 언어든지 간에 Thread를 사용하는 프로그램을 개발할 때에는 Thread Safe하게 코드를 구성해야 할 때가 있습니다. Swift에서도 마찬가지입니다. Swift에는 간단히 사용할 수 있는 NSLock라는 클래스

dongkyprogramming.tistory.com

https://sujinnaljin.medium.com/ios-%EC%B0%A8%EA%B7%BC%EC%B0%A8%EA%B7%BC-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-gcd-10-cb37c3e0cf13

 

[iOS] 차근차근 시작하는 GCD — 10

DispatchSemaphore 에 대해 알아봅시다

sujinnaljin.medium.com

동시성

    • 특정 프로세스의 실행 시간이 다른 프로세스의 흐름과 겹치는 상황에서 동시에 실행한다고 말한다.
    • 동시에 실행하거나 동시에 실행하는 것처럼 보이게 하는 것을 동시성이라고 한다.
    • 싱글코어에서는 2개의 작업을 동시에 실행하는 것 처럼 보이기 위해 context switch가 일어난다.
    • context switch
      • 프로세스나 스레드의 상태를 저장하여 다른 프로세스의 실행을 전환한 다음 새 프로세스가 실행을 시작하기 전에 마지막으로 저장된 상태가 복원된다.

프로세스 (process)

  • 컴퓨터에서 연속적으로 실행되고 있는 컴퓨터 프로그램
  • 프로세스는 하드 디스크에 설치 되어 있는 프로그램을 메모리 상에 실행중인 작업입니다.
  • 운영체제에서 여러개의 프로세스를 동시에 실행하는 것을 멀티태스킹이라고 한다.
  • 어떤 작업을 하나이상의 프로세스에서 병렬로 처리하는 것을 멀티 프로세싱이라고 합니다.

동기화

  • 멀티스레딩 환경에서 여러 스레드가 데이터와 자원을 안전하게 공유하도록 조정하는 프로세스이다.

 

Thread

  • 쓰레드는 컴퓨터 프로그램이 작업을 동시에 수행하기 위해 사용하는 실행 단위이다.
  • 쓰레드는 프로세스 내에서 실행된다.
  • 쓰레드는 프로세스 자원(메모리, 파일 핸들 등)을 공유한다.
  • 쓰레드는 프로세스의 가장 작은 실행 단위로, 한 프로세스 내에서 여러 쓰레드가 동시에 실행될 수 있다. 
  • 프로세스?
    • 실행 중인 컴퓨터 프로그램이다. 프로그램 동작 그 자체를 의미한다.
  • 쓰레드의 특징
    • 경량 프로세스
      • 경량 프로세스라 불린다.
      • 자신만의 실행 컨텍스트(프로그램 카운터, 레지스터 집합, 스택)를 가진다.
    • 자원 공유
      • 같은 프로세스 내의 쓰레드들은 힙 메모리와 같은 자원을 공유한다.
      • 이로인해 효율적인 리소스 사용이 가능하지만, 데이터 접근을 동기화해야하는 복잡성을 도입한다.
    • 독립적 실행
      • 각 쓰레드는 독립적으로 실행되며, 하나의 쓰레드가 실패하더라도 다른 쓰레드에게 영향을 미치지 않음
    • 멀티 쓰레딩
      • 여러 쓰레드를 사용하는 것을 멀티쓰레딩이라고 하며, 이로 인해 동시성(Concurrency) 병렬성(Parallelism)을 달성할 수 있다.
  • 쓰레드의 사용
    • 동시성 처리
      • 여러 작업을 동시에 수행해야할 때 쓰레드를 사용한다.
    • 자원 집약적 작업
      • CPU 사용률이 높거나 I/O 작업이 많은 프로그램에서 쓰레드를 사용하여 자원을 효율적으로 활용할 수 있습니다.
    • 응답성 향상
      • 사용자 인터페이스가 멈추지 않고 부드럽게 작동하도록 하기 위해, 긴 작업을 백그라운드 쓰레드에서 실행하고 메인 쓰레드는 사용자 상호작용에 집중한다.
  • 주의 사항
    • 스레드 안정성(Thread Safety)
      • 여러 쓰레드가 공유 자원에 접근할 때는 동기화 메커니즘이 필요하다.
    • 교착 상태(Deadlock)
      • 쓰레드 간 자원 경쟁이나 잘못된 잠금 순서로 인해 발생할 수 있으며, 프로그램이 멈출 위험이 있습니다.
    • 컨텍스트 스위칭(Context Switching) 비용
      • 쓰레드가 많아질수록 컨텍스트 스위칭에 대한 오버헤드가 증가할 수 있습니다.

 

 

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
      • 애플리케이션 정의 이벤트를 사용하여 특정 조건에 대한 반응을 커스터마이즈 할수 있다.
  • 주의 사항
    • 스레드 안전성
      • 비동기적으로 실행되기 때문에, 이벤트 핸들러 내에서 공유 자원에 접근할 때는 스레드 안정성을 확보해야 한다.
      • 필요한 경우 동기화 메커니즘(예: 락, 세마포어)를 사용하여 자원을 안전하게 접근해야 한다.
  • 리소스 관리
    • 활성화 및 해제
      • DispatchSource 객체는 생성 후에 활성화(activate())해야한다.
      • 사용이 끝난 후에는 cancel()메소드를 호출하여 리소스를 해제해야한다.
    • 참조 유지
      • DispatchSource 객체는 이벤트 핸들러가 실행되는 동안에만 유효하다.
      • 객체가 스코프를 벗어나 자동으로 해제되지 않도록 참조를 유지해야한다.

 

OperationQueue

  • 멀티쓰레딩 작업을 관리하는데 사용되는 고수준 API
  • 이 큐는 Operation 객체를 사용하여 실행할 작업을 관리하며, 작업 실행을 동시에 또는 순차적으로 스케줄링할 수 있습니다.
  • 주요특징
    • 동시성 관리 
      • OperationQueue는 내부적으로 쓰레드 풀을 관리하며 개발자는 작업 실행의 동시성을 쉽게 조절할 수 있습니다.
        • 쓰레드 풀 : 미리 생성된 스레드의 집합으로, 여러 작업을 효율적으로 처리하기 위해 재사용되는 쓰레드들을 관리하는 시스템이다.
    • 작업 우선순위와 종속성
      • Operation 객체들 간에 우선순위를 설정하거나 종속성을 추가하여 작업의 실행순서를 제어할 수 있습니다.
    • 취소 및 완료 상태 관리
      • 실행 중인 작업을 취소하거나 완료 상태를 확인할 수 있습니다.
    • 메인 큐와 사용자 정의 큐
      • OperationQueue는 메인큐(메인 스레드에서 작업 실행)와 사용자 정의 큐(백그라운드 스레드에서 작업 실행)를 지원합니다.
  • 작업(Operation)의 유형
    • BlockOperation
      • 단일 블록 또는 여러 블록을 실행하는 간단한 작업이다.
    • Custom Operation
      • Operation 클래스를 상속받아 커스텀 작업을 정의할 수 있다.
  • 주의 사항
    • 동시 실행 작업 수 제한
      • 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)
      • 여러 쓰레드를 사용하는 환경에서 하나의 공유된 자원(예: 변수, 객체)에 동시에 접근할 때 발생할 수 있는 문제들을 방지하기 위해 취하는 조치
  • 문제 상황
    • 두 스레드가 동시에 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. 동시 작업 개수 제한
    1. (1)과 같이 공유자원에 접근 가능한(한번에 실행가능한) 작업 수를 명시하고 임계 구역에 들어갈 때는 semaphore의 wait()을, 나올 때는 signal()을 호출한다.
    2. wait()은 기다림을 의미하고 semaphore를 감소시킨다.
    3. signal()은 작업이 끝났음을 의미하고  semaphore를 증가시킨다.
    4. 처음 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에서 후속 작업 실행")
    // 여기에 후속 작업의 내용을 작성합니다.
}

 

  1. 두 스레드의 특정 이벤트 완료 상태 동기화
    1. 상단 코드의 의도
      1. 스레드 A는 작업 A 실행 중
      2. 스레드 B는 작업 A가 끝난 후에 무언가를 실행하려고 함
    2. 상단 코드 설명
      1. 스레드 B는 예상된 작업을 기다리기 위해 wait를 호출하고 쓰레드 A는 작업이 준비되면 signal을 호출하는 식으로 쓰레드 B가 작업 A의 완료 상태를 동기화할 수 있다.
      2. DispatchSemaphore(value:0)으로 세마포어를 초기화할 때, 내부 카운트는 0으로 설정된다.
      3. 이 상태에서 wait()을 호출하면 세마포어의 카운트가 0보다 크지않기 때문에 해당 스레드는 대기 상태에 들어간다.
      4. 다른 쓰레드에서 작업 A를 수행한 후 semaphore.signal()을 호출하면, 세마포어의 카운트가 1증가한다. 이로 인해 wait()에 의해 대기 중이던 스레드가 다시 활성화 되고, wait() 다음의 코드가 실행된다.
    3. DispatchSemaphore를 해당 용도로 사용할 때는 초기 값을 0으로 설정한다.
    4. 작업 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("두 번째 작업 시작")
// 두 번째 작업이 여기에 들어갑니다.
// ...

 

  1. 한 가지 작업이 완료될 때까지 기다린 후 다음 작업을 시작하는 방법( 비동기 작업을 순차적으로 수행)
  2. 작동 원리
    1. 세마포어 초기화
      1. DispatchSemaphore(value:0)으로 세마포어를 생성한다.
      2. 초기 값이 0이므로 wait()호출 즉시 대기 상태에 들어감
    2. 첫번째 작업 실행
      1. Dispatch Queue.global().sync를 사용하여 첫 번째 작업을 백그라운드 스레드에서 비동기적으로 실행
      2. 작업이 완료되면 semaphore.signal()을 호출하여 세마포어의 카운트를 1증가시킨다.
    3. 대기 및 두번째 작업 시행
      1. semaphore.wait()은 세마포어 카운트가 0보다 커질 때까지 현재 실행중인 스레드를 대기시킨다.
      2. 첫번째 작업이 완료되고 signal()이 호출되면 wait()는 대기 상태에서 벗어나고 두번째 작업을 시작한다.

 

 

  • DispatchSemaphore를 사용할 때 주의할 점
    • 교착상태(Deadlock) 방지
      • 잘못 사용하면 교착상태가 발생할 수 있다.
      • wait()가 호출된 스레드가 signal()을 호출할 수 없는 상황이면 프로그램이 무한히 대기 상태에 빠진다.
    • 메인스레드 차단 주의
      • 메인 쓰레드에서 wait()를 호출하면 사용자 인터페이스가 멈출 수 있다.
      • 비동기 작업이 메인스레드를 차단하지 않도록 해야한다.
    • 성능 저하 주의 
      • 세마포어는 스레드간 동기화를 위해 대기 시간을 발생시킨다.
      • 과도한 동기화는 시스템의 병렬 처리 능력을 저하시킨다.
    • 올바른 signal() 호출
      • wait()가 호출될 때 마다 대응되는 signal() 호출이 있어야합니다.
      • 그렇지 않으면 세마포어의 내부 카운터가 올바르게 관리 되지 않을 수 있습니다.
    • 예외 처리
      • wait() 중에 예외가 발생하면 signal()이 호출되지 않을 수 있으므로, 예외 처리를 적절히 해야한다.
//예외처리 예시 코드


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("비동기 작업 완료 후의 작업을 계속합니다.")