18. 並行運算

並行運算(concurrency)允許同一支程式在同一時間中執行兩個以上運算,跟我們很熟悉的多執行緒概念很類似,只是多執行緒是實現並行運算的一種底層技術。某種程度上我們可以視並行運算為更高階的多執行緒語法,他可以很容易的處理在多執行緒中惱人的資源競爭情形,也就是不同執行緒間要搶同一個資源時產生的死結或是計算錯誤問題。從 Swift 5.5 版開始,並行運算語法被正式加到 Swift 基本語法中,雖然未來這部分應該還會再擴充,但目前已經可以用他來做些事情了。

先來看在沒有並行運算語法前的作法,請參考下面的程式碼。函數 f1() 與 f2() 中都開啟了多執行續進行非同步運算,運算完成後透過 Closure 將運算結果返回,如果我們想要單獨使用 f1() 或 f2() 功能時,這時可以以非同步的方式執行 f1() 或 f2() 。

func f1(_ value: Int, _ p: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
        p(value)
    }
}

func f2(_ value: Int, _ p: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
        p(value + 1)
    }
}

如果現在有一個需求,是需要等 f1() 執行完後才能執行 f2(),也就是 f2() 的輸入參數來自於 f1() 的返回值時,程式碼需要寫成如下的語法。這樣的語法並不容易閱讀,尤其當層數更多,並且裡面還要判斷返回值是否有錯誤等一大堆判斷式的時候,程式碼會非常難以理解。

f1(1) { value in
    f2(value) { value in
        print(value)
    }
}

我們重新改寫 f1() 與 f2(),只要在函數後方加上 async 保留字就可以了,如下。

func f1(_ value: Int) async -> Int {
    return value
}

func f2(_ value: Int) async -> Int {
    return value + 1
}

如果跟前述一樣,f2() 的輸入參數必須來自於 f1() 的返回值時,程式可以寫成如下語法。保留字 await 會等有 async 的函數執行完後才執行下一行,所以 f2() 一定會等 f1() 執行後才開始執行。此外,async 函數必須被放在 Task 中使用, Task {…} 區段中的程式碼會與 Task 外的程式碼同時執行。若 Task 中的程式要修改 UI 元件時,直接修改即可,並不需要像在多執行緒中還要將程式碼抓回到主執行緒中多一道手續。

Task {
    let v1 = await f1(1)
    let v2 = await f2(v1)
    print(v2)
}

再看另外一個例子。下面這兩個函數都屬於 async 函數。

func f1() async -> Bool {
    for i in 0...4 {
        print(i)
        sleep(1)
    }
    return true
}

func f2() async -> Bool {
    for i in 5...9 {
        print(i)
        sleep(1)
    }
    return true
}

上面這兩個 async 類型的函數,如果要同步執行呼叫方式如下。這段程式碼建議不要在 Playground 中執行,因為稍後的非同步程式碼必須在 App 專案中語法才正確,目前 Playground 不支援非同步語法。執行時可以看到 f1() 先執行,執行完畢後才執行 f2(),兩個函數都執行完才印出 “done” 字串。

@IBAction func onClick(_ sender: Any) {
    Task {
        let _ = await f1()
        let _ = await f2()
        print("done")
    }
}

若是以非同步方式執行 f1() 與 f2(),使用 async let 語法就可以做到,如下。執行後會看到 f1() 與 f2() 中的數字是交錯輸出的,等到兩個函數全部執行完畢,最後才會輸出 “done” 字串。

@IBAction func onClick(_ sender: Any) {
    Task {
        async let ret1 = f1()
        async let ret2 = f2()
        if await ret1, await ret2 {
            print("done")
        }
    }
}

在同一個 Task 中,我們可以透過 await 設定等待點,等 async 函數執行完畢後下面的程式碼才繼續執行,或者可以使用 async let 讓下面的程式碼立刻執行。不論呼叫者用什麼方式呼叫 async 函數,也許是依序呼叫,也許呼叫時透過多工方式大量同時呼叫,但同一時間 async 函數中的程式碼一次只會有一個呼叫者在執行,其他呼叫者都要排隊等待。

演員模型

演員模型(Actor Model)是一種用於並行運算上的程式設計模型,如同物件導向一樣,都是一種程式設計的概念,但專門用在並行運算上。 演員模型是一個發展已久的模型,但現在導入到 Swift 語言中。演員模型基本概念很簡單,一個演員一次只執行一個動作,前一個指令沒執行完,後一個指令就會等待。如果好幾個指令要同時運作,那舞台上就需要多個演員。

在 Swift 中,演員(actor)跟 class 一樣是一種 reference type,宣告的語法也一樣,但跟 class 不同的地方是演員不能繼承,基本形式如下。

actor SomeActor {
    
}

在 actor 中可以使用 let 或 var 來定義屬性,也可以定義方法,就跟類別一樣只不過把保留字 class 換成 actor 而已,與 class 的差異在於 actor 中的屬性與方法都屬於 async 形式,也就是一次只能一個人存取。

下面這個 actor 模擬一個賣票代理商,他手上有兩張票可賣。方法 buy(_:) 中會判斷票是否已經賣完,如果賣完傳回 nil,如果還沒賣完,將票數減一並且傳回票號。

actor TicketAgent {
    let id: Int
    var tickets = 2     // 只有兩張票可賣
    
    init(id : Int) {
        self.id = id
    }
    
    func buy(_ buyer: String) -> UUID? {
        print("buyer name: \(buyer)")    // 印出呼叫者名字
        guard tickets > 0 else {
            return nil
        }
        
        sleep(1)        // 模擬需要花時間存取資料庫等程序
        tickets -= 1
        return UUID()
    }
}

現在來呼叫上面這個演員,程式碼如下:

let agent = TicketAgent(id: 15)
print("agent id: \(agent.id)")

Task {
    if let ticketNumber = await agent.buy("aa") {
        print(ticketNumber)
    }
    print("remaining tickets: \(await agent.tickets)")
}

演員中的屬性與方法,除了唯讀屬性外,都需要放在 Task 區段中,並且使用 await 或是 async let 來呼叫。現在我們不用擔心超賣的情形發生了,因為一個演員一次只做一件事情,即使同時有很多程序都要呼叫 buy() 函數,但只會有一個真正執行 buy() 中的程式碼,其他人都必須等待。

可以在 App 的某個按鈕按下去後執行下列程式碼來模擬同一時間有五個人要買票,不論如何執行,最後結果只會有兩個人買到票,剩餘票數也會是0。

@IBAction func onClick(_ sender: Any) {
    let agent = TicketAgent(id: 15)
    print("agent id: \(agent.id)")
    
    for name in ["aa", "bb", "cc", "dd", "ee"] {
        Task {
            async let ticketNumber = agent.buy(name)
            if let ticketNumber = await ticketNumber {
                print(ticketNumber)
            }
        }
    }

    Task {
        sleep(5)
        print("remaining tickets: \(await agent.tickets)")
    }
}

如果將上面的 actor 改為 class,然後使用 global 佇列來啟動多執行緒模擬五個人同時買票,幾乎可以保證,最後結果一定超賣。

for name in ["aa", "bb", "cc", "dd", "ee"] {
    DispatchQueue.global().async {
        if let ticketNumber = agent.buy(name) {
            print(ticketNumber)
        }
    }
}

sleep(5)
print("remaining tickets: \(agent.tickets)")

目前在 iOS SDK 中已經有許多函數具有 async 形式,例如呼叫 Web Service 常用的 URLSession 類別,除了原本的使用 Closure 方式傳回內容的函數外,還多了一組 async 函數,舉例如下。

Task {
    let url = URL(string: "https://udn.com")
    let (data, _) = try! await URLSession.shared.data(from: url!, delegate: nil)
    let html = String(data: data, encoding: .utf8)
    print(html!)    
}

發表迴響