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 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.
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.
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.
// 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 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.
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.
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.
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 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.
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 (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 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ữ.
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ại | Khi nào sử dụng |
|---|---|---|
| Memory Debugger | Đồ thị trực quan | Kiểm tra thủ công sau khi điều hướng |
| Instruments Leaks | Phân tích tự động | Kiểm thử hồi quy, CI |
| deinit print | Ghi log thủ công | Phát triển, đánh giá mã |
| Malloc Scribble | Cờ runtime | Gỡ 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ỉ.
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.
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.
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.
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.
// 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 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.
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.
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.
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.
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
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.
Đọc thêm