Nói Chuyện Về Chủ Đề GC Một Chút - nói dối e blog

Nói Chuyện Về Chủ Đề GC Một Chút

Hôm nay lúc trò chuyện phiếm với đồng nghiệp, chúng tôi tình cờ nhắc đến việc GAE SDK chính thức hỗ trợ ngôn ngữ Go. Đây quả là tin vui cho cộng đồng yêu thích Go. Hầu hết mọi người đều tin rằng Go sẽ có hiệu năng vượt trội hơn Python nhờ cơ chế biên dịch trực tiếp thành mã máy. Về mặt hiệu suất phát triển, Go cũng không hề kém cạnh Python, thậm chí còn có thể là lựa chọn tối ưu hơn cả Java khi cân bằng giữa tốc độ phát triển và hiệu năng thực thi.

Điều này khiến tôi nhớ lại buổi thảo luận ở Bắc Kinh với các bạn từ Douban về chủ đề Go. Trong buổi trò chuyện, một lập trình viên C++ đã đặt câu hỏi về cơ chế thu gom rác (GC). Điều này hoàn toàn dễ hiểu bởi các lập trình viên C++ thường ít tiếp xúc với khái niệm này. Tôi đã dành khoảng 10 phút giải thích các thuật toán GC cơ bản như quét gốc (root tracing), đánh dấu ba màu (tri-color marking), chiến lược di chuyển hoặc giữ nguyên vùng nhớ, v.v. Thời điểm đó tôi đang nghiên cứu cơ chế GC trong Lua nên đã tham khảo khá nhiều tài liệu chuyên sâu.

Trong bữa ăn, Davies chia sẻ rằng anh phải tối ưu hóa hiệu năng cho proxy của hệ thống beansdb viết bằng Go do chi phí GC gây ra. Về nhà, tôi lập tức kiểm tra kho mã nguồn của Go và thấy rõ trong lộ trình phát triển, nhóm phát triển Go đang có kế hoạch cải tiến đáng kể cơ chế GC. Nhìn từ góc độ khác, giải pháp GC của Python cũng có nhiều điểm đáng học hỏi. Python kết hợp đếm tham chiếu để giải phóng bộ nhớ ngay lập tức với cơ chế quét chu kỳ để dọn dẹp các tham chiếu vòng. Cách tiếp cận này giúp kiểm soát tốc độ tăng bộ nhớ tạm thời trong quá trình chạy. Thậm chí, lập trình viên có thể chủ động tránh tạo ra tham chiếu vòng như cách quản lý thủ công trong C++.

Từ đây, tôi nhận ra rằng cả hai mô hình quản lý bộ nhớ thủ công (C/C++) và tự động (GC) đều có ưu điểm riêng, quan trọng là thói quen sử dụng của lập trình viên. Về hiệu năng, mỗi phương pháp đều có mặt mạnh yếu riêng. Đếm tham chiếu không hẳn rẻ như nhiều người tưởng, và các thuật toán GC quét chu kỳ cũng không đến mức làm chậm hệ thống nếu được thiết kế tốt. Ngay cả mô hình quản lý stack memory “thần thánh” của C/C++ cũng có giới hạn của nó. Khi stack đủ lớn để giả định có thể sử dụng vô tận, thì việc tạo ra hàng ngàn goroutine như trong Go nếu áp dụng mô hình stack của C sẽ phải đối mặt với vấn đề tràn stack. Ngay cả lập trình viên C/C++ giàu kinh nghiệm cũng luôn cẩn trọng tránh lỗi stack overflow.

Vài ngày trước, tôi viết một module nhỏ bằng C để phân tích và chuẩn hóa đường dẫn chứa các ký hiệu ./ ../. Dù đây là bài toán đã được lặp đi lặp lại hàng nghìn lần trong lịch sử lập trình, nhưng việc xử lý hàng loạt trường hợp đặc biệt vẫn khiến tôi phải đau đầu. Chỉ vài trăm dòng mã nhưng phải thiết kế hàng chục test case để kiểm tra từng tình huống biên.

Tôi chợt nghĩ, nếu dùng Go hoặc ngôn ngữ hiện đại khác, việc này sẽ đơn giản hơn nhiều. Chắc chắn tôi sẽ dùng hàm Split trong package strings để tách chuỗi theo ký tự /, sau đó lọc và xử lý các phần tử . và .. Chỉ cần vài dòng code là xong. Nếu suy nghĩ theo hướng này, tôi hoàn toàn có thể làm tương tự trong C mà không cần vật lộn với việc xử lý chuỗi trong cùng một buffer. Nhưng tại sao tôi không làm vậy? Có lẽ vì tư duy của một lập trình viên C luôn hướng đến việc giải quyết bài toán với không gian O(1) và thời gian O(n), tránh tạo ra các đối tượng tạm thời rồi phải giải phóng chúng. Khi string không phải kiểu dữ liệu first-class, tôi buộc phải suy nghĩ ở mức độ thấp hơn, phải quan tâm đến từng byte bộ nhớ.

Ngược lại, khi chuyển sang vai trò lập trình viên Go, tôi sẽ giao toàn bộ gánh nặng quản lý bộ nhớ cho compiler và GC. Dù biết rằng code của tôi sẽ tạo ra hàng loạt đối tượng tạm thời, nhưng tôi tin tưởng vào cơ chế tự động của ngôn ngữ. Dữ liệu sẽ được sao chép và xử lý ở các module底层 (như strings package). Thực tế, trong Go hoàn toàn có thể áp dụng thuật toán tương tự như trong C, nhưng thói quen sử dụng ngôn ngữ sẽ định hình cách tiếp cận.

Quay lại vấn đề của Davies, tôi cho rằng nếu suy nghĩ kỹ, có thể tránh dùng các hàm trong package unsafe để gọi trực tiếp malloc/free. Thay vào đó, xây dựng một pool buffer hợp lý sẽ giúp kiểm soát bộ nhớ hiệu quả hơn. Khi lập trình với các ngôn ngữ có GC tích hợp như Lua/Python/Go, chỉ cần lưu tâm một chút là có thể hạn chế việc tạo ra quá nhiều đối tượng tạm thời, từ đó giảm gánh nặng cho GC. Tuy nhiên, ít dự án thực hiện được điều này, bởi chính tư duy do ngôn ngữ mang lại sẽ quyết định cách bạn viết code.

Một so sánh thú vị khác là về STL trong C++. Nhìn bề ngoài, STL dường như rất hiệu quả, đặc biệt khi bạn đọc qua mã nguồn implement của nó. Tuy nhiên, hiếm lập trình viên C++ nào thừa nhận việc sử dụng STL làm chậm ứng dụng của họ. Những lập trình viên C++ chuyên nghiệp thường coi thường các phiên bản mô phỏng STL kém hiệu quả, họ cổ xúy việc dùng std::vector thay vì mảng kiểu C, hay tận dụng các hàm trong std::algorithm.

Cách đây vài năm, tôi từng so sánh hai dự án tương đồng: một viết theo phong cách C thuần trong C++, một dùng STL triệt để. Qua công cụ đo lường bộ nhớ, tôi phát hiện dự án theo phong cách C chỉ thực hiện khoảng 1/3 số lần cấp phát bộ nhớ so với bản STL. Từ đó tôi hiểu ra rằng, khác biệt về hiệu năng giữa C++ và C không nằm ở mã máy do compiler sinh ra, mà ở tư duy lập trình mà ngôn ngữ định hình cho người dùng. Vì vậy, đừng quá mê tín vào các báo cáo benchmark với đoạn code ngắn được tối ưu kỹ lưỡng - chúng không phản á

0%