Hướng dẫn về Bộ gom rác Go
Giới thiệu
Hướng dẫn này nhằm giúp những người dùng Go nâng cao hiểu rõ hơn về chi phí của ứng dụng bằng cách cung cấp góc nhìn sâu vào bộ gom rác của Go. Ngoài ra, tài liệu cũng hướng dẫn cách người dùng Go có thể tận dụng những hiểu biết đó để cải thiện việc sử dụng tài nguyên của ứng dụng. Tài liệu không đòi hỏi kiến thức trước về thu gom rác, nhưng giả định người đọc đã quen với ngôn ngữ lập trình Go.
Ngôn ngữ Go chịu trách nhiệm sắp xếp nơi lưu trữ các giá trị Go; trong hầu hết các trường hợp, lập trình viên Go không cần quan tâm đến việc các giá trị đó được lưu ở đâu hay tại sao. Tuy nhiên, trên thực tế, các giá trị này thường phải được lưu vào bộ nhớ vật lý của máy tính, và bộ nhớ vật lý là tài nguyên hữu hạn. Do 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 khi thực thi chương trình Go. Việc cấp phát và thu hồi bộ nhớ khi cần là trách nhiệm của phần triển khai Go.
Một thuật ngữ khác cho việc tự động thu hồi bộ nhớ là thu gom rác. Ở mức cao, bộ gom rác (hay viết tắt là GC) là một hệ thống thu hồi 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ộ công cụ chuẩn của Go cung cấp một thư viện runtime đi kèm với mọi ứng dụng, và thư viện runtime này bao gồm một bộ gom rác.
Lưu ý rằng sự tồn tại của một bộ gom rác theo mô tả trong hướng dẫn này không được đặc tả Go đảm bảo, mà chỉ đảm bảo rằng bộ nhớ lưu trữ các giá trị Go được quản lý bởi chính ngôn ngữ. Sự bỏ qua có chủ ý này cho phép sử dụng các kỹ thuật quản lý bộ nhớ hoàn toàn khác nhau.
Vì vậy, hướng dẫn này nói về một triển khai cụ thể của ngôn ngữ lập trình Go và
có thể không áp dụng cho các triển khai khác.
Cụ thể, hướng dẫn sau đây áp dụng cho bộ công cụ 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 triển khai GC rất giống nhau nên nhiều khái niệm tương tự
vẫn áp dụng, nhưng chi tiết có thể khác.
Ngoài ra, đây là 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 mới nhất của Go. Tài liệu này hiện mô tả bộ gom rác tính đến Go 1.19.
Giá trị Go được lưu ở đâu
Trước khi đi sâu vào GC, hãy cùng thảo luận về bộ nhớ không cần GC quản lý.
Chẳng hạn, các giá trị Go không có con trỏ được lưu trong biến cục bộ thường sẽ không được GC của Go quản lý, và Go thay vào đó sẽ sắp xếp để cấp phát bộ nhớ gắn liền với phạm vi từ vựng nơi biến đó được tạo. Nhìn chung, cách này hiệu quả hơn so với dựa vào GC, vì trình biên dịch Go có thể xác định trước khi nào bộ nhớ đó có thể được giải phóng và phát ra các lệnh máy để dọn dẹp. Thông thường, chúng ta gọi cách cấp phát bộ nhớ cho các giá trị Go theo cách này là "cấp phát trên stack," vì không gian đó được lưu trên ngăn xếp goroutine.
Các giá trị Go mà bộ nhớ không thể cấp phát theo cách đó, vì trình biên dịch Go không thể xác định vòng đời của chúng, được gọi là thoát ra heap. "Heap" có thể được hiểu là nơi chứa mọi thứ khi cấp phát bộ nhớ, cho những trường hợp 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à "cấp phát bộ nhớ động" vì cả trình biên dịch lẫn runtime đều không thể đưa ra nhiều giả định về cách bộ nhớ này được sử dụng và khi nào nó có thể được dọn dẹp. Đó là lúc GC phát huy tác dụng: nó là một hệ thống chuyên xác định và dọn dẹp các lần cấp phát bộ nhớ động.
Có nhiều lý do khiến một giá trị Go có thể phải thoát 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 xem xét mảng nền của một slice mà kích thước ban đầu được xác định bởi một biến thay vì một hằng số. Lưu ý rằng việc thoát ra heap cũng phải 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à phải thoát ra, thì giá trị đó cũng phải thoát ra.
Một giá trị Go có thoát ra hay không là hàm của ngữ cảnh sử dụng nó và thuật toán phân tích thoát của trình biên dịch Go. Việc cố gắng liệt kê chính xác khi nào giá trị thoát ra là điều dễ sai và khó làm: bản thân thuật toán khá phức tạp 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 thoát ra và giá trị nào không, hãy xem phần loại bỏ cấp phát heap.
Thu gom rác tracing
Thu gom rác có thể đề cập đến nhiều phương pháp tự động thu hồi bộ nhớ khác nhau; ví dụ, đếm tham chiếu. Trong phạm vi tài liệu này, thu gom rác đề cập đến thu gom rác tracing, tức là xác định các đối tượng đang được sử dụng, gọi là đối tượng sống, bằng cách theo dõi các con trỏ 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.
-
Đối tượng—Một đối tượng là một phần bộ nhớ được cấp phát động chứa một hoặc nhiều giá trị Go.
-
Con trỏ—Một địa chỉ bộ nhớ tham chiếu đến bất kỳ giá trị nào bên trong một đối tượng. Điều này bao gồm các giá trị Go có dạng
*T, nhưng cũng bao gồm các phần của các giá trị Go tích hợp sẵn. String, slice, channel, map, và giá trị interface đều chứa các địa chỉ bộ nhớ mà GC phải tracing.
Cùng nhau, các đối tượng và các con trỏ đến đối tượng khác tạo thành đồ thị đối tượng. Để xác định bộ nhớ đang sống, GC duyệt đồ thị đối tượng bắt đầu từ các gốc của chương trình, tức các con trỏ xác định những đối tượng chắc chắn đang được sử dụng bởi chương trình. Hai ví dụ về gốc là biến cục bộ và biến toàn cục. Quá trình duyệt đồ thị đối tượng được gọi là scanning. Một cụm từ khác bạn có thể thấy trong tài liệu Go là liệu một đối tượng có tiếp cận được hay không, nghĩa là đối tượng đó có thể được phát hiện trong quá trình scanning. Cũng lưu ý rằng, với một ngoại lệ, một khi bộ nhớ trở nên không tiếp cận được, nó vẫn duy trì trạng thái đó.
Thuật toán cơ bản này phổ biến với tất cả các GC tracing. Điểm khác biệt giữa các GC tracing là những gì chúng thực hiện sau khi phát hiện bộ nhớ đang sống. GC của Go sử dụng kỹ thuật đánh dấu-quét, nghĩa là để theo dõi tiến trình, GC cũng đánh dấu các giá trị gặp phải là đang sống. Sau khi hoàn thành tracing, GC duyệt qua toàn bộ bộ nhớ trên heap và làm cho tất cả bộ nhớ chưa được đánh dấu có thể sử dụng cho lần cấp phát tiếp theo. Quá trình này được gọi là quét.
Một kỹ thuật thay thế mà bạn có thể quen thuộc là thực sự di chuyển các đối tượng đến một vùng bộ nhớ mới và để lại một con trỏ chuyển tiếp được dùng sau đó để cập nhật tất cả các con trỏ của ứng dụng. Chúng ta gọi GC di chuyển đối tượng theo cách này là GC di động; Go có GC không di động.
Chu kỳ GC
Vì GC của Go là một GC đánh dấu-quét, nó hoạt động theo hai pha: pha đánh dấu và pha quét. Mặc dù điều này có vẻ hiển nhiên, nhưng nó chứa đựng một hiểu biết quan trọng: không thể giải phóng bộ nhớ để cấp phát lại cho đến khi tất cả bộ nhớ đã được tracing, vì có thể vẫn còn một con trỏ chưa được scan đang giữ cho một đối tượng tồn tại. Do đó, hành động quét phải được tách biệt hoàn toàn khỏi hành động đánh dấu. Hơn nữa, GC cũng có thể không hoạt động gì cả khi không có việc liên quan đến GC cần làm. GC liên tục xoay vòng qua ba pha quét, tắt và đánh dấu trong cái được gọi là chu kỳ GC. Cho mục đích của tài liệu này, hãy coi chu kỳ GC bắt đầu bằng quét, chuyển sang tắt, rồi đánh dấu.
Một vài phần tiếp theo sẽ tập trung vào việc xây dựng trực giác về chi phí của GC để giúp người dùng điều chỉnh các tham số GC theo nhu cầu của mình.
Hiểu về chi phí
GC vốn là một phần mềm phức tạp được xây dựng trên các hệ thống còn phức tạp hơn. Rất dễ bị sa lầy vào chi tiết khi cố gắng hiểu GC và tinh chỉnh hành vi của nó. Phần này nhằm cung cấp một khung tư duy để lý luận về chi phí của GC trong Go và các tham số điều chỉnh của nó.
Để bắt đầu, hãy xem xét mô hình chi phí GC dựa trên ba tiên đề đơn giản.
-
GC chỉ liên quan đến hai tài nguyên: bộ nhớ vật lý và thời gian CPU.
-
Chi phí bộ nhớ của GC bao gồm bộ nhớ heap đang sống, bộ nhớ heap mới được cấp phát trước pha đánh dấu, và không gian cho siêu dữ liệu mà, dù tỉ lệ với các chi phí trước, vẫn nhỏ hơn nhiều so với chúng.
Chi phí bộ nhớ GC cho chu kỳ N = heap sống từ chu kỳ N-1 + heap mới
Bộ nhớ heap sống là bộ nhớ được chu kỳ GC trước xác định là đang sống, trong khi heap mới là bất kỳ bộ nhớ nào được cấp phát trong chu kỳ hiện tại, có thể sống hoặc không vào cuối chu kỳ. Lượng bộ nhớ đang sống tại bất kỳ thời điểm nào là thuộc tính của chương trình, không phải là thứ GC có thể kiểm soát trực tiếp.
-
Chi phí CPU của GC được mô hình hóa là chi phí cố định mỗi chu kỳ, và một chi phí biên tỉ lệ với kích thước của heap đang sống.
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í CPU cố định mỗi chu kỳ bao gồm những thứ xảy ra một số lần cố định mỗi chu kỳ, như khởi tạo cấu trúc dữ liệu cho chu kỳ GC tiếp theo. Chi phí này thường nhỏ và được đưa vào chỉ để hoàn chỉnh.
Phần lớn chi phí CPU của GC là việc đánh dấu và scanning, được thể hiện qua chi phí biên. Chi phí trung bình của đánh dấu và scanning phụ thuộc vào triển khai GC, nhưng cũng phụ thuộc vào hành vi của chương trình. Ví dụ, nhiều con trỏ hơn có nghĩa là GC phải làm nhiều hơn, vì tối thiểu GC cần thăm tất cả các con trỏ trong chương trình. Các cấu trúc như danh sách liên kết và cây cũng khó duyệt song song hơn cho GC, làm tăng chi phí trung bình trên mỗi byte.
Mô hình này bỏ qua chi phí quét, tỉ lệ với tổng bộ nhớ heap, kể cả bộ nhớ đã chết (nó phải được làm sẵn sàng để cấp phát). Đối với triển khai GC hiện tại của Go, quét nhanh hơn nhiều so với đánh dấu và scanning đến mức chi phí là không đáng kể so với chúng.
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 bộ gom rác phụ thuộc vào tổng số chu kỳ GC trong một khoảng thời gian nhất định. Cuối cùng, mô hình này ẩn chứa một sự đánh đổi cơ bản giữa thời gian và không gian cho GC.
Để hiểu lý do, 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. Trạng thái ổn định của một ứng dụng, từ góc nhìn của GC, được định nghĩa bởi các thuộc tính sau:
-
Tốc độ ứng dụng cấp phát bộ nhớ mới (tính bằng byte mỗi giây) là hằng số.
Điều này có nghĩa là, từ 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ư giống nhau theo thời gian. Ví dụ, đối với một dịch vụ web, đây sẽ là một tốc độ yêu cầu không đổi với, trung bình, các loại yêu cầu giống nhau được thực hiện, và thời gian tồn tại trung bình của mỗi yêu cầu duy trì gần như không đổi.
-
Chi phí biên của GC là hằng số.
Điều này có nghĩa là các thống kê của đồ thị đối tượng, như phân phối kích thước đối tượng, số lượng con trỏ, và độ sâu trung bình của các cấu trúc dữ liệu, duy trì giống nhau từ chu kỳ này sang chu kỳ khác.
Hãy làm việc qua một ví dụ. Giả sử một ứng dụng đang hoạt động trong trạng thái ổn định, cấp phát 10 MiB/s, trong khi GC có thể scan bộ nhớ với tốc độ 100 MiB/cpu-giây (đây là con số giả định). Trạng thái ổn định không đưa ra giả định về kích thước của heap sống, nhưng để đơn giản, giả sử heap sống của ứng dụng này luôn là 10 MiB. Cũng giả sử, lại để đơn giản, rằng chi phí GC cố định bằng không. Hãy thử thay đổi chu kỳ GC.
Giả sử mỗi chu kỳ GC xảy ra sau đúng 1 cpu-giây. Thì, vào cuối mỗi chu kỳ GC, ứng dụng ví dụ của chúng ta sẽ đã cấp phát thêm 10 MiB bộ nhớ, dẫn đến tổng kích thước heap là 20 MiB. Và với mỗi chu kỳ GC, GC sẽ mất 0,1 cpu-giây để scan 10 MiB heap sống, dẫn đến chi phí CPU là 10%. Nhớ rằng GC chỉ cần duyệt heap sống, không phải toàn bộ heap. (Lưu ý: heap sống không đổi không có nghĩa là tất cả bộ nhớ mới cấp phát đều chết. Nó có nghĩa là, sau khi GC chạy, một tổ hợp nào đó của bộ nhớ heap cũ và mới chết đi, và chỉ là kết quả cuối cùng là 10 MiB được tìm thấy là sống mỗi chu kỳ.)
Bây giờ giả sử mỗi chu kỳ GC xảy ra ít thường xuyên hơn, mỗi 2 cpu-giây một lần. Thì, ứng dụng ví dụ của chúng ta, trong trạng thái ổn định, sẽ có tổng kích thước heap là 30 MiB trong mỗi chu kỳ GC, vì nó sẽ cấp phát 20 MiB trong thời gian đó. Nhưng với mỗi chu kỳ GC, GC vẫn chỉ cần 0,1 cpu-giây để scan 10 MiB bộ nhớ sống. Một lần nữa, chúng ta giả sử kích thước heap sống vẫn như cũ, bất kể bao nhiêu bộ nhớ được cấp phát. Vậy có nghĩa là chi phí GC của chúng ta vừa giảm xuống, từ 10% xuống 5%, nhưng với cái giá là sử dụng nhiều hơn 50% bộ nhớ.
Sự thay đổi trong chi phí này là sự đánh đổi cơ bản giữa thời gian và không gian đã đề cập trước đó. Và tần suất GC là trung tâm của sự đánh đổi này: nếu chúng ta thực thi GC thường xuyên hơn, chúng ta sử dụng ít bộ nhớ hơn, và ngược lại. Nhưng GC thực sự thực thi bao lâu một lần? Trong Go, việc quyết định khi nào GC nên bắt đầu là tham số chính mà người dùng có thể kiểm soát.
GOGC
Ở mức cao, GOGC xác định sự đánh đổi giữa CPU GC và bộ nhớ.
Nó hoạt động bằng cách xác định kích thước heap mục tiêu sau mỗi chu kỳ GC, một giá trị mục tiêu cho tổng kích thước heap trong chu kỳ tiếp theo. Mục tiêu của GC là hoàn thành một chu kỳ thu gom trước khi tổng kích thước heap vượt quá kích thước heap mục tiêu. Tổng kích thước heap được định nghĩa là kích thước heap sống vào cuối chu kỳ trước, cộng với bất kỳ bộ nhớ heap mới nào được ứng dụng cấp phát kể từ chu kỳ trước. Trong khi đó, bộ nhớ heap mục tiêu được định nghĩa là:
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
Ví dụ, hãy xem xét một chương trình Go với kích thước heap sống là 8 MiB, 1 MiB ngăn xếp goroutine, và 1 MiB con trỏ trong các biến toàn cục. Thì, với giá trị GOGC là 100, lượng bộ nhớ mới được cấp phát trước khi lần GC tiếp theo chạy sẽ là 10 MiB, tức 100% của 10 MiB công việc, với tổng footprint heap là 18 MiB. Với giá trị GOGC là 50, đó sẽ là 50%, hay 5 MiB. Với giá trị GOGC là 200, sẽ là 200%, hay 20 MiB.
Lưu ý: GOGC chỉ bao gồm tập gốc từ Go 1.18 trở đi. Trước đây, nó chỉ tính heap sống. Thông thường, lượng bộ nhớ trên ngăn xếp goroutine khá nhỏ và kích thước heap sống chiếm ưu thế so với các nguồn công việc GC khác, nhưng trong các trường hợp chương trình có hàng trăm nghìn goroutine, GC đưa ra các đánh giá không tốt.
Heap mục tiêu kiểm soát tần suất GC: mục tiêu càng lớn, GC có thể chờ đợi lâu hơn trước khi bắt đầu pha đánh dấu tiếp theo và ngược lại. Mặc dù công thức chính xác hữu ích để ước tính, tốt hơn là nên nghĩ về GOGC theo mục đích cơ bản của nó: một tham số chọn một điểm trong sự đánh đổi giữa CPU GC và bộ nhớ. Điểm mấu chốt là nhân đôi GOGC sẽ nhân đôi chi phí bộ nhớ heap và giảm khoảng một nửa chi phí CPU GC, và ngược lại. (Để xem giải thích đầy đủ về lý do tại sao, hãy xem phụ lục.)
Lưu ý: kích thước heap mục tiêu chỉ là một mục tiêu, và có một số lý do khiến chu kỳ GC có thể không kết thúc ngay tại mục tiêu đó. Trước tiên, một lần cấp phát heap đủ lớn có thể đơn giản là vượt quá mục tiêu. Tuy nhiên, các lý do khác xuất hiện trong các triển khai GC vượt ra ngoài mô hình GC mà hướng dẫn này đã sử dụng cho đến nay. Để biết thêm chi tiết, hãy xem phần độ trễ, nhưng chi tiết đầy đủ có thể tìm thấy trong tài nguyên bổ sung.
GOGC có thể được cấu hình thông qua biến môi trường GOGC (mà tất cả
các chương trình Go nhận biết), hoặc thông qua API
SetGCPercent
trong gói runtime/debug.
Lưu ý rằng GOGC cũng có thể được dùng để tắt hoàn toàn GC (miễn là
giới hạn bộ nhớ không áp dụng) bằng cách đặt
GOGC=off hoặc gọi SetGCPercent(-1).
Về mặt khái niệm, cài đặt này tương đương với việc đặt GOGC thành vô cực, vì lượng
bộ nhớ mới trước khi GC được kích hoạt là không giới hạn.
Để hiểu rõ hơn tất cả những gì đã thảo luận, hãy thử trực quan hóa tương tác bên dưới được xây dựng trên mô hình chi phí GC đã thảo luận trước đó. Trực quan hóa này mô tả việc thực thi một chương trình có công việc không phải GC mất 10 giây CPU để hoàn thành. Trong giây đầu tiên, nó thực hiện một bước khởi tạo (tăng heap sống) 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 sống tại một thời điểm. Nó giả định rằng công việc GC liên quan duy nhất đến từ heap sống, và rằng (không thực tế) ứng dụng không sử dụng thêm bộ nhớ nào.
Dùng thanh trượt để điều chỉnh giá trị GOGC và xem ứng dụng phản hồi như thế nào về tổng thời gian và chi phí GC. Mỗi chu kỳ GC kết thúc khi heap mới giảm về không. Thời gian thực hiện trong khi heap mới giảm về không là thời gian kết hợp cho pha đánh dấu của chu kỳ N và pha quét của chu kỳ N+1. Lưu ý rằng trực quan hóa này (và tất cả các trực quan hóa trong hướng dẫn này) giả định ứng dụng bị tạm dừng khi GC thực thi, vì vậy chi phí CPU của GC được thể hiện đầy đủ bởi thời gian để bộ nhớ heap mới giảm về không. Điều này chỉ để đơn giản hóa trực quan hóa; trực giác tương tự vẫn áp dụng. Trục X thay đổi để luôn hiển thị toàn bộ thời lượng CPU-time của chương trình. Lưu ý rằng thời gian CPU bổ sung được GC sử dụng làm tăng tổng thời lượng.
Lưu ý rằng GC luôn phát sinh một số chi phí CPU và bộ nhớ đỉnh. Khi GOGC tăng, chi phí CPU giảm, nhưng bộ nhớ đỉnh tăng tỉ lệ với kích thước heap sống. Khi GOGC giảm, yêu cầu bộ nhớ đỉnh giảm với cái giá là chi phí CPU bổ sung.
Lưu ý: biểu đồ hiển thị thời gian CPU, không phải thời gian thực để hoàn thành chương trình. Nếu chương trình chạy trên 1 CPU và sử dụng tối đa tài nguyên của nó, thì chúng tương đương. Một chương trình thực tế có thể chạy trên hệ thống đa nhân và không sử dụng 100% CPU mọi lúc. Trong những trường hợp đó, tác động thời gian thực của GC sẽ thấp hơn.
Lưu ý: GC của Go có kích thước heap tối thiểu là 4 MiB, vì vậy nếu mục tiêu được đặt bởi GOGC bao giờ xuống dưới mức đó, nó sẽ được làm tròn lên. Trực quan hóa phản ánh chi tiết này.
Đây là một ví dụ khác năng độ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 thành mà không có GC, nhưng tốc độ cấp phát trong trạng thái ổn định tăng đột ngột ở giữa, và kích thước heap sống thay đổi một chút trong pha đầu tiên. Ví dụ này minh họa trạng thái ổn định trông như thế nào khi kích thước heap sống thực sự thay đổi, và cách tốc độ cấp phát cao hơn dẫn đến các chu kỳ GC 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. Mặc dù nó hoạt động tốt như một cách đặt sự đánh đổi, nó không tính đến việc bộ nhớ khả dụng là hữu hạn. Hãy xem điều gì xảy ra khi có sự tăng đột biến nhất thời trong kích thước heap sống: vì GC sẽ chọn tổng kích thước heap tỉ lệ với kích thước heap sống đó, GOGC phải được cấu hình cho kích thước heap sống đỉnh, ngay cả khi trong trường hợp thông thường một giá trị GOGC cao hơn cung cấp sự đánh đổi tốt hơn.
Trực quan hóa bên dưới minh họa tình huống đỉnh heap 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 với hơn 60 MiB bộ nhớ khả dụng, thì GOGC không thể tăng vượt quá 100, ngay cả khi phần còn lại của các chu kỳ GC có đủ bộ nhớ khả dụng để tận dụng bộ nhớ thừa đó. Hơn nữa, trong một số ứng dụng, các đỉnh nhất thời này có thể hiếm và khó dự đoán, dẫn đến điều kiện hết bộ nhớ thỉnh thoảng xảy ra, không thể tránh khỏi, và có thể tốn kém.
Đó là lý do tại sao trong bản phát hành 1.19, Go đã thêm hỗ trợ để đặt giới hạn
bộ nhớ runtime.
Giới hạn bộ nhớ có thể được cấu hình thông qua biến môi trường GOMEMLIMIT
mà tất cả các chương trình Go nhận biết, hoặc thông qua hàm
SetMemoryLimit có sẵn trong gói runtime/debug.
Giới hạn bộ nhớ này đặt giá trị tối đa cho tổng lượng bộ nhớ mà Go runtime có
thể sử dụng.
Tập hợp bộ nhớ cụ thể được bao gồm được định nghĩa theo
runtime.MemStats
là biểu thức
Sys - HeapReleased
hoặc tương đương theo gói
runtime/metrics,
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
Vì GC của Go có quyền kiểm soát rõ ràng về lượng bộ nhớ heap nó sử 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.
Trực quan hóa bên dưới mô tả khối lượng công việc trạng thái ổn định một pha giống với phần GOGC, nhưng lần này với thêm 10 MiB chi phí từ Go runtime và với giới hạn bộ nhớ có thể điều chỉnh. Hãy thử điều chỉnh cả GOGC và giới hạn bộ nhớ và xem điều gì xảy ra.
Lưu ý rằng khi giới hạn bộ nhớ bị hạ xuống dưới mức bộ nhớ đỉnh được xác định bởi GOGC (42 MiB cho GOGC là 100), GC chạy thường xuyên hơn để giữ bộ nhớ đỉnh trong giới hạn.
Quay lại ví dụ trước về đỉnh heap nhất thời, bằng cách đặt giới hạn bộ nhớ và tăng GOGC, chúng ta có thể đạt được cả hai: không vượt quá giới hạn bộ nhớ, và tiết kiệm tài nguyên tốt hơn. Hãy thử trực quan hóa tương tác bên dưới.
Lưu ý rằng với một số giá trị GOGC và giới hạn bộ nhớ, mức sử dụng bộ nhớ đỉnh dừng lại ở bất cứ mức giới hạn bộ nhớ nào, nhưng phần còn lại của việc thực thi chương trình vẫn tuân theo quy tắc tổng kích thước heap được đặt bởi GOGC.
Quan sát này dẫn đến một chi tiết thú vị khác: ngay cả khi GOGC được đặt thành off, giới hạn bộ nhớ vẫn được tôn trọng! Thực ra, cấu hình cụ thể này đại diện cho việc tối đa hóa tiết kiệm tài nguyên 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ớ. Trong trường hợp này, toàn bộ việc thực thi chương trình có kích thước heap tăng lên để đáp ứng giới hạn bộ nhớ.
Bây giờ, mặc dù giới hạn bộ nhớ rõ ràng là một công cụ mạnh mẽ, việc sử 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 ích của GOGC.
Hãy xem điều gì xảy ra khi heap sống tăng đủ lớn để đưa tổng mức sử dụng bộ nhớ gần với giới hạn bộ nhớ. Trong trực quan hóa 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 dần để xem điều gì xảy ra. Lưu ý rằng tổng thời gian ứng dụng mất sẽ bắt đầu tăng không giới hạn khi GC liên tục thực thi để duy trì giới hạn bộ nhớ không thể đạt được.
Tình huống này, khi chương trình không thể tiến triển hợp lý do các chu kỳ GC liên tục, được gọi là thrashing. Đây là tình trạng đặc biệt nguy hiểm vì nó thực sự làm đình trệ chương trình. Còn tệ hơn, nó có thể xảy ra chính xác trong tình huống mà chúng ta đang cố gắng tránh với GOGC: một đỉnh heap nhất thời đủ lớn có thể khiến chương trình bị đình trệ 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 trực quan hóa đỉnh heap nhất thời và lưu ý cách hành vi tệ nhất bắt đầu cụ thể với đỉnh heap.
Trong nhiều trường hợp, tình trạng đình trệ vô thời hạn còn tệ hơn điều kiện hết bộ nhớ, vốn thường dẫn đến lỗi nhanh hơn nhiều.
Vì lý do này, giới hạn bộ nhớ được định nghĩa là mềm. Go runtime không đảm bảo 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 lượng nỗ lực hợp lý. 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 sử dụng bộ nhớ vượt quá giới hạn để tránh dành quá nhiều thời gian trong GC.
Cách hoạt động nội bộ là GC đặt giới hạn trên về lượng thời gian CPU nó có thể sử
dụng trong một khoảng thời gian nhất định (với một số trễ cho các đợt tăng đột biến
CPU rất ngắn nhất thời).
Giới hạn này hiện được đặt ở khoảng 50%, với cửa sổ 2 * GOMAXPROCS
CPU-giây.
Hậu 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, ngay cả
vượt quá giới hạn bộ nhớ.
Trực giác đằng sau giới hạn 50% CPU của GC dựa trên tác động trong trường hợp xấu nhất đối với một chương trình có nhiều bộ nhớ khả dụng. Trong trường hợp cấu hình sai giới hạn bộ nhớ, khi nó được đặt quá thấp một cách 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ể chiếm hơn 50% thời gian CPU của nó.
Lưu ý: các trực quan hóa trên trang này không mô phỏng giới hạn CPU của GC.
Cách dùng được khuyến nghị
Mặc dù giới hạn bộ nhớ là một công cụ mạnh mẽ, và Go runtime thực hiện các bước để giảm thiểu các hành vi tệ nhất từ việc lạm dụng, điều quan trọng vẫn là sử dụng nó một cách thận trọng. Dưới đây là tập hợp các lời khuyên về nơi giới hạn bộ nhớ hữu ích nhất và áp dụng được 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 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 có quyền truy cập vào một tập tài nguyên nào đó (ví dụ, một loại dự trữ bộ nhớ nào đó, như giới hạn bộ nhớ container).
Một ví dụ tốt là việc triển khai một dịch vụ web vào các container với lượng bộ nhớ khả dụng cố định.
Trong trường hợp này, một quy tắc tốt là để lại thêm 5-10% dự phòng để tính đến các nguồn bộ nhớ mà Go runtime không biết đến.
-
Nên điều chỉnh giới hạn bộ nhớ theo thời gian thực để thích nghi với các điều kiện thay đổi.
Một ví dụ tốt là một chương trình cgo trong đó các thư viện C tạm thời cần sử dụng nhiều bộ nhớ hơn đáng kể.
-
Không nên đặt GOGC thành off cùng với giới hạn bộ nhớ nếu chương trình Go có thể chia sẻ một phần bộ nhớ giới hạn với các chương trình khác, và các chương trình đó thường tách biệt 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 kiềm chế các hành vi nhất thời không mong muốn, nhưng đặt GOGC thành một giá trị nhỏ hơn, hợp lý cho trường hợp trung bình.
Mặc dù có thể hấp dẫn khi cố gắng "dự trữ" bộ nhớ cho các chương trình cùng thuê, trừ khi các chương trình được đồng bộ hóa hoàn toàn (ví dụ: chương trình Go gọi một tiến trình con và chặn trong khi tiến trình con đó thực thi), kết quả sẽ kém tin cậy hơn vì chắc chắn cả hai chương trình đều sẽ cần thêm bộ nhớ. Để chương trình Go sử dụng ít bộ nhớ hơn khi không cần sẽ tạo ra kết quả đáng tin cậy hơn tổng thể. Lời khuyên này cũng áp dụng cho các tình huống overcommit, khi tổng các 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 cho máy đó.
-
Không nên sử dụng giới hạn bộ nhớ khi triển khai vào môi trường thực thi mà bạn không kiểm soát, đặc biệt khi mức sử 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à một công cụ CLI hoặc ứng dụng desktop. Gắn giới hạn bộ nhớ vào chương trình khi không rõ loại đầu vào nào có thể được cung cấp, hoặc bao nhiêu bộ nhớ có thể khả dụng trên hệ thống có thể dẫn đến lỗi khó hiểu và hiệu suất kém. Hơn nữa, người dùng nâng cao luôn có thể đặ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 điều kiện hết bộ nhớ khi một chương trình đã gần với giới hạn bộ nhớ của môi trường.
Điều này thực sự thay thế rủi ro hết bộ nhớ bằng rủi ro ứng dụng chậm nghiêm trọng, thường không phải là sự đánh đổi có lợi, ngay cả với những nỗ lực của Go để giảm thiểu thrashing. Trong trường hợp đó, sẽ hiệu quả hơn nhiều nếu 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 (cung cấp sự đánh đổi rõ ràng hơn nhiều so với giảm thiểu thrashing).
Độ trễ
Các trực quan hóa trong tài liệu này đã mô hình hóa ứng dụng như bị tạm dừng khi GC đang thực thi. Các triển khai GC hoạt động theo cách này thực sự tồn tại, và chúng được gọi là GC "stop-the-world".
Tuy nhiên, GC của Go không hoàn toàn stop-the-world và thực hiện hầu hết công việc của mình đồng thời với ứng dụng. Điều này chủ yếu nhằm giảm độ trễ của ứng dụng. Cụ thể, thời gian đầu cuối của một đơn vị tính toán (ví dụ: một yêu cầu web). Cho đến nay, tài liệu này chủ yếu xem xét thông lượng của ứng dụng (ví dụ: số yêu cầu web được xử lý mỗi giây). Lưu ý rằng mỗi ví dụ trong phần chu kỳ GC tập trung vào tổng thời lượng CPU của một chương trình đang thực thi. Tuy nhiên, thời lượng như vậy ít có ý nghĩa hơn đối với, giả sử, một dịch vụ web. Mặc dù thông lượng vẫn quan trọng đối với dịch vụ web (tức là số truy vấn mỗi giây), độ trễ của mỗi yêu cầu riêng lẻ thường còn quan trọng hơn.
Về mặt độ trễ, một GC stop-the-world có thể cần một khoảng thời gian đáng kể để thực thi cả pha đánh dấu và pha quét, trong thời gian đó ứng dụng, và trong ngữ cảnh dịch vụ web, bất kỳ yêu cầu đang thực hiện nào, đều không thể tiến triển thêm. Thay vào đó, GC của Go tránh làm cho độ dài của bất kỳ lần tạm dừng ứng dụng toàn cục nào tỉ lệ với kích thước của heap, và thuật toán tracing cốt lõi được thực hiện trong khi ứng dụng đang tích cực thực thi. (Các lần tạm dừng có tỉ lệ mạnh hơn với GOMAXPROCS về mặt thuật toán, nhưng phổ biến nhất là bị chi phối bởi thời gian cần thiết để dừng các goroutine đang chạy.) Thu gom đồng thời không phải là không có chi phí: trong thực tế, nó thường dẫn đến thiết kế với thông lượng thấp hơn so với bộ gom rác stop-the-world tương đương. Tuy nhiên, điều quan trọng cần lưu ý là độ trễ thấp hơn không có nghĩa là thông lượng thấp hơn, và hiệu suất của bộ gom rác Go đã cải thiện đều đặn theo thời gian, cả về độ trễ lẫn thông lượng.
Bản chất đồng thời của GC hiện tại của Go không làm mất hiệu lực bất cứ điều gì đã thảo luận trong tài liệu này cho đến nay: không có phát biểu nào dựa trên lựa chọn thiết kế này. Tần suất GC vẫn là cách chính GC đánh đổi giữa thời gian CPU và bộ nhớ cho thông lượng, và thực tế, nó cũng đóng vai trò này cho độ trễ. Điều này là vì hầu hết chi phí của GC phát sinh trong khi pha đánh dấu đang hoạt động.
Điểm mấu chốt là giảm tần suất GC cũng có thể dẫn đến cải thiện độ trễ. Điều này áp dụng không chỉ cho việc giảm tần suất GC từ việc thay đổi các tham số điều 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 hóa được mô tả trong hướng dẫn tối ưu hóa.
Tuy nhiên, độ trễ thường phức tạp hơn để hiểu so với thông lượng, vì nó là sản phẩm của việc thực thi từng thời điểm của chương trình chứ không chỉ là tổng hợp các chi phí. Do đó, mối liên hệ giữa độ trễ và tần suất GC ít trực tiếp hơn. Dưới đây là danh sách các nguồn độ trễ có thể có cho những ai muốn tìm hiểu sâu hơn.
- Các lần tạm dừng stop-the-world ngắn khi GC chuyển đổi giữa các pha đánh dấu và quét,
- Độ trễ lập lịch vì GC chiếm 25% tài nguyên CPU khi ở pha đánh dấu,
- Các goroutine người dùng hỗ trợ GC để phản hồi tốc độ cấp phát cao,
- Các lần ghi con trỏ yêu cầu công việc bổ sung khi GC đang ở pha đánh dấu, và
- Các goroutine đang chạy phải bị tạm dừng để scan các gốc của chúng.
Các nguồn độ trễ này có thể thấy trong execution traces, ngoại trừ việc ghi con trỏ yêu cầu công việc bổ sung.
Finalizer, cleanup, và con trỏ yếu
Thu gom rác tạo ra ảo giác về bộ nhớ vô hạn chỉ sử 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 so với quản lý bộ nhớ thủ công thuần túy. (Một số ngôn ngữ với bộ nhớ được quản lý thủ công sử dụng các phương pháp thay thế như "con trỏ thông minh" và theo dõi quyền sở hữu tại thời gian biên dịch để đảm bảo các đối tượng được giải phóng, nhưng các tính năng này được nhúng sâu vào các quy ước thiết kế API trong những ngôn ngữ đó.)
Chỉ những đối tượng đang sống—những đối tượng có thể tiếp cận từ một biến toàn cục hoặc một phép tính trong một goroutine—mới có thể ảnh hưởng đến hành vi của chương trình. Bất kỳ lúc nào sau khi một đối tượng trở nên không tiếp cận được ("chết"), nó có thể được GC thu hồi một cách an toàn. Điều này cho phép nhiều thiết kế GC, chẳng hạn như thiết kế tracing mà Go sử dụng ngày nay. Cái chết của một đối tượng không phải là một sự kiện có thể quan sát được ở cấp độ 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, con trỏ yếu, và finalizer. Mỗi tính năng này cung cấp một cách để quan sát và phản ứng với cái chết của đối tượng, và trong trường hợp finalizer, thậm chí đảo ngược nó. Điều này tất nhiên làm phức tạp các chương trình Go và thêm gánh nặng cho việc triển khai GC. Dù vậy, các 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 sử dụng chúng và hưởng lợi từ chúng mọi lúc.
Để biết chi tiết về từng tính năng, hãy tham khảo tài liệu của 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, mô tả các lỗi phổ biến có thể gặp phải với từng tính năng, và gợi ý kiểm thử cho các cách dùng đó.Lời khuyên chung
-
Viết unit test.
Thời điểm chính xác khi cleanup, weak pointer và finalizer được chạy rất khó dự đoán, và dễ tự thuyết phục rằng mọi thứ hoạt động đúng dù đã chạy thử nhiều lần liên tiếp. Nhưng cũng rất dễ mắc các lỗi tinh tế. Viết test cho chúng không đơn giản, nhưng vì chúng rất tinh tế khi sử dụng, việc kiểm thử lại càng 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 code Go thông thường.
Đây là các tính năng cấp thấp với nhiều hạn chế và hành vi tinh tế. Ví dụ, không có gì đảm bảo rằng cleanup hoặc finalizer sẽ được chạy khi thoát chương trình, hoặc thậm chí được chạy lần nào cả. Những đoạn chú thích dài trong tài liệu API của chúng nên được coi là lời cảnh báo. Đại đa số code Go không được lợi khi dùng trực tiếp các tính năng này, chỉ khi dùng gián tiếp mới có ích.
-
Đóng gói việc sử dụng các cơ chế này bên trong một package.
Khi có thể, không để việc dùng các cơ chế này lộ ra ngoài API công khai của package; hãy cung cấp các interface khiến người dùng khó hoặc không thể dùng sai. Ví dụ, thay vì yêu cầu người dùng tự đặt cleanup cho vùng nhớ được cấp phát từ C để giải phóng nó, hãy viết một wrapper package và ẩn chi tiết đó vào trong.
-
Giới hạn quyền truy cập vào các đối tượng có finalizer, cleanup và weak pointer chỉ trong package đã tạo và gắn chúng.
Điều này liên quan đến điểm trước, nhưng đáng nêu rõ vì đây là một mẫu rất hữu ích để dùng các tính năng này theo cách ít gây lỗi hơn. Ví dụ, package unique sử dụng weak pointer bên dưới, nhưng hoàn toàn đóng gói các đối tượng được trỏ yếu đến. Các giá trị đó không bao giờ có thể bị thay đổi bởi phần còn lại của ứng dụng, chỉ có thể được sao chép thông qua phương thức Value, giữ cho người dùng package cảm giác bộ nhớ là vô tận.
-
Ưu tiên giải phóng tài nguyên không phải bộ nhớ theo cách xác định khi có thể, dùng finalizer và cleanup như biện pháp dự phòng.
Cleanup và finalizer phù hợp với các tài nguyên là bộ nhớ, chẳng hạn như bộ nhớ được cấp phát bên ngoài như từ C, hoặc các tham chiếu đến vùng ánh xạ
mmap. Bộ nhớ được cấp phát bởimalloccủa C cuối cùng phải được giải phóng bởifreecủa C. Một finalizer gọifree, gắn vào đối tượng wrapper cho vùng nhớ C, là một cách hợp lý để đảm bảo bộ nhớ C được thu hồi khi bộ gom rác chạy.Tuy nhiên, các tài nguyên không phải bộ nhớ như file descriptor thường bị giới hạn bởi hệ thống mà Go runtime thường không biết đến. Ngoài ra, thời điểm bộ gom rác chạy trong một chương trình Go cụ thể thường là thứ tác giả package ít kiểm soát được (ví dụ, tần suất GC chạy được điều khiển bởi GOGC, có thể được người vận hành đặt thành nhiều giá trị khác nhau). Hai thực tế này khiến cleanup và finalizer không phải là lựa chọn tốt nếu dùng làm cơ chế duy nhất để giải phóng tài nguyên không phải bộ nhớ.
Nếu bạn là tác giả package đang cung cấp API bao bọc một tài nguyên không phải bộ nhớ, hãy cân nhắc cung cấp API tường minh để giải phóng tài nguyên theo cách xác định (thông qua phương thức
Closehoặc tương tự), thay vì dựa vào bộ gom rác thông qua cleanup hoặc finalizer. Thay vào đó, hãy dùng cleanup và finalizer như cơ chế xử lý lỗi lập trình viên theo nỗ lực tốt nhất, hoặc bằng cách vẫn giải phóng tài nguyên như os.File làm, hoặc bằng cách thông báo cho người dùng về việc không giải phóng theo cách xác định. -
Ưu tiên cleanup hơn finalizer.
Về mặt lịch sử, finalizer được thêm vào để đơn giản hóa giao tiếp giữa code Go và code C, và để dọn dẹp các tài nguyên không phải bộ nhớ. Mục đích sử dụng là gắn chúng vào các đối tượng wrapper sở hữu bộ nhớ C hoặc một số tài nguyên không phải bộ nhớ khác, để tài nguyên có thể được giải phóng sau khi code Go không còn dùng nó nữa. Những lý do này giải thích một phần tại sao finalizer có phạm vi hẹp, tại sao một đối tượng chỉ có thể có một finalizer, và tại sao finalizer đó phải được gắn vào byte đầu tiên của đối tượng. Hạn chế này đã cản trở một số trường hợp sử dụng. Ví dụ, bất kỳ package nào muốn cache nội bộ một số thông tin về một đối tượng được truyền vào đều không thể dọn dẹp thông tin đó sau khi đối tượng biến mất.
Tệ hơn nữa, finalizer kém hiệu quả và dễ gây lỗi vì chúng làm sống lại đối tượng mà chúng gắn vào, để có thể truyền vào hàm finalizer (và thậm chí có thể tiếp tục tồn tại sau đó nữa). Điều này có nghĩa rằng nếu đối tượng 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ớ backing đối tượng không thể được tái sử dụng cho đến ít nhất là chu kỳ bộ gom rác tiếp theo.
Tuy nhiên, vì finalizer làm sống lại đối tượng, chúng có thứ tự thực thi được xác định rõ hơn so với cleanup. Vì lý do này, finalizer vẫn có thể (nhưng hiếm khi) hữu ích để dọn dẹp các cấu trúc có yêu cầu thứ tự hủy phức tạp.
Nhưng với mọi mục đích sử dụng khác trong Go 1.24 trở lên, chúng tôi khuyến nghị 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 đề phổ biến với cleanup
-
Các đối tượng có cleanup gắn vào không được reachable từ hàm cleanup (ví dụ, thông qua biến cục bộ được capture). Điều này sẽ ngăn đối tượng được thu hồi và cleanup không bao giờ chạy.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
syscall.Close(f.fd) // Lỗi: Chúng ta tham chiếu f, nên cleanup này sẽ không chạy!
}, f.fd)
Các đối tượng có cleanup gắn vào không được reachable từ đối số truyền vào hàm cleanup. Điều này sẽ ngăn đối tượng được thu hồi và cleanup không bao giờ chạy.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
syscall.Close(f.fd)
}, f) // Lỗi: Chúng ta tham chiếu f, nên cleanup này không bao giờ chạy. Trường hợp cụ thể này còn gây panic.
Finalizer có thứ tự thực thi xác định, nhưng 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ác cleanup khác.
runtime.GC sẽ không chờ cho đến khi các cleanup cho các đối tượng
không reachable được thực thi, chỉ cho đến khi tất cả chúng được đưa vào hàng đợi.
Các vấn đề phổ biến với weak pointer
-
Weak pointer có thể bắt đầu trả về
niltừ phương thứcValuevào những thời điểm bất ngờ. Luôn kiểm tra giá trị trả về củaValuebằng kiểm tranilvà có kế hoạch dự phòng. -
Khi weak pointer được dùng làm khóa map, chúng không ảnh hưởng đến khả năng reachable của các giá trị trong map. Do đó, nếu một khóa weak pointer trỏ đến một đối tượng cũng reachable từ giá trị trong map, đối tượng đó vẫn được coi là reachable.
Các vấn đề phổ biến với finalizer
-
Các đối tượng có finalizer gắn vào không được reachable từ chính nó theo bất kỳ đường dẫn nào (nói cách khác, chúng không thể nằm trong chu trình tham chiếu). Điều này sẽ ngăn đối tượng được thu hồi và finalizer không bao giờ chạy.
f := new(myCycle)
f.self = f // Lỗi: f reachable từ f, nên finalizer này không bao giờ chạy.
runtime.SetFinalizer(f, func(f *myCycle) {
...
})
Các đối tượng có finalizer gắn vào không được reachable từ hàm finalizer (ví dụ, thông qua biến cục bộ được capture). Điều này sẽ ngăn đối tượng được thu hồi và finalizer không bao giờ chạy.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
syscall.Close(f.fd) // Lỗi: Chúng ta tham chiếu f bên ngoài, nên cleanup này sẽ không chạy!
})
Chuỗi tham chiếu các đối tượng có finalizer gắn vào (ví dụ trong linked list) cần tối thiểu số chu kỳ GC bằng số lượng đối tượng trong chuỗi để dọn dẹp tất cả. Hãy giữ finalizer nông!
// Lỗi: thu hồi linked list này sẽ mất ít nhất 10 chu kỳ GC.
node := new(linkedListNode)
for range 10 {
tmp := new(linkedListNode)
tmp.next = node
node = tmp
runtime.SetFinalizer(node, func(node *linkedListNode) {
...
})
}
Tránh gắn finalizer vào các đối tượng được trả về tại ranh giới package.
Điều này cho phép người dùng package gọi
runtime.SetFinalizer để thay đổi finalizer trên đối tượng bạn trả về,
tạo ra hành vi bất ngờ mà người dùng package có thể phụ thuộc 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ác finalizer khác.
runtime.GC sẽ không chờ cho đến khi các finalizer cho các đối tượng
không reachable được thực thi, chỉ cho đến khi tất cả chúng được đưa vào hàng đợi.
Kiểm thử việc đối tượng bị hủy
Khi dùng các tính năng này, đôi khi việc viết test cho code sử dụng chúng có thể khá khó. Dưới đây là một số gợi ý để viết test vững chắc cho code dùng những tính năng này.
- Tránh chạy các test như vậy song song với các test khác. Việc tăng tính xác định càng nhiều càng tốt và nắm rõ trạng thái của hệ thống tại mọi thời điểm sẽ rất hữu ích.
-
Dùng
runtime.GCđể thiết lập baseline khi bắt đầu test. Dùngruntime.GCđể buộc weak pointer vềnil, và để đưa cleanup và finalizer vào hàng đợi chạy. -
runtime.GCkhông chờ cleanup và finalizer chạy, nó chỉ đưa chúng vào hàng đợi.Để viết test vững chắc nhất có thể, hãy tiêm một cách để chặn trên một cleanup hoặc finalizer từ test của bạn (ví dụ, truyền một channel tùy chọn vào cleanup và/hoặc finalizer từ test, và ghi vào channel sau khi nó chạy xong). Nếu điều này quá khó hoặc không thể, một cách khác là vòng lặp kiểm tra một trạng thái nhất định sau cleanup. Ví dụ, các test của package
osgọiruntime.Goschedtrong một vòng lặp kiểm tra xem file đã được đóng hay chưa, sau khi nó không còn reachable. -
Nếu viết test cho việc dùng finalizer, và bạn có chuỗi đối tượng dùng finalizer, bạn cần tối thiểu số lần gọi
runtime.GCbằng độ sâu của chuỗi dài nhất mà test có thể tạo ra để đảm bảo tất cả finalizer đều chạy. -
Kiểm thử ở chế độ race để phát hiện race condition giữa các cleanup đồng thời, và giữa code cleanup, finalizer và phần còn lại của codebase.
Tài nguyên bổ sung
Mặc dù thông tin trình bày ở trên là chính xác, nhưng còn thiếu chi tiết để hiểu đầy đủ các chi phí và đánh đổi trong thiết kế GC của Go. Để biết thêm thông tin, hãy xem các tài nguyên bổ sung sau.
- The GC Handbook—Một tài nguyên và tài liệu tham khảo tổng quát tuyệt vời về thiết kế bộ gom rác.
- TCMalloc—Tài liệu thiết kế cho bộ cấp phát bộ nhớ C/C++ TCMalloc, nền tảng của bộ cấp phát bộ nhớ Go.
- Thông báo GC Go 1.5—Bài blog thông báo GC đồng thời 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ự phát triển của thiết kế GC của Go đến năm 2018.
- Điều phối GC đồng thời Go 1.5—Tài liệu thiết kế về cách xác định thời điểm bắt đầu phase đánh dấu đồng thời.
- Scavenging thông minh hơn—Tài liệu thiết kế về việc xem xét lại cách Go runtime trả bộ nhớ cho hệ điều hành.
- Bộ cấp phát trang có khả năng mở rộng—Tài liệu thiết kế về việc xem xét lại 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 sửa đổi thuật toán xác định thời điểm bắt đầu phase đánh dấu đồng thời.
- Giới hạn bộ nhớ mềm (Go 1.19)—Tài liệu thiết kế cho giới hạn bộ nhớ mềm.
Ghi chú về bộ nhớ ảo
Hướng dẫn này chủ yếu tập trung vào việc sử dụng bộ nhớ vật lý của GC, nhưng
một câu hỏi thường xuyên được đặt ra là chính xác điều đó có nghĩa là gì và so sánh như thế nào
với bộ nhớ ảo (thường được hiển thị trong các chương trình như top là
"VSS").
Bộ nhớ vật lý là bộ nhớ nằm trong chip RAM vật lý thực tế trong hầu hết máy tính. Bộ nhớ ảo là lớp trừu tượng trên bộ nhớ vật lý do hệ điều hành cung cấp để cô lập các chương trình với nhau. Thông thường cũng được chấp nhận khi các chương trình đặt trước không gian địa chỉ ảo mà không ánh xạ đến bất kỳ địa chỉ vật lý nào.
Vì bộ nhớ ảo chỉ là một ánh xạ được hệ điều hành duy trì, thường rất rẻ để đặt trước các vùng bộ nhớ ảo lớn không ánh xạ đến bộ nhớ vật lý.
Go runtime thường dựa vào quan điểm này về chi phí của bộ nhớ ảo theo một số cách:
-
Go runtime không bao giờ xóa bộ nhớ ảo mà nó ánh xạ. Thay vào đó, nó sử 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 rõ ràng bất kỳ tài nguyên bộ nhớ vật lý nào liên quan đến một vùng bộ nhớ ảo.
Kỹ thuật này được dùng rõ ràng để quản lý giới hạn bộ nhớ và trả bộ nhớ về hệ điều hành khi Go runtime không còn cần đến. Go runtime cũng liên tục giải phóng bộ nhớ không cần thiết trong nền. Xem tài nguyên bổ sung để biết thêm thông tin.
-
Trên các nền tảng 32-bit, Go runtime đặt trước từ 128 MiB đến 512 MiB không gian địa chỉ trước cho heap để hạn chế vấn đề phân mảnh.
-
Go runtime sử dụng các vùng đặt trước không gian địa chỉ ảo lớn trong triển khai một số cấu trúc dữ liệu nội bộ. Trên các nền tảng 64-bit, chúng thường có footprint bộ nhớ ảo tối thiểu khoảng 700 MiB. Trên các nền tảng 32-bit, footprint của chúng không đáng kể.
Do đó, các chỉ số bộ nhớ ảo như "VSS" trong top
thường không hữu ích để hiểu footprint bộ nhớ của một chương trình Go.
Thay vào đó, hãy tập trung vào "RSS" và các phép đo tương tự, 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 hóa
Xác định chi phí
Trước khi cố gắng tối ưu hóa cách ứng dụng Go tương tác với GC, điều quan trọng là phải xác định trước rằng GC thực sự là chi phí đáng kể.
Hệ sinh thái Go cung cấp nhiều công cụ để xác định chi phí và tối ưu hóa ứng dụng Go. Để có cái nhìn tổng quan về các công cụ này, xem hướng dẫn về chẩn đoán. Ở đây, chúng ta sẽ tập trung vào một tập hợp con các công cụ đó và một thứ tự hợp lý để áp dụng chúng để hiểu tác động và hành vi của GC.
-
CPU profile
Điểm khởi đầu tốt là CPU profiling. CPU profiling cung cấp cái nhìn tổng quan về nơi thời gian CPU được dùng, mặc dù với người chưa quen, có thể khó xác định mức độ tác động của GC trong một ứng dụng cụ thể. May mắn thay, hiểu GC phù hợp như thế nào chủ yếu là biết ý nghĩa của các hàm khác nhau trong package
runtime. Dưới đây là một tập hợp con hữu ích của các hàm này để 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 output mặc định mà công cụ
pprofcung cấp với lệnhtop. Thay vào đó, dùng lệnhtop -cumhoặc dùng lệnhlisttrực tiếp trên các hàm này và tập trung vào cột phần trăm tích lũy. -
runtime.gcBgMarkWorker: Điểm vào cho các goroutine mark worker chạy nền. Thời gian dành ở đây tỷ lệ thuận với tần suất GC và độ phức tạp cũng như kích thước của đồ thị đối tượng. Nó đại diện cho mức nền về thời gian ứng dụng dành cho việc đánh dấu và quét.Lưu ý rằng trong các goroutine này, bạn sẽ thấy các lời gọi đến
runtime.gcDrainMarkWorkerDedicated,runtime.gcDrainMarkWorkerFractional, vàruntime.gcDrainMarkWorkerIdle, cho biết loại worker. Trong một ứng dụng Go gần như nhàn rỗi, Go GC sẽ sử dụng thêm tài nguyên CPU nhàn rỗi để hoàn thành công việc nhanh hơn, được chỉ ra bằng ký hiệuruntime.gcDrainMarkWorkerIdle. Do đó, thời gian ở đây có thể chiếm tỷ lệ lớn các mẫu CPU, mà Go GC cho là miễn phí. Nếu ứng dụng trở nên bận rộn hơn, thời gian CPU trong idle worker sẽ giảm. Một lý do phổ biến cho điều này là nếu một ứng dụng chạy hoàn toàn trong một goroutine nhưngGOMAXPROCS>1. -
runtime.mallocgc: Điểm vào cho bộ cấp phát bộ nhớ heap. Lượng thời gian tích lũy lớn ở đây (>15%) thường chỉ ra rằng có rất nhiều bộ nhớ đang được cấp phát. -
runtime.gcAssistAlloc: Hàm mà các goroutine vào để dành một phần thời gian giúp GC quét và đánh dấu. Lượng thời gian tích lũy lớn ở đây (>5%) chỉ ra rằng ứng dụng có thể đang cấp phát nhanh hơn GC có thể theo kịp. Đây là dấu hiệu tác động đặc biệt lớn từ GC, đồng thời đại diện cho thời gian ứng dụng dành cho việc đánh dấu và quét. Lưu ý rằng hàm này nằm trong cây gọi củaruntime.mallocgc, nên nó cũng làm tăng con số đó. -
Execution trace
Trong khi CPU profile rất tốt để xác định nơi thời gian được dùng tổng thể, chúng kém hữu ích hơn khi chỉ ra các chi phí hiệu suất tinh tế hơn, hiếm gặp hơn, hoặc liên quan cụ thể đến độ trễ. Execution trace mặt khác cung cấp cái nhìn phong phú và sâu sắc vào một khoảng thời gian ngắn trong quá trình thực thi của chương trình Go. Chúng chứa nhiều sự kiện liên quan đến Go GC và các đường thực thi cụ thể có thể được quan sát trực tiếp, cùng với cách ứng dụng tương tác với Go GC. Tất cả 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 cho package
runtime/traceđể biết cách bắt đầu với execution trace. -
GC trace
Khi mọi cách đều không hiệu quả, Go GC cung cấp một vài trace cụ thể cho cái nhìn sâu sắc hơn về hành vi GC. Các trace này luôn được in trực tiếp ra STDERR, một dòng mỗi chu kỳ GC, và được cấu hình thông qua biến môi trường
GODEBUGmà tất cả chương trình Go nhận biết. Chúng hữu ích nhất để debug GC của Go vì đòi hỏi quen thuộc với các chi tiết cụ thể trong triển khai GC, nhưng đôi khi vẫn có thể hữu ích để hiểu hành vi GC tốt hơn.GC trace cơ bản được bật bằng cách đặt
GODEBUG=gctrace=1. Output do trace này tạo ra được ghi lại trong phần biến môi trường trong tài liệu cho packageruntime.Một GC trace bổ sung gọi là "pacer trace" cung cấp cái nhìn sâu sắc hơn và được bật bằng cách đặt
GODEBUG=gcpacertrace=1. Giải thích output này đòi hỏi hiểu biết về "pacer" của GC (xem tài nguyên bổ sung), nằm ngoài phạm vi của hướng dẫn này.
Loại bỏ cấp phát heap
Một cách để giảm chi phí từ GC là để GC quản lý ít giá trị hơn ngay từ đầu. Các kỹ thuật được mô tả dưới đây có thể tạo ra một số cải tiến hiệu suất lớn nhất, vì như phần GOGC đã chứng minh, tốc độ cấp phát của một chương trình Go là nhân tố quan trọng trong tần suất GC, chỉ số chi phí chính mà hướng dẫn này sử dụng.
Heap profiling
Sau khi xác định rằng GC là nguồn chi phí đáng kể, bước tiếp theo để loại bỏ cấp phát 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 ra là heap memory profile) rất hữu ích. Xem tài liệu để biết cách bắt đầu với chúng.
Memory profile mô tả nơi trong chương trình mà các cấp phát heap đến từ, xác định chúng bằng stack trace tạ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 đối tượng đang sống.inuse_space—Phân tích các đối tượng đang sống theo lượng bộ nhớ chúng sử dụng tính bằng byte.alloc_objects—Phân tích số lượng đối tượng đã được cấp phát kể từ khi chương trình Go bắt đầu thực thi.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 thực thi.
Chuyển đổi giữa các góc nhìn khác nhau về heap memory có thể thực hiện bằng cờ
-sample_index của công cụ pprof, hoặc thông qua tùy chọn
sample_index khi công cụ được dùng tương tác.
Lưu ý: memory profile mặc định chỉ lấy mẫu một tập con các đối tượng heap nên
sẽ không chứa thông tin về mọi cấp phát heap đơn lẻ.
Tuy nhiên, điều này đủ để tìm ra điểm nóng.
Để thay đổi tần suất lấy mẫu, xem
runtime.MemProfileRate.
Với mục đích giảm chi phí GC, alloc_space thường là
góc nhìn hữu ích nhất vì nó trực tiếp tương ứng với tốc độ cấp phát.
Góc nhìn này sẽ chỉ ra các điểm nóng cấp phát mang lại lợi ích lớn nhất.
Phân tích thoát (escape analysis)
Sau khi đã xác định được các vị trí cấp phát heap tiềm năng với sự trợ giúp của heap profile, làm thế nào để loại bỏ chúng? Chìa khóa là tận dụng phân tích thoát của trình biên dịch Go để trình biên dịch tìm ra bộ nhớ thay thế, hiệu quả hơn, ví dụ trong stack của goroutine. May mắn thay, trình biên dịch Go có khả năng mô tả lý do tại sao nó quyết định đưa một giá trị Go lên heap. Với thông tin đó, việc cần làm là sắp xếp lại source code để thay đổi kết quả của 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ách đơn giản nhất để truy cập thông tin từ phân tích thoát của trình biên dịch Go là
thông qua một cờ debug mà trình biên dịch Go hỗ trợ, mô tả tất cả các tối ưu hóa
đã áp dụng hoặc không áp dụng cho một package dưới dạng văn bản.
Điều này bao gồm việc các giá trị có thoát hay không.
Thử lệnh sau đây, trong đó [package] là một đường dẫn 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 overlay trong trình soạn thảo hỗ trợ LSP; nó được cung cấp như một code action. Ví dụ, trong VS Code, 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 cài đặt cấu hình này để kiểm soát annotation nào được hiển thị:
-
Bật overlay cho escape analysis bằng cách
đặt
ui.diagnostic.annotationsđể 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 có thể đọc bằng máy (JSON) có thể được dùng để xây dựng các công cụ tùy chỉnh bổ sung. Để biết thêm thông tin, xem tài liệu trong source code Go.
Tối ưu hóa dành riêng cho cài đặt
Go GC nhạy cảm với đặc điểm của bộ nhớ đang sống, vì một đồ thị đối tượng và con trỏ phức tạp vừa hạn chế song song vừa tạo ra nhiều công việc hơn cho GC. Do đó, GC chứa một số tối ưu hóa cho các cấu trúc phổ biến cụ thể. Những cái hữu ích nhất cho tối ưu hóa hiệu suất được liệt kê dưới đây.
Lưu ý: Áp dụng các tối ưu hóa dưới đây có thể làm giảm khả năng đọc của code bằng cách che khuất ý định, và có thể không còn hiệu quả qua các bản phát hành Go. Ưu tiên áp dụng các tối ưu hóa này chỉ ở những nơi quan trọng nhất. Các nơi như vậy có thể được xác định bằng cách sử dụng các công cụ được liệt kê trong phần xác định chi phí.
-
Các giá trị không có con trỏ được tách biệt khỏi các giá trị khác.
Do đó, việc loại bỏ con trỏ khỏi các cấu trúc dữ liệu không thực sự cần đến chúng có thể có lợi, vì điều này giảm áp lực cache mà GC gây ra trên chương trình. Do đó, các cấu trúc dữ liệu dựa trên chỉ số thay vì giá trị con trỏ, dù kém an toàn kiểu hơn, có thể hoạt động tốt hơn. Điều này chỉ đáng làm nếu rõ ràng rằng đồ thị đối tượng phức tạp và GC đang dành nhiều thời gian để đánh dấu và quét.
-
GC sẽ dừng quét các giá trị tại con trỏ cuối cùng trong giá trị đó.
Do đó, việc nhóm các trường con trỏ trong các giá trị kiểu struct ở đầu giá trị có thể có lợi. Điều này chỉ đáng làm nếu rõ ràng rằng ứng dụng dành nhiều thời gian để đánh dấu và quét. (Về lý thuyết trình biên dịch có thể tự động làm điều này, nhưng chưa được triển khai, và các trường struct được sắp xếp theo thứ tự trong source code.)
Hơn nữa, GC phải tương tác với gần như mọi con trỏ nó thấy, vì vậy sử dụng chỉ số vào một slice, ví dụ, thay vì con trỏ, có thể giúp giảm chi phí GC.
Transparent huge page trên Linux (THP)
Khi một chương trình truy cập bộ nhớ, CPU cần dịch các địa chỉ bộ nhớ ảo mà nó sử dụng thành các địa chỉ bộ nhớ vật lý tham chiếu đến dữ liệu nó đang truy cập. Để làm điều này, CPU tham khảo "page table", một cấu trúc dữ liệu đại diện cho ánh xạ từ bộ nhớ ảo đến 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ỏ hơn gọi là page, do đó có cái tên này.
Transparent huge page (THP) là một tính năng Linux thay thế trong suốt các page của bộ nhớ vật lý backing các vùng bộ nhớ ảo liên tiếp bằng các khối bộ nhớ lớn hơn gọi là huge page. Bằng cách dùng các khối lớn hơn, cần ít mục page table hơn để đại diện cho cùng vùng bộ nhớ, cải thiện thời gian tra cứu page table. Tuy nhiên, các khối lớn hơn đồng nghĩa với lãng phí nhiều hơn nếu chỉ một phần nhỏ của huge page được hệ thống sử dụng.
Khi chạy chương trình Go trong môi trường production, việc bật transparent huge page trên Linux có thể cải thiện throughput và độ trễ với chi phí là sử dụng thêm bộ nhớ. Các ứng dụng với heap nhỏ thường không được lợi từ THP và có thể dùng thêm một lượng bộ nhớ đáng kể (lên đến 50%). Tuy nhiên, các ứng dụng với heap lớn (1 GiB trở lên) thường được lợi khá nhiều (lên đến 10% throughput) mà không tốn thêm nhiều bộ nhớ (1-2% hoặc ít hơn). Biết rõ cài đặt THP của bạn trong cả hai trường hợp đều hữu ích, và thử nghiệm luôn được khuyến nghị.
Có thể bật hoặc tắt transparent huge page 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 page trong môi trường production Linux,
chúng tôi khuyến nghị các cài đặt bổ sung sau cho chương trình Go.
-
Đặt
/sys/kernel/mm/transparent_hugepage/defragthànhdeferhoặcdefer+madvise.
Cài đặt này kiểm soát mức độ tích cực mà kernel Linux hợp nhất các page thông thường thành huge page.deferyêu cầu kernel hợp nhất huge page một cách lười biếng và trong nền. Cài đặt tích cực hơn có thể gây ra stall trong các hệ thống bị giới hạn bộ nhớ và thường làm tăng độ trễ của ứng dụng.defer+madvisegiống nhưdefer, nhưng thân thiện hơn với các ứng dụng khác trên hệ thống yêu cầu huge page một cách rõ ràng và cần chúng cho hiệu suất. -
Đặt
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_nonethành0.
Cài đặt này kiểm soát số lượng page bổ sung mà daemon kernel Linux có thể cấp phát khi cố gắng cấp phát một huge page. Cài đặt mặc định cực kỳ tích cực, và thường có thể hoàn tác công việc Go runtime thực hiện để trả bộ nhớ về OS. Trước Go 1.21, Go runtime cố gắng giảm thiểu tác động tiêu cực của cài đặt mặc định, nhưng đi kèm với 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 gặp tình trạng tăng mức sử dụng bộ nhớ khi nâng cấp lên Go 1.21.1 hoặc mới hơn, hãy thử áp dụng cài đặt này; nó có thể giải quyết vấn đề của bạn. Như một biện pháp giải quyết bổ sung, bạn có thể gọi hàmPrctlvớiPR_SET_THP_DISABLEđể tắt huge page ở cấp độ process, hoặc đặtGODEBUG=disablethp=1(sẽ được thêm trong Go 1.21.6 và Go 1.22) để tắt huge page cho heap memory. Lưu ý rằng cài đặtGODEBUGnày có thể bị xóa trong 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. Để hiểu tại sao, hãy phân tích bằng toán học.
Trước tiên, mục tiêu heap đặt ra 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 đến bộ nhớ heap mới, vì heap đang sống là cơ bản 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 ta 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 sẽ cấp phát mỗi chu kỳ, nắm bắt chi phí bộ nhớ heap. Lưu ý rằng Live heap + GC roots là xấp xỉ lượng bộ nhớ mà GC cần quét.
Tiếp theo, hãy xem chi phí CPU của GC. Tổng chi phí có thể được 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.
Total GC CPU cost = (GC CPU cost per cycle) * (GC frequency) * T
Chi phí CPU GC mỗi chu kỳ có thể được 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í phase sweep được bỏ qua ở đây vì chi phí mark và scan chiếm ưu thế.
Trạng thái ổn định được xác định bởi tốc độ cấp phát không đổi và chi phí mỗi byte không đổi, vì vậy trong trạng thái ổn định ta có thể suy ra tần suất GC từ bộ nhớ heap mới này:
GC frequency = (Allocation rate) / (New heap memory) = (Allocation rate) / ((Live heap + GC roots) * GOGC / 100)
Kết hợp lại, ta thu được phương trình đầ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 heap đủ lớn (đại diện cho hầu hết 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 hóa đá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 đơn giản hóa này, ta thấy rằng nếu tăng gấp đôi GOGC, tổng chi phí CPU của GC sẽ giảm một nửa. (Lưu ý rằng các hình minh họa trong hướng dẫn này có mô phỏng chi phí cố định, vì vậy chi phí CPU GC được báo cáo bởi chúng sẽ không giảm đúng một nửa khi GOGC tăng gấp đôi.) Hơn nữa, chi phí CPU GC phần lớn được xác định bởi tốc độ cấp phát và chi phí mỗi byte để quét bộ nhớ. Để biết thêm thông tin về cách giảm các chi phí này cụ thể, xem hướng dẫn tối ưu hóa.
Lưu ý: có sự khác biệt giữa kích thước của heap đang sống và lượng bộ nhớ đó mà GC thực sự cần quét: cùng kích thước heap đang sống nhưng với cấu trúc khác sẽ dẫn đến chi phí CPU khác, nhưng chi phí bộ nhớ như nhau, tạo ra sự đánh đổi khác nhau. Đây là lý do tại sao cấu trúc của heap là một phần định nghĩa của trạng thái ổn định. Mục tiêu heap lẽ ra chỉ nên bao gồm heap đang sống có thể quét như một xấp xỉ gần hơn của bộ nhớ GC cần quét, nhưng điều này dẫn đến hành vi suy thoái khi có rất ít heap đang sống có thể quét nhưng heap đang sống lại lớn.