[WWDC21] Protect mutable state with Swift actors

WWDC21 영상보면서 이해한대로 대충 끄적여보기 첼린지

정확한 이해가 필요하신 분들은 영상을 직접 시청하는 것을 권합니다.

값의 변동에 있어서 비동기로 뭐.. 값을 증가시키는 로직을 수행했을 때 어.. 같은 데이터를 다른 thread에서 동시 접근하는 건 bad access입니다. 어.. 음.. 당연한거긴 한데 아마도 이걸 제어하기 위한 세션이겠죠?

0분 42초 - Data races make concurrency hard

class Counter {
    var value = 0

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

let counter = Counter()

asyncDetached {
    print(counter.increment()) // data race
}

asyncDetached {
    print(counter.increment()) // data race
}

아마도 위에 말한 예시가 이게 될 것 같네요..

2분 59초 - Sometimes shared mutable state is required

struct Counter {
    var value = 0

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

let counter = Counter()

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

asyncDetached {
    var counter = counter
    print(counter.increment()) // always prints 1
}
  • Data races occur when
    • Two threads concurrently access the same data
    • One of them is a write

data race라는게 발생하는게 같은 데이터를 비동기로 다른 thread에서 접근할 때 발생합니다. 그중 하나를 쓰고 있을 때.. 어.. 음.. 이건 뭐 다들 아실거라고 생각합니다.

위에 코드는 let으로 생성되어 있어서 변수가 아닌 상수기 때문에 당연히 컴파일 에러가 발생하게 됩니다.(var counter = counter 코드가 없는 경우)

이런것도 친절하게 세션에서 알려주네요..

오..! 근데 또 운이 좋게도, var로 생성해도(let counter = Counter() 부분을) 컴파일러가 에러를 띄워준다고 하네요.. 다행이다..

그래서 이런 에러들을 피하기위한 코드가 바로 위의 코드입니당~

Shared mutables state in concurrent programs

  • Shared mutable state requires synchronization
  • Various stynchronization primitives exist
    • Atomics
    • Locks
    • Serial dispatch queues

비동기에서 mutable state를 공유하기 위한 방법에 대해 서술한 것들입니다.

atomic이랑 lock은 objc에서 썼던 것 같은데 이게 Swift에 있는지는 잘 모르겠네요.. Swift에선 Serial dispatch queue를 썼던 것 같은데..

Actors

  • Actors provide synchronization for shared mutable state
  • Actors isolate their state from the rest of the program
    • All access to that state goes through the actor
    • The actor ensures mutually-exclusive access to its state

actor는 공유된 mutable state를 동기적으로 제공하고, 그 state를 프로그램에서 분리합니다. state들에 대한 모든 접근 actor를 통해서 이뤄지고, state에 대한 상호 배제적?(mutually-exclusive) 접근을 보장합니다…

말로해선 너무 어려운데, 예시를 봐야할 것 같습니다.

5분 23초 - Actor isolation prevents unsynchronized access

actor Counter {
    var value = 0

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

let counter = Counter()

asyncDetached {
    print(await counter.increment())
}

asyncDetached {
    print(await counter.increment())
}

struct, enum, class에 사용가능하다고 합니다. reference type입니다.

1, 2 또는 2, 1의 값을 얻을 수 있다고 합니다. 만약에 동시접근했거나 값을 write 중인데 접근 요청이 들어오면 suspend한다고 합니다. 그니까 먼저 접근해서 작업 중인 work가 끝날 때까지 기다린다는 거죠..

위에서 말한 동시접근 처리 방식인데 코드만 다른 그런 느낌이네요..

7분 51초 - Synchronous interation within an actor

extension Counter {
    func resetSlowly(to newValue: Int) {
        value = 0
        for _ in 0..<newValue {
            increment()
        }
        assert(value == newValue)
    }
}

어.. 음.. extension해서 위 코드를 다시 reset하는 코드를 작성했는데, 음.. 뭘 말해야할지 모르겠네요.. 딱히 뭐 여기선 별다를게 없는 느낌이긴한데.. 뭘 보여줄려고 했는지 모르겠넹..

9분 02초 - Check your assumptions after an await: The sad cat

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[url] = image
        return image
    }
}

이건 actor기 때문에 비동기로 여러 이미지들을 다운로드하고 cache에 접근하더라도 data race에서 안전하다고 합니다. 아까도 위에서 말했듯이 오직 하나의 접근만 가능하다 뭐 그런 거라고 생각하면 될듯.. 즉, 동시접근이 불가능하다~

Potential bug라는건, 여기서 예시를 들어줬는데, 만약에 이게 await가 작업이 비동기로 실행되는 동안 thread는 다른 작업을 이어서 하는 건데요.. 이미지가 다운로드 되는동안, image URL은 동일한데 서버에서 다운로드 되는 이미지가 바뀔경우? cache에는 하나의 cache 데이터만 저장되는 현상?? 그니까 이게 동일한 URL인데 같은 이미지를 얻지 못한다는 문제를 말하고 싶은 것 같은데 애초에 왜 동일한 URL을 연속적으로 요청을 할까요…

이걸 보여줬는데… 음.. 솔직히 다운로드… 되는동안 이미지가 바뀔 일이 있나.. 싶기도하고, 잘 모르겠네요.. 해결 방법이 있는거니까 다루겠죠? 계속 봐봅시다.

11분 50초 - Check your assumptions after an await: One solution

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)

        // Replace the image only if it is still missing from the cache.
        cache[url] = cache[url, default: image]
        return cache[url]
    }
}

이게 해결책인데, 이미 cache가 존재한다면 원래 있던 걸 쓰고, 새로운 데이터는 버린다고 하네요…? 음.. 불필요한 다운로드를 피하기 위한 거라고 하네요..

이런 경우는 경험이 없어서 필요한지는 모르겠는데, 뭐.. 이런 상황이 온다면 쓰겠죠?

11분 59초 - Check your assumptions after an await: A better solution

actor ImageDownloader {

    private enum CacheEntry {
        case inProgress(Task.Handle<Image, Error>)
        case ready(Image)
    }

    private var cache: [URL: CacheEntry] = [:]

    func image(from url: URL) async throws -> Image? {
        if let cached = cache[url] {
            switch cached {
            case .ready(let image):
                return image
            case .inProgress(let handle):
                return try await handle.get()
            }
        }

        let handle = async {
            try await downloadImage(from: url)
        }

        cache[url] = .inProgress(handle)

        do {
            let image = try await handle.get()
            cache[url] = .ready(image)
            return image
        } catch {
            cache[url] = nil
            throw error
        }
    }
}

이건 영상에서 안다뤄서 넘어갈 뻔했는데, 코드가 있더라구요.. 이게 아까 위에 방식보다 나은 방식이라고 합니다.

enum을 통해 Task 자체를 작업으로 넣어놨다가 다운로드가 완료되면 Enum 타입을 교체하는 방식이네요.

이러면 suspend된 작업이 끝나지 않았어도 처리가 가능하고, 작업이 완료되더라도 결국 cache엔 이전 이미지가 저장되네요..

Actor reentrancy

  • Perform mutation in synchronous code
  • Expect that the actor state could change during suspension
  • Check your assumptions after an await

위에서 다뤘던 내용에 대한 요약이라고 보시면 될 것 같습니다.

13분 30초 - Protocol conformance: Static declarations are outside the actor

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

extension LibraryAccount: Equatable {
    static func ==(lhs: LibraryAccount, rhs: LibraryAccount) -> Bool {
        lhs.idNumber == rhs.idNumber
    }
}

Equatable을 채택해서 static function을 만들었을 때 자체 인스턴스가 없으므로 actor는 not isolated 합니다. (격리되지 않았다..? 라고 해석할 수 있는데 아직까진 정확한 의미를 잘 모르겠네요??)

이 function은 actor의 immutable state에만 access하기 때문에 문제가 없다고합니다.

14분 15초 - Protocol conformance: Non-isolated declarations are outside the actor

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

extension LibraryAccount: Hashable {
    nonisolated func hash(into hasher: inout Hasher) {
        hasher.combine(idNumber)
    }
}

Hashable을 채택했을 경우를 봅시다. 여기서 nonisolated 키워드를 빼주면 컴파일 에러가 난다고 하네요.. 호환성이 허용되지 않는다..?

이 hash라는 function은 외부에서 호출할 수 있지만 이 hash function은 비동기 상태가 아니기 때문에 actor와 isolate 할 수 없다고 합니다.

그니까 이게 그.. 외부에 접근 가능한 function의 state가 async 하지않으면 호환되지 않는다.. 라고 이해하면 될 듯 합니다..

그래서 이럴 경우, nonisolated 키워드를 써주면 가능한 것 같아요!

그리고 hash를 설정할 때 만약에 booksOnLoan으로 설정한다면, booksOnLoan은 mutable하고, 외부 접근이 가능하기 때문에 적합하지 않다고 하네요..

15분 32초 - Closures can be isolated to the actor

extension LibraryAccount {
    func readSome(_ book: Book) -> Int { ... }
    
    func read() -> Int {
        booksOnLoan.reduce(0) { book in
            readSome(book)
        }
    }
}

여기서 reduce로 closure 쓴 것도 문제가 된다고 하는데… 이런거까지..?

그러면 reduce, map, filter 등 고차함수도 코드를 다 바꿔야하나..?

readSome이 asnyc throws로 선언된 function이고, 이걸 고차함수 closure에서 호출한다면 충분히 고려해야 될 문제라고 생각하지만, 어.. 예시코드만 봐서는 문제없어보이는데..

아… 여기서 말하길, reduce closure 안에서 readSome function을 실행할 때, readSome에 대한 작업이 끝날 때까지 기다리지 않는군요… 그래서 문제가 발생한다고 합니당.. 문제라고 하면, 아마.. 값에 동시접근하는 문제겠죠??

(저렇게 써본적이 없어서 잘 몰랐네요..)

16분 29초 - Closures executed in a detached task are not isolated to the actor

extension LibraryAccount {
    func readSome(_ book: Book) -> Int { ... }
    func read() -> Int { ... }
    
    func readLater() {
        asyncDetached {
            await read()
        }
    }
}

그니까 즉, async로 실행하는데 책 읽는 걸 async로 하고 다 읽을 때까지 suspend해서 값을 도출한다… 뭐 그런겁니까.. 뭔가 안써봐서 그런지 확 와닿는 느낌이 없네요..

나중에 이 부분 한 번 더 봐야할듯

17분 15초 - Passing data into and out of actors: structs

actor LibraryAccount {
    let idNumber: Int
    var booksOnLoan: [Book] = []
    func selectRandomBook() -> Book? { ... }
}

struct Book {
    var title: String
    var authors: [Author]
}

func visit(_ account: LibraryAccount) async {
    guard var book = await account.selectRandomBook() else {
        return
    }
    book.title = "\(book.title)!!!" // OK: modifying a local copy
}

아마도 이건 Book이 struct기 때문에 value type이라 값이 복사되고, 그래서 data race에 문제가 없다는 것을 보여주기 위한 예시입니다.

17분 39초 - Passing data into and out of actors: classes

actor LibraryAccount {
    let idNumber: Int
    var booksOnLoan: [Book] = []
    func selectRandomBook() -> Book? { ... }
}

class Book {
    var title: String
    var authors: [Author]
}

func visit(_ account: LibraryAccount) async {
    guard var book = await account.selectRandomBook() else {
        return
    }
    book.title = "\(book.title)!!!" // Not OK: potential data race
}

이건 반대로 Book이 class일 경우인데, class는 reference type이기 때문에 값이 참조되고 잠재적으로 data race가 발생할 수 있습니다.

그래서 class로 선언된 어떤 데이터 타입을 safe하게 제어하기 위해서 Sendable이라는 걸 만듬..

Sendable types

  • Sendable types are safe to share concurrently
  • Many different kinds of types are Sendable:
    • Value types
    • Actor types
    • Immutable classes
    • Internally-synchronized class
    • @Sendable function types

20분 08초 - Check Sendable by adding a conformance

struct Book: Sendable {
    var title: String
    var authors: [Author]
}

Codable이랑 비슷한 구조인듯.. Author가 Sendable이 아니면 Book에 Sendable을 채택할 수 없다고 하네요..

20분 43초 - Propagate Sendable by adding conditional conformance

struct Pair<T, U> {
    var first: T
    var second: U
}

extension Pair: Sendable where T: Sendable, U: Sendable {
}

generic 타입으로 선언해도 마찬가지라고 합니다.

asyncDetached에도 Sendable을 채택한 데이터 타입만 쓸 수 있다고하네요.. 즉 위에서 썼던 값들이 Sendable을 채택하고 있지 않으면 컴파일러에서 에러를 표시해준다고 합니다.

Interacting with the main thread

  • The main thread is important for apps
    • UI rendering
    • Main run loop to processing events

24분 19초 - Interacting with the main thread: Using a DispatchQueue

func checkedOut(_ booksOnLoan: [Book]) {
    booksView.checkedOutBooks = booksOnLoan
}

// Dispatching to the main queue is your responsibility.
DispatchQueue.main.async {
    checkedOut(booksOnLoan)
}

이건 뭐.. 다들 아시겠죠.. 보통 UI redering할 때 자주 쓰는데, DispatchQueue를 사용해서 main thread에 작업을 할당하는 코드입니다.

25분 01초 - Interacting with the main thread: The main actor

@MainActor func checkedOut(_ booksOnLoan: [Book]) {
    booksView.checkedOutBooks = booksOnLoan
}

// Swift ensures that this code is always run on the main thread.
await checkedOut(booksOnLoan)

@MainActor라는 키워드를 쓰면 항상 main thread에서 동작하는 것을 보장합니다.

오호… 비동기 코드에서 UI rendering할 때, 클로저가 하나 줄겠군..

26분 21초 - Main actor types

@MainActor class MyViewController: UIViewController {
    func onPress(...) { ... } // implicitly @MainActor

    nonisolated func fetchLatestAndDisplay() async { ... } 
}

이렇게 선언하면 모든 프로퍼티와 메서드가 MainActor로 된다고 하네요.. 모든게 main thread에서 동작하게 되는… 뭔가 ReactorKit의 View처럼 쓸 수 있지않나..

진짜 UI render만 해주는 느낌으로 간다면…