Memory allocations
Trong việc lập trình, đặc biệt là Go, thông thường ta sẽ không để ý quá nhiều khi khởi tạo biến, do Go đã có trình thu gom rác tự động rồi. Dần dà, ta không còn quan tâm đến việc liệu khởi tạo biến có gây áp lực lên trình dọn rác của Go hay không? Biến nào sẽ được lưu trên stack? Biến nào thì lưu trên heap? Liệu sự vô tâm của ta có làm lãng phí tài nguyên hệ thống hay không?
Dẫn nhập
Trên máy tính, địa chỉ bộ nhớ mà chúng ta dùng là trên một bộ nhớ RAM ảo (vitual memory), Còn việc truy xuất như thế nào trên RAM vật lý (physical memory) sẽ do MMU đảm nhận. Việc này giúp cho chúng ta khi làm việc với bộ nhớ sẽ có cảm giác như đang thao tác với các vùng nhớ liên tục thay vì rời rạc như trên RAM vật lý.
Trong bộ nhớ ảo đó, chúng sẽ được chia thành nhiều phân vùng khác nhau như:
↑ Heap grows up ↑
Khi một biến được lưu trên stack, nó sẽ được tự động giải phóng khi thoát khỏi hàm. Vậy, nếu ta có một biến (ví dụ như slice) cần sử dụng ở một hàm khác với hàm đã tạo ra chính nó thì sao? Nó cần được lưu ở một vùng nhớ mà ở đó nó có vòng đời sống lâu hơn thay vì sẽ tự động giải phóng khi thoát khỏi hàm, đó chính là heap.
Khi ở heap, vấn đề dọn dẹp bắt đầu khó khăn hơn. Vùng nhớ ở heap có thể sẽ được tham chiếu bởi các biến khác nhau, ta có thể giải phóng vùng nhớ heap của biến a, nhưng biến b vẫn còn sử dụng vùng nhớ đó thì sao? Khi nào thì dọn dẹp? Lúc đó, GC sẽ là sự cứu tinh của ta.
ref count chỉ dùng để trực quan hóa.Mỗi process sẽ tách biệt nhau về bộ nhớ. Do đó, địa chỉ bộ nhớ process A có thể trùng với địa chỉ bộ nhớ của process B, nhưng đó là 2 vùng nhớ khác nhau trong RAM vật lý.
Stack/Heap
Có một sự thật rằng, trong phần lớn trường hợp, chi phí việc tìm kiếm memory trên stack sẽ rẻ hơn tìm kiếm trên heap. Cũng tương tự như thế, chi phí cho việc dọn dẹp rác trên stack cũng rẻ hơn trên heap.
Dù sao thì stack cũng sẽ được tự động dọn dẹp sau khi thoát hàm. Còn trên heap thì nó chỉ bị dọn dẹp khi không có 1 vùng nhớ nào đang tham chiếu vào nó nữa, và công việc dọn dẹp đó là việc của GC.
Block size classes trên Heap
Heap là vùng nhớ có dung lượng khá lớn. Heap sẽ lưu trữ các vùng nhớ được cấp phát vào các block size classes.
Mỗi lần bạn khởi tạo một biến, Go sẽ không nói OS rằng: "Hey, cấp giúp tôi vùng nhớ cho biến này đi!", bởi vì việc đó rất tốn kém bởi chi phí switching giữa kernel và Go. Thay vào đó, Go sẽ yêu cầu OS cấp một vùng địa chỉ bộ nhớ có kích thước khoảng 64MB (trong hệ 64 bit) gọi là Arena. Lưu ý đây chỉ là vùng nhớ địa chỉ giành chỗ của Go và chưa được sử dụng, cho nên sẽ không tốn RAM. Vùng địa chỉ bộ nhớ đó sẽ thuộc Go quản lý, khi bạn khởi tạo một biến nào đó, Go sẽ tìm kiếm vùng nhớ trống và cắt phần bộ nhớ đó cho bạn.
Để quản lý hiệu quả, tăng tốc độ truy xuất cũng như cấp phát nhanh hơn. Go tổ chức bộ nhớ thành nhiều vùng khác nhau, nhỏ nhất là block size classes.
- Với các biến <= 32KB, chúng sẽ được phân bổ vào các block size do Go định nghĩa, với kích thước nhỏ nhất là 8, 16, 32, 48, 64, 80 và 96 bytes.
- Đối với các biến > 32KB, chúng sẽ được phân bổ vào nhiều page có kích thước 8KB mỗi page (page là vùng nhớ lớn hơn block size classes).
Do đó, nếu bạn yêu cầu cấp vùng nhớ cho biến có kích thước 24 bytes, bản chất Go sẽ không cắt cho bạn 24 bytes, mà nó sẽ cấp cho bạn một block size classes có kích thước là 32 bytes, bạn sẽ bị lãng phí 8 bytes. Tương tự như thế, nếu bạn yêu cầu Go cấp vùng nhớ nằm trong khoảng [33, 48] thì Go cũng sẽ cấp cho bạn vùng nhớ 48 bytes.
Vậy khi bạn yêu cầu cấp vùng nhớ có slice có kích thước 32769 phần tử (> 32 KB), Go sẽ cấp cho bạn vùng nhớ (32768 + 8192, tương đương là 5 pages). Vì vậy, bạn đang lãng phí mất 8191 bytes bộ nhớ rồi.
Tiết kiệm bộ nhớ
String
Bạn hãy thử xem xét đoạn code sau
var s = []byte{32: 'b'} // len(s) == 33
temp1 := string(s) // alloc 48
temp2 := string(s) // alloc 48
result := temp1 + temp2 // alloc 80Trong đó, với biểu thức này thật chất có 3 lần cấp phát:
string(s) + string(s)Mỗi string(s) sẽ cấp 33 bytes dữ liệu, nhưng vì không có block size classes nào phù hợp nên Go sẽ cấp phát vùng nhớ kích thước 48 bytes. Vì vậy, chúng ta đã lãng phí 15 bytes cho mỗi string(s).
Sau khi có 2 string tạm, Go sẽ cấp bộ nhớ cho kết quả của phép ghép chuỗi string(s) + string(s) là 33 + 33 = 66 bytes và nó thuộc classes 80 bytes. Ta đã lãng phí mất 14 bytes.
Do đó, tổng số lượng lãng phí là 15 + 15 + 14 = 44 bytes.
Ta có 2 string tạm, và GC sẽ phải dọn dẹp nó, với một phép nối chuỗi đơn giản, ta đã vô tình gây áp lực lên GC.
Một phép cộng chuỗi, thì rác sẽ không nhiều, nhưng việc gì xảy ra nếu nó nằm trong một vòng for? Hay nằm trong request được query nhiều liên tục?
Slices
Khi xưa tôi còn học ở đại học, tôi có một bài tập nhỏ với C++ như sau:
Tự triển khai vector từ đầu
Một cách triển khai đơn giản như sau:
- Tôi khởi tạo một mảng có kích thước cố định nhưng đủ lớn, ví dụ như
int[100]. - Tôi cho user push_back thoải mái.
- Đến khi nào đến phần tử 100, thì tôi cấp phát một vùng nhớ mới có kích thước
int[120]. - Sau đó, tôi copy các phần tử từ array ban đầu vào array mới. Và lặp lại các bước trên.
Sau này gặp slices trong Go, tôi hiểu ngay lý do tại sao make slices lại có 3 tham số:
slicesInt := make([]int, size, capacity)Phần quan trọng nhất không phải type, hay size mà là capacity. nếu ta có thể ước lượng được capacity nhất định, ta có thể cấp ngay 1 slices đủ để dùng mà không phải tạo ra nhiều slices rác không còn sử dụng nữa.
For loop
Ta nên cấp phát sao cho vùng nhớ cùng kiểu nằm chung với nhau và trong 1 lần cấp phát duy nhất.
Cấp phát bộ nhớ cùng kiểu nằm gần nhau, biến books:
//go:noinline
func CreateBooksOnOneLargeBlock(n int) []*Book {
books := make([]Book, n)
pbooks := make([]*Book, n)
for i := range pbooks {
pbooks[i] = &books[i]
}
return pbooks
}Cấp phát rời rạc:
//go:noinline
func CreateBooksOnManySmallBlocks(n int) []*Book {
books := make([]*Book, n)
for i := range books {
books[i] = new(Book)
}
return books
}Tạo pool
Với cùng kiểu dữ liệu, ta có thể tái sử dụng biến đó cho các giá trị khác. Ví dụ:
type Farm struct {
Name string
Area int
HasCow bool
}
func main() {
farm := Farm{
Name: "Green Valley",
Area: 120,
HasCow: true,
}
farm = Farm{}
}Để nâng cấp hơn thì ta có thể tạo pool để tăng tính tái sử dụng
package main
import "sync"
type RequestContext struct {
UserID int64
TraceID string
Tags []string
Body []byte
IsAdmin bool
}
var requestContextPool = sync.Pool{
New: func() any {
return new(RequestContext)
},
}
func acquireRequestContext() *RequestContext {
ctx := requestContextPool.Get().(*RequestContext)
*ctx = RequestContext{} // zero lại toàn bộ
return ctx
}
func releaseRequestContext(ctx *RequestContext) {
*ctx = RequestContext{} // dọn dữ liệu cũ
requestContextPool.Put(ctx)
}Dùng:
func handleRequest() {
ctx := acquireRequestContext()
defer releaseRequestContext(ctx)
ctx.UserID = 101
ctx.TraceID = "trace-xyz"
ctx.Tags = append(ctx.Tags, "api", "v1")
}Pool sẽ phù hợp cho các kiểu kiểu dữ liệu mang các tính chất sau:
- Khởi tạo nhiều.
- Kích thước object vừa hoặc lớn.
- Thời gian sống ngắn.
- Benchmark cho kết quả alloc nhiều.
Kết luận
Việc ta để ý tới memory allocations cho ta được ít nhất hai lợi ích. Một là tiết kiệm được bộ nhớ RAM - bộ phận khá đắt đỏ khi mua VPS hoặc Cloud. Hai là giảm bớt gánh nặng cho GC, giảm thời gian delay cũng như giảm công việc CPU xuống đáng kể.
Bình luận