+6

Tìm hiểu về Automatic Reference Counting(ARC) Và quản lý bộ nhớ trong swift

I. Giới thiệu

Trước đây, trước khi ARC được Apple đưa vào Xcode, việc quản lý bộ nhớ là nỗi ác mộng thực sự với các lập trình viên. Lập trình viên chúng ta phải quản lý bộ nhớ một cách thủ công, mất nhiều thời gian vào việc viết code làm sao để vừa phải quản lý bộ nhớ tốt, vừa phải thực thi tốt việc chúng ta muốn làm.

Sau này, khi ARC được sử dụng, chúng ta đã bớt được rất nhiều công việc phải làm, việc quản lý bộ nhớ đã được xử lý tự động nhờ vào ARC. Tuy nhiên, Objective-C là một ngôn ngữ khá “cũ”, dù không phải lo quản lý bộ nhớ thủ công như trước, chúng ta vẫn phải cẩn thận với code của mình, việc để sảy ra tình trạng memory leaks rất dễ xảy ra với các lập trình viên mới.

Thật may, Swift - một ngôn ngữ bậc cao, mới được Apple giới thiệu trong vài năm trở lại đây để thay thế dần cho ngôn ngữ Objective-C đã cũ. Swift là ngôn ngữ mới, vì thế việc quản lý bộ nhớ trong Swift là rất tốt. Khi sử dụng Swift, chúng ta không cần phải lo nghĩ quá nhiều về memory leak như khi chúng ta sử dụng Objective-C. Tuy nhiên, memory leaks vẫn xảy ra trong Swift, mặc dù với tần suất ít hơn trong Objective-C.

Trong bài này, tôi sẽ giới thiệu đến các bạn về cách mà ARC hoạt động, về reference cycle( nguyên nhân gây ra memory leaks) và cách để chúng ta tránh reference cycle. Cách giới thiệu tốt nhất, không gì hơn là thông qua code 😃

II. Nội dung

1. Cách ARC hoạt động

Trong iOS, khi object được khởi tạo, nó sẽ được quản lý bởi ARC. Dựa vào số lượng reference đến mà object đó được gán một số được gọi là Reference counting. Mỗi khi object được reference bởi object khác, số reference counting sẽ tăng thêm, và giảm khi nó không còn được reference đến nữa. Một object sẽ được xoá bỏ và trả lại bộ nhớ cho hệ thống khi nó không còn được reference bởi bất kỳ object nào khác, hay reference counting bằng 0.

Để cho dễ hiểu, chúng ta sẽ đi vào code. Các bạn mở Xcode, tạo một playground project mới và thêm vào đoạn code như sau:

class User {
    var name: String
    
    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }
    
    deinit {
        print("\(name) is being deallocated")
    }
}

let user1 = User(name: "John")

Bên trên, chúng ta viết class User với property name và 2 hàm init(name:) và deinit để in ra message khi object của class User được khởi tạo hay được xoá bỏ.

Nhìn vào giá trị output của playground, chúng ta có thể thấy hàm init(name:) của user1 được gọi, object user1 đã được khởi tạo. Tuy nhiên hàm deinit() không được gọi, bởi vì user1 đã không bị xoá bỏ trong playground. Để xoá bỏ object user1 khi playground chạy hết, chúng ta để đoạn code khởi tạo user1 trong lệnh do:

do {
  let user1 = User(name: "John")
}

Lúc này, cả hàm init(name:) và hàm deinit() đều đã được gọi. Object của chúng ta được khởi tạo, và sau đó đã được giải phóng khỏi bộ nhớ như trong sơ đồ sau:

Như sơ đồ trên, chúng ta khởi tạo object của class User và gán cho user1, lúc này reference counting được đánh số 1. Sau khi chạy hết đoạn code "do", user1 bị xoá, object của class User không còn bất kỳ reference nào, số reference counting là 0 và nó được xoá bỏ bởi ARC, trả lại bộ nhớ cho hệ thống.

2. Reference cycles

Như ở trên, chúng ta thấy ARC hoàn thành rất tốt nhiệm vụ của mình, chúng ta không cần phải quan tâm đến việc khi nào thì object của chúng ta không còn được reference đến nữa. Tuy nhiên, ARC chỉ có thể giúp chúng ta kiểm soát và giải phóng những object không còn được reference đến nữa. Trong nhiều trường hợp, giả sử chúng ta có 2 object, các object chúng ta đã không còn sử dụng đến nữa, chúng vẫn có reference đến nhau, do đó số reference counting vẫn khác 0, và lúc này chúng ta sẽ bị memory leaks(thất hoát bộ nhớ). Xét sơ đồ sau:

Như sơ đồ bên trên, chúng ta có 2 object object1 và object2 được reference bởi variable1 và variable2. Do object1 và object2 đều có reference đến nhau, nên số reference counting của object1 và object2 đều là 2. Khi chúng ta không còn dùng đến variable1 và variable2 nữa, chúng ta không còn dùng đến object1 và object2 nữa. Tuy nhiên 2 object này vẫn còn reference đến nhau nên reference counting của cả 2 object là khác 0, ARC sẽ không giải phóng 2 object và chúng ta bị memory leaks. Đây chính là hiện tượng reference cycles.

Chúng ta sẽ thêm đoạn code minh hoạ cho trường hợp này, thêm đoạn code sau vào playground:

class Phone {
    let model: String
    var owner: User?
    
    init(model: String) {
        self.model = model
        print("Phone \(model) is initialized")
    }
    
    deinit {
        print("Phone \(model) is being deallocated")
    }
}

Tiếp theo, sửa loại đoạn code "do" như sau:

do { let user1 = User(name: "John") let iPhone = Phone(model: "iPhone 6") }

Bên trên, chúng ta thêm class Phone với 2 hàm init(model:), deinit và 2 property model: String, owner: User. Trong đoạn code "do", chúng ta khởi tạo object iPhone. Hiện tại vẫn chưa có hiện tượng reference cycles xảy ra.

Tiếp theo, chúng ta viết thêm property và hàm sau cho class User:

    var phones: [Phone] = []
    func add(phone: Phone) {
        phones.append(phone)
        phone.owner = self
    }

Bây giờ, chúng ta sửa đoạn code "do" một chút như sau:

do {
    let user1 = User(name: "John")
    let iPhone = Phone(model: "iPhone 6")
    user1.add(phone: iPhone)
}

Nhìn vào output trong console log, chúng ta thấy chỉ có hàm init(name:) của User và init(model:) của Phone được gọi. 2 hàm deinit của 2 class này không đã không được gọi khi đoạn code "do" kết thúc.

Để ý code một tí, chúng ta có thể thấy, object iPhone có reference "owner" đến object user1, và object user1 cũng có reference "phones" đến object iPhone.

3. Weak reference - Unowned reference

Trong phần trên, chúng ta thấy reference cycles sẽ gây ra hiện tượng memory leaks. Tuy nhiên, việc 2 hay nhiều object có mối quan hệ qua lại lẫn nhau là việc thường xuyên xảy ra. Vì thế, để các object vẫn có thể có mối quan hệ qua lại lẫn nhau, và không bị hiện tượng reference cycles, Swift cung cấp cho chúng ta 2 kiểu reference khác để thực hiện việc này: Weak reference và Unowned reference.

Cả weak reference và unowned reference đều có chung một mục đích sử dụng: không làm tăng reference counting của object được reference. Chúng ta sẽ nói về sự khác nhau giữa weak và unowned reference sau, bây giờ chúng ta sẽ nói về cách dùng weak-unowned reference để tránh reference cycles trước. Chúng ta sửa property owner của class Phone một chút như sau:

    weak var owner: User?

Điều kỳ diệu đã xảy ra, reference cycles đã không còn nữa, và sau khi kết thúc đoạn code "do", cả 2 object user1 và iPhone đều được giải phóng. Chúng ta cùng xem sơ đồ sau:

Khi property owner của class Phone được khai báo dưới dạng weak (hoặc unowned) thì khi property này reference đến object user1, nó không làm tăng số reference counting của user1. Vì thế, sau khi đoạn code "do" kết thúc, reference counting của object user1 là 0 và bị giải phóng. Sau khi object user1 bị giải phóng, reference đến object iPhone cũng không còn, và object này cũng được giải phóng khỏi bộ nhớ.

Bây giờ, chúng ta sẽ tìm hiểu sự khác nhau giữa weak reference và unowned reference. Chúng ta sử dụng weak cho các optional property, và sử dụng unowned cho các property mà chúng ta chắc chắn rằng các property này có giá trị. Cách đặt property là weak hay unowned phụ thuộc vào cách chúng ta sử dụng property đó. Nếu chúng ta chắc chắn rằng khi chúng ta gọi đến property đó, object của property chưa bị giải phóng (hoặc chắc chắn đã được khởi tạo) thì chúng ta có thể dùng unowned, và ngược lại thì chúng ta dùng weak.

Bên trên, chúng ta sử dụng weak cho property owner của class Phone, bởi vì obect phone có thể được khởi tạo và không gán cho bất kỳ object User nào, và owner có thể là nil.

Bảng bên trên là thống kê các loại khác nhau mà strong, weak và unowned property có thể nhận.

  • Strong property: (property bình thường chúng ta hay sử dụng) có thể khai báo dạng var, let, và có thể là optional hoặc không
  • weak property: chỉ có thể khai báo dạng var, và bắt buộc là optional property
  • unowned property: có thể khai báo dạng var hoặc let, nhưng chắc chắn phải là Non-optional property

Như vậy, để không bị memory leaks do reference cycles, chúng ta có thể sử dụng cả weak reference và unowned reference. Việc sử dụng weak hay unowned tuỳ thuộc vào cách sử dụng của chúng ta trong từng trường hợp cụ thể.

4. Reference cycles với closure

Cũng giống như object, closure trong Swift cũng là kiểu reference, vì thế closure cũng có thể gây ra tình trạng reference cycles. Nếu như object của chúng ta có reference đến closure, và trong closure lại reference lại đến object, thì tình trạng reference cycles sẽ xảy ra.

Chúng ta thêm đoạn code sau vào class User:

    lazy var nameWithHelloWorld: () -> String = {
        self.name + " Hello World"
    }

Trong đoạn code trên, chúng ta thêm một property cho class User. Điều đặc biệt là ở chỗ property này là một closure. Chúng ta dùng "lazy" để property này sẽ không được gán giá trị cho tới khi nó được gọi đến.

Tiếp theo, chúng ta sửa đoạn code "do" như sau:

do { let user1 = User(name: "John") let iPhone = Phone(model: "iPhone 6") user1.add(phone: iPhone) print(user1.nameWithHelloWorld()) }


Để ý console log của playground, chúng ta thấy sau khi kết thúc chương trình, object user1 không hề được gọi đến deinit() để giải phóng bộ nhớ trả hệ thống, dẫn đến việc object iPhone cũng không được giải phóng. Nguyên nhân của việc này là class User có reference đến property "nameWithHelloWorld", và ngược lại, bên trong property "nameWithHelloWorld" lại reference ngược trở lại User(bằng cách sử dụng self trong closure). 

Cách sử lý để loại bỏ reference cycles trong trường hợp này hoàn toàn tương tự cách sử lý đối với các object ở bên trên, chúng ta sử dụng weak hoặc unowned để phá vỡ reference cycles. Bây giờ, chúng ta sửa lại bên trong closure của property nameWithHelloWorld một chút như sau:


```Swift
    lazy var nameWithHelloWorld: () -> String = {  [unowned self] in
        self.name + " Hello World"
    }

Với cách xử lý như trên, closure của chúng ta không còn làm tăng referencce counting của User nữa, và cả 2 sẽ lần lượt được giải phóng khỏi bộ nhớ.

III. Kết luận

Trên đây, chúng ta đã tìm hiểu về cách ARC hoạt động, tại sao hiện tượng memory leaks trên Swift lại xảy ra và cách để chúng ta tránh reference cycles để không bị leaks memory. Hi vọng bài viết này sẽ mang lại thông tin bổ ích cho các bạn, đặc biệt là các bạn new dev.

Cuối cùng, xin chúc các bạn một ngày làm việc vui vẻ, have a nice day ^_^!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí