《golang高级编程》-读书笔记

《Go语言高级编程》

作者 柴树杉 曹春晖

image

1.3 数组、字符串和切片

Go语言拷贝字符串,只是复制了底层字节数组的地址和对应的长度;
字符串的只读属性禁止了在程序中对底层字节数组的元素的修改???行为表

Go语言中数组是值语义,即数组变量被复制或者被传递,实际上会复制整个数组;

Go语言中数组是值语义。一个数组变量即表示整个数组,它并不是隐式地指向第一个元素的指针(例如C语言的数组),而是一个完整的值。当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销。为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组

如何理解数组的长度是数组类型的组成部分?
a := [5]int{1,2,3,4,5},其中[5]int代表a变量的类型;

因为数组的长度是数组类型的组成部分,指向不同长度数组的数组指针类型也是完全不同的。

虽然字符串底层是一个只读的字节数组,但是字符串的长度并不是字符串类型的一部分;
因此不同长度的字符串可以直接复制;

和数组不同的是,字符串的元素不可修改,是一个只读的字节数组。每个字符串的长度虽然也是固定的,但是字符串的长度并不是字符串类型的一部分

Golang源代码要求是UTF8编码,故源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点序列;
如果使用了字符串表示非UTF8数据,则for range并不能支持非UTF8编码的字符串的遍历;

源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列。因为字节序列对应的是只读的字节序列,所以字符串可以包含任意的数据,包括字节值0。我们也可以用字符串表示GBK等非UTF8编码的数据,不过这时候将字符串看作是一个只读的二进制数组更准确,因为for range等语法并不能支持非UTF8编码的字符串的遍历。

字符串其实是一个结构体,字符串的赋值操作也就是uintptr和len的复制过程,并不涉及底层字节数组的复制;

字符串其实是一个结构体,因此字符串的赋值操作也就是reflect.StringHeader结构体的复制过程,并不会涉及底层字节数组的复制

*Go 支持for range 直接遍历UTF8解码后的Unicode码点字,如“hello,世界”直接解码为h e l l o , 世 界 *

如果不想解码UTF8字符串,想直接遍历原始的字节码,可以将字符串强制转为[]byte字节序列后再进行遍历(这里的转换一般不会产生运行时开销):

SliceHeader.Cap——表示切片指向内存空间的最大容量;
注意:cap表示的是对应元素个数,而不是字节数

由此可以看出切片的开头部分和Go字符串是一样的,但是切片多了一个Cap成员表示切片指向的内存空间的最大容量(对应元素的个数,而不是字节数)

var a []int ——nil切片,a == nil,用来表示一个不存在的切片;
var b = []int{} ——空切片,b != nil,用来表示一个空集合;

让我们看看切片有哪些定义方式:

当切片底层数据指针为空时,切片本身才为nil,此时切片的len 和 cap都是无效的;

“只有当切片底层数据指针为空时切片才 == nil”
a []int——此时并没有为a切片分配内存,它对应的底层数据指针为空;
a []int{} ——此时显示为切片分配了内存,虽然len 和cap均为0,但是底层数据指针不为空;
var a = make([]int) ——通过make为a分配了内存,底层数据指针不为空;

切片可以和nil进行比较,只有当切片底层数据指针为空时切片本身才为nil,这时候切片的长度和容量信息将是无效的

*切片头信息:
type reflect.SliceHeader struct {
Data uintptr
Len int
Cap int
}

切片头信息(reflect.SliceHeader)

*切片: append()本质时用于追加元素而不是扩展容量,扩展切片容量只是一个副作用;

  1. 使用append + copy可以在实现中间位置插入切片时减少临时切片的创建;
  2. append(a, 1,2,3)——添加单个或多个元素
  3. append(a, b...)——追加切片*

没有专门的内置函数用于扩展切片的容量,append()本质是用于追加元素而不是扩展容量,扩展切片容量只是append()的一个副作用。

切片的赋值或者值传递,只会复制切片头结构reflect.SliceHeader,而不会复制底层字节数组;

删除开头的元素可以直接移动数据指针:

b := s[:0] ——b是一个长度为0的空切片(len == 0);
但是cap = len(s),因此append(b, x)在删除元素场景中,不会导致内存不足,重新分配底层数组;

切片高效操作的要点是要降低内存分配的次数,尽量保证append()操作不会超出cap的容量,降低触发内存分配的次数和每次分配内存的大小

通过直接移动数据指针,可以删除切片尾部中的元素:a = a[: len(a) -1]
但是,新切片a只是切片头信息Data、len、cap改变了,但是引用的底层字节数组没有变化,即被删除的最后一个元素依然被切片底层数组引用,从而导致不能及时被垃圾回收;
——解决办法:先将需要提前回收内存的指针设置为nil,保证垃圾回收器可以发现需要提前回收的对象,即:a[len(a) - 1] = nil
然后再进行切片删除操作;

保险的方式是先将指向需要提前回收内存的指针设置为nil,保证垃圾回收器可以发现需要回收的对象,然后再进行切片的删除操作:

1.4 函数、方法和接口

匿名函数 -引用外部作用域中的变量——>闭包函数当匿名函数引用了外部作用域中的变量时就成了闭包函数,闭包函数是函数式编程语言的核心

方法——无论是类型本身定义还是继承匿名成员类型的,都可以在编译时按照类型静态绑定;

方法是绑定到一个具体类型的特殊函数,Go语言中的方法是依托于类型的,必须在编译时静态绑定。接口定义了方法的集合,这些方法依托于运行时的接口对象,因此接口对应的方法是在运行时动态绑定的

闭包函数——>编译时静态绑定
接口对象——>运行时绑定?
如何理解?

方法是绑定到一个具体类型的特殊函数,Go语言中的方法是依托于类型的,必须在编译时静态绑定。接口定义了方法的集合,这些方法依托于运行时的接口对象,因此接口对应的方法是在运行时动态绑定的

变参列表——实质上是一个切片

可变数量的参数其实是一个切片类型的参数

Print(a...)——对可变参数进行解包,即将切片元素分别取出

第一个Print调用时传入的参数是a...

匿名函数func() { v++} 捕获了外部函数的局部变量V,并且引用了V,我们称这种匿名函数为闭包;

因为这个匿名函数捕获了外部函数的局部变量v,这种函数我们一般称为闭包

每个匿名函数引用的都是同一个i,所以defer执行匿名函数的结果相同

,每个defer语句延迟执行的函数引用的都是同一个i迭代变量

函数修改调用参数——其实是函数参数中显式或隐式传入了指针参数;

任何可以通过函数参数修改调用参数的情形,都是因为函数参数中显式或隐式传入了指针参数

字符串和切片都是结构体,包含内容:对应结构体内容的首地址以及结构体长度

字符串或切片

内置append()方法返回切片的原因;

指针修改调用参数切片中的

如果被调用函数修改了切片结构体中的len和cap,由于golang函数对参数是按值传递,所以切片的修改信息无法反映到调用方的切片中,此时必须通过返回修改后的切片来更新之前的切片;

除数据之外,切片结构还包含了切片长度和切片容量信息,这两个信息也是传值的。如果被调用函数中修改了Len或Cap信息,就无法反映到调用参数的切片中,这时候我们一般会通过返回修改后的切片来更新之前的切片。这也是内置的append ()必须要返回一个切片的原因。

函数调用栈不占用空间?物理内存没有限制?

Go语言函数的递归调用深度在逻辑上没有限制

Go 1.4之后改用连续的动态栈实现——为了解决热点调用的CPU缓存命中率问题;

Go 1.4之后改用连续的动态栈实现

一句话:不能随意将Go语言中的指针地址保存到数值变量中(因为连续栈需要动态扩展)

当连续栈动态增长时,需要将之前的数据移动到新的内存空间,这会导致之前栈中全部变量的地址发生变化。虽然Go语言运行时会自动更新引用了地址变化的栈变量的指针,但最重要的一点是要明白Go语言中指针不再是固定不变的(因此不能随意将指针保存到数值变量中,Go语言的地址也不能随意保存到不在垃圾回收器控制的环境中,因此使用CGO时不能在C语言中长期持有Go语言对象的地址

划重点:Go语言中,不要假设变量在内存中的位置是固定不变的;

对于有C/C++编程经验的程序员需要强调的是:不用关心Go语言中函数栈和堆的问题,编译器和运行时会帮我们搞定;同样不要假设变量在内存中的位置是固定不变的,指针随时可能会变化

每种类型对应的方法必须和类型的定义在同一个包中;
无法给int这类内置类型添加方法——原因是内置类型的定义在go的源码里包里

每种类型对应的方法必须和类型的定义在同一个包中,因此是无法给int这类内置类型添加方法的

*方法和函数的区别:

  1. 函数:func read(file *FILE, offeset int, data []byte ) int {};
  2. 方法:就是将函数中的类型提前,func (file FIEL) read(offeset int, data []byte) int();
    无论是方法和函数都要求名字唯一,同时它们都不支持重载;

和类型的定义在同一个包

Golang通过在结构体中内置匿名的父类类成员,来实现继承;

Go语言不支持传统面向对象中的继承特性,而是以自己特有的组合方式支持了方法的继承

子类调用继承类的方法时,传递的接受者依然是匿名成员类型,而不是当前子类类型

但是在调用p.Lock()和p.Unlock()时,p并不是方法Lock()和Unlock()的真正接收者,而是会将它们展开为p.Mutex.Lock()和p.Mutex.Unlock()调用。这种展开是编译期完成的,并没有运行时代价。

接口类型定义:Go的接口类型是对其他类型行为的抽象和概括;

Go的接口类型是对其他类型行为的抽象和概括

划重点:Go语言对于基础类型,不支持隐式的转换;

Go语言中,对于基础类型(非接口类型)不支持隐式的转换

1.5 面向并发的内存模型

顺序编程?

  1. 所有指令都是串行执行;
  2. 相同时刻有且仅有一个CPU在顺序执行程序的指令;所有的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令

Go语言的并发是基于消息传递并发编程模型的代表;
Goroutine之间是共享内存的;

Go语言的Goroutine之间是共享内存的

*1. 系统级线程有一个固定大小的栈(默认2M );

  1. Goroutine ——Go语言并发体,以一个很小的栈启动(2 ~ 4KB)*

相反,一个Goroutine会以一个很小的栈启动(可能是2KB或4KB),当遇到深度递归导致当前栈空间不足时,Goroutine会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)

Go运行时对Goroutine采用半抢占式协作调度;
runtime.GOMAXPROCS——控制当前运行正常非阻塞GoRoutine的系统线程数目;

。Goroutine采用的是半抢占式的协作调度,只有在当前Goroutine发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个runtime.GOMAXPROCS变量,用于控制当前运行正常非阻塞Goroutine的系统线程数目

原子操作:最小的且不可并行化的操作

“最小的且不可并行化”的操作

*atomic.Value原子对象提供了Load()和Store()两个原子方法:

  1. 意味着同一时刻只能有一个并发体执行Load()或Store()方法;
  2. 针对同一个原子对象,同一时刻只能Load() 或 Store();*

atomic. Value原子对象提供了Load()和Store()两个原子方法,分别用于加载和保存数据,

如何理解setup线程对done的写入操作甚至无法被main线程看到?

更糟糕的是,因为两个线程之间没有同步事件,setup线程对done的写入操作甚至无法被main线程看到,main()函数有可能陷入死循环中。

如果两个事件不可排序,那么Go语言的编译器和处理器为了最大化并行,可能会对执行语句重新排序;
因此——Go语言只能保证同一个Goroutine线程内部,内存模型是顺序一致性的;

如果两个事件不可排序,那么就说这两个事件是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句重新排序(CPU也会对一些指令进行乱序执行)。

*通道:

  1. 通道上的发送和接收操作在同一个Goroutine上执行很容易导致死锁;
  2. 无缓存通道上的发送操作 总在 对应接收操作完成前发生;*

通道(channel)是在Goroutine之间进行同步的主要方法。在无缓存的通道上的每一次发送操作都有与其对应的接收操作相匹配,发送和接收操作通常发生在不同的Goroutine上(在同一个Goroutine上执行两个操作很容易导致死锁)。无缓存的通道上的发送操作总在对应的接收操作完成前发生。

如何理解main线程通道的发送操作不会被后台线程的接收操作阻塞??

但是,若该通道为带缓存的(例如,done=make(chan bool,1)),main线程的done <- true接收操作将不会被后台线程的<-done接收操作阻塞,该程序将无法保证打印出“hello, world”。

*对于无缓存的Chan,:

  1. go语言保证接收goroutine的操作,发生在发送goroutine的操作完成之前;
  2. 并且接收goroutine的操作完成, 要阻塞至发送操作完成之后;*

所以也就简化为前面无缓存通道的规则:对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前。

1.6 常见的并发模式

Go语言魅力之一:内建的并发支持??goroutineGo语言最吸引人的地方是它内建的并发支持

*并发 vs 并行:

  1. 并发:更关注程序设计层面,多个程序同时执行,但只有在多核CPU上才能真正地同时执行;
  2. 并行:一般是指简单的大量重复,比如GPU对图像处理的大量并行运算;

首先要明确一个概念:并发不是并行。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如,GPU中对图像处理都会有大量的并行运算。为了更好地编写并发程序

*并发编程的一大问题?——对共享资源的正确访问需要精确地控制。。。
方法1:互斥锁+信号量
方法2:通过通道来传值

在并发编程中,对共享资源的正确访问需要精确地控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题

Go语言并发编程哲学:不要通过共享内存来通信,而应通过通信来共享内存。

Go语言将其并发编程哲学化为一句口号:“不要通过共享内存来通信,而应通过通信来共享内存。

*如何理解Go语言内存模型规范:对于无缓存通道进行的接收,发生在对该通道进行的发送完成之前?

  1. 要想通过通道发送出去一个数据,首先要确定有地方接收数据,才能把数据发出去。。*

根据Go语言内存模型规范,对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前。

在此期间,如果接收数据没有完成,那么发送操作一直阻塞在进行中;
如果发送操作完成,那么接收操作肯定已经完成;

后台线程<-done接收操作完成之后,main线程的done<- 1发送操作才可能完成(从而退出main、退出程序),而此时打印工作已经完成了。

*而对于带缓存的通道:

  1. 对于发送操作,不一定会被接收操作阻塞,因为发送的数据可以存到缓存中;
  2. 同样的,如果接收操作完成,那么缓存中肯定被发送过数据,此时发送操作肯定已经开始,发送操作有可能完成了(缓存未满),也有可能未完成(缓存已满)*

对于带缓存的通道,对通道的第K个接收完成操作发生在第K+C个发送操作完成之前,其中C是通道的缓存大小。虽然通道是带缓存的,但是main线程接收完成是在后台线程发送开始但还未完成的时刻,此时打印工作也是已经完成的。

*Publisher调用sendTopic时,有一个select阻塞的动作,直至超时或者sub channel将数据发送出去,如何理解这个发送主题的流程?

  1. 创建publisher时,指定了每个订阅者(chan)能够缓存的数据长度,这样当动态添加订阅者时,其实就是在Publisher对象的map[chan]topicFunc中维护了一个带有缓冲的chan interface{};
  2. 那么订阅者如何过滤Publisher发布的消息呢?——topicFunc,默认为nil(接收全部消息),如果需要过滤消息 v interface{},那么只需要判断topicFunc(v)是否为真 ;
  3. 当Publisher发布某个消息v interface{}时,会遍历订阅者们map[chan]topicFunc,根据2中的过滤规则接收消息,然后将消息发送给订阅者chan interface{};
  4. 如果当前消息的内容没有超过Publisher指定的缓冲区长度,那么每个接收到消息的订阅者可以直接将数据发送给对应的chan interface{};否则剩余的数据发送将被阻塞至订阅着的chan中数据被读取 或者 publisher定时器超时;*

下面的例子中,有两个订阅者分别订阅了全部主题和含有"golang"的主题:

*select的用法:(按照优先级选择case中的通信——通道接收或者发送数据)

  1. 如果存在多个可执行的case 通信,则随机选择一个;
  2. 所有的channel表达式都会被求值,只要一个case被选择,其它都会被忽略;
  3. 如果没有case可执行,执行default;
  4. 否则,一直阻塞当前Goroutine至有通信可执行;*

当有多个通道均可操作时,select会随机选择一个通道。基于该特性我们可以用select实现一个生成随机数序列的程序:

close(chan)——会让所有从关闭通道接收的Goroutine,接收到一个零值,进而达到一个通道向多个Goroutine广播的效果;

其实我们可以通过close()关闭一个通道来实现广播的效果,所有从关闭通道接收的操作均会收到一个零值和一个可选的失败标志。

每个Goroutine退出时需要进行一定的清理工作,需要main线程保证等待各Goroutine退出工作完成,如何实现?
sync.waitGroup

当每个Goroutine收到退出指令退出时一般会进行一定的清理工作,但是退出的清理工作并不能保证被完成,因为main线程并没有等待各个工作Goroutine退出工作完成的机制

1.7 错误和异常

Go语言推荐使用recover()函数将内部异常转换为错误处理;
——Go语言中使用recover()捕获异常?Go语言推荐使用recover()函数将内部异常转为错误处理,这使得用户可以真正地关心业务相关的错误处理。

Go语言不推荐导出的函数抛出异常;

Go语言中的导出函数一般不抛出异常,一个未受控的异常可以看作是程序的bug。

func panic(interface{});
func recover() interface{};

panic()支持抛出任意类型的异常(而不仅是error类型的错误),recover()函数调用的返回值和panic()函数的输入参数类型一致,它们的函数签名如下:

Go函数正常执行结束,执行recover将始终返回nil;
Go函数调用panic()抛出异常时,函数将停止执行后续的普通语句,但是之前住注册的defer()函数调用依然会被执行;
另外,需要注意的是recover()函数捕获的是父级函数帧栈的异常;

必须要和有异常的栈帧只隔一个栈帧,recover()函数才能正常捕获异常。换言之,recover()函数捕获的是祖父一级调用函数栈帧的异常(刚好可以跨越一层defer()函数)!

2.1 快速入门

  1. go程序如何引用C静态库或动态库?
  2. 如果go需要引用C静态库或动态库,则需要将对应C源文件移出当前目录;
    ——CGO构建程序会自动构建当前目录下的C源文件如果是以静态库或动态库方式引用SayHello()函数,需要将对应的C源文件移出当前目录(CGO构建程序会自动构建当前目录下的C源文件,从而导致C函数名冲突)

*Go、C、C++混合编译:

  1. 接口头文件hello.h是hello模块的实现者(C/C++/Go)和使用者(Go)共同的约定;*

接口文件hello.h是hello模块的实现者和使用者共同的约定,但是该约定并没有要求必须使用C语言来实现SayHello()函数。我们也可以用C++语言来重新实现这个C语言函数:

*面向C语言API接口编程规范:

  1. C头文件规定了实现者和调用者对接口的约定;
  2. 这样可以用C、C++、Go语言实现对饮头文件的接口;*

在采用面向C语言API接口编程之后,我们彻底解放了模块实现者的语言枷锁:实现者可以用任何编程语言实现模块,只要最终满足公开的API约定即可。我们可以用C语言实现SayHello()函数,也可以使用更复杂的C++语言来实现SayHello()函数,当然也可以用汇编语言甚至Go语言来重新实现SayHello()函数。

2.2 CGO基础

  1. 何为交叉构建ARM环境运行的Go程序?
  2. Go通过import "C"启动CGO特性;要使用CGO特性,需要安装C/C++构建工具链,在macOS和Linux下需要安装GCC,在Windows下需要安装MinGW工具。同时需要保证环境变量CGO_ENABLED被设置为1,这表示CGO是被启用的状态。在本地构建时CGO默认是启用的,在交叉构建时CGO默认是禁止的。例如要交叉构建ARM环境运行的Go程序,需要手工设置好C/C++交叉构建的工具链,同时开启CGO_ENABLED环境变量。然后通过import "C"语句启用CGO特性。

*CGO的基本用法:

  1. import "C"——表示使用了CGO特性,需要注意import "C"导入语句需要独占一行;
  2. 紧挨着import "C"上面的注释代码,包含的是正常的C语言代码;
  3. Go是强类型语言,所以CGO中传递的参数类型必须与声明的类型完全一致,所以参数传递钱需要同虚拟C包的转换函数转换成对应的C类型;*

这个例子展示了CGO的基本使用方法。开头的注释中写了要调用的C函数和相关的头文件,头文件被include之后里面所有的C语言元素都会被加入"C"这个虚拟的包中。需要注意的是,import "C"导入语句需要单独占一行,不能与其他包一同import。向C函数传递参数也很简单,直接转换成对应的C语言类型传递就可以。例如上例中C.int(v)用于将一个Go中的int类型值强制转换为C语言中的int类型值,然后调用C语言定义的printint()函数进行打印。

*#cgo语句的作用:

  1. 在import "C"语句钱的注释中,通过#cgo语句设置编译阶段和链接阶段的相关参数:
    1.1 #cgo CFLAGS: -I./include ——C头文件检索目录可以是相对路径,但是库文件检索目录需要绝对路径;
    1.2 #cgo CFLAGS ——对应开启C语言特有的编译选项
    1.3 #cgo CXXFALGS——对应开启C++特有的编译选项
    1.4 #cgo CPPFLAGS——对应开启C和C++共有的编译选项
    1.5 #cgo LDFLAGS ——对应开启C、C++的链接选项*

对于在CGO环境混合使用C和C++的用户来说,可能有3种不同的编译选项:CFLAGS对应C语言特有的编译选项,CXXFLAGS对应C++特有的编译选项,CPPFLAGS则对应C和C++共有的编译选项。但是在链接阶段,C和C++的链接选项是通用的,因此这个时候已经不再有C和C++语言的区别,它们的目标文件的类型是相同的。

2.3 类型转换

Go访问C 中的int,使用C.int
C 访问go中的int,使用Goint如果需要在C语言中访问Go语言的int类型,可以通过GoInt类型访问,GoInt类型在CGO工具生成的_cgo_export.h头文件中定义

*unsafe强制类型转换:

  1. unsafe.Pointer可以转换成任意类型指针;
  2. 任意类型指针可以转换为unsafe.Pointer;*

是使用unsafe包强制转换为对应类型

uintptr——一种integer类型,能够存放任意类型的指针;

如果不希望单独分配内存,可以在Go语言中直接访问C语言的内存空间:

C访问Go中的数组和切片:GoString、GoSlice;

在C语言中可以通过GoString和GoSlice来访问Go语言的字符串和切片。如果是Go语言中数组类型

*Go语言是强类型语言:

  1. 两个指针的类型完全一致,可以直接相互替代;
  2. 如果两个指针类型底层是结构完全相同的指针,那么可以通过直接强制转换;*

如果在Go语言中两个指针的类型完全一致,则不需要转换可以直接通用。如果一个指针类型是用type命令在另一个指针类型基础之上构建的,换言之,两个指针底层是结构完全相同的指针,那么我们可以通过直接强制转换语法进行指针间的转换。

2.4 函数调用

通过CGO将Go函数导出为C语言函数,从而实现C调用Go函数:

  1. 通过import "C"后面添加export func注释,说明当前Go包要导出func函数;
  2. 注意对Go来说,func的首字母可以小写,代表该函数是包内私有函数,但是导出的确实一个全局访问的C函数;
  3. Go导出的C func函数与Go函数格式没区别,但是需要使用虚拟C包的类型替换参数和返回类型;CGO还有一个强大的特性:将Go函数导出为C语言函数。这样的话就可以定义好C语言接口,然后通过Go语言实现

*使用CGO导出的C函数:

  1. CGO会生成_cgo_export.h文件包含导出后的C函数声明;
  2. 在纯C文件中,可以通过包含_cgo_export.h头文件来引用导出的C函数;
  3. 然而在当前CGO文件中马上引用_cgo_export.h会导致循环依赖:_cgo_export.h文件的生成需要依赖当前CGO文件才能构建;*

CGO生成的_cgo_export.h文件会包含导出后的C语言函数的声明。我们可以在纯C源文件中包含_cgo_export.h文件来引用导出的add()函数。如果希望在当前的CGO文件中马上使用导出的C语言add()函数,则无法引用_cgo_export.h文件。因为_cgo_export.h文件的生成需要依赖当前文件才可以正常构建,如果当前文件内部循环依赖还未生成的_cgo_export.h文件,将会导致CGO命令错误。

2.7 CGO内存模型

C临时访问传入的Go内存,由于Goroutine上栈空间的自动伸缩,可能会导致C内存越界;
解决办法:

  1. 模仿RPC,在C调用处对Go内存创建同样大小的C内存,然后将Go内存填充到C内存,最后调用;——缺点:多了大量重复拷贝
  2. 另外CGO针对C临时访问Go内存这种场景定义了规则:在调用C语言函数返回前,CGO保证传入的Go语言内存在此期间不会发生移动;
    假设一个极端场景:将一块位于某Goroutine的栈上的Go语言内存传入了C语言函数后,在此C语言函数执行期间,此Goroutine的栈由于空间不足的原因进行了扩展,也就是原来的Go语言内存被移动到了新的位置。但是此时此刻C语言函数并不知道该Go语言内存已经移动了位置,仍然用之前的地址来操作该内存——这将导致内存越界。以上是一个推论(真实情况有些差异),也就是说C访问传入的Go内存可能是不安全的!

CGO 保证在调用C语言函数返回前,传入的Go语言内存在此期间不发生移动;——Golang语言特性??

CGO针对该场景定义了专门的规则:在CGO调用的C语言函数返回前,CGO保证传入的Go语言内存在此期间不会发生移动,C语言函数可以大胆地使用Go语言的内存!

*====使用CGO会出现内存非法访问或内存越界的根本原因:==

  1. Golang非指针类型保持Go对象的地址在GoLang动态伸缩栈内存时,Go语言运行时不会同步更新移动后Go对象的地址;
  2. 上述情况,对C语言环境保持的Go对象地址同样适用;

在非指针类型的tmp保持Go对象的地址和在C语言环境保持Go对象的地址的效果是一样的:如果原始的Go对象内存发生了移动,Go语言运行时并不会同步更新它们。

一般地,Go语言禁止在C语言中长期持有Go指针对象;
如果真是需求存在,则:
可以将Go语言内存对象在Go语言空间映射为一个int类型的ID(实质是通过map存储了{key:go内存对象的索引,value:go内存对象value}),然后通过ID来间接访问和控制Go语言对象;

虽然Go语言禁止在C语言函数中长期持有Go指针对象,但是这种需求是切实存在的。如果需要在C语言中访问Go语言内存对象,可以将Go语言内存对象在Go语言空间映射为一个int类型的ID,然后通过此ID来间接访问和控制Go语言对象。

*1. CGO生成的C语言版本getGoPtr()函数具体细节在CGO生成的_cgo_export.c文件定义;

  1. CGO默认对返回结果的指针进行内存扫描:_cgo_tsan_acquire()——该函数会检查返回结果里是否包含Golang分配的内存*

其中_cgo_tsan_acquire()是从LLVM项目移植过来的内存指针扫描函数,它会检查CGO函数返回的结果是否包含Go指针。

使用Cgo导出的函数返回的内存如果是golang分配的,则go运行时会默认抛出异常:
因为golng可能会在运行期间释放C持有的go内存。

关闭cgocheck功能后再运行上面的代码就不会出现上面的异常。但是要注意的是,如果C语言使用期间对应的内存被Go运行时释放了,将会导致更严重的崩溃。

2.8 C++类包装

为何不直接使用MyBuffer,而是另外封装一层MyBuffer_T?同时和CGO通信时必须通过MyBuffer_T指针,无法将具体的实现暴露给CGO,因为实现中包含了C++特有的语法,CGO无法识别C++特性。

Golang在动态扩缩栈内存时,不会同步更新C环境中持有的指针指向内容;因此不建议通过CGO直接长期访问Go内存对象;

无法在C/C++中直接长期访问Go内存对象

*这里需要注意两种编程范式:

  1. GoLang通过CGO调用C接口,对象一般在C中创建,对应记得释放C内存,Golang可以长期访问C内存对象;
  2. C/C++通过CGO调用golang接口,对象一般在Golang中创建,如果C/C++长期访问Go内存对象,可能会引入两个问题:
    2.1 由于引用部分切片,导致整个go切片长时间无法回收,内存浪费,无法扩栈;——解决办法:golang返回临时切片;、
    2.2 如果golang直接返回内存对象指针,则当栈内存扩缩时,内存对象移动后,C中持有的指针所指向内容不会同步更新,此时会引发C指针引用错误;

因此我们使用前一节所讲述的技术将Go对象映射为一个整数id

Go语言中方法是绑定到类型的;
因此,可以通过对type new old方式,完成在不改变原有数据底层内存结构的前提下对old数据绑定新方法;

这样就可以在不改变原有数据底层内存结构的前提下,自由切换int和Int类型来使用变量。

1. C++无法实现将方法绑定到普通非class类型;
原因:C++构造函数——以失去原有一切特性的代价换取class的施舍;
根本原因是 C++中this被固定为class的指针类型了,无法强制转换为普通类型;

新包装后的Int类型虽然增加了Twice()方法,但是失去了自由转换回int类型的权力。这时候printf不仅无法输出Int类型本身的值,而且失去了int类型运算的所有特性。这就是C++构造函数的失败之处:以失去原有的一切特性的代价换取class的施舍。造成这个问题的根源是C++中this被固定为class的指针类型了。我们重新回顾一下this在Go语言中的本质:

2.9 静态库和动态库

main包中两条CGO指令解释如下:
//#cgo CFLAGS: -I./number
——指定C的编译参数,本例中-I指定头文件检索路径;
//#cgo LDFLAGS:-L{SRCDIR}/number -lnumber ——指定C的链接参数,本例中-L指定静态库检索路径; ——需要注意的是链接部分的检索路径必须为绝对路径,这里必须通过CGO特有的¥{SRCDIR}将当前路径展开为绝对路径;其中有两个#cgo命令,分别是编译和链接参数。CFLAGS通过-I./number将number库对应头文件所在的目录加入头文件检索路径。LDFLAGS通过-L{SRCDIR}/number将编译后number静态库所在目录加入链接库检索路径,-lnumber表示链接libnumber.a静态库。需要注意的是,在链接部分的检索路径不能使用相对路径(C/C++代码的链接程序的限制),必须通过CGO特有的${SRCDIR}变量将源文件对应的当前目录路径展开为绝对路径(因此在Windows平台中绝对路径不能有空格)

*如果go包里包含了静态库的全部代码:

  1. 可以使用go generate 来生成静态库;
  2. 但是对使用方来说,此时无法直接通过go get 安装Go包;
    而是:go get 下载;gogenerate出发静态库构建;go install安装;*

因为我们有number库的全部代码,所以我们可以用gogenerate工具来生成静态库

如何保证Go包引用第三方静态库时,可以动态指定不同操作系统(版本)上编译和链接的路径?
——在CGO命令中直接使用pkg-config来生成编译和链接参数;

如果使用的是第三方的静态库,需要先下载安装静态库到合适的位置。然后在#cgo命令中通过CFLAGS和LDFLAGS来指定头文件和库的位置。对于不同的操作系统甚至同一种操作系统的不同版本,这些库的安装路径可能是不同的,那么如何在代码中指定这些可能变化的参数呢?在Linux环境,有一个pkg-config命令,可以查询要使用某个静态库或动态库时的编译和链接参数

动态库是跨越VC和GCC不同编译器平台的唯一可行方式?
——具体场景?

而且对于Windows等平台,动态库是跨越VC和GCC不同编译器平台的唯一的可行方式。

*运行时搜索动态库的系统路径:

  1. 对Linux,需要设置LD_LIBRARY_PATH环境变量;*

需要注意的是,在运行时需要将动态库放到系统能够找到的位置。对Windows来说,可以将动态库和可执行程序放到同一个目录,或者将动态库所在的目录绝对路径添加到PATH环境变量中。对macOS来说,需要设置DYLD_LIBRARY_PATH环境变量。而对Linux来说,需要设置LD_LIBRARY_PATH环境变量。

*go main包导出C函数:

  1. 对C静态库构建方式来说,会忽略main包中的main()函数;
  2. 构建静态库方式:go build -buildmode=c-archive -o number.a;
  3. 如果在同目录下存在C的测试文件,可以使用下划线作为前缀名,使得go build 构建C静态库时忽略这个文件;*

根据CGO文档的要求,需要在main包中导出C函数。对C静态库构建方式来说,会忽略main包中的main()函数,只是简单导出C函数。采用以下命令构建:

*go build -buildmode=c-archive构建原理:

  1. 遍历main包导入的所有子包,将所有使用CGO导出的符号构建到一个C归档文件中;
  2. 在生成一个libnumber.a文件的同时,CGO还会生成一个number.h文件,但是该文件里只存放了main包中导出的C函数声明;*

这时候在生成main.a静态库的同时,也会生成一个main.h头文件。但是main.h头文件中只有main包中导出的goPrintln()函数的声明,而没有number子包中导出的number_add_ mod()函数的声明。其实number_add_mod()函数在生成的C静态库中是存在的,我们可以直接使用。

3.1 快速入门

Go汇编语言提供了DATA命令用于初始化包变量:
DATA .Id+0(SB)/1, $0x37o汇编语言提供了DATA命令用于初始化包变量,DATA命令的语法如下:

Go汇编语言提供了GLOBL命令用于将符号导出:
GLOBL symbol (SB), width

Go汇编语言提供了GLOBL命令用于将符号导出:

*如何理解Go语言的字符串不是值类型,而是一种只读的引用类型?

  1. 字符串的赋值和参数传递,其实只是对reflect.StringHeader的结构体复制,并不对底层只读字节数组复制;
  2. 如果多个代码中出现了相同的“gopher”只读字符串,程序链接后可以引用同一个符号go.string."gopher";*

因为Go语言的字符串并不是值类型,Go字符串其实是一种只读的引用类型。如果多个代码中出现了相同的"gopher"只读字符串,程序链接后可以引用同一个符号go.string."gopher"

3.2 计算机结构

冯.诺依曼结构:
采用的是一种将程序指令和数据存储在一起的存储结构;
内存 + CPU冯·诺依曼结构也称为普林斯顿结构,采用的是一种将程序指令和数据存储在一起的存储结构。冯·诺伊曼计算机中的指令和数据存储器其实指的是计算机中的内存,然后再配合CPU处理器就组成了一个最简单的计算机了。

*如何理解CPU是由指令和寄存器构成?

  1. 不同架构CPU,寄存器个数和类型不一致;
  2. 不同架构CPU,指令集不一致;指令可以理解为CPU内置的算法,用来处理寄存器和内存;*

CPU由指令和寄存器组成,指令是每个CPU内置的算法,指令处理的对象就是全部的寄存器和内存。我们可以将每个指令看作是CPU内置标准库提供的一个个函数,然后基于这些函数构造更复杂的程序的过程就是用汇编语言编程的过程

3.4 函数

Go函数标识符——TEXT汇编指令,表示该行开始的指令定义在TEXT内存段;
TEXT 函数名(SB)——SB, stack base pointer,函数名(SB)表示函数名符号相对于伪寄存器SB的偏移量;其中TEXT用于定义函数符号,函数名中当前包的路径可以省略。函数的名字后面是(SB),表示是函数名符号相对于伪寄存器SB的偏移量,二者组合在一起最终是绝对地址。作为全局标识符的全局变量和全局函数的名字一般都是基于伪寄存器SB的相对地址。标志部分用于指示函数的一些特殊行为,常见的NOSPLIT主要用于指示叶子函数不进行栈分裂。framesize部分表示函数的局部变量需要多大栈空间,其中包含调用其他函数时准备调用参数的隐式栈空间。最后是可以省略的参数大小,之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。

3.6 再论函数

  1. 叶子函数——不会调用其它函数;
  2. 理论上Go语言的函数是可以任意深度调用的。。。在3.4节中我们已经简单讨论过Go的汇编函数,但是那些主要是叶子函数。叶子函数的最大特点是不会调用其他函数,也就是栈的大小是可以预期的,叶子函数也就可以基本忽略栈溢出的问题(如果已经栈溢出了,那也是上级函数的问题)。如果没有爆栈问题,那么也就不会有栈的分裂问题。如果没有栈的分裂也就不需要移动栈上的指针,也就不会有栈上指针管理的问题。但是现实中Go语言的函数是可以任意深度调用的,永远不用担心爆栈的风险。

*CALL指令:

  1. 将当前的IP指令寄存器的值压入sp中;
  2. 通过JMP指令将要调用函数的地址写入IP寄存器实现跳转;*

CALL指令类似PUSH IP和JMP somefunc两个指令的组合,首先将当前的IP指令寄存器的值压入栈中

g结构体指针——TLS:Thread Local Stack??
g结构体定义:
type g struct {
stack stack
stackguard0 uintptr
stackguard1 uintptr
}

MOVQ (TLS), CX用于加载g结构体指针,然后第二个指令CMPQ SP, 16(CX)用于SP栈指针和g结构体中stackguard0成员比较,如果比较的结果小于0,则跳转到结尾的L_MORE_STK部分。

g.stackguard0——栈扩容

在g结构体中的stackguard0成员是出现爆栈前的警戒线。stackguard0的偏移量是16字节,因此上述代码中的CMPQ SP, 16(AX)表示将当前的真实SP和爆栈警戒线比较,如果超出警戒线则表示需要进行栈扩容,也就是跳转到L_MORE_STK。在L_MORE_STK标号处,先调用runtime·morestack_noctxt进行栈扩容,然后又跳回到函数的开始位置,此时此刻函数的栈已经调整了。然后再进行一次栈大小的检测,如果依然不足则继续扩容,直到栈足够大为止。

runtime.Caller获取当前PC寄存器值,然后根据PC寄存器表示的指令位置获取函数的基本信息,原理:
Go语言编译期记录了函数的开始和结束位置

因为要在运行时获取任意一个地址的位置必然要有一个函数调用,所以我们只需要为函数的开始和结束位置,以及每个函数的调用位置生成地址表格就可以了

如何理解Go语言中的递归函数不用担心爆栈问题,因为栈可以根据需要进行扩容和回收???

Go语言中递归函数的强大之处是不用担心爆栈问题,因为栈可以根据需要进行扩容和回收。

TEXT .sum(SB), $16-16
去掉了NOSPLIT标志,让汇编器自动生成一段栈扩容的代码;

因为sum()函数也需要深度递归调用,所以我们删除了NOSPLIT标志,让汇编器为我们自动生成一段栈扩容的代码:

TEXT .asmFunTwiceClosureBody(SB), NOSPLIT | NEEDCTXT, $0-8
其中,NEEDCTXT标志定义的汇编函数表示需要一个上下文环境,在AMD64环境下通过寄存器DX来传递这个上下文指针,即闭包对象的结构体指针;
通过DX + 8获取闭包对象中捕获的X引用;

最重要的是·asmFunTwiceClosureBody()函数的实现:它有一个NEEDCTXT标志。采用NEEDCTXT标志定义的汇编函数表示需要一个上下文环境,在AMD64环境下通过寄存器DX来传递这个上下文环境指针,也就是对应FunTwiceClosure结构体的指针。函数首先从FunTwiceClosure结构体对象取出之前捕获的X,将X乘以2之后写回内存,最后返回修改之后的X的值。

3.7 汇编语言的威力

Go语言函数中的返回值挪到了函数参数的尾部但是Go语言函数将返回值也通过栈返回,因此Go语言函数可以支持多个返回值。我们可以将Go语言函数看作是没有返回值的C语言函数,同时将Go语言函数中的返回值挪到C语言函数参数的尾部,这样栈不仅用于传入参数也用于返回多个结果。

如何判断CPU支持了哪些高级指令?
——Go语言标准库internal/cpu包提供了CPU是否支持某些高级指令的基本信息。

因此首要的任务是如何判断CPU支持了哪些高级指令。在Go语言标准库的internal/cpu包提供了CPU是否支持某些高级指令的基本信息,但是只有标准库才能引用这个包(因为internal路径的限制)

3.8 例子:Goroutine ID

每个运行的Goroutine结构的g指针保存在当前运行Goroutine的系统线程的局部存储TLS中;根据官方的Go汇编语言文档,每个运行的Goroutine结构的g指针保存在当前运行Goroutine的系统线程的局部存储TLS中

*通过Goroutine对应g结构体中的偏移量来获取goid的值:

  1. 通过运行Goroutine的系统线程的TLS来获取g结构体的指针: MOVQ (TLS)AX
  2. g结构体的指针加上偏移量来获取goid的值:例,Go1.10版本中,goid的偏移量是152字节;*

然后在Go代码中通过goid成员在g结构体中的偏移量来获取goid的值:

gls结构体通过内嵌sync.Mutex类型,继承了Mutex的方法;
因此,gls.Lock()其实等价于 gls.Mutex.Lock();

gls包变量简单包装了map,同时通过sync.Mutex互斥量支持并发访问。

4.4 gRPC入门

gRPC学习待开始~gRPC是谷歌公司基于Protobuf开发的跨语言的开源RPC框架。gRPC基于HTTP/2协议设计,可以基于一个HTTP/2链接提供多个服务,对移动设备更加友好。本节将讲述gRPC的简单用法。

5.1 Web开发简介

Go开源界应用最广泛的路由器——httprouter.在Go开源界应用最广泛的路由器是httprouter,很多开源的路由器框架都是基于httprouter进行一定程度的改造的成果。关于httprouter路由的原理,会在5.2节中进行详细的阐释。

A.8 独占CPU导致其他Goroutine饿死

Goroutine是协作式抢占调度,何为抢占调度?Goroutine是协作式抢占调度,Goroutine本身不会主动放弃CPU:

runtime.Gosched()调度函数???

runtime.Gosched()调度函数:

A.14 内存地址会变化

使用unsafe.Pointer会使变量的地址不受golang监控;

  1. golang定期会对内存变量进行GC,此时并无记录有unsafe.Pointer引用该变量;
  2. 后面通过uintpte再次引用该地址时,程序可能会产生异常;
  3. 同样CGO中也不能保存Go对象地址;当内存发生变化的时候,相关的指针会同步更新,但是非指针类型的uintptr不会做同步更新。同理CGO中也不能保存Go对象地址。

A.15 Goroutine泄漏

context包通过cancel()通知后台goroutine主动退出,可以有效避免goroutine泄露;当main()函数在break跳出循环时,通过调用cancel()来通知后台Goroutine退出,这样就避免了Goroutine的泄漏。


使用 小悦记 导出 | 2022年2月23日

你可能感兴趣的:(《golang高级编程》-读书笔记)