Tính năng đầu tiên tôi muốn nói đến đó là việc xử lý và lưu trữ các giá trị hiệu quả của Go Go
var gocon int32 = 2019
Ở trên là một ví dụ về một giá trị trong Go. Khi biên dịch, gocon
tiêu thụ chính xác 4 byte của bộ nhớ.
So sánh Go với một vài ngôn ngữ khác:
Python
% python
>>> from sys import getsizeof
>>> gocon = 2019
>>> getsizeof(gocon)
24
Do cách Python biểu diễn các biến, để lưu một giá trị tương tự, Python cần tiêu thụ bộ nhớ gấp 6 lần. Phần bộ nhớ nhiều hơn này được Python sử dụng để theo doi thông tin của kiểu dữ liệu, tham chiếu,...
Hãy xem qua ví dụ sử dụng Java:
Java
int gocon = 2019;
Tương tự như Go, kiểu int
trong Java tiêu thụ 4 byte của bộ nhớ để lưu giá trị này. Tuy nhiên, để sử dụng giá trị này trong một collection
như List
hoặc Map
thì trình biên dịch cần phải chuyển nó về đối tượng Integer
.
Java
// 16 bytes on 32 bit JVM
// 24 bytes on 64 bit JVM
Integer gocon = new Integer(2019);
Vì vậy một số nguyên trong Java thường được trông như trên và tiêu thụ 16 byte hoặc 24 byte của bộ nhớ.
Tại sao điều này lại quan trọng? Bộ nhớ rẻ và có sẵn, vậy tại sao nên nắm rõ về chi phí này?
Trên đây là đồ thị cho ta thấy tốc độ CPU xung nhịp và tốc độ memory bus.
Lưu ý là khoảng cách giữa tốc độ xung nhịp của CPU và tốc độ memory bus liên tục mở rộng. Sự khác biệt hiệu năng là CPU dùng bao nhiêu thời gian để đợi memory.
Từ cuối những năm 1960, những nhà thiết kế CPU đã hiểu hơn về vấn đề này. Giải pháp của họ là sử dụng cache, một vùng nhỏ hơn và nhanh hơn memory được chèn giữa CPU và main memory.
Ví dụ:
// Location is a point in a three dimensional space
type Location struct {
// 8 bytes per float64
// 24 bytes in total
X, Y, Z float64
}
// Locations consumes 24 * 1000 bytes
var Locations [1000]Location
Trên đây là một kiểu dữ liệu Location
lưu tọa độ của các đối tượng trong không gian 3 chiều. Nó được viết bằng ngôn ngữ Go, vì vậy mỗi Location
tiêu thụ chính xác 24 byte của bộ nhớ.
Chúng ta có thể sử dụng kiểu dữ liệu này để tạo ra một mảng bao gồm 1,000 đối tượng Location
, việc này sẽ tiêu tốn 24,000 byte của bộ nhớ.
Bên trong mảng này, cấu trúc của Location
được lưu trữ tuần tự mà không phải là các con trỏ đến 1,000 cấu trúc Location
được lưu trữ ngẫu nhiên. Điều này rất quan trọng vì bây giờ toàn bộ 1,000 cấu trúc Location
được lưu tuần tự trong cache, được đóng gói cùng nhau.
Go cho phép tạo ra các cấu trúc dữ liệu nhỏ gọn, tránh những chi phí gián tiếp không cần thiết.
Cấu trúc dữ liệu nhỏ sẽ được cache tốt hơn.
Cache tốt hơn sẽ dẫn đến hiệu năng tốt hơn.
Mỗi lời gọi hàm đều tiêu tốn chi phí. Có ba điều diễn ra khi một hàm được gọi:
- Một stack frame mới được tạo ra và thông tin chi tiết của đối tượng gọi hàm này được lưu lại
- Bất kì thanh ghi nào có thể được ghi đè trong suốt quá trình gọi hàm đều được lưu lại trong stack
- Bộ vi xử lý tính toán địa chỉ của hàm đó và thực thi một nhánh đến địa chỉ mới
Ví dụ về inlining:
package util
// Max returns the larger of a or b.
func Max(a int, b int) int {
if a > b {
return a
}
return b
}
package main
import util
// Double returns twice the value of the larger of a or b.
func Double(a int, b int) int {
return 2 * util.Max(a, b)
}
Ví dụ này cho ta thấy hàm Double
gọi util.Max
.
Để tránh chi phí của việc gọi hàm util.Max
, trình biên dịch có thể chuyển nội dung xử lý của hàm util.Max
(inline) vào bên trong hàm Double
. Kết quả là sẽ trông giống như sau:
Sau khi thực hiện inline:
func Double(a int, b int) int {
temp := b //
if a > b { // Nội dung xử lý của hàm util.Max được sao chép vào trong hàm Double
temp = a //
} //
return 2 * temp
}
Sau khi thực hiện inline ta không còn thấy lời gọi hàm đến util.Max
nữa, nhưng logic xử lý của hàm Double
vẫn không thay đổi.
Inlining không phải chỉ có mỗi Go có, nhiều trình biên dịch hoặc ngôn ngữ JITed cũng thực hiện việc tối ưu này. Nhưng cách Go thực hiện như thế nào?
Go cài đặt điều này rất đơn giản, khi một package
được biên dịch, nhưng function đủ nhỏ để thực hiện inlining đều được đánh dấu và sau đó thực hiện biên dịch như bình thường.
Sau đó cả mã nguồn của hàm đó và phiên bản của lần biên dịch đều được lưu lại.
util.a
% strings ~/pkg/linux_amd64/util.a
...
package util
import runtime "runtime"
func @"".Max (@"".a
2 int, @"".b
3 int) (? int) { if @"".a
2 > @"".b
3 { return @"".a
2 }; return @"".b
func Test() bool {
return false
}
func Expensive() {
if Test() {
// something expensive
}
}
Trong ví dụ trên, mặc dù hàm Test
luôn luôn trả về giá trị false
, hàm Expensive
không thể biết rằng không cần thực thi nó.
Khi hàm Test
được inline, chúng ta sẽ thấy một vài thứ giống như sau:
func Expensive() {
if false {
// something expensive is now unreachable
}
}
Trình biên dịch bây giờ biết rằng đoạn code này không thể truy cập. Điều này không chỉ tiết kiệm chi phí vì không phải thực hiện gọi hàm Test
, nó còn tiết kiệm trong quá trình biên dịch hoặc thực thi các đoạn code không bao giờ truy cập đến.
Garbage collection giúp Go trở thành một ngôn ngữ đơn giản hơn và an toàn hơn.
Sự xuất hiện của garbage collection không có nghĩa là nó sẽ khiến Go chậm đi, hoặc nó có thể quyết định tốc độ xử lý chương trình của bạn.
Điều đó có nghĩa là bộ nhớ được phân vùng trên heap sẽ phải đi kèm với chi phí. Nó giống như một khoản nợ làm tốn thời gian xử lý của CPU mỗi khi GC chạy đến khi bộ nhớ đó được thu hồi.
Tuy nhiên ta cũng có một cùng nhớ khác được gọi là Stack. Không giống như ngôn ngữ C đó là các giá trị sẽ luôn nằm ở vùng nhớ Heap khi sử dụng malloc
, hoặc trên Stack nếu được khai báo bên trong phạm vi của một hàm, Go thực hiện một cải tiến gọi là escape analysis.
Escape analysis:
- Xác định khi nếu có bất kì tham chiếu nào đến một giá trị thoát ra khỏi hàm mà giá trị được khai báo.
- Nếu không có tham chiếu nào thoát ra ngoài, giá trị đó có thể được lưu trữ ở trên Stack.
- Các giá trị được lưu ở trên Stack không cần phải cấp phát hoặc thu hồi.
Hãy xem qua một vài ví dụ sau:
// Sum returns the sum of the numbers 1 to 100.
func Sum() int {
numbers := make([]int, 100) // numbers never escapes Sum()
for i := range numbers {
numbers[i] = i + 1
}
var sum int
for _, i := range numbers {
sum += i
}
return sum
}
Hàm Sum
cộng các số từ 1 đến 100 và trả về kết quả. Đoạn code thực hiện logic này khá dị, nhưng đây là một ví dụ giúp ta hiểu cách Escape Analysis hoạt động.
Vì numbers slice
chỉ được tham chiếu bên trong hàm Sum
, nên trình biên dịch sẽ lưu trữ slice 100 số nguyên đó ở trên vùng nhớ Stack mà không phải Heap. Vì vậy, sẽ không cần GC thủ hồi numbers
, nó sẽ tự động được thu hồi sau khi hàm Sum
kết thúc.
Ví dụ 2:
const Width, Height = 640,480
type Cursor struct {
X, Y int
}
func Center(c *Cursor) { // Center() dosen't retain a reference to c
c.X += Width / 2
c.Y += Height / 2
}
func CenterCursor() {
c := new(Cursor) // c created with new() but never visible
Center(c) // outside CenterCursor(), so will be
fmt.Println(c.X, c.Y) // allocated on the stack
}
Trong hàm CenterCursor
chúng ta tạo một Cursor
mới và lưu một con trỏ của nó ở trong biến c
.
Sau đó ta truyền c
qua hàm Center()
để thực hiện di chuyển Cursor
đến giữa màn hình.
Và cuối cùng ta in ra giá trị X
và Y
của Cursor
.
Ngay cả khi c
được khởi tạo bằng hàm new
, nó sẽ không được lưu ở trong phân vùng Heap, vì không có tham chiếu c
thoát ra khỏi hàm CenterCursor
.
% go build -gcflags=-m esc.go
# command-line-arguments
./esc.go:26 can inline Center # Center() is inlined into CenterCursor()
./esc.go:33 inlining call to Center #
./esc.go:6 Sum make([]int, 100) does not escape # make([]int, 100)
./esc.go:26 Cemter c does not escape # func Center(c *Cursor)
./esc.go:32 CenterCursor new (Cursor) does not escape # c:= new(Cursor)
./esc.go:34 NewPoint ... argument does not escape # fmt.Println(c.X, c.Y)
Escape analysis được thực hiện trong quá trình biên dịch, cấp phát trên Stack sẽ luôn nhanh hơn Heap.
Go có goroutine, là nền tảng cho khái niệm concurrency trong ngôn ngữ Go.
Trong quá khứ, máy tính chỉ chạy một process trong một thời điểm, sau những năm 60 ý tưởng multiprocessing hay time-sharing trờ nên phổ biến.
Trong một hệ thống time-sharing, hệ điều hành phải liên tục chuyển sự "chăm sóc" của CPU giữa các process bằng cách ghi lại trạng thái của process hiện tại, sau đó khôi phục trạng thái của một process khác.
Đây được gọi là process switching.
Chi phí khi thực hiện một process switch:
- Đầu tiên là kernel cần lưu nội dung của tất cả các thanh ghi của CPU cho process đó, sau đó khôi phục các giá trị cho process khác.
- Kernel cũng cần xóa các ánh xạ của CPU từ virtual memory đến physical memory vì chúng chỉ hợp lệ với process hiện tại
- Cuối cùng là chi phí để thực hiện chuyển đổi context (context switch) của hệ điều hành, và chi phí của chức năng lập lịch để chọn process tiếp theo chiếm CPU
Vì một process switch có thể xảy ra ở bất kì thời điểm nào trong một quá trình thực thi process, hệ điều hành cần lưu nội dung của tất cả các thanh ghi vì nó không biết hiện tại thanh ghi nào đang được sử dụng.
Blocking syscalls
// package runtime
TEXT ·Syscall(SB),NOSPLIT,$0-56
CALL runtime·entersyscall(SB)
...
MOVQ 8(SP), AX // syscall number
SYSCALL
...
CALL runtime·exitsyscall(SB)
RET
- Nhiều thread có thể cùng chia sẻ không gian địa chỉ ô nhớ
- Việc tạo và chuyển đổi nhanh hơn so với các process
Điều này dẫn đến sự phát triển của thread, về mặt khái niệm thì thread và process giống nhau nhưng các thread chia sẻ không gian địa chỉ ô nhớ.
Vì các thread chia sẻ không gian địa chỉ ô nhớ, nên chúng nhỏ hơn process và cũng nhanh hơn khi tạo và nhanh hơn khi chuyển đổi giữa các thread với nhau.
Goroutine lấy ý tưởng từ Thread. Goroutine là co-operatively scheduled, không dựa vào kernel để quản lý time-sharing.
Việc chuyển đổi giữa các gorountine chỉ xảy ra ở thời điểm xác định, khi một lời gọi rõ ràng được tạo tới Go runtime scheduler.
Trình biên dịch biết các thanh ghi nào đang sử dụng và lưu chúng tự động.

