Semantics
Giới thiệu
Các Garbage Collector có trách nhiệm theo dõi việc phân bổ heap memory, giải phóng các phân bổ không còn cần thiết và duy trì các phân bổ đang được sử dụng. Cách mà một ngôn ngữ quyết định triển khai hành vi này là phức tạp nhưng không yêu cầu đối với các developer phải hiểu rõ chi tiết để xây dựng phần mềm. Hơn nữa, với các phiên bản khác nhau của VM hoặc runtime của ngôn ngữ, triển khai của các hệ thống này luôn thay đổi và phát triển. Điều quan trọng đối với các developer là duy trì một mô hình làm việc tốt của Garbage collector và làm thế nào họ có thể đồng cảm với hành vi đó mà không quan tâm đến việc triển khai nó.
Kể từ phiên bản 1.12, ngôn ngữ lập trình Go sử dụng non-generational concurrent tri-color mark và sweep collector. Bất kỳ bài viết nào nói về các chi tiết triển khai sẽ không còn chính xác nữa khi phiên bản tiếp theo của ngôn ngữ được release.
Với tất cả những điều đó, việc mô hình hóa tôi sẽ thực hiện trong bài viết này sẽ không tập trung vào các chi tiết triển khai thực tế. Mô hình hóa sẽ tập trung vào hành vi bạn sẽ trải nghiệm và hành vi bạn nên mong đợi trong nhiều năm tới. Trong bài viết này, tôi sẽ chia sẻ với bạn về hành vi của Garbage Collector và giải thích cách đồng cảm với hành vi đó, bất kể triển khai hiện tại hoặc cách triển khai thay đổi trong tương lai. Điều này sẽ giúp bạn trở thành một Go developer tốt hơn.
### Heap Memory không phải là một Container
Tôi sẽ không bao giờ đề cập đến Heap Memory như một container mà bạn có thể lưu trữ hoặc giải phóng các giá trị từ đó. Quan trọng là phải hiểu rằng không có bộ chứa vùng nhớ nào để định nghĩa nên "Heap". Hãy nghĩ rằng bất kỳ vùng nhớ nào được dành riêng cho ứng dụng trong quá trình hoạt động của nó sẽ available để các Heap Memory phân bổ. Ở những nơi mà Heap Memory phân bổ (dù là virtually hay physically) thì đều sẽ không liên quan tới mô hình của chúng ta. Sự hiểu biết này sẽ giúp bạn hiểu rõ hơn về cách Garbage Collector hoạt động.
Hành vi của Collector
Khi một Collector bắt đầu hoạt động, Collector sẽ chạy qua ba giai đoạn. Hai trong ba giai đoạn này tạo ra độ trễ Stop The World (STW), và giai đoạn còn lại tạo ra độ trễ làm chậm toàn bộ của ứng dụng. Ba giai đoạn này là:
- Mark Setup - STW
- Marking - Concurrent
- Mark Termination - STW
Dưới đây là mô tả chi tiết của mỗi giai đoạn.
Mark Setup - STW
Khi một Collector bắt đầu hoạt động, hoạt động đầu tiên cần thực hiện là kích hoạt Write Barrier. Mục đích của Write Barrier là cho phép Collector duy trì tính toàn vẹn dữ liệu trên Heap Memory trong quá trình thu gom vì cả Collector và các goroutine sẽ chạy song song.
Để kích hoạt Write Barrier, mỗi goroutine đang chạy phải được tạm dừng. Hoạt động này thường rất nhanh chóng, trong khoảng trung bình từ 10 đến 30 micro giây. Điều này chỉ xảy ra nếu các goroutine trong ứng dụng hoạt động một cách đúng đắn.
Trong đó có các ký hiệu như:
- P: Viết tắt của "Processor". Trong runtime của Go, một P chịu trách nhiệm thực thi code Go. Đây về cơ bản là một luồng thực thi code Go. Collector hoạt động song song trên nhiều P.
- M: Viết tắt của "Machine". M đại diện cho một luồng hệ thống của hệ điều hành. Mỗi P được kết nối với một M, và một M có thể có nhiều trạng thái, chẳng hạn như thực thi code Go, bị chặn trong các system call, hoặc bị chặn trong runtime.
- G: Viết tắt của "Goroutine". Một Goroutine là một lightweight thread được quản lý bởi Go runtime. Goroutines là cho phép thực thi đồng thời trong Go. Collector cần xem xét Goroutines khi thu gom rác, vì Goroutines có thể giữ các tham chiếu đến các đối tượng.

Hình 1 cho thấy có 4 goroutine trong ứng dụng đang chạy trước khi bắt đầu process thu gom. 4 goroutine này phải được tạm dừng. Cách duy nhất để làm điều đó là cho Collector theo dõi và chờ đợi từng goroutine thực hiện gọi hàm. Việc gọi hàm đảm bảo rằng các goroutine đang ở điểm an toàn (safe point) để tạm dừng. Các lệnh gọi hàm trong Go là một trong những điểm mà bộ thực thi có thể làm gián đoạn các goroutine một cách an toàn cho nhiều mục đích khác nhau và có thể có nhiều safe point như trên, chẳng hạn như thu gom rác. Điều gì sẽ xảy ra nếu một trong những goroutine đó không thực hiện gọi hàm nhưng các goroutine khác lại thực hiện?

Hình 2 cho thấy một vấn đề thực sự. Quá trình thu gom không thể bắt đầu cho đến khi goroutine chạy trên P4 bị dừng và điều đó không thể xảy ra vì nó nằm trong một vòng lặp thực hiện một số phép toán.
Listing 1
01 func add(numbers []int) int {
02 var v int
03 for _, n := range numbers {
04 v += n
05 }
06 return v
07 }Listing 1 hiển thị code mà Goroutine đang chạy trên P4 đang thực hiện. Tùy thuộc vào kích thước của slice, Goroutine có thể chạy trong một khoảng thời gian không hợp lý và không có cơ hội để dừng lại. Đây là loại code có thể làm đình trệ việc bắt đầu quá trình thu gom. Điều tồi tệ hơn là các P khác không thể phục vụ bất kỳ goroutines nào khác trong khi Collector đang chờ đợi. Điều quan trọng cần phải làm là các goroutine phải thực hiện gọi hàm trong khung thời gian hợp lý.
Marking - Concurrent
Khi Write Barrier được bật, Collector bắt đầu với giai đoạn Marking. Điều đầu tiên Collector làm là chiếm 25% dung lượng CPU có sẵn cho chính nó. Collector sử dụng Goroutines để thực hiện công việc thu gom và cần sử dụng các P và M giống như các Goroutine sử dụng. Điều này có nghĩa là đối với chương trình Go với 4 thread, toàn bộ P sẽ được chỉ định cho công việc thu gom.

Hình 3 cho thấy cách Collector đã chiếm P1 cho chính nó trong quá trình thu gom. Bây giờ, Collector có thể bắt đầu giai đoạn Marking. Giai đoạn Marking bao gồm việc đánh dấu các giá trị trong Heap Memory vẫn đang được sử dụng. Công việc này bắt đầu bằng việc kiểm tra các ngăn xếp cho tất cả các goroutines hiện có để tìm con trỏ gốc đến Heap Memory. Sau đó, Collector phải duyệt qua đồ thị Heap Memory từ những con trỏ gốc đó. Trong khi công việc Marking đang diễn ra trên P1, ứng dụng có thể tiếp tục chạy đồng thời trên P2, P3 và P4. Điều này có nghĩa là tác động của Collector đã được giảm thiểu xuống còn 25% dung lượng CPU hiện tại.
Tôi ước đó là kết thúc của câu chuyện nhưng không phải vậy. Điều gì sẽ xảy ra nếu trong quá trình thu gom, Goroutine được dành riêng cho GC trên P1 sẽ không hoàn thành công việc Marking trước khi dung lượng Heap Memory đang được sử dụng đạt đến giới hạn của nó? Điều gì sẽ xảy ra nếu chỉ có một trong số 3 Goroutines đang thực hiện công việc là nguyên nhân khiến Collector không kịp hoàn thành đúng thời hạn? Trong trường hợp này, việc phân bổ bộ nhớ mới phải được làm chậm lại, đặc biệt từ Goroutine đó.
Nếu Collector xác định rằng nó cần làm chậm các phân bổ, nó sẽ nhờ các Goroutine của ứng dụng để hỗ trợ công việc Marking. Điều này được gọi là Mark Assist. Thời gian mà bất kỳ Goroutine của ứng dụng nào sẽ được đưa vào Mark Assist tương ứng với lượng dữ liệu nó đang thêm vào Heap Memory. Một side effect tích cực của Mark Assist là nó giúp hoàn thành quá trình thu gom nhanh hơn.

Hình 4 cho thấy Goroutine của ứng dụng đang chạy trên P3 hiện đang thực hiện một Mark Assist và giúp đỡ công việc garbage collection. Hy vọng rằng các Goroutine khác không cần phải tham gia nữa. Các ứng dụng có phần cấp phát nặng có thể thấy hầu hết các Goroutine đang chạy thực hiện một lượng nhỏ Mark Assist trong quá trình garbage collection.
Một mục tiêu của Garbage Collector là loại bỏ của Mark Assist. Nếu bất kỳ process thu gom nào request nhiều Mark Assist, Garbage Collector có thể bắt đầu process thu góp rác tiếp theo sớm hơn. Điều này được thực hiện với hy vọng giảm lượng Mark Assist cần thiết trong quá trình garbage collection tiếp theo.
Mark Termination - STW
Khi công việc Marking hoàn thành, giai đoạn tiếp theo là Mark Termination. Đây là lúc Write Barrier được tắt, các nhiệm vụ dọn dẹp khác được thực hiện, và mục tiêu garbage collection tiếp theo được tính toán. Các Goroutine mà tìm thấy mình trong một vòng lặptrong giai đoạn Marking cũng có thể gây ra độ trễ Mark Termination STW bị kéo dài..

Hình 5 cho thấy tất cả các Goroutines đều bị dừng lại trong khi giai đoạn Mark Termination hoàn thành. Hoạt động này thường diễn ra trung bình trong khoảng từ 60 đến 90 micro giây. Giai đoạn này có thể được thực hiện mà không cần dừng toàn bộ hệ thống, nhưng việc sử dụng STW giúp code đơn giản hơn và sự phức tạp gây ra không đáng kể so với lợi ích nhỏ.
Khi việc garbage collection hoàn tất, mọi P đều có thể được sử dụng bởi các Goroutines của ứng dụng một lần nữa và ứng dụng hoạt động lại với tốc độ tối đa.

Hình 6 cho thấy tất cả các P khả dụng đều đang xử lý công việc trở lại sau khi việc garbage collection hoàn tất. Ứng dụng đã trở lại với tốc độ tối đa như trước khi việc thu gom bắt đầu.