Go语言核心知识点和原理详解

go核心原理

本人在一家go技术栈工作2年有余,因此梳理一下我认为比较重要的go语言技术知识,一些基础的概念,比如function, interface这些就忽略了。

https://draveness.me/golang/

https://www.bookstack.cn/read/qcrao-Go-Questions/map-map%20%E7%9A%84%E6%89%A9%E5%AE%B9%E8%BF%87%E7%A8%8B%E6%98%AF%E6%80%8E%E6%A0%B7%E7%9A%84.md

go与java的对比

https://www.turing.com/blog/golang-vs-java-which-language-is-best/

  • go比java要快,go没有虚拟机,直接是可执行环境在跑
  • 没有spring框架那么多依赖包,不需要在框架上浪费太多时间,golang专注于语言
  • go很多库没有,使用起来不方便
  • 其实综合来看,倒是没什么区别,业务开发,语言都是其次

函数传递

在golang中所有的传递都是值传递,只不过有的底层数据是指向了同一个数组,所以传递了之后能修改原有的值,比如切片,但是int , string ,struct这些就只是单纯的值传递。当然切片的话,如果发生了扩容,那么底层数组也发生了变化,修改传递之后的切片,就不会修改传递之前的切片的元素。

defer

func main() {
	fmt.Println(1)
	defer fmt.Println(2)
	fmt.Println(3)
	defer fmt.Println(4)
	fmt.Println(5)
	defer fmt.Println(6)
}

1
3
5
6
4
2

defer是值传递

如下所示,每次defer传递时,都会把当前的i传到堆或者栈中

func main() {
	for i := 0; i < 5; i++ {
		defer fmt.Println(i)
	}
}

$ go run main.go
4
3
2
1
0

如果defer后面加一个func, 那么虽然也是值传递,但是传递的是函数的指针,最后defer的func真正执行的时候执行的是func,拿到的i是最新的i

func main() {
	for i := 0; i < 5; i++ {
	    defer func() {
	    	 fmt.Println(i);
	    }
	}
}
5
5
5
5
5

defer有三种机制:
defer的数据结构本质上都是一个链表,运行时或者编译期间,逐步将defer方法加入到链表的首部,然后有三种方式

1、堆上分配,运行时生成链表,在堆上存储

2、栈上分配,运行时生成链表,在栈上存储,如果发生逃逸分析,那么只能使用堆上分配

3、开放编码,编译期间,判断defer关键字少于8个,for循环中没有defer, 并且return语句和derfer的成绩<=15, 则使用开方编码,使用了8bit存储defer应该被执行(所以要少于8个),
在编译期间,就在当前函数的尾部插入函数,并且在运行时通过上述的bit位判断是否要执行相应的函数,也就是for循环8次,每次根据bit位是否是1判断是否要去执行

下面的例子很典型
code1:

func func2()  int {
	i := 1
	defer func() {
		i++
	}()
	i++
	fmt.Printf("&a=%p, a = %v\n", &i, i)
	return i
}
func main() {
	fmt.Println(func2())
}

执行func2, 打印结果,返回的是2
如果改下代码
code2:

func func2() *int {
	i := 1
	defer func() {
		i++
	}()
	i++
	return &i
}
func main() {
	fmt.Println(*func2())
}

那么打印的是3
原因如下:

  • 可以把函数里面的全部指令当做一个栈,return i执行之前压入栈,然后出栈,返回了2,之后defer入栈,i++, 虽然里面确实变为了3,但是之前的return已经返回了,而且返回的时候也是值传递,相当于main方法执行时会将数据再进行拷贝
  • 对于code2,由于返回的是指针,所以虽然return先返回了,但是返回的是地址,之后,defer方法继续执行,修改了地址指向的元素,变为了3,所以fmt.println中接受到的指针复制给参数时复制的是变量,变量的值最后变为了3
  • 上述本质上是编译上,对于demo1, 会在栈上两个地址中存放数据,一个是返回给调用方,而defer中调用的是原始的数据

切片原理

切片的底层是数组

切片有三个属性

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

修改同一个切片的新切片,也会修改原切片,因为使用的底层数组是同一个。

在往切片追加元素时,如果长度超过了已有的容量,就会发生扩容,扩容方法在runtime.growslice中

func growslice(et *_type, old slice, cap int) slice {
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
}

如果新容量大于已有容量的两倍,那么就用新容量

否则,如果旧容量长度<1024,那么就用两倍的旧容量

否则,每次将旧容量新增25%,知道大于新容量

扩容后,底层数组会生成一个新的数组,将原有数组数据直接将内存拷贝到新数组,比一个个拷贝数组元素更快

slice不能用==来比较,因此slice有array, len, cap等成员变量,即使array元素是一样的,那么还有len, cap,可能不同,容易造成困扰

map原理

map的设计跟java map有点类似,都是使用拉链法.

golang初始的桶大小为8.

除了有一个桶数组[]bmap外,还有一个overflow(溢出) bmap数组,用于当桶满了之后,去overflow桶中去put数据。两种桶数组在内存中时连续的. 每个bmap可以容纳8个元素。

  • 当桶的数量小于 16 时,由于数据较少、使用溢出桶的可能性较低,会省略创建的过程以减少额外开销;
  • 当桶的数量多于 16 时,会额外创建 2^B−16 个溢出桶;

bmap结构体中还存放了一个数组,数组存放该桶中链表数据key的高八位哈希,这样可以在查找数据时避免直接判断链表的值,而直接通过key.

key的低八位用于找到是哪个桶.

获取的时候会从正常桶数组和溢出桶数组中依次找数.

初始化时会调用map.go中makemap方法来初始化map.

map扩容

两种条件下会发生扩容

  • 1、超过了装载因子,loadFactor, 即count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen), map元素的数量 > 桶的数量* 6.5 会触发
  • 2、使用了太多溢出桶的时候,溢出桶的大小> 2B次方时(2B为桶的最大值, 当b > 16时,按照2^16来算).

第一种方式会双倍扩容,第二种方式是等量扩容,即比如原有B=8, 那么双倍扩容就是B=9, 等量扩容就是B=8, 相当于让现有正常桶的大小变为溢出桶和原有桶的总大小,总大小其实不变,等量扩容只是改变原有的桶,将数据变得更紧凑。

golang的扩容并不是原子操作,而是增量扩容的,扩容的时候,会创建新的bucket数组,而原有的old bucket数组先不会删除,当写入数据时会触发增量迁移,将老的bucket数据迁移到新的bucket中,即做扩容动作时是以bucket为单位而不是buckets.

而在迁移时,如果有数据读取,会先从old buckets中找数据。

当所有数据都迁移完后,就会删除老得buckets,将扩容flag noverflow置为0。

为什么map长度要为2的N次幂

1、(map.size - 1) & hashKey刚好等于hashKey % map.size, 这正是符合哈希的预期

2、map.size如果是基数,那么-1之后,最后一位就是0,&之后就一定是偶数,显然限制了哈希散列,冲突会加剧

3、扩容为2的倍数,哈希的时候,相当于map.size-1相对于之前,最左边多了个1,那么迁移的时候能够更快地计算属于哪个新bucket

make和new的区别

make用于初始化slice, map和channel的内部数据结构,而new不会,new只是分配内存,new可以初始化其他类型。

var mapa *map[int]int
mapa = new(map[int]int)
(*mapa)[1] = 2

上面这个代码就会报空指针异常

https://juejin.cn/post/6945608377581961230

指针强转

https://www.cnblogs.com/hitfire/articles/6363696.html
只要内存布局一样,指针是可以直接强制类型转换

unsafe包

unsafe.Pointer 可以获取变量的地址指针,然后还可以转成uintprt,对地址进行操作

并发

goroutine

golang中goroutine的概念,类比于线程

go functiongName()就可以开启一个协程

在java等一些编程语言中,线程和操作系统中的线程是一一对应的,切换线程时,都要由操作系统进行保存和切换上下文(这个上下文指的是寄存器信息以及加载线程到内存中),会增加cpu的运行时间。

而goroutine并不是和操作系统的线程一一对应,而是go封装了自己的协程调度器,跟os调度器相似,但是只在go程序层面调度。可以在n个操作系统线程上调度m个goroutine.

阻塞与唤醒

https://blog.csdn.net/liyunlong41/article/details/104949898,
大部分阻塞,都会调用gopark,状态设置为阻塞,然后解除与p的关系,获取到信号量后,状态变为了runnable,放到p的local queue中
信号量这个变量是一个指针型,有一个类似map的结构,存储了所有阻塞的sudog,然后释放信号量的时候,根据信号量的指针地址,找到这个sudog,然后唤醒一个阻塞的goroutine

channel

https://www.cnblogs.com/jiujuan/p/16014608.html

channel即管道,在协程之间接受和发送信号

有带有缓冲的channel和不带缓冲的channel,还有单向channel

直接调用close(channel) 方法可以直接关闭掉channel

sync.WaitGroup

https://zhuanlan.zhihu.com/p/344973865

计数器,通过它可以知道多个协程什么时候执行完

sync.WaitGroup设计原理是信号量

type WaitGroup struct {
	noCopy noCopy

	// 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
	// 64-bit atomic operations require 64-bit alignment, but 32-bit
	// compilers do not ensure it. So we allocate 12 bytes and then use
	// the aligned 8 bytes in them as state, and the other 4 as storage
	// for the sema.
	state1 [3]uint32
}

state数组变量存放gorouitine counters(即目前尚未完成的个数),waiter count(代表已经调用wait的个数), sema, 根据操作系统位数的不同,来不同的存放。 主要是为了内存对齐,便于操作两个counter

在这里插入图片描述

在这里插入图片描述

修改counters时都是通过自旋进行操作。

对于Add(delta int)方法,就会goroutine counters 先+delta,判断是否为0,为0就说明所有的goroutine都已经结束了,那么如果wait counters > 0,就说明还有goroutine在等 信号,那么就调用runtime_Semrelease通知count(wait counters)个

Done()就是调用Add(-1)

Wait()方法即新增wait counters,调用runtime_Semacquire获取信号量

waitGroup总体原理是维护了一个wait的数量,一个计数器,一个信号量
add时,增加计数器,done是减少计数器,如果是0,则判断wait的数量是否为0,大于0,则释放同等数量的信号量
wait时,则wait个数+1, 然后去获取信号量,没有信号量,则阻塞住。

select

select表示分别接收多个channel,对于每种channel的处理方式,使用方式如下:

select {
	case <-tick:
	// Do nothing.
	case <-abort:
		fmt.Println("Launch aborted!")
		return
} 

如果没有default,select会一直阻塞

sync.mutext(锁)

https://lailin.xyz/post/go-training-week3-sync.html#
https://www.lixueduan.com/posts/go/sync-mutex/

golang中有sync.Mutex和sync.RWMutex

sync.Mutext是读和写都会阻塞,sync.RWMutex有lock()和Rlock()方法,Rlock方法通知执行不会阻塞,但是会和lock方法阻塞

sync.Mutext的底层也是通过信号量实现的

type Mutex struct {
	state int32
	sema  uint32
}

在这里插入图片描述

其中state分为waiter, starving, woken, locked几个字段,分别表示正在等待的goroutine数量,是否是饥饿状态,是否有goroutine被唤醒,mutext是否已经被锁定。

当尝试获取锁时,会先尝试自旋一段时间获取锁,也就是normal mode, 但是不会一直自旋,当自旋超过1ms时或者自旋次数小于4次,就会设置为饥饿模式,放到队列的末尾。
获取锁的流程如下所示
在这里插入图片描述

RWMutext

type RWMutex struct {
	w           Mutex
	writerSem   uint32
	readerSem   uint32
	readerCount int32
	readerWait  int32
}

读写锁在互斥锁的基础上实现。
获取写锁时,会先调用互斥锁进行lock,再调用信号量writerSem等待所有读锁执行结束后再真正获取写锁
释放写锁时,释放readerCount 个信号量readerSem ,再释放互斥锁,让读锁优先执行
获取读锁时,获取信号量readerSem
释放读锁时,释放写信号量writerSem

sync.Once

对于一些需要通过锁来初始化数据,并且最好执行一次的方法,可以直接使用sync.Once来直接包装,无需手动加锁解锁

type Once struct {
	// done indicates whether the action has been performed.
	// It is first in the struct because it is used in the hot path.
	// The hot path is inlined at every call site.
	// Placing done first allows more compact instructions on some architectures (amd64/x86),
	// and fewer instructions (to calculate offset) on other architectures.
	done uint32
	m    Mutex
}

在调用Do方法时,会先原子操作,将done置为1,失败了直接返回说明有其他goroutine在使用,成功了,则加锁调用包装的方法。

sync.map原理

https://www.cnblogs.com/qcrao-2018/p/12833787.html
https://tonybai.com/2020/11/10/understand-sync-map-inside-through-examples/

普通的map是线程不安全的,golang本身定位map就是一个不需要并发读写的map。

如果想要实现并发读写的map, 可以实现两种方式,sync.RWMutext + map, 但是这种肯定性能相对查,另一种就是使用sync.map

type Map struct {
	mu Mutex
	read atomic.Value // readOnly
	dirty map[interface{}]*entry
	misses int
}

type readOnly struct {
	m       map[interface{}]*entry
	amended bool // true if the dirty map contains some key not in m.
}

sync.map使用了两个map,一个read, 一个dirty,使用了读写分离的设计方案

load即读的时候,先不加锁从read map中读取,没有的话,则加锁,从dirty中读取,并且misses计数器+1,当misses >= len(dirty)的时候,就将dirty转为read,dirty再清空

store即写的时候,如果read map中有,则取出来,并尝试使用cas自旋更新,更新成功则返回,不成功(可能被并发删除了), 则加锁,再从read中获取一遍,没删除,则原子更新,删除了,则再从dirty中获取,如果dirty中有,则原子更新dirty中的value, 如果dirty中也没有,并且dirty中不存在存在read map中没有的元素,则会遍历read map,将readmap中所有元素加到dirty中,然后将dirty元素赋值给read map, 然后新创建entry,加到dirty map中

删除时,如果在read map中,那么只是将对应的value置为nill(下次dirty赋值给read时,key就会没有,因为dirty中没有这个key), key还是存在的, 如果在dirty中,则直接删除key

sync.map适合读多写少,当首次尝试写read时会先取出map的value:entry指针,然后使用自旋来修改使指向新的value地址,而不是实际直接修改map的value,避免出现map线程安全问题

sync.map下面这两个代码需要注意,也就是当read和dirty都没有元素的时候,判断dirty是否为nill, 为nil会将read中的元素全部复制给dirty,再将新的键值对放到dirty中,同时amended置为true,也就是如果dirty不为空,那么dirty是包含所有元素,read包含部分元素

func (m *Map) dirtyLocked() {
	if m.dirty != nil {
		return
	}

	read, _ := m.read.Load().(readOnly)
	m.dirty = make(map[interface{}]*entry, len(read.m))
	for k, e := range read.m {
		if !e.tryExpungeLocked() {
			m.dirty[k] = e
		}
	}
}
if !read.amended {
			// We're adding the first new key to the dirty map.
			// Make sure it is allocated and mark the read-only map as incomplete.
			m.dirtyLocked()
			m.read.Store(readOnly{m: read.m, amended: true})
		}
		m.dirty[key] = newEntry(value)

Load操作时,当miss太多时,会直接将dirty切换到read,然后dirty变为nil, amended为false, m.read.Store(readOnly{m: m.dirty})其实就是隐含着amended变为false

func (m *Map) missLocked() {
	m.misses++
	if m.misses < len(m.dirty) {
		return
	}
	m.read.Store(readOnly{m: m.dirty})
	m.dirty = nil
	m.misses = 0
}

其实sync.map核心可以看做是快速原子性重试,一般地话,如果store, delete某个元素,某个元素能找到,那么只需要进行一个原子性操作看是否成功,一般就会ok,不成功,则加锁,就可以最大限度地避免使用到锁

GMP

结合这个博客和源码来去了解golang并发的调度模型gmp,

https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/,另外,《go并发编程实战》这本书写的gmp很晦涩,并且对于全局p队列和调度器p队列写的有误导

G : goroutine,协程

M: machine,就是真正的线程

P: procs, 处理器

在这里插入图片描述

M跟操作系统中的线程就是一对一的关系,M和P也是一对一的关系, 但P与G是一对多的关系

golang中最多只能创建10000个 线程,但只有GOMAXPROCS 个线程能够同时运行,默认p的队列大小为服务器的核数。

整个golang程序启动时,有一个全局的m列表,全局的p列表,全局的g列表,

程序启动时,会将maxmcount设置为10000,并根据变量GOMAXPROCS 初始化对应数量的p,放在数组allp中,然后会设置allp[0]与当前m对应, 状态为prunning,其余的p状态为pIdle, 并加入到全局的空闲idle列表中。

GMP中的数据结构

整个调度器中,有一个全局的空闲m列表,全局的空闲p列表,全局的自由g的两个列表,可运行g队列

每个p有一个可运行的g队列和一个自由g列表。(其中可运行的g队列是一个数组,容量为256)

整个go程序中也有一个全局m列表,全局p列表,全局g列表

type schedt struct {
	// accessed atomically. keep at top to ensure alignment on 32-bit systems.
	goidgen   uint64
	lastpoll  uint64 // time of last network poll, 0 if currently polling
	pollUntil uint64 // time to which current poll is sleeping

	lock mutex

	// When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
	// sure to call checkdead().

	midle        muintptr // idle m's waiting for work
	nmidle       int32    // number of idle m's waiting for work
	nmidlelocked int32    // number of locked m's waiting for work
	mnext        int64    // number of m's that have been created and next M ID
	maxmcount    int32    // maximum number of m's allowed (or die)
	nmsys        int32    // number of system m's not counted for deadlock
	nmfreed      int64    // cumulative number of freed m's

	ngsys uint32 // number of system goroutines; updated atomically

	pidle      puintptr // idle p's
	npidle     uint32
	nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.

	// Global runnable queue.
	runq     gQueue
	runqsize int32

	// disable controls selective disabling of the scheduler.
	//
	// Use schedEnableUser to control this.
	//
	// disable is protected by sched.lock.
	disable struct {
		// user disables scheduling of user goroutines.
		user     bool
		runnable gQueue // pending runnable Gs
		n        int32  // length of runnable
	}

	// Global cache of dead G's.
	gFree struct {
		lock    mutex
		stack   gList // Gs with stacks
		noStack gList // Gs without stacks
		n       int32
	}

	// Central cache of sudog structs.
	sudoglock  mutex
	sudogcache *sudog

	// Central pool of available defer structs of different sizes.
	deferlock mutex
	deferpool [5]*_defer

	// freem is the list of m's waiting to be freed when their
	// m.exited is set. Linked through m.freelink.
	freem *m

	gcwaiting  uint32 // gc is waiting to run
	stopwait   int32
	stopnote   note
	sysmonwait uint32
	sysmonnote note

	// safepointFn should be called on each P at the next GC
	// safepoint if p.runSafePointFn is set.
	safePointFn   func(*p)
	safePointWait int32
	safePointNote note

	profilehz int32 // cpu profiling rate

	procresizetime int64 // nanotime() of last change to gomaxprocs
	totaltime      int64 // ∫gomaxprocs dt up to procresizetime
}
goroutine的创建

创建一个新goroutine时,会先尝试从当前gouroutine拿到p,看p中是否有自由的g,没有则从调度器中去拿,然后再放到当前p的可运行队列中,以及加到全局的p列表中。 加这些队列和列表操作时,使用自旋来保证并发性。

如果当前处理器§没有可运行g,则从调度器拿,并放到p的可运行队列中 如果p的可运行g队列已经满了,则将g放到调度器的全局g队列中。

调度器的调度

首先会启动一个m0线程,创建一个g0携程,去进行初始化操作,比如创建调度器,创建maxprocs个p,然后创建main goroutine,去开始调度执行,从这之后,m0和普通的m就没啥区别
然后调度器会schedule()方法,先首先会尝试判断是否需要进行gc,是的话,则唤醒gc协程(每个p都对应一个gc worker,因为要开启一个写屏障)
每调度61次会从调度器的全局可运行队列g中,将g取出放到当前可运行处理器p的可运行队列中,如果全局队列中没有,则从当前协程所在的可运行g队列中去取。如果都没有,则runtime.findrunnable方法,这个方法,会阻塞式地一次从本地,全局队列查找,从网络轮询器是否有goroutine等待运行,再通过runtime.runqsteal方法通过全局的p列表,通过一定算法获取一个p,从p中偷取可运行的g,如果不存在就阻塞。

当goroutine执行完毕之后,就会在调用runtime.schedule方法继续调度器调度。
而执行完的goroutine就会被放到p的free列表里,如果p的free列表超过了64个,就会放到全局的goroutine空闲列表中去

调度器触发的时间点

1、一个协程(线程)启动的时候调用runtime.mstart

2、一个goroutine执行结束的时候,调用runtime.goexit0

3、主动挂起,比如time.sleep, goroutine状态由_GRunning转为_GWaiting
4、系统调用
5、协作式调度
6、系统监控
后面会详细介绍系统调用、协作式调度

系统调用

流程:

1、保存当前的程序计数器和栈指针中的内容

2、将goroutien的状态设置为_Gsyscall

3、将 Goroutine 的处理器和线程暂时分离并更新处理器的状态到 _Psyscall,当前线程会陷入系统调用等待返回

4、释放当前线程上的锁

5、处理器会处理其他的goroutine

系统调用结束流程:

1、尝试调用exitsyscallfast,如果g原来的p处于syscall状态,则重新进行关联;否则如果调度器中存在闲置的p,则用该p处理goroutine

2、runtime.exitsyscall0 会将goroutine状态置为_GRunnable, 并移除线程M和当前goroutine的关联,

然后首先尝试从调度器的空闲p列表中找到一个来关联g,没有,则将当前g放到全局的G列表中,等待调度器调度

协作式调度

即当前groutine状态置为runnable,并加入到全局队列中,让原来的处理器处理其他的goroutine, 而自身等待被调度器调度

goroutine的状态转换

在这里插入图片描述

gwaiting一般是比如管道这种,需要收到信号才会继续往下走

新建时都是Gidle

goroutine执行完毕后,就是Gdead状态,这时候还是会在全局队列中,下次会重新初始化为GRunable状态

process的状态转换

在这里插入图片描述

网络轮训器

网络轮询器是golang用于监控网络IO和文件IO以及计时器的唤醒,利用操作系统的IO多路复用,提高IO设备的利用率和程序的性能。

IO多路复用即同事监听一组文件符的状态,比如select(只能监听1024个), epoll等。

select具有以下缺点:

  • 监听能力有限 — 最多只能监听 1024 个文件描述符;
  • 内存拷贝开销大 — 需要维护一个较大的数据结构存储文件描述符,该结构需要拷贝到内核中;
  • 时间复杂度 O(n)O(n) — 返回准备就绪的事件个数后,需要遍历所有的文件描述符;

因此golang使用其他的IO多路复用,比如linux系统中使用epoll,步骤如下:

  • 初始化创建文件描述符,创建用于通信的管道,将文件描述符打包成epollevent事件加入监听
  • 轮询事件
  • goroutine执行读写操作遇到文件描述符不可读或者不可写(比如正在发生IO操作),会调用runtime.netpollblock等待文件描述符可读护着可写,这个函数会让当前goroutine转到休眠状态,让出线程等待唤醒。
g0

每个p都对应一个g0,g0是一个特殊的goroutine,专门用于进行调度和作为其他处理。
可以这么理解,比如一个goroutine正在运行,然后需要进行退出或阻塞,那么当然是先保存上线文到g中的sched结构体中,然后交给g0这个goroutine来运行schedule方法,一般叫g0栈

系统监控

系统监控线程是单独的,不会受到gmp的调度

系统监控类似于go程序的守护进程,在程序启动时main方法会调用如下方法创建

systemstack(func() {
	newm(sysmon, nil)
})

系统监控有如下作用:

  • 运行计时器
  • 轮询网络(即IO多路复用)
  • 抢占处理器(抢占运行时间较长的或者处于系统调用的goroutine)
  • 垃圾回收

反射

反射是指在运行期间才知道才得知变量的类型和值得方式。

最典型的使用场景是fmt.Printf,变量是一个interface,真正执行的时候会用反射拿到它的值和类型。
reflect.TypeOf可以获得类型,reflect.ValueOf(resp).Elem()可以得到元素,并且reflect.ValueOf(resp).Elem().FieldByName(CommonErrorResponseName).Set(reflectValue)还可以动态修改值

在真正业务中也会经常用到反射, 不如在拦截器中,统一处理error,赋值error code信息

tag

像json, gorm都会在结构体的成员变量定义后面用tag标记,比如:

type User struct{
    Name string `name`
    Age int `age`
}

其实也是通过反射获取的

struct有一个专门的tag变量

type Struct struct {
	fields []*Var
	tags   []string // field tags; nil if there are no tags
}

内存管理

内存分配器

https://juejin.cn/post/6844903795739082760

内存块的划分

golang内存主要分为三个区域,如下图所示

在这里插入图片描述

其中arena区就是真正堆区,存放真正的对象,bitmap即位图,标识arena哪些地方存储了对象,以及是否包含指针,gc等信息,

其中spans存储mspan的指针, 而每个mspan包含多个arena的页(8kb),

内存管理单元

golang中最小的内存管理单元结构是, 也就是链表式结构,并不是连续的内存。每个 runtime.mspan 都管理 npages 个大小为 8KB 的页。

type mspan struct {
	next *mspan
	prev *mspan
	spanclass   spanClass
	...
}

每个mspan有一个spanclass变量,标识这个mspan对应的Object大小,当size是0,标识分配的是超过32Kb的大对象。

var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536,1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}

比如mspan spanclass对应的对象大小为32B, 那么标识它可以存储大小为17B-32B的对象。

golang将对象分为了三种,微小对象(小于16byte),小对象(16bytes到32k bytes),大对象(>32KB)

对于不同的对象类型划分了不同的分配策略。

内存分配组件

https://cloud.tencent.com/developer/article/2051585

go的内存组件如下图
在这里插入图片描述

当对象大于32Kb时,会直接从heap堆中直接创建对象。

而当对象小于32Kb时,会从处理器的线程缓存的内存中去创建对象。当小于16b时,会直接用tiny分配器分配(比如一个微对象的mspan还有一些内存空间,这时候再申请创建一个微对象时,如果这个剩余内存还满足就会直接使用,而不会占用新的mspan)。 创建对象时根据对象的大小,选择合适span class的mspan,然后先从mcache即线程缓存中查找是否有合适的msan,没有则从中心缓存(程序全局唯一)中申请对应大小的mspan,如果mcentral中也没有,则想mheap中申请,mheap中没有则从操作系统创建内存。

golang使用类似TCmalloc内存分配策略,尽可能小的避免的内存碎片
golang源码中malloc.go中mallocgc方法就是给对象分配内存,从里面的代码就可以知道,根据对象的大小判断是微小对象,还是小对象,还是大对象,对于小对象先从mcache中获取,没有就从mheap对象里的mcentral去获取,mcentral再没有则到mheap中去分配,大对象直接从heap中去获取

垃圾回收机制

垃圾回收主要有两种算法:

1、引用计数法,即每个对象有一个计数器,对象被引用一次,就+1,当垃圾回收开始时,对于引用计数为0的就可以清楚,优点是设计复杂,而且不需要stop the world, 但是缺点是循环引用的对象无法回收,时间和空间成本高,毕竟每个对象都维护一个计数器,现在貌似只有Object c在使用。

2、可达性分析,即维护一个树形结构,从根开始遍历,不在gc树上的对象就是需要清除的对象。

而使用可达性分析的算法又可以细分为:标记-清理,标记-复制,标记-整理

刚开始都是先根据可达性分析查找并标记需要清除的对象,然后的不同之处在于:

标记-清理后会有许多内存碎片,而标记-复制是将堆空间一分为二,清理后将剩余的对象移动到另一部分,标记-整理就是将清理后的对象移动到内存的另一端。

golang采用三色标记法来进行标记

例如四个对象的依赖关系如下:
A -> B -> C -> D
加入GC程序与用户程序并行,当垃圾回收程序遍历到C时,依赖关系变为:
A->B、D->C
继续标记下去,D对象将会被认为没有被任何对象依赖。
三色标记法

将对象标位三种颜色

  • 白色: 潜在的垃圾,根对象不可达
  • 黑色: 活跃的对象
  • 灰色:对象内存在指向白色对象的指针
内存屏障技术

如果在内存回收时,用户程序有黑色对象里的指针指向了白色对象,那么显然会造成问题,

因此有一种内存屏障技术,在内存屏障之前执行的操作一定会先与内存屏障之后的操作

在垃圾回收器开始标记的时候,就使用内存屏障技术, 插入内存屏障, 大致做法是在垃圾回收期间,如果有对象的引用的改动,比如一个对象对另一个对象有一个引用,那么就会触发内存屏障,将被引用对象置为灰色。

golang使用混合屏障,即当gc正在扫描时,如果用户修改了对象,就会触发内存屏障,将对象置为灰色,加入到单独的扫描队列中

https://zhuanlan.zhihu.com/p/92210761

https://developer.aliyun.com/article/861507

因此在垃圾回收栈的时候,还是需要一定的stw

go垃圾回收

go 1.5之后使用并发收集器,使用三色标记发和内存屏障技术保证垃圾收集器执行的正确性。

golang的gc使用无分代,不整理,并发。

  • 不进行分代,因为大多数新生对象直接放在栈上
  • 不需要整理或者复制,因为golang底层内存时使用链表式
  • 并发,短暂的stop the world后,gc线程可以和用户线程并发
触发时机

1、后台触发,有一个goroutine专门用于垃圾回收,然后一般会自己休眠,会被其他条件唤醒,比如系统监控

2、主动触发

3、申请内存时会触发

抢占式调度

golang中有两种抢占式调度

  • 基于协作的抢占式调度,主要针对发生了系统调用的P和执行了很长时间goroutine的p,对于系统调用,对于网络IO会将p调度其他goroutine,对于阻塞的文件IO,会将p与其他空闲或者新的mthread绑定去处理goroutine; 对于长时间执行的goroutine,会给goroutine加上一个preempt标记,然后编译器在函数调用的开始处进行检查,如果有则让出线程
  • 基于信号的抢占式调度,会先注册信号,当需要抢占式发送信号,线程会让出goroutine,调度别的goroutine
    基于信号的抢占式调度解决的是对于那些没有函数调用的场景,比如for循环里一直i++, 那么就会走不到查看是否被抢占的地方,因此就有了基于信号的抢占式调度

Context

在众多编程语言中,context是比较特殊的,是golang专门为了在多goroutine种实现消息传递、设置截止日期、同步信号而设计的。

goroutine是一个树形结构,即从当前goroutine依次创建不同的goroutine时,最终形成的是一个树形结构,而context就是树根往下传递。

  • context.Background, context.TODO, 两种本质上是一样的,只不过前者表示不需要对上线文传递什么参数,后者表示不确定使用哪种上下文。不过两者都是创建一个emptyCtx
  • context.WithCancel,创建一个可被取消的context,这个方法会拿到父context的Done管道(channel),并且会在cancel context结构体维护一个map,存放child,然后通过select方法从父管道和当前管道取通知,如果父context cancel了或者本context cancel了,那么就会对child进行遍历调用cancel方法再调用close方法关闭管道。 Withcancel不仅返回一个Context结构体,还会返回一个cancel方法,用户可以手动调用cancel取消context,也可以监听父context的cancel事件
  • context.WithTimeout, context.WithDeadline(), 定时取消context,withtimeout原理和withdeadline一样,其实就是加一个定时器,然后其余逻辑跟withcancel类似。
  • context.WithValue,携带参数,其实就是将创建一个新的withValueContext结构体,包含了原有的context,并且有key和value两个字段。其实就是继承的概念。

context.WithCancel, WithTimeout, WithDeadline 本质上都是可以取消的,因此最终都会创建一个cancelCtxt, timeout和deadline只不过是用一个timerCtx包装cancelCtx而已。 cancelCtx中有一个mutext,保证了context的并发安全

拿grpc来说,client发起grpc调用时,如果使用WithTimeout,就会开启一个goroutine来监听done信号,如果收到则就会停止grpc的client端调用

https://segmentfault.com/a/1190000040917752

https://www.cnblogs.com/qcrao-2018/p/11007503.html

golang http

golang底层http封装的非常好,自带连接池
client,go中定义了一个默认的DefaultClient, 当没有自定义初始化client时就是用Default.
Client有两个关键的成员变量,一个是timeout, 一个是Transport
timeout就是超时时间,其中包括了tcp连接的时间。
Transport可以看做是一个连接池。
transport里的变量如下:

idleConn     map[connectMethodKey][]*persistConn // most recently used at end
idleConnWait map[connectMethodKey]wantConnQueue  // waiting getConns
idleLRU      connLRU
MaxIdleConns int
MaxIdleConnsPerHost int
MaxConnsPerHost int
IdleConnTimeout time.Duration

可以看出维护了空闲的连接和等待连接的队列,key是根据addr和http1/2来进行唯一生成的。
还有最大连接数等限制。
大致的流程是: 当发起一个http请求时,最终会调用transport的roundTrip方法发起请求,会从连接池拿空闲的tcp连接,空闲的不够,要是没超出最大连接数,就直接创建,否则就放到wait队列里面(等有空闲连接时,会从等待连接池中取出空闲连接),每次创建连接时会创建两个goroutine, 一个readLoop,一个writeLoop,分别是处理网络连接的读和写,通过channel互相和根据client调用端传入的req进行通信。
通过resp要手动close, 会发出信号,readloop和writeloop停止,如果不close,那么没请求一次,这两个goroutine就没停止。

gin框架

gin框架是golang环境用的比较多的http框架,主要用来接收http请求。

路由是一个树结构

// 很关键
// 路由树节点
type node struct {
	// 路由path
	path      string
	indices   string
	// 子路由节点
	children  []*node
	// 所有的handle 构成一个链
	handlers  HandlersChain
	priority  uint32
	nType     nodeType
	maxParams uint8
	wildChild bool
}

在r.GET(“/hello”, hello)类似的方法中会将路径和handler加到树中,并且将该路由的handler和中间件的handler合并起来放到一个数组中type HandlersChain []HandlerFunc,

接着就是网络相关了,在当前goroutine里监听http端口,然后调用listner.Accept方法监听tcp信息,有请求发过来后,则开启一个goroutine来进行处理,最终具体的处理方法下面的方法中

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {

里面会循环调用handler方法进行处理,然后再处理返回信息

panic

map并发读写,oom recover住没用

go语言高级性能编程

https://geektutu.com/post/hpg-string-concat.html

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值