go语言基础

Go 语言的诞生主要基于如下原因:

  • 多核服务器已经成为主流,当前的编程语言对并发的支持不是很好,不能很好地发挥多核 CPU 的威力
  • 程序规模越来越大,编译速度越来越慢,如何快速编译程序是程序的迫切的需求。
  • 现有的编程语言设计越来越复杂,由于历史的包袱,某些特性的实现不怎么优雅,程序员花费了更多的精力来应对编程语法细节而不是问题域。

变量和常量

变量具有如下几个属性:

  • 变量名

  • 变量值

    变量实际指向的是地址里存放的值,变量的值具体怎么解析是由变量的类型来决定的。

  • 变量存储和生命周期

    Go语言提供自动内存管理,通常不需要特别关注变量的生命周期和存放位置。编译器使用栈逃逸技术能够自动为变量分配空间:可能在栈上,也可能在堆上。

  • 类型信息

    类型决定了该变量存储的值怎么解析,以及支持哪些操作和运算,不同类型的变量支持的操作和运算是不一样的。

  • 可见性和作用域

    Go内部使用统一的命名空间对变量进行管理,每个变量都一个唯一的名字,包名是这个名字的前缀。

常量:

常量使用一个名称来绑定一块内存地址,该内存地址中存放的数据类型由定义常量时指定的类型决定,而且内存地址里面存放的内容不可以改变。

rune类型

Go内置两种字符类型:一种是byte的字节类型(byte 是uint的别名),另一种是表示Unicode编码的字符rune。rune在Go内部是int32 类型的别名,占用 4 个字节。Go语言默认的字符编码就是UTF-8类型的,如果需要特殊的编码转换,则使用Unicode/UTF-8标准包。

指针

Go语言支持指针,指针的声明类型为*T,Go同样支持多级指针**T。通过在变量名前加&来获取变量地址。指针特定如下:

  • 在赋值语言中,*T出现在“=” 左边表示指针声明,*T 出现在“=” 右边表示取指针指向的值。

  • 结构体指针访问结构体字段仍然使用“.” 点操作符。

  • Go不支持指针的运算。

    Go由于支持垃圾回收,如果支持指针运算,则会给垃圾回收的实现带来很多不便。

  • 函数中允许返回局部变量地址。

  • Go编译器使用“栈逃逸”机制将这种局部变量的空间分配在堆上。

数组

数组的特点如下:

  • 数组创建完长度就固定了,不可以再追加元素。
  • 数组是值类型的,数组赋值或作为函数参数都是值copy。
  • 数组长度是数组类型的组成部分,[10]int 和 [20]int 表示不同的类型。
  • 可以根据数组创建切片。

切片

Go语言的数组定长性和值拷贝限制了其使用场景,Go提供了另一种数据类型 slice(切片),这是一种变长数组,其数据结构中有指向数组的指针,所以事一种引用类型。

map

map也是一种引用类型。

注意要点:

  • Go内置的map 不是并发安全,并发安全的map可以使用标准包sync中的map。
  • 不要直接修改map value 内的某个元素值,如果要修改map 某个键值,则必须整体赋值。

struct

Go语言里的struct(结构体)有两层含义:第一,struct结构中的类型可以是任意类型;第二,struct的存储空间是连续的,其字段按照声明时的顺序存放(注意字段之间有对齐要求)。

struct有两种形式:一种是struct类型字面量,另一种是使用type声明的自定义struct类型。

控制结构

程序执行从本质上来说就是两种模式:顺序和跳转。

  • 顺序就是按照程序指令在存储器上的存放顺序逐条执行。
  • 跳转就是遇到跳转指令就跳转到某处继续线性执行。

顺序在 Go 里面体现在从 main 函数开始逐条向下执行,就像我们的程序源代码顺序一样;跳转在 Go 里面体现为多个语法糖,包括 goto 语句和函数调用、分支(if、switch、select)、循环等。跳转分两种:一种是无条件跳转,比如函数调用和 goto 语句;一种是有条件的跳转,比如分支和循环。

标签

标签的语法是:

Lable:Statement

goto 语句用于函数的内部的跳转,需要配合标签一起使用,goto语句有以下几个特点:

  • goto 语句只能在函数内跳转
  • goto 语言不能跳过内部变量声明语句,这些变量在 goto 语句的标签语句处又是可见的。
  • goto 语句只能跳转到同级作用域或者上层作用域内,不能跳到内部作用域内。
函数

Go 不是纯函数式的编程语言,但是函数在Go 中是“第一公民”,表现在:

  • 函数式一种类型,函数类型变量可以像其他类型变量一样使用,可以作为函数的参数或返回值,也可以直接调用执行。
  • 函数支持多值返回。
  • 支持闭包。
  • 函数支持可变参数。

基本概念

函数是 Go 程序源代码的基本构造单位,一个函数的定义包括如下几个部分:函数声明关键字 func、函数名、参数列表、返回值列表和函数体。函数名遵循标识符的命名规则,首字母的大小写决定该函数在其他包的可见性:大写其他包可见,小写只有相同的包可以访问;

实参到形参的传递

Go 函数实参到形参的传递永远是值拷贝,有时函数调用后实参指向的值发生了变化,那是因为参数传递的是指针值的拷贝,实参是一个指针变量,传递给形参的是这个指针变量的副本,二者指向同一地址,本质上参数传递仍然是值拷贝。

不定参数

Go 函数支持不定数目的形式参数,不定参数声明使用 param …type 的语法格式。

  • 所有的不定参数类型必须是相同的。
  • 不定参数必须是函数的最后一个参数。
  • 不定参数名在函数体内相当于切片,对切片的操作同样适合对不定参数的操作。
  • 切片可以作为参数传递给不定参数,切片名后要加上“…”。
  • 形参为不定参数的函数和形参为切片的函数类型不相同。

defer

Go 函数里提供defer 关键字,可以注册多个延迟调用,这些调用以先进后出(FILO)的顺序在函数返回前被执行。defer 常用于保证一些资源最终一定能够得到回收和释放。

defer 函数后面必须是函数或方法的调用,不能是语句,否则会报 expression in defer mustbe function call 错误。

defer 函数的实参在注册时通过值拷贝传递进去。

defer 语句必须先注册后才能执行,如果defer 位于 return之后,则defer因为没有注册,不会执行。

defer 的好处是可以在一定程度上避免资源泄露,特别是在很多 return语句,有多个资源需要关闭的场景中,很容易漏掉资源的关闭操作。

defer 语句的位置不当,有可能导致panic,一般defer 语句放在错误检查语句之后。

defer 也有明细的副作用:defer 会推迟资源的释放,defer 尽量不要放到循环语句里面,将大函数内部的 defer 语句单独拆分成一个小函数式是一种很好的实践方法。另外,defer 相对于普通的函数调用需要间接的数据结构的支持,相对于普通函数调用有一定的性能损耗。

defer 中最好不要对有名返回值进行操作,否则会引发匪夷所思的结果。

闭包

闭包是由函数及其相关引用环境组合而成的实体,一般通过在匿名函数中引用外部函数的局部变量或全局变量构成。

  • 闭包 = 函数 + 引用环境

闭包对闭包外的环境引入是直接引用,编译器检测到闭包,会将闭包引用的外部变量分配到堆上。

如果函数返回的闭包引用了该函数的局部变量(参数或函数内部变量):

  • 多次调用该函数,返回的多个闭包所引用的外部变量是多个副本,原因是每次调用函数都会为局部变量分配内存。
  • 用一个闭包函数多次,如果该闭包修改了其引用的外部变量,则每一次调用该闭包对该外部变量都有影响,因为闭包函数共享外部引用。

闭包的价值

闭包最初的目的是减少全局变量,在函数调用的过程中隐式地传递共享变量,有其有用的一面;但是这种隐秘的共享变量的方式带来的坏处是不够直接,不够清晰,除非是非常有价值的地方,一般不建议使用闭包。

对象是附有行为的数据,而闭包是附有数据的行为,类在定义时已经显式地几种定义了行为,但是闭包中的数据没有显式地集中声明的地方,这种数据和行为耦合的模型不是一种推荐的编程模型,闭包仅仅是锦上添花的东西,不是不可缺少的。

panic 和 recover

概念

引发panic 有两种情况,一种是程序主动调用 panic 函数,另一种是程序发生运行时错误,由运行时检测并抛出。

发生panic 后,程序会从调用panic 的函数的位置或发生panic的地方立即返回,逐层向上执行函数的 defer 语句,然后逐层打印函数调用的堆栈,直到被recover 捕获或运行到最外层函数而退出。

panic 不但可以在函数正常流程中抛出,在defer逻辑里面也可以再次调用 panic或抛出panic。defer里面的panic 能够被后续执行的defer捕获。

recover() 用来捕获panic,阻止 panic 继续向上传递。recover() 和defer一起使用,但是recover() 只有在defer后面的函数体内被直接调用才能捕获panic 终止异常,否则返回nil,异常继续向外传递。

使用场景

什么情况下主动调用panic函数抛出 panic?
一般有两种情况:

  1. 程序遇到了无法正常执行下去的错误,主动调用 panic 函数结束程序运行。
  2. 在调试程序时,通过主动调用 panic 实现快速推出,panic打印出的堆栈能够更快地定位错误。

为了保证程序的健壮性,需要主动在程序的分支流程上使用recover() 拦截运行时错误。

Go 提供了两种处理错误的方式,一种借助panic 和 recover的抛出捕获机制,另一种是使用error 错误类型。

错误处理 error

Go 语言内置错误接口类型error。任何类型只要实现Error() string方法,都可以传递error接口类型变量。Go 语言典型的错误处理方式是将error作为函数最后一个返回值。

错误处理的最佳实践:

  • 在多个返回值的函数中,error通常作为最后一个返回值。
  • 如果一个函数返回 error 类型变量,则先用 if 语句处理 error != nil 的异常场景,正常逻辑放到 if 语块的后面,保持代码平坦。
  • defer 语句应该放到 err 判断的后面,不然有可能产生panic。
  • 在错误逐级向上传递的过程中,错误信息应该不断地丰富和完善,而不是简单地抛出下层调用错误。这在错误日志分析时非常有用和友好。

错误和异常

对错误和异常做一个区分。
**广义上的错误:**发生非期望的行为。
**狭义上的错误:**发生非期望的已知行为,这里的已知是指错误的类型是预料并定义好的。
**异常:**发生非期待的未知行为。这里的未知是指错误的类型不在预先定义的范围内。异常又被称为未捕获的错误(untrapped error)。程序在执行时发生未预先定义的错误,程序编译器和运行时都没有及时将其捕获出来。而是由操作系统进行异常处理。

错误分类关系如下图:

Go 是一门类型安全的语言,其运行时不会出现这种编译器和运行时都无法捕获的错误,也就是说,不会出现 untrapped error,所以从这个角度来说,Go 语言不存在所谓的异常,出现的“异常”全是错误。

Go 程序需要处理的这些错误可以分为两类:

  • 一类是运行时错误(runtime erros),此类错误语言的运行时能够捕获,并采取措施——隐式或显式地抛出panic。
  • 一类是程序逻辑错误:程序执行结构不符合预期,但不会引发运行时错误。

对于运行时错误,程序员无法完全避免其发生,只能尽量减少其发生的概率,并在不影响程序主功能的分支流程上“recover” 这些panic,避免因为一个 panic 引发整个程序的崩溃。

Go 对于错误提供了两种错误机制:

  1. 通过函数返回错误类型的值来处理错误。
  2. 通过panic 打印程序调用栈,终止程序执行来处理错误。

所以对错误的处理有两种方法,一种是通过返回一个错误类型值来处理错误,另一种是调用panic抛出错误,退出程序。

Go 是静态类型语言,程序的大部分错误是可以在编译器检测到的,但是有些错误行为需要在运行期才能检测出来。此种错误行为将导致程序异常退出。其表现出的行为就和直接调用panic 一样:打印出函数调用栈信息,并且终止程序执行。

在实际编程汇总,error和panic 的使用应该遵循如下三条原则:

  1. 程序局部代码的执行结果不符合预期,但此种行为不是运行时错误范围内预定义的错误,此种非期望的行为不会导致程序无法提供服务,此类场景应该是用函数返回error类型变量进行错误处理。
  2. 程序执行过程中发生错误,且该种错误是运行时错误范围内预定义的错误,此时 Go 语言默认的隐式处理动作就是调用panic,如果此种panic 发生在程序的分支流程不影响注意功能,则可以在发生panic 的程序分支上游处使用recover 进行捕获,避免引发整个程序的崩溃。
  3. 程序局部代码执行结果不符合预期,此种行为虽然不是运行时错误范围内预定义的错误,但此种非期望的行为会导致程序无法继续提供服务,此类场景在代码中应该主动调用panic,终止程序的执行。

进一步浓缩为两条规则:

  1. 程序发生的错误导致程序不能容错继续执行,此时程序应该主动调用 panic或由运行时抛出 panic。
  2. 程序虽然发生错误,但是程序能够容错继续执行,此时应用使用错误返回值的方式处理错误,或者在可能发生运行时错误的非关键分支上使用recover 捕获panic。

Go 的整个错误处理过程如下图所示

底层实现

基于堆栈式的程序执行模型决定了函数式语言的一个核心元素。分析 Go 函数的内部实现,对理解整个程序的执行模型有很大的好处。

类型系统

类型系统对一门语言来说至关重要,特别是静态编程语言,类型系统能够在编译阶段发现大部分程序错误。类型是高级语言实现抽象编程的基础,学好类型系统对于掌握一门语言来说至关重要。

Go 语言从设计之初就本着“大道至简” 的理念,所以Go 语言的类型系统设计得非常精炼,抛弃了大部分传统面向对象语言的类的概念,取而代之的是结构(struct)。这种简单的设计实际上蕴藏着一种哲学:把语言的特性设计得尽可能正交,相互之间不要关联,对多态的支持交给接口去处理,类型的存储尽量简单、平坦、直接。

命名类型和未命名类型

命名类型(Named Type)

类型可以通过标识符来表示,这种类型称为命名类型。

未命名类型(Unamed Type)
一个类型由预声明类型、关键字和操作符组合而成,这个类型称为未命名类型。未命名类型又称为类型字面量(Type Literal)。

类型相同和类型赋值

类型相同:
Go 是强类型的语言,编译器在编译时会进行严格的类型效验。两个命名类型是否相同,参考如下:

  • 两个命名类型相同的条件是两个类型声明的语句完全相同。
  • 命名类型和未命名类型永远不相同。
  • 两个未命名类型相同的条件是它们的类型声明字面量的结构相同。并且内部元素的类型相同。
  • 通过类型别名语句声明的两个类型相同。

方法表达式

方法表达式相当于提供一种语法将类型方法调用显式地转换为函数调用,接收者(receiver)必须显式地传递进去。

方法集

命名类型方法接收者有两种类型,一个是值类型,另一个是指针类型,这个和函数式一样的,前者的形参是值类型,后者的形参是指针类型。无论接收者是什么类型,方法和函数的实参传递都是值拷贝。如果接收者是值类型,则传递的是值的副本;如果接收者是指针类型,则传递的是指针的副本。

组合和方法集

结构类型(struct)为 Go 提供了强大的类型扩展,主要体现在两个方面:第一,struct可以嵌入任意其他类型的字段;第二,struct 可以嵌套自身的指针类型的字段。这两个特性决定了struct 类型有着强大的表达力,几乎可以表示任意的数据结构。同时,结合结构类型的方法,“数据+方法” 可以灵活地表达程序逻辑。

组合

使用type定义的新类型不会继承原有类型的方法,有个特例就是命名结构类型,命名结构类型可以嵌套其他的命名类型的字段,外层的结构类型是可以调用嵌入字段类型的方法,这种调用即可以是显式的调用,也可以是隐式的调用。这就是Go 的“继承”,准确地说这就是 Go的“组合”。因为Go语言没有继承的语义,结构和字段之间“has a”的关系,而不是“is a”的关系;没有父子的概念,仅仅是整体和局部的概念,所以后续统称这种嵌套的结构和字段的关系为组合。

struct 中组合非常灵活,可以表现为水平的字段扩展,由于struct 可以嵌套其他struct 字段,所以组合也可以分层次扩展。struct 类型中的字段称为“内嵌字段“。

函数类型

函数类型也分两种,一种是函数字面量类型(未命名类型),另一种是函数命名类型。

函数类型的如下意义:

  1. 函数也是一种类型,可以在函数字面量类型的基础上定义一种命名函数类型。
  2. 有名函数和匿名函数的函数签名与命名函数类型的底层类型相同,它们之间可以进行类型转换。
  3. 可以为有名函数类型添加方法,这种为一个函数类型添加方法的技法非常有价值,可以方便地为一个函数增加”拦截“或”过滤“ 等额外功能,这提供了一种装饰设计模式。
  4. 为有名函数类型添加方法,使其与接口打通关系,使用接口的地方可以传递函数类型的变量,这为函数到接口的转换开启了大门。
接口

接口是一个编程规约,也是一组方法签名的集合。Go 的接口是非侵入式的设计,也就说,一个具体类型实现接口不需要在语法上显式地声明,只要具体类型的方法集是接口方法集的超集,就代表类型实现了接口,编译器在编译时会进行方法集的校验。接口是没有具体实现逻辑的,也不能定义字段。

接口初始化

单纯地声明一个接口变量没有任何意义,接口只有被初始化为具体类型时才有意义。接口作为一个胶水层或抽象层,起到抽象和适配的作用。没有初始化的接口变量,其默认值是nil。

接口绑定具体类型的实例的过程称为接口初始化。接口变量支持两种直接初始化方法:

  1. 实例赋值接口

    如果具体类型实例的方法集是某个接口的方法集的超集,则称该具体类型实现了接口,可以将该具体类型的实例直接赋值给接口类型的变量,此时编译器会进行静态的类型检查。

  2. 接口变量赋值接口变量

    已经初始化的接口类型变量 a 直接赋值给另一种接口变量b,要求b的方法集是 a 的方法集的子集。此时 Go 编译器会在编译时进行方法集静态检查。这个过程也是接口初始化的一种方式。

接口的动态类型和静态类型

动态类型

接口绑定的具体实例的类型称为接口的动态类型。接口可以绑定不同类型的实例,所以接口的动态类型是随着其绑定的不同类型实例而发生变化的。

静态类型

接口被定义时,其类型就已经确定,这个类型叫接口的静态类型。接口的静态类型在其定义时就被确定,静态类型的本质特征就是接口的方法签名集合

接口运算

接口是一个抽象的类型,接口像一层胶水,可以灵活地解耦软件的每一个层次,基于接口编程是 Go 语言编程的基本思想。

在编程过程中有时需要确认已经初始化的接口变量指向实例的具体类型是什么,也需要检查运行时的接口类型。

接口优点和使用形式

接口优点

  1. 解耦:复杂系统进行垂直和水平分割是常用的设计手段,在层与层之间使用接口进行抽象和解耦是一种好的编程策略。Go 的非侵入式的接口使层与层之间的代码更加干净,具体类型和实现的接口之间不需要显式声明,增加了接口使用的自由度。
  2. 实现泛型:目前Go 语言还不支持泛型,使用空接口作函数或方法参数能够用再需要泛型的场景中,

接口使用形式

接口类型是”第一公民“,可以用再任何使用变量的地方,使用灵活,方便解耦,主要使用在如下地方:

  1. 作为结构内嵌字段。
  2. 作为函数或方法的形参。
  3. 作为函数或方法的返回值。
  4. 作为其他接口定义的嵌入字段。
并发

并发和并行

并发和并行时两个不同的概念:

  • 并行意味着程序在任意时刻都是同时运行的;

    并行就是在任一粒度的时间内都具备同时执行的能力:最简单的并行就是多机,多台机器并行处理;SMP 表面上看是并行的,但由于是共享内存,以及线程间的同步等,不可能完全做到并行。

  • 并发意味着程序在单位时间内是同时运行的;

    并发是在规定的时间内多个请求都得到执行和处理,强调的是给外界的感觉,实际上内部可能是分时操作的。并发重在避免阻塞,使程序不会因为一个阻塞而停止处理。并发典型的应用场景:分时操作系统就是一种并发设计(忽略多核CPU)。

并行是硬件和操作系统开发者重点考虑的问题,作为应用层的开发,唯一可以选择的就是充分借助操作系统提供的API 和程序语言特性,结合实际需求设计出具有良好并发结构的程序,提升程序的并发处理能力。

goroutine

goroutine有如下特性:

  • go 的执行时非阻塞的,不会等待。
  • go 后面的函数的返回值会被忽略。
  • 调度器不能保证多个 goroutine 的执行顺序。
  • 没有父子 goroutine 的概念,所有的 goroutine 是平等地被调度和执行的。
  • Go 程序在执行时会单独为 main 函数创建一个 goroutine,遇到其他 go 关键字时再去创建其他的 goroutine。
  • Go 没有暴露 goroutine_id 给用户,所以不能在一个 goroutine 里面显式地操作另一个 goroutine,不过runtime 包提供了一些函数访问和设置 goroutine 的相关信息。

func GOMAXPROCS(n int) int 用来设置或查询可以并发执行的 goroutine 数目,n大于1表示设置 GOMAXPROCS 值,否则表示查询当前的 GOMAXPROCS 值。

func Goexit() 是结束当前 goroutine 的运行,Goexit 在结束当前 goroutine 运行之前会调用当前goroutine 已经注册的 defer。Goexit 并不会产生panic,所以该 goroutine defer 里面的 recover 调用都返回nil。

func Gosched() 是放弃当前调度执行计划,将当前goroutine 放到队列中等待下次被调度。

chan

chan 是Go语言里面的一个关键字,是channel 的简写,也就是管道。goroutine 是Go 语言里面的并发执行体,channel是goroutine 之间通信和同步的重要组件。Go 的理念是“不要通过共享内存来通信,而是通过通信来共享内存”。channel是Go 通过通信来共享内存的载体。channel是有类型的,可以简单地把它理解为有类型的管道。

channel 分为无缓冲的管道和有缓冲的管道,Go提供内置函数len和cap,无缓冲的管道的len和cap都是0,有缓冲的管道的len代表没有被读取的元素数,cap代表整个管道的容量。无缓冲的管道可以用于通信,也可以用于两个goroutine 的同步,有缓冲的通道主要用于通信。

操作不同状态的 channel 会引发三种行为:

  1. panic

    向已经关闭的通道写数据会导致panic。最佳实践是由写入者关闭channel,能最大程度地避免向已经关闭的通道写数据而导致的panic。

    重复关闭的channel会导致panic。

  2. 阻塞

    向未初始化的通道写数据或读取数据都会导致当前 goroutine 的永久阻塞。

    向缓存区已满的channel 写入数据会导致goroutine阻塞。

    goroutine 中没有数据,读取该channel会导致goroutine阻塞。

  3. 非阻塞

    读取已经关闭的channel 不会引发阻塞,而是立即返回channel元素类型的零值,可以使用comma,ok 语法判断通道是否已经关闭。

    向有缓冲且没有满的channel 读/写不会引发阻塞。

WaitGroup

WaitGroup 用来等待多个 goroutine 完成,main goroutine 调用 Add 设置需要等待 goroutine 的数目,每一个 goroutine 结束时调用 Done(),Wait() 被main 用来等待所有的 goroutine 完成。

select

select 是类 UNIX 系统提供的一个多路复用系统 API,Go语言借用多路复用的概念,提供了select 关键字,用于多路监听多个channel。当监听的通道没有状态是可读或可写的,select是阻塞的;只要监听的channel中有一个状态是可读或可写的,则select 就不会阻塞,而是进入出来就绪的分支流程。如果监听的通道有多个可读或可写的状态,则select 随机选取一个处理。

扇入(Fan in)和 扇出(Fan out)

所谓扇入是指将多路管道聚合到一条通道中处理,Go 语言最简单的扇入就是使用 select 聚合多条管道服务;所谓扇出是指将一条管道发散到多条管道中处理,在Go 语言里面具体实现就是使用go 关键字启动多个goroutine并发处理。

通知退出机制

读取已经关闭的通道不会引起阻塞,也不导致panic,而是立即返回该channel 存储类型的零值。关闭select 监听的某个channel 能使select 立即感知这种通知,然后进行相应的处理,这就是所谓的退出通知机制(close channel to broadcast)。

下面通过一个随机数生成器的示例来演示退出通知机制,下游消费者不需要随机数时,显示地通知生产者停止生产。

package     
import ( ” fmt ”
” math/rand" ”runtime”
                          
func GenerateintA(done chan struct{}) chan int {
	ch : = make(chan int) 
	go func() {
		Lable :
			for {
				select {
					case ch<- rand.Int() :
					//增加一路监听,就是对退出通知信号 doen 的监听
                	case <-done:
						break Lable
					}
				}
				//收到通知后关闭通道 ch
				close(ch)
		}()
		return ch
}

func main() {
	done : = make(chan struct{})
	ch := GenerateintA(done)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	
	//发送通知,告知生产者停止生产
	close(done)
	fmt.Println(<-ch)
	fmt.Println(<-ch)
	//此时生成者已经退出
	println("NumGoroutine=", runtime.NumGoroutine())

}

context标准库

Go 中的goroutine 之间没有父与子的关系,也就没有所谓子进程退出后的通知机制,多个goroutine 都是平行地被调度,多个goroutine 如何协作工作涉及通信、同步、通知和退出四个方面。

通信:channel 当然是goroutine之间通信的基础,这里的通信主要是指程序的数据通道。

同步:不带缓冲的 channel 提供了一个天然的同步等待机制;当然sync.WaitGroup 也为多个goroutine 协同工作提供一种同步等待机制。

通知:这个通知和通信的数据不一样,通知通常不是业务数据,而是管理、控制流数据。要处理这个也好办,在输入端绑定两个channel,一个用于业务流数据,另一个用于异常通知数据,然后通过select 收敛进行处理。这个方案可以解决简单的问题,但不是一个通用的解决方案。

退出:goroutine 之间没有父子关系,如何通知goroutine 退出?可以通过增加一个单独的channel,借助goroutine和select 的广播机制(close channel to broadcase)实现退出。

Go 语言在语法上处理某个 goroutine 退出机制很简单。但是遇到复杂的并发结构处理起来就显得力不从心。实际编程中goroutine 会拉起新的goroutine,新的goroutine 又会拉起另一个新的goroutine,最终形成一个树状的结构,由于 goroutine 里并没有父子的概念,这个树状的结构只是程序员头脑想象出来的,程序的执行模型并没有维护这么一个树状结构。怎么通知这个树状上的所有goroutine 退出。仅靠语法层面的支持显然比较难处理。在Go1.7 之后提供了一个标准库context 来解决这个问题。context提供两种功能:退出通知和元数据传递。context库设计的目的就是跟踪goroutine 调用,在其内部维护一个调用数,并在这些调用数中传递通知和元数据。

context 的设计目的

context库设计的目的就是跟踪goroutine 调用,在其内部维护一个调用数,并在这些调用数中传递通知和元数据。两个目的:

  • 退出通知机制——通知可以传递给整个goroutine 调用树上的每一个goroutine。
  • 传递数据——数据可以传递给整个goroutine 调用树上的每一个goroutine。

基本数据结构

context包的整体工作机制:第一个创建Context的goroutine 被称为root节点。root节点负责创建一个实现context接口的具体对象,并将该对象作为参数传递到其新拉起的goroutine,下游的goroutine 可以继续封装该对象,再传递到更下游的goroutine。context对象在传递的过程中最终形成一个树状的数据结构,这样通过位于root节点(树的根节点)的context就能遍历整个context对象树,通知和消息就可以通过root节点传递出去,实现了上游 goroutine 对下游goroutine 的消息传递。

Context 接口

Context 是一个基本接口,所有的Context 对象都要实现该接口,context的使用者在调用接口中都使用Context 作为参数类型。

canceler 接口

canceler 接口是一个扩展接口,规定了取消通知的 Context 具体类型需要实现的接口。

empty Context 结构

emptyCtx 实现了 Context 接口,但不具备任何功能,因为其所有的方法都是空实现。其存在的目的是作为Context 对象树的根(root节点)。因为context包的使用思路就是不停地调用context包提供的包装函数来创建具有特殊功能的Context实例,每一个 Context 实例的创建都以上一个 Context 对象为参数,最终形成一个树状的结构。

cancelCtx

cancelCtx 是一个实现了Context 接口的具体类型,同时实现了 conceler 接口。conceler具有退出通知方法。注意退出通知机制不但能通知自己,也能逐层通知器children节点。

timerCtx

timerCtx 是一个实现了 Context 接口的具体类型,内部封装了 cancelCtx类型实例,同时有一个 deadline 变量,用来实现定时退出通知。

vauleCtx

valueCtx 是一个实现了Context 接口的具体类型,内部封装了 Context 接口类型,同时封装了一个 k/v 的存储变量。valueCtx可用来传递通知消息。

API函数

下面这两个函数式构造 Context 取消树的根节点对象,根节点对象用作后续 With 包封装函数的实参。

  • Background
  • TODO

With 包装函数用来构建不同功能的 Context 具体对象。

  • 创建一个带有退出通知的 Context 具体对象,内部创建一个 cancelCtx的类型实例。
  • 创建一个带有超时通知的 Context 具体对象,内部创建一个timerCtx 的类型实例。
  • 创建一个带有超时通知的 Context 具体对象,内部创建一个timerCtx 的类型实例。
  • 创建一个能够传递数据的 Context 具体对象,内部创建一个valueCtx 的类型实例。

这些函数都一个共同的特定——parent参数,其实这就是实现 Context 通知树的必备条件。在 goroutine 的调用链中,Context 的实例被主从包装并传递,每层又可以对传进来的Context 实例封装自己所需的功能,整个调用数需要一个数据结构来维护,这个维护逻辑在这些包装函数内部实现。

辅助函数

With 开头的构造函数是给外部程序使用的 API 接口函数、Context 具体对象的链条关系是在 With 函数的内部维护的。现在分析一下 With函数内部使用的通用函数。
func propagateCancel(parent Context , child canceler) 有如下几个功能:

  1. 判断parent的方法 Done() 返回值是否是nil,如果是,则说明parent 不是一个可取消的 Context 对象,也就无所谓取消构造树,说明child 就是取消构造树的根。
  2. 如果parent 的方法 Done() 返回值不是nil,则向上回溯自己的祖先是否是cancelCtx 类型实例,如果是,则将child 的子节点注册维护到那颗关系树里面。
  3. 如果向上回溯自己的祖先都不是 cancelCtx 类型实例,则说明整个链条的取消树是不连续的。此时只需监听parent 和自己的取消信号即可。

使用 context 传递数据的争议

该不该使用context 传递数据

首先要清除使用context包主要是解决goroutine 的通知退出,传递数据是其一个额外功能。可以使用它传递一些元信息,总之使用context 传递的信息不能影响正常的业务流程,程序不要期待在context中传递一些必需的参数等,没有这些参数,程序也应该正常运行。

在context中传递数据的坏处

  1. 传递的都是 interface{} 类型的值,编译器不能进行严格的类型效验。
  2. 从 interface{} 到具体类型需要使用类型断言和接口查询,有一定运行期开销和性能损失。
  3. 值在传递过程中有可能被后续的服务覆盖,且不易被发现。
  4. 传递信息不简明,较晦涩;不能通过代码或文档直目了然的看清传递的是什么,不利于后续维护。

context 应该传递什么数据

  1. 日志信息
  2. 调试信息
  3. 不影响业务主逻辑的可选数据

context包提供的核心功能是多个goroutine 之间的退出通知机制,传递数据只是一个辅助功能,应谨慎使用 context 传递数据。

并发模型

CSP 简介

CSP 是用来描述并发系统消息通信模型并验证其正确性。其最基本的思想是:将并发系统抽象为Channel 和 Process 两部分,Channel 用来传递消息,Poocess 用于执行,Channel 和Process 之间相互独立,没有从属关系,消息的发送和接收有严格的时序限制。Go 语言主要借鉴了 Channel 和 Process的概念,在Go中 Channel 就是通道,Process 就是goroutine。

调度模型

CPU 执行指令的速度是非常快的。CPU 慢在对外部数据的读/写上。外部I/O 的速度慢和阻塞是导致 CPU 使用效率不高的最大原因。在大部分真是系统中,CPU 都不是瓶颈,CPU 的大部分时间被白白浪费了,增加CPU的有效吞吐量是工程师的重要目标。

所谓增加CPU 的有效吞吐量,就是让CPU 尽量多干活,而不是空跑或等待。理想状态是机器的每个 CPU 核心都有事情做,而且尽可能快地做事情,这里有两层含义:

  1. 尽可能让每个CPU 核心都有事情做。

    这就要求工作的线程要大于CPU 的核心数,单进程的程序最多使用一个CPU 干活,是没有办法有效利用机器资源的。由于CPU 要和外部设备通信,单个线程经常会被阻塞,包括 I/O 等待、缺页中断、等待网络等。所以CPU 和线程的比例是1:1,大部分情况下也不能充分发挥 CPU 的威力。实际上依据程序的特性(CPU 密集型还是I/O 密集型),合理调整CPU 和线程的关系,一般情况下,线程数要大于CPU 的个数,才能发挥机器的价值。

  2. 尽可能提高每个CPU 核心做事情的效率

    现代操作系统虽然能够进行并行调度,但是当进程数大于CPU 核心的时候,就存在进程切换的问题。这个切换需要保持上下行,恢复堆栈。频繁地切换也很耗时,我们的目标是尽量让程序减少阻塞和切换,尽量让进程跑满操作系统分配的时间片(分时系统)。

上面是从整个系统的角度来看程序的运行效率问题,具体到应用程序又有所不同。应用程序的并发模型是多样的,总结一下有三种。

  • 多进程模型

    进程都能被多核CPU 并发调度,优点是每个进程都有自己独立的内存空间,隔离性好、健壮性高;缺点是进程比较重,进程的切换消耗较大,进程间的通信需要多次在内核区和用户去之间复制数据。

  • 多线程模型

    这里的多线程是指启动多个内核线程进行处理,线程的优点是通过共享内存进行通信更快捷,切换代价小;缺点是多个线程共享内存空间,极易导致数据访问混乱,某个线程误操作内存可能危及整个线程组,健壮性不高。

  • 用户级多线程模型

    用户级多线程又分为两种情况,一种是M : 1 的方式,M个用户线程对应一个内核进程,这种情况很容易因为一个系统阻塞,其他用户线程都会被阻塞,不能利用机器多核的优势。还有一种模式就是 M : N 的方式,M个用户线程对应 N 个内核线程,这种模式一般需要语言运行时或库的支持,效率最高。

程序并发处理的要求越来越高,但是不能无限制地增加系统线程数,线程数过多会导致操作系统的调度开销变大,单个线程的单位时间内被分配的运行时间片减少,单个线程的运行速度降低,单靠增加系统线程不能满足要求。为了不让系统线程无限膨胀,于是就有了协程的概念。

协程是一种用户太的轻量级线程,协程的调度完全由用户态程序控制,协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,每个内核线程可以对应多个用户协程,当一个协程执行体阻塞了,调度器会调度另一个协程执行,最大效率地利用操作系统分给系统线程的时间片。用户多级线程模型就是一种协程模型,尤其以 M :N 模型最为高效。

这样的好处显而易见:

  1. 控制了系统线程数,保证每个线程的运行时间片充足。
  2. 调度层能进行用户态的切换,不会导致单个协程阻塞整个程序的情况,尽量减少上下文切换,提升运行效率。

由此可见,协程是一种非常高效、理想的执行模型。Go 的并发执行模型就是一种变种的协程模型。

并发和调度

Go 在语言层面引入 goroutine,有以下好处:

  1. goroutine 可以在用户空间调度,避免了内核态和用户态的切换导致的成本。
  2. goroutine 是语言原生支持的,提供了非常简洁的语法,屏蔽了大部分复杂底层实现。
  3. goroutine 更小的栈空间允许用户创建成千上万的实例。

Go 的调度模型中抽象出三个实体:G、P、M。

G(Goroutine)

G 是 Go运行时对goroutine 的抽象描述,G 中存放并发执行的代码入库地址、上下文、运行环境(关联的 P 和 M)、运行栈等执行相关的元信息。

G 的新建、休眠、恢复、停止都受到 Go 运行时的管理。Go 运行时的监控线程会监控 G 的调度,G 不会长久地阻塞系统线程,运行时的调度器会自动切换到其他G 上继续运行。G 新建或恢复时会添加到运行队列,等待 M 取出并运行。

M(Machine)

M 代表 OS 内核线程,是操作系统层面调度和执行的实体。M 仅负责执行,M 不停地被唤醒或创建,然后执行。M 启动时进入的是运行时的管理代码,由这段代码获取 G 和 P 资源,然后执行调度。另外,Go语言运行时会单独创建一个监控线程,负责对程序的内存、调度等信息进行监控和控制。

P (Processor)

P 代表 M 运行 G 所需要的资源,是对资源的一种抽象和管理,P 不是一段代码实体,而是一个管理的数据结构,P 主要是降低 M 管理调度 G 的复杂性,增加一个间接的控制层数据结构。把 P 看作是资源,而不是处理器,P 控制 Go 代码的并行度,它不是运行实体。P 持有 G 的队列,P 可以隔离调度,解除 P 和 M 的绑定就解除了 M 对一串 G 的调用。P 在运行模型中只是一个数据模型,而不是程序控制模型,理解这一点非常重要。

M 和 P 一起构成一个运行时环境,每个 P 有一个本地的可调度 G 队列,队列里面的 G 会被 M 依次调度执行,如何本地队列空了,则回去全局队列偷取一部分G,如果全局队列也是空的,则去其他的P 偷取一部分G,这就是 Work Stealing 算法的基本原理。调度结构如下图所示。

G 并不是执行体,而是用于存放并发执行体的元信息,包括并发执行的入口函数、堆栈、上下文等信息。G 由于保存的是元信息,为了减少对象的分配和回收,G对象是可以复用的,只需将相关元信息初始化为新值即可。M 仅负责执行,M 启动时进入运行时的管理代码,这段管理代码必须拿到可用的 P 后,才能执行调度。P 的数目默认是 CPU 核心的数量,可以通过sync.GOMAXPROCS 函数设置或查询,M 和 P的数目差不多,但运行时会根据当前的状态动态地创建M,M 有一个最大值上限,目前是10000;G 与 P 是一种 M :N的关系,M 可以成千上万,远远大于N。

m0 和 g0

Go 中还有特殊的 M 和 G,它们是 m0 和 g0。m0 是启动程序后的主线程,这个m 对应的信息会存放在全局变量 m0 中,m0 负责执行初始化操作和启动第一个g,之后 m0 就和其他的 M 一样了。

每个 M 都会有一个自己的管理堆栈 g0,g0 不指向任何可执行的函数,g0 仅在 M 执行管理和调度逻辑时使用。在调度或系统调用时会切换到 g0的栈空间,全局变量的 g0 是m0 的 g0。

Go 启动初始化过程

  • 分配和检查栈空间
  • 初始化参数和环境变量
  • 当前运行线程标记为m0,m0 是程序启动的主线程
  • 调用运行时初始化函数 runtime.schedinit 进行初始化
    主要是初始化内存空间分配器、GC、生成空闲 P 列表
  • 在m0 上调度第一个G,这个G 运行runtime.main 函数

runtime.main 会拉起运行时的监控线程,然后调用main 包的 init() 初始化函数,最后执行 main 函数。

什么时候创建 M、P、G

在程序启动过程中会初始化空闲 P 列表,P 是在这个时候被创建的,同时第一个 G 也是在初始化过程中被创建的。后续有 go 并发调用的地方都有可能创建 G,由于 G 只有一个数据结构,并不是执行体,所以 G 是可以被复用的。在需要 G 结构时,首先要去 P 的空闲 G 列表里找到已经运行结束的goroutine,其G 会被缓存起来。

每个并发调用都会初始化一个新的 G 任务,然后唤醒 M 执行任务。这个唤醒不是特定唤醒某个线程去工作,而是先尝试获取当前线程M,如果无法获取,则从全局调度的空闲 M 列表中获取可用的 M,如果没有可用的,则新建M,然后绑定 P 和 G进行运行。所以 M 和 P 不是一一对应的,M 是按需分配的,但是运行时会设置一个上限值(默认是10000),超出最大值将导致程序崩溃。

注意:创建新的 M 有一个自己的栈 g0,在没有执行并发程序的过程中,M 一直是在g0 栈上工作的。M 一定要拿到 P 才能执行,G、M 和 P 维护着绑定关系,M 在自己的堆栈 g0 上运行恢复 G 上下文的逻辑。完成初始化后,M 从g0 栈切换到 G 的栈,并跳转到并发程序代码点开始执行。

M 线程里有管理调度和切换堆栈的逻辑,但是 M 必须拿到 P 后才能运行,可以看到 M 是自驱动的,但是需要 P 的配合。这是一个巧妙的设计。

抢占调度

抢占调度的原因:

  1. 不让某个 G 长久地被系统调用阻塞,阻碍其他 G 运行
  2. 不让某个 G 一直占用某个 M 不释放
  3. 避免全局队列里面的 G 得不到执行

抢占调度的策略:

  1. 进入系统调用(syscall)前后,各封装一层代码检测 G 的状态,当检测到当前 G 已经被监控线程抢占调度,则 M 停止执行当前 G,进行调度切换。
  2. 监控线程经过一段时间检测感知到 P 运行超过一定时间,取消 P 和 M 的关联,这也是一种更高层次的调度。
  3. 监控线程经过一段时间检测感知到 G 一直运行,超过一定的时间,设置 G 标记, G 执行栈扩展逻辑检测到抢占标准,根据相关条件决定是否抢占调度。

Go 程序运行时是比较复杂的,涉及内存分配、垃圾回收、goroutine 调度和通信管理等诸多方面。整个运行时的初始化过程也很烦琐、复杂。

反射

反射就是程序能够在运行时动态地查看自己的状态,并且允许修改自身的行为。

基本概念

Go 的反射巧妙地借助了实例到接口的转换所使用的数据结构,首先将实例传递给内部的空接口,实际上是将一个实例类型转换为接口可以表述的数据结构 eface,反射基于这个转换后的数据结构来访问和操作实例的值和类型。

基本数据结构和入库函数

reflect.Type

其实反射包里有一个通用的描述类型公共信息的结构 rtype。

reflect.Value

relfect.Value 总共有三个字段,一个是值的类型指针 typ,另一个是指向值的指针ptr,最后一个是标记字段flag。

反射三定律

  • 反射可以从接口值得到反射对象。
  • 反射可以从反射对象获得接口值。
  • 若要修改一个反射对象,则其值必须可以修改。

inject库

inject是什么

inject 借助了反射提供了2种类型实体的注入:函数和结构。

依赖注入和控制反转

正常情况下,对函数或方法的调用是调用方的主动直接行为,调用方清楚地知道被调的函数名是什么,参数有哪些类型,直接主动地调用;包括对象的初始化也是显式地直接初始化。所谓的控制反转就是将这种主动行为变成间接的行为,主调方不是直接调用函数或对象,而是借助框架代码进行间接的调用和初始化,这种行为被称为控制反转,控制反转可以解耦调用方和被调方。

库和框架能很好地解释控制反转的概念。一般情况下,使用库的程序是程序主动调用库的功能,但使用框架的程序常常由框架驱动整个程序,在框架下写的业务代码是被框架驱动的,这种模式就是控制转账。

依赖注入是实现控制反转的一种方法,如果说控制反转是一种设计思想,那么依赖注入就是这种思想的一种实现,通过注入的参数或实例的方式实现控制反转。如果没有特殊说明,我们通常说的依赖注入和控制反转是一个东西。

inject实践

inject 是Go语言依赖注入的实现,它实现了对struct和函数的依赖注入。

inject 提供了一种注入参数调用函数的通用的功能,inject.New() 相当于创建了一个控制实例,由其来实现对函数的注入调用。inject 包不但提供了对函数的注入,还实现了对struct 类型的注入。

反射的优缺点

反射的优点

  1. 通用性

    特别是一些类库和框架代码需要一种通用的处理模式,而不是针对每一种场景做硬编码处理,此时借助反射可以极大简化设计。

  2. 灵活性

    反射提供了一种程序了解自己和改变自己的能力,这为一些测试工具的开发提供了有力的支持。

反射的缺点

  1. 反射是脆弱的

    由于反射可以在程序运行时修改程序的状态,这种修改没有经过编译器的严格检查,不正确的修改很容易导致程序的崩溃。

  2. 反射是晦涩难懂的

    语言的反射接口由于涉及语言的运行时,没有具体的类型系统的约束,接口的抽象级别高但实现细节复杂,导致使用反射的代码难以理解。

  3. 反射有部分性能损失

    反射提供动态修改程序状态的能力,必然不是直接的地址引用,而是要借助运行时构造一个抽象层,这种间接访问会有性能损失。

反射的最佳实践

  1. 在库或框架内部使用反射,而不是把反射的接口暴露给调用者,复杂性留在内部,简单放到接口。
  2. 框架代码才考虑使用反射,一般的业务代码没有必要抽象到反射的层次,这种过渡设计会带来复杂度的提升,使得代码难以维护。
  3. 除非没有其他办法,否则不要使用反射技术。