iOS Concurrency - Phần 3.3: Grand Central Dispatch

Vấn đề liên quan đến Singletons và giải pháp

Singleton là một trong những design pattern phổ biến lập trình nói chung và iOS nói riêng. Singleton đảm bảo chỉ có một instance của một class được tạo ra và được truy cập từ bất cứ nơi đâu. Một vấn đề chúng ta hay gặp với Singletons là nó không an toàn về luồng (thread safe). Đôi khi chúng ta gặp phải trường hợp nhiều controller truy xuất đến singleton instance cùng một lúc. Tại đây chúng ta lại gặp bài toán nhiều thread cùng truy xuất tới tài nguyên dùng chung tại một thời điểm dẫn tới race condition. Race condition làm cho dữ liệu chúng ta không chính xác hoặc làm crash app. Do đó khi hiện thực Singleton chúng ta phải đảm bảo rằng nó chúng phải thật sự an toàn về luồng. Với Singleton có hai trường hợp cần xém xét để đảm bảo an toàn về luồng: thứ nhất là trong quá trình khởi tạo singleton instance, thứ 2 là trong quá trình đọc và viết tới instance.

  1. Việc khởi tạo single instance là trường hợp dễ dàng bởi vì cách Swift khởi tạo biến toàn cục (global variable). Biến toàn cục được khởi tạo khi lần đầu được truy cập và chúng được đảm bảo chỉ khởi tạo một lần. Phần code khởi tạo được đối xử như là vùng găng (critical section) và được đảm bảo hoàn thành trước khi luồng khác truy cập tới biến toàn cục. Mình đã đề cập tới khái niệm critical section ở bài trước, các bạn có thể xem lại. Dưới đây là cách singleton được khởi tạo.
private let _sharedManager = BookManager()
class BookManager {
  class var sharedManager: BookManager {
    return _sharedManager
  }
}

Biến toàn cục private _ sharedManager được sử dụng để khởi tạo BookManager lazily. Biến public shareManager trả về biến private _ sharedManager. Swift đảm bảo rằng hoạt động này an toàn về luồng. Bạn vẫn còn phải giải quyết với vấn đề an toàn về luồng khi truy cập đoạn code trong singleton mà nó thao tác (đọc viết) trên dữ liệu dùng chung. Bạn có thể giải quyết vấn đề này thông qua qua việc truy cập dữ liệu tuần sự (synchronizing data access) mà chúng ta sẽ đề cập ngay sau đây 2. Trường hợp thứ 2 chúng ta cần xem xét là khi chúng ta đọc, viết tới instance Trong Swift, bất cứ một biến nào được khai báo với từ khoá let được xem như là hằng (constant) và chỉ có thể đọc (readonly) do đó an toàn về luồng. Việc khái báo một biến với từ khoá var giúp chúng ta chỉnh sửa được (mutable) nhưng lại không an toàn về luồng nếu kiểu dữ liệu không được thiết kế để an toàn về luồng. Những kiểu collection trong Swift như Array và Dictionary thì không an toàn về luồng khi khai báo với từ khoá var. Mặc dù nhiều thread có thể đọc một instance của một Array được khai báo với từ khoá var đồng thời mà không có vấn đề gì, tuy nhiên sẽ không an toàn nếu một thread sửa Array trong khi thread khác đọc nó. Singleton khai báo bên trên không ngăn được trường hợp này. Chúng ta cùng xem đoạn code sau đây

private let _sharedManager = BookManager()
class BookManager {
  class var sharedManager: BookManager {
    return _sharedManager
  }
  
  fileprivate var _books: [Book] = []
  var books: [Book] {
    return _books
  }
  
  func addBook(_ book: Book) {
    _books.append(book)
    DispatchQueue.main.async {
      self.postContentAddedNotification()
    }
  }
  
  fileprivate func postContentAddedNotification() {
    NotificationCenter.default.post(name: Notification.Name(rawValue: "Add book successful"), object: nil)
  }
}

Hàm addBook(_😃 dùng để thêm Sách mới vào Array, được biết đến thư là write method. Chúng ta cùng nhìn vào thuộc tính books, getter của nó được dùng để lấy Array _books (read methods). Hai methods này không cung cấp bất cứ bảo vệ nào chống lại trường hợp : một thread gọi method addBook trong khi thread khác gọi getter cho thuộc tính photos. Đây là vấn đề Readers-Writers Problems trong việc phát triển phần mềm. GCD cung cấp một giải pháp là tạo một khoá đọc viết (read/write lock) sử dụng dispatch barriers. Dispatch barriers là một nhóm những hàm mà nó chạy tuần tự (serial) khi làm việc với concurrent queues. Khi bạn submit một DispatchWorkItem tới một dipatch queue, bạn set một cái cờ (flag) để cho biết rằng chỉ có một item được chạy trên queue đó tại một thời điểm. Điều đó có nghĩa là tất cả các item được submit tới queue trước dispatch barrier phải hoàn thành trước khi DispatchWorkItem sẽ chạy. Khi DispatchWorkItem tới lượt, barrier sẽ chạy nó và đảm bảo rằng queue không chạy bất cứ một task nào khác trong thời gian đó. Một khi nó kết thúc, queue trở về tới hiện thực ban đầu của nó. Hình dưới đây sẽ chứng minh cho ảnh hưởng của barrier trên nhiều task asyn Ở hình trên, queue này chạy giống như một concurrent queue thông thường. Nhưng khi Barrier chạy, nó trở giống như một serial queue, barrier là thứ duy nhất được chạy tại lúc đó. Nhưng khi barrier kết thúc, queue này quay trở lại concurrent queue. Chúng ta sẽ sử dụng custom concurrent queue và barrier để giải quyết như sau:

private let _sharedManager = BookManager()
class BookManager {
  class var sharedManager: BookManager {
    return _sharedManager
  }
  
  fileprivate var _books: [Book] = []
  var books: [Book] {
    return _books
  }
  
  fileprivate let concurrentBookQueue =
    DispatchQueue(
      label: "com.tuananhsteven.BookManager.bookQueue",
      attributes: .concurrent) // 1
  
  func addBook(_ book: Book) {
    concurrentBookQueue.async(flags: .barrier) { // 2
      self._books.append(book) // 3
      DispatchQueue.main.async { // 4
        self.postContentAddedNotification()
      }
    }
  }
  
  fileprivate func postContentAddedNotification() {
    NotificationCenter.default.post(name: Notification.Name(rawValue: "Add book successful"), object: nil)
  }
}

(// 1): chúng ta khởi tạo một custom concurrent queue tên là concurrentBookQueue. Chúng ta cần phải gán cho nó một cái label hay còn gọi là một cái tên để sử dụng trong quá trình debug. Ở đây mình sử dụng reversed DNS style naming convention để đặt tên cho nó. Sau đó chúng ta đặc tả nó là concurrent queue (// 2): Trong hàm addBook, chúng ta chạy async hoạt động thêm sách trên queue vừa mới tạo với flag là barrier. Khi barrier chạy, chỉ có duy nhất một item trong queue này. (// 3): chúng ta thêm sách vào Array (// 4): Cuối cùng chúng ta gửi một notification để báo rằng việc thêm sách thành công. Notification này được chạy trên main thread bởi vì nó sẽ làm UI hoạt đông. Do đó chúng ta chuyển task async tới main queue để trigger notification. Những thay đổi trên giải quyết được vấn đề write nhưng chúng ta cần hiện thực lại phương thức read. Để đảm bảo an toàn về luồng với phương thức write bên trên, chúng ta cần thực hiện việc read trên concurrentBookQueue. Bạn cần dữ liệu trả về từ việc gọi một hàm dó đó dispatch async sẽ không làm được. Trong trường hợp này sync sẽ là một sự lựa chọn đúng.

fileprivate var _books: [Book] = []
var books: [Book] {
  var booksCopy: [Book]!
  concurrentBookQueue.sync { // 1
    booksCopy = self._books // 2
  }
  return booksCopy
}

Như vậy là chúng ta đã giải quyết được vấn đề an toàn về luồng với Singleton. Cho dù bạn có đọc hay thêm Sách với bất kì hình thức nào, bạn cũng không cần quan tâm nhiều vì chúng ta đã hiện thực Singleton một cách an toàn về luồng.

Những gì mình sẽ nói tiếp?

Thông qua bài giới thiệu nho nhỏ này, các bạn chắc hẳn đã biết cách làm một singleton an toàn về luồng cho việc đọc viết bằng cách sử dụng kết hợp dispatch barrierssynchronous dispatch queues. Ở bài tiếp theo mình sẽ giới thiệu cho các bạn DispatchGroup và cách sử dụng chúng trong một số vấn đề. Hẹn gặp các bạn ở những bài viết kế tiếp trong chuỗi series về iOS concurrency.

Tài liệu tham khảo

https://www.raywenderlich.com/60749/grand-central-dispatch-in-depth-part-1 https://www.raywenderlich.com/148513/grand-central-dispatch-tutorial-swift-3-part-1