Ngôn ngữ lập trình là một trong số các công cụ lập trình viên sử dụng để có thể tạo ra những chương trình cho máy tính. Và cũng giống như những ngành nghề khác trên thế giới, khi càng hiểu sâu về cách làm việc của công cụ mình đang dùng, thì sản phẩm chúng ta tạo ra càng có chất lượng cao hơn.
You don’t have to be an engineer to be a racing driver, but you do have to have mechanical sympathy.
Nguồn: Jackie Stewart, racing driver
Khi lập trình với ngôn ngữ Golang cũng như vậy, chúng ta càng hiểu sâu về cách làm việc của bộ biên dịch và cả cách vận hành của ngôn ngữ lúc runtime, thì chúng ta càng có nhiều cơ hội để tạo ra chương trình có hiệu năng tốt hơn ngay từ đầu.
Và bài viết này sẽ làm rõ cách Golang quản lý bộ nhớ để từ đó chúng ta có thể biết được các cách để tối ưu hiệu năng chương trình của mình.
1. Cấp phát bộ nhớ là gì?
Khi lập trình, chúng ta khai báo các biến, và khi chương trình chạy, các biến này cần các vùng nhớ để có thể lưu trữ giá trị. Trong chương trình Golang, các biến có thể được lưu ở một trong hai vùng nhớ là stack hoặc heap.
Stack là vùng nhớ liên tiếp nhau được tổ chức theo cấu trúc LIFO để chứa các biến local của một Goroutine nào đó, mỗi goroutine có một stack riêng. Còn heap là vùng nhớ được chia sẻ chung giữa tất cả Goroutines trong một chương trình.
Trong quá trình chạy, khi cần lưu trữ giá trị cho các biến, chương trình cần quyết định xem biến đó có thể đặt ở đâu trong RAM, quá trình tìm vùng nhớ phù hợp để đặt dữ liệu này gọi là cấp phát bộ nhớ.
2. Stack hoạt động như thế nào?
Cách cấp phát bộ nhớ của stack là cực kỳ nhanh, do stack là 1 vùng nhớ liên tiếp, nên chương trình không cần phải tìm đâu xa mà chỉ cần lấy các ô nhớ ngay kế tiếp để lưu trữ dữ liệu. Và chỉ cần lưu lại đỉnh của stack là chương trình đã có thể phân biệt được vùng nhớ đang dùng và vùng nhớ chưa được dùng trong một stack.
Kích thước của stack được khởi tạo với 2 KB (có thể thay đổi tuỳ thuộc vào phiên bản) và tự tăng, giảm khi cần thiết miễn là vùng nhớ đó là liên tục. Mặc định kích thước tối đa của 1 stack là 1GB đối với CPU 64 bit, và 250 MB đối với CPU 32 bit, chúng ta có thể thay đổi kích thước tối đa của stack qua hàm SetMaxStack.
Chúng ta hãy dùng chương trình đơn giản sau đây để có thể hiểu được cách Golang cấp phát và thu hồi bộ nhớ trong stack.
Khi bắt đầu chạy chương trình, 1 stack frame sẽ được tạo ra giành cho hàm run
, trong stack frame này, có 2 vùng nhớ được cấp phát để lưu trữ 2 biến a
và b
.
Mũi tên màu xanh thể hiện vùng nhớ đang được sử dụng (valid
), còn mũi tên màu đỏ thể hiện các vùng nhớ chưa sử dụng hay không thể truy cập (invalid
).
Khi chương trình chạy tới dòng 11, lúc này có thêm stack frame mới ứng với hàm sum
được tạo ra, và có 3 vùng nhớ ứng với 3 biến được tạo và tính toán trong stack frame này. Lúc này phần vùng nhớ valid đã tăng lên, còn phần vùng nhớ invalid đã giảm xuống.
Khi chương trình chạy đến dòng 6, stack frame của hàm sum
bị thu hồi, stack frame trước đó của hàm sum
bị coi là invalid. Stack frame của hàm run
lúc này tăng lên, đè lên vùng nhớ của stack frame của hàm sum
lúc trước (không còn vùng nhớ cho x = 1
nữa, thay vào đó là vùng nhớ cho c = 3
).
Như vậy, để thu hồi vùng nhớ không dùng nữa, stack thì chỉ cần di chuyển con trỏ của đỉnh stack sang vùng nhớ khác là đủ, và nếu cần thêm bộ nhớ thì chỉ cần ghi đè dữ liệu lên các vùng nhớ ngay sau và di chuyển con trỏ tiến lên tương ứng.
3. Heap hoạt động như thế nào?
Không giống như stack, heap là tập hợp các vùng nhớ có thể không liên tiếp nhau ở trên RAM, mỗi vùng nhớ như vậy gọi là 1 segment, và để có thể có được các segment này, Golang cần hệ điều hành cấp phát cho mình một vùng nhớ nào đó.
Quá trình tìm kiếm vùng nhớ phù hợp trong heap này tốn kém hơn nhiều so với việc chỉ cần lấy các vùng nhớ tiếp theo như của stack. Hơn nữa Golang còn phải duy trì thông tin về các segment đang được sử dụng để có thể truy cập và thu hồi trong tương lai.
Golang sử dụng GC (Garbage collector) với thuật toán Mark and sweep để có thể xác định các vùng nhớ đang dùng và loại bỏ các vùng nhớ không còn sử dụng. Tuy nhiên quá trình thu hồi bộ nhớ của GC có thể tạo ra một số ảnh hưởng tới hiệu năng của chương trình như:
- Có khoảng dừng nhỏ trong toàn bộ chương trình: Mặc dù GC chạy đồng thời với các Goroutine khác, tuy nhiên trong quá trình GC làm việc thì vẫn có thể gây ra một khoảng dừng nhỏ của toàn bộ chương trình, khoảng dừng này có thể là đáng kể trong các ứng dụng đòi hỏi hiệu năng cực cao như các ứng dụng thời gian thực.
- Tăng RAM: Khi GC thực hiện công việc của mình, nó cần được cấp phát bộ nhớ để hoạt động, vì vậy quá trình này làm cho chương trình tăng dùng RAM và CPU.
- Tăng CPU: Do GC khi chạy có thể dùng tới 25% CPU để có thể xác định các vùng nhớ cần loại bỏ, nên quá trình này có thể ảnh hưởng tới thời gian thực thi của các Goroutine khác khi mà CPU đang được sử dụng ở mức cao và ảnh hưởng tới hiệu năng của toàn bộ chương trình nói chung.
Như vậy, ta thấy được việc cấp phát và thu hồi bộ nhớ trong heap là tốn kém hơn nhiều so với stack.
4. Tại sao không chỉ dùng mỗi stack để cấp phát cho nhanh?
Có 2 lý do chính cho việc này:
- Khi dùng stack, chương trình chỉ có thể giải phóng bộ nhớ bằng cách thay đổi con trỏ tới đỉnh của stack. Với những vùng nhớ nằm ở giữa thì sẽ không thể giải phóng được cho đến khi những vùng nhớ ở đỉnh được giải phóng, việc này có thể gây ra tình trạng dùng quá nhiều bộ nhớ một cách không mong muốn.
- Có thể làm cho chương trình chạy không đúng do giá trị của biến bị thay đổi một cách không thể biết trước khi sử dụng con trỏ.
Cùng làm rõ lý do số 2 qua đoạn chương trình đơn giản như ở ảnh dưới đây:
Khi nhìn vào chương trình này, chúng ta dễ dàng biết được màn hình sẽ in ra giá trị là 3. Nhưng hãy giả định rằng chương trình này chỉ sử dụng stack mà không dùng heap để cấp phát bộ nhớ, thì giá trị in ra màn hình sẽ như nào nhé?
Khi chương trình chạy đến dòng 4, biến x
sẽ được khởi tạo với giá trị là nil
ở trong stack.
Khi chương trình chạy đến dòng 11 như hình bên trên thì tại địa chỉ add-2
sẽ chứa dữ liệu với giá trị bằng 1.
Khi chạy đến dòng 5, x
lúc này có giá trị là add-2
(địa chỉ của ô nhớ nằm trong stack).
Do ở dòng 5 xuất hiện thêm biến z
, nên khi chạy đến dòng 6, stack frame của hàm main
mở rộng hơn, và ô nhớ add-2
lúc này nhận giá trị của z = 2
(do z = *x + 1
, mà *x = 1
), chứ không còn là 1 nữa. Và như vậy khi chạy đến dòng 7, màn hình sẽ in ra 4 (do x = add-2
, nên *x
lúc này là 2, và z
là 2, nên z = *x + z = 4
).
Như vậy nếu chỉ sử dụng stack để cấp phát bộ nhớ, thì chương trình đã ra kết quả khác so với kỳ vọng của chúng ta, vì vậy chương trình cần một cách quản lý bộ nhớ với cơ chế khác để có thể ra được kết quả như mong muốn. Và đó là một trong số các lý do heap được sử dụng.
Hình dưới đây cho thấy cách bộ nhớ heap được sử dụng trong chương trình Golang cùng với bộ nhớ stack.
Khi sử dụng heap, biến y
sẽ được cấp phát bộ nhớ trong heap, thay vì trong stack, và như vậy, giá trị của x là add-3
(địa chỉ của 1 ô nhớ trong heap). Lúc này khi stack frame của hàm main có mở rộng lên nữa thì giá trị tại add-3
cũng sẽ không bị thay đổi, và chúng ta có được kết quả đầu ra như mong muốn.
5. Khi nào vùng nhớ được cấp phát trên heap?
Khi viết chương trình với golang, chúng ta không thấy có sự xuất hiện của các cú pháp để quy định một biến được lưu trữ ở stack hay heap, công việc này là do bộ biên dịch của golang hoàn toàn quyết định (công việc này của bộ biên dịch được gọi là Escape analysis). Bộ biên dịch dùng một số luật chính sau:
- Kích thước của biến của lớn để có thể lưu trữ trong stack.
- Các biến toàn cục mà nhiều Goroutine có thể truy cập.
- Biến là một con trỏ được gửi vào channel.
- Một biến được tham chiếu bởi giá trị khi gửi vào channel.
- Biến chưa biết được chính xác kích thước tại thời điểm biên dịch.
- Cấp phát lại bộ nhớ cho mảng hỗ trợ cho slice khi dùng hàm append.
- Biến nhận giá trị là con trỏ được trả về từ một hàm/ phương thức .
Tuy nhiên, đây không phải là toàn bộ các luật mà bộ biên dịch của Golang sử dụng. Và rất có thể, các luật này sẽ bị thay đổi trong tương lai trong các phiên bản tiếp theo. Cách chính xác nhất để biết biến nào trong chương trình được cấp phát ở heap, hãy hỏi chính bộ biên dịch của Golang. Có thể dùng câu lệnh sau để kiểm tra:
go build -gcflags "-m=2"
Khi chạy lệnh này với chương trình được lấy vụ dụ ở mục 4. Ta thấy kết quả là biến y đã được di chuyển sang heap như hình bên dưới
6. Một số cách tối ưu
Sau khi đã biết chính xác cơ chế hoạt động của stack và heap trong golang. Chúng ta thấy rằng việc giảm thiểu việc cấp phát vùng nhớ (đặc biệt là trên heap) sẽ tăng hiệu năng của chương trình, và dưới đây là một số cách chúng ta có thể sử dụng
6.1 Một số cách tiếp cận để giảm cấp phát vùng nhớ trên heap
- Dùng
strings.Builder
thay vì phép toán+
để nối chuỗi. - Nếu có thể, tránh biến đổi
[]byte
thành kiểustring
. - Điền trước kích thước của slice và map trong mã nguồn nếu có thể biết trước được.
- Sử dụng các cấu trúc dữ liệu nhỏ để có thể vừa trên stack.
- Sử dụng
pool
hoặccache
để có thể tái sử dụng lại các đối tượng trong tương lai thay vì phải tạo mới từ đầu. - Đổi chữ ký của hàm/ phương thức (cách khai báo tham số đầu vào và kết quả đầu ra) nếu có thể miễn không gây ra sự thay đổi về chức năng của hàm/ phương thức đó
Chúng ta hãy tìm hiểu kỹ hơn về ý cuối cùng qua nội dung bên dưới.
6.2 Tối ưu bằng cách đổi chữ ký của hàm/ phương thức
Khi làm việc với package io
, chúng ta bắt gặp io.Reader interface
như sau (tạm gọi là cách làm thứ nhất):
type Reader interface {
Read(p []byte) (n int, err error)
}
Vậy tại sao những người tạo ra Golang không sử dụng cách viết như sau (tạm gọi là cách làm thứ hai)?
type Reader interface {
Read(n int) (p []byte, err error)
}
Bởi slice bản chất là một cấu trúc dữ liệu chứa thông tin về length
, capacity
và một pointer
trỏ tới mảng hỗ trợ của slice đó. Khi hàm trả về slice, thì bộ biên dịch sẽ quyết định slice đó sẽ được cấp phát ở trên heap, và như vậy thì chúng ta sẽ không có cơ hội tối ưu việc cấp phát bộ nhớ nếu làm theo cách thứ hai.
Với cách thứ nhất, do slice được truyền vào từ tham số, nên trong phương thức Read, không cần phải khởi tạo slice nào để trả về cả, mà chỉ cần đưa dữ liệu vào slice là được.
Như vậy chúng ta có cơ hội tối ưu ở phương thức mà có gọi tới Read
, ví dụ như định nghĩa trước capacity
của slice ở một số không quá lớn thì slice đó sẽ ở trên stack, rồi truyền slice đó làm tham số của hàm Read
.
Như vậy chỉ bằng cách thay đổi chữ ký của hàm/ phương thức mà có thể đảm bảo được tính năng của hàm/ phương thức đó không bị thay đổi thì chúng ta cũng có thể tối ưu được hiệu năng chương trình của mình.
7. Tổng kết
Việc hiểu về cách thức quản lý bộ nhớ của Golang, cụ thể là sự khác biệt giữa heap và stack là một trong những kiến thức quan trọng giúp chúng ta tối ưu chương trình của mình trong rất nhiều bài toán khác nhau.
Hơn nữa, khi đã hiểu về các quy tắc bộ biên dịch áp dụng để xác định nơi cấp phát bộ nhớ, chúng ta có thể có những cách viết mã nguồn tối ưu hơn. Từ việc thay đổi cách khởi tạo, tương tác với các cấu trúc dữ liệu sẵn có, cho đến việc thay đổi chữ ký của hàm/ phương thức mà chúng ta viết ra đều có thể đem lại hiệu năng tốt hơn cho chương trình.
8. Tài liệu tham khảo
- https://pkg.go.dev/runtime/debug
- https://www.youtube.com/watch?v=ZMZpH4yT7M0&t=1137s
- Sách Go Optimizations 101
- Sách 100 Go Mistakes and How to Avoid Them