Giới thiệu
Hướng dẫn này nhằm giúp những người dùng Go ở mức nâng cao hiểu rõ hơn về chi phí của ứng dụng, thông qua việc cung cấp hiểu biết sâu về Go garbage collector (bộ thu gom rác). Tài liệu cũng chỉ ra cách người dùng Go có thể vận dụng những hiểu biết đó để cải thiện mức sử dụng tài nguyên của ứng dụng. Tài liệu không giả định bạn có sẵn kiến thức về garbage collection, nhưng có giả định bạn đã quen thuộc với ngôn ngữ lập trình Go.
Ngôn ngữ Go nhận trách nhiệm sắp xếp nơi lưu trữ các giá trị Go; trong hầu hết trường hợp, lập trình viên Go không cần quan tâm những giá trị đó được lưu ở đâu, hay vì sao lại lưu ở đó. Tuy nhiên, trên thực tế các giá trị này thường phải được lưu trong bộ nhớ vật lý của máy tính, mà bộ nhớ vật lý là tài nguyên hữu hạn. Vì hữu hạn, bộ nhớ phải được quản lý cẩn thận và tái sử dụng để tránh cạn kiệt trong lúc chương trình Go đang chạy. Việc cấp phát và tái sử dụng bộ nhớ khi cần là nhiệm vụ của phần hiện thực Go.
Một thuật ngữ khác cho việc tự động tái sử dụng bộ nhớ là garbage collection. Ở mức tổng quát, một garbage collector (hay gọi tắt là GC) là một hệ thống tái sử dụng bộ nhớ thay cho ứng dụng bằng cách xác định những phần bộ nhớ nào không còn cần thiết nữa. Bộ toolchain chuẩn của Go cung cấp một thư viện runtime đi kèm mọi ứng dụng, và thư viện runtime này bao gồm một garbage collector.
Lưu ý rằng sự tồn tại của một garbage collector như mô tả trong hướng dẫn này không được đặc tả Go bảo đảm; đặc tả chỉ bảo đảm rằng phần lưu trữ bên dưới cho các giá trị Go được chính ngôn ngữ quản lý. Sự bỏ ngỏ này là có chủ đích và cho phép sử dụng những kỹ thuật quản lý bộ nhớ hoàn toàn khác biệt.
Do đó, hướng dẫn này nói về một phần hiện thực cụ thể của ngôn ngữ lập trình Go và có thể không áp dụng cho các phần hiện thực khác. Cụ thể, hướng dẫn dưới đây áp dụng cho toolchain chuẩn (trình biên dịch Go gc và các công cụ đi kèm). Gccgo và Gollvm đều dùng phần hiện thực GC rất tương tự nên nhiều khái niệm vẫn đúng, nhưng chi tiết có thể khác.
Hơn nữa, đây là một tài liệu sống và sẽ thay đổi theo thời gian để phản ánh chính xác nhất bản phát hành Go mới nhất. Tài liệu này hiện mô tả garbage collector tính đến Go 1.19.
Các giá trị Go nằm ở đâu
Trước khi đi sâu vào GC, hãy bàn trước về phần bộ nhớ không cần GC quản lý.
Chẳng hạn, các giá trị Go không phải pointer được lưu trong biến cục bộ nhiều khả năng sẽ không do Go GC quản lý chút nào; thay vào đó Go sẽ thu xếp cấp phát bộ nhớ gắn với phạm vi từ vựng nơi giá trị được tạo ra. Nhìn chung, cách này hiệu quả hơn việc dựa vào GC, bởi trình biên dịch Go có thể xác định trước thời điểm bộ nhớ đó được giải phóng và phát sinh chỉ thị máy để dọn dẹp. Thông thường, chúng tôi gọi cách cấp phát bộ nhớ này cho các giá trị Go là “stack allocation”, vì vùng nhớ được lưu trên stack của goroutine.
Những giá trị Go mà bộ nhớ không thể cấp phát theo cách này, vì trình biên dịch Go không xác định được vòng đời của chúng, được gọi là escape to the heap (thoát ra heap). “Heap” có thể được xem như nơi chứa chung cho việc cấp phát bộ nhớ, dành cho khi các giá trị Go cần được đặt ở đâu đó. Hành động cấp phát bộ nhớ trên heap thường được gọi là “dynamic memory allocation” (cấp phát bộ nhớ động) vì cả trình biên dịch lẫn runtime đều rất khó đưa ra giả định về việc bộ nhớ này được dùng ra sao và khi nào có thể dọn dẹp. Đó là lúc GC xuất hiện: nó là hệ thống chuyên xác định và dọn dẹp các allocation bộ nhớ động.
Có nhiều lý do khiến một giá trị Go phải escape ra heap. Một lý do có thể là kích thước của nó được xác định động. Ví dụ, hãy xét mảng nền của một slice có kích thước ban đầu được quyết định bởi một biến thay vì một hằng số. Lưu ý rằng việc escape ra heap còn có tính bắc cầu: nếu một tham chiếu đến một giá trị Go được ghi vào một giá trị Go khác đã được xác định là escape, thì giá trị đó cũng phải escape.
Việc một giá trị Go có escape hay không phụ thuộc vào ngữ cảnh sử dụng nó và vào thuật toán escape analysis của trình biên dịch Go. Sẽ rất mong manh và khó khăn nếu cố liệt kê chính xác khi nào giá trị escape: bản thân thuật toán khá tinh vi và thay đổi giữa các bản phát hành Go. Để biết thêm chi tiết về cách xác định giá trị nào escape và giá trị nào không, xem phần loại bỏ heap allocation.
Tracing Garbage Collection
Garbage collection có thể chỉ nhiều phương pháp tự động tái sử dụng bộ nhớ khác nhau; ví dụ như đếm tham chiếu. Trong ngữ cảnh của tài liệu này, garbage collection chỉ tracing garbage collection, tức là cách xác định các object đang được dùng — còn gọi là live — bằng việc đi theo các pointer một cách bắc cầu.
Hãy định nghĩa các thuật ngữ này một cách chặt chẽ hơn.
-
Object—Một object là một vùng bộ nhớ được cấp phát động chứa một hoặc nhiều giá trị Go.
-
Pointer—Một địa chỉ bộ nhớ tham chiếu tới một giá trị bất kỳ bên trong một object. Điều này dĩ nhiên bao gồm các giá trị Go dạng
*T, nhưng cũng bao gồm các thành phần của những giá trị Go dựng sẵn. String, slice, channel, map và giá trị interface đều chứa địa chỉ bộ nhớ mà GC phải trace.
Cùng với nhau, các object và các pointer tới object khác tạo thành object graph (đồ thị object). Để xác định bộ nhớ nào là live, GC duyệt object graph bắt đầu từ các root của chương trình, tức những pointer chỉ tới các object chắc chắn đang được chương trình sử dụng. Hai ví dụ về root là biến cục bộ và biến toàn cục. Quá trình duyệt object graph được gọi là scanning (quét). Một cụm từ khác bạn có thể gặp trong tài liệu Go là object có reachable (tới được) hay không, nghĩa là object đó có thể được quá trình scanning phát hiện ra. Cũng lưu ý rằng, với một ngoại lệ, một khi bộ nhớ trở nên không tới được thì nó sẽ mãi không tới được.
Thuật toán cơ bản này là chung cho mọi tracing GC. Điểm khác biệt giữa các tracing GC nằm ở việc chúng làm gì sau khi phát hiện bộ nhớ là live. GC của Go dùng kỹ thuật mark-sweep, nghĩa là để theo dõi tiến độ, GC còn mark (đánh dấu) các giá trị nó gặp là live. Khi việc trace hoàn tất, GC sẽ duyệt toàn bộ bộ nhớ trong heap và biến mọi vùng nhớ không được mark thành khả dụng cho việc cấp phát. Quá trình này gọi là sweeping (quét dọn).
Một kỹ thuật thay thế mà có thể bạn đã biết là thực sự di chuyển các object sang một vùng bộ nhớ mới và để lại một pointer chuyển tiếp, về sau được dùng để cập nhật mọi pointer của ứng dụng. Chúng tôi gọi GC di chuyển object theo cách này là GC moving; Go có một GC non-moving.
Vòng GC
Vì Go GC là một GC mark-sweep, nó hoạt động một cách tổng quát theo hai giai đoạn: mark phase và sweep phase. Tuy phát biểu này nghe có vẻ hiển nhiên, nó hàm chứa một điều quan trọng: không thể trả bộ nhớ về để cấp phát lại cho đến khi toàn bộ bộ nhớ đã được trace, bởi vẫn có thể còn một pointer chưa được scan đang giữ cho một object sống. Hệ quả là việc sweeping phải hoàn toàn tách biệt khỏi việc marking. Hơn nữa, GC cũng có thể không hoạt động chút nào, khi không có việc gì liên quan tới GC cần làm. GC liên tục xoay vòng qua ba giai đoạn sweeping, tắt, và marking trong cái được gọi là GC cycle (vòng GC). Trong phạm vi tài liệu này, hãy xem vòng GC bắt đầu bằng sweeping, rồi tắt, rồi marking.
Vài phần tiếp theo sẽ tập trung xây dựng trực giác về chi phí của GC, nhằm giúp người dùng tinh chỉnh các tham số GC có lợi cho mình.
Hiểu về chi phí
GC vốn dĩ là một phần mềm phức tạp được xây trên những hệ thống còn phức tạp hơn. Rất dễ sa lầy vào chi tiết khi cố hiểu GC và điều chỉnh hành vi của nó. Phần này nhằm cung cấp một khung tư duy về chi phí của Go GC và các tham số tinh chỉnh của nó.
Để bắt đầu, hãy xét mô hình chi phí GC sau, dựa trên ba tiên đề đơn giản.
-
GC chỉ liên quan tới hai tài nguyên: bộ nhớ vật lý và thời gian CPU.
-
Chi phí bộ nhớ của GC gồm live heap, bộ nhớ heap mới được cấp phát trước mark phase, và vùng chứa metadata mà, dù có tỉ lệ với các chi phí trên, vẫn nhỏ khi so sánh.
GC memory cost for cycle N = live heap from cycle N-1 + new heap
Live heap là phần bộ nhớ được xác định là live bởi vòng GC trước, còn bộ nhớ heap mới là mọi vùng nhớ được cấp phát trong vòng hiện tại, có thể là live hoặc không vào lúc kết thúc. Lượng bộ nhớ đang live tại bất kỳ thời điểm nào là đặc tính của chương trình, chứ không phải thứ GC có thể trực tiếp kiểm soát.
-
Chi phí CPU của GC được mô hình hoá thành một chi phí cố định cho mỗi vòng, cộng với một chi phí biên tỉ lệ thuận với kích thước live heap.
GC CPU time for cycle N = Fixed CPU time cost per cycle + average CPU time cost per byte * live heap memory found in cycle N
Chi phí thời gian CPU cố định mỗi vòng bao gồm những việc xảy ra một số lần cố định trong mỗi vòng, chẳng hạn khởi tạo cấu trúc dữ liệu cho vòng GC kế tiếp. Chi phí này thường nhỏ, và được đưa vào chỉ để đầy đủ.
Phần lớn chi phí CPU của GC là marking và scanning, được thể hiện qua chi phí biên. Chi phí trung bình của marking và scanning phụ thuộc vào phần hiện thực GC, nhưng cũng phụ thuộc vào hành vi của chương trình. Ví dụ, càng nhiều pointer thì GC càng phải làm nhiều việc, vì tối thiểu GC cần ghé thăm mọi pointer trong chương trình. Những cấu trúc như danh sách liên kết và cây cũng khó cho GC duyệt song song hơn, làm tăng chi phí trung bình trên mỗi byte.
Mô hình này bỏ qua chi phí sweeping, vốn tỉ lệ với tổng bộ nhớ heap, bao gồm cả bộ nhớ đã chết (nó phải được đưa về trạng thái khả dụng cho việc cấp phát). Với phần hiện thực GC hiện tại của Go, sweeping nhanh hơn marking và scanning rất nhiều nên chi phí này là không đáng kể khi so sánh.
Mô hình này đơn giản nhưng hiệu quả: nó phân loại chính xác các chi phí chủ đạo của GC. Nó cũng cho chúng ta biết rằng tổng chi phí CPU của garbage collector phụ thuộc vào tổng số vòng GC trong một khoảng thời gian cho trước. Cuối cùng, ẩn trong mô hình này là một đánh đổi cơ bản giữa thời gian và không gian của GC.
Để thấy vì sao, hãy khám phá một kịch bản bị ràng buộc nhưng hữu ích: trạng thái ổn định (steady state). Trạng thái ổn định của một ứng dụng, dưới góc nhìn của GC, được định nghĩa bởi các tính chất sau:
-
Tốc độ ứng dụng cấp phát bộ nhớ mới (tính bằng byte mỗi giây) là không đổi.
Điều này nghĩa là, dưới góc nhìn của GC, khối lượng công việc của ứng dụng trông gần như không đổi theo thời gian. Ví dụ, với một web service, đó sẽ là tần suất request không đổi với, tính trung bình, cùng những loại request được gửi tới, và thời gian sống trung bình của mỗi request gần như không đổi.
-
Các chi phí biên của GC là không đổi.
Điều này nghĩa là các thống kê của object graph, chẳng hạn phân bố kích thước object, số lượng pointer, và độ sâu trung bình của các cấu trúc dữ liệu, giữ nguyên từ vòng này sang vòng khác.
Hãy cùng đi qua một ví dụ. Giả sử một ứng dụng nào đó đang hoạt động ở trạng thái ổn định, cấp phát 10 MiB/s, còn GC có thể scan bộ nhớ ở tốc độ 100 MiB/cpu-giây (con số này là bịa ra). Trạng thái ổn định không giả định gì về kích thước live heap, nhưng cho đơn giản, hãy nói live heap của ứng dụng này luôn là 10 MiB. Cũng cho đơn giản, hãy giả sử thêm rằng các chi phí GC cố định bằng không. Hãy thử nghịch với chu kỳ của vòng GC.
Giả sử mỗi vòng GC xảy ra sau đúng 1 cpu-giây. Khi đó, đến cuối mỗi vòng GC, ứng dụng ví dụ của chúng ta sẽ đã cấp phát thêm 10 MiB bộ nhớ, dẫn tới tổng kích thước heap là 20 MiB. Và với mỗi vòng GC, GC sẽ tốn 0,1 cpu-giây để scan live heap 10 MiB, dẫn tới chi phí CPU phụ trội 10%. Nhớ rằng GC chỉ cần duyệt live heap, không phải toàn bộ heap. (Lưu ý: live heap không đổi không có nghĩa là mọi bộ nhớ mới cấp phát đều chết. Nó nghĩa là, sau khi GC chạy, một pha trộn nào đó giữa bộ nhớ heap cũ và mới chết đi, và chỉ có nghĩa là kết quả cuối cùng là 10 MiB được tìm thấy live mỗi vòng.)
Giờ giả sử mỗi vòng GC xảy ra thưa hơn, cứ 2 cpu-giây một lần. Khi đó, ứng dụng ví dụ của chúng ta, ở trạng thái ổn định, sẽ có tổng kích thước heap là 30 MiB ở mỗi vòng GC, vì nó sẽ cấp phát 20 MiB trong khoảng thời gian đó. Nhưng với mỗi vòng GC, GC sẽ vẫn chỉ cần 0,1 cpu-giây để scan 10 MiB bộ nhớ live. Một lần nữa, chúng ta giả định kích thước live heap giữ nguyên, bất kể bao nhiêu bộ nhớ được cấp phát. Vậy nghĩa là chi phí phụ trội GC vừa giảm, từ 10% xuống 5%, đổi lại là dùng nhiều hơn 50% bộ nhớ.
Sự thay đổi chi phí phụ trội này chính là đánh đổi cơ bản giữa thời gian và không gian đã nhắc tới ở trên. Và tần suất GC nằm ở trung tâm của đánh đổi này: nếu chúng ta cho GC chạy thường xuyên hơn thì dùng ít bộ nhớ hơn, và ngược lại. Nhưng GC thực sự chạy thường xuyên tới mức nào? Trong Go, quyết định khi nào GC nên bắt đầu là tham số chính mà người dùng có quyền kiểm soát.
GOGC
Ở mức tổng quát, GOGC quyết định đánh đổi giữa CPU của GC và bộ nhớ.
Nó hoạt động bằng cách xác định heap target sau mỗi vòng GC, tức một giá trị đích cho tổng kích thước heap ở vòng kế tiếp. Mục tiêu của GC là hoàn tất một vòng thu gom trước khi tổng kích thước heap vượt quá heap target. Tổng kích thước heap được định nghĩa là kích thước live heap ở cuối vòng trước, cộng với mọi bộ nhớ heap mới được ứng dụng cấp phát kể từ vòng trước. Trong khi đó, bộ nhớ heap đích được định nghĩa là:
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
Ví dụ, hãy xét một chương trình Go có kích thước live heap là 8 MiB, 1 MiB stack của goroutine, và 1 MiB pointer trong các biến toàn cục. Khi đó, với giá trị GOGC là 100, lượng bộ nhớ mới sẽ được cấp phát trước khi GC chạy lần kế tiếp là 10 MiB, tức 100% của 10 MiB công việc, cho tổng mức chiếm dụng heap là 18 MiB. Với giá trị GOGC là 50, con số đó sẽ là 50%, tức 5 MiB. Với giá trị GOGC là 200, nó sẽ là 200%, tức 20 MiB.
Lưu ý: GOGC chỉ tính cả root set kể từ Go 1.18. Trước đó, nó chỉ tính live heap. Thường thì lượng bộ nhớ trong stack của goroutine khá nhỏ và kích thước live heap áp đảo mọi nguồn công việc GC khác, nhưng trong những trường hợp chương trình có hàng trăm nghìn goroutine, GC đã đưa ra những phán đoán tồi.
Heap target điều khiển tần suất GC: đích càng lớn thì GC càng có thể chờ lâu hơn mới khởi động một mark phase khác, và ngược lại. Tuy công thức chính xác hữu ích cho việc ước lượng, tốt nhất nên nghĩ về GOGC theo mục đích cốt lõi của nó: một tham số chọn ra một điểm trong đánh đổi giữa CPU của GC và bộ nhớ. Điều quan trọng cần nhớ là tăng gấp đôi GOGC sẽ tăng gấp đôi chi phí phụ trội bộ nhớ heap và giảm chi phí CPU của GC đi khoảng một nửa, và ngược lại. (Để xem lời giải thích đầy đủ vì sao, xem phụ lục.)
Lưu ý: kích thước heap đích chỉ là một đích nhắm tới, và có vài lý do khiến vòng GC có thể không kết thúc đúng ngay tại đích đó. Thứ nhất, một heap allocation đủ lớn có thể đơn giản là vượt quá đích. Tuy nhiên, những lý do khác xuất hiện trong các phần hiện thực GC vượt ra ngoài mô hình GC mà hướng dẫn này đang dùng đến giờ. Để biết thêm chi tiết, xem phần về latency, nhưng chi tiết đầy đủ có thể tìm thấy trong phần tài nguyên bổ sung.
GOGC có thể được cấu hình qua biến môi trường GOGC (mà mọi chương trình Go đều nhận biết), hoặc qua API SetGCPercent trong package runtime/debug.
Lưu ý rằng GOGC cũng có thể được dùng để tắt hẳn GC (với điều kiện giới hạn bộ nhớ không được áp dụng) bằng cách đặt GOGC=off hoặc gọi SetGCPercent(-1). Về mặt khái niệm, thiết lập này tương đương với đặt GOGC bằng vô cùng, vì lượng bộ nhớ mới trước khi GC được kích hoạt là không có giới hạn.
Để hiểu rõ hơn mọi thứ chúng ta đã bàn đến giờ, hãy thử biểu đồ tương tác dưới đây, được xây dựng dựa trên mô hình chi phí GC đã bàn ở trên. Biểu đồ này mô tả việc thực thi một chương trình nào đó mà phần công việc không thuộc GC mất 10 giây thời gian CPU để hoàn tất. Trong giây đầu tiên nó thực hiện một bước khởi tạo (làm live heap lớn lên) trước khi ổn định vào trạng thái ổn định. Ứng dụng cấp phát tổng cộng 200 MiB, với 20 MiB live tại mỗi thời điểm. Nó giả định rằng công việc GC liên quan duy nhất cần hoàn tất đến từ live heap, và rằng (một cách phi thực tế) ứng dụng không dùng thêm bộ nhớ nào khác.
Hãy dùng thanh trượt để điều chỉnh giá trị GOGC và xem ứng dụng phản ứng ra sao về tổng thời lượng và chi phí phụ trội GC. Mỗi vòng GC kết thúc khi heap mới tụt về không. Thời gian trôi qua trong lúc heap mới tụt về không là tổng thời gian của mark phase cho vòng N và sweep phase cho vòng N+1. Lưu ý rằng biểu đồ này (và mọi biểu đồ trong hướng dẫn này) giả định ứng dụng bị tạm dừng trong lúc GC thực thi, nên chi phí CPU của GC được thể hiện trọn vẹn bằng thời gian cần để bộ nhớ heap mới tụt về không. Điều này chỉ nhằm làm biểu đồ đơn giản hơn; trực giác rút ra vẫn đúng. Trục X dịch chuyển để luôn hiển thị trọn vẹn thời lượng CPU của chương trình. Để ý rằng thời gian CPU thêm vào do GC dùng làm tăng tổng thời lượng.
Để ý rằng GC luôn phát sinh một mức chi phí phụ trội nào đó về CPU và bộ nhớ đỉnh. Khi GOGC tăng, chi phí phụ trội CPU giảm, nhưng bộ nhớ đỉnh tăng tỉ lệ với kích thước live heap. Khi GOGC giảm, nhu cầu bộ nhớ đỉnh giảm, đổi lại là chi phí phụ trội CPU tăng thêm.
Lưu ý: biểu đồ hiển thị thời gian CPU, không phải thời gian đồng hồ để hoàn tất chương trình. Nếu chương trình chạy trên 1 CPU và tận dụng hết tài nguyên của nó thì hai đại lượng này tương đương. Một chương trình thực tế nhiều khả năng chạy trên hệ thống nhiều nhân và không tận dụng 100% CPU ở mọi thời điểm. Trong những trường hợp đó, tác động của GC lên thời gian đồng hồ sẽ thấp hơn.
Lưu ý: Go GC có kích thước tổng heap tối thiểu là 4 MiB, nên nếu đích do GOGC đặt ra thấp hơn mức đó thì nó sẽ được làm tròn lên. Biểu đồ phản ánh chi tiết này.
Đây là một ví dụ khác động và thực tế hơn một chút. Một lần nữa, ứng dụng mất 10 CPU-giây để hoàn tất nếu không có GC, nhưng tốc độ cấp phát ở trạng thái ổn định tăng mạnh vào giữa chừng, và kích thước live heap dịch chuyển đôi chút trong giai đoạn đầu. Ví dụ này minh hoạ trạng thái ổn định trông thế nào khi kích thước live heap thực sự thay đổi, và việc tốc độ cấp phát cao hơn dẫn tới các vòng GC diễn ra thường xuyên hơn.
Giới hạn bộ nhớ
Cho đến Go 1.19, GOGC là tham số duy nhất có thể dùng để thay đổi hành vi của GC. Tuy nó hoạt động rất tốt như một cách đặt ra đánh đổi, nó không tính đến việc bộ nhớ khả dụng là hữu hạn. Hãy xét chuyện gì xảy ra khi có một đợt tăng vọt nhất thời của kích thước live heap: vì GC sẽ chọn tổng kích thước heap tỉ lệ với kích thước live heap đó, GOGC phải được cấu hình cho kích thước live heap đỉnh, ngay cả khi trong trường hợp thông thường một giá trị GOGC cao hơn cho đánh đổi tốt hơn.
Biểu đồ dưới đây minh hoạ tình huống heap tăng vọt nhất thời này.
Nếu khối lượng công việc ví dụ đang chạy trong một container chỉ có hơn 60 MiB bộ nhớ khả dụng một chút, thì GOGC không thể tăng quá 100, mặc dù phần còn lại của các vòng GC vẫn còn bộ nhớ khả dụng để tận dụng lượng bộ nhớ dôi ra đó. Hơn nữa, ở một số ứng dụng, những đỉnh nhất thời này có thể hiếm và khó dự đoán, dẫn tới các tình huống hết bộ nhớ thỉnh thoảng xảy ra, không thể tránh khỏi và có thể rất tốn kém.
Đó là lý do trong bản phát hành 1.19, Go bổ sung hỗ trợ đặt giới hạn bộ nhớ cho runtime. Giới hạn bộ nhớ có thể được cấu hình hoặc qua biến môi trường GOMEMLIMIT mà mọi chương trình Go đều nhận biết, hoặc qua hàm SetMemoryLimit có trong package runtime/debug.
Giới hạn bộ nhớ này đặt ra mức tối đa cho tổng lượng bộ nhớ mà Go runtime có thể dùng. Tập bộ nhớ cụ thể được tính vào đây được định nghĩa theo runtime.MemStats bằng biểu thức
Sys - HeapReleased
hoặc tương đương, theo package runtime/metrics,
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
Vì Go GC kiểm soát tường minh lượng bộ nhớ heap mà nó dùng, nó đặt tổng kích thước heap dựa trên giới hạn bộ nhớ này và lượng bộ nhớ khác mà Go runtime sử dụng.
Biểu đồ dưới đây mô tả cùng khối lượng công việc trạng thái ổn định một giai đoạn ở phần GOGC, nhưng lần này có thêm 10 MiB chi phí phụ trội từ Go runtime và có giới hạn bộ nhớ điều chỉnh được. Hãy thử dịch chuyển cả GOGC lẫn giới hạn bộ nhớ và xem điều gì xảy ra.
Để ý rằng khi giới hạn bộ nhớ bị hạ xuống dưới mức bộ nhớ đỉnh do GOGC quyết định (42 MiB với GOGC bằng 100), GC chạy thường xuyên hơn để giữ bộ nhớ đỉnh nằm trong giới hạn.
Quay lại ví dụ trước về đợt heap tăng vọt nhất thời, bằng cách đặt giới hạn bộ nhớ và tăng GOGC lên, chúng ta có thể có được điều tốt nhất của cả hai thế giới: không vi phạm giới hạn bộ nhớ, và tiết kiệm tài nguyên tốt hơn. Hãy thử biểu đồ tương tác dưới đây.
Để ý rằng với một số giá trị của GOGC và giới hạn bộ nhớ, mức dùng bộ nhớ đỉnh dừng lại đúng tại giới hạn bộ nhớ, nhưng phần còn lại của quá trình thực thi chương trình vẫn tuân theo quy tắc tổng kích thước heap do GOGC đặt ra.
Quan sát này dẫn tới một chi tiết thú vị khác: ngay cả khi GOGC được đặt là off, giới hạn bộ nhớ vẫn được tôn trọng! Thực tế, cấu hình cụ thể này thể hiện mức tiết kiệm tài nguyên tối đa vì nó đặt tần suất GC tối thiểu cần thiết để duy trì một giới hạn bộ nhớ nào đó. Trong trường hợp này, toàn bộ quá trình thực thi chương trình có kích thước heap dâng lên chạm tới giới hạn bộ nhớ.
Giờ thì, tuy giới hạn bộ nhớ rõ ràng là một công cụ mạnh, việc dùng giới hạn bộ nhớ không phải là không có chi phí, và chắc chắn không làm mất đi tính hữu dụng của GOGC.
Hãy xét chuyện gì xảy ra khi live heap lớn lên đủ để đưa tổng mức dùng bộ nhớ tiến sát giới hạn bộ nhớ. Trong biểu đồ trạng thái ổn định ở trên, hãy thử tắt GOGC rồi từ từ hạ giới hạn bộ nhớ xuống thấp dần để xem điều gì xảy ra. Để ý rằng tổng thời gian ứng dụng cần sẽ bắt đầu tăng không giới hạn khi GC liên tục chạy để cố duy trì một giới hạn bộ nhớ bất khả thi.
Tình huống chương trình không tiến triển hợp lý được vì các vòng GC liên tục này được gọi là thrashing. Nó đặc biệt nguy hiểm vì thực chất nó làm chương trình đứng máy. Tệ hơn nữa, nó có thể xảy ra đúng trong tình huống mà chúng ta đang cố tránh với GOGC: một đợt heap tăng vọt nhất thời đủ lớn có thể khiến chương trình đứng vô thời hạn! Hãy thử giảm giới hạn bộ nhớ (khoảng 30 MiB hoặc thấp hơn) trong biểu đồ heap tăng vọt nhất thời và để ý rằng hành vi tệ nhất bắt đầu đúng từ đợt tăng vọt của heap.
Trong nhiều trường hợp, một cú đứng vô thời hạn còn tệ hơn tình trạng hết bộ nhớ, vốn thường dẫn tới việc thất bại nhanh hơn nhiều.
Vì lý do đó, giới hạn bộ nhớ được định nghĩa là mềm (soft). Go runtime không bảo đảm rằng nó sẽ duy trì giới hạn bộ nhớ này trong mọi hoàn cảnh; nó chỉ hứa hẹn một mức nỗ lực hợp lý nào đó. Sự nới lỏng giới hạn bộ nhớ này rất quan trọng để tránh hành vi thrashing, vì nó cho GC một lối thoát: để mức dùng bộ nhớ vượt quá giới hạn nhằm tránh tiêu tốn quá nhiều thời gian trong GC.
Cách nó hoạt động bên trong là GC đặt một giới hạn trên cho lượng thời gian CPU nó có thể dùng trong một cửa sổ thời gian nào đó (với một chút trễ trong phản ứng để chịu được các đợt tăng vọt rất ngắn về mức dùng CPU). Giới hạn này hiện được đặt ở khoảng 50%, với cửa sổ 2 * GOMAXPROCS CPU-giây. Hệ quả của việc giới hạn thời gian CPU của GC là công việc của GC bị trì hoãn, trong khi đó chương trình Go có thể tiếp tục cấp phát bộ nhớ heap mới, thậm chí vượt quá giới hạn bộ nhớ.
Trực giác đằng sau giới hạn 50% CPU cho GC dựa trên tác động tệ nhất lên một chương trình có dư dả bộ nhớ khả dụng. Trong trường hợp giới hạn bộ nhớ bị cấu hình sai, đặt quá thấp do nhầm lẫn, chương trình sẽ chậm đi nhiều nhất là 2 lần, vì GC không thể lấy đi quá 50% thời gian CPU của nó.
Lưu ý: các biểu đồ trên trang này không mô phỏng giới hạn CPU của GC.
Những cách dùng được gợi ý
Tuy giới hạn bộ nhớ là một công cụ mạnh, và Go runtime có những bước để giảm nhẹ các hành vi tệ nhất khi bị dùng sai, việc sử dụng nó một cách cân nhắc vẫn rất quan trọng. Dưới đây là một tập hợp các lời khuyên nhỏ về nơi giới hạn bộ nhớ hữu ích và phù hợp nhất, và nơi nó có thể gây hại nhiều hơn lợi.
-
Nên tận dụng giới hạn bộ nhớ khi môi trường thực thi của chương trình Go của bạn hoàn toàn nằm trong tầm kiểm soát của bạn, và chương trình Go là chương trình duy nhất truy cập tới một tập tài nguyên nào đó (ví dụ một dạng bộ nhớ được dành riêng, như giới hạn bộ nhớ của container).
Một ví dụ tốt là việc triển khai một web service vào các container với một lượng bộ nhớ khả dụng cố định.
Trong trường hợp này, một quy tắc ngón tay cái tốt là chừa thêm 5-10% khoảng dư để tính đến các nguồn tiêu thụ bộ nhớ mà Go runtime không biết.
-
Nên thoải mái điều chỉnh giới hạn bộ nhớ theo thời gian thực để thích ứng với điều kiện thay đổi.
Một ví dụ tốt là chương trình cgo trong đó các thư viện C tạm thời cần dùng nhiều bộ nhớ hơn đáng kể.
-
Không nên đặt GOGC là off kèm giới hạn bộ nhớ nếu chương trình Go có thể chia sẻ một phần bộ nhớ hữu hạn của nó với các chương trình khác, và những chương trình đó nhìn chung tách rời khỏi chương trình Go. Thay vào đó, hãy giữ giới hạn bộ nhớ vì nó có thể giúp kìm hãm hành vi nhất thời không mong muốn, nhưng đặt GOGC về một giá trị nhỏ hơn, hợp lý cho trường hợp trung bình.
Dù có thể rất hấp dẫn khi cố “dành riêng” bộ nhớ cho các chương trình cùng chia sẻ máy, trừ khi các chương trình được đồng bộ hoàn toàn (ví dụ chương trình Go gọi một tiến trình con nào đó và bị chặn trong lúc bên được gọi thực thi), kết quả sẽ kém tin cậy hơn vì không tránh khỏi việc cả hai chương trình đều cần thêm bộ nhớ. Để chương trình Go dùng ít bộ nhớ hơn khi nó không cần sẽ cho kết quả tổng thể đáng tin cậy hơn. Lời khuyên này cũng áp dụng cho các tình huống overcommit, nơi tổng giới hạn bộ nhớ của các container chạy trên một máy có thể vượt quá bộ nhớ vật lý thực sự khả dụng của máy đó.
-
Không nên dùng giới hạn bộ nhớ khi triển khai vào một môi trường thực thi mà bạn không kiểm soát, nhất là khi mức dùng bộ nhớ của chương trình tỉ lệ với đầu vào của nó.
Một ví dụ tốt là công cụ CLI hoặc ứng dụng desktop. Việc nướng sẵn một giới hạn bộ nhớ vào chương trình khi chưa rõ nó có thể được cho ăn loại đầu vào nào, hay hệ thống có bao nhiêu bộ nhớ khả dụng, có thể dẫn tới những cú sập khó hiểu và hiệu năng kém. Hơn nữa, người dùng cuối có trình độ luôn có thể tự đặt giới hạn bộ nhớ nếu họ muốn.
-
Không nên đặt giới hạn bộ nhớ để tránh tình trạng hết bộ nhớ khi chương trình vốn đã sát với giới hạn bộ nhớ của môi trường.
Làm vậy thực chất là đánh đổi rủi ro hết bộ nhớ lấy rủi ro ứng dụng chậm đi nghiêm trọng, vốn thường không phải là một đánh đổi có lợi, ngay cả với những nỗ lực Go bỏ ra để giảm nhẹ thrashing. Trong trường hợp như vậy, sẽ hiệu quả hơn nhiều nếu hoặc tăng giới hạn bộ nhớ của môi trường (và sau đó có thể đặt giới hạn bộ nhớ) hoặc giảm GOGC (vốn cho một đánh đổi sạch sẽ hơn nhiều so với việc giảm nhẹ thrashing).
Latency
Các biểu đồ trong tài liệu này đã mô hình hoá ứng dụng như thể nó bị tạm dừng trong lúc GC thực thi. Có tồn tại những phần hiện thực GC hành xử như vậy, và chúng được gọi là GC “stop-the-world”.
Tuy nhiên, Go GC không hoàn toàn stop-the-world và thực hiện phần lớn công việc của nó đồng thời với ứng dụng. Điều này chủ yếu nhằm giảm latency (độ trễ) của ứng dụng. Cụ thể là thời lượng đầu-cuối của một đơn vị tính toán đơn lẻ (ví dụ một web request). Đến giờ, tài liệu này chủ yếu xét tới throughput (thông lượng) của ứng dụng (ví dụ số web request xử lý được mỗi giây). Lưu ý rằng mỗi ví dụ trong phần vòng GC đều tập trung vào tổng thời lượng CPU của một chương trình đang chạy. Tuy nhiên, thời lượng như vậy ít ý nghĩa hơn nhiều với, chẳng hạn, một web service. Tuy throughput vẫn quan trọng với web service (tức số truy vấn mỗi giây), thường thì latency của từng request riêng lẻ còn quan trọng hơn.
Xét về latency, một GC stop-the-world có thể cần một khoảng thời gian đáng kể để thực thi cả mark phase lẫn sweep phase, trong lúc đó ứng dụng — và trong ngữ cảnh web service là mọi request đang dở dang — không thể tiến triển thêm. Thay vào đó, Go GC tránh để độ dài của bất kỳ đợt tạm dừng toàn cục nào của ứng dụng tỉ lệ với kích thước heap, và thuật toán trace cốt lõi được thực hiện trong lúc ứng dụng đang tích cực chạy. (Về mặt thuật toán, các đợt tạm dừng tỉ lệ mạnh hơn với GOMAXPROCS, nhưng thường gặp nhất là chúng bị chi phối bởi thời gian cần để dừng các goroutine đang chạy.) Thu gom đồng thời không phải là không có chi phí: trên thực tế nó thường dẫn tới một thiết kế có throughput thấp hơn so với một garbage collector stop-the-world tương đương. Tuy nhiên, cần lưu ý rằng latency thấp hơn không hiển nhiên đồng nghĩa với throughput thấp hơn, và hiệu năng của Go garbage collector đã liên tục được cải thiện theo thời gian, ở cả latency lẫn throughput.
Bản chất đồng thời của GC hiện tại của Go không làm vô hiệu bất cứ điều gì đã bàn trong tài liệu này đến giờ: không phát biểu nào phụ thuộc vào lựa chọn thiết kế này. Tần suất GC vẫn là cách chính mà GC đánh đổi giữa thời gian CPU và bộ nhớ để lấy throughput, và thực tế nó cũng đảm nhiệm vai trò này với latency. Đó là vì phần lớn chi phí của GC phát sinh trong lúc mark phase đang hoạt động.
Vậy điều quan trọng cần nhớ là giảm tần suất GC cũng có thể dẫn tới cải thiện latency. Điều này không chỉ áp dụng cho việc giảm tần suất GC bằng cách thay đổi các tham số tinh chỉnh, như tăng GOGC và/hoặc giới hạn bộ nhớ, mà còn áp dụng cho các tối ưu được mô tả trong hướng dẫn tối ưu.
Tuy nhiên, latency thường phức tạp để hiểu hơn throughput, vì nó là sản phẩm của quá trình thực thi từng khoảnh khắc của chương trình chứ không chỉ là tổng hợp các chi phí. Hệ quả là mối liên hệ giữa latency và tần suất GC kém trực tiếp hơn. Dưới đây là danh sách các nguồn latency khả dĩ dành cho ai muốn đào sâu hơn.
- Các đợt tạm dừng stop-the-world ngắn khi GC chuyển tiếp giữa mark phase và sweep phase,
- Trễ do scheduler vì GC chiếm 25% tài nguyên CPU khi đang ở mark phase,
- Các goroutine của người dùng phải assist cho GC để đáp lại tốc độ cấp phát cao,
- Việc ghi pointer đòi hỏi công việc phụ thêm trong lúc GC đang ở mark phase, và
- Các goroutine đang chạy phải bị tạm ngưng để root của chúng được scan.
Các nguồn latency này đều thấy được trong execution trace, ngoại trừ việc ghi pointer đòi hỏi công việc phụ thêm.
Finalizer, cleanup và weak pointer
Garbage collection tạo ra ảo giác về bộ nhớ vô hạn trong khi chỉ dùng bộ nhớ hữu hạn. Bộ nhớ được cấp phát nhưng không bao giờ được giải phóng tường minh, điều này cho phép các API đơn giản hơn và các thuật toán đồng thời dễ viết hơn so với việc quản lý bộ nhớ thủ công trần trụi. (Một số ngôn ngữ quản lý bộ nhớ thủ công dùng các cách tiếp cận thay thế như “smart pointer” và theo dõi quyền sở hữu tại thời điểm biên dịch để bảo đảm object được giải phóng, nhưng những tính năng đó ăn sâu vào các quy ước thiết kế API trong những ngôn ngữ ấy.)
Chỉ những object live — những object tới được từ một biến toàn cục hoặc từ một phép tính trong goroutine nào đó — mới có thể ảnh hưởng tới hành vi của chương trình. Bất cứ lúc nào sau khi một object trở nên không tới được (“chết”), nó có thể được GC tái sử dụng một cách an toàn. Điều này cho phép rất nhiều thiết kế GC khác nhau, chẳng hạn thiết kế tracing mà Go dùng hiện nay. Cái chết của một object không phải là một sự kiện quan sát được ở mức ngôn ngữ.
Tuy nhiên, thư viện runtime của Go cung cấp ba tính năng phá vỡ ảo giác đó: cleanup, weak pointer, và finalizer. Mỗi tính năng này cung cấp một cách nào đó để quan sát và phản ứng với cái chết của object, và với finalizer thì thậm chí đảo ngược nó. Dĩ nhiên điều này làm các chương trình Go phức tạp hơn và tạo thêm gánh nặng cho phần hiện thực GC. Dù vậy, những tính năng này tồn tại vì chúng hữu ích trong nhiều hoàn cảnh, và các chương trình Go vẫn luôn dùng chúng và hưởng lợi từ chúng.
Để biết chi tiết từng tính năng, hãy tham khảo tài liệu package tương ứng (runtime.AddCleanup, weak.Pointer, runtime.SetFinalizer). Dưới đây là một số lời khuyên chung khi dùng các tính năng này, phác thảo những vấn đề thường gặp với từng tính năng, và lời khuyên cho việc kiểm thử các đoạn mã dùng chúng.
Lời khuyên chung
-
Hãy viết unit test.
Thời điểm chính xác của cleanup, weak pointer và finalizer có thể rất khó dự đoán, và rất dễ tự thuyết phục bản thân rằng mọi thứ đều hoạt động, kể cả sau nhiều lần chạy liên tiếp. Nhưng cũng rất dễ mắc những lỗi tinh vi. Viết test cho chúng có thể khó nhằn, nhưng vì chúng quá tinh vi khi sử dụng, việc kiểm thử còn quan trọng hơn bình thường.
-
Tránh dùng trực tiếp các tính năng này trong mã Go thông thường.
Đây là các tính năng mức thấp với những ràng buộc và hành vi tinh vi. Chẳng hạn, không có bảo đảm nào rằng cleanup hay finalizer sẽ được chạy khi chương trình thoát, hay thậm chí là được chạy chút nào. Những đoạn chú thích dài trong tài liệu API của chúng nên được xem như một lời cảnh báo. Phần lớn áp đảo mã Go không hưởng lợi từ việc dùng trực tiếp các tính năng này, mà chỉ gián tiếp.
-
Hãy đóng gói việc dùng các cơ chế này bên trong một package.
Ở những chỗ có thể, đừng để việc dùng các cơ chế này rò rỉ ra API công khai của package bạn; hãy cung cấp các giao diện khiến người dùng khó hoặc không thể dùng sai chúng. Ví dụ, thay vì yêu cầu người dùng thiết lập một cleanup trên vùng bộ nhớ do C cấp phát để giải phóng nó, hãy viết một package bọc ngoài và giấu chi tiết đó bên trong.
-
Hãy hạn chế quyền truy cập tới các object có finalizer, cleanup và weak pointer chỉ trong package đã tạo và áp dụng chúng.
Điều này liên quan tới ý trước, nhưng đáng được nêu ra một cách tường minh, vì đó là một khuôn mẫu rất mạnh để dùng các tính năng này theo cách ít có nguy cơ sai sót hơn. Ví dụ, package unique dùng weak pointer ở bên dưới, nhưng bao bọc hoàn toàn các object được trỏ tới một cách yếu. Những giá trị đó không bao giờ có thể bị phần còn lại của ứng dụng thay đổi, chúng chỉ có thể được sao chép thông qua phương thức Value, giữ nguyên ảo giác về bộ nhớ vô hạn cho người dùng package.
-
Hãy ưu tiên dọn dẹp các tài nguyên không phải bộ nhớ một cách tất định khi có thể, và chỉ dùng finalizer và cleanup như phương án dự phòng.
Cleanup và finalizer phù hợp với các tài nguyên dạng bộ nhớ, chẳng hạn bộ nhớ được cấp phát từ bên ngoài, như từ C, hoặc tham chiếu tới một ánh xạ
mmap. Bộ nhớ được cấp phát bởi malloc của C rốt cuộc phải được giải phóng bởi free của C. Một finalizer gọifree, gắn vào một object bọc ngoài cho vùng bộ nhớ C, là một cách hợp lý để bảo đảm bộ nhớ C rốt cuộc được thu hồi như một hệ quả của garbage collection.Tuy nhiên, các tài nguyên không phải bộ nhớ, như file descriptor, thường chịu các giới hạn hệ thống mà Go runtime nhìn chung không biết tới. Thêm nữa, thời điểm garbage collector chạy trong một chương trình Go cho trước thường là thứ mà tác giả package có rất ít quyền kiểm soát (chẳng hạn, GC chạy thường xuyên ra sao là do GOGC điều khiển, mà trên thực tế nó có thể được người vận hành đặt thành đủ loại giá trị khác nhau). Hai điều này kết hợp lại khiến cleanup và finalizer không phù hợp để làm cơ chế duy nhất giải phóng các tài nguyên không phải bộ nhớ.
Nếu bạn là tác giả package phơi bày một API bọc ngoài một tài nguyên không phải bộ nhớ nào đó, hãy cân nhắc cung cấp một API tường minh để giải phóng tài nguyên một cách tất định (qua phương thức
Closehoặc gì đó tương tự), thay vì dựa vào garbage collector thông qua cleanup hay finalizer. Thay vào đó, hãy ưu tiên dùng cleanup và finalizer như một cơ chế nỗ lực tối đa xử lý sai sót của lập trình viên, hoặc bằng cách dù sao cũng dọn dẹp tài nguyên như os.File vẫn làm, hoặc bằng cách báo lại cho người dùng rằng việc dọn dẹp tất định đã thất bại. -
Hãy ưu tiên cleanup hơn finalizer.
Về mặt lịch sử, finalizer được thêm vào để đơn giản hoá giao diện giữa mã Go và mã C và để dọn dẹp các tài nguyên không phải bộ nhớ. Mục đích sử dụng dự kiến là áp dụng chúng lên các object bọc ngoài sở hữu bộ nhớ C hoặc một tài nguyên không phải bộ nhớ nào đó, để tài nguyên có thể được giải phóng khi mã Go đã dùng xong. Những lý do này ít nhất phần nào giải thích vì sao finalizer có phạm vi hẹp, vì sao một object bất kỳ chỉ có thể có một finalizer, và vì sao finalizer đó chỉ có thể được gắn vào byte đầu tiên của object. Hạn chế này vốn đã bóp nghẹt một số trường hợp sử dụng. Ví dụ, bất kỳ package nào muốn lưu đệm nội bộ một số thông tin về một object được truyền vào cho nó đều không thể dọn dẹp thông tin đó khi object biến mất.
Nhưng tệ hơn thế, finalizer kém hiệu quả và dễ gây lỗi vì chúng hồi sinh object mà chúng được gắn vào, để object đó có thể được truyền cho hàm finalizer (và thậm chí tiếp tục sống sau đó nữa). Sự thật đơn giản này nghĩa là nếu object nằm trong một chu trình tham chiếu thì nó không bao giờ có thể được giải phóng, và bộ nhớ nền của object không thể được tái sử dụng ít nhất cho tới vòng garbage collection kế tiếp.
Tuy nhiên, chính vì finalizer hồi sinh object, chúng có thứ tự thực thi được định nghĩa rõ ràng hơn cleanup. Vì lý do đó, finalizer vẫn có thể (nhưng hiếm khi) hữu ích cho việc dọn dẹp các cấu trúc có yêu cầu phức tạp về thứ tự huỷ.
Nhưng với mọi cách dùng khác trong Go 1.24 trở đi, chúng tôi khuyên bạn dùng cleanup vì chúng linh hoạt hơn, ít gây lỗi hơn và hiệu quả hơn finalizer.
Các vấn đề thường gặp với cleanup
- Các object có gắn cleanup không được phép tới được từ hàm cleanup (ví dụ, thông qua một biến cục bộ bị bắt giữ). Điều đó sẽ ngăn object được thu hồi và ngăn cleanup được chạy.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
syscall.Close(f.fd) // Mistake: We reference f, so this cleanup won't run!
}, f.fd)
- Các object có gắn cleanup không được phép tới được từ đối số truyền cho hàm cleanup. Điều đó sẽ ngăn object được thu hồi và ngăn cleanup được chạy.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
syscall.Close(f.fd)
}, f) // Mistake: We reference f, so this cleanup wouldn't ever run. This specific case also panics.
-
Finalizer có thứ tự thực thi được định nghĩa rõ ràng, còn cleanup thì không. Các cleanup cũng có thể chạy đồng thời với nhau.
-
Các cleanup chạy lâu nên tạo một goroutine để tránh chặn việc thực thi của các cleanup khác.
-
runtime.GCsẽ không chờ cho tới khi cleanup của các object không tới được được thực thi xong, mà chỉ chờ tới khi tất cả chúng được xếp hàng.
Các vấn đề thường gặp với weak pointer
-
Weak pointer có thể bắt đầu trả về
niltừ phương thứcValuecủa chúng vào những lúc bất ngờ. Hãy luôn bảo vệ lời gọiValuebằng một phép kiểm tranilvà có sẵn phương án dự phòng. -
Khi weak pointer được dùng làm khoá của map, chúng không ảnh hưởng tới tính tới được của các giá trị trong map. Do đó, nếu một khoá map là weak pointer trỏ tới một object mà object đó cũng tới được từ giá trị trong map, thì object đó vẫn sẽ được coi là tới được.
Các vấn đề thường gặp với finalizer
- Các object có gắn finalizer không được phép tới được từ chính chúng theo bất kỳ đường nào (nói cách khác, chúng không thể nằm trong một chu trình tham chiếu). Điều đó sẽ ngăn object được thu hồi và ngăn finalizer được chạy.
f := new(myCycle)
f.self = f // Mistake: f is reachable from f, so this finalizer would never run.
runtime.SetFinalizer(f, func(f *myCycle) {
...
})
- Các object có gắn finalizer không được phép tới được từ hàm finalizer (ví dụ, thông qua một biến cục bộ bị bắt giữ). Điều đó sẽ ngăn object được thu hồi và ngăn finalizer được chạy.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
syscall.Close(f.fd) // Mistake: We reference the outer f, so this cleanup won't run!
})
- Các chuỗi tham chiếu gồm những object có gắn finalizer (chẳng hạn trong một danh sách liên kết) cần tối thiểu số vòng GC bằng số object trong chuỗi để dọn dẹp hết chúng. Hãy giữ cho finalizer nông!
// Mistake: reclaiming this linked list will take at least 10 GC cycles.
node := new(linkedListNode)
for range 10 {
tmp := new(linkedListNode)
tmp.next = node
node = tmp
runtime.SetFinalizer(node, func(node *linkedListNode) {
...
})
}
-
Hãy tránh đặt finalizer lên các object được trả ra ở ranh giới package. Điều đó khiến người dùng package của bạn có thể gọi
runtime.SetFinalizerđể thay đổi finalizer trên object bạn trả về, vốn có thể là một hành vi bất ngờ mà người dùng package của bạn rốt cuộc lại dựa vào. -
Các finalizer chạy lâu nên tạo một goroutine mới để tránh chặn việc thực thi của các finalizer khác.
-
runtime.GCsẽ không chờ cho tới khi finalizer của các object không tới được được thực thi xong, mà chỉ chờ tới khi tất cả chúng được xếp hàng.
Kiểm thử cái chết của object
Khi dùng các tính năng này, đôi khi việc viết test cho đoạn mã sử dụng chúng khá khó nhằn. Dưới đây là một số mẹo để viết test vững chắc cho mã dùng các tính năng này.
-
Tránh chạy những test như vậy song song với các test khác. Nó giúp ích rất nhiều trong việc tăng tính tất định hết mức có thể và nắm được rõ trạng thái của thế giới tại bất kỳ thời điểm nào.
-
Dùng
runtime.GCđể thiết lập một mốc nền khi bắt đầu vào test. Dùngruntime.GCđể buộc weak pointer trở thànhnil, và để xếp hàng cleanup và finalizer cho việc chạy. -
runtime.GCkhông chờ cleanup và finalizer chạy xong, nó chỉ xếp hàng chúng.Để viết test vững chắc nhất có thể, hãy chèn vào một cách để chặn chờ một cleanup hoặc finalizer từ test của bạn (ví dụ, truyền một channel tuỳ chọn từ test vào cleanup và/hoặc finalizer, rồi ghi vào channel đó khi nó đã thực thi xong). Nếu việc này quá khó hoặc bất khả thi, một phương án khác là quay vòng chờ cho tới khi một trạng thái hậu-cleanup cụ thể trở thành đúng. Ví dụ, các test của
osgọiruntime.Goschedtrong một vòng lặp kiểm tra xem một tệp đã được đóng hay chưa, sau khi nó trở nên không tới được. -
Nếu bạn viết test cho việc dùng finalizer, và bạn có một chuỗi object dùng finalizer, bạn sẽ cần tối thiểu số lời gọi
runtime.GCbằng độ dài của chuỗi sâu nhất mà test có thể tạo ra, để bảo đảm mọi finalizer đều được chạy. -
Hãy test ở chế độ race để phát hiện các race giữa những cleanup chạy đồng thời, và giữa mã cleanup, mã finalizer với phần còn lại của codebase.
Tài nguyên bổ sung
Tuy thông tin trình bày ở trên là chính xác, nó thiếu chi tiết để hiểu trọn vẹn các chi phí và đánh đổi trong thiết kế GC của Go. Để biết thêm thông tin, xem các tài nguyên bổ sung sau đây.
- The GC Handbook—Một tài nguyên tổng quát và tài liệu tham khảo tuyệt vời về thiết kế garbage collector.
- TCMalloc—Tài liệu thiết kế của bộ cấp phát bộ nhớ C/C++ TCMalloc, thứ mà bộ cấp phát bộ nhớ của Go dựa trên.
- Thông báo GC của Go 1.5—Bài blog thông báo GC đồng thời của Go 1.5, mô tả thuật toán chi tiết hơn.
- Getting to Go—Một bài trình bày chuyên sâu về sự tiến hoá trong thiết kế GC của Go cho tới năm 2018.
- Pacing GC đồng thời của Go 1.5—Tài liệu thiết kế về việc xác định khi nào bắt đầu một mark phase đồng thời.
- Smarter scavenging—Tài liệu thiết kế về việc chỉnh sửa cách Go runtime trả bộ nhớ về cho hệ điều hành.
- Scalable page allocator—Tài liệu thiết kế về việc chỉnh sửa cách Go runtime quản lý bộ nhớ lấy từ hệ điều hành.
- Thiết kế lại GC pacer (Go 1.18)—Tài liệu thiết kế về việc chỉnh sửa thuật toán xác định khi nào bắt đầu một mark phase đồng thời.
- Giới hạn bộ nhớ mềm (Go 1.19)—Tài liệu thiết kế về giới hạn bộ nhớ mềm.
Đôi lời về virtual memory
Hướng dẫn này chủ yếu tập trung vào lượng bộ nhớ vật lý mà GC sử dụng, nhưng một câu hỏi thường xuyên được đặt ra là điều đó chính xác nghĩa là gì và nó khác gì so với virtual memory (bộ nhớ ảo, thường được các chương trình như top hiển thị dưới tên “VSS”).
Bộ nhớ vật lý là bộ nhớ nằm trong chip RAM vật lý thực sự trên phần lớn máy tính. Virtual memory là một lớp trừu tượng phủ lên bộ nhớ vật lý do hệ điều hành cung cấp nhằm cô lập các chương trình với nhau. Việc một chương trình đặt trước (reserve) vùng địa chỉ ảo hoàn toàn không ánh xạ tới bất kỳ địa chỉ vật lý nào cũng thường là điều chấp nhận được.
Vì virtual memory chỉ là một ánh xạ do hệ điều hành duy trì, nên việc đặt trước những vùng virtual memory lớn mà không ánh xạ tới bộ nhớ vật lý thường có chi phí rất thấp.
Go runtime nhìn chung dựa vào quan điểm này về chi phí của virtual memory theo một vài cách:
-
Go runtime không bao giờ xoá virtual memory mà nó đã ánh xạ. Thay vào đó, nó dùng các thao tác đặc biệt mà hầu hết hệ điều hành cung cấp để giải phóng tường minh mọi tài nguyên bộ nhớ vật lý gắn với một khoảng virtual memory nào đó.
Kỹ thuật này được dùng một cách tường minh để quản lý memory limit và trả bộ nhớ về cho hệ điều hành khi Go runtime không còn cần đến nữa. Go runtime cũng liên tục giải phóng bộ nhớ không còn cần đến ở chế độ nền. Xem phần tài nguyên bổ sung để biết thêm thông tin.
-
Trên nền tảng 32-bit, Go runtime đặt trước từ 128 MiB đến 512 MiB không gian địa chỉ ngay từ đầu cho heap nhằm hạn chế vấn đề phân mảnh.
-
Go runtime dùng những vùng đặt trước không gian địa chỉ virtual memory lớn khi hiện thực một số cấu trúc dữ liệu nội bộ. Trên nền tảng 64-bit, các cấu trúc này thường chiếm tối thiểu khoảng 700 MiB virtual memory. Trên nền tảng 32-bit, lượng chiếm dụng của chúng là không đáng kể.
Do đó, các chỉ số virtual memory như “VSS” trong top thường không mấy hữu ích để hiểu lượng bộ nhớ mà một chương trình Go chiếm dụng. Thay vào đó, hãy tập trung vào “RSS” và các phép đo tương tự, vì chúng phản ánh trực tiếp hơn mức sử dụng bộ nhớ vật lý.
Hướng dẫn tối ưu hoá
Xác định chi phí
Trước khi tìm cách tối ưu hoá cách ứng dụng Go của bạn tương tác với GC, điều quan trọng trước tiên là xác định xem GC có thực sự là một chi phí lớn hay không.
Hệ sinh thái Go cung cấp nhiều công cụ để xác định chi phí và tối ưu hoá ứng dụng Go. Để có cái nhìn tổng quan ngắn gọn về những công cụ này, xem hướng dẫn về chẩn đoán. Ở đây, chúng tôi sẽ tập trung vào một nhóm nhỏ trong số các công cụ đó và một trình tự áp dụng hợp lý để hiểu tác động và hành vi của GC.
-
CPU profile
Một điểm khởi đầu tốt là CPU profiling. CPU profiling cho bạn cái nhìn tổng quan về thời gian CPU được tiêu tốn ở đâu, mặc dù với người chưa quen thì có thể khó nhận ra mức độ ảnh hưởng của GC trong một ứng dụng cụ thể. May thay, việc hiểu GC nằm ở đâu chủ yếu quy về việc biết ý nghĩa của các hàm khác nhau trong package `runtime`. Dưới đây là một nhóm hàm hữu ích để diễn giải CPU profile.
Lưu ý rằng các hàm liệt kê dưới đây không phải là hàm lá, nên chúng có thể không xuất hiện trong kết quả mặc định mà công cụ
pprofđưa ra với lệnhtop. Thay vào đó, hãy dùng lệnhtop -cumhoặc dùng lệnhlisttrực tiếp lên các hàm này và chú ý cột phần trăm tích luỹ.
-
runtime.gcBgMarkWorker: Điểm vào của các goroutine mark worker chạy nền. Thời gian tiêu tốn ở đây tỉ lệ thuận với tần suất GC cũng như độ phức tạp và kích thước của đồ thị object. Nó thể hiện mức nền về lượng thời gian ứng dụng dành cho việc mark và scan.Lưu ý rằng bên trong các goroutine này, bạn sẽ thấy các lời gọi tới
runtime.gcDrainMarkWorkerDedicated,runtime.gcDrainMarkWorkerFractionalvàruntime.gcDrainMarkWorkerIdle, cho biết loại worker. Trong một ứng dụng Go phần lớn thời gian rảnh rỗi, Go GC sẽ tận dụng tài nguyên CPU (nhàn rỗi) dư ra để hoàn thành công việc nhanh hơn, và điều đó được thể hiện qua ký hiệuruntime.gcDrainMarkWorkerIdle. Do đó, thời gian ở đây có thể chiếm một phần lớn số mẫu CPU, nhưng Go GC coi đó là phần miễn phí. Nếu ứng dụng trở nên bận rộn hơn, thời gian CPU trong các idle worker sẽ giảm xuống. Một nguyên nhân phổ biến khiến điều này xảy ra là khi ứng dụng chạy hoàn toàn trong một goroutine nhưngGOMAXPROCSlại >1. -
runtime.mallocgc: Điểm vào của bộ cấp phát bộ nhớ cho heap. Thời gian tích luỹ lớn ở đây (>15%) thường cho thấy có rất nhiều bộ nhớ đang được cấp phát. -
runtime.gcAssistAlloc: Hàm mà các goroutine đi vào để nhường một phần thời gian của mình nhằm hỗ trợ GC scan và mark. Thời gian tích luỹ lớn ở đây (>5%) cho thấy ứng dụng nhiều khả năng đang cấp phát nhanh hơn tốc độ GC xử lý. Nó cho thấy mức độ ảnh hưởng đặc biệt cao từ GC, đồng thời cũng thể hiện thời gian ứng dụng dành cho mark và scan. Lưu ý rằng phần này nằm trong cây lời gọi củaruntime.mallocgc, nên nó cũng làm con số đó tăng theo.
-
Execution trace
Trong khi CPU profile rất hữu ích để xác định thời gian được tiêu tốn ở đâu trên tổng thể, chúng lại ít hữu ích hơn trong việc chỉ ra những chi phí hiệu năng tinh tế hơn, hiếm gặp hơn, hoặc liên quan riêng đến latency. Ngược lại, execution trace cung cấp một góc nhìn phong phú và sâu vào một khoảng ngắn trong quá trình thực thi của chương trình Go. Chúng chứa nhiều loại sự kiện liên quan tới Go GC và ta có thể quan sát trực tiếp các đường thực thi cụ thể, cùng với cách ứng dụng tương tác với Go GC. Mọi sự kiện GC được theo dõi đều được gắn nhãn rõ ràng trong trình xem trace.
Xem tài liệu của package
runtime/traceđể biết cách bắt đầu với execution trace. -
GC trace
Khi mọi cách khác đều thất bại, Go GC cung cấp một vài loại trace chuyên biệt giúp nhìn sâu hơn nhiều vào hành vi của GC. Các trace này luôn được in trực tiếp ra STDERR, mỗi chu kỳ GC một dòng, và được cấu hình qua biến môi trường
GODEBUGmà mọi chương trình Go đều nhận biết. Chúng chủ yếu hữu ích cho việc gỡ lỗi chính Go GC vì đòi hỏi sự quen thuộc nhất định với chi tiết hiện thực của GC, nhưng đôi khi vẫn có thể hữu ích để hiểu rõ hơn về hành vi của GC.GC trace cốt lõi được bật bằng cách đặt
GODEBUG=gctrace=1. Kết quả mà trace này tạo ra được mô tả trong phần biến môi trường trong tài liệu của packageruntime.Một GC trace bổ sung gọi là “pacer trace” cho cái nhìn còn sâu hơn nữa và được bật bằng cách đặt
GODEBUG=gcpacertrace=1. Việc diễn giải kết quả này đòi hỏi hiểu biết về “pacer” của GC (xem tài nguyên bổ sung), vốn nằm ngoài phạm vi của hướng dẫn này.
Loại bỏ allocation trên heap
Một cách để giảm chi phí từ GC là ngay từ đầu để GC phải quản lý ít giá trị hơn. Các kỹ thuật mô tả dưới đây có thể mang lại một số cải thiện lớn nhất về hiệu năng, bởi như phần GOGC đã cho thấy, tốc độ allocation của một chương trình Go là yếu tố chính quyết định tần suất GC, chỉ số chi phí then chốt được dùng trong hướng dẫn này.
Heap profiling
Sau khi đã xác định được rằng GC là nguồn gây chi phí đáng kể, bước tiếp theo để loại bỏ allocation trên heap là tìm ra phần lớn chúng đến từ đâu. Với mục đích này, memory profile (thực chất là heap memory profile) rất hữu ích. Hãy xem tài liệu để biết cách bắt đầu.
Memory profile mô tả các allocation trên heap trong chương trình đến từ đâu, nhận diện chúng qua stack trace tại thời điểm chúng được cấp phát. Mỗi memory profile có thể phân tách bộ nhớ theo bốn cách.
inuse_objects—Phân tách số lượng object đang live.inuse_space—Phân tách các object đang live theo lượng bộ nhớ chúng dùng, tính bằng byte.alloc_objects—Phân tách số lượng object đã được cấp phát kể từ khi chương trình Go bắt đầu chạy.alloc_space—Phân tách tổng lượng bộ nhớ đã được cấp phát kể từ khi chương trình Go bắt đầu chạy.
Bạn có thể chuyển đổi giữa các góc nhìn khác nhau này về bộ nhớ heap bằng cờ -sample_index của công cụ pprof, hoặc qua tuỳ chọn sample_index khi dùng công cụ ở chế độ tương tác.
Lưu ý: theo mặc định, memory profile chỉ lấy mẫu một phần các object trên heap nên chúng sẽ không chứa thông tin về từng allocation trên heap. Tuy nhiên, như vậy là đủ để tìm ra các điểm nóng. Để thay đổi tần suất lấy mẫu, xem runtime.MemProfileRate.
Nhằm mục đích giảm chi phí GC, alloc_space thường là góc nhìn hữu ích nhất vì nó tương ứng trực tiếp với tốc độ allocation. Góc nhìn này sẽ chỉ ra những điểm nóng allocation mang lại lợi ích lớn nhất.
Escape analysis
Khi đã xác định được các vị trí allocation trên heap tiềm năng nhờ heap profile, làm sao để loại bỏ chúng? Mấu chốt là tận dụng escape analysis của trình biên dịch Go để nó tìm chỗ lưu trữ thay thế và hiệu quả hơn cho vùng bộ nhớ này, chẳng hạn trên stack của goroutine. May thay, trình biên dịch Go có khả năng mô tả vì sao nó quyết định cho một giá trị Go escape ra heap. Với hiểu biết đó, vấn đề trở thành việc sắp xếp lại mã nguồn để thay đổi kết quả phân tích (thường là phần khó nhất, nhưng nằm ngoài phạm vi của hướng dẫn này).
Còn về cách lấy thông tin từ escape analysis của trình biên dịch Go, cách đơn giản nhất là qua một cờ debug mà trình biên dịch Go hỗ trợ, mô tả mọi tối ưu hoá mà nó đã hoặc không áp dụng cho một package nào đó ở dạng văn bản. Điều này bao gồm cả việc giá trị có escape hay không. Hãy thử lệnh sau, trong đó [package] là đường dẫn tới một package Go.
$ go build -gcflags=-m=3 [package]
Thông tin này cũng có thể được hiển thị dưới dạng lớp phủ trong một trình soạn thảo hỗ trợ LSP; nó được cung cấp dưới dạng một code action. Ví dụ, trong VS Code, hãy gọi lệnh “Source Action… > Show compiler optimization details” để bật chẩn đoán cho package hiện tại. (Bạn cũng có thể chạy lệnh “Go: Toggle compiler optimization details”.) Dùng thiết lập cấu hình sau để kiểm soát những chú giải nào được hiển thị:
- Bật lớp phủ cho escape analysis bằng cách đặt
ui.diagnostic.annotationssao cho bao gồmescape.
Cuối cùng, trình biên dịch Go cung cấp thông tin này ở định dạng máy đọc được (JSON) để có thể dùng xây dựng thêm các công cụ tuỳ chỉnh. Để biết thêm về điều đó, xem tài liệu trong mã nguồn Go.
Các tối ưu hoá đặc thù cho hiện thực
Go GC nhạy cảm với đặc điểm cấu thành của bộ nhớ live, vì một đồ thị object và pointer phức tạp vừa hạn chế tính song song vừa tạo thêm việc cho GC. Do đó, GC có một vài tối ưu hoá dành cho các cấu trúc phổ biến cụ thể. Những tối ưu hoá hữu ích trực tiếp nhất cho việc tối ưu hiệu năng được liệt kê dưới đây.
Lưu ý: Áp dụng các tối ưu hoá dưới đây có thể làm giảm khả năng đọc hiểu mã của bạn do che mờ ý định, và có thể không còn đúng qua các bản phát hành Go. Hãy chỉ áp dụng những tối ưu hoá này ở những nơi chúng quan trọng nhất. Có thể xác định những nơi đó bằng các công cụ được liệt kê trong phần về xác định chi phí.
-
Các giá trị không chứa pointer được tách riêng khỏi những giá trị khác.
Do đó, có thể sẽ có lợi nếu loại bỏ pointer khỏi những cấu trúc dữ liệu không thực sự cần đến chúng, vì điều này giảm áp lực cache mà GC gây ra cho chương trình. Theo đó, các cấu trúc dữ liệu dựa trên chỉ số thay vì giá trị pointer, dù kém chặt chẽ về kiểu hơn, có thể chạy tốt hơn. Chỉ nên làm vậy khi đã rõ rằng đồ thị object là phức tạp và GC đang dành nhiều thời gian để mark và scan.
-
GC sẽ dừng scan một giá trị tại pointer cuối cùng trong giá trị đó.
Do đó, có thể sẽ có lợi nếu gom các trường pointer trong những giá trị kiểu struct về đầu giá trị. Chỉ nên làm vậy khi đã rõ rằng ứng dụng dành nhiều thời gian để mark và scan. (Về lý thuyết trình biên dịch có thể làm việc này tự động, nhưng điều đó chưa được hiện thực, và các trường struct được sắp xếp đúng như viết trong mã nguồn.)
Hơn nữa, GC phải tương tác với gần như mọi pointer mà nó thấy, nên việc dùng chỉ số vào một slice, chẳng hạn, thay cho pointer, có thể giúp giảm chi phí GC.
Transparent huge pages (THP) trên Linux
Khi một chương trình truy cập bộ nhớ, CPU cần dịch các địa chỉ virtual memory mà nó dùng thành các địa chỉ bộ nhớ vật lý trỏ tới dữ liệu mà nó muốn truy cập. Để làm việc đó, CPU tra “page table”, một cấu trúc dữ liệu biểu diễn ánh xạ từ bộ nhớ ảo sang bộ nhớ vật lý, do hệ điều hành quản lý. Mỗi mục trong page table đại diện cho một khối bộ nhớ vật lý không thể chia nhỏ gọi là page, và tên gọi bắt nguồn từ đó.
Transparent huge pages (THP) là một tính năng của Linux, thay thế một cách trong suốt các page bộ nhớ vật lý nằm dưới những vùng virtual memory liền kề bằng những khối bộ nhớ lớn hơn gọi là huge page. Nhờ dùng khối lớn hơn, cần ít mục page table hơn để biểu diễn cùng một vùng bộ nhớ, giúp cải thiện thời gian tra cứu page table. Tuy nhiên, khối lớn hơn cũng nghĩa là lãng phí nhiều hơn nếu hệ thống chỉ dùng một phần nhỏ của huge page.
Khi chạy chương trình Go trong môi trường production, bật transparent huge pages trên Linux có thể cải thiện throughput và latency, đổi lại là dùng thêm bộ nhớ. Các ứng dụng có heap nhỏ thường không hưởng lợi từ THP và có thể phải dùng thêm một lượng bộ nhớ đáng kể (lên tới 50%). Tuy nhiên, các ứng dụng có heap lớn (1 GiB trở lên) thường hưởng lợi khá nhiều (throughput tăng tới 10%) mà chi phí bộ nhớ phụ trội không nhiều (1-2% hoặc ít hơn). Trong cả hai trường hợp, biết rõ thiết lập THP của mình đều có ích, và luôn nên thử nghiệm.
Bạn có thể bật hoặc tắt transparent huge pages trong môi trường Linux bằng cách sửa /sys/kernel/mm/transparent_hugepage/enabled. Xem hướng dẫn quản trị Linux chính thức để biết thêm chi tiết. Nếu bạn chọn bật transparent huge pages cho môi trường production Linux của mình, chúng tôi khuyến nghị các thiết lập bổ sung sau cho chương trình Go.
-
Đặt
/sys/kernel/mm/transparent_hugepage/defragthànhdeferhoặcdefer+madvise.
Thiết lập này kiểm soát mức độ quyết liệt mà nhân Linux gộp các page thường thành huge page.deferbảo nhân gộp huge page một cách lười biếng và ở chế độ nền. Một thiết lập quyết liệt hơn có thể gây đình trệ trên các hệ thống hạn chế bộ nhớ và thường làm xấu đi latency của ứng dụng.defer+madvisegiốngdefer, nhưng thân thiện hơn với các ứng dụng khác trên hệ thống vốn yêu cầu huge page một cách tường minh và cần chúng để đạt hiệu năng. -
Đặt
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_nonethành0.
Thiết lập này kiểm soát số page bổ sung mà tiến trình nền của nhân Linux có thể cấp phát khi cố cấp phát một huge page. Giá trị mặc định là quyết liệt tối đa, và thường có thể phá hỏng công sức mà Go runtime bỏ ra để trả bộ nhớ về cho hệ điều hành. Trước Go 1.21, Go runtime cố gắng giảm nhẹ các tác động tiêu cực của thiết lập mặc định, nhưng điều đó đi kèm chi phí CPU. Với Go 1.21+ và Linux 6.2+, Go runtime không còn thay đổi trạng thái huge page nữa.
Nếu bạn thấy mức sử dụng bộ nhớ tăng lên khi nâng cấp lên Go 1.21.1 hoặc mới hơn, hãy thử áp dụng thiết lập này; nhiều khả năng nó sẽ giải quyết vấn đề của bạn. Như một cách khắc phục bổ sung, bạn có thể gọi hàmPrctlvớiPR_SET_THP_DISABLEđể tắt huge page ở cấp tiến trình, hoặc bạn có thể đặtGODEBUG=disablethp=1(sẽ được bổ sung trong Go 1.21.6 và Go 1.22) để tắt huge page cho bộ nhớ heap. Lưu ý rằng thiết lậpGODEBUGnày có thể bị loại bỏ trong một bản phát hành tương lai.
Phụ lục
Ghi chú bổ sung về GOGC
Phần GOGC đã khẳng định rằng tăng gấp đôi GOGC sẽ tăng gấp đôi chi phí bộ nhớ heap và giảm một nửa chi phí CPU của GC. Để thấy vì sao, hãy phân tích bằng toán học.
Trước hết, heap target đặt ra một mục tiêu cho tổng kích thước heap. Tuy nhiên, mục tiêu này chủ yếu ảnh hưởng tới phần bộ nhớ heap mới, vì live heap là phần cốt lõi của ứng dụng.
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
Total heap memory = Live heap + New heap memory
⇒
New heap memory = (Live heap + GC roots) * GOGC / 100
Từ đây có thể thấy rằng tăng gấp đôi GOGC cũng sẽ tăng gấp đôi lượng bộ nhớ heap mới mà ứng dụng cấp phát trong mỗi chu kỳ, và đó chính là chi phí bộ nhớ heap. Lưu ý rằng Live heap + GC roots là một xấp xỉ cho lượng bộ nhớ mà GC cần scan.
Tiếp theo, hãy xét chi phí CPU của GC. Tổng chi phí có thể phân tách thành chi phí mỗi chu kỳ, nhân với tần suất GC trong một khoảng thời gian T nào đó.
Total GC CPU cost = (GC CPU cost per cycle) * (GC frequency) * T
Chi phí CPU của GC mỗi chu kỳ có thể suy ra từ mô hình GC:
GC CPU cost per cycle = (Live heap + GC roots) * (Cost per byte) + Fixed cost
Lưu ý rằng chi phí của pha sweep được bỏ qua ở đây vì chi phí mark và scan chiếm ưu thế.
Trạng thái ổn định được định nghĩa bởi tốc độ allocation không đổi và chi phí mỗi byte không đổi, nên ở trạng thái ổn định ta có thể suy ra tần suất GC từ lượng bộ nhớ heap mới này:
GC frequency = (Allocation rate) / (New heap memory) = (Allocation rate) / ((Live heap + GC roots) * GOGC / 100)
Ghép lại, ta có công thức đầy đủ cho tổng chi phí:
Total GC CPU cost = (Allocation rate) / ((Live heap + GC roots) * GOGC / 100) * ((Live heap + GC roots) * (Cost per byte) + Fixed cost) * T
Với một heap đủ lớn (tức là phần lớn các trường hợp), chi phí biên của một chu kỳ GC chiếm ưu thế so với chi phí cố định. Điều này cho phép đơn giản hoá đáng kể công thức tổng chi phí CPU của GC.
Total GC CPU cost = (Allocation rate) / (GOGC / 100) * (Cost per byte) * T
Từ công thức rút gọn này, ta thấy rằng nếu tăng gấp đôi GOGC thì tổng chi phí CPU của GC giảm một nửa. (Lưu ý rằng các biểu đồ trong hướng dẫn này có mô phỏng cả chi phí cố định, nên chi phí CPU của GC mà chúng báo cáo sẽ không giảm đúng một nửa khi GOGC tăng gấp đôi.) Hơn nữa, chi phí CPU của GC chủ yếu được quyết định bởi tốc độ allocation và chi phí trên mỗi byte để scan bộ nhớ. Để biết thêm về cách giảm riêng các chi phí này, xem hướng dẫn tối ưu hoá.
Lưu ý: tồn tại một sự chênh lệch giữa kích thước của live heap và lượng bộ nhớ trong đó mà GC thực sự cần scan: cùng một live heap có kích thước như nhau nhưng cấu trúc khác nhau sẽ dẫn tới chi phí CPU khác nhau, trong khi chi phí bộ nhớ vẫn như nhau, tạo ra một đánh đổi khác biệt. Đó là lý do cấu trúc của heap là một phần trong định nghĩa của trạng thái ổn định. Có lẽ heap target chỉ nên tính phần live heap có thể scan được, coi như một xấp xỉ gần hơn cho lượng bộ nhớ mà GC cần scan, nhưng điều đó dẫn tới hành vi suy biến khi lượng live heap có thể scan rất nhỏ trong khi live heap nói chung lại lớn.