Retain Cycle — bản chất, nguyên nhân và cách loại bỏ trong phát triển ứng dụng

Tác giả: IT Sectr Đã đăng: 2026-03-29 Thời gian đọc: 8 phút

Retain Cycle là một tình huống trong ARC khi hai hoặc nhiều đối tượng tham chiếu lẫn nhau thông qua các tham chiếu mạnh, tạo thành một vòng khép kín. Theo Apple Memory Management Guide, 2026, retain cycle chặn việc giải phóng tất cả các đối tượng trong vòng vì mỗi đối tượng có retain count ≥ 1. Khác với rò rỉ bộ nhớ trong GC, retain cycle đảm bảo các đối tượng vẫn sống miễn là có ít nhất một thành viên bên ngoài của vòng còn sống — và ngay cả sau khi mất tất cả các tham chiếu bên ngoài, nếu vòng được cô lập.

Những điểm chính

  • Retain Cycle — một chuỗi tham chiếu mạnh khép kín khiến ARC không thể giải phóng các đối tượng
  • Nguyên nhân — hai (hoặc nhiều) đối tượng giữ tham chiếu mạnh lẫn nhau, không thể đưa retain count về 0
  • Hậu quả — rò rỉ bộ nhớ: các đối tượng ở lại trong bộ nhớ mãi mãi, tiêu thụ RAM tăng
  • Giải pháp — thay thế một trong các tham chiếu mạnh trong vòng bằng weak hoặc unowned
  • Chẩn đoán — Xcode Memory Debugger, Instruments Leaks, Debug Memory Graph

Retain Cycle là gì?

Retain Cycle là một tình huống trong đó hai hoặc nhiều đối tượng sở hữu lẫn nhau thông qua các tham chiếu mạnh, tạo ra một đồ thị phụ thuộc khép kín. ARC không thể giải phóng bất kỳ đối tượng nào vì retain count của mỗi đối tượng luôn ≥ 1: đối tượng A giữ B, B giữ A, và bộ đếm của chúng không bao giờ về 0.

Vấn đề chỉ xảy ra trong các hệ thống đếm tham chiếu (ARC, MRR). Trong Garbage Collection, bộ thu gom xác định tính không thể truy cập thông qua đồ thị tham chiếu từ tập gốc — các vòng không phải là trở ngại. Trong ARC, tuy nhiên, một vòng tương đương với rò rỉ, bởi vì việc giải phóng xác định bằng cách đếm không thể giải quyết các phụ thuộc vòng.

Theo WWDC 2012 Session 406, retain cycle là nguyên nhân phổ biến nhất của rò rỉ bộ nhớ trong các ứng dụng Objective-C và Swift. Các kịch bản điển hình: mối quan hệ cha-con với delegate, closure bắt self, và kiến trúc phân lớp với mối quan hệ hai chiều.

Ví dụ về retain cycle trong phát triển iOS

Hãy xem xét các kịch bản retain cycle cổ điển mà mọi nhà phát triển iOS đều gặp phải. Hiểu các mẫu này là nền tảng để viết mã an toàn với ARC.

Parent-Child với delegate

Kịch bản cổ điển: một đối tượng cha (ví dụ, UIViewController) tạo một đối tượng con và trở thành delegate của nó. Nếu cả hai sử dụng tham chiếu mạnh, một retain cycle sẽ xảy ra. Giải pháp — delegate phải là weak.

swift
// LỖI: retain cycle qua delegate mạnh
protocol ChildDelegate: AnyObject { }

class ParentVC: UIViewController, ChildDelegate {
    var child: ChildVC?

    func showChild() {
        child = ChildVC()
        child?.delegate = self        // Parent → Child (strong)
    }                                 // Child → Parent (strong qua delegate)
}                                     // ⚠️ Retain cycle!

class ChildVC: UIViewController {
    var delegate: ChildDelegate?    // ❌ strong mặc định
}

// SỬA: delegate weak
class ChildVC: UIViewController {
    weak var delegate: ChildDelegate? // ✅ weak — không giữ
}

Trong ví dụ, ParentVC giữ một tham chiếu mạnh đến ChildVC thông qua thuộc tính child. ChildVC giữ một tham chiếu mạnh đến ParentVC thông qua delegate. Vòng đã khép. Sữa: weak var delegate — tham chiếu không làm tăng retain count, và ParentVC có thể được giải phóng.

NSTimer và retain cycle

NSTimer là một nguồn gốc cổ điển của retain cycle. Timer giữ target của nó (thường là self), và target giữ timer thông qua một thuộc tính. Ngay cả khi timer là dùng một lần, nó sẽ không được giải phóng cho đến khi invalidate được gọi. Giải pháp: luôn gọi timer.invalidate() trong deinit hoặc viewDidDisappear.

Kiến trúc phân lớp

Trong các kiến trúc có quyền sở hữu xếp tầng (coordinators, routers), các vòng nhiều bước thường xảy ra: Coordinator → ViewController → ViewModel → Coordinator (thông qua callback). Mỗi tham chiếu mạnh trong chuỗi phải được chọn một cách có ý thức — một tham chiếu weak ở bất kỳ mắt xích nào sẽ phá vỡ vòng.

Retain Cycle trong closure Swift

Các closure trong Swift bắt các biến bên ngoài bằng tham chiếu mạnh. Nếu một closure được lưu trữ như một thuộc tính của đối tượng (ví dụ, completion handler) và bắt self, nó sẽ tạo ra một retain cycle: self → closure → self.

Đây là nguồn phổ biến nhất của retain cycle trong phát triển Swift hiện đại. Nó xảy ra một cách ngầm định — nhà phát triển có thể không nhận thấy việc bắt self trong closure, đặc biệt khi sử dụng cú pháp rút gọn không có self rõ ràng.

swift
class DownloadService {
    var onComplete: ((Data) -> Void)?
    var result: Data?

    func startDownload() {
        // ❌ Retain cycle: self → onComplete → self
        onComplete = { data in
            self.result = data
            self.notifyUI()
        }

        // ✅ Sữa: capture list với weak self
        onComplete = { [weak self] data in
            guard let self else { return }
            self.result = data
            self.notifyUI()
        }
    }

    func notifyUI() { }
}

Danh sách bắt [weak self] tạo một tham chiếu yếu đến self bên trong closure. Nếu DownloadService được giải phóng trước khi closure thực thi, self trở thành nil, và mã thoát ra một cách an toàn thông qua guard. Đây là mẫu tiêu chuẩn cho các closure bất đồng bộ trong Swift — nên được sử dụng bất cứ khi nào một closure được lưu trữ như một thuộc tính.

Unowned self trong closure

unowned self là một thay thế cho weak self khi self được đảm bảo sống lâu hơn closure. Ví dụ: các closure đồng bộ thực thi ngay lập tức (sorted, filter). Trong những trường hợp này, self chắc chắn còn sống và unowned an toàn. Tuy nhiên, unowned sẽ crash khi truy cập vào một đối tượng đã được giải phóng — do đó, weak được coi là lựa chọn an toàn mặc định.

Cách phát hiện retain cycle: công cụ chẩn đoán

Phát hiện retain cycle ở giai đoạn đầu là cực kỳ quan trọng đối với hiệu suất ứng dụng. Hãy xem xét các công cụ và kỹ thuật chính để xác định các tham chiếu vòng trong phát triển iOS.

Xcode Memory Debugger

Xcode Memory Debugger (Debug Memory Graph) là một công cụ trực quan hiển thị đồ thị các đối tượng trong bộ nhớ với các tham chiếu của chúng. Một retain cycle xuất hiện như một chuỗi mũi tên mạnh khép kín. Để khởi chạy: nhấp vào nút Debug Memory Graph trong bảng Debug area khi ứng dụng đang chạy. Mỗi đối tượng được hiển thị với kiểu, địa chỉ và danh sách tham chiếu.

Instruments Leaks

Instruments Leaks là một profiler để tự động phát hiện rò rỉ. Nó ghi lại các cấp phát và phân tích đồ thị tham chiếu theo thời gian thực. Nó không chỉ phát hiện retain cycle mà còn cả các tham chiếu bị bỏ quên, ViewController không được giải phóng và các rò rỉ khác. Leaks chỉ ra đối tượng chính xác và chuỗi giữ.

Ghi log deinit

Phương pháp đơn giản nhất là thêm một print trong deinit của mỗi lớp chính. Nếu deinit không được gọi khi đối tượng đáng lẽ phải bị hủy, thì có một retain cycle. Phương pháp này không yêu cầu công cụ và hiệu quả cho chẩn đoán ban đầu.

Công cụLoạiKhi nào sử dụng
Memory DebuggerĐồ thị trực quanKiểm tra thủ công sau khi điều hướng
Instruments LeaksPhân tích tự độngKiểm thử hồi quy, CI
deinit printGhi log thủ côngPhát triển, đánh giá mã
Malloc ScribbleCờ runtimeGỡ lỗi use-after-free

Cách tiếp cận được khuyên dùng: sử dụng ghi log deinit trong quá trình phát triển, Memory Debugger trong kiểm thử thủ công và Instruments Leaks trong đường ống CI/CD để tự động phát hiện hồi quy rò rỉ.

Phòng ngừa retain cycle và các thực hành tốt nhất

Ngăn chặn retain cycle dễ hơn là sửa chữa chúng trong sản xuất. Dưới đây là một số quy tắc giảm thiểu rủi ro tham chiếu vòng.

Quy tắc delegate weak

Tất cả các delegate và dataSource phải là weak. Quy tắc này đã được tích hợp trong UIKit: tất cả các giao thức delegate trong Apple SDK được khai báo với thuộc tính weak (UITableView.delegate, UICollectionView.dataSource). Đối với các giao thức của riêng bạn, hãy sử dụng weak var delegate: MyDelegate? và kế thừa giao thức từ AnyObject.

Danh sách bắt trong closure

Bất kỳ closure nào được lưu trữ như một thuộc tính (completion handler, callback) và bắt self phải sử dụng [weak self] trong danh sách bắt. Ngoại lệ là các closure thực thi ngay lập tức và không được lưu trữ (sorted, map, filter). Đối với chúng, unowned self là an toàn.

Đánh giá kiến trúc

Trong các kiến trúc phức tạp (VIPER, Coordinators, Redux), hãy theo dõi hướng của các tham chiếu mạnh. Chủ sở hữu giữ tham chiếu mạnh đến cấp dưới, nhưng cấp dưới chỉ nên tham chiếu đến chủ sở hữu thông qua weak hoặc unowned. Luồng dữ liệu một chiều đơn giản hóa việc quản lý tham chiếu.

swift
// Ví dụ: kiểm tra với ghi log deinit
class BaseViewController: UIViewController {
    deinit {
        print("✅ \(type(of: self)) deallocated")
    }
}

// Sử dụng: tất cả ViewController kế thừa BaseViewController
class ProfileVC: BaseViewController {
    var viewModel: ProfileViewModel?
    var onLogout: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()
        onLogout = { [weak self] in
            self?.dismiss(animated: true)
        }
    }
}
// Khi đóng ProfileVC, mong đợi "✅ ProfileVC deallocated" trong console

Một lớp cơ sở với ghi log deinit cung cấp phản hồi tức thời. Nếu thông báo không xuất hiện khi màn hình đáng lẽ phải đóng, thì có retain cycle trong lớp này. Thêm thực hành này vào mẫu dự án cho tất cả các ViewController.

Câu hỏi thường gặp

Retain Cycle khác với rò rỉ bộ nhớ trong GC như thế nào?

Retain Cycle là một vấn đề đặc thù của ARC nơi một vòng khép kín của các tham chiếu mạnh chặn việc giải phóng. Trong GC, bộ thu gom phân tích tính khả truy cập từ tập gốc, không phải bộ đếm tham chiếu — do đó các vòng không phải là rò rỉ. Trong ARC, tuy nhiên, bất kỳ vòng cô lập nào cũng là một rò rỉ chắc chắn.

Tham chiếu weak phá vỡ retain cycle như thế nào?

Một tham chiếu weak không làm tăng retain count của một đối tượng. Nếu bạn thay thế một trong các tham chiếu mạnh trong vòng bằng weak, retain count của mỗi đối tượng có thể về 0. Sau khi đối tượng được giải phóng, tham chiếu weak tự động được đặt thành nil, ngăn chặn việc truy cập vào bộ nhớ đã giải phóng.

Một retain cycle có thể bao gồm ba hoặc nhiều đối tượng không?

Có, một retain cycle có thể bao gồm bất kỳ số lượng đối tượng nào: A → B → C → A. Để phá vỡ nó, bạn chỉ cần phá vỡ một mắt xích trong vòng — thay thế bất kỳ tham chiếu mạnh nào bằng weak hoặc unowned. Các công cụ hiển thị toàn bộ đồ thị, không chỉ các cặp đối tượng.

Tại sao GCD DispatchWorkItem không tạo ra retain cycle?

GCD (Grand Central Dispatch) không lưu trữ closure sau khi thực thi. DispatchWorkItem được thực thi và giải phóng, ngay cả khi closure bắt self. Retain cycle chỉ xảy ra khi một closure được lưu trữ như một thuộc tính (completion handler trong một lớp), không phải khi nó được truyền vào một hàng đợi.

Những loại retain cycle nào không được Instruments phát hiện?

Instruments Leaks không phải lúc nào cũng tìm thấy retain cycle tạm thời (chỉ tồn tại trong giây lát) hoặc tham chiếu vòng trong các đối tượng C/C++ thông qua bridging. Để kiểm tra đầy đủ, hãy sử dụng Memory Debugger thủ công cùng với ghi log deinit của tất cả các đối tượng chính trong scene.

Tổng kết

  • Retain Cycle — một chuỗi tham chiếu mạnh khép kín chặn việc giải phóng đối tượng trong ARC
  • Nguyên nhân — delegate với tham chiếu mạnh, closure bắt self, quan hệ cha-con hai chiều
  • Giải pháp — thay thế một tham chiếu mạnh bằng weak hoặc unowned phá vỡ vòng
  • Closure — completion handler được lưu trữ phải luôn sử dụng [weak self]
  • Delegate — luôn weak; giao thức delegate phải kế thừa từ AnyObject
  • Phát hiện — Xcode Memory Debugger, Instruments Leaks, ghi log deinit
  • Phòng ngừa — luồng dữ liệu một chiều, delegate weak, danh sách bắt, lớp cơ sở với deinit

Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay

IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.

Thảo luận dự án

Đọc thêm