La esencia de la entrevista en lenguaje GO: ¿cuál es el principio de implementación subyacente del mapa?

¿Qué es el mapa?

Wikipedia define el mapa así:

En informática, una matriz asociativa, mapa, tabla de símbolos o diccionario es un tipo de datos abstracto compuesto por una colección de pares (clave, valor), de modo que cada clave posible aparece como máximo una vez en la colección.

Una breve explicación: en informática, se denomina matriz asociada, mapa, tabla de símbolos o diccionario, que es una <key, value>estructura de datos abstracta compuesta por un conjunto de pares, y la misma clave solo aparecerá una vez.

Hay dos puntos clave: el mapa se compone key-valuede pares; keysolo aparece una vez.

Las principales operaciones relacionadas con el mapa son:

  1. Agregar un par kv: agregar o insertar;
  2. Eliminar un par kv: eliminar o eliminar;
  3. Modificar v correspondiente a una determinada k - Reasignar;
  4. Consulta la v correspondiente a una determinada k - Búsqueda;

En pocas palabras, es el más básico 增删查改.

El diseño de mapas, también llamado "El problema del diccionario", su tarea es diseñar una estructura de datos para mantener los datos de una colección y realizar operaciones de adición, eliminación, consulta y modificación en la colección al mismo tiempo. Hay dos estructuras de datos principales: 哈希查找表(Hash table), 搜索树(Search tree).

La tabla de búsqueda hash utiliza una función hash para asignar claves a diferentes depósitos (es decir, diferentes índices de la matriz). De esta forma, la sobrecarga recae principalmente en el cálculo de la función hash y el tiempo de acceso constante a la matriz. En muchos escenarios, el rendimiento de las tablas de búsqueda hash es muy alto.

Las tablas de búsqueda hash generalmente tienen problemas de "colisión", lo que significa que diferentes claves se asignan al mismo depósito. Generalmente hay dos maneras de abordarlo: 链表法y 开放地址法. 链表法Implemente un depósito como una lista vinculada y las claves que se encuentren en el mismo depósito se insertarán en esta lista vinculada. 开放地址法Después de que ocurre la colisión, de acuerdo con ciertas reglas, se seleccionan "vacantes" en la parte posterior de la matriz para colocar nuevas claves.

El método del árbol de búsqueda generalmente utiliza árboles de búsqueda autoequilibrados, que incluyen: árboles AVL y árboles rojo-negro. Durante las entrevistas, muchas veces nos piden e incluso nos piden que escribamos a mano el código del árbol rojo-negro, muchas veces el entrevistador no puede escribirlo él mismo, lo cual es muy excesivo.

La peor eficiencia de búsqueda del método del árbol de búsqueda autoequilibrado es O (logN), mientras que la peor eficiencia de búsqueda de la tabla de búsqueda hash es O (N). Por supuesto, la eficiencia de búsqueda promedio de una tabla de búsqueda hash es O (1). Si la función hash está bien diseñada, el peor de los casos básicamente no ocurrirá. Otro punto es que al atravesar el árbol de búsqueda autoequilibrado, la secuencia de claves devuelta generalmente estará en orden ascendente, mientras que la tabla de búsqueda hash está desordenada.

Cómo implementar la capa inferior del mapa

Primero declare la versión de Go que uso:

go version go1.9.2 darwin/amd64

Como se mencionó anteriormente, hay varias formas de implementar mapas: el lenguaje Go usa tablas de búsqueda hash y listas vinculadas para resolver conflictos hash.

A continuación, exploraremos los principios básicos del mapa y echaremos un vistazo a su estructura interna.

modelo de memoria de mapas

En el código fuente, la estructura que representa el mapa es hmap, que es la "abreviatura" de hashmap:

// A header for a Go map.
type hmap struct {
    // 元素个数,调用 len(map) 时,直接返回此值
	count     int
	flags     uint8
	// buckets 的对数 log_2
	B         uint8
	// overflow 的 bucket 近似数
	noverflow uint16
	// 计算 key 的哈希的时候会传入哈希函数
	hash0     uint32
    // 指向 buckets 数组,大小为 2^B
    // 如果元素个数为0,就为 nil
	buckets    unsafe.Pointer
	// 等量扩容的时候,buckets 长度和 oldbuckets 相等
	// 双倍扩容的时候,buckets 长度会是 oldbuckets 的两倍
	oldbuckets unsafe.Pointer
	// 指示扩容进度,小于此地址的 buckets 迁移完成
	nevacuate  uintptr
	extra *mapextra // optional fields
}

Para explicarlo, Bes el logaritmo de la longitud de la matriz de depósitos, lo que significa que la longitud de la matriz de depósitos es 2 ^ B. La clave y el valor se almacenan en el depósito, que se analizará más adelante.

cubos es un puntero, que en última instancia apunta a una estructura:

type bmap struct {
	tophash [bucketCnt]uint8
}

Pero esta es solo la estructura de la superficie (src/runtime/hashmap.go), se agregará durante la compilación y se creará dinámicamente una nueva estructura:

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

bmapEs lo que a menudo llamamos un "depósito". Un depósito puede contener hasta 8 claves. La razón por la que estas claves caen en el mismo depósito es porque después de haber sido procesadas, los resultados del hash son del mismo tipo. En el depósito, los 8 bits superiores del valor hash calculado por la clave se utilizarán para determinar dónde cae la clave en el depósito (hay hasta 8 posiciones en un depósito).

Aquí hay una imagen general:

Insertar descripción de la imagen aquí

Cuando la clave y el valor del mapa no son punteros y el tamaño es inferior a 128 bytes, el bmap se marcará como que no contiene punteros, lo que puede evitar escanear todo el hmap durante gc. Sin embargo, vemos que bmap en realidad tiene un campo de desbordamiento, que es de tipo puntero, lo que destruye la idea de que bmap no contiene punteros. En este caso, el desbordamiento se moverá al campo extra.

type mapextra struct {
	// overflow[0] contains overflow buckets for hmap.buckets.
	// overflow[1] contains overflow buckets for hmap.oldbuckets.
	overflow [2]*[]*bmap

	// nextOverflow 包含空闲的 overflow bucket,这是预分配的 bucket
	nextOverflow *bmap
}

bmap es donde se almacena kv. Acerquémonos y echemos un vistazo más de cerca a la composición interna de bmap.

Insertar descripción de la imagen aquí

La imagen de arriba es el modelo de memoria del depósito, HOB Hashque se refiere al hash superior. Tenga en cuenta que la clave y el valor se combinan por separado, no de key/value/key/value/...esta forma. El código fuente indica que la ventaja de esto es que en algunos casos el campo de relleno se puede omitir para ahorrar espacio en la memoria.

Por ejemplo, existe un mapa de este tipo:

map[int64]int8

Si key/value/key/value/...se almacena en este modo, se agregarán 7 bytes adicionales de relleno después de cada par clave/valor, y todas las claves y valores se vincularán por separado. De esta forma, solo es necesario agregar relleno al final key/key/.../value/value/....

Cada depósito está diseñado para contener hasta 8 pares clave-valor. Si un noveno valor-clave cae en el depósito actual, es necesario construir otro depósito y conectarlo mediante punteros overflow.

Crear mapa

Sintácticamente, crear un mapa es sencillo:

ageMp := make(map[string]int)
// 指定 map 长度
ageMp := make(map[string]int, 8)

// ageMp 为 nil,不能向其添加元素,会直接panic
var ageMp map[string]int

Se puede ver en el lenguaje ensamblador que makemapen realidad se llama a la función subyacente, y su tarea principal es inicializar hmapvarios campos de la estructura, como calcular el tamaño de B, configurar la semilla hash hash0, etc.

func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
	// 省略各种条件检查...

	// 找到一个 B,使得 map 的装载因子在正常范围内
	B := uint8(0)
	for ; overLoadFactor(hint, B); B++ {
	}

	// 初始化 hash table
	// 如果 B 等于 0,那么 buckets 就会在赋值的时候再分配
	// 如果长度比较大,分配内存会花费长一点
	buckets := bucket
	var extra *mapextra
	if B != 0 {
		var nextOverflow *bmap
		buckets, nextOverflow = makeBucketArray(t, B)
		if nextOverflow != nil {
			extra = new(mapextra)
			extra.nextOverflow = nextOverflow
		}
	}

	// 初始化 hamp
	if h == nil {
		h = (*hmap)(newobject(t.hmap))
	}
	h.count = 0
	h.B = B
	h.extra = extra
	h.flags = 0
	h.hash0 = fastrand()
	h.buckets = buckets
	h.oldbuckets = nil
	h.nevacuate = 0
	h.noverflow = 0

	return h
}

[Extensión 1] ¿Cuál es la diferencia entre corte y mapa cuando se usan como parámetros de función?

Tenga en cuenta que el resultado devuelto por esta función es: *hmap, que es un puntero, mientras que makeslicela función de la que hablamos antes devuelve Sliceuna estructura:

func makeslice(et *_type, len, cap int) slice

Repasemos la definición de estructura de sector:

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 元素指针
    len   int // 长度 
    cap   int // 容量
}

La estructura contiene punteros de datos subyacentes.

La diferencia entre makemap y makelice trae consigo una diferencia: cuando map y slice se usan como parámetros de función, la operación de map dentro de los parámetros de función afectará al mapa en sí; pero no para el sector (como se mencionó en el artículo anterior sobre el sector).

La razón principal: uno es un puntero ( *hmap) y el otro es una estructura ( slice). Todos los parámetros de función en el lenguaje Go se pasan por valor. Dentro de la función, los parámetros se copiarán localmente. *hmapDespués de copiar el puntero, todavía apunta al mismo mapa, por lo que la operación del mapa dentro de la función afectará los parámetros reales. Una vez copiado el segmento, se convertirá en un nuevo segmento y las operaciones realizadas en él no afectarán los parámetros reales.

Función hash

Un punto clave del mapa es la elección de la función hash. Cuando se inicia el programa, detectará si la CPU admite aes. Si es así, use aes hash, de lo contrario use memhash. Esto se alginit()hace en la función, ubicada src/runtime/alg.goen la ruta:.

La función hash tiene tipos cifrados y no cifrados.
El tipo cifrado se utiliza generalmente para cifrar datos, resúmenes digitales, etc. Los representantes típicos son md5, sha1, sha256, aes256, el
tipo no cifrado se utiliza generalmente para búsquedas. En el escenario de aplicación del mapa, se utiliza la búsqueda.
Hay dos puntos principales a considerar al elegir una función hash: rendimiento y probabilidad de colisión.

Hablamos de ello antes, la estructura que representa el tipo:

type _type struct {
	size       uintptr
	ptrdata    uintptr // size of memory prefix holding all pointers
	hash       uint32
	tflag      tflag
	align      uint8
	fieldalign uint8
	kind       uint8
	alg        *typeAlg
	gcdata    *byte
	str       nameOff
	ptrToThis typeOff
}

El algcampo está relacionado con el hash, que es un puntero a la siguiente estructura:

// src/runtime/alg.go
type typeAlg struct {
	// (ptr to object, seed) -> hash
	hash func(unsafe.Pointer, uintptr) uintptr
	// (ptr to object A, ptr to object B) -> ==?
	equal func(unsafe.Pointer, unsafe.Pointer) bool
}

typeAlg contiene dos funciones: la función hash calcula el valor hash del tipo y la función igual calcula si los dos tipos son "iguales hash".

Para el tipo de cadena, sus funciones hash e igual son las siguientes:

func strhash(a unsafe.Pointer, h uintptr) uintptr {
	x := (*stringStruct)(a)
	return memhash(x.str, h, uintptr(x.len))
}

func strequal(p, q unsafe.Pointer) bool {
	return *(*string)(p) == *(*string)(q)
}

Según el tipo de clave, el campo alg de la estructura _type se configurará con las funciones hash e igual del tipo correspondiente.

proceso de posicionamiento clave

Después de realizar el hash de la clave, se obtiene el valor hash, con un total de 64 bits (las máquinas de 64 bits y las máquinas de 32 bits no se discutirán. Ahora la corriente principal son las máquinas de 64 bits). Al calcular en qué depósito caerá en, sólo se utilizan los últimos bits B. ¿Recuerdas que B mencionó antes? Si B = 5, entonces el número de depósitos, es decir, la longitud de la matriz de depósitos es 2 ^ 5 = 32.

Por ejemplo, después de calcular una clave mediante una función hash, el resultado hash es:

 10010111 | 000011110110110010001111001010100010010110010101010 │ 01010

Utilice los últimos 5 bits, es decir 01010, el valor es 10, que es el depósito número 10. Esta operación es en realidad una operación restante, pero la operación restante es demasiado costosa, por lo que la implementación del código utiliza operaciones de bits en su lugar.

Luego use los 8 bits superiores del valor hash para encontrar la ubicación de esta clave en el depósito. Esto busca una clave existente. Al principio no hay ninguna llave en el depósito, y la llave recién agregada encontrará la primera ranura vacía y la colocará.

El número de depósitos es el número de depósito. Cuando dos claves diferentes caen en el mismo depósito, se produce un conflicto de hash. El método de resolución de conflictos es utilizar el método de lista vinculada: en el depósito, busque el primer espacio vacío de adelante hacia atrás. De esta manera, cuando busque una determinada clave, primero busque el depósito correspondiente y luego recorra las claves en el depósito.

Aquí hay una referencia a una imagen en el blog de Github de Cao Da. La imagen original es una imagen ASCII, que está llena de sabor geek. Puede encontrar el blog de Cao Da en los materiales de referencia. Recomiendo a todos que echen un vistazo.

Insertar descripción de la imagen aquí

En la figura anterior, se supone que B = 5, por lo que el número total de depósitos es 2^5 = 32. Primero, calcule el hash de la clave que se va a encontrar. Utilice los 5 bits inferiores 00110para encontrar el cubo correspondiente número 6. Utilice los 8 bits superiores 10010111, que corresponden al decimal 151. Encuentre la clave con un valor tophash (hash HOB) de 151 en el cubo número 6 y encuéntrelo en la ranura 2, y todo el proceso de búsqueda habrá terminado.

Si no se encuentra en el depósito y el desbordamiento no está vacío, continuará buscando en el depósito de desbordamiento hasta que se encuentre o se hayan buscado todas las ranuras de claves, incluidos todos los depósitos de desbordamiento.

Echemos un vistazo al código fuente, ¡jaja! A través del lenguaje ensamblador podemos ver que la función subyacente para encontrar una determinada clave es mapacessuna serie de funciones, las funciones de las funciones son similares y las diferencias se discutirán en la siguiente sección. Aquí miramos directamente mapacess1la función:

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	// ……
	
	// 如果 h 什么都没有,返回零值
	if h == nil || h.count == 0 {
		return unsafe.Pointer(&zeroVal[0])
	}
	
	// 写和读冲突
	if h.flags&hashWriting != 0 {
		throw("concurrent map read and map write")
	}
	
	// 不同类型 key 使用的 hash 算法在编译期确定
	alg := t.key.alg
	
	// 计算哈希值,并且加入 hash0 引入随机性
	hash := alg.hash(key, uintptr(h.hash0))
	
	// 比如 B=5,那 m 就是31,二进制是全 1
	// 求 bucket num 时,将 hash 与 m 相与,
	// 达到 bucket num 由 hash 的低 8 位决定的效果
	m := uintptr(1)<<h.B - 1
	
	// b 就是 bucket 的地址
	b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
	
	// oldbuckets 不为 nil,说明发生了扩容
	if c := h.oldbuckets; c != nil {
	    // 如果不是同 size 扩容(看后面扩容的内容)
	    // 对应条件 1 的解决方案
		if !h.sameSizeGrow() {
			// 新 bucket 数量是老的 2 倍
			m >>= 1
		}
		
		// 求出 key 在老的 map 中的 bucket 位置
		oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
		
		// 如果 oldb 没有搬迁到新的 bucket
		// 那就在老的 bucket 中寻找
		if !evacuated(oldb) {
			b = oldb
		}
	}
	
	// 计算出高 8 位的 hash
	// 相当于右移 56 位,只取高8位
	top := uint8(hash >> (sys.PtrSize*8 - 8))
	
	// 增加一个 minTopHash
	if top < minTopHash {
		top += minTopHash
	}
	for {
	    // 遍历 bucket 的 8 个位置
		for i := uintptr(0); i < bucketCnt; i++ {
		    // tophash 不匹配,继续
			if b.tophash[i] != top {
				continue
			}
			// tophash 匹配,定位到 key 的位置
			k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
			// key 是指针
			if t.indirectkey {
			    // 解引用
				k = *((*unsafe.Pointer)(k))
			}
			// 如果 key 相等
			if alg.equal(key, k) {
			    // 定位到 value 的位置
				v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
				// value 解引用
				if t.indirectvalue {
					v = *((*unsafe.Pointer)(v))
				}
				return v
			}
		}
		
		// bucket 找完(还没找到),继续到 overflow bucket 里找
		b = b.overflow(t)
		// overflow bucket 也找完了,说明没有目标 key
		// 返回零值
		if b == nil {
			return unsafe.Pointer(&zeroVal[0])
		}
	}
}

La función devuelve el puntero de h [clave]. Si no existe tal clave en h, devolverá un valor cero del tipo de clave correspondiente y no devolverá nil.

El código es relativamente sencillo en general y no hay nada difícil de entender. Simplemente siga los comentarios anteriores para entender paso a paso.

Aquí, hablemos sobre el método para localizar la clave y el valor y cómo escribir el ciclo completo.

// key 定位公式
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))

// value 定位公式
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))

b es la dirección de bmap. Aquí, bmap sigue siendo una estructura definida en el código fuente. Solo contiene una matriz tophash. La estructura expandida por el compilador solo contiene campos de clave, valor y desbordamiento. dataOffset es el desplazamiento de la clave en relación con la dirección inicial de bmap:

dataOffset = unsafe.Offsetof(struct {
		b bmap
		v int64
	}{}.v)

Por lo tanto, la dirección inicial de la clave en el depósito no es segura. Puntero (b) + dataOffset. La dirección de la i-ésima clave abarcará el tamaño de i claves sobre esta base; y también sabemos que la dirección del valor es después de todas las claves, por lo que a la dirección del i-ésimo valor también se le deben sumar los desplazamientos de todas las llaves.. Después de comprender esto, las fórmulas de posicionamiento de clave y valor anteriores son fáciles de entender.

Hablemos del método de escritura de todo el gran bucle: la capa más externa es un bucle infinito.

b = b.overflow(t)

Recorrer todos los depósitos, lo que equivale a una lista vinculada de depósitos.

Cuando se ubica un depósito específico, el bucle interno atraviesa todas las celdas del depósito, o todas las ranuras, es decir, bucketCnt = 8 ranuras. Todo el proceso del ciclo:

Insertar descripción de la imagen aquí

Hablemos nuevamente de minTopHash: cuando el valor de tophash de una celda es menor que minTopHash, marca el estado de migración de esta celda. Debido a que este valor de estado se coloca en la matriz tophash, para distinguirlo del valor hash normal, se incrementará el valor hash calculado por la clave: minTopHash. Esto distingue entre valores hash superiores normales y valores hash que representan el estado.

Los siguientes estados caracterizan la situación del cubo:

// 空的 cell,也是初始时 bucket 的状态
empty          = 0
// 空的 cell,表示 cell 已经被迁移到新的 bucket
evacuatedEmpty = 1
// key,value 已经搬迁完毕,但是 key 都在新 bucket 前半部分,
// 后面扩容部分会再讲到。
evacuatedX     = 2
// 同上,key 在后半部分
evacuatedY     = 3
// tophash 的最小正常值
minTopHash     = 4

La función utilizada en el código fuente para determinar si el depósito ha sido reubicado:

func evacuated(b *bmap) bool {
	h := b.tophash[0]
	return h > empty && h < minTopHash
}

Solo se toma el primer valor de la matriz tophash para determinar si está entre 0 y 4. Comparando las constantes anteriores, cuando el hash superior es uno de los tres valores ,,, significa que todas las claves en este depósito se han movido al nuevo depósito evacuatedEmpty.evacuatedXevacuatedY

Supongo que te gusta

Origin blog.csdn.net/zy_dreamer/article/details/132799666
Recomendado
Clasificación