Trao đổi với tôi

http://www.buidao.com

3/22/10

[Programming] Về Memory leaks trong MFC/C++ trên Windows

Những ai lập trình C có kinh nghiệm chắc hẳn đều thừa nhận rằng việc C/C++ giao hẳn trách nhiệm quản lí bộ nhớ cho người lập trình thực sự là một ưu điểm mạnh mẽ, và cũng chính là khuyết điểm lớn của ngôn ngữ này. Với tư tưởng đó, lập trình viên C++ luôn phải làm một công việc khó chịu và mất nhiều thời gian là phát hiện và loại trừ memory leaks trong chương trình. Entry này không trình bày cặn kẽ và chi tiết về memory leaks, mà chỉ là ghi lại một số kinh nghiệm mà người viết rút ra sau một ngày vì chán coding quá nên chuyển sang leaks detection. Hi vọng bài viết sẽ có ích với những người (và chỉ những người) đã có kinh nghiệm làm việc với C++, đã có những hiểu biết cơ bản về memory leaks như: tại sao lại có memory leaks, tại sao phải tránh memory leaks v.v...

Entry được tổ chức thành 3 phần. Phần thứ nhất nói về các kĩ thuật phát hiện memory leaks với Microsoft Visual C++ (MSVC). Phần 2 nói về một số kinh nghiệm tránh memory leaks khi làm việc với C++, MFC, GDI, GDI+. Trong phần này sẽ có một số vấn đề là cụ thể trên MFC/GDI, nhưng cũng có một số phần có thể áp dụng cho C/C++ nói chung. Phần 3 sẽ là một số địa chỉ đến các bài viết và công cụ hỗ trợ.

1. Phát hiện memory leaks với MSVC:

Trình biên dịch C++ của Microsoft đi kèm với thư viện C Runtime Library (CRT). Thư viện này cung cấp một số hàm và macro rất tốt cho việc phát hiện memory leaks. Ta sẽ điểm qua những công cụ cơ bản của CRT và cả MFC.

1.1. macro DEBUG_NEW (chỉ có trong MFC):

Macro này định nghĩa sẵn trong file afx.h, lập trình viên có thể dùng nó để thay thế cho từ khóa new. Trong chế độ Debug, macro này sẽ lưu vết (tên file, số dòng) của tất cả các vùng nhớ đã tạo. Trong chế độ Release, DEBUG_NEW được định nghĩa thành toán tử new bình thường, như vậy sẽ không ảnh hưởng tới hiệu năng của chương trình khi build ở chế độ Release.

Với macro này, nếu build ở chế độ Debug, sau khi chạy xong chương trình, nếu có memory leaks xảy ra, thông tin về những đối tượng này sẽ được xuất ra cửa sổ Output, ví dụ:
{1707} normal block at 0x026122C8, 12 bytes long.
Data: <+ > 2B 00 00 00 02 00 00 00 C0 0A 02 03

Dòng thông tin trên cho biết có một vùng nhớ ở vị trí 0x026122C8, dài 12 bytes, được cấp phát ở lượt 1707, bị leaks. (Cách đọc log xin xem phần 1.4).

Để sử dụng macro này, thay vì phải Find-and-Replace tất cả các từ khóa new thành DEBUG_NEW, ta có thể define new thành DEBUG_NEW như sau (đặt ở đầu các file cpp):
#ifdef _DEBUG
#define new DEBUG_NEW
#endif

Mặc định một số lớp do MFC tạo ra cũng được đặt sẵn các dòng define này.

1.2. Biến _crtBreakAlloc:

...

1.3. Snapshot bộ nhớ và dump đối tượng với lớp CMemoryState:

...

1.4. Cách đọc log khi dump object:

...

Trên đây là những mặt hỗ trợ chính của MSVC trong phát hiện memory leaks. Hiện giờ đang... lười nên chưa thể viết đầy đủ, người đọc quan tâm có thể xem trong [1].

2. Một số kĩ thuật để giảm memory leaks.

Điều quan trọng cần nhớ là lập trình viên phải đề phòng memory leaks ngay trong thời gian phát triển, vì mặc dù đã có nhiều công cụ hỗ trợ phát hiện leaks, nhưng thời gian và chi phí để phát hiện/loại trừ leaks sẽ tăng theo dung lượng và "mức độ rối rắm" của mã nguồn. Sau đây là một số kinh nghiệm mà người viết đúc kết được.

2.1. Kế thừa và các lớp tập hợp trong MFC:

Trong MFC hỗ trợ một số lớp tập hợp phục vụ cho mục đích tổng quát như CArray, CPtrArray... Ta phải hết sức thận trọng khi đưa con trỏ cácl ớp con vào các tập hợp này. Sau đây là một ví dụ:

class CParent
{
public:
~CParent(){TRACE("In Parent's destructor\r\n");}
};
class CChild : public CParent
{
public:
~CChild()
{
TRACE(L"In child's destructor.\r\n");
}
};

// cause mem. leaks!!!
CArray a;
a.Add(new CChild());
delete a.GetAt(0);


Khi gọi delete a.GetAt(0); trình biên dịch chỉ biết mọi phần tử của a đều là kiểu CParent*, do đó nó sẽ gọi trực tiếp destructor của lớp cha, bất kể con trỏ đang nằm tại vị trí 0 trong a có kiểu là CChild. Nếu như CChild có dữ liệu cần delete của riêng nó thì rõ ràng đoạn mã này sẽ gây memory leak.

Trước khi đọc tiếp, hãy thử nghĩ cách để hạn chế trường hợp này.

Cách giải quyết hợp lý nhất cho trường hợp này mà vẫn đảm bảo tính đa hình là: sửa lại khai báo destructor của lớp CParent thành virtual:
class CParent
{
public:
virtual ~CParent(){TRACE("In Parent's destructor\r\n");}
};

Sau khi sửa thành virtual, câu lệnh delete a.GetAt(0); sẽ thực thi destructor của CChild trước, rồi sau đó đến CParent, như đúng mong đợi.

Tuy nhiên hãy xem trường hợp sau, thay vì dùng CArray, ta dùng CPtrArray:
CPtrArray a;
a.Add(new CChild());
delete a.GetAt(0);

Kết quả sẽ còn tệ hại hơn, vì khi đó câu lệnh delete a.GetAt(0); không hề gọi destructor của CParent lẫn CChild. Lí do là CPtrArray không phải là lớp template, nó chỉ là danh sách các biến kiểu void*, do đó trình biên dịch không hề có thông tin gì về con trỏ đang nằm ở vị trí 0 trong mảng.
Cách duy nhất khả dĩ trong trường hợp này có lẽ là phải ép kiểu:
delete (CChild*)a.GetAt(0);
Rõ ràng đây là cách rất tệ, vì ta đang muốn tổng quát hóa các phần từ trong mảng a, nhưng đến khi delete lại phải biết thông tin cụ thể về các phần tử trong đó (thì mới ép kiểu rồi delete được).

Kinh nghiệm rút ra là:
  • Khi tạo lớp cha, luôn luôn đặt destructor là virtual.
  • Hạn chế dùng các lớp tập hợp dùng kiểu void* như CPtrArray, CPtrList, CMapWordToPtr... mà nên chuyển sang các lớp template như CTypedPtrList, CTypedPtrMap... vì các lớp này "type-safe" hơn. (Xem thêm về các lớp tập hợp trong MFC ở [2]). Hoặc thậm chí nên dùng STL để không phụ thuộc vào MFC.

2.2. GDI/GDI+:

Có một điều khó chịu là GDI và GDI+ không chịu làm việc với macro DEBUG_NEW đã nói ở phần 1.1. Lý do là bên trong GDI+ dùng các hàm GdipAlloc/GdipFree để cấp phát/giải phóng bộ nhớ. GDI thì còn phiền phức hơn, vì cách khởi tạo và xóa đối tượng tùy thuộc vào đối tượng ta đang thao tác (Ví dụ với HBITMAP thì dùng CreateBitmap/CreateCompatibleBitmap/DeleteObject... Với HMENU dùng CreateMenu/CreatePopupMenu/DestroyMenu... Với HICON dùng CreateIcon/DestroyIcon... Với HDC dùng CreateDC/GetDC/DeleteDC/ReleaseDC...) Chính vì vậy hình như có người viết hẳn một quyển (hay chương) sách chỉ nói về làm thế nào để lập trình GDI/GDI+ mà không bị leak.

Thực ra cá nhân người viết nhận thấy làm việc với GDI/GDI+ hoàn toàn không khó, nó chỉ yêu cầu sự tỉ mỉ và kiên trì. Ở đây liệt kê một số gợi ý:
  • Với GDI+, hạn chế dùng con trỏ ở mức tối đa. Ví dụ trong hàm OnPaint, khi cần vẽ bằng một SolidBrush thì đơn giản nên viết:
    SolidBrush br(...)
    chứ đừng nên màu mè:
    Brush* br = new SolidBrush(...)
    //...
    Như thế sẽ đỡ phải delete, và các đối tượng vẽ được tự giải phóng khi ra khỏi phạm vi của nó.
  • Với GDI, cần nhớ rõ khi tạo bằng hàm nào thì phải xóa bằng hàm tương ứng. Ví dụ tạo HDC bằng GetDC thì phải xóa bằng ReleaseDC, tạo bằng CreateDC/CreateCompatibleDC thì xóa bằng DeleteDC... Do các đối tượng trong GDI hầu hết đều là handle (như HPEN, HBRUSH, HBITMAP, HICON .v.v..) nên đã tạo thì buộc phải delete.
  • Các kĩ thuật tối ưu khi vẽ như double-buffer, cached... khi sử dụng phải lưu ý delete đúng lúc.
  • Ngoài ra có một số lập trình viên viết sẵn các lớp/hàm/macro hỗ trợ, điển hình là [3]

2.3. Làm việc với chuỗi


2.4. Các hàm thao tác trực tiếp lên bộ nhớ (memcpy, memmove...)


<Đuối wá, sẽ viết khi có thời gian ^^>

3. Tham khảo

[1] http://msdn.microsoft.com/en-us/library/c99kz476%28VS.80%29.aspx
[2] http://msdn.microsoft.com/en-us/library/942860sh%28VS.80%29.aspx
[3] http://www.codeproject.com/KB/GDI-plus/gdiplush.aspx

Có khá nhiều công cụ chuyên nghiệp và mạnh mẽ hỗ trợ performance profiling/memory tracing trong C++ như: AQTime ($518), Compuware, Deleaker. Tất cả đều là phần mềm thương mại. Cá nhân người viết từng dùng qua Deleaker, và kết quả khá hài lòng.

Lâu rồi mới viết một bài "ra hồn" ở đây. Hị vọng sau này sẽ khá hơn ^^

relink: http://phvu007.spaces.live.com/blog/cns!75F5081AE5375F06!283.entry