Đi tới nội dung chính
Go

Bài 1.2: Biến và Hằng (Var và Const)

Toàn tập về cách khai báo biến (var), hằng số (const), kỹ thuật iota nâng cao, Stringer, bản chất bộ nhớ (AST, Inlining) và phân tích tối ưu Escape Analysis.

24 phút5002 từMạnh Cường

Trong ngôn ngữ lập trình Go, việc khai báo các thực thể mang một triết lý rất rõ ràng: chặt chẽ, tối giản nhưng ẩn chứa những cơ chế quản lý bộ nhớ cực kỳ tinh vi. Bài viết này sẽ đưa bạn từ những cách khai báo cơ bản nhất đến các kỹ thuật tối ưu hóa bộ nhớ chuyên sâu như một lập trình viên Go thực thụ.

Lưu ý: Các ví dụ và cơ chế trong bài viết này tại thời điểm xuất bản (tháng 6/2026) hoàn toàn tương thích với phiên bản Go 1.26.4 ra mắt ngày 02/6/2026.


1. Khai báo Biến (Variables) với var:=

Từ khóa var dùng để khai báo một hoặc nhiều biến, nhằm yêu cầu chương trình cấp phát một vùng nhớ để lưu trữ dữ liệu trong quá trình thực thi. Hãy hình dung mỗi biến là một chiếc hộp được đặt tên: var age int nghĩa là “hãy tạo ra một chiếc hộp tên là age, chứa được số nguyên”.

Các cách khai báo biến bằng var

Tùy thuộc vào việc bạn có muốn chỉ định rõ kiểu dữ liệu hay giá trị ban đầu, var cho phép linh hoạt ở nhiều dạng:

Cú pháp khai báoVí dụ thực tếĐặc điểm
Đầy đủ kiểu dữ liệu và giá trịvar name string = "Go"Khai báo tường minh, dễ đọc nhất.
Khai báo không gán giá trị đầuvar age intBiến nhận Zero Value (giá trị mặc định) của kiểu dữ liệu.
Tự động suy luận kiểu dữ liệuvar isValid = trueTrình biên dịch tự nhận biết kiểu dựa vào giá trị gán vào.
Khai báo nhiều biến cùng lúcvar x, y int = 10, 20Rút ngắn số dòng code khi các biến chung kiểu dữ liệu.
Khai báo dạng khối (Block)var ( a = 1; b string = "Hi" )Gom nhóm các biến có liên quan lại với nhau ở đầu file/hàm.

Giá trị mặc định (Zero Value)

Khi bạn dùng var khai báo biến mà không gán giá trị, Go tự động điền vào một giá trị mặc định an toàn thay vì để ô nhớ chứa rác (như C/C++ đôi khi làm). Đây là thiết kế có chủ ý để tránh lỗi đọc dữ liệu rác.

  • Kiểu số (int, float64…): 0
  • Kiểu chuỗi (string): "" (chuỗi rỗng)
  • Kiểu luận lý (bool): false
  • Kiểu con trỏ, interface, slice, map, channel: nil
1
2
3
var count int      // count = 0, không phải rác bộ nhớ
var label string   // label = "", không phải ký tự ngẫu nhiên
var active bool    // active = false, không phải 1 hay -1 bất kỳ

So sánh var và toán tử khai báo ngắn :=

Toán tử := là cú pháp viết tắt phổ biến nhất trong Go.

Thay vì viết var x int = 5, bạn viết gọn thành x := 5. Tuy nhiên giữa hai cách này có những khác biệt quan trọng:

Phạm vi sử dụng (Scope):

  • var: Dùng được ở mọi nơi — bên ngoài hàm (biến toàn cục, package-level) hoặc bên trong hàm.
  • :=: Chỉ được dùng bên trong hàm. Nếu bạn dùng := ở cấp độ package (ngoài hàm), trình biên dịch sẽ báo lỗi ngay.

Bắt buộc gán giá trị:

  • var: Không bắt buộc gán giá trị ngay — biến sẽ nhận Zero Value.
  • :=: Bắt buộc phải gán giá trị ngay khi khai báo. Không thể viết x := mà không có vế phải.

Trường hợp đặc biệt: Khai báo lại với :=

Đây là điểm gây nhầm lẫn nhiều nhất cho người mới. Thông thường, bạn không thể khai báo lại một biến đã tồn tại trong cùng phạm vi. Tuy nhiên := có ngoại lệ: nếu phía trái có ít nhất một biến hoàn toàn mới, Go cho phép bạn “tái sử dụng” tên biến cũ mà không báo lỗi.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    x := 10
    fmt.Println(x) // 10

    // Đây KHÔNG phải khai báo lại x. Đây là gán lại giá trị cho x
    // và khai báo mới biến y. Go cho phép vì y là biến mới.
    x, y := 20, 30
    fmt.Println(x, y) // 20 30

    // Dòng này sẽ BÁO LỖI vì x đã tồn tại, không có biến mới nào được tạo.
    // x := 50 // Lỗi: no new variables on left side of :=
}

Quy tắc nhớ: := ở vế trái chỉ hợp lệ khi có ít nhất 1 tên biến chưa từng được khai báo trong cùng phạm vi đó.

Quy tắc đặt tên biến

  • Phải bắt đầu bằng một chữ cái (Latin hoặc Unicode, kể cả tiếng Việt) hoặc dấu gạch dưới _.
  • Không được bắt đầu bằng chữ số hoặc chứa ký tự đặc biệt (!, @, #…).
  • Phân biệt chữ hoa và chữ thường: ageAge là hai biến hoàn toàn khác nhau.
  • Để export (cho phép code bên ngoài package sử dụng) một biến, chữ cái đầu tiên bắt buộc phải viết HOA: Age thay vì age.

2. Khai báo Hằng số (Constants) với const

Trong Go, const khai báo hằng số — một giá trị không thể thay đổi trong suốt quá trình chạy chương trình. Nếu var là chiếc hộp có thể mở ra và thay nội dung, thì const là chiếc hộp được hàn kín ngay từ lúc tạo ra.

Các đặc điểm cốt lõi của const

  • Giá trị cố định: Trình biên dịch báo lỗi ngay lập tức nếu bạn cố gán lại giá trị.
  • Xác định tại thời điểm biên dịch (Compile-time): Giá trị của const phải được biết trước khi chương trình chạy. Điều này có nghĩa là bạn không thể gán kết quả của một hàm cho const, vì hàm chỉ chạy lúc Runtime.
1
2
const Pi = 3.14159       // [✓] Giá trị cố định, biết trước lúc biên dịch
const X = math.Sqrt(2)  // [✗] Lỗi! math.Sqrt() chỉ chạy được lúc Runtime
  • Không dùng toán tử :=: Hằng số bắt buộc phải viết tường minh bằng const, không có cú pháp ngắn.

Các cách khai báo hằng số const

Kiểu khai báoVí dụ thực tếĐặc điểm
Khai báo đơn lẻconst Pi = 3.14159Tự động suy luận kiểu dữ liệu.
Chỉ định rõ kiểuconst Domain string = "google.com"Định nghĩa rõ ràng kiểu hằng số.
Dạng khối (Block)const ( StatusOK = 200; StatusNotFound = 404 )Gom nhóm các hằng số có liên quan.
Kế thừa giá trịconst ( A = 1; B; C )BC tự động nhận giá trị và kiểu của A.

Giải thích cơ chế “Kế thừa giá trị” trong khối const:

Khi bạn khai báo nhiều hằng số trong một khối const (...) mà không gán giá trị, Go sẽ tự động áp dụng biểu thức của dòng trước đó cho dòng hiện tại.

1
2
3
4
5
6
const (
    A = 1 // Giá trị: 1
    B     // Tự kế thừa biểu thức "= 1" từ A → Giá trị: 1
    C     // Tự kế thừa biểu thức "= 1" từ B → Giá trị: 1
)
// A = 1, B = 1, C = 1

Tính năng này ít hữu ích khi giá trị là số cố định như trên, nhưng nó trở nên cực kỳ mạnh mẽ khi kết hợp với iota ở phần tiếp theo.


3. Các kỹ thuật nâng cao với iota

Khi làm việc với hằng số dạng khối, Go cung cấp từ khóa iota. Nó không phải là hàm hay biến — iota là một bộ đếm tự động được trình biên dịch duy trì riêng cho mỗi khối const (...). Nó bắt đầu từ 0 và tự tăng thêm 1 cho mỗi dòng hằng số trong khối đó.

1
2
3
4
5
const (
    A = iota // iota = 0 → A = 0
    B = iota // iota = 1 → B = 1
    C = iota // iota = 2 → C = 2
)

Kết hợp với cơ chế “kế thừa biểu thức” ở mục 2, bạn chỉ cần viết iota một lần ở dòng đầu:

1
2
3
4
5
const (
    A = iota // 0 — viết iota một lần
    B        // 1 — kế thừa biểu thức "= iota", iota tự tăng lên 1
    C        // 2 — kế thừa biểu thức "= iota", iota tự tăng lên 2
)

Sức mạnh của iota không chỉ là đếm số. Nó trở thành công cụ cực kỳ linh hoạt khi kết hợp với biểu thức toán học.

Sử dụng định danh _ để bỏ qua giá trị

Trong nhiều trường hợp thực tế, giá trị 0 được dành riêng để biểu thị trạng thái “chưa được thiết lập” hoặc “không hợp lệ”. Bạn có thể dùng _ (blank identifier) để bỏ qua giá trị 0 của iota, đồng thời vẫn cho phép bộ đếm tự tăng lên 1 cho dòng tiếp theo:

1
2
3
4
5
6
const (
    _ = iota // Giá trị 0 bị bỏ qua. iota tăng lên 1 cho dòng kế tiếp.
    Low      // 1
    Medium   // 2
    High     // 3
)

Nhờ vậy, nếu bạn khai báo var priority Priority mà quên gán giá trị, nó sẽ là 0 — và 0 không khớp với Low, Medium, hay High. Đây là kỹ thuật bảo vệ logic phổ biến trong Go.

Tạo cấu hình Dịch Bit (Bitmask / Flags)

Đây là ứng dụng mạnh nhất của iota. Để hiểu được, bạn cần nắm qua một chút về số nhị phân.

Nền tảng cần biết: Bitwise Operation

Mỗi số nguyên trong máy tính được lưu dưới dạng chuỗi bit 01. Ví dụ, byte có 8 bit:

1
2
3
4
Số 1  = 00000001
Số 2  = 00000010
Số 4  = 00000100
Số 8  = 00001000

Bạn thấy điểm đặc biệt chưa? Mỗi số là lũy thừa của 2, và chỉ có đúng 1 bit bằng 1 ở một vị trí khác nhau. Đây là nền tảng của Bitmask: dùng mỗi vị trí bit để đại diện cho một trạng thái boolean (có/không).

Phép toán 1 << iota nghĩa là gì?

<< là toán tử “dịch trái” (left shift). 1 << n có nghĩa là “dịch số 1 sang trái n vị trí bit”, tương đương với 2^n.

1
2
3
4
1 << 0 = 00000001 = 1
1 << 1 = 00000010 = 2
1 << 2 = 00000100 = 4
1 << 3 = 00001000 = 8

Ứng dụng thực tế: Hệ thống phân quyền người dùng

1
2
3
4
5
6
7
8
type Roles byte

const (
    Admin    Roles = 1 << iota // 1 << 0 = 00000001 (1)
    Editor                     // 1 << 1 = 00000010 (2)
    Viewer                     // 1 << 2 = 00000100 (4)
    Reporter                   // 1 << 3 = 00001000 (8)
)

Bây giờ bạn có thể lưu nhiều quyền cùng lúc trong một biến duy nhất bằng toán tử | (OR):

1
2
3
4
5
// Người dùng này vừa là Editor vừa là Viewer
//   Editor = 00000010
//   Viewer = 00000100
//   OR     = 00000110  (Bit 1 và bit 2 cùng bật)
var myUserRoles Roles = Editor | Viewer

Để kiểm tra xem người dùng có một quyền cụ thể không, dùng toán tử & (AND):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Kiểm tra: myUserRoles có phải Admin không?
//   myUserRoles = 00000110
//   Admin       = 00000001
//   AND         = 00000000  → Kết quả là 0, tức là false
isAdmin := (myUserRoles & Admin) == Admin  // false

// Kiểm tra: myUserRoles có phải Editor không?
//   myUserRoles = 00000110
//   Editor      = 00000010
//   AND         = 00000010  → Kết quả khác 0, bằng Editor, tức là true
isEditor := (myUserRoles & Editor) == Editor  // true

Tại sao & lại “kiểm tra” được? Vì & chỉ giữ lại bit 1 khi cả hai bên đều là 1. Nếu bit quyền đó được bật trong myUserRoles, phép AND sẽ cho ra đúng giá trị của quyền đó. Nếu không được bật, kết quả là 0.

Áp dụng công thức toán học tùy biến

Kết hợp cơ chế “kế thừa biểu thức” và iota, bạn có thể viết một biểu thức phức tạp một lần duy nhất ở dòng đầu, và trình biên dịch sẽ tự áp dụng đúng biểu thức đó (với giá trị iota được cập nhật) cho tất cả các dòng sau.

Điều quan trọng cần hiểu: toàn bộ biểu thức ở dòng đầu tiên được kế thừa, không phải chỉ một phần. iota trong biểu thức được thay thế bằng giá trị đếm của dòng hiện tại.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type ByteSize float64

const (
    _           = iota
    // Biểu thức đầy đủ: "ByteSize = 1 << (10 * iota)"
    // Toàn bộ biểu thức này được kế thừa xuống các dòng sau.
    KB ByteSize = 1 << (10 * iota) // iota=1 → 1 << 10 = 1,024
    MB                             // iota=2 → 1 << 20 = 1,048,576
    GB                             // iota=3 → 1 << 30 = 1,073,741,824
    TB                             // iota=4 → 1 << 40 = 1,099,511,627,776
)

Nếu bạn viết lại đầy đủ, kết quả sẽ giống hệt:

1
2
3
KB ByteSize = 1 << (10 * 1) // 1024
MB ByteSize = 1 << (10 * 2) // 1,048,576
GB ByteSize = 1 << (10 * 3) // 1,073,741,824

Tạo mới giá trị iotabằng 0

Bộ đếm iota có vòng đời gắn liền với từng khối const (...). Mỗi khi bạn mở một khối const mới, iota tự bắt đầu lại từ 0.

1
2
3
4
5
6
7
8
9
const (
    A = iota // 0
    B        // 1
)

const (
    C = iota // 0 (khởi tạo bộ đếm iota mới từ 0 cho khối const này)
    D        // 1
)

4. In Enum ra chữ với Stringer Interface

Khi bạn dùng iota để tạo các hằng số kiểu Roles như trên, hệ thống mặc định sẽ in ra con số khi bạn dùng fmt.Println:

1
2
fmt.Println(Admin)  // In ra: 1  (không ai hiểu 1 là gì!)
fmt.Println(Editor) // In ra: 2

Để Go in ra tên có ý nghĩa như "Admin" hay "Editor", kiểu dữ liệu của bạn cần triển khai phương thức String() string. Đây là cách Go thực thi fmt.Stringer interface — một thỏa thuận: “nếu kiểu của mày có phương thức String(), tao sẽ dùng nó để in thay vì dùng số”.

Cách 1: Switch-Case thủ công

Cách đơn giản nhất là tự viết hàm chuyển đổi:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func (r Roles) String() string {
    switch r {
    case Admin:
        return "Admin"
    case Editor:
        return "Editor"
    case Viewer:
        return "Viewer"
    case Reporter:
        return "Reporter"
    default:
        return fmt.Sprintf("Roles(%d)", int(r)) // In ra số nếu không khớp
    }
}

fmt.Println(Admin)  // In ra: Admin
fmt.Println(Editor) // In ra: Editor

Cách này dễ đọc nhưng tẻ nhạt khi bạn có hàng chục hằng số, và dễ quên cập nhật khi thêm hằng số mới.

Cách 2: Tự động hóa bằng công cụ stringer

Go cung cấp công cụ stringer trong bộ golang.org/x/tools để tự động sinh code. Bạn chỉ cần thêm một dòng comment đặc biệt ngay trên định nghĩa kiểu:

1
2
//go:generate stringer -type=Roles
type Roles byte

Sau đó chạy lệnh trong Terminal:

1
go generate

Go sẽ tự động tạo ra một file mới tên roles_string.go với nội dung được tối ưu hóa — thay vì dùng switch, nó dùng kỹ thuật tra cứu qua mảng ký tự (string indexing), nhanh hơn đáng kể khi có nhiều case. Bạn không cần đụng đến file đó, chỉ cần chạy lại go generate mỗi khi thêm hằng số mới.


5. Hằng số không định kiểu (Untyped Constants) và Cây AST

Untyped Constant là gì?

Trong Go, có sự khác biệt quan trọng giữa hai cách khai báo sau:

1
2
const TypedMax int = 100     // Typed constant: kiểu là int, cứng nhắc
const UntypedMax = 100       // Untyped constant: chưa có kiểu cụ thể

UntypedMax được gọi là Untyped Constant — nó chỉ là “con số thuần túy 100” chứ chưa bị ràng buộc vào kiểu int, int64, float64 hay bất kỳ kiểu nào. Giá trị của nó chỉ được chuyển thành kiểu cụ thể khi bạn gán vào một biến hoặc truyền vào một hàm. Bản thân hằng số vẫn là untyped.

1
2
3
4
var a int32   = UntypedMax  // UntypedMax → int32
var b float64 = UntypedMax  // UntypedMax → float64
var c int64   = UntypedMax  // UntypedMax → int64
// Nếu là TypedMax int, chỉ gán được cho int, gán cho float64 sẽ lỗi.

Cây cú pháp trừu tượng (AST) là gì?

Để hiểu tại sao Untyped Constant lại “thoát khỏi” giới hạn kích thước bộ nhớ, bạn cần biết sơ qua về AST (Abstract Syntax Tree — Cây cú pháp trừu tượng).

Khi bạn viết code Go và chạy go build, trình biên dịch không đọc code của bạn như một con người. Nó thực hiện nhiều bước biến đổi, trong đó bước đầu tiên là chuyển toàn bộ code thành một cấu trúc dữ liệu dạng cây — gọi là AST. Mỗi nút trên cây đại diện cho một phần tử trong code: biểu thức, câu lệnh, khai báo…

Hằng số không định kiểu chỉ tồn tại trên AST — chúng là các nút của cây, được xử lý bằng số học của trình biên dịch (compiler arithmetic) với độ chính xác tùy ý. Chúng chưa cần “bước xuống” thế giới thực với giới hạn 32-bit hay 64-bit. Chỉ khi gán vào biến, chúng mới được ép vào kích thước bộ nhớ cụ thể.

Ví dụ chứng minh: Tính toán vượt giới hạn int64

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main
import "fmt"

func main() {
    // 1. THỬ NGHIỆM VỚI CONST (Untyped)
    // 1 << 100 là con số khổng lồ: 2^100, vượt xa giới hạn int64 (2^63 - 1).
    // Nhưng trình biên dịch xử lý nó "trên AST" — chỉ là tính toán trên giấy.
    const bigConst = 1 << 100
    // Tiếp theo, dịch phải 98 bit: (1 << 100) >> 98 = 1 << (100-98) = 1 << 2 = 4
    const resultConst = bigConst >> 98

    fmt.Println(resultConst) // In ra: 4 — hoàn toàn bình thường

    // 2. THỬ NGHIỆM VỚI VAR (sẽ lỗi)
    // var bigVar int64 = 1 << 100
    // Lỗi biên dịch: cannot use 1 << 100 (overflows int64)
    // Lý do: var yêu cầu cấp phát ngay 8 byte (64 bit) trong bộ nhớ vật lý.
    // Con số 2^100 không thể nhét vào 64 bit → tràn bộ nhớ (overflow).
}

Tại sao (1 << 100) >> 98 = 4?

  • 1 << 100 nghĩa là dịch số 1 sang trái 100 vị trí bit = 2^100
  • >> 98 nghĩa là dịch sang phải 98 vị trí = chia cho 2^98
  • 2^100 / 2^98 = 2^(100-98) = 2^2 = 4

Với var, máy tính cần cấp phát ô nhớ 64-bit ngay lập tức, không thể chứa 2^100. Với const, phép toán được thực hiện “trên giấy” trong trình biên dịch, chỉ đến khi kết quả cuối cùng là 4 (rất nhỏ) mới cần nghĩ đến bộ nhớ thực.


6. Bản chất Bộ nhớ: var vs const

Inlining là gì?

Inlining (nội tuyến hóa) là kỹ thuật trình biên dịch Go dùng để tối ưu hằng số. Thay vì tạo ra một vùng nhớ riêng để lưu tên hằng số và để chương trình tra cứu mỗi lần dùng, trình biên dịch đơn giản là thay thế tên hằng số bằng giá trị số trực tiếp ngay trong mã máy được tạo ra.

Hãy hình dung thế này: bạn viết const Timeout = 30 và dùng nó 1000 lần trong code. Khi biên dịch, Go không tạo ra một “chiếc hộp” tên Timeout ở đâu đó trong bộ nhớ. Thay vào đó, mỗi chỗ bạn viết Timeout, trình biên dịch tự động thay thành số 30 — như thể bạn tự tay gõ 30 ở tất cả 1000 chỗ đó. Đây là Inlining.

Phân đoạn .rodata là gì?

Khi một hằng số không thể inline trực tiếp (ví dụ: một chuỗi dài được dùng ở nhiều nơi), trình biên dịch có thể đặt nó vào phân đoạn .rodata (read-only data) của file thực thi. Đây là vùng nhớ đặc biệt:

  • Được nạp vào RAM một lần duy nhất khi chương trình khởi động.
  • Được đánh dấu chỉ đọc ở cấp độ hệ điều hành — mọi nỗ lực ghi vào đây sẽ bị OS chặn lại.
  • Tất cả các goroutine/thread đều chia sẻ cùng một bản sao — không ai có bản riêng.

Ngược lại, biến var nằm trên Stack hoặc Heap (sẽ giải thích chi tiết ở mục 7).

Bảng so sánh đầy đủ

Đặc điểmHằng số (const)Biến (var)
Thời điểm xử lýGiai đoạn biên dịch (Compile-time).Giai đoạn thực thi (Runtime).
Vị trí bộ nhớĐược nhúng thẳng vào mã máy (Inlining) hoặc phân đoạn chỉ đọc (.rodata).Nằm trên Stack hoặc Heap tùy kết quả Escape Analysis.
Địa chỉ bộ nhớKhông có địa chỉ vật lý. Lệnh &Pi sẽ báo lỗi biên dịch.Có địa chỉ vật lý cố định trong RAM, lấy được bằng &age.
Chi phí RuntimeBằng 0 — không tốn gì lúc chạy.Tốn tài nguyên CPU để cấp phát, giải phóng, và Garbage Collector dọn dẹp.

Ví dụ minh họa tính tối ưu của const:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Cách 1: Dùng var — Mỗi vòng lặp tạo ra một biến mới trên Stack
// Sau 1,000,000 vòng lặp = 1,000,000 lần cấp phát và giải phóng bộ nhớ
for i := 0; i < 1_000_000; i++ {
    var timeout = 30
    runJob(timeout)
}

// Cách 2: Dùng const — Trình biên dịch thay thế Timeout bằng số 30 (Inlining)
// Sau khi biên dịch, dòng runJob(Timeout) trở thành runJob(30)
// Không có biến nào được tạo, không có bộ nhớ nào được cấp phát
const Timeout = 30
for i := 0; i < 1_000_000; i++ {
    runJob(Timeout) // Sau khi biên dịch: runJob(30) — y chang gõ tay
}

Quy tắc vàng: Cái gì không cần thay đổi, hãy ưu tiên dùng const. Đây không chỉ là về tốc độ — nó còn giúp code dễ đọc hơn vì bạn tuyên bố rõ ràng ý định “giá trị này không được phép thay đổi”.


7. Escape Analysis (Phân tích Trốn bộ nhớ)

Stack và Heap là gì?

Trước khi hiểu Escape Analysis, bạn cần biết chương trình Go quản lý bộ nhớ bằng hai vùng riêng biệt:

Stack (Ngăn xếp):

  • Mỗi Goroutine có một Stack riêng (thường bắt đầu từ 2KB và tự mở rộng khi cần).
  • Hoạt động theo nguyên tắc LIFO (vào sau ra trước): khi bạn gọi hàm A, một “khung nhớ” (stack frame) được đẩy lên. Khi A kết thúc và trả về, khung nhớ đó bị đẩy ra và bộ nhớ được giải phóng tức thì, hoàn toàn tự động, không cần GC.
  • Rất nhanh: Cấp phát chỉ là tăng một con trỏ số nguyên.

Heap (Vùng nhớ động):

  • Một vùng nhớ lớn dùng chung cho toàn bộ chương trình.
  • Dữ liệu trên Heap tồn tại lâu hơn vòng đời của hàm tạo ra nó.
  • Chậm hơn và tạo ra gánh nặng cho Garbage Collector (GC) — bộ dọn rác phải định kỳ quét toàn bộ Heap để tìm và giải phóng các vùng nhớ không còn được dùng đến. Mỗi lần GC chạy, toàn bộ chương trình phải tạm dừng trong vài micro-giây đến mili-giây.

Escape Analysis là gì?

Trong Go, bạn không tự gọi malloc() hay free() như trong C. Trình biên dịch tự quyết định biến nào nằm trên Stack và biến nào phải lên Heap. Quá trình phân tích đó gọi là Escape Analysis.

Tên gọi “trốn” (escape) xuất phát từ góc nhìn của Stack: nếu một biến “trốn khỏi” vòng đời của hàm chứa nó — tức là nó cần tồn tại sau khi hàm kết thúc — biến đó không thể ở Stack (vì Stack sẽ bị dọn sạch khi hàm kết thúc). Trình biên dịch bắt buộc phải đẩy nó lên Heap.

Quan trọng: Đây là quyết định của trình biên dịch lúc compile-time, không phải lúc runtime. Biến không tự “di chuyển” trong lúc chương trình chạy — ngay từ lúc biên dịch, trình biên dịch đã quyết định biến đó sẽ ở đâu và tạo ra code tương ứng.

4 trường hợp kinh điển biến trốn lên Heap

1. Trả về con trỏ tới biến cục bộ

1
2
3
4
5
6
7
func newUser() *User {
    u := User{ID: 1} // Biến u được tạo trong hàm này
    return &u        // Địa chỉ của u bị lọt ra ngoài hàm
    // Hàm sẽ kết thúc, Stack Frame của nó sẽ bị xóa.
    // Nhưng caller đang giữ &u — trỏ vào vùng nhớ sẽ bị xóa đó!
    // → Trình biên dịch phát hiện ra điều này và đẩy u lên Heap.
}

2. Tham số interface{} / any

Khi truyền một biến vào hàm nhận interface{}, Go cần thực hiện thao tác gọi là Boxing — gói giá trị và thông tin kiểu dữ liệu của nó vào một cấu trúc 2 con trỏ. Cấu trúc này không thể nằm trên Stack của hàm gọi vì nó cần tồn tại cho đến khi hàm nhận (fmt.Println) dùng xong.

Boxing là gì? Hãy hình dung bạn có một tờ giấy ghi số 42 (kiểu int). Khi truyền vào interface{}, Go tạo ra một “phong bì” có 2 thứ: địa chỉ để tra cứu “tờ giấy bên trong là kiểu gì” và địa chỉ đến bản sao của số 42. Chiếc phong bì này cần được đặt ở nơi caller và callee đều truy cập được → phải lên Heap.

1
2
3
func logValue(z int) {
    fmt.Println(z) // z bị đẩy lên Heap do quá trình Boxing vào interface{}
}

3. Kích thước biến thiên (Dynamic size) hoặc quá lớn

Kích thước Stack Frame phải được biết trước lúc biên dịch. Nếu kích thước chỉ biết lúc Runtime, trình biên dịch không thể xác định trước bao nhiêu ô nhớ cần dành → phải lên Heap.

1
2
3
func makeSlice(n int) []int {
    return make([]int, n) // n chỉ biết lúc Runtime → Heap
}

4. Gán vào Map hoặc Slice thông qua con trỏ

Map và Slice tự quản lý bộ nhớ của mình trên Heap. Nếu bạn lưu con trỏ của một biến vào Map/Slice, biến đó cần tồn tại lâu hơn hàm tạo ra nó → phải lên Heap.

Thực hành: Tự tay kiểm tra và sửa lỗi Escape Analysis

Hãy xem một ví dụ thực tế — viết hàm tạo chuỗi Log.

Cách CHƯA tối ưu (Biến trốn lên Heap):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type User struct {
    ID   int
    Name string
}

func FormatLog(u User) *string {
    // fmt.Sprintf nhận interface{} → Boxing → u.ID và u.Name trốn lên Heap
    res := fmt.Sprintf("User ID: %d, Name: %s", u.ID, u.Name)
    return &res // Trả về con trỏ → res bắt buộc trốn lên Heap
}

Kiểm tra bằng lệnh:

1
go build -gcflags="-m -l" main.go

Cờ -m yêu cầu trình biên dịch in ra các quyết định tối ưu hóa, trong đó có Escape Analysis. Cờ -l tắt tính năng inlining hàm để kết quả dễ đọc hơn. Kết quả bạn sẽ thấy:

1
2
3
./main.go:8:2: moved to heap: res
./main.go:7:44: u.ID escapes to heap
./main.go:7:53: u.Name escapes to heap

Ba dòng “moved to heap” và “escapes to heap” cho thấy mỗi lần gọi FormatLog, chương trình phải thực hiện 3 lần cấp phát Heap — và Garbage Collector phải dọn dẹp chúng. Trong hệ thống xử lý hàng triệu request/giây, đây là thảm họa hiệu năng.

Cách ĐÃ tối ưu (Giữ 100% trên Stack):

Nguyên tắc: Trả về giá trị (value) thay vì con trỏ với kiểu string. Và thay thế fmt.Sprintf bằng phép cộng chuỗi hoặc strconv.

Tại sao fmt.Sprintf gây escape còn strconv.Itoa thì không? Vì fmt.Sprintf nhận tham số dạng ...interface{} (variadic interface) — bất kỳ giá trị nào truyền vào đều phải được Boxing và đẩy lên Heap. Trong khi đó strconv.Itoa(n int) nhận thẳng kiểu int — không cần Boxing, không cần Heap.

1
2
3
4
5
6
7
import "strconv"

func FormatLogOptimized(u User) string {
    // strconv.Itoa nhận int trực tiếp → không Boxing → không escape
    // Phép cộng chuỗi được Go tối ưu thành một lần cấp phát trên Stack
    return "User ID: " + strconv.Itoa(u.ID) + ", Name: " + u.Name
}

Chạy lại lệnh kiểm tra:

1
go build -gcflags="-m -l" main.go

Output lần này:

1
./main.go:12:6: FormatLogOptimized u does not escape

Không còn “moved to heap” nào. Chi phí GC cho đoạn code này bằng 0. Đây là kỹ thuật thiết yếu khi xây dựng hệ thống chịu tải cao.

Tóm lại: Hiểu Escape Analysis giúp bạn viết code Go nhanh theo cách chủ động — không phải đoán mò mà là đọc được output của trình biên dịch và sửa đúng chỗ.

Chủ đề của bài viết

Chia sẻ bài viết

Gửi bài này cho người đang cần đúng chủ đề, hoặc lưu lại để quay về sau.

Đọc tiếp

Tất cả lộ trình học

Tiếp theo

Gợi ý bài tiếp theo

Bài 1.1c: Next-Generation Firewalls (NGFW) và IPS

Next-Generation Firewall (NGFW) và hệ thống phòng chống xâm nhập (IPS) là những thành phần thiết yếu trong kiến trúc bảo mật mạng hiện đại. Sự kết hợp giữa các …

Mở bài tiếp theo