본문 바로가기

Swift 공부

WWDC2021 Protect mutable state with Swift actors

Swift Actor를 이용한 데이터레이스를 방지해보자

 

class Counter {
    var value = 0

    func increment() -> Int {
        value = value + 1
        return value
    }
}

let counter = Counter()

Task.detached {
    print(counter.increment()) // data race
}

Task.detached {
    print(counter.increment()) // data race
}

 

위의 상황에서 개발자는 자연스럽게 데이터레이스 현상을 겪게된다 

Task.detached를 통해 서로 다른 스레드에서 하나의 클래스의 내부변수를 바꾸려는 시도가 생김

 

위의 동작에서 아래의 함수가 동시에 실행하게되면 1,2 중에 무엇이 나올지 알수없고 문제가된다면 둘다 1이 나올수도있음

그이유는 value값이 0이였을 시점에 동시에 콜하게 된다면

 

그래서 개발자는 항상 이런 문제를 고민해야하는데 데이터 경합을 피하는 방법은 값 class와 같이 Reference types 타입을 value type으로 변경해서 사용하는 방법이 있다.

 

 

  • 값 의미 체계(Value Semantics):
    • 구조체는 값 타입이므로 값 의미 체계를 따릅니다.
    • 값이 복사되어 전달되므로 공유된 상태가 줄어듭니다.
  • 불변성(Immutability):
    • 구조체는 보통 불변(immutable)으로 설계됩니다.
    • 불변 객체는 생성 후 상태가 변경되지 않아 스레드 안전합니다.
  • 공유 상태 감소:
    • 클래스는 참조 타입이라 여러 곳에서 같은 인스턴스를 참조할 수 있습니다.
    • 구조체는 복사되어 전달되므로 실제로 공유되는 상태가 줄어듭니다.
  • 스레드 안전성:
    • 구조체를 사용하면 각 스레드가 자신만의 데이터 복사본을 가지게 됩니다.
    • 이로 인해 여러 스레드가 동시에 같은 데이터에 접근하는 상황이 줄어듭니다.
struct Counter {
    var value = 0

    mutating func increment() -> Int {
        value = value + 1
        return value
    }
}

let counter = Counter()

Task.detached {
    var counter = counter
    print(counter.increment()) // always prints 1
}

Task.detached {
    var counter = counter
    print(counter.increment()) // always prints 1
}

 

 

위의 그림과같에 value type으로 변경을 진행하고 해당 밸류타입을 로컬에서 복사한 후 함수를 실행시키면 항상 1의 결과값을 

얻을수있지만 이건 우리가 원한결과가 아니다 우리는 하나의 값을 공유하고 사용하고 싶어하기 때문에

 

그렇기때문에 기존의 개발자들은 이런 문제를 해결하기위해 직렬큐 / locks 등을 사용하여 이러한 문제를 해결해왔다 

 

이런 문제를 해결하기위해 Swift에서는 Actor를 만들어주었다.

 

Actor를 통해 기존의 개발자들이 직접 데이터레이스를 잡아주었던것을 Swift에서 보장해서 잡아주는 방식을 사용할수있게되었다.

 

Actor -> 기존의 class와 마찬가지로 참조유형이며 extension을 통해 확장될수있고 protocol을 준수할수도있다.

 

Actor는 데이터에 대한 동기화된 엑세스를 보장한다 -> 액터의 가장 중요한 특징

 

actor Counter {
    var value = 0

    func increment() -> Int {
        value = value + 1
        return value
    }
}

let counter = Counter()

Task.detached {
    print(await counter.increment())
}

Task.detached {
    print(await counter.increment())
}

 

위의 코드를 한번 살펴보자 기존의 코드에서는 최악의경우 둘다 1과 2이라는 동일한 값이 나올수있었는데

위의 함수를 실행하게된다면 1 or 2라는 값이 둘중 한곳에서 무조건 보장되어 나올수있다.

 

그이유는 무엇인가? counter에서 사용된 await키워드를 통해 해당 함수는 즉시호출이아닌 잠깐의 대기가 있을수있다를 의미한다

이건 기존의 async/await에 대한 영상을 통해 알게된 내용이다.

 

extension Counter {
//actor의 내부의 함수이기때문에 함수에 await키워를 사용하지 않아도된다.
    func resetSlowly(to newValue: Int) {
        value = 0
        for _ in 0..<newValue {
            increment()
        }
        assert(value == newValue)
    }
}

 

 

actor ImageDownloader {
    private var cache: [URL: Image] = [:]

	//이미지를 다운받는 함수이다.
    func image(from url: URL) async throws -> Image? {
    //저장된 캐시값에 이미지가 있다면 가져오고
        if let cached = cache[url] {
            return cached
        }
	//없다면 다운로드 받아라
        let image = try await downloadImage(from: url)
		// Potential bug: `cache` may have changed.
    //하지만 위의 다운 받는 과정에서 cache의 값이 변경이된다면?
        cache[url] = image
        return image
    }
}

 

 

//그럼 궁금한점 Actor를 통해 해당 함수가 동시에 실행되는건 막았지만 다른문제는 없을까?
//하나의 함수가 끝나기전에 해당 다음함수의 시작을 미루고 작업을 하지 않는걸까?


Task.detached {
    try await imageDownloader.image(from: 10)
}

Task.detached {
    try await imageDownloader.image(from: 5)
}

이런식으로 서로다른 스레드에서 해당 함수를 실행하게된다면
두개의 함수가 동시에 실행이 되지는 않지만 하나의 함수가 끝나기전까지 작업을 멈추지는 않는다
그렇기때문에 위에 이야기한 문제가 발생하는것이다.

 

 

actor LibraryAccount {
    let idNumber: Int
    var booksOnLoan: [Book] = []
}

extension LibraryAccount: Equatable {
//액터 내부의 변수값에 접근하지않고 외부로 부터 받은 불변의 값에 접근하기때문에 문제가없다.
    static func ==(lhs: LibraryAccount, rhs: LibraryAccount) -> Bool {
        lhs.idNumber == rhs.idNumber
    }
}

extension LibraryAccount: Hashable {

//해당함수는 내부의 idNumber에 접근하기지만 내부의 변수에 변화를 주지않기때문에 문제가없다 
//그러므로 nonisolated키워드를 이용해 구현할수있다.
    nonisolated func hash(into hasher: inout Hasher) {
        hasher.combine(idNumber)
    }
}

 

actor DataManager {
    private var data: [String] = []
    
    //만약에 actor내부의 보호되고있는 개체에 접근을 하게된다면 
    //컴파일단계에서 오류라고 알려주게된다.
    //Actor-isolated property 'data' can not be referenced from a nonisolated context
    func addData(_ item: String) {
        data.append(item)
    }
    
    func getData() -> [String] {
        return data
    }
    //해당함수는 nonisolated 키워드를 통해 데이터 경합을 방지하기 위해 
	//actor에 의해 보호되고있는 객체에 접근하지 않습니다
    nonisolated func processData(_ item: String) -> String {
        return item.uppercased()
    }
}

// 사용 예시
func example() async {
    let manager = DataManager()
    
    await manager.addData("Hello")
    let processed = manager.processData("world") // await 키워드가 필요 없음
    print(processed) // 출력: WORLD
    let actualData = await manager.getData()
    print(actualData) // 출력: ["Hello"]
}

 

위와 같은 코드의 예시를 보고 액터 내부에서 실행이되는지 아니면 외부에서 실행이되는지에대한 이해할수있었다.

 

 

 

Sendable

 

Sendable은 Swift의 동시성 시스템에서 중요한 프로토콜이다

  1. 정의: Sendable은 타입이 동시성 도메인 간에 안전하게 전송될 수 있음을 나타내는 프로토콜
  2. 목적: 데이터 경합과 메모리 안전성 문제를 컴파일 단계에 방지
  3. 사용: 주로 동시성 컨텍스트(예: actor 간 통신, Task 간 데이터 전달)에서 사용됩니다.
  4. 적용: 값 타입(구조체, 열거형)은 자동으로 Sendable을 준수합니다.
    클래스는 명시적으로 선언해야 하며, 추가 요구사항을 충족해야 합니다.
  5. 제약: Sendable 타입은 내부적으로 가변 상태를 안전하게 관리해야 합니다. (중요)

그럼 class를 Sendable을 충족시키기위해 무엇을 할수있을까?

 

 

  • 불변(Immutable) 속성 사용
    • 모든 속성을 let으로 선언하여 불변으로 만듭니다.
    • 가장 안전하지만, 객체의 상태를 변경할 수 없습니다.
  • @unchecked Sendable 사용 - 추가공부필요
    • 컴파일러의 검사를 우회하고 개발자가 직접 스레드 안전성을 보장합니다.
    • 동기화 메커니즘(예: NSLock)을 사용하여 스레드 안전성을 확보해야 합니다.
    • 주의가 필요하며, 잘못 사용하면 런타임 오류가 발생할 수 있습니다.
  • NSCopying 프로토콜 사용 - 추가공부필요
    • 객체를 복사하여 새 인스턴스를 반환합니다.
    • 변경 작업마다 새 객체를 생성하므로 원본 객체의 불변성을 유지합니다.
    • 메모리 사용량이 증가할 수 있습니다.
  • actor 사용 
    • Swift의 actor 타입을 사용하여 자동으로 동시성 안전성을 확보합니다.
    • 모든 상호작용이 비동기적으로 이루어져야 합니다.
    • 가장 안전하고 Swift의 동시성 모델에 부합하는 방법입니다.

 

 

 

MainActor는 Swift의 동시성 시스템에서 중요한 역할을 하는 속성 래퍼

@MainActor의 주요 특징 및 용도:

  1. 정의: @MainActor는 특정 코드가 메인 스레드(또는 메인 디스패치 큐)에서만 실행되도록 보장하는 속성 래퍼입니다.
  2. 목적:
    • UI 업데이트와 같이 메인 스레드에서만 실행되어야 하는 작업을 안전하게 처리합니다.
    • 동시성 코드에서 메인 스레드 작업을 명시적으로 표시합니다.
  3. 사용 범위:
    • 클래스, 구조체, 열거형 전체에 적용 가능
    • 개별 메서드나 프로퍼티에도 적용 가능
  4. 동작 방식:
    • @MainActor로 표시된 코드는 자동으로 메인 스레드에서 실행됩니다.
    • 다른 컨텍스트에서 이 코드를 호출할 때는 'await' 키워드를 사용해야 합니다.
  5. 컴파일러 지원:
    • 컴파일러가 @MainActor로 표시된 코드가 잘못된 컨텍스트에서 호출되는 것을 방지합니다.

 

마지막에 MainActor에 대한 설명을 굉장히 간단하게 하고 마무리하는데 이부분은 조금더 유심히 살펴볼 필요가있는것같다.

다음에는 MainActor를 사용해야할때 주의해야할점에대해 자세히 조사해볼 예정이다.