Computer >> Máy Tính >  >> Điện thoại thông minh >> iPhone

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Đồng tiền trong iOS là một chủ đề lớn. Vì vậy, trong bài viết này, tôi muốn phóng to một chủ đề phụ liên quan đến hàng đợi và khuôn khổ Grand Central Dispatch (GCD).

Đặc biệt, tôi muốn khám phá sự khác biệt giữa hàng đợi nối tiếp và đồng thời, cũng như sự khác biệt giữa thực thi đồng bộ và không đồng bộ.

Nếu bạn chưa bao giờ sử dụng GCD trước đây, bài viết này là một nơi tuyệt vời để bắt đầu. Nếu bạn có một số kinh nghiệm với GCD, nhưng vẫn tò mò về các chủ đề được đề cập ở trên, tôi nghĩ bạn vẫn sẽ thấy nó hữu ích. Và tôi hy vọng bạn sẽ chọn được một hoặc hai điều mới trên đường đi.

Tôi đã tạo một ứng dụng đồng hành SwiftUI để thể hiện trực quan các khái niệm trong bài viết này. Ứng dụng cũng có một câu đố ngắn vui nhộn mà tôi khuyến khích bạn nên thử trước và sau khi đọc bài viết này. Tải xuống mã nguồn tại đây hoặc tải bản beta công khai tại đây.

Tôi sẽ bắt đầu bằng phần giới thiệu về GCD, tiếp theo là phần giải thích chi tiết về đồng bộ, không đồng bộ, nối tiếp và đồng thời. Sau đây, tôi sẽ đề cập đến một số cạm bẫy khi làm việc với đồng thời. Cuối cùng, tôi sẽ kết thúc bằng một bản tóm tắt và một số lời khuyên chung.

Giới thiệu

Hãy bắt đầu với phần giới thiệu ngắn gọn về GCD và hàng đợi điều phối. Vui lòng bỏ qua đến Đồng bộ hóa so với Không đồng bộ nếu bạn đã quen thuộc với chủ đề này.

Công văn đồng thời và Grand Central

Đồng thời cho phép bạn tận dụng thực tế là thiết bị của bạn có nhiều lõi CPU. Để sử dụng các lõi này, bạn sẽ cần sử dụng nhiều luồng. Tuy nhiên, các luồng là một công cụ cấp thấp và việc quản lý các luồng theo cách thủ công một cách hiệu quả là vô cùng khó khăn.

Grand Central Dispatch đã được Apple tạo ra hơn 10 năm trước như một sự trừu tượng để giúp các nhà phát triển viết mã đa luồng mà không cần tự tạo và quản lý các luồng theo cách thủ công.

Với GCD, Apple đã thực hiện cách tiếp cận thiết kế không đồng bộ vào vấn đề. Thay vì tạo các luồng trực tiếp, bạn sử dụng GCD để lập lịch các tác vụ công việc và hệ thống sẽ thực hiện các tác vụ này cho bạn bằng cách sử dụng tốt nhất các tài nguyên của nó. GCD sẽ xử lý việc tạo các luồng cần thiết và sẽ lên lịch các tác vụ của bạn trên các luồng đó, chuyển gánh nặng quản lý luồng từ nhà phát triển sang hệ thống.

Một lợi thế lớn của GCD là bạn không phải lo lắng về tài nguyên phần cứng khi bạn viết mã đồng thời của mình. GCD quản lý nhóm luồng cho bạn và nó sẽ mở rộng quy mô từ Apple Watch lõi đơn lên đến MacBook Pro nhiều lõi.

Hàng đợi Gửi hàng

Đây là các khối xây dựng chính của GCD cho phép bạn thực thi các khối mã tùy ý bằng cách sử dụng một tập hợp các tham số mà bạn xác định. Các tác vụ trong hàng đợi gửi luôn được bắt đầu theo kiểu nhập trước, xuất trước (FIFO). Lưu ý rằng tôi đã nói bắt đầu , bởi vì thời gian hoàn thành nhiệm vụ của bạn phụ thuộc vào một số yếu tố và không được đảm bảo là FIFO (sẽ nói thêm về điều đó sau.)

Nói chung, có ba loại hàng đợi có sẵn cho bạn:

  • Hàng đợi điều phối chính (nối tiếp, được xác định trước)
  • Hàng đợi chung (đồng thời, được xác định trước)
  • Hàng đợi riêng tư (có thể nối tiếp hoặc đồng thời, bạn tạo chúng)

Mỗi ứng dụng đều đi kèm với một hàng đợi Chính, là một sê-ri hàng đợi thực thi các tác vụ trên luồng chính. Hàng đợi này chịu trách nhiệm vẽ giao diện người dùng của ứng dụng của bạn và phản hồi các tương tác của người dùng (chạm, cuộn, xoay, v.v.) Nếu bạn chặn hàng đợi này quá lâu, ứng dụng iOS của bạn sẽ có vẻ như bị đóng băng và ứng dụng macOS của bạn sẽ hiển thị bãi biển khét tiếng bóng / bánh xe quay.

Khi thực hiện một tác vụ kéo dài (cuộc gọi mạng, công việc đòi hỏi nhiều tính toán, v.v.), chúng tôi tránh đóng băng giao diện người dùng bằng cách thực hiện công việc này trên hàng đợi nền. Sau đó, chúng tôi cập nhật giao diện người dùng với kết quả trên hàng đợi chính:

URLSession.shared.dataTask(with: url) { data, response, error in
    if let data = data {
        DispatchQueue.main.async { // UI work
            self.label.text = String(data: data, encoding: .utf8)
        }
    }
}
URLSession cung cấp các lệnh gọi lại trên hàng đợi nền

Theo nguyên tắc chung, tất cả công việc giao diện người dùng phải được thực hiện trên hàng đợi Chính. Bạn có thể bật tùy chọn Trình kiểm tra chuỗi chính trong Xcode để nhận cảnh báo bất cứ khi nào công việc giao diện người dùng được thực thi trên một chuỗi nền.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Ngoài hàng đợi chính, mọi ứng dụng đều đi kèm với một số hàng đợi đồng thời được xác định trước có các mức Chất lượng dịch vụ khác nhau (một khái niệm trừu tượng về mức độ ưu tiên trong GCD.)

Ví dụ:đây là mã để gửi công việc không đồng bộ tới người dùng tương tác (ưu tiên cao nhất) Hàng đợi QoS:

DispatchQueue.global(qos: .userInteractive).async {
    print("We're on a global concurrent queue!") 
}

Ngoài ra, bạn có thể gọi ưu tiên mặc định hàng đợi toàn cầu bằng cách không chỉ định QoS như thế này:

DispatchQueue.global().async {
    print("Generic global queue")
}
QoS mặc định nằm trong khoảng giữa do người dùng khởi tạo tiện ích

Ngoài ra, bạn có thể tạo hàng đợi riêng tư của mình bằng cú pháp sau:

let serial = DispatchQueue(label: "com.besher.serial-queue")
serial.async {
    print("Private serial queue")
}
hàng đợi riêng tư được nối tiếp theo mặc định

Khi tạo hàng đợi riêng tư, sẽ hữu ích khi sử dụng nhãn mô tả (chẳng hạn như ký hiệu DNS ngược), vì điều này sẽ hỗ trợ bạn khi gỡ lỗi trong trình điều hướng, lldb và Instruments của Xcode:

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Theo mặc định, hàng đợi riêng tư là nối tiếp (Tôi sẽ giải thích điều này ngay lập tức, xin hứa!) Nếu bạn muốn tạo một đồng thời riêng tư hàng đợi, bạn có thể làm như vậy thông qua thuộc tính tùy chọn tham số:

let concurrent = DispatchQueue(label: "com.besher.serial-queue", attributes: .concurrent)
concurrent.sync {
    print("Private concurrent queue")
}

Cũng có một tham số QoS tùy chọn. Các hàng đợi riêng tư mà bạn tạo cuối cùng sẽ đổ bộ vào một trong các hàng đợi đồng thời toàn cầu dựa trên các tham số đã cho của chúng.

Nhiệm vụ là gì?

Tôi đã đề cập đến việc điều động các nhiệm vụ để xếp hàng. Nhiệm vụ có thể tham chiếu đến bất kỳ khối mã nào mà bạn gửi đến hàng đợi bằng cách sử dụng sync hoặc async chức năng. Chúng có thể được gửi dưới dạng đóng ẩn danh:

DispatchQueue.global().async {
    print("Anonymous closure")
}

Hoặc bên trong một hạng mục công việc điều phối sẽ được thực hiện sau:

let item = DispatchWorkItem(qos: .utility) {
    print("Work item to be executed later")
}
lưu ý cách chúng tôi xác định QoS nhiệm vụ tại đây

Bất kể bạn gửi đồng bộ hay không đồng bộ và cho dù bạn chọn hàng đợi nối tiếp hay đồng thời, tất cả mã bên trong một tác vụ duy nhất sẽ thực thi từng dòng một. Đồng tiền chỉ có liên quan khi đánh giá nhiều nhiệm vụ.

Ví dụ:nếu bạn có 3 vòng lặp bên trong giống nhau nhiệm vụ, các vòng lặp này sẽ luôn luôn thực hiện theo thứ tự:

DispatchQueue.global().async {
    for i in 0..<10 {
        print(i)
    }

    for _ in 0..<10 {
        print("?")
    }

    for _ in 0..<10 {
        print("?")
    }
}

Mã này luôn in ra mười chữ số từ 0 đến 9, theo sau là mười vòng tròn màu xanh lam, theo sau là mười trái tim tan vỡ, bất kể bạn gửi lệnh đóng đó như thế nào.

Các tác vụ riêng lẻ cũng có thể có mức QoS của riêng chúng (theo mặc định, chúng sử dụng mức độ ưu tiên của hàng đợi.) Sự khác biệt này giữa QoS hàng đợi và QoS nhiệm vụ dẫn đến một số hành vi thú vị mà chúng ta sẽ thảo luận trong phần đảo ngược ưu tiên.

Bây giờ bạn có thể tự hỏi nối tiếp là gì và đồng thời là tất cả về. Bạn cũng có thể thắc mắc về sự khác biệt giữa syncasync khi gửi nhiệm vụ của bạn. Điều này đưa chúng ta đến điểm mấu chốt của bài viết này, vì vậy chúng ta hãy đi sâu vào!

Đồng bộ hóa so với Không đồng bộ

Khi bạn gửi một nhiệm vụ đến một hàng đợi, bạn có thể chọn thực hiện đồng bộ hoặc không đồng bộ bằng cách sử dụng syncasync các chức năng điều phối. Đồng bộ hóa và không đồng bộ hóa chủ yếu ảnh hưởng đến nguồn của nhiệm vụ đã gửi, đó là hàng đợi mà nó đang được gửi từ .

Khi mã của bạn đạt đến sync , nó sẽ chặn hàng đợi hiện tại cho đến khi tác vụ đó hoàn thành. Khi nhiệm vụ trả về / hoàn thành, quyền điều khiển được trả lại cho người gọi và mã theo sau sync nhiệm vụ sẽ tiếp tục.

Hãy nghĩ về sync đồng nghĩa với "chặn".

async mặt khác, câu lệnh sẽ thực thi không đồng bộ đối với hàng đợi hiện tại và ngay lập tức trả lại quyền điều khiển cho người gọi mà không cần đợi nội dung của async đóng cửa để thực thi. Không có gì đảm bảo khi nào chính xác mã bên trong việc đóng không đồng bộ đó sẽ thực thi.

Hàng đợi hiện tại?

Có thể không rõ nguồn gốc hoặc hiện tại , hàng đợi, bởi vì nó không phải lúc nào cũng được xác định rõ ràng trong mã.

Ví dụ:nếu bạn gọi sync của mình câu lệnh bên trong viewDidLoad, hàng đợi hiện tại của bạn sẽ là hàng đợi Công văn chính. Nếu bạn gọi cùng một hàm bên trong trình xử lý hoàn thành URLSession, hàng đợi hiện tại của bạn sẽ là hàng đợi nền.

Quay lại đồng bộ hóa so với không đồng bộ, hãy lấy ví dụ sau:

DispatchQueue.global().sync {
    print("Inside")
}
print("Outside")
// Console output:
// Inside
// Outside

Đoạn mã trên sẽ chặn hàng đợi hiện tại, nhập đóng và thực thi mã của nó trên hàng đợi chung bằng cách in “Bên trong”, trước khi tiếp tục in “Bên ngoài”. Đơn đặt hàng này được đảm bảo.

Hãy xem điều gì sẽ xảy ra nếu chúng ta thử async thay vào đó:

DispatchQueue.global().async {
    print("Inside")
}
print("Outside")
// Potential console output (based on QoS): 
// Outside
// Inside

Mã của chúng tôi bây giờ gửi đóng vào hàng đợi chung, sau đó ngay lập tức tiến hành chạy dòng tiếp theo. Nó sẽ có khả năng in “Bên ngoài” trước “Bên trong”, nhưng thứ tự này không được đảm bảo. Nó phụ thuộc vào QoS của hàng đợi nguồn và đích, cũng như các yếu tố khác mà hệ thống kiểm soát.

Các luồng là một chi tiết triển khai trong GCD - chúng tôi không có quyền kiểm soát trực tiếp đối với chúng và chỉ có thể xử lý chúng bằng cách sử dụng trừu tượng hàng đợi. Tuy nhiên, tôi nghĩ rằng có thể hữu ích nếu bạn 'nhìn lén' hành vi của chuỗi để hiểu một số thách thức mà chúng ta có thể gặp phải với GCD.

Ví dụ:khi bạn gửi một nhiệm vụ bằng sync , GCD tối ưu hóa hiệu suất bằng cách thực thi tác vụ đó trên luồng hiện tại (trình gọi.)

Tuy nhiên, có một ngoại lệ, đó là khi bạn gửi tác vụ đồng bộ đến hàng đợi chính - làm như vậy sẽ luôn chạy tác vụ trên chuỗi chính chứ không phải trình gọi. Hành vi này có thể có một số phân nhánh mà chúng ta sẽ khám phá trong phần đảo ngược ưu tiên.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng
Từ Dispatcher trên Github

Nên sử dụng cái nào?

Khi gửi công việc vào hàng đợi, Apple khuyên bạn nên sử dụng thực thi không đồng bộ thay vì thực thi đồng bộ. Tuy nhiên, có những trường hợp sync có thể là lựa chọn tốt hơn, chẳng hạn như khi đối phó với các điều kiện chủng tộc, hoặc khi thực hiện một nhiệm vụ rất nhỏ. Tôi sẽ đề cập đến những tình huống này ngay sau đây.

Một hậu quả lớn của việc thực hiện công việc không đồng bộ bên trong một hàm là hàm không còn có thể trả về trực tiếp các giá trị của nó nữa (nếu chúng phụ thuộc vào công việc không đồng bộ đang được thực hiện). Thay vào đó, nó phải sử dụng tham số trình xử lý đóng / hoàn thành để cung cấp kết quả.

Để chứng minh khái niệm này, hãy sử dụng một hàm nhỏ chấp nhận dữ liệu hình ảnh, thực hiện một số phép tính tốn kém để xử lý hình ảnh, sau đó trả về kết quả:

func processImage(data: Data) -> UIImage? {
    guard let image = UIImage(data: data) else { return nil }
    // calling an expensive function
    let processedImage = upscaleAndFilter(image: image)
    return processedImage 
}

Trong ví dụ này, hàm upscaleAndFilter(image:) có thể mất vài giây, vì vậy chúng tôi muốn tải nó vào một hàng đợi riêng để tránh đóng băng giao diện người dùng. Hãy tạo một hàng đợi chuyên dụng để xử lý hình ảnh, sau đó gửi hàm đắt tiền một cách không đồng bộ:

let imageProcessingQueue = DispatchQueue(label: "com.besher.image-processing")

func processImageAsync(data: Data) -> UIImage? {
    guard let image = UIImage(data: data) else { return nil }
    
    imageProcessingQueue.async {
        let processedImage = upscaleAndFilter(image: image)
        return processedImage
    }
}
mã này không biên dịch!

Có hai vấn đề với mã này. Đầu tiên, câu lệnh trả về nằm bên trong bao đóng không đồng bộ, vì vậy nó không còn trả về giá trị cho processImageAsync(data:) chức năng, và hiện không phục vụ mục đích nào.

Nhưng vấn đề lớn hơn là processImageAsync(data:) của chúng tôi hàm không còn trả về bất kỳ giá trị nào nữa, bởi vì hàm đi đến cuối phần thân của nó trước khi nhập vào async đóng cửa.

Để khắc phục lỗi này, chúng tôi sẽ điều chỉnh hàm để nó không còn trực tiếp trả về một giá trị nữa. Thay vào đó, nó sẽ có một tham số xử lý hoàn thành mới mà chúng ta có thể gọi sau khi hàm không đồng bộ của chúng ta đã hoàn thành công việc của nó:

let imageProcessingQueue = DispatchQueue(label: "com.besher.image-processing")

func processImageAsync(data: Data, completion: @escaping (UIImage?) -> Void) {
    guard let image = UIImage(data: data) else {
        completion(nil)
        return
    }

    imageProcessingQueue.async {
        let processedImage =  self.upscaleAndFilter(image: image)
        completion(processedImage)
    }
}

Rõ ràng trong ví dụ này, sự thay đổi để làm cho hàm không đồng bộ đã được truyền tới người gọi của nó, người bây giờ phải chuyển vào một bao đóng và cũng xử lý các kết quả không đồng bộ. Bằng cách giới thiệu một tác vụ không đồng bộ, bạn có thể kết thúc việc sửa đổi một chuỗi một số chức năng.

Thực thi đồng thời và không đồng bộ làm tăng thêm độ phức tạp cho dự án của bạn như chúng tôi vừa quan sát. Điều hướng này cũng làm cho việc gỡ lỗi khó khăn hơn. Đó là lý do tại sao bạn thực sự có ích khi sớm nghĩ về sự đồng thời trong thiết kế của mình - đó không phải là điều bạn muốn thực hiện vào cuối chu trình thiết kế của mình.

Ngược lại, thực thi đồng bộ không làm tăng độ phức tạp. Thay vào đó, nó cho phép bạn tiếp tục sử dụng các câu lệnh trả về như bạn đã làm trước đây. Một hàm chứa sync nhiệm vụ sẽ không trở lại cho đến khi mã bên trong tác vụ đó đã hoàn thành. Do đó, nó không yêu cầu trình xử lý hoàn thành.

Nếu bạn đang gửi một nhiệm vụ nhỏ (ví dụ:cập nhật một giá trị), hãy cân nhắc thực hiện nó một cách đồng bộ. Điều đó không chỉ giúp bạn giữ cho mã của mình đơn giản mà còn hoạt động tốt hơn - Async được cho là sẽ phát sinh chi phí lớn hơn lợi ích của việc thực hiện công việc không đồng bộ đối với các tác vụ nhỏ cần dưới 1ms để hoàn thành.

Tuy nhiên, nếu bạn đang gửi một tác vụ lớn, như xử lý hình ảnh mà chúng tôi đã thực hiện ở trên, thì hãy cân nhắc thực hiện nó một cách không đồng bộ để tránh chặn người gọi quá lâu.

Điều động trên cùng một hàng đợi

Mặc dù an toàn khi gửi một tác vụ không đồng bộ từ một hàng đợi vào chính nó (ví dụ:bạn có thể sử dụng .asyncAfter trên hàng đợi hiện tại), bạn không thể gửi một tác vụ đồng bộ từ một hàng đợi vào cùng một hàng đợi. Làm như vậy sẽ dẫn đến tình trạng bế tắc khiến ứng dụng bị treo ngay lập tức!

Sự cố này có thể tự biểu hiện khi thực hiện một chuỗi lệnh gọi đồng bộ dẫn trở lại hàng đợi ban đầu. Đó là bạn sync một nhiệm vụ vào hàng đợi khác và khi tác vụ hoàn thành, nó sẽ đồng bộ hóa kết quả trở lại hàng đợi ban đầu, dẫn đến bế tắc. Sử dụng async để tránh những sự cố như vậy.

Chặn hàng đợi chính

Điều phối các tác vụ một cách đồng bộ từ hàng đợi chính sẽ chặn hàng đợi đó, do đó đóng băng giao diện người dùng, cho đến khi hoàn thành nhiệm vụ. Vì vậy, tốt hơn là tránh điều phối công việc một cách đồng bộ từ hàng đợi chính trừ khi bạn đang thực hiện công việc thực sự nhẹ nhàng.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng
thích sử dụng không đồng bộ từ hàng đợi chính

Nối tiếp so với Đồng thời

Sê-ri đồng thời ảnh hưởng đến điểm đến - hàng đợi mà công việc của bạn đã được gửi để chạy. Điều này trái ngược với đồng bộ hóa async , điều này đã ảnh hưởng đến nguồn .

Hàng đợi nối tiếp sẽ không thực hiện công việc của nó trên nhiều luồng cùng một lúc, bất kể bạn gửi bao nhiêu tác vụ trên hàng đợi đó. Do đó, các tác vụ được đảm bảo không chỉ bắt đầu mà còn kết thúc, theo thứ tự xuất trước, nhập trước.

Hơn nữa, khi bạn chặn một hàng đợi nối tiếp (sử dụng sync gọi, semaphore hoặc một số công cụ khác), tất cả hoạt động trên hàng đợi đó sẽ tạm dừng cho đến khi khối kết thúc.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng
Từ Dispatcher trên Github

Một hàng đợi đồng thời có thể tạo ra nhiều luồng và hệ thống quyết định có bao nhiêu luồng được tạo. Nhiệm vụ luôn bắt đầu theo thứ tự FIFO, nhưng hàng đợi không đợi các tác vụ kết thúc trước khi bắt đầu tác vụ tiếp theo, do đó các tác vụ trên các hàng đợi đồng thời có thể kết thúc theo bất kỳ thứ tự nào.

Khi bạn thực hiện một lệnh chặn trên một hàng đợi đồng thời, nó sẽ không chặn các luồng khác trên hàng đợi này. Ngoài ra, khi một hàng đợi đồng thời bị chặn, nó có nguy cơ bùng nổ chuỗi . Tôi sẽ trình bày chi tiết hơn về vấn đề này ở phần sau.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng
Từ Dispatcher trên Github

Hàng đợi chính trong ứng dụng của bạn là nối tiếp. Tất cả các hàng đợi toàn cầu được xác định trước là đồng thời. Bất kỳ hàng đợi gửi riêng nào bạn tạo đều là nối tiếp theo mặc định, nhưng có thể được đặt thành đồng thời bằng cách sử dụng thuộc tính tùy chọn như đã thảo luận trước đó.

Điều quan trọng cần lưu ý ở đây là khái niệm nối tiếp so với đồng thời chỉ có liên quan khi thảo luận về một hàng đợi cụ thể. Tất cả các hàng đợi đồng thời có liên quan đến nhau .

Đó là, nếu bạn gửi công việc không đồng bộ từ hàng đợi chính đến một sê-ri riêng tư hàng đợi, công việc đó sẽ được hoàn thành đồng thời đối với hàng đợi chính. Và nếu bạn tạo hai hàng đợi nối tiếp khác nhau, rồi thực hiện công việc chặn trên một trong số chúng, hàng đợi kia sẽ không bị ảnh hưởng.

Để chứng minh sự đồng thời của nhiều hàng đợi nối tiếp, hãy lấy ví dụ sau:

let serial1 = DispatchQueue(label: "com.besher.serial1")
let serial2 = DispatchQueue(label: "com.besher.serial2")

serial1.async {
    for _ in 0..<5 { print("?") }
}

serial2.async {
    for _ in 0..<5 { print("?") }
}
Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Cả hai hàng đợi ở đây đều nối tiếp nhau, nhưng kết quả bị lộn xộn vì chúng thực thi đồng thời liên quan đến nhau. Thực tế là chúng là từng nối tiếp (hoặc đồng thời) không ảnh hưởng đến kết quả này. Mức QoS của họ xác định ai sẽ nói chung hoàn thành trước (đơn hàng không được đảm bảo).

Nếu chúng ta muốn đảm bảo rằng vòng lặp đầu tiên kết thúc trước khi bắt đầu vòng lặp thứ hai, chúng ta có thể gửi tác vụ đầu tiên một cách đồng bộ từ trình gọi:

let serial1 = DispatchQueue(label: "com.besher.serial1")
let serial2 = DispatchQueue(label: "com.besher.serial2")

serial1.sync { // <---- we changed this to 'sync'
    for _ in 0..<5 { print("?") }
}
// we don't get here until first loop terminates
serial2.async {
    for _ in 0..<5 { print("?") }
}
Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Điều này không nhất thiết phải mong muốn, vì chúng tôi hiện đang chặn người gọi trong khi vòng lặp đầu tiên đang thực thi.

Để tránh chặn người gọi, chúng tôi có thể gửi cả hai tác vụ không đồng bộ nhưng giống nhau hàng đợi nối tiếp:

let serial = DispatchQueue(label: "com.besher.serial")

serial.async {
    for _ in 0..<5 { print("?") }
}

serial.async {
    for _ in 0..<5 { print("?") }
}	
Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Giờ đây, các tác vụ của chúng tôi thực hiện đồng thời đối với người gọi , đồng thời giữ nguyên trật tự của họ.

Lưu ý rằng nếu chúng tôi thực hiện đồng thời một hàng đợi duy nhất của mình thông qua tham số tùy chọn, chúng tôi sẽ quay lại kết quả lộn xộn, như mong đợi:

let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)

concurrent.async {
    for _ in 0..<5 { print("?") }
}

concurrent.async {
    for _ in 0..<5 { print("?") }
}
Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Đôi khi bạn có thể nhầm lẫn giữa thực thi đồng bộ với thực thi nối tiếp (ít nhất là tôi đã làm), nhưng chúng là những thứ rất khác nhau. Ví dụ:hãy thử thay đổi công văn đầu tiên trên dòng 3 từ ví dụ trước của chúng tôi thành sync gọi:

let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)

concurrent.sync {
    for _ in 0..<5 { print("?") }
}

concurrent.async {
    for _ in 0..<5 { print("?") }
}
điều này có thể gây hiểu lầm
Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Đột nhiên, kết quả của chúng tôi trở lại theo thứ tự hoàn hảo. Nhưng đây là một hàng đợi đồng thời, vậy làm thế nào điều đó có thể xảy ra? Đã làm sync bằng cách nào đó biến nó thành một hàng đợi nối tiếp?

Câu trả lời là không!

Đây là một chút lén lút. Điều đã xảy ra là chúng tôi không đạt được async gọi cho đến khi tác vụ đầu tiên hoàn thành việc thực thi. Hàng đợi vẫn còn rất nhiều đồng thời, nhưng bên trong phần được phóng to này của mã. nó xuất hiện như thể nó được nối tiếp. Điều này là do chúng tôi đang chặn người gọi và không tiếp tục tác vụ tiếp theo cho đến khi tác vụ đầu tiên hoàn thành.

Nếu một hàng đợi khác ở đâu đó trong ứng dụng của bạn đã thử gửi công việc đến cùng hàng đợi này trong khi nó vẫn đang thực thi sync tuyên bố, rằng công việc sẽ chạy đồng thời với bất kỳ thứ gì chúng tôi đã chạy ở đây, vì nó vẫn là một hàng đợi đồng thời.

Nên sử dụng cái nào?

Hàng đợi nối tiếp tận dụng tối ưu hóa CPU và bộ nhớ đệm, đồng thời giúp giảm chuyển đổi ngữ cảnh.

Apple khuyên bạn nên bắt đầu với một hàng đợi nối tiếp cho mỗi hệ thống con trong ứng dụng của bạn - ví dụ:một hàng dành cho mạng, một hàng để nén tệp, v.v. Nếu cần, sau đó bạn có thể mở rộng thành phân cấp hàng đợi cho mỗi hệ thống con bằng phương pháp setTarget hoặc mục tiêu tùy chọn khi tạo hàng đợi.

Nếu bạn gặp phải tình trạng tắc nghẽn về hiệu suất, hãy đo lường hiệu suất của ứng dụng, sau đó xem liệu một hàng đợi đồng thời có hữu ích hay không. Nếu bạn không thấy lợi ích có thể đo lường được, tốt hơn là bạn nên tuân theo các hàng đợi nối tiếp.

Cạm bẫy

Đảo ngược Mức độ Ưu tiên và Chất lượng Dịch vụ

Đảo ngược mức độ ưu tiên là khi một nhiệm vụ có mức độ ưu tiên cao bị một nhiệm vụ có mức độ ưu tiên thấp hơn ngăn chặn việc chạy, đảo ngược hiệu quả các mức độ ưu tiên tương đối của chúng.

Tình huống này thường xảy ra khi hàng đợi QoS cao chia sẻ tài nguyên với hàng đợi QoS thấp và hàng đợi QoS thấp bị khóa tài nguyên đó.

Nhưng tôi muốn đề cập đến một tình huống khác phù hợp hơn với cuộc thảo luận của chúng ta - đó là khi bạn gửi nhiệm vụ đến hàng đợi nối tiếp có QoS thấp, sau đó gửi nhiệm vụ có QoS cao cho cùng hàng đợi đó. Kịch bản này cũng dẫn đến đảo ngược mức độ ưu tiên, vì nhiệm vụ QoS cao phải đợi các tác vụ có QoS thấp hơn để hoàn thành.

GCD giải quyết việc đảo ngược mức độ ưu tiên bằng cách tạm thời tăng QoS của hàng đợi chứa các tác vụ có mức độ ưu tiên thấp đang ‘đi trước’ hoặc chặn tác vụ có mức độ ưu tiên cao của bạn.

Giống như có ô tô bị kẹt ở phía trước trong tổng số xe cứu thương. Đột nhiên họ được phép vượt đèn đỏ để xe cứu thương có thể di chuyển (thực tế là ô tô chạy sang một bên, nhưng hãy tưởng tượng một con phố hẹp (nối tiếp) hoặc một cái gì đó, bạn hiểu đúng :-P)

Để minh họa vấn đề đảo ngược, hãy bắt đầu với mã này:


enum Color: String {
    case blue = "?"
    case white = "⚪️"
}

func output(color: Color, times: Int) {
    for _ in 1...times {
        print(color.rawValue)
    }
}

let starterQueue = DispatchQueue(label: "com.besher.starter", qos: .userInteractive)
let utilityQueue = DispatchQueue(label: "com.besher.utility", qos: .utility)
let backgroundQueue = DispatchQueue(label: "com.besher.background", qos: .background)
let count = 10

starterQueue.async {

    backgroundQueue.async {
        output(color: .white, times: count)
    }

    backgroundQueue.async {
        output(color: .white, times: count)
    }

    utilityQueue.async {
        output(color: .blue, times: count)
    }

    utilityQueue.async {
        output(color: .blue, times: count)
    }

    // next statement goes here
}

Chúng tôi tạo hàng đợi bắt đầu (nơi chúng tôi gửi nhiệm vụ từ ), cũng như hai hàng đợi có QoS khác nhau. Sau đó, chúng tôi gửi nhiệm vụ đến từng hàng trong số hai hàng đợi này, mỗi tác vụ in ra một số lượng bằng nhau của các vòng tròn có màu cụ thể ( tiện ích hàng đợi có màu xanh lam, nền là màu trắng.)

Vì các tác vụ này được gửi không đồng bộ, nên mỗi khi chạy ứng dụng, bạn sẽ thấy các kết quả hơi khác nhau. Tuy nhiên, như bạn mong đợi, hàng đợi có QoS (nền) thấp hơn hầu như luôn kết thúc cuối cùng. Trên thực tế, 10–15 vòng tròn cuối cùng thường có màu trắng.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng
không có gì ngạc nhiên ở đây

Nhưng hãy xem điều gì sẽ xảy ra khi chúng tôi gửi đồng bộ hóa tác vụ vào hàng đợi nền sau câu lệnh không đồng bộ cuối cùng. Bạn thậm chí không cần in bất cứ thứ gì bên trong sync câu lệnh, chỉ cần thêm dòng này là đủ:

// add this after the last async statement, 
// still inside starterQueue.async
backgroundQueue.sync {}
Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng
đảo ngược mức độ ưu tiên

Kết quả trong bảng điều khiển đã lật! Giờ đây, hàng đợi có mức độ ưu tiên cao hơn (tiện ích) luôn kết thúc sau cùng và 10–15 vòng tròn cuối cùng có màu xanh lam.

Để hiểu lý do tại sao điều đó xảy ra, chúng ta cần xem lại thực tế là công việc đồng bộ được thực thi trên chuỗi người gọi (trừ khi bạn đang gửi đến hàng đợi chính.)

Trong ví dụ của chúng tôi ở trên, người gọi (starterQueue) có QoS cao nhất (userInteractive.) Do đó, sync có vẻ vô hại đó nhiệm vụ không chỉ chặn hàng đợi của người khởi động mà còn chạy trên chuỗi QoS cao của người khởi động. Do đó, tác vụ sẽ chạy với QoS cao, nhưng có hai tác vụ khác trước nó trên cùng một hàng đợi nền có nền QoS. Đảo ngược mức độ ưu tiên đã được phát hiện!

Như mong đợi, GCD giải quyết sự đảo ngược này bằng cách tăng QoS của toàn bộ hàng đợi để tạm thời khớp với tác vụ QoS cao. Do đó, tất cả các tác vụ trên hàng đợi nền sẽ chạy ở tương tác của người dùng QoS, cao hơn tiện ích QoS. Và đó là lý do tại sao các tác vụ tiện ích kết thúc cuối cùng!

Lưu ý phụ:Nếu bạn xóa hàng đợi bắt đầu khỏi ví dụ đó và gửi từ hàng đợi chính thay vào đó, bạn sẽ nhận được kết quả tương tự, vì hàng đợi chính cũng có người dùng tương tác QoS.

Để tránh đảo ngược mức độ ưu tiên trong ví dụ này, chúng ta cần tránh chặn hàng đợi bắt đầu bằng sync bản tường trình. Sử dụng async sẽ giải quyết vấn đề đó.

Mặc dù điều đó không phải lúc nào cũng lý tưởng, nhưng bạn có thể giảm thiểu các đảo ngược ưu tiên bằng cách tuân theo QoS mặc định khi tạo hàng đợi riêng tư hoặc điều phối đến hàng đợi đồng thời toàn cầu.

Sự bùng nổ của chuỗi

Khi bạn sử dụng một hàng đợi đồng thời, bạn có nguy cơ bị nổ chuỗi nếu không cẩn thận. Điều này có thể xảy ra khi bạn cố gắng gửi nhiệm vụ đến hàng đợi đồng thời hiện đang bị chặn (ví dụ:với semaphore, đồng bộ hóa hoặc một số cách khác.) Nhiệm vụ của bạn sẽ chạy, nhưng hệ thống có thể sẽ kết thúc các chuỗi mới để đáp ứng các tác vụ mới này và các chuỗi này không hề rẻ.

Đây có thể là lý do tại sao Apple đề xuất bắt đầu với một hàng đợi nối tiếp cho mỗi hệ thống con trong ứng dụng của bạn, vì mỗi hàng đợi nối tiếp chỉ có thể sử dụng một chuỗi. Hãy nhớ rằng các hàng đợi nối tiếp đồng thời trong mối quan hệ đến khác hàng đợi, vì vậy bạn vẫn nhận được lợi ích về hiệu suất khi tải công việc của mình xuống một hàng đợi, ngay cả khi nó không diễn ra đồng thời.

Điều kiện cuộc đua

Mảng Swift, Từ điển, Cấu trúc và các loại giá trị khác không an toàn theo chuỗi theo mặc định. Ví dụ:khi bạn có nhiều chuỗi đang cố gắng truy cập và sửa đổi cùng một mảng, bạn sẽ bắt đầu gặp rắc rối.

Có các giải pháp khác nhau cho vấn đề người đọc-người viết, chẳng hạn như sử dụng khóa hoặc bán tín hiệu. Nhưng giải pháp liên quan mà tôi muốn thảo luận ở đây là sử dụng hàng đợi cách ly.

Giả sử chúng ta có một mảng các số nguyên và chúng ta muốn gửi tác phẩm không đồng bộ tham chiếu đến mảng này. Miễn là tác phẩm của chúng tôi chỉ đọc mảng và không sửa đổi nó, chúng tôi an toàn. Nhưng ngay sau khi chúng tôi cố gắng sửa đổi mảng trong một trong các tác vụ không đồng bộ của mình, chúng tôi sẽ tạo ra sự không ổn định trong ứng dụng của mình.

Đó là một vấn đề phức tạp vì ứng dụng của bạn có thể chạy 10 lần mà không gặp sự cố và sau đó nó gặp sự cố vào lần thứ 11. Một công cụ rất hữu ích cho tình huống này là Thread Sanitizer trong Xcode. Bật tùy chọn này sẽ giúp bạn xác định các điều kiện cuộc đua tiềm năng trong ứng dụng của mình.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng
t tùy chọn của anh ấy chỉ khả dụng trên trình mô phỏng

Để chứng minh vấn đề, hãy lấy ví dụ này (được thừa nhận là đã tạo ra):

class ViewController: UIViewController {
    
    let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
    var array = [1,2,3,4,5]

    override func viewDidLoad() {
        for _ in 0...1 {
            race()
        }
    }

    func race() {

        concurrent.async {
            for i in self.array { // read access
                print(i)
            }
        }

        concurrent.async {
            for i in 0..<10 {
                self.array.append(i) // write access
            }
        }
    }
}

Một trong những async nhiệm vụ đang sửa đổi mảng bằng cách thêm các giá trị. Nếu bạn thử chạy điều này trên trình mô phỏng của mình, bạn có thể không gặp sự cố. Nhưng chạy nó đủ lần (hoặc tăng tần số vòng lặp trên dòng 7), và cuối cùng bạn sẽ gặp sự cố. Nếu bạn bật trình làm sạch chuỗi, bạn sẽ nhận được cảnh báo mỗi khi chạy ứng dụng.

Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Để đối phó với tình trạng cuộc đua này, chúng tôi sẽ thêm một hàng đợi cách ly sử dụng cờ rào cản. Cờ này cho phép mọi tác vụ còn tồn đọng trên hàng đợi kết thúc, nhưng chặn mọi tác vụ khác không thể thực thi cho đến khi hoàn thành tác vụ rào cản.

Hãy nghĩ về rào chắn giống như một người lao công dọn dẹp nhà vệ sinh công cộng (tài nguyên dùng chung.) Có nhiều quầy hàng (đồng thời) bên trong nhà vệ sinh mà mọi người có thể sử dụng.

Khi đến nơi, người gác cổng đặt một biển báo (rào chắn) dọn dẹp ngăn không cho bất kỳ người mới đến nào bước vào cho đến khi việc dọn dẹp hoàn tất, nhưng người gác cổng không bắt đầu dọn dẹp cho đến khi tất cả những người bên trong đã hoàn thành công việc của họ. Sau khi tất cả rời đi, người gác cổng tiến hành dọn dẹp nhà vệ sinh công cộng một cách riêng biệt.

Cuối cùng khi hoàn thành, người gác cổng sẽ dỡ bỏ biển báo (rào chắn) để những người xếp hàng bên ngoài cuối cùng có thể vào.

Đây là những gì trông giống như trong mã:

class ViewController: UIViewController {
    let concurrent = DispatchQueue(label: "com.besher.concurrent", attributes: .concurrent)
    let isolation = DispatchQueue(label: "com.besher.isolation", attributes: .concurrent)
    private var _array = [1,2,3,4,5]
    
    var threadSafeArray: [Int] {
        get {
            return isolation.sync {
                _array
            }
        }
        set {
            isolation.async(flags: .barrier) {
                self._array = newValue
            }
        }
    }
    
    override func viewDidLoad() {
        for _ in 0...15 {
            race()
        }
    }
    
    func race() {
        concurrent.async {
            for i in self.threadSafeArray {
                print(i)
            }
        }
        
        concurrent.async {
            for i in 0..<10 {
                self.threadSafeArray.append(i)
            }
        }
    }
}

Chúng tôi đã thêm một hàng đợi cách ly mới và hạn chế quyền truy cập vào mảng riêng tư bằng cách sử dụng getter và setter sẽ đặt một rào cản khi sửa đổi mảng.

Getter cần phải là sync để trực tiếp trả về một giá trị. Bộ định hình có thể là async , vì chúng tôi không cần phải chặn người gọi trong khi quá trình ghi đang diễn ra.

Chúng tôi có thể đã sử dụng một hàng đợi nối tiếp không có rào cản để giải quyết điều kiện chủng tộc, nhưng sau đó chúng tôi sẽ mất lợi thế khi có quyền truy cập đọc đồng thời vào mảng. Có lẽ điều đó hợp lý trong trường hợp của bạn, bạn phải quyết định.

Kết luận

Cảm ơn bạn rất nhiều vì đã đọc đến đây! Tôi hy vọng bạn đã học được một cái gì đó mới từ bài viết này. Tôi sẽ để lại cho bạn một bản tóm tắt và một số lời khuyên chung:

Tóm tắt

  • Hàng đợi luôn bắt đầu nhiệm vụ của họ theo thứ tự FIFO
  • Hàng đợi luôn đồng thời so với khác hàng đợi
  • Đồng bộ hóa so với Không đồng bộ liên quan đến nguồn
  • Sê-ri so với Đồng thời liên quan đến điểm đến
  • Đồng bộ hóa đồng nghĩa với 'chặn'
  • Async ngay lập tức trả lại quyền kiểm soát cho người gọi
  • Serial sử dụng một chuỗi duy nhất và đảm bảo thứ tự thực hiện
  • Sử dụng đồng thời nhiều luồng và có nguy cơ bùng nổ luồng
  • Hãy sớm suy nghĩ về tính đồng thời trong chu kỳ thiết kế của bạn
  • Mã đồng bộ dễ lý giải và gỡ lỗi hơn
  • Tránh dựa vào các hàng đợi đồng thời toàn cầu nếu có thể
  • Cân nhắc bắt đầu với một hàng đợi nối tiếp cho mỗi hệ thống con
  • Chỉ chuyển sang hàng đợi đồng thời nếu bạn thấy có thể đo lường lợi ích về hiệu suất

Tôi thích phép ẩn dụ từ Tuyên ngôn đồng thời Swift về việc có một "hòn đảo tuần tự hóa trong biển đồng thời". Cảm xúc này cũng được chia sẻ trong tweet này bởi Matt Diephouse:

Khi bạn áp dụng đồng thời với triết lý đó, tôi nghĩ nó sẽ giúp bạn đạt được mã đồng thời có thể được lý luận mà không bị lạc trong một mớ hỗn độn của các lệnh gọi lại.

Nếu bạn có bất kỳ câu hỏi hoặc nhận xét nào, vui lòng liên hệ với tôi trên Twitter

Besher Al Maleh

Ảnh bìa của Onur K trên Unsplash

Tải xuống ứng dụng đồng hành tại đây:

ứng dụng almaleh / DispatcherCompanion cho bài viết của tôi về đồng thời. Đóng góp vào sự phát triển của almaleh / Dispatcher bằng cách tạo tài khoản trên GitHub. Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng almalehGitHub Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Fireworks - Trình chỉnh sửa hạt trực quan cho Swift Tạo mã Swift nhanh chóng cho macOS và iOS khi bạn thiết kế và lặp lại các hiệu ứng hạt của mình Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Besher Al MalehFlawless iOS Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng You don’t (always) need [weak self]In this article, we’ll talk about weak self inside of Swift closures to avoid retain cycles &explore cases where it may or may not be necessary to capture self weakly. Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Besher Al MalehFlawless iOS Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

Further reading:

IntroductionExplains how to implement concurrent code paths in an application.Concurrent Programming:APIs and Challenges · objc.ioobjc.io publishes books on advanced techniques and practices for iOS and OS X development Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Florian Kugler Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Low-Level Concurrency APIs · objc.ioobjc.io publishes books on advanced techniques and practices for iOS and OS X development Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Daniel Eggert

https://khanlou.com/2016/04/the-GCD-handbook/

Concurrent vs serial queues in GCDI’m struggling to fully understand the concurrent and serial queues in GCD. I have some issues and hoping someone can answer me clearly and at the point.I’m reading that serial queues are created... Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Bogdan AlexandruStack Overflow Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng

WWDC Videos:

Modernizing Grand Central Dispatch Usage - WWDC 2017 - Videos - Apple DevelopermacOS 10.13 and iOS 11 have reinvented how Grand Central Dispatch and the Darwin kernel collaborate, enabling your applications to run... Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Apple Developer Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Building Responsive and Efficient Apps with GCD - WWDC 2015 - Videos - Apple DeveloperwatchOS and iOS Multitasking place increased demands on your application’s efficiency and responsiveness. With expert guidance from the... Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng Apple Developer Giải thích về đồng thời:Cách tạo ứng dụng iOS đa luồng