Swift Concurrency 教學

Photo by Ben White on Unsplash
Photo by Ben White on Unsplash
Swift 5.5 推出了 Swift concurrency。它讓我們用 synchronous 的方式來完成 asynchronous code。大大地降低 asynchronous code 的複雜度。本文章將介紹 Swift concurrency 的基本知識。

Swift 5.5 推出了 Swift concurrency。它讓我們用 synchronous 的方式來完成 asynchronous code。大大地降低 asynchronous code 的複雜度。本文章將介紹 Swift concurrency 的基本知識。

async & await

Swift 的 async/await 讓我們可以撰寫 asynchronous code 如同是 synchronous code,如下。在第四行,當呼叫 getImageData() 時,getImage() 會被 suspended,然後等待 getImageData() 執行完畢。此時,getImage() 會放棄當前的 thread,使得當前的 thread 不會被 blocked。當 getImageData() 執行完畢,getImage() 會 resume 執行接下來的程式碼。

func getImageData() async throws -> Data

func getImage() async throws -> UIImage? {
    let data = try await getImageData()
    return UIImage(data: data)
}

Suspension Points

一個 suspension point 是一個 point 當一個 asynchronous function 放棄它的 thread。在上面的程式碼中,第四行呼叫 getImageData() 就是一個 suspension point。當一個 asynchronous function 跑在一個 thread 上,並到達一個 suspension point 時,其他的程式碼可以跑在相同的 thread。當這個 thread 是 UI main thread 時,這就非常地重要了。因為,這使得 UI thread 不會被 blocked。

一般來說,在 await 關鍵字的地方,就是一個 suspension point。

Structured Tasks

Tasks

在系統中,一個 task 是 concurrency 的一個基本 unit。每一個 asynchronous function 都執行在一個 task 中。一個 task 會包含它的 scheduling 資訊,如 task 的 priority。

Child Tasks

一個 asynchronous function 可以建立一個 child task。Child tasks 從它們的 parent tasks 繼承一些資訊,如 priority。Child tasks 和 parent tasks 可以 concurrently 執行。但是,這個 concurrency 是 bounded。也就是說,一個 asynchronous function 必須等待它所有的 child tasks 結束,它才可以結束。

因為 tasks 可以有 parents 和 children 這樣的階層關係,所以稱為 structured tasks。

建立一個 child task 的方法有兩種,一個是 TaskGroup,另一個是 async let

TaskGroup

以下程式碼顯示如何利用 TaskGroup 建立多個 child tasks。

/// Concurrently chop the vegetables.
func chopVegetables() async throws -> [Vegetable] {
  // Create a task group where each child task produces a Vegetable.
  try await withThrowingTaskGroup(of: Vegetable.self) { group in 
    var rawVeggies: [Vegetable] = gatherRawVeggies()
    var choppedVeggies: [Vegetable] = []
    
    // Create a new child task for each vegetable that needs to be chopped.
    for v in rawVeggies {
      group.addTask { 
        try await v.chopped()
      }
    }

    // Wait for all of the chopping to complete, collecting the veggies into
    // the result array in whatever order they're ready.
    while let choppedVeggie = try await group.next() {
      choppedVeggies.append(choppedVeggie)
    }
    
    return choppedVeggies
  }
}

async let

另外一個建立 child tasks 的方式是 async let,如下。第二行會建立一個 child task 去執行 chopVegetables()。makeDinner() 不會等待 chopVegetables() 完成,而是繼續執行下去。之後,再用 await 來等待 child task 完成,並取得回傳值。

func makeDinner() async throws -> Meal {
  async let veggies = chopVegetables()
  async let meat = marinateMeat()
  async let oven = preheatOven(temperature: 350)

  let dish = Dish(ingredients: await [try veggies, meat])
  return try await oven.cook(dish, duration: .hours(3))
}

Jobs

在系統中,一個 Job 是一個 schedulable work 的基本 unit。在一個 asynchronous function 中,一段 synchronous 程式碼到一個 suspension point,或是到函式的結尾,就是一個 job。如下程式碼中,第二行至第三行是一個 job;第四行至第五行是一個 job;第六行是一個 job。

func getUserProfile() async throws -> UserProfile {
    let url = "https://www.waynestalk.com/user"
    let data = try await getUserProfile(url)
    let userProfile = try JSONDecoder().decode(UserProfile.self, from: data)
    try await saveToDatabase()
    return userProfile
}

Executors

一個 executors 是一個 service 會接受 jobs 的提交,並安排 thread 來執行他們。

Swift 提供一個預設的 executor 實作。一般來說,當呼叫一個 asynchronous function,我們不需要去指定要使用哪個 executor 來執行。

Unstructured Tasks

至目前為止,我們所討論的 tasks 都必須要有 parent tasks。也就是說,這些 tasks 都是 child tasks。但是,實際上,在目前的 iOS 開發環境中,這是不可能的。因為,目前大部分的 iOS 程式碼(legacy code)都不是 concurrency 程式碼。所以,當我們想要呼叫一個 asynchronous functions 時,我們必須要建立一個新的 task,而這個 task 沒有 parent task。所以,這就稱為 unstructured tasks。

Task()

我們可以使用 Task() 建立一個新的 task,如下。viewDidLoad() 在呼叫 Task() 來建立一個新的 task 後,會立即往下執行,不會等待新的 task 執行結束。所以,viewDidLoad() 和新的 task 會 concurrently 執行。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let handle = Task {
            // This task will run in the UI thread.
            let userProfile = try await getUserProfile()
        }
        // You can cancel this task.
        handle.cancel()
    }
    
    private func getUserProfile() async throws -> UserProfile {
        let url = "https://www.waynestalk.com/user"
        let data = try await getUserProfile(url)
        let userProfile = try JSONDecoder().decode(UserProfile.self, from: data)
        try await saveToDatabase()
        return userProfile
    }

    ...
}

Context 繼承

如果 Task() 是在一個 task 中被呼叫的話,

  • 它會繼承當前 task 的 priority。
  • 它會繼承所有的 task-local 值。把它們都複製一份到新的 task。
  • 假如是在一個 actor 的 function 中被呼叫的話,
    • 它會繼承 actor 的 context。它會在這個 actor 的 executor 中執行新的 task。
    • Task {} 裡的 closure 會變成 actor-isolated。

如果 Task() 不是在一個 task 中被呼叫的話,

  • 它會根據當前的 thread,決定最好的 priority。
  • 它會在 global concurrent executor 中執行。

Task.detached()

Task.detached() 和 Task() 一樣建立一個新的 task,但是它不會繼承當前的 context。也就是說,它不會繼承 priority、task-local values、和 actor context。

有一個會用到 Task.detached() 的情況是,在 UI thread 中,我們希望建立一個在 non-UI thread 上執行的 task。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let handle = Task.detached {
            // This task will run in a non-UI thread.
            let userProfile = try await getUserProfile()
        }
        // You can cancel this task.
        handle.cancel()
    }
    
    private func getUserProfile() async throws -> UserProfile {
        let url = "https://www.waynestalk.com/user"
        let data = try await getUserProfile(url)
        let userProfile = try JSONDecoder().decode(UserProfile.self, from: data)
        try await saveToDatabase()
        return userProfile
    }

    ...
}

withCheckedContinuation() & withCheckedThrowingContinuation()

withCheckedContinuation() 和 withCheckedThrowingContinuation() 可以讓我們將還是使用 callback 的 legacy asynchronous code 包裝成 async/await code,如下。

class ViewController: UIViewController {
    let webView = WKWebView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        Task {
            let result = try await execJavaScript("some js code")
            print(result)
        }
    }
    
    private func execJavaScript(_ js: String) async throws -> String {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
            webView.evaluateJavaScript(js) { result, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: "\(result ?? "")")
                }
            }
        }
    }
}

Actors

Actors 是 Swift 提供用來 statically 偵測 data races 的方法。也就是說,Swift 希望可以在編譯時期就可以偵測出 data races 的程式碼,而這個解決方法就是 actors。

Actors 是一個新的 Swift type。它和 classes 非常相似,它們都是 reference type,但是 actors 不支援繼承。下面的程式碼中,我們宣告一個 actor 叫 BankAccount。它使用與 classes 和 structs 相同的方式來建立 instance。

actor BankAccount {
    var accountNumber: String
    var balance: Double

    init(accountNumber: String, balance: Double) {
        self.accountNumber = accountNumber
        self.balance = balance
    }

    func deposit(amount: Double) {
        balance = balance + amount
    }
}

let bankAccount = BankAccount(accountNumber: "123456789", balance: 300)

Actor Isolation

當我們從 actor 的外面存取 actor 的 variables 或 methods 時,就叫做 cross-actor reference。我們可以在 actor BankAccount 的外面直接存取 actor 的 immutable variables,因為 immutable variables 自宣告之後,他們的值就不會改變了。但是如果我們在 actor BankAccount 的外面直接存取 actor 的 mutable variables,或是直接呼叫 actor 的 methods 的話,Xcode 就會顯示 Actor-isolated property/method can not be referenced 的錯誤,如下。

class ViewController: UIViewController {
    let webView = WKWebView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let bankAccount = BankAccount(accountNumber: "123456789", balance: 300)
        let balance = bankAccount.balance // Error: Actor-isolated property 'balance' can not be referenced from the main actor
        bankAccount.deposit(amount: 100) // Error: Actor-isolated instance method 'deposit(amount:)' can not be referenced from the main actor
    }
}

這是因為 actor 裡面的 mutable variables 和 methods 是 actor-isolated。要存取 actor-isolated 的 variables 或 methods 的話,要用呼叫 asynchronous function 的方式來存取才可以,如下。

這是因為每一個 actor 裡面包含一個 serial executor。actor 的程式碼都必須在它自己的 executor 上執行。在上面的範例中,當我們呼叫 bankAccount.deposit(amount:) 時,我們是用呼叫 asynchronous function 的方式來呼叫它。也就是說,我們送出一個 task 給 actor 的 executor,等待這個 executor 依序處理它目前的 task,值到 executor 處理好我們的 task。

class ViewController: UIViewController {
    let webView = WKWebView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        Task {
            let bankAccount = BankAccount(accountNumber: "123456789", balance: 300)
            let balance = await bankAccount.balance
            await bankAccount.deposit(amount: 100)
        }
    }
}

一個 actor 裡面的一個 actor-isolated method 存取在同一個 actor 裡面的 actor-isolated variables 或 methods 時,可以用 synchronous 的方式存取。如,actor BankAccount 裡的 deposit(amount:) 可以直接存取 balance 變數。

actor BankAccount {
    var balance: Double

    ...

    func deposit(amount: Double) {
        balance = balance + amount
    }
}

講到這邊,也許你會對 actor 產生困惑。也許你可以想像 actor 是一個包含以下的物件。

  • 一個 data structure
  • 一個 queue
  • 一個 serial executor

當所有存取 actor 的 actor-isolated variables 或 methods 的 tasks,都會被排成到它內部的 queue 裡面。actor 內部有一個 serial executor,一一地執行 queue 裡面的 tasks。所以,任何一個時間點,只會有一個 task 被執行,而且只會有一個 single thread 存取 actor 的 actor-isolated variables 或 methods。所以,actor 不會有 data races 的問題。

Actor Reentrancy

下面的程式碼中,Person.thinkOfGoodIdea() 和 Person.thinkOfBadIdea() 都是 actor-isolated methods。我們同時呼叫 Person.thinkOfGoodIdea() 和 Person.thinkOfBadIdea()。假設,Person.thinkOfGoodIdea() 先被執行,在執行到第 9 行的 suspension point 時,actor-isolated method 被 suspended,actor 會先去執行其他的工作,也就是 Person.thinkOfBadIdea()。當執行到第 15 行,actor-isolated method 被 suspended。actor 可能會執行第 9 行的回傳值。這稱為 actor reentrancy。

Actor reentrancy 排除 deadlocks,並且藉由移除不必要的 blocked 來提供效能。

actor Person {
  let friend: Friend
  
  // actor-isolated opinion
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea
    await friend.tell(opinion, heldBy: self)
    return opinion
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea
    await friend.tell(opinion, heldBy: self)
    return opinion
  }
}

let goodThink = Task.detach { await person.thinkOfGoodIdea() }  // runs async
let badThink = Task.detach { await person.thinkOfBadIdea() } // runs async

nonisolated

當你確定 actor 裡的某個 method 不會存取 actor-isolated variables 或 methods 時,你可以宣告此 method 為 nonisolated。這時你就可以在 actor 的外面,用呼叫 synchronous function 的方式來呼叫它,如下。

class ViewController: UIViewController {
    let webView = WKWebView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        Task {
            let bankAccount = BankAccount(accountNumber: "123456789", balance: 300)
            await bankAccount.deposit(amount: 100)
            let accountNumber = bankAccount.getAccountNumber()
        }
    }
}

actor BankAccount {
    private let accountNumber: String
    private var balance: Double
    
    init(accountNumber: String, balance: Double) {
        self.accountNumber = accountNumber
        self.balance = balance
    }

    func deposit(amount: Double) {
        balance = balance + amount
    }
    
    nonisolated func getAccountNumber() -> String {
        accountNumber
    }
}

@globalActor & @MainActor

我們可以發現使用 actor 最大的用意是,我們想要它的 actor isolation 特性,因為 actor isolation 可以排除 data races。但是它有一個缺點,那就是它只能在 actor 裡面使用。想像一下,如果我們可以將 actor isolation 這個特性使用在整個程式碼中,那該有多好。也就是說,在任何一行程式碼,我們都可以利用 actor isolation 來排除 data races。Swift 提出 global actor 來達到這個目的。

Global actor 和一般的 actor 不一樣。所以不要因為名稱的相似,而將 global actor 視為一般的 actor。這樣子的話,你會產生混淆,而無法了解 global actor。

一個 global actor 可以是一個 struct、enum、actor、或 final class。它必須要有一個 static 的 shared 變數。下面的程式碼顯示如何宣告一個 global actor,以及如何使用它。

@globalActor
struct SomeGlobalActor {
  actor MyActor { }

  static let shared = MyActor()
}

@SomeGlobalActor
var someVariable = "something"

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        Task {
            let sv = await someVariable
        }
    }
}

一般來說,我們可能不會自行宣告 global actors。不過,我們會常常使用到一個預先定義好的 global actor,叫 main actor。

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@globalActor final public actor MainActor : GlobalActor {
    public static let shared: MainActor

    @inlinable nonisolated final public var unownedExecutor: UnownedSerialExecutor { get }

    @inlinable public static var sharedUnownedExecutor: UnownedSerialExecutor { get }

    @inlinable nonisolated final public func enqueue(_ job: UnownedJob)

    public typealias ActorType = MainActor
}

在 iOS 中,任何標示為 @MainActor 的 variables 或 methods 都會 actor-isolated 到 UI main thread。在下面的程式碼中,我們可以看到 BankAccount.deposit(amount:) 是在一個 background thread 上執行。但是,因為 BankAccount.getAccountNumber() 標示為 @MainActor,所以它在 main thread 上執行。

另外,如果我們將一個 class 標示為 @MainActor 的話,那它所有的 variables 和 methods 都會在 main thread 上執行。

在實際的應用上,如果某一段程式碼需要更新 UI 時,我們可以將它標示為 @MainActor,那它就會在 UI thread 上執行。這可以取代我們以往用 DispatchQueue.main.async 的方式來將某段程式碼在 UI thread 上執行。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        Task.detached {
            print("Task.detached, \(Thread.current)")
            let bankAccount = BankAccount(accountNumber: "123456789", balance: 300)
            await bankAccount.deposit(amount: 100)
            _ = await bankAccount.getAccountNumber()
        }
    }
}

actor BankAccount {
    private let accountNumber: String
    private var balance: Double
    
    init(accountNumber: String, balance: Double) {
        self.accountNumber = accountNumber
        self.balance = balance
    }

    func deposit(amount: Double) {
        balance = balance + amount
        print("deposit, \(Thread.current)")
    }
    
    @MainActor
    func getAccountNumber() -> String {
        print("getAccountNumber, \(Thread.current)")
        return accountNumber
    }
}

// Output:
// Task.detached, <NSThread: 0x6000034c1000>{number = 7, name = (null)}
// deposit, <NSThread: 0x6000034c1000>{number = 7, name = (null)}
// getAccountNumber, <_NSMainThread: 0x600003494200>{number = 1, name = main}

值得注意的是,在 UIKit 中,很多和 UI 相關的程式碼都標示 @MainActor。也就是說,當你在 background thread 中,呼叫 UIViewController 或 UIView 裡面的 methods 時,都會切換到 UI main thread 上執行。

@MainActor open class UIViewController : UIResponder, NSCoding, UIAppearanceContainer, UITraitEnvironment, UIContentContainer, UIFocusEnvironment

@MainActor open class UIView : UIResponder, NSCoding, UIAppearance, UIAppearanceContainer, UIDynamicItem, UITraitEnvironment, UICoordinateSpace, UIFocusItem, UIFocusItemContainer, CALayerDelegate

Sendable & @Sendable

一個 actor 裡面的 mutable variables 和 methods 是 actor-isolated。那當這個 variable 是一個 reference type 時,那我們要如何保證這個 reference type 裡面的變數不會有 data races 的情況。也就是說,如果 actor 裡有一個 class type 的 variable,我們要如何保證 class 裡面的 members 不會有 data races 呢?或者是說,當呼叫 actor 的 methods 時,如果它的參數或是回傳值是 class type,那要如何保證這個參數或回傳值裡的 members 不會有 data races 呢?Swift 提出 Sendable 來解決這個問題。

Sendable 是一個 marker protocol。也就是說,它是一個空的 protocol。

public protocol Sendable {}

所有 cross-actor 的變數都必須要 conform Sendable。也就是說,所有 actor-isolated 的 methods 的參數和回傳值,都必須要 conform Sendable。不然在編譯時期 Xcode 就會顯示錯誤。而,任何 conform Sendable 的 class 也都要排除 data races 的可能性。不然在編譯時期,Xcode 就會顯示錯誤。如下程式碼中,class Money 實作 Sendable。Xcode 會在編譯時期就幫我們檢查 Money 是否可能有 data races。

class Money : Sendable { // Error: Non-final class 'Money' cannot conform to 'Sendable'; use '@unchecked Sendable'
    var amount: Double // Error: Stored property 'amount' of 'Sendable'-conforming class 'Money' is mutable
    var rate: Double // Error: Stored property 'rate' of 'Sendable'-conforming class 'Money' is mutable
    
    init(amount: Double, rate: Double) {
        self.amount = amount
        self.rate = rate
    }
    
    func calculateAmount() -> Double {
        amount * rate
    }
}

因此藉由 Sendable,Swift 編譯器可以排除 cross-actor 的變數是否有 data races 的可能。

除了 classes 之外,我們還需要考慮另外一個 type,就是 function type。當傳給 actor 的 methods 的參數是一個 closure 時,若這個 closure 修改外面的變數的話,則會有 data races。我們可以在 method 宣告的時候,在參數那邊加上 @Sendable。這樣 Xcode 會在編譯時期,幫我們確認 closure 裡面是否有 date races,如下。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        Task.detached {
            var amount = 1.0
            
            let bankAccount = BankAccount(accountNumber: "123456789", balance: 300)
            await bankAccount.deposit {
                amount = amount * 100 // Error: Mutation of captured var 'amount' in concurrently-executing code
                return amount // Error: Mutation of captured var 'amount' in concurrently-executing code
            }
        }
    }
}

actor BankAccount {
    private let accountNumber: String
    private var balance: Double
    
    init(accountNumber: String, balance: Double) {
        self.accountNumber = accountNumber
        self.balance = balance
    }

    func deposit(computeAmount: @Sendable () -> Double) {
        balance = balance + computeAmount()
    }
}

然而,實際上,在目前的 Xcode 14.x 上,actor-isolated methods 的參數和回傳值不是必須要 conform Sendable。也就是說,Xcode 並不會因為它們沒有實作 Sendable 或而顯示錯誤。這是因為,目前 iOS 開發環境中還存在著很多 legacy code。為了相容性,Sendable 變成了 optional。不過,對於我們自己撰寫的程式碼,最好還是要 conform Sendable。

actor BankAccount {
    private let accountNumber: String
    private var balance: Double
    
    init(accountNumber: String, balance: Double) {
        self.accountNumber = accountNumber
        self.balance = balance
    }

    // Xcode does not display any error.
    func deposit(money: Money) {
        balance = balance + money.calculateAmount()
        print("deposit, \(Thread.current)")
    }
}

class Money {
    var amount: Double
    var rate: Double
    
    init(amount: Double, rate: Double) {
        self.amount = amount
        self.rate = rate
    }
    
    func calculateAmount() -> Double {
        amount * rate
    }
}

結語

Swift Concurrency 將 asynchronous code 簡化成 synchronous 的呼叫方式。但是,它也增加不少新的概念。我們必須要理解這些東西,才能夠寫出正確的程式碼。本文章只是概略地解釋這些新的概念,讀者還是必須要詳讀列在參考中的文章。

參考

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Chris Murray on Unsplash
Read More

Swift Combine 教學

Swift Combine 是 Apple 用來實現 reactive programming 的函式庫。在 Combine 還沒出來之前,我們一般是使用 RxSwift。不過,使用 Combine 的話,我們就不需要再引入額外的函式庫。而且與 RxSwift 相比,Combine 的效能更好。
Read More
Photo by Heather Barnes on Unsplash
Read More

如何建立並發佈 Swift Packages

Swift packages 是可重複使用的程式碼元件。它可包含程式碼、二進位檔、和資源檔。我們可以很容易地在我們的 app 專案中使用 Swift packages。本文章將介紹如何建立並發佈 Swift packages。
Read More