12/08/2018, 13:09

Concurrency trong iOS: Tìm hiểu về Grand Central Dispatch và NSOperation

Khi sử dụng iPhone, người dùng thường đánh giá iPhone là một chiếc điện thoại sử dụng rất mượt mà, kể cả trên những đời iPhone đã cũ như iphone 4s, iphone 5. Nguyên nhân của việc ứng dụng chạy mượt trên cả những thiết bị đã cũ là do việc quản lý bộ nhớ rất tốt trên cả hệ điều hành iOS và cả của lập ...

Khi sử dụng iPhone, người dùng thường đánh giá iPhone là một chiếc điện thoại sử dụng rất mượt mà, kể cả trên những đời iPhone đã cũ như iphone 4s, iphone 5. Nguyên nhân của việc ứng dụng chạy mượt trên cả những thiết bị đã cũ là do việc quản lý bộ nhớ rất tốt trên cả hệ điều hành iOS và cả của lập trình viên khi viết ứng dụng.

Trong khi lập trình trên iOS, đối với những lập trình viên kinh nghiệm, chúng ta không những phải quan tâm đến quản lý và giải phóng bộ nhớ, tránh memory leak, hạn chế việc sử dụng bộ nhớ lãng phí,... mà chúng ta còn phải quan tâm đến một vấn đề rất quan trọng: concurrency (xử lý đồng thời). Việc quản lý tốt concurrency mang lại cho chúng ta rất nhiều lợi ích:

  • Sử dụng tốt hơn resource của thiết bị (iPhone, iPad): những thiết bị chạy iOS hiện tại thường được trang bị cpu với nhiều core, chúng ta nên sử dụng concurrency để xử lý các tác vụ đồng thời, tối ưu việc sử dụng resource của thiết bị.

  • Tăng trải nghiệm người dùng: việc sử dụng concurrency sẽ giúp ứng dụng của chúng ta chạy mượt mà hơn, tránh hiện tượng giật lag, giúp trải nghiệm người dùng được tốt hơn. Khi trải nghiệm người dùng tốt hơn, khả năng ứng dụng của chúng ta được sử dụng nhiều sẽ cao hơn.

Việc sử dụng concurrency trong lập trình iOS là không hề khó, bởi vì Apple đã cung cấp cho lập trình viên các API để chúng ta dễ dàng thao tác sử dụng. Trong bài viết này, tôi xin giới thiệu về Grand Central Dispatch và NSOperation, 2 API thông dụng nhất giúp chúng ta quản lý concurrency một cách đơn giản và dễ dàng.

Sau đây, tôi xin giới thiệu các khái niệm về Grand Central Dispatch, NSOperation và cách sử dụng chúng.

GCD là API thường được sử dụng để quản lý việc xử lý đồng thời và xử lý không đồng bộ (asynchronously) ở Unix level của hệ thống bằng cách cung cấp và quản lý các queue (hàng đợi) cho các task.

1. Dispatch Queues

Dispatch Queue là queue được tạo ra để quản lý việc xử lý các task đồng thời hay tuần tự. Chúng ta có thể tự tạo dispatch queue, hoặc dùng các dispatch queue mặc định của GCD. Để sử dụng dispatch queue, chúng ta viết code dưới dạng các block, gán các block vào các dispatch queue để yêu cầu GCD xử lý.

Để tạo dispatch queue, chúng ta tạo như trong ví dụ sau:

let mySerialQueue = dispatch_queue_create("com.framgia.serialQueue", DISPATCH_QUEUE_SERIAL)

Trong đoạn code trên, hàm dispatch_queue_create() để tạo một queue mới, với parameter đầu tiên là tên queue mới này, và parameter thứ 2 là loại của queue mới tạo ra.

Có 2 loại dispatch queue: serial queue và concurrent queue, mỗi loại queue có một đặc điểm khác nhau để dùng trong những trường hợp cụ thể khác nhau. Chúng ta sẽ tìm hiểu về serial queue và concurrent queue.

a. Serial queue

Serial queue là loại queue mà tại 1 thời điểm chỉ có thể chạy được 1 task. Khi chúng ta gán các task của chúng ta vào queue này, các task của chúng ta sẽ được thực hiện lần lượt từng task theo thứ tự first in first out (FIFO).

Để tạo serial queue, chúng ta tạo dispatch queue với loại queue là DISPATCH_QUEUE_SERIAL

let mySerialQueue = dispatch_queue_create("com.framgia.serialQueue", DISPATCH_QUEUE_SERIAL)

Ngoài ra, GCD cung cấp cho chúng ta 1 serial system queue của hệ thống: main queue. Chúng ta lấy main queue như sau:

let mainQueue = dispatch_get_main_queue()

Ví dụ: chúng ta có 3 task cần xử lý, và chúng ta gán 3 task này lần lượt theo thứ tự #1, #2, #3 vào 1 serial queue. Lúc này, các task của chúng ta sẽ được xử lý lần lượt theo thứ tự #1, #2, #3.

let serialQueue = dispatch_queue_create("com.framgia.serialQueue", DISPATCH_QUEUE_SERIAL)

dispatch_async(serialQueue) { () -> Void in
    // Do task 1 code
}

dispatch_async(serialQueue) { () -> Void in
    // Do task 2 code
}

dispatch_async(serialQueue) { () -> Void in
    // Do task 3 code
}

Như đoạn code bên trên, các task sẽ được xử lý lần lượt task 1, task 2, task 3.

Tuy serial queue chỉ xử lý 1 task tại 1 thời điểm, tuy nhiên chúng ta hoàn thoàn có thể sử dụng serial queue để chạy các task đồng thời, thông qua việc sử dụng nhiều task khác nhau.

Ví dụ: vẫn với 3 task cần sử lỹ #1, #2, #3 bên trên, chúng ta gán task #1 và #2 vào một serial queue, và gán task #3 vào một serial queue khác, chúng ta sẽ được kết quả là các task chạy đồng thời. Ở đây, task #1 và task #3 sẽ cùng được xử lý tại 1 thời điểm, còn task #2 sẽ chờ task #1 kết thúc mới xử lý.

let serialQueue1 = dispatch_queue_create("com.framgia.serialQueue1", DISPATCH_QUEUE_SERIAL)

dispatch_async(serialQueue1) { () -> Void in
    // Do task 1 code
}

dispatch_async(serialQueue1) { () -> Void in
    // Do task 2 code
}

let serialQueue2 = dispatch_queue_create("com.framgia.serialQueue2", DISPATCH_QUEUE_SERIAL)

dispatch_async(serialQueue2) { () -> Void in
    // Do task 3 code
}

Trong đoạn code trên, task 1 và task 3 sẽ được xử lý đồng thời trên 2 queue serialQueue1 và serialQueue2. Task 2 sẽ phải chờ task 1 xử lý xong mới được xử lý trên serialQueue1

Sử dụng serial queue sẽ rất tiện lợi khi chúng ta cần chạy các tác vụ mà chắc chắn tác vụ này phải hoàn thành trước khi tác vụ khác được phép chạy. Ngoài ra, trong trường hợp các task cần xử lý tốn quá nhiều resource, chúng ta cũng có thể đưa các task này vào serial queue để tránh việc nhiều task nặng cùng chạy một lúc.

iOS không hạn chế số lượng serial queue chúng ta được tạo, vì thế việc tạo bao nhiêu serial queue là do chúng ta quyết định.

b. Concurrent queue

Ngược lại với serial queue, concurrent queue là loại queue mà các task được xử lý song song. Khi các task được gán vào queue, task nào vào trước sẽ được xử lý trước. Tuy nhiên, mỗi task trong queue không phải chờ đợi task trước đó xử lý xong mới được xử lý, mà các task được xử lý một cách đồng thời. Điều này có nghĩa là chúng ta không biết chắc được task nào sẽ hoàn thành xử lý trước.

Giống như khi tạo serial queue, để tạo concurrent queue, chúng ta tạo dispatch queue với loại queue là DISPATCH_QUEUE_CONCURRENT

let myConcurrentQueue = dispatch_queue_create("com.framgia.concurrentQueue", DISPATCH_QUEUE_CONCURRENT)

GCD cung cấp sẵn cho chúng ta 4 global concurrent queue của hệ thống:

  • DISPATCH_QUEUE_PRIORITY_HIGH
  • DISPATCH_QUEUE_PRIORITY_DEFAULT
  • DISPATCH_QUEUE_PRIORITY_LOW
  • DISPATCH_QUEUE_PRIORITY_BACKGROUND

Để lấy global concurrent queue, chúng ta sử dụng code sau:

let myQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0)

Chúng ta lấy ví dụ như sau: chúng ta có 3 task #1, #2, #3 lần lượt được gán vào 1 concurrent queue. Như vậy, thứ tự được xử lý của các task sẽ là #1, #2, #3, tức là task 1 sẽ được bắt đầu xử lý trước task 2 và task 3. Tuy nhiên, khi task 1 được bắt đầu xử lý, thì task 2 cũng bắt đầu được xử lý, và tiếp theo là task 3. Nếu thời gian task 1 xử lý là lâu hơn task 2, thì task 2 có thể được xử lý xong trước task 1. Các task có thể được code như sau:

let concurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0)

dispatch_async(concurrentQueue) { () -> Void in
    // Do task 1 code
}

dispatch_async(concurrentQueue) { () -> Void in
    // Do task 2 code
}

dispatch_async(concurrentQueue) { () -> Void in
    // Do task 3 code
}

Lưu ý, trong 4 loại global concurrent queue ở trên, hệ thống sẽ xét thứ tự ưu tiên xử lý lần lượt là DISPATCH_QUEUE_PRIORITY_HIGH > DISPATCH_QUEUE_PRIORITY_DEFAULT > DISPATCH_QUEUE_PRIORITY_LOW > DISPATCH_QUEUE_PRIORITY_BACKGROUND. Tức là trong cùng 1 thời điểm, có thể có 2 task trong 2 queue cùng được xem xét xử lý, thì task nào ở queue có độ ưu tiên cao hơn sẽ được ưu tiên xử lý trước.

Chúng ta cũng cần chú ý rằng các dispatch queue có sẵn (1 main queue đối với serial queue và 4 global queue đối với concurrent queue) đều là global queue của hệ thống. Tức là các queue này không chỉ xử lý các task của chúng ta, mà còn xử lý các task của các ứng dụng khác

1. Operation Queues

Ở phần trên, tôi đã giới thiệu về GCD và các loại queue của GCD. Chúng ta hoàn toàn có thể sử dụng GCD để thực hiện việc xử lý đồng thời các task. Vậy thì câu hỏi được đặt ra là operation queue để làm gì, có tác dụng gì trong concurrency?

Câu trả lời rất đơn giản, GCD là một API cấp thấp của ngôn ngữ C, còn operation queues được xây dựng tầng bên trên của GCD. Điều này có nghĩa là chúng ta có thể sử dụng NSOperation để thực thi các task đồng thời giống như GCD, nhưng việc sử dụng operation queue sẽ hướng đối tượng hơn, đơn giản hơn cho lập trình viên.

Không giống như các queue trong GCD, operation queue có nhiều sự khác biệt:

  • Operation queue không thực hiện các task theo quy tắc FIFO. Chúng ta có thể set độ ưu tiên cho operation queue, cũng có thể tạo ràng buộc giữa các operation, ví dụ như ràng buộc 1 operation chỉ được thực thi sau khi 1 operation khác kết thúc.
  • Operation queue chỉ xử lý theo kiểu concurrent queue mà không xử lý theo kiểu serial queue của GCD. Để xử lý theo kiểu serial queue, chúng ta tạo các ràng buộc cho các operation.
  • Operation queue là kiểu hướng đối tượng, mỗi operation queue là một instance của lớp NSOperationQueue, và mỗi task là một instance của lớp NSOperation.

2. NSOperation

Đối với GCD, các task được cho vào dispatch queue dưới dạng các block, còn đối với operation queue, các task được cho vào queue dưới dạng các instance của lớp NSOperation. Lớp NSOperation là lớp trừu tượng (abstract class), do đó chúng ta không thể tạo các instance của NSOperation một cách trực tiếp, mà phải tạo thông qua các subClass của NSOperation. Trong iOS, NSOperation được implement trong 2 subClass: NSBlockOperation và NSInvocationOperation, ngoài 2 subClass này chúng ta hoàn toàn có thể tự viết class kế thừa và implement từ NSOperation class.

  • NSBlockOperation: class này dùng để khởi tạo operation với block. Một operation có thể có một hoặc nhiều block, và khi thực thi hết các block thì operation sẽ được coi là đã hoàn thành.
  • NSInvocationOperation: class này dùng để khởi tạo operation chứa một Selector trên 1 object

3. Ưu điểm của operation queue

a. Tạo sự phụ thuộc giữa các operation

Chúng ta có thể tạo sự phụ thuộc giữa 1 operation với 1 operation khác trong operation queue. Do đó, trong cùng 1 queue, chúng ta có thể vừa có những task chạy đồng thời (concurrent) và các task chạy tuần tự (serial) mà không cần phải xác định queue như GCD. Trong GCD, nếu muốn task chạy tuần tự, chúng ta phải gán task vào serial queue, và gán vào concurrent queue nếu muốn chạy đồng thời. Đối với GCD, chúng ta có thể tạo sự phụ thuộc bằng cách gán task vào serial queue, nhưng sử dụng operation queue ưu việt hơn hẳn dùng GCD.

b. Thay đổi độ ưu tiên xử lý các NSOperation

Cũng giống như trên, GCD có các concurrent queue với độ ưu tiên khác nhau, do đó chúng ta phải xác định độ ưu tiên của task trước rồi gán vào queue tương ứng. Đối với operation queue, chúng ta có thể thay đổi độ ưu tiên cho các task một cách dễ dàng sau khi gán task vào operation queue. Điều này là không thể đối với GCD.

Độ ưu tiên của operation được định nghĩa qua enum:

public enum NSOperationQueuePriority : Int {
    case VeryLow
    case Low
    case Normal
    case High
    case VeryHigh
}

Trong đó, VeryHigh có độ ưu tiên cao nhất và thấp nhất là VeryLow

c. Hủy bỏ việc thực thi NSOperation

Lại thêm một việc chúng ta không thể làm khi sử dụng GCD. Khi sử dụng operation queue, chúng ta có thể cancel việc thực thi các task bất kỳ lúc nào chúng ta muốn bằng cách gọi hàm cancel() của class NSOperation:

  • Trường hợp task đã thực hiện xong: hàm cancel() không có tác dụng gì, bởi lúc này task đã thực hiện xong
  • Trường hợp task đang dược thực thi: lúc này task của chúng ta đang được thực thi trong queue, việc gọi hàm cancel() sẽ không làm việc thực thi task của chúng ta bị hủy bỏ, mà property cancelled của instance operation thực thi task sẽ được gán bằng true.
  • Trường hợp task đang chờ ở trong queue để thực thi, lúc này operation sẽ không được thực thi nữa.

d. Các property trạng thái của NSOperation

Class NSOperation cung cấp cho chúng ta 3 property trạng thái của một instance. Các property đó là:

  • finished: property này sẽ được gán giá trị là true khi việc thực thi operation kết thúc.
  • cancelled: proprety này sẽ được gán giá trị là true khi hàm cancel() của class NSOperation được gọi.
  • ready: property này sẽ được gán giá trị là true khi operation đã sẵn sàng trong hàng đợi và được gán ngay trước khi thực thi operation.

e. Completion block

Chúng ta có thể gán completion block cho instance của class NSOperation. block này sẽ được thực thi khi kết thúc việc thực thi các task của operation.

4. Coding & Explanation NSOperation

Dưới đây là một đoạn code nho nhỏ để các bạn có thể hiểu cách sử dụng NSOperation. Trong đoạn code của tôi, tôi có:

  • 1 property NSOperationQueue: queue
  • 4 IBOutlet UIImageView: imageView1, imageView2, imageView3, imageView4.
  • 4 property String là url của các image: imageUrl1, imageUrl2, imageUrl3, imageUrl4
  • 2 IBAction: onDownloadImageButtonClicked(), onCancelButtonClicked()
// download image function
func downloadImageWithURL(url:String) -> UIImage! {
        let data = NSData(contentsOfURL: NSURL(string: url)!)
        return UIImage(data: data!)
}

@IBAction func onDownloadImageButtonClicked(sender: AnyObject) {
    // 1. initialize NSOperationQueue instance
    queue = NSOperationQueue()
    // 2. initialize NSBlockOperation instance - subClass of NSOperation
    let operation1 = NSBlockOperation(block: {
        let image1 = downloadImageWithURL(imageUrl1)
        // 3. add NSOperation to main queue
        NSOperationQueue.mainQueue().addOperationWithBlock({
            imageView1.image = image1
        })
    })

    // 4. add completion block to operation
    operation1.completionBlock = {
        print("Operation 1 completed")
    }
    // 5. add NSOperation to NSOperationQueue
    queue.addOperation(operation1)

    let operation2 = NSBlockOperation(block: {
        let image2 = downloadImageWithURL(imageUrl2)
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView2.image = image2
        })
    })

    // 6. add dependency: operation 2 depend on operation 1
    operation2.addDependency(operation1)
    operation2.completionBlock = {
        print("Operation 2 completed")
    }
    queue.addOperation(operation2)

    let operation3 = NSBlockOperation(block: {
        let image3 = downloadImageWithURL(imageUrl3)
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView3.image = image3
        })
    })

    operation3.completionBlock = {
        print("Operation 3 completed")
    }
    queue.addOperation(operation3)

    let operation4 = NSBlockOperation(block: {
        let image4 = downloadImageWithURL(imageUrl4)
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView4.image = image4
        })
    })

    operation4.completionBlock = {
        print("Operation 4 completed")
    }
    queue.addOperation(operation4)
}

@IBAction func onCancelButtonClicked(sender: AnyObject) {
    // 7. Cancel all operation in operation queue
    self.queue.cancelAllOperations()
}

Đoạn code bên trên là ví dụ trực quan về việc khởi tạo và sử dụng NSOperationQueue, NSOperation trong code. Trong đoạn code trên, tôi đã kèm theo những comment khá rõ ràng với các hàm của NSOperation nên tôi sẽ không giải thích gì thêm.

Trong bài viết này, tôi đã giới thiệu đến các bạn khái niệm về concurrency, vì sao concurrency lại là một phần rất quan trọng mà chúng ta cần phải biết khi lập trình iOS. Chúng ta đã tìm hiểu về tìm hiểu về GCD và NSOperation, tìm hiểu cách sử dụng GCD và NSOperation để thực hiện các task đồng thời, các task tuần tự trong ứng dụng iOS. Hi vọng bài viết này sẽ giúp ích cho các bạn trong việc nghiên cứu về concurrency.

Cuối cùng, tôi xin cảm ơn các bạn đã theo dõi bài viết này!!!

0