Data Race는 shared mutable data에 대해 여러 Thread에서 동시에 접근하여 발생한다. 

 동시성 환경에서 자주 발생하는 문제 중 하나이지만, Debug 하기는 어렵다.

class Counter {
    var value = 0
    
    func increment() -> Int {
        value += 1
        return value
    }
}

let counter = Counter()

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

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

해당 코드에서, counter의 0인 상태에 동시에 접근하게 되어 둘 다 1의 결과가 출력될 수도,

value += 1 코드에 동시에 접근하게 되어 둘 다 2의 결과가 출력되는 등 다양한 문제가 발생한다.

 

이는 Shared Mutable Data에 동시에 접근하여 발생하는 문제이기 때문에, sychronization 작업이 필요하다. 

이전 동시성 모델에서 "Serial Dispatch Queue"를 활용하였으나, 

Swift 5.5의 새로운 Concurrency API에서는 "Actor"라는 더 쉽고 안전한 synchronization 메커니즘을 제공한다.

 

 

Actor

Actor는 mutable State에 대해 Synchronization을 제공하며, 

mutable State를 프로그램의 나머지 부분과 "isolate"한다. 

 

즉, actor는 mutable state에 하나의 process만 접근 가능하도록 보장하며, 

mutable state는 actor를 통해서만 접근이 가능하다.

 

우선 위의 data race 코드를 actor로 변경하게 되면 아래와 같다. 

actor Counter {
    var value = 0
    
    func increment() -> Int {
        value += 1
        return value
    }
}

actor는 Swift에서 새로운 reference type이다.

따라서, struct, class, enum과 같은 type에서 사용할 수 있었던

properties, initializer, subscripts 등의 기능들도 사용이 가능하며,

extension, protocol 채택 역시 가능하다.

 

actor는 mutable state를 share한다는 목적이기에 class와 같은 reference type이지만, 차이점은 아래와 같다. 

  • synchronization 제공
  • 멤버들을 isolate
  • 상속 불가능

 

 

Actor Isolation

actor는 "Actor Isolation"을 통해 mutable state를 프로그램의 다른 부분과 "isolated"하며,

이는 하나의 process만 수행하는 것을 보장한다.

 

actor내에 정의된,

  • stored, computed instace property
  • instance method 
  • instance subscripts

들은 default로 actor-isolated 상태가 되며,

actor-isolated들끼리는 모두 self(같은 인스턴스)를 통해서만 동기적으로 접근이 가능하다. 

extension Counter {    
    func someFunction() { // actor-isolated 메서드
    	print(value)  // self내에 다른 actor-isolated 프로퍼티
        print("call actor method")
        printFunction() // self내에 다른 actor-isolated 메서드
    }
}

 

반면, 아래와 같은 코드는 컴파일 에러가 나게 된다.

extension Counter {
    func someFunction(other: Counter) {
        
        // Actor-isolated property 'value' can not be referenced on a non-isolated actor instance
        value += other.value
    }
}

이 이유는 자기 자신(self)를 통해서 value 프로퍼티에 접근한 것이 아닌, 

다른 othervalue에 접근하였기 때문이다.

 

즉, someFunction메서드는 other.value에 대해서는 actor-isolated 하지 않게 된다.

(이를 non-isolated라고 표현한다.)

 

따라서, non-isolated는 actor-isolated에 동기적으로 접근할 수 없다. 

 

이러한 이유로, actor외부(non-isolated)에서는 actor-isolated에 비동기적으로 접근한다. 

Task.detached {
	// non-isolated임
    print(await counter.increment())
    print(await counter.value)
}

actor 내부에 정의된 static 프로퍼티, 메서드 등은 non-isolated하기 때문에,

non-isolated구간에서는 동기적으로 접근이 가능하다.

extension Counter {
    static let staticConstant = 0
    
    static func staticFunction() {
        print("staticFunction")
        print(staticConstant)
    }
}

print(Counter.staticConstant)
print(Counter.staticFunction())

 

또한 actor내부를 통해서만 actor-isolated의 변경이 가능하므로,

다음과 같이 외부에서 actor 내부의 프로퍼티를 직접 수정하게 되면 compile error가 나게 된다.

Task.detached {
    //Actor-isolated property 'value' can not be mutated from a Sendable closure
    await counter.value = 10
}

 

Cross-Actor Reference

위의 코드와 같이 actor 외부에서 actor-isolated를 참조하는 것을 의미한다. 

이는 결론적으로 non-isolated에서 actor-isolated를 참조하는 것이 된다. 

 

해당 경우에는 2가지 경우에 대해서 허용된다. 

 

immutable state

actor가 정의된 모듈내부에선 let과 같은 immutable state는 어디서든 동기적으로 참조가 가능하다.

actor Counter {
    var value = 0
    let imutableValue = 0
    
    func accessImutable(other: Counter) {
        // other의 actor-isolated에 접근
        value += other.imutableValue
    }
}


let counter = Counter()
let counter2 = Counter()

print(counter.imutableValue)

counter.accessImutable(other: counter2)

print(counter.imutableValue)

이는 immutable state는 initializer가 진행된 후로(instance가 생성된 이후) 변경이 불가능하다.

따라서, data race가 발생하지 않기에 Swift에서는 위와 같은 접근을 허용한다.

 

아래의 두 코드는 결과적으로는 non-isolated에서 actor-isolated에 접근한 코드이다.

func accessImutable(other: Counter) {
    value += other.imutableValue
}

print(counter.imutableValue)

함수의 경우에는 self가 아닌, other의 actor-isolated에 접근한 것이므로

other의 입장에선 non-isolated영역이다.

 

print문의 경우에는 actor외부 즉, non-isolated에서 actor-isolated를 접근한다.

 

비동기 함수로 호출되는 방식

actor외부에서 actor-isolated에 접근하게 되면 비동기적으로 전달한다. 

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

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

이는 actor를 통해 mutable shared state에 접근 중이라면,

actor를 통해 state에 접근하는 code는 suspension되고,

앞선 작업이 끝날 때까지 await되어야 하기 때문에 비동기적으로 전달된다. 

 

따라서, actor에서 다른 actor의 actor isolated에 접근할 때도 마찬가지로 await를 사용한다. 

extension Counter {
    func accessActorIsolated(other: Counter) async {
        value += await other.value
    }
}

 

isolated parameter

위의 코드를 다시 살펴보자. 

actor외부에서 actor-isolated에 접근하기 위해서는 다음과 같이 사용해야 했다. 

extension Counter {
    func accessActorIsolated(other: Counter) async {
        value += await other.value
    }
}

parameter앞에 isolated 키워드를 사용함으로써, 해당 메서드가 actor-isolated되게 만들 수 있다.

extension Counter {
    func accessActorIsolated(other: isolated Counter)  {
        value += other.value
    }
}

swift proposal에 의하면 isolated키워드를 여러 번 사용하지 못한다고 나와있지만, 

현재 Swift에서는 에러가 나지 않는다. 

이는 차후에 수정될 수 있으니 유의하자.

extension Counter {
    func accessActorIsolated2(other: isolated Counter, other2: isolated Counter)  {
        value += other.value
        value += other2.value
    }
}

 

nonisolated keyword

다음과 같은 코드가 있다고 하자.

actor Counter {
    var value = 0
    let imutableValue = 0
    
    var computedValue: String {
        return "현재 imutableValue 값은 \(imutableValue)입니다."
    }
    ...
}

해당 코드에선, computedValueimutableValue에 대해 연산을 진행하여 return하는 read-only computed 프로퍼티이다.

즉, imutable state에 대해 get연산만 진행하므로 data race가 일어나지 않는다. 

 

하지만, computed 프로퍼티 역시 actor-isolated이기 때문에 외부에서 접근하게 되면 컴파일 에러가 나게 된다.

let counter = Counter()
// Actor-isolated property 'computedValue' can not be referenced from a non-isolated context
counter.computedValue

 

해당 경우에 actor-isolated를 nonisolated 키워드를 사용함으로써, non-isolated하게 만들 수 있다.

actor Counter {
    ...
    nonisolated var computedValue: String {
        return "현재 imutableValue 값은 \(imutableValue)입니다."
    }
}

let counter = Counter()
counter.computedValue //현재 imutableValue 값은 0입니다.

 

하지만, nonisolated키워드는 제약사항이 존재한다. 

우선, stored variable 프로퍼티에는 nonisolated키워드를 추가할 수 없다.

actor Counter {
    //'nonisolated' can not be applied to stored properties
    nonisolated var value = 0
    ...
}

 

또한, nonisolated키워드가 붙은 computed 프로퍼티에서는 mutable한 actor-isolated 프로퍼티에 접근할 수 없다. 

actor Counter {
    //Actor-isolated property 'value' can not be referenced from a non-isolated context
    nonisolated var computedValue: String {
        return "현재 imutableValue 값은 \(value)입니다."
    }
    ...
}

이는 Actor Isolation의 원칙을 생각해 보면 당연한 결과이다. 

computedValuenonisolated키워드를 통해 non-isolated하게 되었기 때문에,

actor-isolated 상태에 동기적으로 접근이 불가능하다.

 

이는 메서드에서도 마찬가지로 적용된다.

nonisolated키워드가 붙은 메서드는 actor-isolated상태에 동기적으로 접근할 수 없다.

extension Counter {
    //Actor-isolated property 'value' can not be referenced from a non-isolated context
    nonisolated func someFunction() {
        print("value 값은 \(value)입니다.")
    }
}

 

Actor Reetrancy

actor-isolated 메서드는 Reetrancy(재진입)을 허용한다.

즉, actor-isolated 메서드에서 

async 함수의 호출로 인해 suspension 되게 되면, 

다른 actor에서 해당 메서드로 진입이 가능하다. 

 

이 내용을 예시로 자세히 살펴보자.

 

만약, actor의 actor-isolated메서드 내부에서 async 함수에 접근하게 되면, 

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)
        
        // 이를 캐시에 저장한다.
        cache[url] = image
        return image
    }
}

await를 통해 actor의 실행이 suspension되게 된다.

let image = try await downloadImage(from: url)

이는 다른 Task에서 actor를 통해서 접근이 가능하게 된다.

let imageDownloader = ImageDownloader()

Task.detached {
    async let image1 = imageDownloader.image(from: ImageURL)
    async let image2 = imageDownloader.image(from: ImageURL)
    
    images += [try? await image1]
    images += [try? await image2]
}

image1 Task가 먼저 수행되었다고 가정하자.

image1 Task가 actor 메서드 내부에 await 키워드를 통해 suspension되게 되면, 

image2 Task가 actor를 통해 접근할 수 있게 된다. 

 

위와 같은 동작을 Actor reetrancy라고 하는데,

이는 프로그램을 멈추지 않고 계속 수행함을 통해 deadlock을 방지한다. 

 

또한, 위의 코드에서 image2 Task에서

같은 url의 이미지를 캐시에서 가져오는 것이 아닌! 다운로드 하게 된다. 

만약 서버에서 해당 url의 이미지를 사이에 변경하였다면, 둘은 다른 이미지를 다운로드 하게 되는 문제가 발생한다.

 

이는 await이후로 프로그램의 state가 변할 수 있기 때문이다.

이러한 문제를 해결하기 위해, 동기코드로 작성하는 것이 가장 이상적이지만, 

비동기 코드를 써야 하는 경우 아래와 같이 코드 수준에서 해결해야 한다.

 

아래의 코드는 actor가 download에 대한 state를 저장하고 있고, 

이미지를 다운로드하기 전에 확인 함으로써, 중복된 다운로드를 방지한다.

actor ImageDownloader {
    private enum ImageStatus {
        case downloading(_ task: Task<UIImage, Error>)
        case downloaded(_ image: UIImage)
    }
    
    private var cache: [URL: ImageStatus] = [:]
    
    func image(from url: URL) async throws -> UIImage {
        if let imageStatus = cache[url] {
            switch imageStatus {
            case .downloading(let task):
                return try await task.value
            case .downloaded(let image):
                return image
            }
        }
        
        let task = Task {
            try await downloadImage(url: url)
        }
        
        cache[url] = .downloading(task)
        
        do {
            let image = try await task.value
            cache[url] = .downloaded(image)
            return image
        } catch {
            cache.removeValue(forKey: url)
            throw error
        }
    }
}

 

 

Sendable Type

Sendable 프로토콜을 채택한 Sendable Type

Concurrent 환경에서 안전하게 데이터를 share할 수 있다.

 

Sendable 프로토콜의 requirement는 타입마다 다르다. 

 

우선, Actor의 경우는 mutable state에 대해서 synchronization을 제공하기 때문에, 

implicit하게 Sendable Type이 된다.

 

Sendable Value Type

우선, 앞서 말했듯이 data race가 발생하는 이유는 mutable shared state때문이다.

하지만, struct,enum과 같은 value type의 경우는 복사본을 통해 데이터를 전달하기 때문에, 

공유하지 않게 되므로 Sendable을 준수할 수 있게 된다. 

 

다만, 내부 멤버들이 모드 Sendable Type일 때만, implicit하게 Sendable Type이 된다.

struct SendableStruct {
    var someValue: Int
    var someValue2: Double
}

Int, Double, String과 같은 value-semantic type들은 Sendable 프로토콜을 준수한다.

 

하지만, 아래의 경우는 someClass라는 멤버가 Sendable Type이 아니기 때문에, Sendable Type이 아니게 된다.

struct NonSendableStruct {
    var someValue: Int
    var someValue2: Double
    let someClass = SomeClass() // non sendable Type
}

 

Swift에서는 필요할 때, struct가 Sendable Type인지 추론을 하게 되는데, 

아래와 같이 명시적으로 Sendable 프로토콜을 채택할 수도 있다.

struct NonSendableStruct: Sendable {
    var someValue: Int
    var someValue2: Double
    
    let someClass = SomeClass()
}

하지만 다음과 같은 에러 메시지가 나오게 된다.

Stored property 'someClass' of 'Sendable'-conforming struct 'NonSendableStruct' has non-sendable type 'SomeClass'

 

Sendable Class

actor와 value Type의 경우에는 implicit하게 Sendable일 수 있지만, class는 아니다.

 

class의 경우에는 reference 타입이지만, actor와 다르게 synchronization을 제공하지 않기에

Value Type과는 다르게 Sendable 프로토콜을 준수하기 위해 많은 제약사항이 존재한다.

 

우선, 상속은 재정의를 통한 Sendable 적합성을 깨트릴 수 있기 때문에, final class여야 한다.

final class의 모든 멤버가 Sendable Type이고, immutable할 경우 Sendable 프로토콜을 채택할 수 있게 된다.

final class SendableClass: Sendable {
    let someValue: Int = 10
    let someStruct = SendableStruct()
}

 

Sendable Function

함수도 concurrency domain을 통해 전달될 수 있으므로, Sendable 프로토콜 conformance가 필요하다.

Swift에서 함수는 프로토콜을 준수할 수 없는 reference 타입인데,

이에 따라 Swift에서는 @Sendable annotatoin을 제공한다.

 

@Sendable annotation은 compiler에게 클로져가 capturing하는 값들은 thread-safe하다고 알리게 된다.

 

Swift의 새로운 Concurrency Model에서 클로져에는 @Sendable annotation이 많이 적용되어 있다.

static func detached (operation: @Sendable () async -> Success) -> Task‹Success, Never>

public mutating func addTask(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> ChildTaskResult)

 

Sendable 클로져의 경우 다음과 같은 제약사항이 생긴다. 

  • mutable variable을 캡쳐할 수 없다. 
  • Sendable Type만 캡쳐가 가능하다. 
  • 동기 클로져는 actor-isolated일 수 없다.
struct Counter {
    var value = 0
    
    mutating func increment() -> Int {
        value += 1
        return value
    }
}

var counter = Counter()

Task.detached {
    // Mutation of captured var 'counter' in concurrently-executing code
    print(counter.increment())
}

따라서, counter는 Sendable Type이지만, mutable 변수(var)이기 때문에

Sendable 클로져는 counter 변수를 캡쳐할 수 없다.

 

 

Main Actor

Swift에서 UI Update 작업들은 main Thread에서 진행하였다. 

이에 따라 네트워킹과 같은 오래 걸리는 작업을 진행하면, Completion Handler에서

DispatchQueue.main.async를 호출하여 UI 작업을 진행하였다. 

 

이에 따라, Swift에선 MainActor라는 globalActor를 도입하게 되었는데,

이는 main dispatch Queue에서 동기화 작업을 수행한다. 

MainActor는 @MainActor 어노테이션을 앞에 붙여 프로퍼티, 메서드, 클래스 등 여러 곳에 사용할 수 있다. 

@MainActor var someValue = 0

@MainActor func someFunction() {
    print("mainActor Function")
}

 

 

해당 어노테이션이 붙은 프로퍼티, 메서드, 클래스는

MainActor의 actor-isolated가 되고

이들을 Actor외부에서 비동기적으로 전달한다.

func nonIsolatedFunction() {
    Task.detached {
        await someFunction()
    }
}

Main Thread에서 특정 작업을 수행 중이라면, await하면서 앞선 작업이 끝날 때까지 기다려야 한다.

해당 동작은 진행할 작업을 큐에서 대기하는 main Dispatch Queue의 동작과 유사하다.

 

@MainActor annotation을 클래스와 같은 타입에 붙이게 되면, 

해당 클래스의 모든 멤버 역시 MainActor의 actor-isolated된다. 

 

만약, 특정 메서드나 프로퍼티들은 non-isolated하고 싶다면, 

앞서 살펴보았던, nonisolated 키워드를 붙이면 된다.

@MainActor class SomeClass {
    var value = 0
    
    func someFunction() {
        print("mainActor Function")
    }
    
    nonisolated func nonisolatedFunction() {
        print("non isolated Function")
    }
}

func printFunction() {
    
    Task.detached {
        let someClass = await SomeClass()
        // default initializer도 actor-isolated하기 때문에 await 키워드
        
        await someClass.someFunction()
        someClass.nonisolatedFunction()
    }
}

 

 

 

Reference

WWDC 2021

 

Apple Documentation

 

swift-evolution

 

 

Understanding Actors in the New Concurrency Model in Swift - Andy Ibanez

@MainActor and Global Actors in Swift - Andy Ibanez

복사했습니다!