Search…

C++11 - Smart Pointers - Quản Lý Tài Nguyên

18/09/202013 min read
Tìm hiểu về quản lý tài nguyên với smart pointer trong C++.

Quản lý tài nguyên

Đặt vấn đề

"With great power comes great responsibility"

Thường nghe lập trình viên C++, thậm chí những chuyên gia nói rằng quản lý bộ nhớ trong C++ là 1 vấn đề rất nhức óc vì phải tự giải phóng những vùng nhớ không còn được dùng nữa. Nhưng nghĩ lại điều đó liệu có đúng? Như những gì đã được biết thì việc cần làm chỉ là delete những vùng nhớ đã cấp phát. Khi xây dựng đối tượng chỉ cần thực hiện delete những vùng nhớ cấp phát bởi đối tượng đó trong destructor của nó. Vậy tại sao mọi người lại nghĩ nó phức tạp?

Đúng là nó sẽ không phức tạp cho đến lúc mở Visual Studio lên thì vấn đề xuất hiện: "Nếu 1 tài nguyên được sử dụng bởi nhiều đối tượng (ở nhiều nơi) trong chương trình thì thực hiện hủy tài nguyên đó ở đâu?”. Đó là khi đối mặt với vấn đề quản lý tài nguyên trong C++.

Đi tìm giải pháp

Nếu mỗi tài nguyên được sử dụng bởi 1 đối tượng, chỉ việc thực hiện giải phóng tài nguyên đó trong destructor của đối tượng. Nhưng mọi việc sẽ hoàn toàn khác khi có những tài nguyên chia sẻ, được sử dụng bởi nhiều đối tượng. Khi đó không biết phải thực hiện giải phóng tài nguyên đó ở đâu - hay nói cách khác, không biết đối tượng nào chịu trách nhiệm giải phóng tài nguyên đó.

Để giải quyết được vấn đề này cần sử dụng 1 khái niệm có mức trừu tượng hóa cao hơn (so với những khái niệm thường dùng trong lập trình) - Quyền sở hữu.

Từ thực tế vào lý thuyết

Tài nguyên là 1 khái niệm có lẽ đã quá quen thuộc (tài sản, nhà đất, hay bất cứ thứ gì có thể sở hữu đều có thể xem là tài nguyên). Khi sở hữu 1 tài nguyên nào đó, thì có quyền sở hữu đối với tài nguyên đó, nghĩa là có mọi quyền và trách nhiệm đối với tài nguyên. Quyền ở đây có thể bao gồm quyền sử dụng, quyền mua bán, quyền chuyển đổi, ... còn trách nhiệm ở đây là mọi trách nhiệm pháp lý đối với tài nguyên.

Khái niệm quyền sở hữu ra đời giúp quản lý tài nguyên tốt hơn. Khi đó biết chính xác đối tượng nào đang sở hữu nó, đối tượng nào có thể sử dụng và chịu trách nhiệm với nó.

Nếu có thể áp dụng khái niệm về quyền sở hữu đối với tài nguyên trong chương trình thì vấn đề quản lý tài nguyên trở nên dễ dàng hơn. Lúc này biết được đối tượng nào có quyền sở hữu tài nguyên, đối tượng nào có quyền sử dụng, và đối tượng nào chịu trách nhiệm thu hồi tài nguyên đó.

Từ lý thuyết vào lập trinh

Khái niệm quyền sở hữu tài nguyên xoay quanh vấn đề quyền và nghĩa vụ của đối tượng đó đối với 1 tài nguyên. Khi 1 đối tượng sở hữu tài nguyên, nó có quyền:

  • Sử dụng và hủy tài nguyên đó.
  • Chuyển quyền sở hữu cho đối tượng khác.
  • Hủy bỏ dữ liệu đó.

Regular Pointer và quyền sở hữu

Giả sử khi có 1 đối tượng, trong đó có 1 thuộc tính là con trỏ (regular pointer) trỏ đến tài nguyên, đối tượng này đã có thể sử dụng tài nguyên đó.

Có thể coi như đối tượng này đã sở hữu tài nguyên. Nhưng nếu xét theo đúng khái niệm, thì đối tượng này vẫn chưa thật sự nắm quyền sở hữu.

Nếu đối tượng trên tự cấp phát tài nguyên, và tài nguyên đó chỉ mình nó sử dụng, thì nó có thể thực hiện thu hồi trong destructor của mình. Tổng quát, tài nguyên đó có thể không do đối tượng này tạo ra, và không chỉ được sử dụng bên trong nó. Do vậy, trong phạm vi của mình thì nó không có cách nào để thực hiện trách nhiệm hủy đi tài nguyên khi tài nguyên này không còn được sử dụng nữa.

Vậy cần tới 1 loại con trỏ mới, thông minh hơn, để có thể giúp các đối tượng thật sự nắm được quyền sở hữu - bằng cách thực hiện nghĩa vụ hủy đi tài nguyên khi nó không còn được sử dụng nữa. Và đó cũng chính là lý do mà Smart pointer ra đời.

Smart Pointer trong C++

Smart pointer là 1 loại dữ liệu mới bổ sung thêm các khả năng regular pointer, kiểm tra truy xuất ngoài vùng được cấp phát, ...

Để đạt được điều này, thiết kế C++ đã tạo ra các đối tượng để đóng gói các regular pointer (các đối tượng này còn được gọi là proxy object, được thiết kế dựa theo proxy pattern). Để các đối tượng này hành xử như 1 regular pointer thật sự, các toán tử đặc trưng của pointer thông thường như *  và -> đều được override lại trong các đối tượng này. Nhờ vậy mà nó trông rất giống các regular pointer, và có thể sử dụng nó như là 1 regular pointer thật sự. Ngoài ra thì nó còn được định nghĩa để tự động xử lý các vấn đề liên quan đến quản lý bộ nhớ tự động.

Cách mà các smart pointer trong C++ hoạt động để thực hiện tự động quản lý tài nguyên, đều dựa vào khái niệm quyền sở hữu tài nguyên.

Trong C++ có 3 loại smart pointer chính là:

  • unique_ptr: đại diện cho quyền sở hữu duy nhất, nghĩa là tài nguyên mà unique_ptr quản lý chỉ có thể được sở hữu bởi duy nhất 1 đối tượng.
  • shared_ptr: đại diện cho quyền sở hữu chia sẻ, nghĩa là tài nguyên mà shared_ptr quản lý có thể được sở hữu bởi nhiều đối tượng.
  • weak_ptr: đại diện cho quyền sở hữu yếu, nghĩa là đối tượng nắm trong tay weak_ptr trỏ tới 1 tài nguyên chỉ có quyền được sử dụng tài nguyên đó, chứ không có quyền hủy đi tài nguyên.

Các loại smart pointer trên xuất hiện từ C++11, từ trước C++11 vẫn có 1 loại smart pointer nữa đó là auto_ptr, nhưng loại pointer này lạc hậu và không sử dụng nữa.

unique_ptr

Giới thiệu

Khi nhắc đến bất kì 1 smart pointer nào trong C++, thì điều đầu tiên phải đề cập đến là cách mà nó thực thi quyền sở hữu để thực hiện tự động quản lý bộ nhớ.

unique_ptr trong C++ đại diện cho quyền sở hữu duy nhất, nghĩa là tài nguyên mà unique_ptr trỏ tới chỉ được sở hữu bởi 1 đối tượng, và trên lý thuyết 1 tài nguyên chỉ được trỏ tới bởi duy nhất 1 unique_ptr (nếu lập trình viên không vi phạm nguyên tắc của unique_ptr). Có lẽ sẽ tự hỏi: "Tại sao lại giới hạn quyền sở hữu của tài nguyên mà unique_ptr quản lý là duy nhất?". Để trả lời cho câu hỏi đó phải xét trên trên ứng dụng cụ thể.

Xét ví dụ đối tượng Matrix quản lý 1 mảng dữ liệu matrix[], tài nguyên chiếm phổ biến nhất trong chương trình thông thường là tài nguyên chỉ được sở hữu bởi duy nhất 1 đối tượng tại 1 thời điểm (nếu biết cách sử dụng hiệu quả chúng), điển hình là các mảng cấp phát động, và các field member được cấp phát trên heap trong đối tượng, giả sử có 1 lớp Matrix, bên trong có con trỏ để trỏ tới mảng 2 chiều chứa dữ liệu của ma trận, và thường mảng 2 chiều này chỉ được sử dụng bên trong lớp Matrix, do đó tài nguyên này chỉ được sở hữu duy nhất bởi 1 đối tượng Matrix.

Điểm thứ 2 dễ dàng nhận ra, đó là về vấn đề performance, khi giới hạn được phạm vi của tài nguyên, quản lý nó 1 cách dễ dàng hơn, do đó việc quản lý sẽ gây tốn ít chi phí hơn.

Đặc điểm

unique_ptr đại diện cho quyền sở hữu duy nhất, tài nguyên mà nó trỏ tới chỉ được sở hữu bởi duy nhất 1 đối tượng, và tài nguyên đó cũng chỉ được trỏ tới bởi duy nhất 1 unique_ptr. Do đó không thể thực hiện gán thông thường (bằng copy constructor hoặc toán tử copy assignment) đối với unique_ptr, vì khi đó thì cả 2 unique_ptr sẽ cùng quản lý 1 tài nguyên, vi phạm nguyên tắc của unique_ptr. Thay vào đó, để chuyển tài nguyên mà unique_ptr này sang unique_ptr kia, sử dụng move constructor hoặc toán tử move assignment.

Chi phí sử dụng

Các pointer được sử dụng rất thường xuyên trong chương trình, do đó chi phí (overhead) của nó là 1 điều cần phải quan tâm đầu tiên, vì dù nó chỉ gây tốn lượng chi phí nhỏ nhưng cũng có thể đủ để làm chậm đáng kể performance của chương trình.

Thực tế, unique_ptr chỉ quản lý các tài nguyên sở hữu duy nhất, nên định nghĩa nó rất dễ dàng, hầu như nó hoàn toàn không tốn thêm bất kỳ chi phí nào so với khi sử dụng regular pointer. Bên trong unique_ptr chỉ chứa 1 con trỏ để trỏ tới tài nguyên mà nó quản lý (trừ trường hợp có sử dụng thêm deleter cho unique_ptr) nên hầu như kích thước của unique_ptr cũng bằng kích thước của 1 regular pointer. Ngoài ra việc truy xuất giá trị sử dụng toán tử *, và truy xuất thành viên sử dụng toán tử -> của unique_ptr chỉ tốn thêm 1 lần truy xuất bộ nhớ so với regular pointer. Do vậy, hầu như có thể dùng unique_ptr để thay thế hoàn toàn cho regular pointer đối với các tài nguyên sở hữu duy nhất, vì lợi ích mà nó mang lại lớn hơn rất nhiều so với chi phí cần thiết.

shared_ptr

Giới thiệu

Khác với unique_ptr, shared_ptr sẽ đại diện cho quyền sở hữu chia sẻ. Nghĩa là tài nguyên mà shared_ptr trỏ tới là tài nguyên chia sẻ, có thể được sở hữu bởi nhiều đối tượng cùng 1 lúc. Nhờ shared_ptr mà có thể dễ dàng quản lý các tài nguyên được sử dụng bởi nhiều đối tượng 1 cách dễ dàng. Khi sử dụng shared_ptr với các loại tài nguyên này, hoàn toàn không còn cần phải quan tâm việc thu hồi nó, không còn phải nhức óc để nghĩa xem tài nguyên đó sẽ phải được thu hồi khi nào, và ở đâu.

shared_ptr được dùng để quản lý các tài nguyên được chia sẻ bởi nhiều đối tượng, nhưng vẫn có thể dùng nó với tất cả các loại tài nguyên khác, shared_ptr như 1 "smart pointer tổng quát", có thể sử dụng trong tất cả các trường hợp, và có thể dùng shared_ptr để thay thế hoàn toàn raw pointer, và coi nó là 1 công cụ quản lý tài nguyên tự động cực kì hiệu quả. Nhưng trường hợp tổng quát bao giờ cũng là trường hợp có mức tối ưu trung bình. shared_ptr có thể gây tốn rất nhiều chi phí và việc lạm dụng nó thật sự sẽ làm giảm performance 1 cách đáng kể.

Chi phí sử dụng

Tài nguyên chia sẻ là tài nguyên có thể được sử dụng bởi nhiều đối tượng trong chương trình, việc quản lý các tài nguyên này rất khó khăn và gây tốn nhiều chi phí. Để quản lý loại tài nguyên này, shared_ptr được định nghĩa phức tạp hơn unique_ptr, sử dụng cơ chế reference counting để đảm bảo khi không còn shared_ptr quản lý tài nguyên đó nữa thì tài nguyên sẽ bị thu hồi, và sử dụng biến atomic để bảo đảm thread-safe khi tài nguyên đó được sử dụng bởi các đối tượng nằm ở các thread khác nhau.

Do vậy, tùy trường hợp cụ thể và cách tài nguyên đó được sở hữu (duy nhất hay chia sẻ?) để quyết định có nên sử dụng shared_ptr hay không?

Vấn đề gặp phải

shared_ptr làm giúp việc tự động quản lý tài nguyên, để giúp giảm thiểu các vấn đề như memory leak. Nhưng cơ chế reference counting mà shared_ptr sử dụng vẫn có 1 nhược điểm lớn, nó gây ra memory leak nếu gặp phải trường hợp nhất định. Bản thân cơ chế reference counting thực hiện việc thu hồi tài nguyên tự động dựa vào số lượng con trỏ đang trỏ tới tài nguyên đó, khi số lượng con trỏ này về 0 thì tài nguyên sẽ được thu hồi.

Giả sử có 2 tài nguyên AB (là 2 đối tượng), và 2 tài nguyên này sở hữu lẫn nhau (A có 1 shared_ptr quản lý B, và B có 1 shared_ptr quản lý A), khi đó reference counter (biến để lưu giữ số lượng con trỏ hiện tại trỏ đến tài nguyên đó) của cả 2 shared_ptr trong AB không bao giờ về 0, gây ra memory leak, vì AB không bao giờ được thu hồi tự động. Tình huống này được người ta gọi là "Circular references".

Để giải quyết vấn đề về circular references, C++ xuất hiện thêm 1 loại smart pointer nữa, gọi là weak_ptr.

weak_ptr

Giới thiệu

weak_ptr đại diện cho quyền sở hữu yếu, nghĩa là đối tượng sở hữu tài nguyên bằng weak_ptr chỉ có quyền sử dụng chứ không có quyền thu hồi tài nguyên. Nói cách khác, cơ chế reference counting chỉ dựa vào số lượng shared_ptr đang trỏ tới tài nguyên, chứ không quan tâm tới tài nguyên đó đang được trỏ tới bởi bao nhiêu weak_ptr, dù đang có 5 weak_ptr đang trỏ tới tài nguyên đó, nhưng không còn shared_ptr nào trỏ tới nữa thì tài nguyên đó vẫn bị thu hồi.

Do vậy, weak_ptr được sử dụng với các shared_ptr trong trường hợp circular dependencies (có thể gây nên circular references).

Chi phí sử dụng

weak_ptr cũng có 1 điểm yếu rất lớn khi sử dụng, mỗi lần sử dụng tài nguyên mà weak_ptr tham chiếu đến, cần phải thực hiện câu lệnh lock() để tạo ra 1 shared_ptr trỏ tới tài nguyên đó, chi phí để copy-constructing 1 shared_ptr (tạo ra 1 đối tượng shared_ptr bằng copy constructor của nó thông qua 1 shared_ptr khác đã tham chiếu tài nguyên) là rất lớn so với các câu lệnh tính toán thông thường.

Kết luận

C++11 có 3 loại smart pointers để phục vụ cho những mục đích khác nhau, tùy vào cách mà tài nguyên đó được sở hữu để lựa chọn loại smart pointer phù hợp, tránh lỗi cũng như giảm thiểu chi phí khi sử dụng nó. Do vậy, việc hiểu rõ về smart pointer và cách nó hoạt động là rất cần thiết để sử dụng đúng cách và hiệu quả.

IO Stream

IO Stream Co., Ltd

30 Trinh Dinh Thao, Hoa Thanh ward, Tan Phu district, Ho Chi Minh city, Vietnam
+84 28 22 00 11 12
developer@iostream.co

383/1 Quang Trung, ward 10, Go Vap district, Ho Chi Minh city
Business license number: 0311563559 issued by the Department of Planning and Investment of Ho Chi Minh City on February 23, 2012

©IO Stream, 2013 - 2024