掘金 后端 ( ) • 2024-04-11 20:51

掘金的朋友们,好久不见~

上一篇更新内容是面经(引来了不少朋友们的关注),转眼我已经来字节两年啦。这两年做了不少事情,收获了很多成长(但是一直没来这里冒泡... 🙂)。最近组里缺服务端人力,对我而言是个不错的机会,所以我开始学习写服务端的代码啦。服务端入门要学的东西实在是不少,来这里记录一下学习过程。

因为我们团队用的是 Golang,所以就从 Golang 开始入门!

环境准备

for Mac

安装 Homebrew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

安装 Go

# 推荐1.21版本
brew install [email protected]

# 指定版本
brew link [email protected] --force

# 验证是否安装成功
go version

安装 IDE Goland

https://www.jetbrains.com/go/ (需要激活 💡)

配置 GoRoot:AllSettings -- GO -- GOROOT

OK,完成以上步骤就可以开始愉快地写代码啦!

语法基础

包(package)

Golang 项目的模块化是基于包(package) 实现的,每个 package 中是一段可编译执行的代码。

程序入口文件必须以 package main 定义,并且内部定义一个 func main() 入口方法,程序执行时会从 main 开始执行,main执行结束则程序进程也随即结束。(遥远的关于 C 语言的记忆开始攻击我...)

运行代码:

// 1. 直接命令 run
go run main.go

// 2. 编译为二进制可执行文件
go build

// 3. 执行
./${output}

package 是模块的拆分,通常以目录为单位定义包,包名与目录名保持一致(当然了也可以不一样)。

package 命名规范:

  • 只包含小写字母
  • 尽量简短且包含一定的上下文信息
  • 拆分子包时使用 a/b 而不是 aB 或 a_b
  • 使用单数而不是复数
  • 谨慎使用缩写

使用 go mod tidy 命令管理依赖包,移除没有用到的模块,补齐缺失的模块

// 定义一个包
package a

// 引入其他包
import "b" // 单个
import (
  "b"
  "c"
) // 多个
import (pkgA "a") // 包重命名为 pkgA
import (. "a")// 匿名引用,可直接调用包a中的方法
import (_ "a") // 只执行 a 中的 init 方法

// 对于包进行引用时,默认会执行引用包中的 init() 方法
func init() {
  // 这里可以写一些初始化逻辑
}

注意点:

  1. 除了使用 _ 关键字之外,引用某个包必须调用其中的内容,否则编译不通过
  2. 使用 . 关键字时,需要保证多个包中没有重名方法
  3. 包不能循环引用
  4. 包内只有首字母为大写的内容(包括方法、变量、常量、结构体)才可以被别的包调用到(相当于首字母的大写的成员属于 public,小写的属于 private)
  5. 同属于一个 package(文件夹)的内容可以互相引用,没有限制

import 分组规范:

  • 两个及以上的 import 应该聚合为一个组
  • import 的标准库和其他库应该使用一个空行隔开,先引入标准库,然后是其他库

Hello World!

package main

// 引入内置包 fmt, 用于进行字符串格式化输出
import "fmt"

// 程序入口方法
func main(){
  fmt.Println("Hello,world!")
}

变量和常量声明

变量命名规范:

  • 可导出的变量首字母大写,内部使用的变量首字母小写
  • 变量中包含缩略词时,如果位于开头且不需要导出时全小写,否则全部大写,如 ServerHTTP
  • 简洁胜于冗长,如在循环中用 i 而不是 sthIndex

变量声明:

// 关键字 var,类型一旦声明不可更改
var {变量名称} {变量类型}

// int
var num int 
var num1, num2 int 
var num int = 1 
var num1, num2 int = 1 , 2

// float32
var num float32 
var num int = 3.1415926

// 当然了也支持类型推断
var a = 1
var b = true

// 混合声明,按顺序对应
var a,b,c,d = 1, "2", true, 3.444

// 快捷声明 := 代替 var
a := 1

// 指针
var v1 int
// 定义一个指向整型的指针
var p1 *int 
// 使用&获取整型变量地址并赋值给指针
p1 = &v1

// 定义一个指向浮点型的指针
var p2 *float32 = new(float32)

和 JS 很不一样的一点在于:变量类型后置

变量未指定初始值时,默认会有一个零值,汇总在下表中

基础类型 初始值 说明 int, int8, int16, int32, int64 0 有符号整型;不同类型的整型无法互相赋值,需要做类型转换。其中位数长的类型转换为位数短的类型,或者无符号的类型转换为有符号的类型时,会丢失准确性 uint, uint8, uint16, uint32, uint64, uintptr 0 无符号整型,uintptr 用于指针运算 float32, float64 0 浮点型, complex64, complex128 复数,a+bi string "" 默认采用UTF-8编码 bool false byte 单个字节数据,同 uint8,字面量为单引号c := 'a' rune 单个字符数据,同 uint32,字面量为单引号d := '啊' 其他类型 初始值 说明 array s1 := [32]int slice 数组的一段数组元素区间,定义方式与数组类似,只不过不需要指定数组大小 func map 声明形式: map[KeyType]ValueType``KeyType 必须是可比较的类型,不可以是map/slice/array... struct 每一个字段都会有相应的零值 结构体, OOP 基础,自定义类型; 指针 nil 对于变量的间接引用,存放变量的内存地址关键字 * 用于定义指针和获取指针的值关键字 & 用于获取元素的指针地址 interface nil

三种常见的 int 转 string 方法:

  • fmt.Sprintf("%d", n)
  • strconv.Itoa(n) [推荐]
  • strconv.FormatInt(n2, 10) [推荐]

string to int

字符串转 int 主要两种方法:

  • strconv.Atoi [推荐]
  • strconv.ParseInt

常量声明:

const Pi = 3.1415926
const Pi float32 = 3.1415926

// 自定义类型常量
type Status int
const StatusSuccess Status = 0
const StatusFail Status = 1

// 有一个 iota 可以用来生成序列化整数,从0开始自增
// 不建议在业务代码中使用
type Status int
const (
  StatusSuccess Status = iota // 值为0
  StatusFail // 值为1,如果 -iota 的话就是向负递增,-1
  StatusPending // 值为2,如果 -iota 的话就是 -2
)

注释规范:

  • 如果注释位于单独一行,则需要以大写开头(缩略词或变量名除外)并以 . 结尾
  • 如果位于语句后方,则不要以大写开头,且结尾不需要加入标点符号
  • 建议写英文注释

结构体 struct

结构体是一种自定义类型,是 OOP 的基础,可以理解为一个 class。

结构体定义:

type {结构体名称} struct{
    {字段名} {字段类型}
}

// eg:
type Person struct {
  Name string
  Age int8
}

结构体创建:

// 一个名为 Person 的结构体
type Person struct {
  Name string
  Age int8
  unavailable string // 私有字段
}

// 定义一个 Person 类型的结构体变量并赋值
var hah Person = Person{
  Name: "hah",
  Age: 99
}

// 修改其中的值
hah.Age = 100

字段可访问性:小写字母开头相当于私有字段;大写字母开头则公开可访问。

结构体方法:

// 一个名为 Person 的结构体
type Person struct {
  Name string
  Age int8
  unavailable bool // 私有字段
}

// (p Person) 标识它是一个结构体方法
func (p Person) SayHi() {
  fmt.printf("Hi, my name is %s", p.Name)
}

func main() {
// 实例化一个 Person 类型变量
  var hah Person = Person{
    Name: "hah",
    Age: 99
  }
  
  hah.SayHi()
}

结构体方法在执行时,会在内存中创建一个新的结构体并执行其中的方法,即我们无法修改原结构体中的指针。那如果我们想修改原结构体的内容呢?借助指针的引用传递,寻找到源结构体。

func (p *Person) updateAge(age int8) {
  p.Age= age
}

// 结构体指针方法只能通过结构体指针调用
func main() {
  var h * Person = & Person{
    Name: "huihui",
    Age: 100
  }
  
  h.updateAge = 101
}

结构体继承:

type Person struct {
  Name string
  Age int8
}

func (p Person) SayHi() {
  fmt.printf("Hi, my name is %s", p.Name)
}

// 非常简单的语法糖写法
type Student struct {
  Person // 继承 Person
}

func main(){
  var s Student = Student {
    Name: "xiaoming",
    Age: 12
  }
  
  s.SayHi()
}

接口 interface

interface 命名规范:

  • 对于只有一个方法的 interface,通常将其命名为方法名加上 er,例如 Reader 和 Writer
// 空接口
var person interface {}

// 类型接口,定义各种方法的规范
type 类型名称 interface {
  方法名(传入参数)返回参数
...
}

type Runner interface {
   Run()
}

// 类型推断,方法 1
{{ 推断后的变量 }} := {{ 接口变量 }}.({{ 推断类型 }})
// 使用该方法需要保证类型推断是正确的,否则会引发 Panic 错误
var i interface{}
i = 100
var j int
j = i.(int)
 

// 类型推断,方法 2
{{ 推断后的变量 }}, {{ 是否推断成功 }} := {{ 接口变量 }}.({{ 推断类型 }})
var i interface{}
i = 100
var j string
j, ok := i.(int)
if !ok {
  panic("error")
}

// 类型推断,方法 3,参数类型不确定时,推荐使用方法 3
switch {{ 推断后的变量 }}:={{ 接口变量 }}.(type){
case {{ 推断类型1 }}:
    ...
case {{ 推断类型2 }}:
    ...
default:
    ...
}

var i interface{}
i = 100
var j float32
switch v := i.(type) {
  case float32:
    j = i
  default: 
    panic("error")
}

接口只关心类型能否去执行某个方法,而不会去关心类型中有什么值。

条件判断和循环

// if 判断
func handler(num int) {
  if num == 1 {
    log.Println("1")
  } else if num == 2 {
    log.Println("2")
  } else {
    log.Println("else")
  }
}

// switch 判断
func CheckScore(score int) {
  switch score {
    case score < 60:
      log.Println("bad")
      if true {
        break
       }
    case score < 80:
      log.Println("normal")
      if true {
        break
       }
    default:
      log.Println("good")
  }
}

// for 判断
func myLoop() {
  i := 1
  for i < 3 {
    log.Println(i)
    i++
  } 
}

// 结合 range
func myLoop() {
  arr := []string{"1", "2"}
  for i := range arr {
    log.Println(arr[i])
    i++
  } 
}

错误处理

使用内置包 errors 的 NewError() 可以创建错误,不过业务中基于业务特点会自己封装 bizError

一些比较严重的错误,可以用内置的 panic 方法引发运行时错误,这个错误会在方法中逐层向上传递直到 main 中,导致程序的错误退出。

如果我们希望接管 panic 错误自定义处理,而不是让程序中断退出,可以通过内置的 recover 方法结合延迟执行 defer实现。

func main(){
   // ... 一些逻辑
   
   // 在末尾执行
   defer func(){
     e := recover() // 拦截 panic 错误并将错误赋值给 e
     if err, ok := e.(error); ok {
       fmt.Println(err)
     } else {
       fmt.Println(e)
     }
   }
   
   panic("uh oh!")
}

协程

协程可以理解为轻量级的线程,golang 中,可以轻松创建出成千上万个协程

得益于 golang 的优化~TODO: 有空看看是怎么优化的。

协程可以通过 go 关键字创建:

func main(){
  // ....
  
  go func(){
    fmt.Println("我在协程中运行 :P")
  }
  
  // 协程的执行是非阻塞的,协程创建后主方法会继续往下走,直接退出进程
  // 这时协程很有可能还没执行到,需要有一个方法去等待协程执行完毕
  time.Sleep(time.Second)
  // 这个方法不太可靠
}

如果多方对同一个数据做读写操作,会导致数据不准确,所以需要引入锁机制保证数据并发读写时操作的原子性。锁具有互斥性,同一个锁在一个协程中加锁之后在另一个协程中是无法加锁的,会一直阻塞到锁被解开,从而保证一段时间内只有一个事务在进行

只读没必要加锁,读写同时发生或者并发写入就需要加锁。

锁的种类很多,比如分布式锁、线程锁、协程锁...

在 golang 中,内置库 sync 实现了互斥锁 sync.Mutex

var waitGroup sync.WaitGroup
var myMutex sync.Mutex

waitGroup.Add(2)

go someReadAction(&waitGroup)
go someWriteAction(&waitGroup)

waitGroup.wait() // 等 Group 操作都执行完毕


// 加锁
func someWriteAction(wg *sync.WaitGroup){
  myMutex.Lock()
  // ....操作
  myMutex.Unlock()
  wg.Done()
}

以及读写锁 sync.RWMutex

读写锁对锁的作用做了进一步延伸,包括两种锁:读锁和写锁。

写锁和互斥锁一样,有锁时无法再加锁;

读锁不太一样,只要不存在写锁,多个读锁可以同时锁上。

只要不是协程安全的类型,在跨协程使用过程中会发生数据变化的,都应当加锁。 加锁时应当以事务为单位加,且不能出现嵌套加锁,否则会出现死锁。

只要事务中出现了写操作,就应当加写锁;只有在事务中不存在写操作时,才加读锁。

等待组 WaitGroup

我们怎么获取所有协程都执行完毕的准确时机呢?

在内置包 sync 中有一个 WaitGroup 可以对应这种场景,它基于锁机制实现,原理是在 WaitGroup 内部有一个带锁的计数器,通过它的 Add() 方法可以加值,Done()可以释放一个计数器(值减1),还有一个 Wait() 方法会一直阻塞到计算器归零。

比如我们要设计一个并发去下载数据的功能,可以在协程执行前为 WaitGroup 计数器加1,然后把 WaitGroup 作为参数传入并运行协程,在协程任务完成后执行 WaitGroupDone() 释放;在主方法中执行 Wait() 方法阻塞知道所有协程任务完成。

func export() {
  arr := []string{"https://xxxxximage1", "https://xxxxximage2", "https://xxxxximage3", "https://xxxxximage4"}

  var wg sync.WaitGroup
  for i := range arr {
    // 等待组计数器+1
    wg.Add(1)
    go downloadImages(arr[i], &wg)
    i++
  } 
  
  wg.Wait()
  fmt.Println("全部下载完成!!!")
}


func downloadImages(url string, waitgroup *sync.WaitGroup){
  // 执行下载逻辑
  // Sleep 模拟一下
  time.Sleep(time.Second)
  fmt.Println("下载完成:", url)
  // 释放计数器
  waitgroup.Done()
}

运行结果:

通道 channel

通道,可以理解为支持跨协程使用的空间大小固定的队列,由关键字 chan 定义:

var {变量名} chan {通道类型}

通过 make()方法创建通道:

var msgChannel chan string
// 给通道分配内存空间,不指定时默认为0
msgChannel = make(chan string, 10)

哦我说项目中的 make 是啥意思呢!明白了 💡

通道可以作为参数协程中传递,通过操作符 <--> 将数据读出或写入通道:

func test(testChan chan string){
  fmt.Println("test begins!")
  time.Sleep(time.Second)
  // 将数据写入通道,这里会阻塞直到另一端读取通道
  testChan <- "start"
}

func main(){
  fmt.Println("main begins!")
  // 创建通道
  var testChan chan string = make(chan string)
  // 执行协程,传入通道作为参数
  go test(testChan)
  // 从通道中读取数据,这里会阻塞直到另一端写入通道
  <-testChan
  
  fmt.Println("main ends!")
}

// 运行结果
main begins!
test begins!
main ends!

关闭通道可以基于内置的 close 方法实现,通道关闭之后不支持写入数据(此时写入会 panic),但仍然可以读取数据直到通道中内容为空。

var msgChannel chan string
msgChannel = make(chan string, 10)

// 写入一条数据
msgChannel <- "hello"

// 关闭
close(msgChannel)

// 读取数据,队列嘛,取一条少一条
msg, ok := <- msgChannel // ok 为 false 时,表示通道已经关闭且没有数据

上面的示例是双向通道,可以读也可以写;此外还可以指定单向通道(只读或者只写);双向通道可以转换为单向通道,反之不可以。

var <-chan {通道类型} // 只读
var chan-> {通道类型} // 只写

在高并发场景中,通道可以用来缓存无法即时消费的写请求,从而释放协程资源。

TODO:这块后面可以再进一步了解下。

上下文 Context

type Context interface {
  // 返回一个只读通道,当此上下文被取消或传递的 deadline 时间到达时,该通道将关闭
  Done() <-chan struct{}
  // 返回上下文已取消的原因(如果有)
  Err() error
  // 如果上下文有设置 deadline,则返回 deadline 时间;否则,返回 false
  Deadline() (deadline time.Time, ok bool)
  // 返回与某个键相关联的值,没有则返回 nil
  Value(key interface{}) interface{}
}

协程运行是独立的,如果处理不当有协程一直驻留在内存区,会形成僵尸协程,影响系统稳定性。在内置包 context 中,我们可以基于 context.Context 应对这种场景。

Context 是一个协程安全的类型,可以传递给不同的协程方法使用。

func main(){
  // 通过 context.Background() 创建一个上下文,做为根级上下文,只用于派生子上下文,不做其他用途
  // 通过根级上下文进行派生,获得一个子上下文以及子上下文的终止方法
  ctx, cancel := context.WithCancel(context.Background())
  
  go doSth(ctx)
  
  // 3s 后关闭上下文
  time.Sleep(3 * time.Second)
  cancel()
}


doSth(ctx context.Context){
  // ....
}

测试与分析

单元测试

Golang 中内置了测试包 testing,可以对某个方法或者模块做功能测试和验证。

比如我们有一个 a.go 文件,测试时在同级目录下创建一个同名且带 _test.go 后缀的文件【必须】;在 a_test.go 文件中,引入包 testing,给需要测试的方法加 Test 前缀【必须】,接收参数为 *testing.T*testing.T 可以在测试过程中输出相关信息。

// a.go
package unittest

func Add(x int, y int) int {
  return x + y
}

// a_test.go
package unittest

import "testing"

func TestAdd(t *testing.T) {
  if Add(1, 2) !== 3 {
    t.Error("add error!")
  } else {
    t.Log("add correctly!")
  }
}

运行 go test -v 执行单元测试,-v 表示打印日志。go test -v -cover 可以开启覆盖率统计。

性能测试

性能测试文件也是以 _test.go 结尾,同时引入包 testing。性能测试方法的定义要求以 Benchmark 作为前缀。

运行 go test a_test.go -test.bench=".*" 执行性能测试。测试结果会包含执行次数、执行时间等信息。

go test b_test.go -test.bench=".*" -count=5 执行5轮。

go test b_test.go -test.bench=".*" -benchmem 查看内存分配情况。

性能分析

Golang 中提供了工具 pprof 用来做性能分析,有两个库:runtime/pprof(用于为非守护形式运行的程序做性能分析)和net/http/pprof(用于为守护态的 http 服务程序做性能分析)。

Q:守护态是啥?

A(by ChatGPT):守护进程(daemon process)是指在后台运行的进程,该进程通常以超级用户权限运行,并且不与控制台或用户交互。守护进程通常用于作为后台服务运行,例如 Web 服务器、数据库服务或其他长时间运行的服务,但不需要与用户交互。

编译

后面再看

HTTP

Handler

Golang 中内置包 net/http 提供了一些 http 相关的方法,通过 http.ListenAndServe() 方法监听端口并启动服务,接收到请求后需要根据不同路由指定对应的处理方法,http.HandleFunc()实现了请求路径和 handler 的绑定。

import (
  "fmt"
  "net/http"
)

func SayHi(w http.ResponseWriter, req *http.Request){
  w.Write([]byte("hello,world!\n"))
}

func main(){
  // 将所有匹配到 / 的请求交由 SayHi 方法处理
  http.HandleFunc("/", SayHi)
  // 开启 http 服务并侦听到 8080 端口
  // 第二个参数表示服务处理程序,一般设置为空,会用默认的路由分发器 http.DefaultServeMux
  // http.Handle() 或 http.HandleFunc() 会默认将路由注入 http.DefaultServeMux 中
  if err := http.ListenAndServe("localhost:8080", nil); err != nil {
    fmt.Println(err)
  }
}


// http.ListenAndServe 会使用默认配置开启一个 Server
// 也支持自定义 Server
server := http.Server{
  Addr:         "localhost:8080",
  ReadTimeout:  5 * time.Second,
  WriteTimeout: 5 * time.Second,
  Handler:      mux,
}

ServeMux

http.NewServeMux() 方法支持创建自定义的路由分发器:

// 创建自定义的路由分发器
mux := http.NewServeMux()

// mux 里面也封装了 Handle() 和 HandleFunc() 方法,和上述使用一致

Middleware

中间层,在路由分发和 HandlerFunc 中间生效,附加一些通用的功能。

func LoggerMiddleware(next http.Handler){
  return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request){
    start := time.Now()
    log.Printf("here comes a request %s %s", r.Method, r.URL.Path)
    next.ServeHTTP(w, r)
    log.Printf("response cost %s", time.Since(start))
  })
}

进阶

数组与切片

slice 是对数组的封装,它的底层数据是数组。

数组定长,长度指定之后不能再更改,在 Go 中数组用的比较少,因为数组长度是类型的一部分,限制了它的表达能力,比如 [3]int[4]int 就是不同的类型。而切片则非常灵活,它可以动态地扩容。切片的类型和长度无关。

// 创建切片
var slice []T                // 声明一个 T 类型的空切片
slice := make([]T, length)   // 使用 make 函数创建指定长度的 T 类型切片
slice := make([]T, length, capacity)  // 创建指定长度和容量的 T 类型切片

// append 扩容, 返回一个新的 slice
// append 函数执行完后,返回的是一个全新的 slice,并且对传入的 slice 并不影响
slice = append(slize, ele1, ele2)
slize = append(slize, anotherSlice)

数组就是一片连续的内存, slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。

// runtime/slice.go
type slice struct {
    array unsafe.Pointer // 元素指针,指向底层数据的地址
    len   int // 长度 
    cap   int // 容量
}

使用 append 可以向 slice 追加元素,实际上是往底层数组添加元素。但是底层数组的长度是固定的,如果索引 len-1 所指向的元素已经是底层数组的最后一个元素,就没法再添加了。

这时,slice 会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice 的容量是留了一定的 buffer 的。否则,每次添加元素的时候,都会发生迁移,成本太高。

新 slice 预留的 buffer 大小是有一定规律的。在 golang1.18 版本更新之前网上大多数的文章都是这样描述 slice 的扩容策略的:

当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。

在 1.18 版本更新之后,slice 的扩容策略变为了:

当原 slice 容量(oldcap)小于 256 的时候,新 slice(newcap) 容量为原来的 2 倍;原 slice 容量超过256,新 slice 容量 newcap = oldcap+(oldcap+3*256)/4

  • 使用slice尽量初始化一定的大小,有接近10倍的性能提升。

Map

map 是由 key-value 对组成的;key 只会出现一次。和 map 相关的操作就是最基本的 增删查改

map 的设计也被称为 “The dictionary problem”,它的任务是设计一种数据结构用来维护一个集合的数据,并且可以同时对集合进行增删查改的操作。最主要的数据结构有两种:哈希查找表(Hash table)搜索树(Search tree)

哈希查找表用一个哈希函数将 key 分配到不同的桶(bucket,也就是数组的不同 index)。这样,开销主要在哈希函数的计算以及数组的常数访问时间。在很多场景下,哈希查找表的性能很高。

哈希查找表一般会存在“冲突”的问题,就是说不同的 key 被哈希到了同一个 bucket。一般有两种应对方法:链表法开放地址法链表法将一个 bucket 实现成一个链表,落在同一个 bucket 中的 key 都会插入这个链表。开放地址法则是冲突发生后,通过一定的规律,在数组的后面挑选“空位”,用来放置新的 key。

搜索树法一般采用自平衡搜索树,包括:AVL 树,红黑树。自平衡搜索树法的最差搜索效率是 O(logN),而哈希查找表最差是 O(N)。当然,哈希查找表的平均查找效率是 O(1),如果哈希函数设计的很好,最坏的情况基本不会出现。还有一点,遍历自平衡搜索树,返回的 key 序列,一般会按照从小到大的顺序;而哈希查找表则是乱序的。

Go 语言采用的是哈希查找表,并且使用链表法解决哈希冲突。

// runtime/map.go
// A header for a Go map.
type hmap struct {
   count     int    // 元素个数,len返回
   flags     uint8  // 当前map所处的状态标志。目前定义了四个状态值: iterator、oldlterator、hashWriting、sameSizeGrow
   B         uint8  // buckets 的log2 对数
   noverflow uint16 // 溢出桶的数量
   hash0     uint32 // hash seed
   buckets    unsafe.Pointer // 2^B长度的Buckets数组
   oldbuckets unsafe.Pointer // 扩容时指向老buckets,每次扩容2倍
   nevacuate  uintptr        // 扩容进度
   extra *mapextra // 可选字段。如果有overflowbucket存在,且key、value都因不包含指针而被内联(inline)的情况下,
                   // 这个字段将存储所有指向overflow bucket的指针, 保证overflow|bucket是始终可用的(不被GC掉)
}