核心篇主要涵盖接口类型语法与 Go 原生提供的三个并发原语(Goroutine、channel 与 select)
接口类型是由 type 和 interface 关键字定义的一组方法集合,其中,方法集合唯一确定了这个接口类型所表示的接口.
type MyInterface interface {
M1(int) error
M2(io.Writer, ...string)
}
Go 规定:如果一个类型 T 的方法集合是某接口类型 I 的方法集合的等价集合或超集,我们就说类型 T 实现了接口类型 I,那么类型 T 的变量就可以作为合法的右值赋值给接口类型 I 的变量。
如果一个变量的类型是空接口类型,由于空接口类型的方法集合为空,这就意味着任何类型都实现了空接口的方法集合,所以我们可以将任何类型的值作为右值,赋值给空接口类型的变量.
Go 语言还支持接口类型变量赋值的“逆操作”,也就是通过接口类型变量“还原”它的右值的类型与值信息,这个过程被称为“类型断言(Type Assertion)”v, ok := i.(T)
其中 i 是某一个接口类型变量,如果 T 是一个非接口类型且 T 是想要还原的类型,那么这句代码的含义就是断言 存储在接口类型变量 i 中的值的类型为 T
Go 语言中接口类型与它的实现者之间的关系是隐式的,不需要像其他语言(比如 Java)那样要求实现者显式放置“implements”进行修饰,实现者只需要实现接口方法集合中的全部方法便算是遵守了契约,并立即生效了。
在代码上就是尽量定义小接口,即方法个数在1~3 个之间的接口。
小接口有哪些优势?
第一点:接口越小,抽象程度越高。
计算机程序本身就是对真实世界的抽象与再建构。抽象就是对同类事物去除它具体的、次要的方面,抽取它相同的、主要的方面。不同的抽象程度,会导致抽象出的概念对应的事物的集合不同。抽象程度越高,对应的集合空间就越大;抽象程度越低,也就是越具像化,更接近事物真实面貌,对应的集合空间越小。
第二点:小接口易于实现和测试
小接口拥有比较少的方法,一般情况下只有一个方法。所以要想满足这一接口,我们只需要实现一个方法或者少数几个方法就可以了。
第三点:小接口表示的“契约”职责单一,易于复用组合
接口:为什么nil接口不等于nil
未赋初值的接口类型变量的值为 nil,这类变量也就是 nil 接口变量。
对于空接口类型变量,只有 _type 和 data 所指数据内容一致的情况下,两个空接口类型变量之间才能划等号。
在 Go 语言中,将任意类型赋值给一个接口类型变量也是装箱操作。
只有两个接口类型变量的类型信息(eface._type/iface.tab._type)相同,且数据指针(eface.data/iface.data)所指数据相同时,两个接口类型变量才是相等的。
接口本质上是一种抽象,它的功能是解耦,所以这条原则也在告诉我们:不要为了使用接口而使用接口。
组合
如果把 Go 应用程序比作是一台机器的话,那么组合关注的就是如何将散落在各个包中的“零件”关联并组装到一起。我们前面也说过,组合是 Go 语言的重要设计哲学之一。
方法和类型是正交的,每种类型都可以拥有自己的方法集合,方法本质上只是一个将receiver参数作为第一个参数的函数而已;
一、垂直组合:
- 第一种:通过嵌入接口构建接口,通过在接口定义中嵌入其他接口类型,实现接口行为聚合,组成大接口.
- 第二种:通过嵌入接口构建结构体类型.
- 第三种:通过嵌入结构体类型构建新结构体类型.
二、水平组合:
通过接口进行水平组合的基本模式就是:使用接受接口类型参数的函数或方法。
创建模式:
“接受接口,返回结构体(Accept interfaces, return structs)”,我这里把它叫做创建模
式,是因为这个经验法则多用于创建某一结构体类型的实例。
包装器模式:
当返回值的类型与参数类型相同时,我们可以实现对输入参数的类型的包装,并在不改变被包装类型(输入参数类型)的定义的情况下,返回具备新功能特性的、实现相同接口类型的新类型。这种接口应用模式我们叫它包装器模式,也叫装饰器模式;包装器多用于对输入数据的过滤、变换等操作。
由于包装器模式下的包装函数(如上面的 LimitReader)的返回值类型与参数类型相同,因此我们可以将多个接受同一接口类型参数的包装函数组合成一条链来调用。
适配器模式
适配器模式的核心是适配器函数类型(Adapter Function Type)。适配器函数类型是一个辅助水平组合实现的“工具”类型。这里我要再强调一下,它是一个类型。它可以将一个满足特定函数签名的普通函数,显式转换成自身类型的实例,转换后的实例同时也是某个接口类型的实现者。
(中间件就是包装模式和适配器模式结合的产物。)
三、Go 并发
"Go 并发" 这个词拆开来看,它包含两方面内容,一个是并发的概念,另一个是 Go 针对并发设计给出的自身的实现方案,也就是 goroutine、channel、select 这些 Go 并发的语法特性。
Go 实现了goroutine这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
并发是一种能力,它让你的程序可以由若干个代码片段组合而成,并且每个片段都是独立运行的。goroutine 恰恰就是 Go 原生支持并发的一个具体实现。无论是 Go 自身运行时代码还是用户层 Go 代码,都无一例外地运行在 goroutine 中。
goroutine 的执行函数的返回,就意味着 goroutine 退出.
如果 main goroutine 退出了,那么也意味着整个应用程序的退出。如果你要获取goroutine 执行后的返回值,你需要另行考虑其他方法,比如通过 goroutine 间的通信来实现.
调度:
将这些 Goroutine按照一定算法放到“CPU”上执行的程序,就被称为 Goroutine 调度器(Goroutine Scheduler)。Goroutine调度器的任务也就明确了:将 Goroutine 按照一定算法放到不同的操作系统线程中去执行.
调度原理:
P 是一个“逻辑 Proccessor”,每个 G(Goroutine)要想真正运行起来,首先需要被分配一个 P,也就是进入到 P 的本地运行队列(local runq)中。对于 G 来说,P 就是运行它的“CPU”,可以说:在 G 的眼里只有 P。但从 Go 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。
Go 运行时已经实现了 netpoller,这使得即便 G 发起网络 I/O 操作,也不会导致 M 被阻塞(仅阻塞 G),也就不会导致大量线程(M)被创建出来 。
Channel
channel 既可以用来实现 Goroutine 间的通信,还可以实现 Goroutine 间的同步。它就好比 Go 并发设计这门“武功”的秘籍口诀.
在理解 channel 的发送与接收操作时,你一定要始终牢记:channel 是用于 Goroutine 间通信的,所以绝大多数对 channel 的读写都被分别放在了不同的 Goroutine 中。
无缓冲Channel:
由于无缓冲 channel 的运行时层实现不带有缓冲区,所以 Goroutine 对无缓冲 channel的接收和发送操作是同步的。也就是说,对同一个无缓冲 channel,只有对它进行接收操作的Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进行,否则单方面的操作会让对应的 Goroutine 陷入挂起状态.
对无缓冲 channel 类型的发送与接收操作,一定要放在两个不同的 Goroutine 中进行,否则会导致 deadlock
无缓冲的Channel 作用:
第一种用法:用作信号传递
第二种用法:用于替代锁机制 (同步)
有缓冲Channel:
带缓冲 channel 的运行时层实现带有缓冲区,因此,对带缓冲channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。
对一个带缓冲 channel 来说,在缓冲区未满的情况下,对它进行发送操作的Goroutine 并不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的 Goroutine也不会阻塞挂起。
但当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起;当缓冲区为空的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起。
使用操作符<-,我们还可以声明只发送 channel 类型(send-only)和只接收 channel类型(recv-only)
有缓冲的Channel 作用:
第一种用法:用作消息队列
第二种用法:用作计数信号量(counting semaphore)
通过“comma, ok”惯用法或 for range 语句,我们可以准确地判定 channel是否被关闭。
select
当涉及同时对多个 channel 进行操作时,我们会结合 Go 为 CSP 并发模型提供的另外一个
原语 select,一起使用。通过 select,我们可以同时在多个 channel 上进行发送 / 接收操作。
当 select 语句中没有 default 分支,而且所有 case 中的 channel 操作都阻塞了的时候,
整个 select 语句都将被阻塞。直到某一个 case 上的 channel 变成可发送,或者某个 case
上的 channel 变成可接收,select 语句才可以继续进行下去。
Select 的用法:
- 第一种用法:利用 default 分支避免阻塞
- 第二种用法:实现超时机制
带超时机制的 select,是 Go 中常见的一种 select 和 channel 的组合用法。通过超时事
件,我们既可以避免长期陷入某种操作的等待中,也可以做一些异常处理工作。 - 第三种用法:实现心跳机制
结合 time 包的 Ticker,我们可以实现带有心跳机制的 select。这种机制让我们可以在监
听 channel 的同时,执行一些周期性的任务。
nil channel 的妙用
如果一个 channel 类型变量的值为 nil,我们称它为 nil channel。nil channel 有一个特性,那就是对 nil channel 的读写都会发生阻塞。
如何使用共享变量?
提供了针对传统的、基于共享内存并发模型的低级同步原语,包括:互斥锁(sync.Mutex)、读写锁(sync.RWMutex)、条件变量(sync.Cond)等,并通过 atomic 包提供了原子操作原语等。
互斥锁(Mutex)还是读写锁(RWMutex)?
sync 包提供了两种用于临界区同步的原语:互斥锁(Mutex)和读写锁(RWMutex)。它们都是零值可用的数据类型,也就是不需要显式初始化就可以使用。
sync.Cond是传统的条件变量原语概念在 Go 语言中的实现。我们可以把一个条件变量理解为一个容器,这个容器中存放着一个或一组等待着某个条件成立的 Goroutine。当条件成立后,这些处于等待状态的 Goroutine 将得到通知,并被唤醒继续进行后续的工作。