Lập trình đồng bộ (Synchronous) và bất đồng bộ (Asynchronous) trong iOS: Concurrency là gì?

Bài viết này dành cho các lập trình viên iOS muốn tìm hiểu sâu hơn về lập trình đồng bộ và bất đồng bộ, một kỹ thuật quan trọng để xây dựng các ứng dụng mượt mà và hiệu quả. Để hiểu rõ các khái niệm trong bài viết này, bạn cần có kiến thức cơ bản về các API bất đồng bộ như URLSession và quen thuộc với việc sử dụng completion handler closures. Nếu bạn chưa có kinh nghiệm với những khái niệm này, bạn có thể coi bài viết này như một tài liệu tham khảo và tìm hiểu dần.

Lịch sử phát triển của Concurrency

Trong quá trình phát triển của máy tính, hiệu năng của CPU là yếu tố then chốt quyết định khả năng xử lý công việc. Các nhà sản xuất liên tục tìm cách tăng tốc độ CPU bằng cách thu nhỏ kích thước bóng bán dẫn và tích hợp nhiều bóng bán dẫn hơn vào một diện tích nhỏ. Tuy nhiên, cuộc đua tăng tốc độ lõi CPU gặp phải những giới hạn về phần cứng và nhiệt độ.

Để vượt qua những giới hạn này, các nhà phát triển chuyển sang một giải pháp khác: tích hợp nhiều lõi (core) vào một CPU. Thay vì chỉ một lõi xử lý, công việc được chia nhỏ và xử lý đồng thời bởi nhiều lõi, giúp tăng hiệu năng tổng thể mà vẫn duy trì hiệu quả sử dụng điện năng.

Tuy nhiên, việc tận dụng sức mạnh của các CPU đa lõi không hề đơn giản. Trong quá khứ, các lập trình viên phải tự quản lý việc tạo và điều phối các thread, một công việc phức tạp đòi hỏi kiến thức sâu rộng về hệ thống và khả năng xử lý lỗi. Việc xác định số lượng thread tối ưu dựa trên tải hệ thống và phần cứng cũng là một thách thức lớn.

Để giải quyết vấn đề này, cả iOS và macOS đều giới thiệu một phương pháp tiếp cận mới: thay vì tạo thread trực tiếp, ứng dụng chỉ cần gửi các task vào các hàng đợi (queue). Hệ thống sẽ tự động quản lý việc tạo và phân phối thread, giúp ứng dụng đạt được sự linh hoạt cao hơn và đơn giản hóa mô hình lập trình.

Concurrency là gì?

Concurrency (xử lý đồng thời) là khả năng thực hiện nhiều task cùng một lúc. Điều này không nhất thiết có nghĩa là các task được thực hiện song song (parallelism). Với concurrency, các task có thể bắt đầu, chạy và hoàn thành theo thứ tự bất kỳ, có thể xen kẽ nhau.

Tại sao ứng dụng cần Concurrency?

Concurrency mang lại nhiều lợi ích cho ứng dụng, bao gồm:

  • Giữ cho giao diện người dùng (UI) luôn phản hồi nhanh: Chuyển các tác vụ nặng sang các thread khác giúp UI không bị “đóng băng” khi thực hiện các tác vụ tốn thời gian.
  • Tăng tốc độ xử lý: Tận dụng tối đa sức mạnh của kiến trúc chip đa nhân bằng cách phân chia công việc cho nhiều lõi xử lý đồng thời.

Nếu một tác vụ nặng (non-UI task) được thực hiện trên main thread, nó sẽ chặn (block) main thread, khiến ứng dụng không thể phản hồi các tương tác của người dùng.

Main thread bị block bởi một task nặngMain thread bị block bởi một task nặng

Để tránh tình trạng này, chúng ta cần chuyển các tác vụ nặng sang một thread khác để xử lý. Main thread sẽ tiếp tục thực hiện các nhiệm vụ khác, bao gồm việc phản hồi các tương tác của người dùng.

Chuyển tác vụ nặng sang background thread để giải phóng main threadChuyển tác vụ nặng sang background thread để giải phóng main thread

Các khái niệm cơ bản trong lập trình đồng thời

Concurrency

Concurrency không chỉ áp dụng cho các thiết bị có chip đa nhân. Ngay cả trên các thiết bị đơn nhân, chúng ta vẫn có thể xử lý đa luồng nhờ cơ chế time-slicing (chia sẻ thời gian) để chuyển đổi ngữ cảnh giữa các thread.

Time-slicing trên thiết bị đơn nhânTime-slicing trên thiết bị đơn nhân

Queue

Queue (hàng đợi) là một danh sách các công việc (task) được thực hiện theo nguyên tắc FIFO (First In, First Out) – vào trước ra trước. Task nào được thêm vào queue trước sẽ được thực hiện trước, và ngược lại. Có hai loại queue chính:

  • Serial Queue: Là hàng đợi thực hiện các task theo tuần tự. Tại một thời điểm, chỉ có một task được thực thi. Task tiếp theo chỉ bắt đầu khi task hiện tại hoàn thành. Main thread là một ví dụ điển hình của serial queue.

    Serial Queue: Các task được thực hiện tuần tựSerial Queue: Các task được thực hiện tuần tự

  • Concurrent Queue: Là hàng đợi cho phép thực hiện nhiều task đồng thời. Hệ thống sẽ tự động tạo và phân phối các thread để xử lý các task, tùy thuộc vào tải hệ thống và cấu hình phần cứng.

    Concurrent Queue: Các task được thực hiện đồng thờiConcurrent Queue: Các task được thực hiện đồng thời

So sánh giữa Serial Queue và Concurrent Queue

So sánh Serial Queue và Concurrent QueueSo sánh Serial Queue và Concurrent Queue

Synchronous và Asynchronous

Đầu vào của các queue là các closure. Các closure này được đánh dấu để chỉ định cách thức thực hiện trước khi được gửi đến một queue. Có hai cách thức thực hiện chính:

  • Synchronous (đồng bộ): Khi một task được đánh dấu là synchronous, nó sẽ chặn queue mà nó được gọi, ngăn queue thực thi bất kỳ task nào khác cho đến khi task đó hoàn thành.
  • Asynchronous (bất đồng bộ): Khi một task được đánh dấu là asynchronous, nó sẽ được gọi và ngay lập tức trả quyền điều khiển cho hàm gọi nó. Queue sẽ tiếp tục thực thi các closure tiếp theo (nếu có).

Mối quan hệ giữa Synchronous, Asynchronous và Serial Queue, Concurrent Queue

  • SynchronousAsynchronous mô tả cách thức một task được thực hiện và ảnh hưởng của nó đến queue.
  • Serial QueueConcurrent Queue mô tả đặc tính của queue, bao gồm số lượng thread và khả năng thực hiện task đồng thời.

Nói cách khác:

  • Synchronous/Asynchronous cho bạn biết queue hiện tại có phải đợi task hoàn thành rồi mới gọi task mới hay không.
  • Serial Queue/Concurrent Queue cho bạn biết queue hiện tại có một thread hay nhiều thread, và liệu một task được thực hiện một lúc hay nhiều task được thực hiện đồng thời.

Ví dụ minh họa

Để hiểu rõ hơn về cách hoạt động của các queue và task, chúng ta sẽ xem xét một số ví dụ cụ thể.

Trường hợp 1: Gửi 2 async task vào serial queue

func simpleQueues() {
    let queue = DispatchQueue(label: "com.bigZero.GCDSamples")

    queue.async {
        for i in 0..<5 {
            print("🔵 (i) -(Thread.current))")
        }
    }

    queue.async {
        for i in 0..<5 {
            print("⚾️ (i) – (Thread.current))")
        }
    }

    for i in 0..<10 {
        print("❤️ (i) – (Thread.current)")
    }
}

Kết quả khi gửi 2 async task vào serial queueKết quả khi gửi 2 async task vào serial queue

Trong ví dụ này:

  1. Chúng ta tạo một serial queue.
  2. Chúng ta gửi hai async task (🔵 và ⚾️) vào queue. Vì chúng là async task, chúng sẽ ngay lập tức trả quyền điều khiển cho hàm gọi nó.
  3. Hàm simpleQueues() tiếp tục thực thi và in ra ❤️.
  4. Vì 🔵 và ⚾️ được đưa vào cùng một serial queue, chúng sẽ được chạy trên cùng một thread theo cách thức tuần tự.
  5. ❤️ được thực hiện trên main thread.
  6. Vì main thread có mức độ ưu tiên cao nhất, nên mặc dù số lượng ❤️ bằng tổng số 🔵 + ⚾️, ❤️ vẫn hoàn thành trước hai task kia.

Trường hợp 2: Gửi 2 sync task vào serial queue

func simpleQueues() {
    let serialQueue = DispatchQueue(label: "com.bigZero.GCDSamples")

    queue.sync {
        for i in 0..<5 {
            print("🔵 (i) -(Thread.current))")
        }
    }

    queue.sync {
        for i in 0..<5 {
            print("⚾️ (i) – (Thread.current))")
        }
    }

    for i in 0..<10 {
        print("❤️ (i) – (Thread.current)")
    }
}

Khi gửi sync task 🔵 vào serialQueue, task 🔵 sẽ chặn hàm simpleQueues và ngăn nó thực hiện các tác vụ tiếp theo. Lúc này, main thread sẽ rảnh (vì main queue đang bị chặn), vì vậy nó sẽ được cấp phát để thực hiện task 🔵. Sau khi task 🔵 hoàn thành, task ⚾️ bắt đầu được gọi và cũng tiếp tục chặn như trên. Cuối cùng, task ❤️ được gọi trên main queue, và tất cả các task trên main queue đều sẽ được ưu tiên thực hiện trên main thread.

Bây giờ, chúng ta thay đổi một chút và chuyển task thứ hai thành async:

func simpleQueues() {
    let serialQueue = DispatchQueue(label: "com.bigZero.GCDSamples")

    queue.sync {
        for i in 0..<5 {
            print("🔵 (i) -(Thread.current))")
        }
    }

    queue.async {
        for i in 0..<5 {
            print("⚾️ (i) – (Thread.current))")
        }
    }

    for i in 0..<10 {
        print("❤️ (i) – (Thread.current)")
    }
}

Tương tự như trên, khi gửi sync task 🔵 vào serialQueue, task 🔵 sẽ chặn hàm simpleQueues và ngăn nó thực hiện các tác vụ tiếp theo. Lúc này, main thread sẽ rảnh (vì main queue đang bị chặn), vì vậy nó sẽ được cấp phát để thực hiện task 🔵. Sau khi task 🔵 hoàn thành, task ⚾️ bắt đầu được gọi. Do task ⚾️ là async, nó sẽ ngay lập tức trả lại quyền điều khiển cho hàm gọi nó và task ❤️ được gọi ưu tiên trên main thread. Do main thread đang được sử dụng, hệ thống sẽ cấp phát cho task ⚾️ một thread khác để xử lý.

Trường hợp 3: Gửi 3 async task vào Concurrent Queue

func concurrentQueues() {
    let concurrentQueue = DispatchQueue(label: "com.bigZero.GCDSamples", attributes: .concurrent)

    concurrentQueue.async {
        for i in 0..<10 {
            print("🔵 (i) – (Thread.current)")
        }
    }

    concurrentQueue.async {
        for i in 0..<10 {
            print("❤️ (i)- (Thread.current)")
        }
    }

    concurrentQueue.async {
        for i in 0..<10 {
            print("⚾️ (i)- (Thread.current)")
        }
    }
}

Do cả 3 task đều là async, cả 3 task đều được đưa vào trong concurrentQueue. Lúc này, hệ thống sẽ cấp phát cho concurrentQueue 3 thread để thực hiện đồng thời 3 task trên 3 thread khác nhau.

Thay đổi một chút, ta cho task ❤️ trở thành sync. Khi add xong task ❤️, do task này là sync nên nó khóa queue lại, không cho add task ⚾️ vào nữa. Sau khi task ❤️ chạy xong, task ⚾️ mới được add vào concurrent Queue. Do vậy, task ⚾️ chạy một mình cuối cùng.

Kết luận

Lập trình đồng thời là một công cụ mạnh mẽ để xây dựng các ứng dụng iOS mượt mà và hiệu quả. Bằng cách hiểu rõ các khái niệm cơ bản như queue, synchronous và asynchronous, bạn có thể tận dụng tối đa sức mạnh của các thiết bị đa nhân và mang lại trải nghiệm tốt nhất cho người dùng.

Một lưu ý quan trọng: Khi tạo queue, bạn có thể quyết định queue đó sẽ chạy trên một thread hay nhiều thread bằng cách chỉ định loại queue là Serial hay Concurrent. Tuy nhiên, bạn không được quyền quyết định thread nào sẽ thực thi các tác vụ. Việc này được hệ thống quyết định dựa trên các yếu tố phần cứng và tải hiện tại của hệ thống.