Рубрики
Uncategorized

Глубокое понимание Карты Go: Назначение и миграция расширения

Автор оригинала: David Wong.

контур

В предыдущей главе в разделе структура данных объяснялось большое количество основных полей, вы можете быть смущены необходимостью &(!… Это очень важно. $! Почему? Далее давайте кратко рассмотрим основные концепции. Начните обсуждать ключевое содержание сегодняшней статьи. Я верю, что таким образом вы сможете лучше прочитать эту статью.

Исходный адрес: Глубокое понимание Карты Go: Назначение и миграция расширения

хэш-функция

Хэш-функция, также известная как алгоритм хэширования, хэш-функция. Основная функция заключается в регенерации данных в соответствии с определенными правилами по определенному алгоритму. Хэш-значение

В хэш-таблицах сгенерированные хэш-значения часто используются для определения того, к какому сегменту сопоставляются их ключи. Хорошая хэш-функция должна максимально избегать конфликтов хэшей, чтобы обеспечить временную сложность работы с хэш-таблицей (но конфликты хэшей в настоящее время неизбежны). Нам нужно “решить” эту проблему.

Способ адресации цепочки

В операции хэширования решение “конфликта хэшей” является основным действием. В Go map для решения конфликтов хэшей используется “метод цепного адреса”, также известный как “метод молнии”. Основным методом является структура данных массива + связанный список. Память узла переполнения применяется динамически, поэтому она относительно более гибкая. Каждый элемент представляет собой связанный список. На следующем рисунке:

Ведро/Переливное ведро

type hmap struct {
    ...
    buckets    unsafe.Pointer
    ...
    extra *mapextra
}

type mapextra struct {
    overflow    *[]*bmap
    oldoverflow *[]*bmap
    nextOverflow *bmap
}

В предыдущем разделе мы представили концепции ведра и переполненного ведра в Go map, в которых могут храниться только восемь элементов пары ключ-значение. Переполненные ведра будут использоваться для хранения или расширения, если используется более 8 ведер.

Вы можете задаться вопросом, что произойдет, если подсказка больше 8? Ответ очевиден: проблемы с производительностью, изменения временной сложности (т. Е. Проблемы с эффективностью выполнения)

Предисловие

После краткого обзора мы обсудим три других основных поведения Go map: назначение, расширение и миграция. Давайте официально начнем наш семинар-тур.

назначение

m := make(map[int32]string)
m[0] = "EDDYCJY"

Прототип функции

При назначении карты все еще существуют различные преобразования для 32/64-битных, строковых и указательных типов. Прототип общей функции выглядит следующим образом:

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess1_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
func mapaccess2_fast32(t *maptype, h *hmap, key uint32) (unsafe.Pointer, bool)
func mapassign_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
func mapassign_fast32ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer

func mapaccess1_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
func mapaccess2_fast64(t *maptype, h *hmap, key uint64) (unsafe.Pointer, bool)
func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
func mapassign_fast64ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer
func mapaccess2_faststr(t *maptype, h *hmap, ky string) (unsafe.Pointer, bool)
func mapassign_faststr(t *maptype, h *hmap, s string) unsafe.Pointer
...

Затем мы разделим его на несколько частей, чтобы увидеть, что делает базовый слой при присвоении значений.

Исходный код

Этап 1: Проверка и инициализация

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    if h == nil {
        panic(plainError("assignment to entry in nil map"))
    }
    ...
    if h.flags&hashWriting != 0 {
        throw("concurrent map writes")
    }
    alg := t.key.alg
    hash := alg.hash(key, uintptr(h.hash0))

    h.flags |= hashWriting

    if h.buckets == nil {
        h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
    }
    ...    
}
  • Определите, была ли карта инициализирована (ноль)
  • Определите, выполняется ли чтение и запись карты одновременно, и если да, создайте исключение
  • Значение хэша вычисляется путем вызова различных методов хэширования в соответствии с различными типами ключей
  • Установите флаг flags, чтобы указать, что гороутина записывает данные. потому что alg.хэш Возможно паника Причина ненормальная
  • Определите, равны ли ведра нулю, и если да, вызовите newobject Выделите в соответствии с текущим размером ячейки (например, как упоминалось в предыдущем разделе) Метод makemap_small , при инициализации начальных ячеек нет, поэтому он выделяет ячейки при первом назначении.

Этап 2: Поиск вставляемых битов и обновление существующих значений

...
again:
    bucket := hash & bucketMask(h.B)
    if h.growing() {
        growWork(t, h, bucket)
    }
    b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
    top := tophash(hash)

    var inserti *uint8
    var insertk unsafe.Pointer
    var val unsafe.Pointer
    for {
        for i := uintptr(0); i < bucketCnt; i++ {
            if b.tophash[i] != top {
                if b.tophash[i] == empty && inserti == nil {
                    inserti = &b.tophash[i]
                    insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
                    val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
                }
                continue
            }
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.indirectkey {
                k = *((*unsafe.Pointer)(k))
            }
            if !alg.equal(key, k) {
                continue
            }
            // already have a mapping for key. Update it.
            if t.needkeyupdate {
                typedmemmove(t.key, k, key)
            }
            val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
            goto done
        }
        ovf := b.overflow(t)
        if ovf == nil {
            break
        }
        b = ovf
    }

    if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        hashGrow(t, h)
        goto again // Growing the table invalidates everything, so try again
    }
    ...
  • Адрес памяти блока вычисляется в соответствии с восемью младшими битами, и оценивается, расширяется ли он. Если он расширяется, он сначала будет перенесен, а затем обработан.
  • Адрес указателя КАРТЫ корзины вычисляется, и для поиска ключа используется хэш ключа длиной восемь битов.
  • Итеративные ведра для каждого ведра (всего 8), сравнение ведро.верхний хэш Соответствует ли он топу?
  • Если нет, то судите, пусто ли место. Если слот пуст (есть два случая, первый-пустой слот). Вставки нет . Второй Удаляется после вставки Идентифицируется вставляемое верхнее расположение хэша. Обратите внимание, что это первое место для вставки данных.
  • Если ключ не соответствует текущему k, пропустите. Но если совпадение (то есть оно уже существует), оно обновляется. Наконец, выпрыгните и верните значение адреса памяти
  • Определите, завершена ли итерация, и если да, завершите циклы итераций и обновите текущую позицию блока
  • Если выполняются три условия: максимальный триггер Коэффициент нагрузки Чрезмерное переполнение ведер переполнение ведер Расширение не выполняется. Расширение будет выполнено (для обеспечения последующих действий)

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

Фаза 3: Подайте заявку на новые биты вставки и вставьте новые значения

    ...
    if inserti == nil {
        newb := h.newoverflow(t, b)
        inserti = &newb.tophash[0]
        insertk = add(unsafe.Pointer(newb), dataOffset)
        val = add(insertk, bucketCnt*uintptr(t.keysize))
    }

    if t.indirectkey {
        kmem := newobject(t.key)
        *(*unsafe.Pointer)(insertk) = kmem
        insertk = kmem
    }
    if t.indirectvalue {
        vmem := newobject(t.elem)
        *(*unsafe.Pointer)(val) = vmem
    }
    typedmemmove(t.key, insertk, key)
    *inserti = top
    h.count++

done:
    ...
    return val

После предыдущей итерации для поиска действия, если вставляемая позиция не найдена, это означает, что все текущие ячейки заполнены, и для действия вставки будет переназначен новый блок переполнения. Наконец, в новом месте вставки, примененном на предыдущем шаге, сохраняется пара ключ-значение и возвращается адрес памяти значения.

Этап 4: Написание

Но вот опять какая-то путаница? Последняя причина – вернуть адрес памяти. Это связано с тем, что последнее скрытое действие записи (копирование значения в указанную область памяти) выполняется путем объединения базовой сборки, и только подавляющее большинство действий выполняется во время выполнения.

func main() {
    m := make(map[int32]int32)
    m[0] = 6666666
}

Соответствующая сборочная деталь:

...
0x0099 00153 (test.go:6)    CALL    runtime.mapassign_fast32(SB)
0x009e 00158 (test.go:6)    PCDATA    $2, $2
0x009e 00158 (test.go:6)    MOVQ    24(SP), AX
0x00a3 00163 (test.go:6)    PCDATA    $2, $0
0x00a3 00163 (test.go:6)    MOVL    $6666666, (AX)

Есть несколько частей, в основном призывающих. назначение карты Функция и адрес памяти, в котором хранится значение, а затем значение 6666666 сохраняется в адресе памяти. Кроме того, мы видим, что PCDATA Инструкции, в основном содержащие некоторую информацию о сборке мусора, генерируются компилятором

Резюме

Проанализировав предыдущие этапы, мы можем выделить некоторые ключевые моменты. Например:

  • Различные типы соответствующих хэш-функций различны
  • Высокие восемь бит для определения местоположения ведер
  • Младшие восемь бит используются для поиска ключа, и полное сравнение производится после быстрых проб и ошибок.
  • Обход ведер/переполнения ведер
  • Обработка вставляемых битов
  • Взаимодействие между заключительным действием записи и базовой сборкой

Расширение производственных мощностей

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

Когда следует расширять пропускную способность

if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
    hashGrow(t, h)
    goto again
}

При определенных условиях и в настоящее время нет действия по расширению (судить) карта.старые ведра В качестве эталона. Хэш – таблица запускает поведение расширения при действии назначения и удаления, при условии, что:

  • триггер коэффициент нагрузки Максимальный коэффициент нагрузки достиг текущего предела
  • Переполненное ведро переполненные ведра слишком много

Когда это будет затронуто?

Итак, при каких обстоятельствах будут затронуты эти две “ценности”? Следующим образом:

  1. Коэффициент загрузки коэффициент загрузки Цель состоит в том, чтобы оценить текущую временную сложность хэш-таблицы, которая связана с логарифмом значений ключей и количеством сегментов, содержащихся в настоящее время в хэш-таблице. Чем больше коэффициент загрузки, тем выше использование пространства, но тем выше вероятность конфликта хэшей. Чем меньше коэффициент загрузки, тем меньше использование пространства и тем меньше вероятность конфликта хэшей.
  2. Переполненное ведро переполненные ведра Оценка количества ведер связана с общим количеством ведер и общим количеством переполненных ведер.

Соотношение факторов

3.00 2.13 4.0 4.0 20.77
3.25 4.05 4.5 4.5 17.30
3.50 6.85 5.0 5.0 14.77
3.75 10.55 5.5 5.5 12.94
4.00 15.27 6.0 6.0 11.67
4.25 20.90 6.5 6.5 10.79
4.50 27.14 7.0 7.0 10.15
  • Коэффициент нагрузки: Коэффициент нагрузки
  • % переполнения: Скорость переполнения, с ведром переполнения ведра переполнения Процент баррелей
  • Байты/запись: Накладные расходы в байтах на значение ключа
  • Проверка попадания: среднее количество записей, которые необходимо получить при поиске существующего ключа.
  • Пропущенный зонд: среднее количество элементов, которые необходимо извлечь при поиске несуществующих ключей.

Этот набор данных может показать, как различные факторы нагрузки будут влиять на действия хэш-таблиц. В предыдущем разделе мы упоминали, что коэффициент нагрузки по умолчанию равен 6,5 (число коэффициентов нагрузки/значение коэффициента нагрузки). После тестирования можно увидеть, что это разумный фактор. На сроки расширения емкости хэш-таблицы можно повлиять лучше.

Анализ исходного кода

func hashGrow(t *maptype, h *hmap) {
    bigger := uint8(1)
    if !overLoadFactor(h.count+1, h.B) {
        bigger = 0
        h.flags |= sameSizeGrow
    }
    oldbuckets := h.buckets
    newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
    ...
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
    h.nevacuate = 0
    h.noverflow = 0

    if h.extra != nil && h.extra.overflow != nil {
        if h.extra.oldoverflow != nil {
            throw("oldoverflow is not nil")
        }
        h.extra.oldoverflow = h.extra.overflow
        h.extra.overflow = nil
    }
    if nextOverflow != nil {
        if h.extra == nil {
            h.extra = new(mapextra)
        }
        h.extra.nextOverflow = nextOverflow
    }

    // the actual copying of the hash table data is done incrementally
    // by growWork() and evacuate().
}

Этап 1. Определение правил расширения производственных мощностей

В последнем разделе есть две основы для расширения возможностей. вырос Разделение было произведено в самом начале. Следующим образом:

if !overLoadFactor(h.count+1, h.B) {
    bigger = 0
    h.flags |= sameSizeGrow
}

Если коэффициент нагрузки коэффициент нагрузки Не превышает текущего предела, то есть он относится к переполненному ведру. переполнение ведер Слишком много случаев. Таким образом, это правило расширения будет расти на тот же размер То есть Расширение без изменения размера . Что, если имеет место первое?

bigger := uint8(1)
...
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

В сочетании с анализом кода можно сделать вывод, что если коэффициент загрузки коэффициент загрузки Достижение текущих границ приведет к динамическому расширению Удвоит текущий размер По мере увеличения его новой емкости

Фаза 2: Инициализация, обмен старыми и новыми бочками/переполненными бочками

В основном для расширения соответствующих данных предварительной обработки Для полей, связанных с хранением, таких как ведра/и ведра, переполнение/старый поток

...
oldbuckets := h.buckets
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

flags := h.flags &^ (iterator | oldIterator)
if h.flags&iterator != 0 {
    flags |= oldIterator
}

h.B += bigger
...
h.noverflow = 0

if h.extra != nil && h.extra.overflow != nil {
    ...
    h.extra.oldoverflow = h.extra.overflow
    h.extra.overflow = nil
}
if nextOverflow != nil {
    ...
    h.extra.nextOverflow = nextOverflow
}

Обратите внимание на код: новые пакеты, следующее переполнение(t, h.B+больше, ноль) . Первая реакция-немедленно запросить и инициализировать память при расширении емкости? Предполагая, что требуется много места для выделения памяти, это требует больших затрат производительности.

Однако нет, внутреннее распределение будет выполнено только сначала, и при использовании произойдет реальная деинициализация.

Фаза 3: Расширение

В исходном коде обнаруживается, что третья фаза потока явно не отображается. Это происходит потому, что поток контролируется дном. Но, проанализировав код и комментарии, вы можете увидеть, что третья фаза включает в себя расширенную работу и эвакуацию Метод. Следующим образом:

func growWork(t *maptype, h *hmap, bucket uintptr) {
    evacuate(t, h, bucket&h.oldbucketmask())

    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

В этом методе их в основном два. эвакуация Вызов функции. В чем разница между ними с точки зрения звонков? Следующим образом:

  • Эвакуация (t, h, ведро и H. маска старого ведра ()): перенос элементов из старого ведра в новое расширенное ведро.
  • Эвакуация (t, h, H. эвакуация): Если в настоящее время осуществляется расширение, затем повторите миграцию

Кроме того, при выполнении действия расширения можно обнаружить, что устройство представляет собой ведро/загрузочное ведро, а не традиционные ведра/старые ведра. В сочетании с анализом кода мы видим, что в Go map Принято постепенное расширение емкости, а не один шаг на месте.

Почему происходит постепенное расширение мощностей?

Если мощности будут расширены в полном объеме, то возникнет проблема. Предполагая, что текущая емкость карты относительно велика, прямое расширение полной емкости приведет к увеличению большого объема времени и памяти, в результате чего система Катон, наиболее интуитивно понятная производительность, будет медленной. Очевидно, что вы не можете этого сделать.

Постепенное расширение может решить эту проблему. Он распределяет общее единовременное действие по каждой операции с картой. Таким образом, при проектировании ведер/старых ведер он постепенно завершается и будет опорожнен после завершения расширения.

Резюме

Благодаря анализу первых трех этапов мы можем узнать общий процесс расширения. Давайте подведем итоги поэтапно. Главным образом следующим образом:

  • В зависимости от причин расширения (коэффициент перегрузки/слишком много переполненных ведер) существует два типа направлений правил емкости, которые являются равным расширением (без изменения исходного размера) или двойным расширением.
  • Новые сегменты/новое переполнение предварительно выделены и не будут инициализированы до тех пор, пока они фактически не будут использованы
  • После расширения (предварительного распределения) миграция не произойдет немедленно. Скорее, возьмем Постепенное расширение мощностей Таким образом, когда у вас будет доступ к определенному ведру, вы будете постепенно мигрировать (переносить старое ведро в ведро).

В это время пришло в голову, что, поскольку миграция происходит постепенно. Так что же, если нам придется расширять наши возможности по пути?

again:
    bucket := hash & bucketMask(h.B)
    ...
    if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        hashGrow(t, h)
        goto again 
    }

Обратите внимание, что здесь еще раз Операторы в сочетании с контекстом могут непрерывно переноситься, если они расширяются. Следующее расширение не начнется до тех пор, пока миграция не будет завершена.

перемещение

В полном замкнутом цикле расширения он содержит действие миграции, также известное как “перемещение”. Поэтому мы продолжаем углубленно изучать его. эвакуация Функция. Далее, давайте откроем дверь для миграции всего мира. Следующим образом:

type evacDst struct {
    b *bmap          
    i int            
    k unsafe.Pointer 
    v unsafe.Pointer 
}

evacDst – это базовая структура данных при миграции, которая содержит следующие поля:

  • B: Текущее целевое ведро
  • I: Количество пар ключей, хранящихся в текущем целевом сегменте
  • K: Адрес памяти, указывающий на текущий ключ
  • V: Адрес памяти, указывающий на текущее значение
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
    b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
    newbit := h.noldbuckets()
    if !evacuated(b) {
        var xy [2]evacDst
        x := &xy[0]
        x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
        x.k = add(unsafe.Pointer(x.b), dataOffset)
        x.v = add(x.k, bucketCnt*uintptr(t.keysize))

        if !h.sameSizeGrow() {
            y := &xy[1]
            y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
            y.k = add(unsafe.Pointer(y.b), dataOffset)
            y.v = add(y.k, bucketCnt*uintptr(t.keysize))
        }

        for ; b != nil; b = b.overflow(t) {
            ...
        }

        if h.flags&oldIterator == 0 && t.bucket.kind&kindNoPointers == 0 {
            b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
            ptr := add(b, dataOffset)
            n := uintptr(t.bucketsize) - dataOffset
            memclrHasPointers(ptr, n)
        }
    }

    if oldbucket == h.nevacuate {
        advanceEvacuationMark(h, t, newbit)
    }
}
  • Вычислите и получите адрес указателя КАРТЫ старого ведра
  • Рассчитайте количество баррелей до того, как карта вырастет
  • Оцените текущий статус миграции (перемещения), чтобы облегчить поток последующих операций. Если миграция не выполняется !эвакуировано(b) В соответствии с другими правилами расширения, когда правило равно расширению Одинаковое увеличение При использовании только одного Эвакуации Для маневрирования используются ведра. Для двойного расширения будут использоваться два evacDst Проведение операции шунтирования
  • Когда шунтирование будет завершено, данные, которые необходимо перенести, пройдут через typedmemmove Миграцию функций в указанный целевой сегмент
  • Если флаги в настоящее время не существуют, используйте старый итератор ведра, а ведро не является типом указателя. Отмените переполнение корзины ссылок и очистите значения ключей
  • В конце Отметка предварительной эвакуации Прогресс миграции будет достигнут в функции hmap.эвакуировать Кумулятивный подсчет и вызов bucketEvacuated Непрерывная миграция старых ведер. Пока вся миграция не будет завершена. Таким образом, это означает, что расширение завершено. Да. hmap.старые ведра и h.extra.старое переполнение Опорожнение

Вообще говоря, это делается для расчета местоположения требуемых данных. Затем, в соответствии с текущим статусом миграции и правилами расширения, выполняется миграция шунта данных. Очистка после завершения для содействия восстановлению GC

резюме

В этой главе мы в основном обсуждаем несколько ключевых действий Go map, а именно: назначение, расширение и миграция. Благодаря этому чтению мы можем лучше понять некоторые ключевые моменты, такие как:

  • Вызывает ли назначение расширение?
  • Каков коэффициент нагрузки? В чем проблема быть слишком высоким? Повлияют ли его изменения на операции с хэш-таблицами?
  • В чем проблема с большим количеством переполненных ведер?
  • Каковы критерии для расширения?
  • Каково правило расширения мощностей?
  • Каковы этапы расширения? Какие структуры данных задействованы?
  • Является ли расширение одноразовым расширением или постепенным расширением?
  • Что мы должны делать при расширении мощностей?
  • Каково движение миграции и перенаправления во время расширения мощностей?
  • Какую роль играет базовая сборка в расширении? Что ты сделал?
  • При поиске ведер/переполненных ведер, как вы “быстро”находите значения? Использование небольшого веса и высокой восьмерки?
  • Возможно ли, чтобы пустые слоты появлялись где угодно? Предполагая, что пустого слота нет, но есть новые значения для вставки, что будет делать нижняя часть?

Наконец, я надеюсь, что вы сможете лучше понять, как работает карта Go, прочитав эту статью.