Memory allocations

Khám phá cách Go quản lý bộ nhớ và các kỹ thuật tối ưu hóa giúp tiết kiệm RAM, tăng hiệu năng.
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ư:

🗺 Sơ đồ
📖 Chi tiết
Virtual Memory Layout
0xFFFF…
Stack Segment
↓ Stack grows down ↓
↑ Heap grows up ↑
Free Memory
Heap Segment
BSS Segment
Data Segment
Code Segment
0x0000…

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.

⚠️
Lưu ý ref count chỉ dùng để trực quan hóa.
Stack (biến cục bộ)
varA 0xC0001 dùng được
varB 0xC0001 dùng được
Cả hai biến cùng trỏ đến một vùng nhớ trên heap.
💡
Heap ref count: 2
struct User 0xC0001
Name"Alice"
Age30
Email"alice@..."

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

Physical RAM quản lý bởi OS kernel Process A go run server.go stack (goroutine) heap (GC managed) BSS / Data code segment virtual addr space 0x00 → 0xFF… Process B go run worker.go stack (goroutine) heap (GC managed) BSS / Data code segment virtual addr space 0x00 → 0xFF… Process C go run api.go stack (goroutine) heap (GC managed) BSS / Data code segment virtual addr space 0x00 → 0xFF…

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.

💡
Do đó, nếu ta sinh ra quá nhiều bộ nhớ trên heap mà không có mục đích dùng lâu dài trong chương trình, thì sẽ gây áp lực lên GC để dọn dẹp trên heap.

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.

✍️
Nếu bạn đọc muốn hiểu rõ hơn về cơ chế chia cũng như cấp phát, bạn đọc có thể đọc bài Understanding the Go Runtime: The Memory Allocator. Tôi sẽ không đi sâu đến phần thiết kế bên dưới của Go vì không phải mục đích tôi muốn nói đến ở đây. Bên dưới tôi sẽ vẽ một bản tóm tắt.
Go Runtime Memory Allocator
A
Arena
64 MB / arena
P
Page
8 KB · 8192 pages/arena
S
Span
1–N pages, 1 size class
C
Size Class
68 classes · 8B → 32KB
O
Object Slot
allocBits bitmap

Chọn một tầng bên trái để xem chi tiết.

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 80

Trong đó, 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)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?

💡
Thay vì sử dụng phép cộng chuỗi, ta có thể dùng strings.Builder để giảm allocation trong Go.

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

Tham khảo

Bình luận