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.

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.

  1. GC chỉ liên quan đến hai tài nguyên: bộ nhớ vật lý và thời gian CPU.

  2. 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.

  3. 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:

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.

GOGC

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.

GOGC

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.

GOGC

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.

GOGC
Memory Limit

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.

GOGC
Memory Limit

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.

Độ 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.

  1. 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,
  2. Độ trễ lập lịch vì GC chiếm 25% tài nguyên CPU khi ở pha đánh dấu,
  3. Các goroutine người dùng hỗ trợ GC để phản hồi tốc độ cấp phát cao,
  4. 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à
  5. 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

Các vấn đề phổ biến với cleanup

Các vấn đề phổ biến với weak pointer

Các vấn đề phổ biến với finalizer

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.

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.

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:

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.

  1. 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ụ pprof cung cấp với lệnh top. Thay vào đó, dùng lệnh top -cum hoặc dùng lệnh list trự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ệu runtime.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ưng GOMAXPROCS >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ủa runtime.mallocgc, nên nó cũng làm tăng con số đó.

  2. 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.

  3. 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 GODEBUG mà 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 package runtime.

    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.

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ị:

  1. Bật overlay cho escape analysis bằng cách đặt ui.diagnostic.annotations để bao gồm escape .

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í.

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.

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.