Go 编译器自动检测类型是否实现接口

开源库里会有一些类似下面这种奇怪的用法:

1
var _ io.Writer = (*myWriter)(nil)

这时候会有点懵,不知道作者想要干什么,实际上这就是此问题的答案。编译器会由此检查 *myWriter 类型是否实现了 io.Writer 接口。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "io"

type myWriter struct {

}

/*func (w myWriter) Write(p []byte) (n int, err error) {
return
}*/

func main() {
// 检查 *myWriter 类型是否实现了 io.Writer 接口
var _ io.Writer = (*myWriter)(nil)

// 检查 myWriter 类型是否实现了 io.Writer 接口
var _ io.Writer = myWriter{}
}

注释掉为 myWriter 定义的 Write 函数后,运行程序:

1
2
3
4
5
# command-line-arguments
src/learn/tongbu/tongbu.go:14:6: cannot use (*myWriter)(nil) (type *myWriter) as type io.Writer in assignment:
*myWriter does not implement io.Writer (missing Write method)
src/learn/tongbu/tongbu.go:17:6: cannot use myWriter{} (type myWriter) as type io.Writer in assignment:
myWriter does not implement io.Writer (missing Write method)

报错信息:*myWriter/myWriter 未实现 io.Writer 接口,也就是未实现 Write 方法。

解除注释后,运行程序不报错。

实际上,上述赋值语句会发生隐式地类型转换在转换的过程中,编译器会检测等号右边的类型是否实现了等号左边接口所规定的函数。

总结: 可通过在代码中添加类似如下的代码,用来检测类型是否实现了接口:

1
2
var _ io.Writer = (*myWriter)(nil)
var _ io.Writer = myWriter{}

Go 类型转换和断言

Go 语言中不允许隐式类型转换,也就是说 = 两边,不允许出现类型不相同的变量

类型转换类型断言本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。

类型转换

对于类型转换而言,转换前后的两个类型要相互兼容才行。类型转换的语法为:

<结果类型> := <目标类型> ( <表达式> )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
var i int = 9

var f float64
f = float64(i)
fmt.Printf("%T, %v\n", f, f) //float64, 9

f = 10.8
a := int(f)
fmt.Printf("%T, %v\n", a, a) //int, 10

// s := []int(i)
}

上面的代码里,定义了一个 int 型和 float64 型的变量,尝试在它们之间相互转换,结果是成功的:int 型和 float64 是相互兼容的。

把最后一行代码的注释去掉,编译器会报告类型不兼容的错误:

1
cannot convert i (type int) to type []int

断言

前面说过,因为空接口 interface{} 没有定义任何函数,因此 Go 中所有类型都实现了空接口当一个函数的形参是 interface{},那么在函数中,需要对形参进行断言,从而得到它的真实类型。

断言的语法为:

<目标类型的值>,<布尔参数> := <表达式>.( 目标类型 ) // 安全类型断言
<目标类型的值> := <表达式>.( 目标类型 )  //非安全类型断言

类型转换和类型断言有些相似,不同之处,在于类型断言是对接口进行的操作

还是来看一个简短的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

type Student struct {
Name string
Age int
}

func main() {
var i interface{} = new(Student)
s := i.(Student)

fmt.Println(s)
}

运行一下:

1
panic: interface conversion: interface {} is *main.Student, not main.Student

直接 panic 了,这是因为 i*Student 类型,并非 Student 类型,断言失败

1
2
3
4
5
6
7
8
//安全断言
func main() {
var i interface{} = new(Student)
s, ok := i.(Student)
if ok {
fmt.Println(s)
}
}

这样,即使断言失败也不会 panic

switch语句判断接口类型

断言其实还有另一种形式,就是利用 switch 语句判断接口的类型。每一个 case 会被顺序地考虑。当命中一个 case 时,就会执行 case 中的语句,因此 case 语句的顺序是很重要的,因为很有可能会有多个 case 匹配的情况。

代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type Student struct {
Name string
Age int
}

func main() {
//var i interface{} = new(Student)
//var i interface{} = (*Student)(nil)
var i interface{}

fmt.Printf("%p %v\n", &i, i)

judge(i)
}

func judge(v interface{}) {
fmt.Printf("%p %v\n", &v, v)

switch v := v.(type) {
case nil:
fmt.Printf("%p %v\n", &v, v)
fmt.Printf("nil type[%T] %v\n", v, v)

case Student:
fmt.Printf("%p %v\n", &v, v)
fmt.Printf("Student type[%T] %v\n", v, v)

case *Student:
fmt.Printf("%p %v\n", &v, v)
fmt.Printf("*Student type[%T] %v\n", v, v)

default:
fmt.Printf("%p %v\n", &v, v)
fmt.Printf("unknow\n")
}
}

main 函数里有三行不同的声明,每次运行一行,注释另外两行,得到三组运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// --- var i interface{} = new(Student)
0xc000010240 &{ 0}
0xc000010260 &{ 0}
0xc00000e030 &{ 0}
*Student type[*main.Student] &{ 0}

// --- var i interface{} = (*Student)(nil)
0xc000010240 <nil>
0xc000010250 <nil>
0xc00000e030 <nil>
*Student type[*main.Student] <nil>

// --- var i interface{}
0xc00010a220 <nil>
0xc00010a230 <nil>
0xc00010a240 <nil>
nil type[<nil>] <nil>

对于第一行语句:

1
var i interface{} = new(Student)

i 是一个 *Student 类型,匹配上第三个 case,从打印的三个地址来看,这三处的变量实际上都是不一样的。在 main 函数里有一个局部变量 i;调用函数时,实际上是复制了一份参数,因此函数里又有一个变量 v,它是 i 的拷贝;断言之后,又生成了一份新的拷贝。所以最终打印的三个变量的地址都不一样。

对于第二行语句:

1
var i interface{} = (*Student)(nil)

这里想说明的其实是 i 在这里动态类型是 (*Student), 数据为 nil,它的类型并不是 nil,它与 nil 作比较的时候,得到的结果也是 false

最后一行语句:

1
var i interface{}

这回 i 才是 nil 类型。

【引申1】
fmt.Println 函数的参数是 interface。对于内置类型,函数内部会用穷举法,得出它的真实类型,然后转换为字符串打印。而对于自定义类型,首先确定该类型是否实现了 String() 方法,如果实现了,则直接打印输出 String() 方法的结果;否则,会通过反射来遍历对象的成员进行打印。

再来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

type Student struct {
Name string
Age int
}

func main() {
var s = Student{
Name: "qcrao",
Age: 18,
}

fmt.Println(s)
}

因为 Student 结构体没有实现 String() 方法,所以 fmt.Println 会利用反射挨个打印成员变量:

1
{qcrao 18}

增加一个 String() 方法的实现:

1
2
3
func (s Student) String() string {
return fmt.Sprintf("[Name: %s], [Age: %d]", s.Name, s.Age)
}

打印结果:

1
[Name: qcrao], [Age: 18]

按照我们自定义的方法来打印了。

【引申2】
针对上面的例子,如果改一下:

1
2
3
func (s *Student) String() string {
return fmt.Sprintf("[Name: %s], [Age: %d]", s.Name, s.Age)
}

注意看两个函数的接受者类型不同,现在 Student 结构体只有一个接受者类型为 指针类型String() 函数,打印结果:

1
{qcrao 18}

为什么?

类型 T 只有接受者是 T 的方法;而类型 *T 拥有接受者是 T*T 的方法。语法上 T 能直接调 *T 的方法仅仅是 Go 的语法糖。

Student 结构体定义了接受者类型是值类型的 String() 方法时,通过

1
2
fmt.Println(s)
fmt.Println(&s)

均可以按照自定义的格式来打印。

如果 Student 结构体定义了接受者类型是指针类型的 String() 方法时,只有通过

1
fmt.Println(&s)

才能按照自定义的格式打印。

参考资料

【类型转换和断言】https://www.cnblogs.com/zrtqsk/p/4157350.html

【断言】https://studygolang.com/articles/11419

Go 接口转换原理

通过前面提到的 iface 的源码可以看到,实际上它包含接口的类型 *interface type 和 实体类型的类型 *_type,这两者都是 iface 的字段 *itab 的成员。也就是说生成一个 itab 同时需要接口的类型和实体的类型。

1
<interface 类型, 实体类型> ->itable

当判定一种类型是否满足某个接口时,Go 使用类型的方法集和接口所需要的方法集进行匹配,如果类型的方法集完全包含接口的方法集,则可认为该类型实现了该接口。

例如某类型有 m 个方法,某接口有 n 个方法,则很容易知道这种判定的时间复杂度为 O(mn)Go 会对方法集的函数按照函数名的字典序进行排序,所以实际的时间复杂度为 O(m+n)

探索将一个接口转换给另外一个接口背后的原理,当然,能转换的原因必然是类型兼容

直接来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import "fmt"

type coder interface {
code()
run()
}

type runner interface {
run()
}

type Gopher struct {
language string
}

func (g Gopher) code() {
return
}

func (g Gopher) run() {
return
}

func main() {
var c coder = Gopher{}

var r runner
r = c
fmt.Println(c, r)
}

简单解释上述代码:定义了两个 interface: coderrunner。定义了一个实体类型 Gopher,类型 Gopher 实现了两个方法,分别是 run()code()main 函数里定义了一个接口变量 c,绑定了一个 Gopher 对象,之后将 c 赋值给另外一个接口变量 r 。赋值成功的原因是 c 中包含 run() 方法。这样,两个接口变量完成了转换。

执行命令:

1
go tool compile -S ./src/main.go

得到 main 函数的汇编命令,可以看到: r = c 这一行语句实际上是调用了 runtime.convI2I(SB),也就是 convI2I 函数,从函数名来看,就是将一个 interface 转换成另外一个 interface看下它的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func convI2I(inter *interfacetype, i iface) (r iface) {
tab := i.tab
if tab == nil {
return
}
if tab.inter == inter {
r.tab = tab
r.data = i.data
return
}
r.tab = getitab(inter, tab._type, false)
r.data = i.data
return
}

代码比较简单,函数参数 inter 表示接口类型,i 表示绑定了实体类型的接口,r 则表示接口转换了之后的新的 iface。通过前面的分析,我们又知道, iface 是由 tabdata 两个字段组成。所以,实际上 convI2I 函数真正要做的事,找到新 interfacetabdata,就大功告成了。

convI2I函数查找tab和data

tab 包括接口类型 interfacetype 和 实体类型 _type。所以最关键的语句是 r.tab = getitab(inter, tab._type, false)

因此,重点来看下 getitab 函数的源码,只看关键的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
// ……

// 根据 inter, typ 计算出 hash 值
h := itabhash(inter, typ)

// look twice - once without lock, once with.
// common case will be no lock contention.
var m *itab
var locked int
for locked = 0; locked < 2; locked++ {
if locked != 0 {
lock(&ifaceLock)
}

// 遍历哈希表的一个 slot
for m = (*itab)(atomic.Loadp(unsafe.Pointer(&hash[h]))); m != nil; m = m.link {

// 如果在 hash 表中已经找到了 itab(inter 和 typ 指针都相同)
if m.inter == inter && m._type == typ {
// ……

if locked != 0 {
unlock(&ifaceLock)
}
return m
}
}
}

// 在 hash 表中没有找到 itab,那么新生成一个 itab
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
m.inter = inter
m._type = typ

// 添加到全局的 hash 表中
additab(m, true, canfail)
unlock(&ifaceLock)
if m.bad {
return nil
}
return m
}

简单总结:getitab 函数会根据 interfacetype_type 去全局的 itab 哈希表中查找,如果能找到,则直接返回;否则,会根据给定的 interfacetype_type 新生成一个 itab,并插入到 itab 哈希表,这样下一次就可以直接拿到 itab

写itab

这里查找了两次,并且第二次上锁了,这是因为如果第一次没找到,在第二次仍然没有找到相应的 itab 的情况下,需要新生成一个,并且写入哈希表,因此需要加锁。这样,其他协程在查找相同的 itab 并且也没有找到时,第二次查找时,会被挂住,之后,就会查到第一个协程写入哈希表的 itab

再来看一下 additab 函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 检查 _type 是否符合 interface_type 并且创建对应的 itab 结构体 将其放到 hash 表中
func additab(m *itab, locked, canfail bool) {
inter := m.inter
typ := m._type
x := typ.uncommon()

// both inter and typ have method sorted by name,
// and interface names are unique,
// so can iterate over both in lock step;
// the loop is O(ni+nt) not O(ni*nt).
//
// inter 和 typ 的方法都按方法名称进行了排序
// 并且方法名都是唯一的。所以循环的次数是固定的
// 只用循环 O(ni+nt),而非 O(ni*nt)
ni := len(inter.mhdr)
nt := int(x.mcount)
xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt]
j := 0
for k := 0; k < ni; k++ {
i := &inter.mhdr[k]
itype := inter.typ.typeOff(i.ityp)
name := inter.typ.nameOff(i.name)
iname := name.name()
ipkg := name.pkgPath()
if ipkg == "" {
ipkg = inter.pkgpath.name()
}
for ; j < nt; j++ {
t := &xmhdr[j]
tname := typ.nameOff(t.name)
// 检查方法名字是否一致
if typ.typeOff(t.mtyp) == itype && tname.name() == iname {
pkgPath := tname.pkgPath()
if pkgPath == "" {
pkgPath = typ.nameOff(x.pkgpath).name()
}
if tname.isExported() || pkgPath == ipkg {
if m != nil {
// 获取函数地址,并加入到itab.fun数组中
ifn := typ.textOff(t.ifn)
*(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*sys.PtrSize)) = ifn
}
goto nextimethod
}
}
}
// ……

m.bad = true
break
nextimethod:
}
if !locked {
throw("invalid itab locking")
}

// 计算 hash 值
h := itabhash(inter, typ)
// 加到Hash Slot链表中
m.link = hash[h]
m.inhash = true
atomicstorep(unsafe.Pointer(&hash[h]), unsafe.Pointer(m))
}

additab 会检查 itab 持有的 interfacetype_type 是否符合,就是看 _type 是否完全实现了 interfacetype 的方法,也就是看两者的方法列表重叠的部分就是 interfacetype 所持有的方法列表。注意到其中有一个双层循环,乍一看,循环次数是 ni * nt,但由于两者的函数列表都按照函数名称进行了排序,因此最终只执行了 ni + nt 次,代码里通过一个小技巧来实现:第二层循环并没有从 0 开始计数,而是从上一次遍历到的位置开始。

求 hash 值的函数比较简单:

1
2
3
4
5
func itabhash(inter *interfacetype, typ *_type) uint32 {
h := inter.typ.hash
h += 17 * typ.hash
return h % hashSize
}

hashSize 的值是 1009。

更一般的,当把实体类型赋值给接口的时候,会调用 conv 系列函数,例如空接口调用 convT2E 系列、非空接口调用 convT2I 系列。这些函数比较相似:

  1. 具体类型转空接口时,_type 字段直接复制源类型的 _type;调用 mallocgc 获得一块新内存,把值复制进去,data 再指向这块新内存。
  2. 具体类型转非空接口时,入参 tab 是编译器在编译阶段预先生成好的,新接口 tab 字段直接指向入参 tab 指向的 itab;调用 mallocgc 获得一块新内存,把值复制进去,data 再指向这块新内存。
  3. 而对于接口转接口,itab 调用 getitab 函数获取。只用生成一次,之后直接从 hash 表中获取。

参考资料

【接口赋值、反射】http://wudaijun.com/2018/01/go-interface-implement/

【itab】http://legendtkl.com/2017/07/01/golang-interface-implement/

【和 C++ 的对比】https://www.jianshu.com/p/b38b1719636e

【getitab源码说明】https://www.twblogs.net/a/5c245d59bd9eee16b3db561d

Go 接口的构造过程

博客Go接口的底层实现源码分析可以看到 ifaceeface 的源码,知道 iface 最重要的是 itab_type

为了研究清楚接口是如何构造的,接下来利用汇编还原背后的真相。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

type Person interface {
growUp()
}

type Student struct {
age int
}

func (p Student) growUp() {
p.age += 1
return
}

func main() {
var qcrao = Person(Student{age: 18})

fmt.Println(qcrao)
}

执行命令:

1
go tool compile -S main.go

得到 main 函数的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
0x0000 00000 (./src/main.go:30) TEXT    "".main(SB), $80-0
0x0000 00000 (./src/main.go:30) MOVQ (TLS), CX
0x0009 00009 (./src/main.go:30) CMPQ SP, 16(CX)
0x000d 00013 (./src/main.go:30) JLS 157
0x0013 00019 (./src/main.go:30) SUBQ $80, SP
0x0017 00023 (./src/main.go:30) MOVQ BP, 72(SP)
0x001c 00028 (./src/main.go:30) LEAQ 72(SP), BP
0x0021 00033 (./src/main.go:30) FUNCDATA$0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
0x0021 00033 (./src/main.go:30) FUNCDATA$1, gclocals·e226d4ae4a7cad8835311c6a4683c14f(SB)
0x0021 00033 (./src/main.go:31) MOVQ $18, ""..autotmp_1+48(SP)
0x002a 00042 (./src/main.go:31) LEAQ go.itab."".Student,"".Person(SB), AX
0x0031 00049 (./src/main.go:31) MOVQ AX, (SP)
0x0035 00053 (./src/main.go:31) LEAQ ""..autotmp_1+48(SP), AX
0x003a 00058 (./src/main.go:31) MOVQ AX, 8(SP)
0x003f 00063 (./src/main.go:31) PCDATA $0, $0
0x003f 00063 (./src/main.go:31) CALL runtime.convT2I64(SB)
0x0044 00068 (./src/main.go:31) MOVQ 24(SP), AX
0x0049 00073 (./src/main.go:31) MOVQ 16(SP), CX
0x004e 00078 (./src/main.go:33) TESTQ CX, CX
0x0051 00081 (./src/main.go:33) JEQ 87
0x0053 00083 (./src/main.go:33) MOVQ 8(CX), CX
0x0057 00087 (./src/main.go:33) MOVQ $0, ""..autotmp_2+56(SP)
0x0060 00096 (./src/main.go:33) MOVQ $0, ""..autotmp_2+64(SP)
0x0069 00105 (./src/main.go:33) MOVQ CX, ""..autotmp_2+56(SP)
0x006e 00110 (./src/main.go:33) MOVQ AX, ""..autotmp_2+64(SP)
0x0073 00115 (./src/main.go:33) LEAQ ""..autotmp_2+56(SP), AX
0x0078 00120 (./src/main.go:33) MOVQ AX, (SP)
0x007c 00124 (./src/main.go:33) MOVQ $1, 8(SP)
0x0085 00133 (./src/main.go:33) MOVQ $1, 16(SP)
0x008e 00142 (./src/main.go:33) PCDATA $0, $1
0x008e 00142 (./src/main.go:33) CALL fmt.Println(SB)
0x0093 00147 (./src/main.go:34) MOVQ 72(SP), BP
0x0098 00152 (./src/main.go:34) ADDQ $80, SP
0x009c 00156 (./src/main.go:34) RET
0x009d 00157 (./src/main.go:34) NOP
0x009d 00157 (./src/main.go:30) PCDATA $0, $-1
0x009d 00157 (./src/main.go:30) CALL runtime.morestack_noctxt(SB)
0x00a2 00162 (./src/main.go:30) JMP 0

从第 10 行开始看:

汇编行数 操作
10-14 构造调用 runtime.convT2I64(SB)参数

函数的参数形式:

1
2
3
func convT2I64(tab *itab, elem unsafe.Pointer) (i iface) {
// ……
}

convT2I64 会构造出一个 inteface,也就是我们的 Person 接口。

第一个参数的位置是 (SP),这里被赋上了 go.itab."".Student,"".Person(SB) 的地址。

我们从生成的汇编找到:

1
2
3
4
5
go.itab."".Student,"".Person SNOPTRDATA dupok size=40
0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0010 00 00 00 00 00 00 00 00 da 9f 20 d4
rel 0+8 t=1 type."".Person+0
rel 8+8 t=1 type."".Student+0

size=40 大小为40字节,回顾一下:

1
2
3
4
5
6
7
8
9
10
type itab struct {
inter *interfacetype // 8字节
_type *_type // 8字节
link *itab // 8字节
hash uint32 // 4字节
bad bool // 1字节
inhash bool // 1字节
unused [2]byte // 2字节
fun [1]uintptr // variable sized // 8字节
}

把每个字段的大小相加,itab 结构体的大小就是 40 字节。上面那一串数字实际上是 itab 序列化后的内容,注意到大部分数字是 0,从 24 字节开始的 4 个字节 da 9f 20 d4 实际上是 itabhash 值,这在判断两个类型是否相同的时候会用到。

下面两行是链接指令,简单说就是将所有源文件综合起来,给每个符号赋予一个全局的位置值。这里的意思也比较明确:前8个字节最终存储的是 type."".Person 的地址,对应 itab 里的 inter 字段,表示接口类型;8-16 字节最终存储的是 type."".Student 的地址,对应 itab_type 字段,表示具体类型。

第二个参数就比较简单了,它就是数字 18 的地址,这也是初始化 Student 结构体的时候会用到。

汇编行数 操作
15 调用 runtime.convT2I64(SB)

具体看下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func convT2I64(tab *itab, elem unsafe.Pointer) (i iface) {
t := tab._type

//...

var x unsafe.Pointer
if *(*uint64)(elem) == 0 {
x = unsafe.Pointer(&zeroVal[0])
} else {
x = mallocgc(8, t, false)
*(*uint64)(x) = *(*uint64)(elem)
}
i.tab = tab
i.data = x
return
}

这块代码比较简单,tab 赋给了 ifacetab 字段;data 部分则是在堆上申请了一块内存,然后将 elem 指向的 18 拷贝过去。这样 iface 就组装好了。

汇编行数 操作
17 i.tab 赋给 CX
18 i.data 赋给 AX
19-21 检测 i.tab 是否是 nil,如果不是的话,把 CX 移动 8 个字节,也就是把 itab_type 字段赋给了 CX,这也是接口的实体类型,最终要作为 fmt.Println 函数的参数

后面,就是调用 fmt.Println 函数及之前的参数准备工作了.

这样,我们就把一个 interface 的构造过程说完了。

【引申1】
如何打印出接口类型的 Hash 值?

这里参考一篇文章Go和interface探究。具体做法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct {
inter uintptr
_type uintptr
link uintptr
hash uint32
_ [4]byte
fun [1]uintptr
}

func main() {
var qcrao = Person(Student{age: 18})

iface := (*iface)(unsafe.Pointer(&qcrao))
fmt.Printf("iface.tab.hash = %#x\n", iface.tab.hash)
}

定义了一个山寨版ifaceitab,说它山寨是因为 itab 里的一些关键数据结构都不具体展开了,比如 _type,对比一下正宗的定义就可以发现,但是山寨版依然能工作,因为 _type 就是一个指针而已嘛。

main 函数里,先构造出一个接口对象 qcrao,然后强制类型转换,最后读取出 hash 值!

运行结果:

1
iface.tab.hash = 0xd4209fda

值得一提的是,构造接口 qcrao 的时候,即使把 age 写成其他值,得到的 hash 值依然不变的,这应该是可以预料的,hash 值只和他的字段、方法相关。

参考资料

http://xargin.com/go-and-interface/#reconstructing-an-itab-from-an-executable

Go 接口的动态类型和动态值

从上一篇博客Go接口的底层实现源码分析里可以看到:iface包含两个字段:tab 是接口表指针,指向类型信息;data 是数据指针,则指向具体的数据。它们分别被称为动态类型动态值。而接口值包括动态类型动态值

【引申1】接口类型和 nil 作比较

接口值的零值是指动态类型动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下,这个接口值就才会被认为 接口值 == nil

来看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

type Coder interface {
code()
}

type Gopher struct {
name string
}

func (g Gopher) code() {
fmt.Printf("%s is coding\n", g.name)
}

func main() {
var c Coder
fmt.Println(c == nil)
fmt.Printf("c: %T, %v\n", c, c)

var g *Gopher
fmt.Println(g == nil)

c = g
fmt.Println(c == nil)
fmt.Printf("c: %T, %v\n", c, c)
}

输出:

1
2
3
4
5
true
c: <nil>, <nil>
true
false
c: *main.Gopher, <nil>

一开始,c 的 动态类型和动态值都为 nilg 也为 nil,当把 g 赋值给 c 后,c 的动态类型变成了 *main.Gopher,仅管 c 的动态值仍为 nil,但是当 cnil 作比较的时候,结果就是 false 了。

【引申2】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "fmt"

type MyError struct {}

func (i MyError) Error() string {
return "MyError"
}

func main() {
err := Process()
fmt.Println(err)

fmt.Println(err == nil)
}

func Process() error {
var err *MyError = nil
return err
}

函数运行结果:

1
2
<nil>
false

这里先定义了一个 MyError 结构体,实现了 Error 函数,也就实现了 error 接口。Process 函数返回了一个 error 接口,这块隐含了类型转换。所以,虽然它的值是 nil,其实它的类型是 *MyError,最后和 nil 比较的时候,结果为 false

【引申3】如何打印出接口的动态类型和值?

直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"unsafe"
"fmt"
)

type iface struct {
itab, data uintptr
}

func main() {
var a interface{} = nil

var b interface{} = (*int)(nil)

x := 5
var c interface{} = (*int)(&x)

ia := *(*iface)(unsafe.Pointer(&a))
ib := *(*iface)(unsafe.Pointer(&b))
ic := *(*iface)(unsafe.Pointer(&c))

fmt.Println(ia, ib, ic)

fmt.Println(*(*int)(unsafe.Pointer(ic.data)))
}

代码里直接定义了一个 iface 结构体,用两个指针来描述 itabdata,之后将 a, b, c 在内存中的内容强制解释成我们自定义的 iface。最后就可以打印出动态类型和动态值的地址。

运行结果如下:

1
2
{0 0} {4843168 0} {4843168 824634355376}
5

a 的动态类型和动态值的地址均为 0,也就是 nil;b 的动态类型和 c 的动态类型一致,都是 *int;最后,c 的动态值为 5。

Go 接口的底层实现

ifaceeface 都是 Go 中描述接口的底层结构体,区别在于 iface 描述的接口包含方法eface 则是不包含任何方法的空接口:interface{}

从源码层面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type iface struct {
tab *itab
data unsafe.Pointer
}

type itab struct {
inter *interfacetype
_type *_type
link *itab
hash uint32 // copy of _type.hash. Used for type switches.
bad bool // type does not implement interface
inhash bool // has this itab been added to hash?
unused [2]byte
fun [1]uintptr // variable sized
}

iface 内部维护两个指针,tab 指向一个 itab 实体, 它表示接口的类型以及赋给这个接口的实体类型。data 则指向接口具体的值,一般而言是一个指向堆内存的指针。

再来仔细看一下 itab 结构体:

  • _type 字段描述了实体的类型,包括内存对齐方式,大小等;
  • inter 字段则描述了接口的类型
  • fun 字段放置和接口方法对应的具体数据类型的方法地址,实现接口调用方法的动态分派,一般在每次给接口赋值发生转换时会更新此表,或者直接拿缓存的 itab。

为什么 fun 数组的大小为 1,要是接口定义了多个方法可怎么办?实际上,这里存储的是第一个方法的函数指针,如果有更多的方法,在它之后的内存空间里继续存储。从汇编角度来看,通过增加地址就能获取到这些函数指针,没什么影响这些方法是按照函数名称的字典序进行排列的。

再看一下 interfacetype 类型,它描述的是接口的类型

1
2
3
4
5
type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}

可以看到,它包装了 _type 类型,_type 实际上是描述 Go 语言中各种数据类型的结构体。这里还包含一个 mhdr 字段,表示接口所定义的函数列表, pkgpath 记录定义了接口的包名。

这里通过一张图来看下 iface 结构体的全貌:

eface 的源码:

1
2
3
4
type eface struct {
_type *_type
data unsafe.Pointer
}

相比 ifaceeface 比较简单。只维护了一个 _type 字段,表示空接口所承载的具体的实体类型data 描述了具体的值。

eface 结构体

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import "fmt"

func main() {
x := 200
var any interface{} = x
fmt.Println(any)

g := Gopher{"Go"}
var c coder = g
fmt.Println(c)
}

type coder interface {
code()
debug()
}

type Gopher struct {
language string
}

func (p Gopher) code() {
fmt.Printf("I am coding %s language\n", p.language)
}

func (p Gopher) debug() {
fmt.Printf("I am debuging %s language\n", p.language)
}

执行命令,打印出汇编语言:

1
go tool compile -S ./src/main.go

可以看到,main 函数里调用了两个函数:

1
2
func convT2E64(t *_type, elem unsafe.Pointer) (e eface)
func convT2I(tab *itab, elem unsafe.Pointer) (i iface)

上面两个函数的参数和 ifaceeface 结构体的字段是可以联系起来的:两个函数都是将参数组装一下,形成最终的接口。

作为补充,最后再来看下 _type 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type _type struct {
// 类型大小
size uintptr
ptrdata uintptr
// 类型的 hash 值
hash uint32
// 类型的 flag,和反射相关
tflag tflag
// 内存对齐相关
align uint8
fieldalign uint8
// 类型的编号,有bool, slice, struct 等等等等
kind uint8
alg *typeAlg
// gc 相关
gcdata *byte
str nameOff
ptrToThis typeOff
}

Go 语言各种数据类型都是在 _type 字段的基础上,增加一些额外的字段来进行管理的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type arraytype struct {
typ _type
elem *_type
slice *_type
len uintptr
}

type chantype struct {
typ _type
elem *_type
dir uintptr
}

type slicetype struct {
typ _type
elem *_type
}

type structtype struct {
typ _type
pkgPath name
fields []structfield
}

这些数据类型的结构体定义,是反射实现的基础。

参考资料

【Go Interface 源码剖析 】http://legendtkl.com/2017/07/01/golang-interface-implement/

【interface 源码解读 包含反射】http://wudaijun.com/2018/01/go-interface-implement/

TCP基础知识

为什么需要TCP

IP 层是不可靠的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性

如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。因为 TCP 是一个工作在传输层可靠数据传输的服务,它能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。

TCP是什么

TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。

  • 面向连接:一定是一对一才能连接,不能像UDP协议可以一个主机同时向多个主机发送消息,也即无法做到一对多。
  • 可靠的:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端。
  • 字节流:消息是没有边的,所以无论消息有多大都可以进行传输。并且消息是有序的,当前一个消息没有收到的时候,即使它先收到了后面的字节已经收到,那么也不能扔给应用层去处理,同时对重复的报文会自动丢弃。

TCP头部中比较重要的字段:

  • 序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就累加一次该数据字节数的大小。用来解决网络包乱序问题。
  • 确认应答号:指下一次期望收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收用来解决不丢包的问题。
  • 控制位:
    • ACK:该位为 1 时,确认应答的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1
    • RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接
    • SYN:该位为 1 时,表示希望建立连,并在其序列号的字段进行序列号初始值的设定。
    • FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位置为 1 的 TCP 段。

TCP建立连接

什么是TCP连接

用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗口大小称为连接。

建立一个 TCP 连接是需要客户端与服务器端达成三个信息的共识。

  • Socket:由 IP 地址和端口号组成
  • 序列号:用来解决乱序问题等
  • 窗口大小:用来做流量控制

如何唯一确定一个TCP连接

TCP 四元组可以唯一的确定一个连接,四元组包括如下:

  • 源地址
  • 源端口
  • 目的地址
  • 目的端口

源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机

源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程

TCP 的最大连接数是多少

服务器通常固定在某个本地端口上监听,等待客户端的连接请求。

因此,客户端 IP 和 端口是可变的,其理论值计算公式如下:

对 IPv4,客户端的 IP 数最多为 232 次方,客户端的端口数最多为 216 次方,也就是服务端单机最大 TCP 连接数,约为 248 次方

当然,服务端最大并发 TCP 连接数远不能达到理论上限。

  • 首先主要是文件描述符限制,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目
  • 另一个是内存限制,每个 TCP 连接都要占用一定内存,操作系统是有限的。

TCP三次握手过程和状态变迁

  • 一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
  • 第一次握手:客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的序号字段中,同时把 SYN 标志位置为 1 ,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态
  • 第二次握手:服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的序号字段中,其次把 TCP 首部的确认应答号字段填入 client_isn + 1, 接着把 SYNACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
  • 第三次握手:客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,之后客户端处于 ESTABLISHED 状态。
  • 服务器收到客户端的应答报文后,也进入 ESTABLISHED 状态。

查看tcp的连接状态

netstat -napt

为什么需要三次握手,两次或者四次是否可以?

  • 避免历史连接:防止旧的重复连接初始化造成混乱。网络环境是错综复杂的,往往并不是如期望的一样,先发送的数据包,就先到达目标主机,可能会由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何避免的呢?
    • 客户端连续发送多次 SYN 建立连接的报文,在网络拥堵等情况下
      • 一个旧 SYN 报文比最新的 SYN 报文早到达了服务端
      • 此时服务端就会回一个 SYN + ACK 报文给客户端
      • 客户端收到后可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),那么客户端就会发送 RST 报文给服务端,表示中止这一次连接。
    • 如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端因有足够的上下文来判断当前连接是否是历史连接
      • 如果是历史连接(序列号过期或超时),则第三次握手发送的报文是 RST 报文,以此中止历史连接
      • 如果不是历史连接,则第三次发送的报文是 ACK 报文,通信双方就会成功建立连接
  • 同步双方初始序列号。TCP 协议的通信双方, 都必须维护一个序列号, 序列号是可靠传输的一个关键因素,它的作用:
    • 接收方可以去除重复的数据
    • 接收方可以根据数据包的序列号按序接收
    • 可以标识发送出去的数据包中, 哪些是已经被对方收到的
    • 客户端和服务器双发使用序列号保证在来回通信过程中初始序列号能被可靠的同步。
  • 避免资源浪费。如果只有「两次握手」,当客户端的 SYN 请求连接在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接,这会造成重复发送多次 SYN 报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。

为什么客户端和服务器端的初始序列号ISN是不相同的

网络中的报文会延迟、会复制重发、也有可能丢失,这样会造成的不同连接之间产生互相影响,所以为了避免互相影响,客户端和服务端的初始序列号是随机且不同的。

IP层会分片,为什么TCP层还需要MSS

  • MTU(maximum transmission unit):一个网络包的最大长度,以太网中一般为 1500 字节
  • MSS(maximum segment size):除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度

如果TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常?

当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,在交给上一层 TCP 传输层。

这看起来井然有序,但这存在隐患的,那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发整个 TCP 报文(头部 + 数据)。因此,可以得知由 IP 层进行分片传输,是非常没有效率的

所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位而不用重传所有的分片,大大增加了重传的效率。

SYN攻击

TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。

SYN攻击的避免

方式一:通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理

  • 网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值参数:net.core.netdev_max_backlog
  • SYN_RCVD 状态连接的最大个数:net.ipv4.tcp_max_syn_backlog
  • 超出处理能时,对新的 SYN 直接回 RST,丢弃连接:net.ipv4.tcp_abort_on_overflow

Linux 内核的 SYN (未完成连接建立)队列与 Accpet (已完成连接建立)队列是如何工作的:

SYN_ACCPET队列工作原理

正常流程:

  • 当服务端接收到客户端的 SYN 报文时,会将其加入到内核的SYN 队列
  • 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文
  • 服务端接收到 ACK 报文后,从 SYN 队列移除放入到 Accept 队列
  • 应用通过调用 accpet() socket 接口,从Accept 队列取出连接。

应用程序过慢

如果应用程序过慢时,就会导致Accept 队列被占满。

受到SYN攻击

  • 如果不断受到 SYN 攻击,就会导致「 SYN 队列」被占满。tcp_syncookies 的方式可以应对 SYN 攻击的方法:net.ipv4.tcp_syncookies = 1

cookie启动

  • SYN 队列满之后,后续服务器收到 SYN 包,不进入SYN 队列
  • 计算出一个 cookie 值,再以 SYN + ACK 中的序列号返回客户端
  • 服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到 Accept 队列
  • 最后应用通过调用 accpet() socket 接口,从 Accept 队列取出的连接

TCP断开连接

TCP四次挥手过程及状态变迁

双方都可以主动断开连接,断开连接后主机中的资源将被释放

  • 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态
  • 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态。
  • 客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态
  • 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。
  • 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
  • 服务器收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
  • 客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。

每个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手

为什么需要四次挥手

  • 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
  • 服务器收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。

从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACKFIN 一般都会分开发送,从而比三次握手导致多了一次。

为什么需要 TIME_WAIT 状态

主动发起关闭连接的一方,才会有 TIME-WAIT 状态。

需要 TIME-WAIT 状态,主要是两个原因:

  • 防止具有相同四元组的旧数据包被收到
    • 假设 TIME-WAIT 没有等待时间或时间过短,有相同端口的 TCP 连接可能被复用,被延迟的数据包抵达后会:有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题
    • 经过 2MSL 时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
  • 保证被动关闭连接的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭
    • 假设 TIME-WAIT 没有等待时间或时间过短,客户端四次挥手的最后一个 ACK 报文如果在网络中被丢失了,此时如果客户端 TIME-WAIT 过短或没有,则就直接进入了 CLOSE 状态了,那么服务端则会一直处在 LASE-ACK 状态。
    • 当客户端发起建立连接的 SYN 请求报文后,服务端会发送 RST 报文给客户端,连接建立的过程就会被终止。

为什么 TIME_WAIT 等待的时间是 2MSL

MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。

MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。

TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是:网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。比如,如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。

2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时

在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒

其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN:

1
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT state, about 60 seconds  */

如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里TCP_TIMEWAIT_LEN的值,并重新编译 Linux 内核。

TIME_WAIT 过多有什么危害

如果服务器有处于 TIME-WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。

过多的 TIME-WAIT 状态主要的危害有两种:

  • 第一是内存资源占用
  • 第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口

第二个危害是会造成严重的后果的,端口资源是有限的,一般可以开启的端口为 32768~61000,也可以通过如下参数设置指定

1
net.ipv4.ip_local_port_range

如果服务端 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。

如何优化 TIME_WAIT?

优化 TIME-WAIT 的几个方式,都是有利有弊:

  • 打开 net.ipv4.tcp_tw_reusenet.ipv4.tcp_timestamps选项

    • 复用处于 TIME_WAIT 的 socket 为新的连接所用
    • net.ipv4.tcp_tw_reuse = 1,使用前提:打开对 TCP 时间戳的支持:net.ipv4.tcp_timestamps=1(默认即为 1)(这个时间戳的字段是在 TCP 头部的选项里,用于记录 TCP 发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳,前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃)
    • 注意:net.ipv4.tcp_tw_reuse要慎用,因为使用了它就必然要打开时间戳的支持 net.ipv4.tcp_timestamps,当客户端与服务端主机时间不同步时,客户端的发送的消息会被直接拒绝掉。
  • net.ipv4.tcp_max_tw_buckets

    • 这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置。这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多
  • 程序中使用 SO_LINGER ,应用强制使用 RST 关闭

    • 可以通过设置 socket 选项,来设置调用 close 关闭连接行为。
    1
    2
    3
    4
    struct linger so_linger;
    so_linger.l_onoff = 1;
    so_linger.l_linger = 0;
    setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
    • 如果l_onoff为非 0, 且l_linger值为 0,那么调用close后,会立该发送一个RST标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。但这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为。

如果已经建立了连接,但是客户端突然出现故障了怎么办

TCP 有一个机制是保活机制。该机制的原理为:

定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。

在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:

1
2
3
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
  • tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
  • tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
  • tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。

也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个死亡连接。这个时间可以根据实际的需求,对以上的保活相关的参数进行设置。

如果开启了 TCP 保活,需要考虑以下几种情况:

  • 对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。

  • 对端程序崩溃并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。

  • 是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡

socket编程

socket编程与TCP状态的对应图:

socket编程与通信过程的对应

listen 时候参数 backlog 的意义

Linux内核中会维护两个队列:

  • 未完成连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于SYN_RCVD 状态
  • 已完成连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED状态

backlog

1
2
3
int listen (int socketfd, int backlog)
// 参数一 socketfd 为 socketfd 文件描述符
// 参数二 backlog,这参数在历史有一定的变化

在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。

在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。

accept 发送在三次握手的哪一步

第三次握手的包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后

客户端调用 close 了,连接是断开的流程是什么

  • 客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态
  • 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态
  • 接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得会发出一个 FIN 包,之后处于 LAST_ACK 状态
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态
  • 客户端进过 2MSL 时间之后,也进入 CLOSED 状态

参考

https://mp.weixin.qq.com/s/tH8RFmjrveOmgLvk9hmrkw

鸭子类型

维基百科: 如果某个东西长得像鸭子,像鸭子一样游泳,像鸭子一样嘎嘎叫,那它就可以被看成是一只鸭子。

  • Duck Typing,鸭子类型,是动态编程语言的一种对象推断策略,它更关注对象能如何被使用,而不是对象的类型本身

  • Go 语言作为一门静态语言,它通过通过接口的方式完美支持鸭子类型

动态语言实现

动态语言的特点:

变量绑定的类型是不确定的,在运行期间才能确定
函数和方法可以接收任何类型的参数,且调用时不检查参数类型
不需要实现接口

在动态语言 python 中,定义一个这样的函数:

1
2
def hello_world(coder):
coder.say_hello()

当调用此函数的时候,可以传入任意类型,只要它实现了 say_hello() 函数就可以。如果没有实现,运行过程中会出现错误。

静态语言实现

而在静态语言如 Java, C++ 中,必须要显示地声明实现了某个接后,才能用在任何需要这个接口的地方。 如果你在程序中调用 hello_world 函数,却传入了一个根本就没有实现 say_hello() 的类型,那在编译阶段就不会通过。这也是静态语言比动态语言更安全的原因。

动态语言和静态语言的差别在此就有所体现。静态语言在编译期间就能发现类型不匹配的错误,不像动态语言,必须要运行到那一行代码才会报错。

静态语言要求程序员在编码阶段就要按照规定来编写程序,为每个变量规定数据类型,这在某种程度上,加大了工作量,也加长了代码量。动态语言则没有这些要求,可以让人更专注在业务上,代码也更短,写起来更快,这一点,eg. python 。

Go鸭子类型实现

Go 语言作为一门现代静态语言,是有后发优势的。它引入了动态语言的便利,同时又会进行静态语言的类型检查,写起来是非常高效的。

Go 采用了折中的做法:不要求类型显示地声明实现了某个接口,只要实现了相关的方法即可,编译器就能检测到。

例子:

定义一个接口,和使用此接口作为参数的函数:

1
2
3
4
5
6
7
type IGreeting interface {
sayHello()
}

func sayHello(i IGreeting) {
i.sayHello()
}

定义两个结构体:

1
2
3
4
5
6
7
8
9
type Go struct {}
func (g Go) sayHello() {
fmt.Println("Hi, I am GO!")
}

type PHP struct {}
func (p PHP) sayHello() {
fmt.Println("Hi, I am PHP!")
}

最后,在 main 函数里调用 sayHello() 函数:

1
2
3
4
5
6
7
func main() {
golang := Go{}
php := PHP{}

sayHello(golang)
sayHello(php)
}

程序输出:

1
2
Hi, I am GO!
Hi, I am PHP!

在 main 函数中,调用 sayHello() 函数时,传入了 golang, php 对象,它们并没有显式地声明实现了 IGreeting 类型,只是实现了接口所规定的 sayHello() 函数。实际上,编译器在调用 sayHello() 函数时,会隐式地将 golang, php 对象转换成 IGreeting 类型,这也是静态语言的类型检查功能。

总结

鸭子类型是一种动态语言的风格,在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由它”当前方法和属性的集合”决定。Go 作为一种静态语言,通过接口实现了 鸭子类型,实际上是 Go 的编译器在其中作了隐匿的转换工作。

参考资料

【Golang 与鸭子类型,讲得比较好】https://blog.csdn.net/cszhouwei/article/details/33741731

【各种面向对象的名词】https://cyent.github.io/golang/other/oo/

接口设计的意义

  • 泛型编程:使用 interface 可以实现泛型编程
  • 隐藏具体实现:设计一个函数返回一个 interface,那么只能通过 interface 里面的方法来做一些操作,但是内部的具体实现是完全不知道的。

golang中的接口

接口定义了一种规范,描述了类的行为和功能,而不做具体实现

Interface定义了一个或一组method(s),这些method(s)只有函数签名,没有具体的实现代码.

若某个数据类型实现了Interface中定义的那些被称为”methods”的函数,则称这些数据类型实现(implement)了interface。

C++中的接口实现方法

C++ 的接口是使用抽象类来实现的,如果类中至少有一个函数被声明为纯虚函数,则这个类就是抽象类。纯虚函数是通过在声明中使用 “= 0” 来指定的。例如:

1
2
3
4
5
6
7
8
class Shape
{
public:
// 纯虚函数
virtual double getArea() = 0;
private:
string name; // 名称
};

设计抽象类的目的:为了给其他类提供一个可以继承的适当的基类。抽象类不能被用于实例化对象,它只能作为接口使用。

派生类需要明确地声明它继承自基类,并且需要实现基类中所有的纯虚函数

golang接口与C++接口区别

  • C++ 定义接口的方式称为“侵入式”

  • Go 采用的是 “非侵入式”,不需要显式声明,只需要实现接口定义的函数,编译器自动会识别。

  • 底层实现不同

    • 实现方式:C++ 通过虚函数表来实现基类调用派生类的函数;而 Go 通过 itab 中的 fun 字段来实现接口变量调用实体类型的函数。
    • 编译生成过程:C++ 中的虚函数表是在编译期生成的;而 Go 的 itab 中的 fun 字段是在运行期间动态生成的。原因在于,Go 中实体类型可能会无意中实现 N 多接口,很多接口并不是本来需要的,所以不能为类型实现的所有接口都生成一个 itab, 这也是“非侵入式”带来的影响;这在 C++ 中是不存在的,因为派生需要显示声明它继承自哪个基类。

epoll事件模型

POLL事件有两种模型:

  • Edge Triggered (ET) 边缘触发只有数据到来才触发,不管缓存区中是否还有数据

  • Level Triggered (LT) 水平触发只要有数据都会触发

思考如下步骤:

  1. 假定已经把一个用来从管道中读取数据的文件描述符(RFD)添加到epoll描述符。

  2. 管道的另一端写入了2KB的数据

  3. 调用epoll_wait,并且它会返回RFD,说明它已经准备好读取操作

  4. 读取1KB的数据

  5. 调用epoll_wait……

在这个过程中,有两种工作模式:

ET模式

ET模式即Edge Triggered工作模式。

如果在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用ET模式的epoll接口:

  • 基于非阻塞文件句柄

  • 只有当read或者write返回EAGAIN(非阻塞读,暂时无数据)时才需要挂起、等待。但这并不是说每次read时都需要循环读,直到读到产生一个EAGAIN才认为此次事件处理完成,当read返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。

LT模式

LT模式即Level Triggered工作模式。

与ET模式不同的是,以LT方式调用epoll接口的时候,它就相当于一个速度比较快的poll,无论后面的数据是否被使用。

LT(level triggered):LT是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉一个文件描述符是否就绪了,然后可以对这个就绪的fd进行IO操作。如果不作任何操作,内核还是会继续通知,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。

ET(edge-triggered):ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉。然后它会假设知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once).

基于管道的epoll ET触发模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <errno.h>
#include <unistd.h>

#define MAXLINE 10

int main(int argc, char *argv[])
{
int efd, i;
int pfd[2];
pid_t pid;
char buf[MAXLINE], ch = 'a';

pipe(pfd);
pid = fork();
/*子进程每次向管道的写端写入10个字节:aaaa\nbbbb...\n*/
if (pid == 0) {
close(pfd[0]); //子进程写
while (1) {
for (i = 0; i < MAXLINE/2; i++)
buf[i] = ch;
buf[i-1] = '\n';
ch++;

for (; i < MAXLINE; i++)
buf[i] = ch;
buf[i-1] = '\n';
ch++;

write(pfd[1], buf, sizeof(buf));
sleep(2);
}
close(pfd[1]);
} else if (pid > 0) {
struct epoll_event event;
struct epoll_event resevent[10];
int res, len;
close(pfd[1]);

efd = epoll_create(10);
event.events = EPOLLIN | EPOLLET; /* ET 边沿触发 ,默认是水平触发 */
// event.events=EPOLLIN; // 水平触发
event.data.fd = pfd[0];
epoll_ctl(efd, EPOLL_CTL_ADD, pfd[0], &event);

/*父进程死循环阻塞监听,每次读取一半*/
while (1) {
res = epoll_wait(efd, resevent, 10, -1);
printf("res %d\n", res);
if (resevent[0].data.fd == pfd[0]) {
len = read(pfd[0], buf, MAXLINE/2);
write(STDOUT_FILENO, buf, len);
}
}
close(pfd[0]);
close(efd);
} else {
perror("fork");
exit(-1);
}
return 0;
}

现象:

  • 当设置水平触发时, 只要管道中有数据, epoll_wait就会返回, 触发父进程读取数据, 所以虽然父进程每次只读取一半的数据, 但读完一半后剩下的一半又会触发父进程读取, 所以10个字节的数据都会显示出来

  • 当设置边缘触发时, 父进程阻塞读取, 而只有当子进程向管道中进行一次写入时才会触发父进程进行读取, 所以每次只会打印一半的数据

总结:

  • 边缘触发: 缓冲区未读尽的数据不会导致epoll_wait返回, 新的数据写入才会触发
  • 水平触发: 缓冲区未读尽的数据会导致epoll_wait返回

基于网络C/S模型的epoll ET触发模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/* server.c */
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/epoll.h>
#include <unistd.h>

#define MAXLINE 10
#define SERV_PORT 9527

int main(void)
{
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
int listenfd, connfd;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
int i, efd;

listenfd = socket(AF_INET, SOCK_STREAM, 0);

bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));

listen(listenfd, 20);

struct epoll_event event;
struct epoll_event resevent[10];
int res, len;
efd = epoll_create(10);
event.events = EPOLLIN | EPOLLET; /* ET 边沿触发 ,默认是水平触发 */

printf("Accepting connections ...\n");
cliaddr_len = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));

event.data.fd = connfd;
epoll_ctl(efd, EPOLL_CTL_ADD, connfd, &event);

while (1) {
res = epoll_wait(efd, resevent, 10, -1);
printf("res %d\n", res);
if (resevent[0].data.fd == connfd) {
len = read(connfd, buf, MAXLINE/2);
write(STDOUT_FILENO, buf, len);
}
}
return 0;
}