Array được giới thiệu trong phần trước không được sử dụng phổ biến trong ngôn ngữ Go, cấu trúc dữ liệu thường được sử dụng hơn là một slice, tức là một mảng động, độ dài của nó không cố định, chúng ta có thể thêm các phần tử vào một slice và nó sẽ tự động mở rộng khi dung lượng không đủ.
Trong ngôn ngữ Go, cách khai báo của kiểu slice hơi giống với kiểu của mảng, nhưng do độ dài của slice là động nên bạn chỉ cần xác định kiểu phần tử trong slice khi khai báo:
Từ định nghĩa của slice, chúng ta có thể suy ra rằng kiểu được tạo bởi slice trong quá trình biên dịch sẽ chỉ chứa các kiểu phần tử trong slice, tức là int
hoặc interface{}
v.v. cmd/compile/internal/types.NewSlice
là hàm dùng để tạo các kiểu slice trong quá trình biên dịch:
Trường Extra
trong struct được phương thức trên trả về là struct chỉ chứa kiểu phần tử trong slice, nghĩa là kiểu phần tử trong slice được xác định trong quá trình biên dịch. Sau khi trình biên dịch xác định kiểu, nó sẽ lưu trữ kiểu trong trường Extra
. Chương trình lấy kiểu một cách linh hoạt khi chạy.
1. Cấu trúc dữ liệu
Các slice trong quá trình biên dịch thuộc kiểu cmd/compile/internal/types.Slice
, nhưng các slice trong runtime có thể được biểu diễn bằng reflect struct sau reflect.SliceHeader
, trong đó:
Data
là một con trỏ tới một mảng;Len
là chiều dài của slice hiện tại;Cap
là dung lượng của slice hiện tại, tức là kích thước của mảngData
:
Data
là một không gian bộ nhớ liên tục, không gian bộ nhớ này có thể được sử dụng để lưu trữ tất cả các phần tử trong slice. các phần tử trong mảng chỉ là các khái niệm logic. Bộ nhớ cơ bản thực sự là liên tục, vì vậy chúng ta có thể hiểu slice là bộ nhớ liên tục. Không gian cộng với việc xác định chiều dài và dung lượng.
Từ hình trên, chúng ta sẽ thấy rằng các slice có liên quan rất chặt chẽ với array. Các slice giới thiệu một lớp trừu tượng cung cấp các tham chiếu đến một số phân đoạn liên tục trong mảng. Là một tham chiếu đến một mảng, chúng ta có thể sửa đổi độ dài của nó trong runtime. Khi độ dài của mảng ở dưới cùng của slice không đủ, quá trình mở rộng sẽ được kích hoạt và mảng được chỉ định bởi slice có thể thay đổi, tuy nhiên, từ phối cảnh của lớp trên, slice không thay đổi. Chỉ cần xử lý slice và không cần quan tâm đến sự thay đổi của mảng.
Chúng tôi đã giới thiệu trong phần trước rằng trình biên dịch đơn giản hóa các thao tác như lấy kích thước của mảng và đọc ghi các phần tử trong mảng trong quá trình biên dịch: Do bộ nhớ của mảng là cố định và liên tục nên hầu hết các thao tác sẽ trực tiếp đọc ghi các vị trí cụ thể trong bộ nhớ. Tuy nhiên, cấu trúc của slice được xác định trong runtime và tất cả các thao tác cũng phụ thuộc vào runtime của ngôn ngữ Go.
2. Khởi tạo
Có ba cách để khởi tạo slice trong ngôn ngữ Go:
- Lấy một phần của một mảng hoặc slice bằng cách đăng ký
- Khởi tạo slice mới bằng chữ
- Sử dụng từ khóa
make
để tạo slice
Sử dụng chỉ số
Sử dụng các chỉ số con để tạo slice là phương thức nguyên thủy nhất và gần gũi nhất với hợp ngữ, là phương thức cấp thấp nhất trong tất cả các phương thức, trình biên dịch sẽ chuyển đổi các câu lệnh như arr[0:3]
hoặc slice[0:3]
thành các toán tử OpSliceMake
, chúng ta có thể kiểm chứng điều đó thông qua đoạn code sau:
Có thể thu được một loạt mã trung gian SSA bằng cách biên dịch mã trên thông qua biến GOSSAFUNC
và mã tương ứng với câu lệnh slice := arr[0:1]
trong giai đoạn “decompose builtin” như sau:
Thao tác SliceMake
sẽ chấp nhận bốn tham số để tạo một slice mới, kiểu phần tử, con trỏ mảng, kích thước và dung lượng của slice, đây cũng là một số trường của slice mà chúng tôi đã đề cập trong phần cấu trúc dữ liệu. Cần lưu ý rằng việc khởi tạo slice với chỉ số con sẽ không sao chép dữ liệu trong mảng hoặc slice đầu, nó sẽ chỉ tạo cấu trúc slice trỏ đến mảng ban đầu. Do đó, việc sửa đổi dữ liệu của slice mới cũng sẽ sửa đổi slice ban đầu.
Literal
Khi chúng ta sử dụng literal []int{1, 2, 3}
để tạo một slice mới, hàm cmd/compile/internal/gc.slicelit
sẽ mở rộng nó trong quá trình biên dịch thành đoạn mã sau:
- Suy luận về kích thước của mảng bên dưới dựa trên số lượng phần tử trong slice và tạo một mảng;
- Lưu trữ các phần tử chữ này vào một mảng được khởi tạo;
- Tạo một con trỏ mảng cũng trỏ đến kiểu
[3]int
; - Gán mảng
vstat
trong vùng lưu trữ tĩnh tới địa chỉ chứa con trỏvauto
; - Có được một lát cắt sử dụng vauto ở lớp dưới cùng thông qua thao tác
[:]
;
Ở bước 5 là phương pháp tạo slice sử dụng chỉ số [:]
, từ đây ta cũng có thể thấy thao tác [:]
là phương pháp tạo slice tầng dưới.
Từ khóa make
Nếu bạn tạo các slice theo literal, thì hầu hết công việc sẽ được thực hiện tại thời điểm biên dịch. Nhưng khi chúng ta sử dụng từ khóa make
để tạo một slice, rất nhiều công việc yêu cầu sự tham gia của runtime; lời gọi phải chuyển kích thước slice và dung lượng tùy chọn cho hàm make
và hàm cmd/compile/internal/gc.typecheck1
sẽ kiểm tra các tham số đầu vào:
Hàm trên sẽ không chỉ kiểm tra xem len
có được truyền vào hay không mà còn đảm bảo rằng dung lượng cap
được truyền vào phải lớn hơn hoặc bằng len
. Ngoài các tham số xác minh, hàm hiện tại sẽ chuyển đổi nút OMAKE
thành OMAKESLICE
, và hàm cmd/compile/internal/gc.walkexpr
sẽ chuyển đổi nút kiểu OMAKESLICE
theo hai điều kiện sau:
- Kích thước và dung lượng của slice có đủ nhỏ hay không;
- Cho dù slice đã thoát và cuối cùng được khởi tạo trên heap
Khi slice thoát ra hoặc rất lớn, trình thực thi runtime.makeslice
khởi tạo slice trên heap, nếu slice hiện tại không thoát ra và slice rất nhỏ, make([]int, 3, 4)
sẽ được chuyển đổi trực tiếp thành mã sau:
Đoạn mã trên sẽ khởi tạo mảng và lấy lát cắt tương ứng của mảng thông qua chỉ số [:3]
. Hai phần thao tác này sẽ được hoàn thành trong giai đoạn biên dịch. Trình biên dịch sẽ tạo mảng trên stack hoặc trong vùng lưu trữ tĩnh và chuyển đổi [:3]
thành thao tác OpSliceMake
như đã đề cập ở trên.
Sau khi phân tích các nhánh chủ yếu được xử lý bởi trình biên dịch, chúng ta quay lại hàm runtime runtime.makeslice
được sử dụng để tạo các slice, việc triển khai hàm này rất đơn giản:
Công việc chính của hàm trên là tính dung lượng bộ nhớ bị chiếm bởi slice và áp dụng cho một phần bộ nhớ liên tục trên heap, nó sử dụng phương pháp sau để tính dung lượng bộ nhớ bị chiếm:
Dung lượng bộ nhớ = kích thước phần tử trong slice × dung lượng slice
Mặc dù có thể phát hiện nhiều lỗi trong quá trình biên dịch, nhưng nếu các lỗi sau xảy ra trong quá trình tạo slice, nó sẽ trực tiếp gây ra lỗi runtime và sự cố:
- Kích thước của không gian bộ nhớ đã bị tràn;
- Bộ nhớ được yêu cầu lớn hơn bộ nhớ được cấp phát tối đa;
- Độ dài được truyền vào nhỏ hơn 0 hoặc độ dài lớn hơn dung lượng;
Hàm runtime.mallocgc
được sử dụng để cấp phát cho bộ nhớ được gọi ở cuối runtime.makeslice
, việc triển khai hàm này vẫn tương đối phức tạp, nếu gặp một đối tượng tương đối nhỏ, nó sẽ được khởi tạo trực tiếp trong cấu trúc P trong bộ lập lịch ngôn ngữ Go và đối tượng lớn hơn hơn 32KB sẽ nằm trên heap. Chúng tôi sẽ giới thiệu chi tiết về bộ cấp phát bộ nhớ của ngôn ngữ Go trong các chương sau nên chúng tôi sẽ không phân tích ở đây.
Trong phiên bản trước của ngôn ngữ Go, con trỏ mảng, độ dài và dung lượng sẽ được kết hợp thành một struct runtime.slice
, nhưng sau khi gửi từ cmd/compile: di chuyển cấu trúc slice tới lời gọi makelice , công việc reflect.SliceHeader
được chuyển giao cho lời gọi của runtime.makeslice
, hàm này sẽ chỉ trả về một con trỏ tới mảng bên dưới và người gọi sẽ xây dựng cấu trúc lát cắt trong quá trình biên dịch:
Thao tác OSLICEHEADER
này tạo ra cấu trúc reflect.SliceHeader
mà chúng tôi đã giới thiệu ở trên, chứa con trỏ mảng, độ dài và dung lượng của slice, là biểu diễn runtime của slice:
Chính vì hầu hết các thao tác trên kiểu slice không cần thao tác trực tiếp với cấu trúc runtime.slice
, nên việc giới thiệu reflect.SliceHeader
có thể giảm một lượng nhỏ chi phí hoạt động trong quá trình khởi tạo slice. Sự thay đổi này không chỉ có thể giảm kích thước của gói ngôn ngữ Go xuống ~ 0,2% nhưng cũng nó cũng tiết kiệm 92 lệnh gọi runtime.panicIndex
cuộc gọi , chiếm ~3,5% mã nhị phân ngôn ngữ Go.
3. Truy cập các phần tử
Sử dụng len
và cap
để lấy độ dài hoặc dung lượng là thao tác phổ biến nhất của các slice. Trình biên dịch coi chúng là hai thao tác đặc biệt, cụ thể là OLEN
và OCAP
, và hàm cmd/compile/internal/gc.state.expr
sẽ chuyển đổi chúng thành OpSliceLen
và OpSliceCap
trong giai đoạn tạo SSA (Golang IR SSA):
Việc truy cập các trường trong một slice có thể kích hoạt tối ưu hóa trong giai đoạn “decompose builtin”, len(slice)
hoặc cap(slice)
sẽ được thay thế trực tiếp bằng độ dài hoặc dung lượng của lát cắt trong một số trường hợp và không cần lấy trong runtime :
Ngoài việc lấy độ dài và dung lượng của một slice, các thao tác OINDEX
cũng được chuyển đổi thành truy cập trực tiếp đến các địa chỉ trong quá trình tạo họa tiết:
Các thao tác của slice về cơ bản được hoàn thành trong quá trình biên dịch. Ngoài việc truy xuất độ dài, dung lượng hay các phần tử của slice, quá trình truyền tải chứa từ khóa range
cũng sẽ được chuyển đổi thành một dạng vòng lặp đơn giản hơn trong quá trình biên dịch. Chúng tôi sẽ giới thiệu quy trình sử dụng phạm vi để duyệt các lát trong các chương sau.
4. Bổ sung và mở rộng
Sử dụng từ khóa append
để thêm các phần tử vào slice cũng là một thao tác slice phổ biến, phương thức cmd/compile/internal/gc.state.append
ở giai đoạn tạo mã trung gian sẽ ghi đè lên biến ban đầu tùy theo giá trị trả về. Chọn nhập hai quy trình, nếu slice mới được append
trả về không cần được gán trở lại biến ban đầu, nó sẽ bước vào luồng xử lý sau:
Đầu tiên chúng ta sẽ giải cấu trúc slice để lấy con trỏ mảng, kích thước và dung lượng của nó, nếu kích thước của slice lớn hơn dung lượng sau khi thêm các phần tử, thì chúng ta sẽ gọi runtime.growslice
để mở rộng slice và lần lượt thêm các phần tử mới.
Nếu câu lệnh slice = append(slice, 1, 2, 3)
, thì slice sau append
sẽ bao phủ slice ban đầu và phương thức cmd/compile/internal/gc.state.append
sẽ sử dụng một cách khác để mở rộng:
Logic của việc có ghi đè lên biến ban đầu hay không thực sự giống nhau, sự khác biệt lớn nhất là slice mới thu được có được gán lại cho biến ban đầu hay không. Nếu chọn ghi đè lên biến gốc, chúng ta không cần lo copy slice ảnh hưởng đến hiệu suất, vì trình biên dịch của ngôn ngữ Go đã tối ưu hóa tình trạng phổ biến này rồi.
Cho đến nay chúng ta đã thấy cách ngôn ngữ Go có thể thêm các phần tử vào các slice khi dung lượng slice đủ, nhưng chúng ta vẫn cần nghiên cứu quy trình xử lý khi dung lượng slice không đủ. Khi dung lượng của slice không đủ chúng ta sẽ gọi hàm runtime.growslice
để mở rộng slice. Mở rộng dung lượng là quá trình cấp phát không gian bộ nhớ mới cho slice và sao chép các phần tử trong slice ban đầu. Đầu tiên hãy xem dung lượng của slice như thế nào slice mới được xác định:
Trước khi phân bổ không gian bộ nhớ, bạn cần xác định dung lượng slice mới và chọn các chiến lược khác nhau để mở rộng dung lượng theo dung lượng hiện tại của slice khi chạy:
- Nếu dung lượng dự kiến lớn hơn gấp đôi dung lượng hiện tại thì sẽ sử dụng hết công suất dự kiến;
- Nếu độ dài của slice hiện tại nhỏ hơn 1024, dung lượng sẽ tăng gấp đôi;
- Nếu độ dài của slice hiện tại lớn hơn 1024, dung lượng sẽ tăng 25% mỗi lần cho đến khi dung lượng mới lớn hơn dung lượng dự kiến;
Đoạn mã trên sẽ chỉ xác định dung lượng gần đúng của slice và bộ nhớ cần được căn chỉnh theo kích thước của các phần tử trong slice. Khi kích thước byte của các phần tử trong mảng là bội số của 1, 8, hoặc 2, trình thực thi sẽ sử dụng mã sau đây để căn chỉnh bộ nhớ:
Hàm runtime.roundupsize
sẽ làm tròn bộ nhớ được áp dụng. Mảngruntime.class_to_size
sẽ được sử dụng khi làm tròn. Việc sử dụng các số nguyên trong mảng này có thể cải thiện hiệu quả cấp phát bộ nhớ và giảm phân mảnh. Chúng tôi sẽ giới thiệu chi tiết chức năng của mảng này trong phần cấp phát bộ nhớ:
Theo mặc định, chúng tôi nhân dung lượng mục tiêu với kích thước phần tử để có mức sử dụng bộ nhớ. Nếu xảy ra tràn bộ nhớ khi tính toán dung lượng mới hoặc bộ nhớ được yêu cầu vượt quá giới hạn trên, nó sẽ trực tiếp gây lỗi và thoát khỏi chương trình. Tuy nhiên, để giảm chi phí hiểu biết, mã liên quan được bỏ qua ở đây.
Nếu phần tử trong slice không phải là kiểu con trỏ, thì runtime.memclrNoHeapPointers
sẽ được gọi để xóa vị trí vượt quá độ dài hiện tại của slice.và cuối cùng dùng runtime.memmove
để sao chép nội dung của bộ nhớ mảng ban đầu sang bộ nhớ mới được cấp phát. Cả hai phương pháp này đều được thực hiện bằng cách sử dụng các assembly instruction trên máy mục tiêu, vì vậy chúng tôi sẽ không giới thiệu chúng ở đây.
Cuối cùng, hàm runtime.growslice
sẽ trả về một slice mới, chứa con trỏ mảng, kích thước và dung lượng mới, và bộ ba được trả về cuối cùng sẽ ghi đè lên slice ban đầu.
Tóm tắt ngắn gọn quá trình mở rộng. Khi chúng ta thực thi đoạn mã trên, hàm runtime.growslice
sẽ được gọi để mở rộng arr
và dung lượng mới dự kiến là 5. Tại thời điểm này, kích thước bộ nhớ được phân bổ dự kiến là 40 byte. Tuy nhiên, do kích thước của các phần tử trong slice bằng sys.PtrSize
, vì vậy trình thực thi sẽ gọi runtime.roundupsize
làm tròn kích thước bộ nhớ lên 48 byte, vì vậy dung lượng của slice mới là 48 / 8 = 6
.
5. Sao chép slice
Mặc dù việc sao chép các slice không phải là một hoạt động phổ biến, nhưng chúng ta cần phải tìm hiểu nguyên tắc thực hiện slice. Khi chúng ta sử dụng copy(a, b)
để sao chép các slice, trong quá trình biên dịch, hàm cmd/compile/internal/gc.copyany
sẽ xử lý theo hai trường hợp, nếu hiện tại, không gọi copy
trong runtime, copy(a, b)
sẽ được chuyển đổi trực tiếp thành mã sau:
trong đoạn mã trên runtime.memmove
sẽ chịu trách nhiệm sao chép bộ nhớ. Và nếu việc sao chép xảy ra trong runtime, chẳng hạn: go copy(a, b)
, trình biên dịch sẽ thay thế copy
bằng lệnh gọi runtime.slicecopy
trong runtime, việc thực hiện chức năng này rất đơn giản:
Cho dù đó là sao chép trong quá trình biên dịch hay sao chép trong runtime, cả hai phương thức sao chép sẽ dùng runtime.memmove
để sao chép nội dung của toàn bộ khối bộ nhớ vào vùng bộ nhớ đích:
So với việc sao chép các yếu tố một cách tuần tự, runtime.memmove
có thể mang lại hiệu suất tốt hơn. Cần lưu ý rằng việc sao chép toàn bộ khối bộ nhớ vẫn sẽ chiếm nhiều tài nguyên và bạn phải chú ý đến tác động đến hiệu suất khi thực hiện thao tác sao chép trên các slice lớn.
6. Tóm tắt
Nhiều chức năng của các slice được trình thực thi thực hiện. Cho dù đó là khởi tạo các slice, hoặc nối thêm hoặc mở rộng các slice, thì đều cần có sự hỗ trợ của runtime. Cần lưu ý rằng việc sao chép bộ nhớ quy mô lớn có thể xảy ra khi mở rộng hoặc sao chép các slice lớn. Đảm bảo giảm bớt các thao tác tương tự để tránh ảnh hưởng đến hiệu suất của chương trình.