Принцип работы типа slice в GO

04.04.2020 срез исходники аллокации


Принцип работы типа slice в GO

Как работать со срезами, объяснено в в блоге GO. Далее рассмотрим внутренее устройство среза.

Slice, слайс, срез — тип данных, построенный как обертка над массивом.

От массива срез отличает следующее:

  • Срез — всегда ссылка на массив;
  • Срез может менять свой размер и динамически аллоцировать память;

В исходниках GO срез представлен следующей структурой:

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

len (length, длина) — текущая длина среза, cap (capacity, вместимость) — длина внутреннего массива.

Оба эти параметра можно задать при вызове функции make:

s := make(
	[]int, 
	10, // len 
	10, // cap
)

Cap — ключевой параметр для аллокации памяти, влияет на производительность вставки в срез.

Рассмотрим поведение среза при увеличении его размера.

a := []int{1}
b := a[0:1]
b[0] = 0

fmt.Println(a)
// [0]

a[0] = 1

fmt.Println(b)
// [1]

Мы получили срез b из среза a. Далее видим, что изменение в одном срезе влияют на другой. Оба среза ссылаются на один и тот же массив.

Теперь дополним пример вставкой элементов в срез a:

a := []int{1}
b := a[0:1]

a = append(a, 2)
a[0] = 10

fmt.Println(a, b)
// [10 2] [0]

Теперь значения у элементов срезов разные. Теперь срезы ссылаются на разный массив.

Понять, почему так произошло, помогут исходники функции growslice.

В результате изменения cap среза всегда произойдет копирование данных массива:

memmove(p, old.array, lenmem)

Теперь рассмотрим, каким образом происходит фактическое увеличение cap:

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
	newcap = cap
} else {
	if old.len < 1024 {
		newcap = doublecap
	} else {
		// Check 0 < newcap to detect overflow
		// and prevent an infinite loop.
		for 0 < newcap && newcap < cap {
			newcap += newcap / 4
		}
		// Set newcap to the requested cap when
		// the newcap calculation overflowed.
		if newcap <= 0 {
			newcap = cap
		}
	}
}

При текущем размере среза менее 1024 элементов, размер памяти увеличивается вдвое (вне зависимости от запрашиваемой cap).

При размере среза > 1024 элементов, срез увеличивается на четверть текущего размера.

Приходим к выводу, что операция вставки в срез имеет серьезные последствия для памяти:

  • При увеличении cap, массив будет скопирован;
  • Размер аллоцированной памяти будет расти по своей внутренней логике, лишь отчасти связанной с требуемой cap;

Делаем вывод — чтобы избежать новых аллокаций и копирования, нужно еще при создании среза выставлять большой cap.

В примере ниже мы изменили лишь cap у среза a. Но теперь после вставки в него, копирование не произошло и оба среза даже после вставки ссылаются на один массив:

a := make([]int, 1, 2)
a[0] = 1

b := a[0:1]
b[0] = 0

fmt.Println(a, b)
// [0] [0]

a = append(a, 2)
a[0] = 10

fmt.Println(a, b)
// [10 2] [10] 

В блоге GO предлагают взять контроль над увеличением среза в свои руки - использовать свою функцию вместо append.
Но мы сможем лишь еще больше увеличить рост аллоцируемой памяти.

func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    if n > cap(slice) { // if necessary, reallocate
        // allocate double what's needed, for future growth.
        newSlice := make([]byte, (n+1)*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:n]
    copy(slice[m:n], data)
    return slice
}

Стоит также быть внимательным при получении новых срезов из срезов большего размера. Сборщик мусора не удаляет весь массив, на который ссылается какой-либо срез, даже если срез ссылается всего на 1 элемент.

Другие статьи