[Phỏng vấn Golang] Pointer là gì và ưu nhược điểm của nó?
I. Pointer và ưu điểm?
Thông thường khi khai báo 1 biến, giá trị của biến đó sẽ được lưu vào trong vào 1 vùng nhớ. Còn với pointer nó sẽ lưu địa chỉ bộ nhớ của một biến khác. Con trỏ thường chiếm 4 - 8 byte nên khá là nhẹ.
1.Tối ưu hóa bộ nhớ
Chỉ mất 4-8 byte để lưu trữ giúp tối ưu hoá việc lưu trữ, tránh việc sao chép dữ liệu không cần thiết, đặc biệt với các cấu trúc dữ liệu lớn.
2.Thay đổi giá trị gốc
Cho phép bạn truyền con trỏ vào hàm thay vì truyền giá trị, bạn có thể thao tác trực tiếp. Hữu ích khi cần sửa đổi dữ liệu từ bên trong hàm.
3. Tạo cấu trúc dữ liệu động
Hữu ích trong việc tạo các cấu trúc dữ liệu phức tạp như danh sách liên kết, cây, đồ thị.
4. Biểu diễn giá trị nil
Con trỏ cho phép biểu diễn khái niệm "không có giá trị" (nil) cho các kiểu dữ liệu không thể có giá trị zero. Khi sử dụng con trỏ nil, chúng ta chỉ cần lưu trữ một giá trị nil (thường là một địa chỉ bằng 0), thay vì phải cấp phát bộ nhớ cho toàn bộ cấu trúc dữ liệu. Con trỏ nil cho phép chúng ta trì hoãn việc cấp phát bộ nhớ cho đến khi thực sự cần thiết.
5. Làm việc với slice và map
Con trỏ được sử dụng ngầm định trong slice và map, 2 loại dữ liệu rất hữu ích trong golang.
II. Nhược điểm
1.Độ phức tạp
Có thể khó hiểu và dễ gây lỗi cho người mới học. Với go thì cũng đơn giản đi nhiều rồi. Lạm dụng con trỏ có thể làm code khó đọc và bảo trì.
2. Nguy cơ lỗi null dereference
Khi một con trỏ chưa được khởi tạo hoặc trỏ đến giá trị nil mà lập trình viên cố tình dereference (truy cập giá trị tại địa chỉ mà con trỏ trỏ tới), sẽ dẫn đến lỗi truy cập vào vùng nhớ không hợp lệ, gây ra panic và dừng chương trình.
3. Race condition
Race condition
là một hiện tượng xảy ra trong lập trình đồng thời khi hai hoặc nhiều tiến trình (hoặc luồng) cùng truy cập và thao tác trên dữ liệu chung mà không có cơ chế đồng bộ hóa phù hợp. Điều này có thể dẫn đến kết quả không xác định hoặc không mong muốn.Khi sử dụng con trỏ và goroutine có thể dẫn tới race condition
4. Cache missing
Cách hoạt động của bộ nhớ cache:
- CPU có các tầng bộ nhớ cache (L1, L2, L3) để lưu trữ tạm thời dữ liệu từ RAM.
- Cache nhanh hơn RAM nhiều lần, giúp CPU truy cập dữ liệu nhanh hơn.
Cache miss
xảy ra khi CPU cố gắng đọc hoặc ghi dữ liệu từ bộ nhớ cache, nhưng dữ liệu đó không có trong cache. Điều này có thể xảy ra khi thao tác với con trỏ, bạn không truy cập trực tiếp vào dữ liệu mà đang gián tiếp truy cập thông qua một địa chỉ bộ nhớ. Dữ liệu tại địa chỉ này có thể không nằm trong cache, dẫn đến cache miss.
Con trỏ có thể dẫn đến cache miss nhiều hơn so với các kiểu dữ liệu tuyến tính (như array) vì con trỏ có thể trỏ đến các vùng nhớ phân tán, thay vì liên tiếp.
// VD1: ác Node có thể nằm rải rác trong bộ nhớ. Có thể gây nhiều cache misses
type Node struct {
Value int
Next *Node
}
// VD2: dữ liệu liền kề nhau, Ít cache misses hơn
type Node struct {
Value [1000]int
}
nodes := make([]Node, 1000)
5. Overhead
Khi sử dụng con trỏ, CPU cần thực hiện ít nhất hai lần truy cập bộ nhớ:
- Đọc địa chỉ từ con trỏ
- Truy cập dữ liệu tại địa chỉ đó
Điều này tạo ra overhead
so với truy cập trực tiếp vào dữ liệu. Tuy nhiên, overhead này thường nhỏ, nhưng khi bạn sử dụng nhiều con trỏ hoặc con trỏ đến những vùng nhớ không liền kề, nó có thể trở thành vấn đề.
Sử dụng nhiều con trỏ có thể tăng số lượng đối tượng mà Garbage Collection
phải theo dõi và quản lý.
6. Branch prediction
Branch prediction là cơ chế mà CPU dự đoán hướng đi của một câu lệnh nhánh (như if), và nếu dự đoán đúng, CPU sẽ tiết kiệm thời gian.
Ví dụ, đến bạn đi từ SG tới ngã 3 Vũng Tàu, bạn chưa biết tiếp theo nên rẽ trái hay phải. Theo cách thông thường bạn phải mở bản đồ lên để xem đi hướng nào (if rẽ phải == true
), rồi mới đi tiếp (then thực thi code
). Nhưng để tiết kiệm thời gian thì CPU sẽ dựa vào trí nhớ của nó, rẽ trái trước đã rồi tính, đồng thời vẫn mở bản đồ ra xem. Nếu nó dự đoán đúng thì nó đi nhanh hơn được một khúc, nếu dự đoán sai thì nó phải quay lại và rẽ phải.
Kiểm tra null pointer có thể ảnh hưởng đến branch prediction của CPU (vì mất thêm thời gian kiểm tra). Nếu phải kiểm tra null pointer thường xuyên mà tần suất xảy ra khó đoán định, điều này có thể làm giảm hiệu quả dự đoán, dẫn đến việc CPU phải dừng lại để xử lý các trường hợp dự đoán sai, gây tốn thời gian và công sức của CPU, hiện tượng gọi là branch misprediction
.
func sum(ptr *int, sum int) int {
if ptr != nil { // Có thể gây branch misprediction
sum += *ptr
}
return sum
}
func sum(val int, sum int) int {
sum += val // Không cần kiểm tra, tránh misprediction
return sum
}
III. Cách khắc phục
1. Sử dụng các công cụ phân tích tĩnh:
- Các công cụ như
go vet
haygolangci-lint
có thể giúp phát hiện lỗi tiềm ẩn liên quan đến con trỏ.
2. Sử dụng các biện pháp thay thế
- Không dùng nếu không cần thiết để không phải lo các vấn đề tiềm ẩn. Dùng thì khai báo rõ ràng tý để tránh người sau khó đọc.
- Sử dụng slices thay vì con trỏ mảng: Slices trong Go đã tích hợp sẵn cơ chế con trỏ, dễ sử dụng và an toàn hơn.
3. Xử lý null deference
Sử dụng defer
và recover
có thể giúp chương trình không bị crash. Tuy nhiên, đây không phải là cách khắc phục tốt nhất. Thay vào đó, phòng bệnh hơn chữa bệnh, kiểm tra trước xem nó có nil không để tránh panic ngay từ đầu. Recover chỉ nên được sử dụng khi không thể dự đoán lỗi trước, hoặc trong các trường hợp ngoại lệ.
4. Xử lý race condition
-
Sử dụng
Go Race Detector
: Chạy chương trình với flagrace
để phát hiện race conditions:Copy go run -race myprogram.go
Thiết kế để tránh chia sẻ dữ liệu: Cố gắng thiết kế chương trình để mỗi goroutine làm việc với dữ liệu riêng, giảm thiểu việc chia sẻ thông qua con trỏ.
- Sử dụng
atomic operation
- Sử dụng read-write
mutex
cho trường hợp đọc nhiều, ghi ít - Sử dụng
sync/atomic
package cho các phép toán đơn giản: - Sử dụng
channels
để đồng bộ hóa
Tham khảo
Group discord 2k+ mems: chém gió về lập trình và làm pet project cùng nhau
All rights reserved