PHILIP x BLOG

心有猛虎,细嗅蔷薇


  • 首页

  • 归档

Go研究之Interface

发表于 2018-11-15 |

空接口interface{}

interface{} 赋值

interface{} 有点类似于 C/C++ 里的 void*,interface{} ,在 Golang 中可以存储任何数据类型:int、string、struct、function、nil、map等等所有:

1
2
var a interface{} = 1     //字面1为int类型
var v interface{} = nil

由于Go中所有的变量有类型信息,因此存储到 interface{} 里也会带上类型信息,这样才可以在运行时支持反射等特性(这也是不同于void*的地方)。而且interface{} 还可以通过类型assert反转换到具体类型:

1
2
var a interface{} = 1
b := a.(int)

空接口interface{} 底层是通过eface结构来实现的,意思是empty interface。eface 本质上类似一个 pair<type, data> ,其中type 存储了变量的实际类型,而data 指向变量的值。具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type eface struct {
_type *_type
data unsafe.Pointer
}

type _type struct {
size uintptr // type size 描述类型的大小
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32 // hash of type; avoids computation in hash tables
tflag tflag // extra type information flags
align uint8 // 变量对齐
fieldalign uint8 // 结构体对齐
kind uint8 // 和反射里的kind一致,数据的大类
alg *typeAlg //算法函数指针,存储了hash/equal/print/copy四个函数操作
gcdata *byte // garbage collection data
str nameOff // string form
ptrToThis typeOff // type for pointer to this type, may be zero
}

Go1.7 源码中将变量赋值给 interface{}是通过convT2E 实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func convT2E(t *_type, elem unsafe.Pointer, x unsafe.Pointer) (e eface) {
if raceenabled {
raceReadObjectPC(t, elem, getcallerpc(unsafe.Pointer(&t)), funcPC(convT2E))
}
if msanenabled {
msanread(elem, t.size)
}
if isDirectIface(t) {
throw("direct convT2E")
}
if x == nil {
x = newobject(t)
// TODO: We allocate a zeroed object only to overwrite it with
// actual data. Figure out how to avoid zeroing. Also below in convT2I.
}
typedmemmove(t, x, elem)
e._type = t
e.data = x
return
}

可以看到在运行时,通过 typedmemmove 进行了内存拷贝,data 不是简单的指向原数据区。而反射里修改数据时,如果不是指针类型,修改会失败,应该也是基于这个原因:修改的只是拷贝的数据。

我们可以用以下实验试一下

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
package main

import (
"fmt"
)

type User struct {
id int
name string
}

func main() {
u := User{1, "Tom"}
var i interface{} = u
u.id = 2
u.name = "Jack"
fmt.Printf("u: %#v\n", u);
fmt.Printf("i: %#v\n", i);

u2 := &User{2, "Tom2"}
var i2 interface{} = u2
u2.id = 2
u2.name = "Jack2"
fmt.Printf("u2: %#v\n", u2);
fmt.Printf("i2: %#v\n", i2);
}

运行结果如下

1
2
3
4
u: main.User{id:2, name:"Jack"}
i: main.User{id:1, name:"Tom"}
u2: &main.User{id:2, name:"Jack2"}
i2: &main.User{id:2, name:"Jack2"}

证明了代码里的拷贝实现。

interface{} 与 nil

当将 nil 赋值给 interface{} 变量时,type 和 data 域都将被赋值为 nil, 因此其本质上是一个nil

而如果是一个其他类型的 nil 值,被赋值给 interface{},则其 type是有具体类型的,只不过data 是nil,因而组合而成的 eface结构就不是一个nil

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

import (
"fmt"
)

type User struct {
id int
name string
}

func main() {
var i1 interface{} = nil // type 和 data 都是 nil
fmt.Printf("%v\n", i1 == nil);

var u2 *User
var i2 interface{} = u2 // type 是 *User,data是 nil
fmt.Printf("%v\n", i2 == nil);
}

运行结果

1
2
true
false

不仅是空接口interface{} 是这样,其他有方法的interface 如果被赋值为一个具体类型的nil 值,本质上是不等于nil,而只有被直接赋值为nil,才是真正上的nil。可以认为直接赋值字面上的nil 是类型type和data 都为nil 的nil。

非空 interface

非空interface赋值

非空 interface 一般用来实现类似C++的运行时的多态特性。将一个struct 变量赋值给非空interface时编译器会先做一次校验:看该struct类型是否实现了接口所需的所有方法,如果没有,则会报错。例如

1
2
3
4
5
type I interface {
String()
}
var a int = 5
var b I = a

编译器会给出提示

1
2
cannot use a (type int) as type I in assignment:
int does not implement I (missing String method)

运行时赋值底层借助接口 iface 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type iface struct {
tab *itab
data unsafe.Pointer
}

// layout of Itab known to compilers
// allocated in non-garbage-collected memory
// Needs to be in sync with
// ../cmd/compile/internal/gc/reflect.go:/^func.dumptypestructs.
type itab struct {
inter *interfacetype
_type *_type
link *itab
bad int32
unused int32
fun [1]uintptr // variable sized
}

type interfacetype struct {
typ _type
pkgpath name
mhdr []imethod
}

itab 结构包含了两个类型:1)该 interface自己的类型*interfacetype; 2) 其data所指向的具体接口实现的实际类型*_type。interfacetype 是对_type的封装,加上了一些interface才有的数据,专门来表示interface的具体类型。我们可以看到其mhdr成员表示该interface的方法集,但是注意这里只是函数原型metadata,不是具体的函数定义,具体的函数定义是由实现接口的struct来定义的。

相比于 empty interface,non-empty interface 要包含实现该 interface的method 具体定义,定义会被存放在 itab.fun 变量里。虽然 fun 数组只有一个元素,但实际赋值的时候会在内存上依次连续的存储各函数指针。

一个法国的bloger teh-cmc 的 go-internals 里通过汇编代码,详细说明了如何在运行时一个个填充itab结构的各个成员的,有兴趣的同学可以自行查看。

当itab结构被填充好了之后,运行时就可以通过调用convT2I 来将变量赋值给非空 interface

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
t := tab._type
if raceenabled {
raceReadObjectPC(t, elem, getcallerpc(unsafe.Pointer(&tab)), funcPC(convT2I))
}
if msanenabled {
msanread(elem, t.size)
}
if isDirectIface(t) {
// This case is implemented directly by the compiler.
throw("direct convT2I")
}
x := newobject(t)
typedmemmove(t, x, elem)
i.tab = tab
i.data = x
return
}

其中x := newobject(t) 会在堆上分配一个 t 类型的对象。由此可见,不管赋值给非空interface的变量存放在哪里,赋值操作都会在堆上重新生成一个对象,然后将对象的类型和指针存储在非空interface里,必要时可能会引发变量逃逸。因此该转换是比较消耗性能的,看下一个benchmark

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Addifier interface{ Add(a, b int32) int32 }

type Adder struct{ id int32 }

//go:noinline
func (adder Adder) Add(a, b int32) int32 { return a + b }

func BenchmarkDirect(b *testing.B) {
adder := Adder{id: 6754}
for i := 0; i < b.N; i++ {
adder.Add(10, 32)
}
}

func BenchmarkInterface(b *testing.B) {
adder := Adder{id: 6754}
for i := 0; i < b.N; i++ {
Addifier(adder).Add(10, 32)
}
}

直接调用和通过interface来调用的差别很大,测试结果如下

1
2
BenchmarkDirect-4      	2000000000	         1.77 ns/op
BenchmarkInterface-4 100000000 22.5 ns/op
非空interface动态dispatch

动态dispatch实际上就类似于C++里的多态实现,C++通过虚函数表存储了各个具体实现类的函数指针,这是编译时完成的。而运行时通过构造函数来生成指向虚函数表的虚表指针,调用的时候通过指针来查找具体应该调用虚函数表里的哪个函数。

而Go的实现方式也有些许类似,上文提到的itab.fun 结构就类似于虚表概念,所不同的是,虚表是在运行时通过go的runtime来赋值的。一旦虚表被填充好,函数调用就简单的在虚表中查找了,主要的开销应该还是在interface 赋值的时候。

参考资料

深入解析Go

Golang interface接口深入理解

Go 反射与interface拾遗

go-internals

随机数生成

发表于 2018-11-12 |

Go泛型

发表于 2018-10-17 |

Go 语言本身不支持泛型 generics。我们这里主要看下怎么处理泛型的需求。

泛型数据

如果要存储泛型数据,Go 提供了空接口 interface{}。可以将任意的数据结构存储到 interface{} 中,然后使用的时候需要做一下 cast 转换。

泛型函数

这种使用比较常用,意思是针对不同的数据类型,需要运行同样的一套函数化的流程或者算法。

目前 Go 并不支持这种传统意义上的泛型编程。Jon Bodner 在他的 Closures are the Generics for Go 这篇文章中提供了一种间接实现泛型函数需求的做法,就是利用回调形式的闭包。

简单来说,就是将需要泛型抽象的一套流程和算法封装成一个公共接口,并且提供一个回调,可以在回调时候利用闭包的特性来读取或者修改变量 T,而 T 在通常的泛型编程中是需要传给泛型函数的。

下面是一个C++ 中的泛型函数的使用,compare 就是一套函数化的流程或者算法。

1
2
3
4
5
6
7
template <typename T>
int compare(const T &lhs, const T &rhs)
{
if (lhs < rhs) return -1;
if (rhs < lhs) return 1;
return 0;
}

在 Go 中如果实现类似的效果呢?方法就是回调闭包函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func compare(fnCmp func() int) bool {
ret := fnCmp()
return ret >= 0
}

func main() {
var a int = 1;
var b int = 2;
result := compare(func() int {
if (a < b) {return -1}
if (a > b) {return 1}
return 0
})
fmt.Println(result)
}

通过闭包的特性,可以不需要传之前需要的泛型参数 T,就可以对变量进行读取和修改。

我们只需要将相同的流程抽象成一个公共接口,这里是compare,然后将不同的部分通过回调闭包的形式作为参数让公共接口调用即可,这里是 fnCmp func() int 参数。

另一个比较恰当的例子是数据库的批量分页拉取。针对每个业务的数据库表,都可能会需要分页拉取 table 里的所有数据,就有必要将分页拉取的操作封装成一个公共接口,拉取后通过闭包参数来让调用方自定义后续操作。

1
2
3
4
5
6
7
var records []*DBTerritoryRespawn
batchGet(dbtbl, fields, wheres, func(rows [][]hqdb.TbField){
for _, row := range rows {
record := do_something_with(row)
records = append(records, record)
}
})

每次拉取一页(batchGet 内部调用一次闭包函数),都可以修改最终的结果 records。

利用闭包,我们可以方便的抽象出一个通用的排序接口

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
import(
"sort"
)

type sorter struct {
len int
fnSwap func(i, j int)
fnLess func(i, j int) bool
}
func (s sorter) Len() int { return s.len }
func (s sorter) Swap(i, j int) {
s.fnSwap(i, j)
}
func (s sorter) Less(i, j int) bool {
return s.fnLess(i, j)
}

//slice排序
func Sort(len int, fnSwap func(i, j int), fnLess func(i, j int) bool) {
sort.Sort(sorter{len, fnSwap, fnLess})
}

func main() {
data := []int{2, 1, 5, 3, 4}
Sort(len(data), func(i, j int) {
data[i], data[j] = data[j], data[i]
}, func(i, j int) bool {
return data[i] < data[j]
})
}

只不过这里用了2个闭包函数 fnSwap 和 fnLess。

以上通过 closure 闭包提供的解决方式实质上使用了软件理论中的side-effects来实现的,意思是函数调用会影响调用方的数据。现代编程理论认为这是一种不好的方式,和其相对的是函数化编程。

这里仅仅是提供一个泛型的一种 work around,所以还是期待未来 Go 能支持真正意义上的泛型。

参考资料

Closures are the Generics for Go

服务器性能优化备忘

发表于 2018-09-29 |

最近对服务器进行了一次性能优化,这里记录一下要点以供备忘。

Go Profile

golang 官方提供了一个称为 pprof 的性能调优工具。我们可以利用该工具来进行诊断。pprof 的原理是每秒钟暂停100次,然后对当前正在运行的 goroutine 堆栈进行采样并记录次数。

pprof 开启

对于服务器,一般通过 http 方式来启用 pprof,例如

1
2
3
4
5
import _ "net/http/pprof"

go func() {
log.Println(http.ListenAndServe("0.0.0.0:6060", nil))
}()
pprof 诊断热点函数
1
go tool pprof bin/sessionsvr http://your-ip:port/debug/pprof/profile?seconds=30

30秒后,数据采集完成,top20 可以列出 CPU 占用最高的20项,结果如下

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
(pprof) top20
6300ms of 8360ms total (75.36%)
Dropped 236 nodes (cum <= 41.80ms)
Showing top 20 nodes out of 137 (cum >= 430ms)
flat flat% sum% cum cum%
1200ms 14.35% 14.35% 3070ms 36.72% runtime.mapassign1
1000ms 11.96% 26.32% 1010ms 12.08% runtime.mapiternext
750ms 8.97% 35.29% 750ms 8.97% runtime.aeshashbody
430ms 5.14% 40.43% 690ms 8.25% runtime.scanobject
320ms 3.83% 44.26% 4980ms 59.57% hgame/sessionsvr/logic/territory.(*territoryMap).moveSight
280ms 3.35% 47.61% 430ms 5.14% runtime.evacuate
260ms 3.11% 50.72% 260ms 3.11% runtime.usleep
250ms 2.99% 53.71% 310ms 3.71% syscall.Syscall
240ms 2.87% 56.58% 240ms 2.87% runtime.memmove
210ms 2.51% 59.09% 210ms 2.51% runtime.futex
190ms 2.27% 61.36% 190ms 2.27% runtime.memclr
180ms 2.15% 63.52% 290ms 3.47% runtime.mapiterinit
180ms 2.15% 65.67% 380ms 4.55% runtime.typedmemmove
160ms 1.91% 67.58% 160ms 1.91% sync/atomic.AddUint32
140ms 1.67% 69.26% 140ms 1.67% runtime.heapBitsForObject
140ms 1.67% 70.93% 200ms 2.39% runtime.strequal
110ms 1.32% 72.25% 450ms 5.38% runtime.mallocgc
110ms 1.32% 73.56% 180ms 2.15% runtime.mapaccess2_faststr
90ms 1.08% 74.64% 90ms 1.08% runtime.heapBitsSetType
60ms 0.72% 75.36% 430ms 5.14% github.com/ugorji/go/codec.encFnInfo.kStruct

其中前两列 flat 表示该函数调用的时间和百分比,后两列 cum 表示该函数处于堆栈中的时间和百分比,包含正在被调用或者等待其他子函数返回的情况。 sum 表示前面 N 行到当前行函数累计的时间百分比。

从上面的结果可以知道 cum% 为 59.57% 的 moveSight 函数长时间处于栈列表中,是正在运行或者等待该函数里面其他函数调用返回。而其很可能主要等待前面几个 map 的操作完成。

运行下面的命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(pprof) top mapassign1
2590ms of 7720ms total (33.55%)
Dropped 9 nodes (cum <= 38.60ms)
Showing top 10 nodes out of 42 (cum >= 30ms)
flat flat% sum% cum cum%
1020ms 13.21% 13.21% 2710ms 35.10% runtime.mapassign1
550ms 7.12% 20.34% 550ms 7.12% runtime.aeshashbody
240ms 3.11% 23.45% 240ms 3.11% runtime.memmove
240ms 3.11% 26.55% 540ms 6.99% runtime.typedmemmove
210ms 2.72% 29.27% 240ms 3.11% runtime.strequal
130ms 1.68% 30.96% 370ms 4.79% runtime.evacuate
100ms 1.30% 32.25% 100ms 1.30% runtime.memclr
40ms 0.52% 32.77% 60ms 0.78% runtime.heapBitsBulkBarrier
30ms 0.39% 33.16% 30ms 0.39% runtime.aeshash32
30ms 0.39% 33.55% 30ms 0.39% runtime.aeshashstr

(pprof) svg mapassign1
Generating report in cpu.svg

用浏览器打开 生成的结果 cpu.svg 可以看到

image-20180930144018771

从图中可以证实,确实是 moveSight 导致的 map 操作占用 CPU 时间较多。

利用 list 命令查看具体是那一行

1
(pprof) list moveSight

找到具体数据结构后,就可以有针对性的修改。一般来说 map 的优化主要是 key 要使用比较简单的类型,这样计算 hash 的时候也比较快。通常来说 int 类型 key 比 string 类型的 key 要快。此外由于 map 的内存增长是指数级的,新插入的时候如果发现空间不足,需要重新分配空间 hashGrow 和进行内存 typedmemmove,很耗性能,所以如果能预估 map 的大小,最好一开始就分配足够大的空间,以空间换时间。

机器人策略

Lua Profile

  • 数据库数据的分批拉取、存储。Mysql 一次 update 多条记录会比多次 update 快。(有网络开销,获取锁开销等)
  • 配置数据的加载,预处理。
  • map 或者 slice 预分配空间大小,减少频繁扩容及数据拷贝代价。
  • 注意共享资源锁的粒度。
  • 数据更新量。大且频繁的数据使用版本号做增量通知。
  • 算法优化:KDtree 查找距离最近,KNN 算法;模糊查找算法。
  • 广播裁剪
  • 包大小优化,序列化方法,压缩
  • 登录模块,预分配 user 池
  • 增加 metrics 统计项,及早发现问题
  • snap 快照对比,找出 lua 内存泄露
  • lua 有性能问题的地方改用 c 或者 go 实现(加解密、time、socket、json 等序列化、随机数发生器、位操作)
  • gettimeofday
  • 避免密集操作,如避免定点发放物品,分批发放
  • 利用 goroutine 多核并行执行
  • 空间换时间
  • 消峰:均摊思想。例如哈希表的扩容时候,不是一次做完数据迁移。
  • 用 SSD 硬盘。
  • 数据库分表分库,读写分离
算法调优
代码调优
  • 尽量用整形取代字符串(例如用整形 flags 来表示多个状态,利用位操作来查询设置状态;数据库用整形做 key)
  • 单线程中,不要用带锁相关的数据结构,很多 stl 的线程安全的容器或者智能指针 AutoPtr 都是加锁的,很耗性能;多线程环境下,尽量用无锁编程,乐观锁,读写锁等来替代互斥锁、悲观锁;最后,尽量用单线程
  • 池化技术:内存池、对象池、连接池、线程池
  • 缓存技术:LRU 缓存
  • 将同步操作转换为异步操作,提高 throughout
网络调优
  • 及时关闭空闲连接,避免资源耗尽。客户端服务器心跳 keepalive 机制。
  • TIMEWAIT 状态的处理。
  • TCP buff 的选择。理论上的RWIN应该设置成:吞吐量 * 回路时间。Sender端的buffer应该和RWIN有一样的大小,因为Sender端发送完数据后要等Receiver端确认,如果网络延时很大,buffer过小了,确认的次数就会多,于是性能就不高,对网络的利用率也就不高了。也就是说,对于延迟大的网络,我们需要大的buffer,这样可以少一点ack,多一些数据,对于响应快一点的网络,可以少一些buffer。因为,如果有丢包(没有收到ack),buffer过大可能会有问题,因为这会让TCP重传所有的数据,反而影响网络性能。
  • 对于一个UDP的包,我们要尽量地让他大到MTU的最大尺寸再往网络上传,这样可以最大化带宽利用率。对于这个MTU,以太网是1500字节,光纤是4352字节,802.11无线网是7981。
  • Epoll 的使用。
  • DNS lookup。gethostbyaddr/gethostbyname 这个函数可能会相当的费时,因为其要到网络上去找域名,因为DNS的递归查询,会导致严重超时,而又不能通过设置什么参数来设置time out,对此你可以通过配置hosts文件来加快速度,或是自己在内存中管理对应表,在程序启动时查好,而不要在运行时每次都查。
数据库调优
  • 选对引擎
  • 索引
  • 数据类型选择
  • 分表分库
  • 读写分离
  • 实现层优化:vtune 等工具查找热点,针对性优化。

  • 实现层优化:空间换时间,为高频不善变计算建立缓存。

  • 业务层优化:柔性可用,设立资源消耗配额(定时重置),每次请求消费一个配额,控制总体。
  • 业务层优化:有损服务,对业务需求进行必要裁剪。

参考资料

Profiling Go Programs

MySQL Cheat Sheet

发表于 2018-09-13 |

安装

版本选择

关于 mysql 的版本选择,可以参考 如何选择 MySQL 版本 这篇文章。我们选择社区版的 5.6 版本。

安装步骤

为了方便用户安装,MySQl 官方提供了单独的 repository,对于Linux 发行版,有专门的 yum 源和 apt 源。将源添加好后,就可以很方便的通过 linux 的 yum 或者 apt 工具进行安装。这里我们以 yum 为例来讲述 MySQL 社区版本的安装过程。

  • 首先下载 MySQL yum 源,页面位于 Download MySQL Yum Repository

注意下载的文件名虽然统一叫做 mysql80-community-release-<platform-and-version>.noarch.rpm,但是里面包含了 8.0,5.7,5.6,5.5 等各个版本以及工具。

  • 本地安装下载的源,我们以 centos7为例,下载的文件为 mysql80-community-release-el7-1.noarch.rpm

    1
    2
    $ sudo rpm -Uvh mysql80-community-release-el7-1.noarch.rpm
    $ rpm -qa | grep mysql
  • 查看启用的版本,默认是启用 8.0 版本

1
2
3
4
$ yum repolist enabled | grep mysql
mysql-connectors-community/x86_64 MySQL Connectors Community 65
mysql-tools-community/x86_64 MySQL Tools Community 69
mysql80-community/x86_64 MySQL 8.0 Community Server 412
  • 修改启用版本为 5.6
1
2
$ sudo yum-config-manager --disable mysql80-community
$ sudo yum-config-manager --enable mysql56-community
  • 安装
1
2
$ sudo yum install mysql-community-server
$ mysql --version

如果安装时下载有问题,可以考虑修改 /etc/yum.conf 里的 proxy 代理配置为你的 http 代理地址 http://your_proxy_ip:port

配置

默认安装路径是 /var/lib/mysql, 可以自定义数据文件路径。一般来说云主机的 data 盘较大,可以将数据库的数据存放于此

1
2
3
4
5
6
[mysqld]
datadir=/data/mysql
socket=/data/mysql/mysql.sock

[client]
socket=/data/mysql/mysql.sock # 供 mysql client 连接用

这里自定义的/data/mysql 路径需要设置为 mysql 用户拥有

1
$ sudo chown -R mysql:mysql /data/mysql

开启慢查询

1
2
3
4
[mysqld]
slow_query_log=1
long_query_time=1 # 1s
slow_query_log_file=/data/mysql/mysqld-slow.log

开启 binlog

1
2
[mysqld]
log-bin=mysql-bin

设置字符集(character)及校对规则(collation,字符串比较规则),字符集默认是 latin1,我们设置为 utf8

1
2
3
[mysqld]
character-set-server=utf8
collation-server=utf8_general_ci

配置完成后,启动 mysqld 服务器进程

1
$ sudo service mysqld start

安全性控制

运行 mysql_secure_installation 命令,来对新安装的 mysql 进行安全性设置,这包括 root 密码设置、限制 root 只能本机登录、删除 test 库、删除匿名用户等。对于生产环境,强烈建议设置一下。

注意 5.7 及以上版本已经不需要运行该命令

性能测试

使用 sysbench 工具来对 MySQL 进行测试(这里我们只测试 select primary key)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ sysbench /usr/share/sysbench/oltp_read_only.lua help
sysbench 1.0.9 (using system LuaJIT 2.0.4)

oltp_read_only.lua options:
--distinct_ranges=N Number of SELECT DISTINCT queries per transaction [1]
--sum_ranges=N Number of SELECT SUM() queries per transaction [1]
--skip_trx[=on|off] Don't start explicit transactions and execute all queries in the AUTOCOMMIT mode [off]
--secondary[=on|off] Use a secondary index in place of the PRIMARY KEY [off]
--create_secondary[=on|off] Create a secondary index in addition to the PRIMARY KEY [on]
--index_updates=N Number of UPDATE index queries per transaction [1]
--range_size=N Range size for range SELECT queries [100]
--auto_inc[=on|off] Use AUTO_INCREMENT column as Primary Key (for MySQL), or its alternatives in other DBMS. When disabled, use client-generated IDs [on]
--delete_inserts=N Number of DELETE/INSERT combination per transaction [1]
--tables=N Number of tables [1]
--mysql_storage_engine=STRING Storage engine, if MySQL is used [innodb]
--non_index_updates=N Number of UPDATE non-index queries per transaction [1]
--table_size=N Number of rows per table [10000]
--pgsql_variant=STRING Use this PostgreSQL variant when running with the PostgreSQL driver. The only currently supported variant is 'redshift'. When enabled, create_secondary is automatically disabled, and delete_inserts is set to 0
--simple_ranges=N Number of simple range SELECT queries per transaction [1]
--order_ranges=N Number of SELECT ORDER BY queries per transaction [1]
--range_selects[=on|off] Enable/disable all range SELECT queries [on]
--point_selects=N Number of point SELECT queries per transaction [10]

准备数据

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
$ sysbench /usr/share/sysbench/oltp_read_only.lua --threads=4  --mysql-user=root --mysql-password=xxxxx --mysql-db=db_test --db-driver=mysql --tables=10 --table_size=1000000 prepare

sysbench 1.0.9 (using system LuaJIT 2.0.4)


Creating table 'sbtest3'...
Creating table 'sbtest4'...
Creating table 'sbtest1'...
Creating table 'sbtest2'...
Inserting 1000000 records into 'sbtest3'
Inserting 1000000 records into 'sbtest2'
Inserting 1000000 records into 'sbtest4'
Inserting 1000000 records into 'sbtest1'
Creating a secondary index on 'sbtest4'...
Creating a secondary index on 'sbtest2'...
Creating a secondary index on 'sbtest1'...
Creating a secondary index on 'sbtest3'...
Creating table 'sbtest8'...
Inserting 1000000 records into 'sbtest8'
Creating table 'sbtest6'...
Inserting 1000000 records into 'sbtest6'
Creating table 'sbtest5'...
Inserting 1000000 records into 'sbtest5'
Creating table 'sbtest7'...
Inserting 1000000 records into 'sbtest7'
Creating a secondary index on 'sbtest8'...
Creating a secondary index on 'sbtest6'...
Creating a secondary index on 'sbtest5'...
Creating a secondary index on 'sbtest7'...
Creating table 'sbtest10'...
Inserting 1000000 records into 'sbtest10'
Creating table 'sbtest9'...
Inserting 1000000 records into 'sbtest9'
Creating a secondary index on 'sbtest10'...
Creating a secondary index on 'sbtest9'...

数据准备好之后,开始运行

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
$ sysbench /usr/share/sysbench/oltp_read_only.lua --threads=16 --events=0 --time=300 --mysql-db=db_test --mysql-user=root --mysql-password=xxxxx --db-driver=mysql --table_size=1000000 --range_selects=off --db-ps-mode=disable --report-interval=1 --histogram run

...
[ 298s ] thds: 16 tps: 4907.59 qps: 58910.03 (r/w/o: 49095.86/0.00/9814.17) lat (ms,95%): 4.03 err/s: 0.00 reconn/s: 0.00
[ 299s ] thds: 16 tps: 4915.22 qps: 58992.64 (r/w/o: 49161.20/0.00/9831.44) lat (ms,95%): 4.03 err/s: 0.00 reconn/s: 0.00
[ 300s ] thds: 16 tps: 4909.87 qps: 58891.44 (r/w/o: 49070.70/0.00/9820.74) lat (ms,95%): 4.03 err/s: 0.00 reconn/s: 0.00

SQL statistics:
queries performed:
read: 14678040
write: 0
other: 2935608
total: 17613648
transactions: 1467804 (4892.54 per sec.)
queries: 17613648 (58710.46 per sec.)
ignored errors: 0 (0.00 per sec.)
reconnects: 0 (0.00 per sec.)

General statistics:
total time: 300.0070s
total number of events: 1467804

Latency (ms):
min: 0.80
avg: 3.27
max: 114.77
95th percentile: 3.96
sum: 4797566.33

Threads fairness:
events (avg/stddev): 91737.7500/481.98
execution time (avg/stddev): 299.8479/0.00

登录

通过 mysql_secure_installation 脚本设置好 root 密码后,就可以登录了

1
2
$ mysql -h [host] -u [user] -p
Enter password: ********

登录并使用某一数据库(例子中密码前面不能有空格)

1
$ mysql -h [host] -u [user] -p[passwd] [DB_NAME]

权限

创建用户

1
mysql> CREATE USER 'user'@'localhost' IDENTIFIED BY 'password';

删除用户

1
mysql> DROP USER 'user'@'host';

赋权限

1
2
3
mysql> GRANT SELECT, INSERT, DELETE ON base.* TO 'user'@'localhost' IDENTIFIED BY 'password';
mysql> GRANT SELECT ON `db\_oss\_%`.* TO 'query'@'172.31.%' IDENTIFIED BY '123';
mysql> FLUSH PRIVILEGES;

更新密码

1
mysql> SET PASSWORD FOR 'user'@'host' = PASSWORD('new_pass');

常用命令

查看当前正在 use 的库

1
2
3
4
5
6
7
8
mysql> use mysql;
mysql> select database();
+------------+
| database() |
+------------+
| mysql |
+------------+
1 row in set (0.00 sec)

查看建表语句

1
mysql> show create table t_xxx;

查看索引

1
mysql> show index from table t_xxx;

查看字符集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
mysql> show variables like 'character_set_%';
+--------------------------+----------------------------+
| Variable_name | Value |
+--------------------------+----------------------------+
| character_set_client | utf8 |
| character_set_connection | utf8 |
| character_set_database | utf8 |
| character_set_filesystem | binary |
| character_set_results | utf8 |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | /usr/share/mysql/charsets/ |
+--------------------------+----------------------------+
8 rows in set (0.00 sec)

mysql> show variables like 'collation%';
+----------------------+-----------------+
| Variable_name | Value |
+----------------------+-----------------+
| collation_connection | utf8_general_ci |
| collation_database | utf8_general_ci |
| collation_server | utf8_general_ci |
+----------------------+-----------------+
3 rows in set (0.00 sec)

查看连接

1
mysql> show processlist;

设置连接编码为 utf8

1
mysql> SET NAMES 'utf8';

修改表结构

1
2
mysql> ALTER TABLE [table] ADD COLUMN [column] VARCHAR(120);
mysql> ALTER TABLE [table] DROP COLUMN [column];

插入一条记录

1
mysql> INSERT INTO [table] ([column], [column]) VALUES ('[value]', '[value]');

解释执行

1
mysql> EXPLAIN SELECT * FROM [table] WHERE [column] LIKE '%[value]%';

更新数据

1
mysql> UPDATE [table] SET [column] = '[updated-value]' WHERE [column] = [value];

为一列或者多列添加命名索引

1
2
mysql> ALTER TABLE table ADD INDEX [index_name](column, ...);
mysql> CREATE [UNIQUE|FULLTEXT] INDEX index_name ON table (column,...);

删除索引

1
mysql> DROP INDEX index_name;

使用 group by 和 having 来获取特定组数据

1
mysql> SELECT * FROM table GROUP BY column_1 HAVING condition;

设置所有 row 的某一列

1
mysql> UPDATE table SET column_1 = value_1, ...;

备份和恢复

物理备份

物理备份是拷贝数据库的文件,比较快,适用于数据迁移等场景。

逻辑备份

逻辑备份可以无视版本和架构对数据进行备份,比如进行 mysql 升级就可以使用逻辑备份来备份数据。逻辑备份通常结合全量备份和增量备份。

在 mysql 服务器本地,使用 mysqldump 进行全量备份

1
$ mysqldump --single-transaction --all-databases --flush-logs -u [username] -p > db_backup.sql

如果要备份特定的库和表,可以这样

1
$ mysqldump --single-transaction --flush-logs -u [username] -p [db_name] <table_name> > db_backup.sql

如果不需要导出建库,使用–no-create–db选项,不需要建表语句,使用 –no-create-info 选项;导出特定条件的行,使用 –where 选项。例如

1
$ mysqldump -h 10.137.213.82 -udbuser -ppassword --no-create-info db_hgame_1020 t_user --where="accid=776085" > /tmp/776085.sql

导入备份的 sql 语句

1
$ mysql -hhostname -uusername -ppassword databasename < backupfile.sql

或者使用 mysql 的 source 命令

1
mysql> source backup.sql;

如果备份的数据比较大,可以压缩一下

1
2
$ mysqldump -u [username] -p --default-character-set=utf8 [db_name] | gzip > backup.sql.gz
$ gunzip -c backup.sql.gz | mysql -u [username] -p [db_name] 1> load.log 2>err.log

如果想将一台服务器的数据直接导入另外一台机器,可以

1
$ mysqldump -u root -p database_name | mysql -h other-host database_name

mysqldump 需要 SELECT 权限,对于没有指定 –single-transaction 选项的还需要 LOCK TABLES 权限。

对于增量备份,可以使用 binlog。

查看所有的 binlog

1
2
3
4
5
6
7
mysql> show binary logs;
+------------------+-----------+
| Log_name | File_size |
+------------------+-----------+
| mysql-bin.000001 | 120 |
+------------------+-----------+
1 row in set (0.00 sec)

查看当前正在写的 binlog

1
2
3
4
5
6
7
mysql> show master status;
+------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+------------------+----------+--------------+------------------+-------------------+
| mysql-bin.000001 | 120 | | | |
+------------------+----------+--------------+------------------+-------------------+
1 row in set (0.00 sec)

导出特定时间的 binlog 以便根据 pos 来进行具体恢复(binlog 是二进制格式,利用 mysqlbinlog 工具导出成 sql)

1
$ mysqlbinlog --start-datetime="2018-05-20 9:58:00" --stop-datetime="2018-05-20 10:01:00" /data/mysql/mysql-bin.000001 > /tmp/mysql_restore.sql

通过 mysql_restore.sql 我们发现只需要恢复到 log_pos 为368312 的位置,则执行

1
$ mysqlbinlog --stop-position=368312 /data/mysql/mysql-bin.000001 | mysql -uroot -p

卸载

先查询安装了哪些 mysql 的文件,例如

1
2
3
4
5
6
7
$ rpm -qa | grep mysql
mysql-community-libs-5.7.21-1.el7.x86_64
mysql-community-devel-5.7.21-1.el7.x86_64
mysql-community-client-5.7.21-1.el7.x86_64
mysql-community-server-5.7.21-1.el7.x86_64
mysql-community-common-5.7.21-1.el7.x86_64
mysql-community-libs-compat-5.7.21-1.el7.x86_64

然后使用 yum remove 命令卸载

1
2
3
$ sudo yum remove mysql
$ sudo yum remove mysql-community-common
$ sudo yum -y remove mysql-libs

其他

如果已知旧密码,需要修改 root 密码为新密码,可以这样

1
2
3
4
$ which mysqladmin
/usr/bin/mysqladmin
$ /usr/bin/mysqladmin -u root -p password 'new-password'
Enter password:

如果忘记密码,在 my.cnf [mysqld] 部分最后添加 skip-grant-tables 选项

1
2
[mysqld]
skip-grant-tables

重启 mysqld

1
$ sudo service mysqld restart

使用 root 无需密码登录

1
$ mysql -uroot

更新密码

1
2
3
mysql> use mysql;
mysql> UPDATE user SET password=password('new-password') WHERE user = 'root' ;
mysql> flush privileges ;

退出,删除 skip-grant-tables 选项并重启 mysqld 即可

参考资料

如何选择 MySQL 版本

How to Benchmark Performance of MySQL & MariaDB using SysBench

<深入浅出MySQL>笔记之优化

发表于 2018-09-03 |

本文主要是 <深入浅出 MySQL> 一书中对于 MySQL 优化相关内容的读书笔记,另外参考了一些其他资料得出的总结性内容。

引擎选择

查看本 MySQL server 支持的引擎

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
65
mysql>  show engines\G;
*************************** 1. row ***************************
Engine: InnoDB
Support: DEFAULT
Comment: Supports transactions, row-level locking, and foreign keys
Transactions: YES
XA: YES
Savepoints: YES
*************************** 2. row ***************************
Engine: MRG_MYISAM
Support: YES
Comment: Collection of identical MyISAM tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 3. row ***************************
Engine: MEMORY
Support: YES
Comment: Hash based, stored in memory, useful for temporary tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 4. row ***************************
Engine: BLACKHOLE
Support: YES
Comment: /dev/null storage engine (anything you write to it disappears)
Transactions: NO
XA: NO
Savepoints: NO
*************************** 5. row ***************************
Engine: MyISAM
Support: YES
Comment: MyISAM storage engine
Transactions: NO
XA: NO
Savepoints: NO
*************************** 6. row ***************************
Engine: CSV
Support: YES
Comment: CSV storage engine
Transactions: NO
XA: NO
Savepoints: NO
*************************** 7. row ***************************
Engine: ARCHIVE
Support: YES
Comment: Archive storage engine
Transactions: NO
XA: NO
Savepoints: NO
*************************** 8. row ***************************
Engine: PERFORMANCE_SCHEMA
Support: YES
Comment: Performance Schema
Transactions: NO
XA: NO
Savepoints: NO
*************************** 9. row ***************************
Engine: FEDERATED
Support: NO
Comment: Federated MySQL storage engine
Transactions: NULL
XA: NULL
Savepoints: NULL
9 rows in set (0.00 sec)

MyISAM 引擎适合多读的情况,对于有很多写的情况,其性能很差,因为其只支持表锁。

InnoDB 引擎支持行锁和表锁,且默认情况下是行锁。且支持事务。对于有不少写的情况,优先选择 InnoDB 引擎。

问题定位

要查找自己业务数据库的问题,可以依次采取下面措施来定位

SQL 语句频率统计

打印数据库启动后运行一段时间后,各项数据统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mysql> show global status;
+--------------------------------+------------+
| Variable_name | Value |
+--------------------------------+------------+
| Aborted_clients | 631 |
| Aborted_connects | 7 |
| Binlog_cache_disk_use | 0 |
| Binlog_cache_use | 0 |
| Bytes_received | 3967622624 |
| Bytes_sent | 8593148107 |
| Com_admin_commands | 1 |
| Com_assign_to_keycache | 0 |
| Com_alter_db | 0 |
......

其中,Com_xxx 表示 xxx 语句的执行次数,通过这类选项我们可以统计数据库的增删改查次数。

例如,生产环境的次数统计数据如下

1
2
3
4
| Com_update                     | 616602     |
| Com_delete | 87616 |
| Com_select | 111538287 |
| Com_insert | 283173 |

查询数量远远大于更新数量。

查找慢查询语句

通过以下配置项开启慢查询日志

1
2
3
4
[mysqld]
slow_query_log=1
long_query_time=1 # 1s
slow_query_log_file=/data/mysql/mysqld-slow.log

开启后测试一下是否起效

1
mysql> SELECT SLEEP(2);

此时到 mysql 数据目录,我这里是 /data/mysql,查看 mysqld-slow.log

1
2
3
4
5
6
7
8
$ sudo cat mysqld-slow.log 
/usr/libexec/mysqld, Version: 5.1.73-log (Source distribution). started with:
Tcp port: 0 Unix socket: /data/mysql/mysql.sock
Time Id Command Argument
# Time: 180913 10:28:34
# Query_time: 2.000476 Lock_time: 0.000000 Rows_sent: 1 Rows_examined: 0
SET timestamp=1536805714;
SELECT SLEEP(2);

可见,慢查询的 SQL 语句以及执行情况已经被记录下来。可以利用 explain 来具体分析慢查询语句的问题所在。

Explain慢查询

下面是一个典型的慢查询的语句 Expain 分析结果

1
2
3
4
5
6
7
8
9
10
11
*************************** 1. row *************************** 
id: 1
select_type: SIMPLE
table: a
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1000
Extra: Using where

type 表示表的连接类型,常见的有 ALL表示全表扫描、Ref 表示索引查找

key 表示使用的索引

rows 表示扫码的行数

上面的结果表明 mysql 使用的全表扫描,性能很差,我们可以为 where 字段建个索引

1
mysql> create index ind_sale_year on t_sale(year);

再次分析结果如下

1
2
3
4
5
6
7
8
9
10
11
*************************** 1. row *************************** 
id: 1
select_type: SIMPLE
table: a
type: ref
possible_keys: ind_sale_year
key: ind_sale_year
key_len: 2
ref: const
rows: 1
Extra: Using where

效果很明显,只需要扫描1行了。

索引

大部分 MySQL 的索引(PRIMARY KEY, UNIQUE, INDEX, and FULLTEXT) 是以 BTREE 方式存储的。

索引具有前缀特性,特别是针对多列索引的情况,从左到右部分连续涉及到的字段 where 查询都会用到索引。例如我们有个三列索引建在 (col1, col2, col3) 上,那么where 条件是 (col1) 或者 (col1, col2) 或者 (col1, col2, col3) 都会用到该索引,其他情况如 (col2)、(col1, col3) 等均不会用到索引。

对于 where 条件中有 like 的语句,只有 % 不是第一个字符的才会用到索引。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mysql> explain select * from company2 where name like '%3'\G; *************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: company2
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1000
Extra: Using where

mysql> explain select * from company2 where name like '3%'\G; *************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: company2
type: range
possible_keys: ind_company2_name
key: ind_company2_name
key_len: 11
ref: NULL
rows: 103
Extra: Using where

可以发现第一个例子没有使用索引,而第二例子就能够使用索引,区别就在于 % 的位置不同,前者把 % 放到第一位就不能用到索引,而后者没有放到第一位就使用了索引。如果确实有必要搜索 %pattern%的情况,索引是无法解决的,可以考虑使用 mysql 的 全文索引

如果 MySQL 估计使用索引比全表扫描更慢,则不使用索引。例如

1
mysql> SELECT * FROM table_name where key_part1 > 1 and key_part1 < 90;

假如 key_part1 的取值均匀分布在 1 - 100,那此时使用全表扫描可能更快。

此外,对于用 or 分割开的条件,如果 or 前的条件中的列有索引,而后面的列中没有索引,那么也不会用到索引。例如

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
mysql> show index from sales\G;
*************************** 1. row ***************************
Table: sales
Non_unique: 1
Key_name: ind_sales_year
Seq_in_index: 1
Column_name: year
Collation: A
Cardinality: NULL
Sub_part: NULL
Packed: NULL
Null:
Index_type: BTREE
Comment:

mysql> explain select * from sales where year = 2001 or country = 'China'\G; *************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: sales
type: ALL
possible_keys: ind_sales_year
key: NULL
key_len: NULL
ref: NULL
rows: 12
Extra: Using where

查看索引使用情况

1
2
3
4
5
6
7
8
9
10
11
12
mysql> show global status like 'Handler_read%';
+-----------------------+----------+
| Variable_name | Value |
+-----------------------+----------+
| Handler_read_first | 299 |
| Handler_read_key | 20554298 |
| Handler_read_next | 17493910 |
| Handler_read_prev | 0 |
| Handler_read_rnd | 4269136 |
| Handler_read_rnd_next | 11003400 |
+-----------------------+----------+
6 rows in set (0.00 sec)

优化 SQL 语句

优化 select

当要查询的数据确认只有一条记录时,我们可以使用 limit 1 来提高性能,数据库引擎可以在找到一条记录后立即返回而不是再继续向后查找(实验结果并非如此)。例如

1
mysql> select * from t_user where accid = 123456 limit 1;

效果就要比下面的好

1
mysql> select * from t_user where accid = 123456;
优化 insert
  • 对于插入多行的情况,一次插入多行比分多次插入数据要快,主要是避免了多次与数据库的连接、关闭操作。如

    1
    mysql> insert into t_test(col1, col2) values(1, 2) (3, 4) (5, 6);
  • 当从文件中加载表时,使用 LOAD DATA INFILE,通常比很多 insert 的语句要快。

优化 group by

默认情况下 group by 语句会对字段进行排序,效果类似于 order by 语句。如果避免排序带来的性能消耗,可以在 group by 语句后显示指定 order by null 来禁止排序。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mysql> explain select id,sum(moneys) from sales2 group by id\G; *************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: sales2
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1000
Extra: Using temporary; Using filesort

mysql> explain select id,sum(moneys) from sales2 group by id order by null\G; *************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: sales2
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1000
Extra: Using temporary

从上面的例子可以看出第一个 SQL 语句需要进行 filesort,而第二个 SQL 由于 ORDER BY NULL 不需要进行 filesort,而 filesort 往往非常耗费时间。

表字段类型

尽量使用短的数据类型

如果数据容量足够,尽量选用比较段的数据类型,例如可以使用 SMALLINT 就不用 INT。

尽量使用 NOT NULL

对于允许 NULL 的字段,数据库需要额外的空间来存储该字段是否为 NULL,且在涉及比较操作时,会更为复杂。因此除非特殊情况,尽量使用 NOT NULL。

定长 vs 变长

常见的非定长数据类型是 VARCHAR,TEXT,BLOB,其他基本都是定长。

对于 MyISAM 引擎,固定长度的表会提高性能,因为搜寻得会更快一些,因为这些固定的长度是很容易计算下一个数据的偏移量的,所以读取的自然也会很快。而如果字段不是定长的,那么,每一次要找下一条的话,需要查找主键索引。

并且,固定长度的表在 crash 之后也更容易被缓存和重建。不过,唯一的副作用是,固定长度的字段会浪费一些空间,因为定长的字段无论你用不用,他都是要分配那么多的空间。

而对于 InnoDB 引擎,行存储格式并没有区分定长还是变长,所有数据行都使用指向数据列值的头指针。因此变长数据类型的优势会更大。

VARCHAR vs BLOB

如果存储的数据大小小于8KB,那使用二进制 varchar 类型,而不使用 blob。

顺序主键(InnoDB)

对于 InnoDB 引擎,如果主键的值是个完全随机值,则比较好的方式是在主键前面加上一个当前日期或者时间的前缀,那么这些有序的主键存储时就会相邻,在插入或者获取时会更快。

压缩

如果一个 blob 内存储的是很大的文本内容,可以考虑在存储时先进行压缩。

切割

水平切割

一般数据库的行数如果超过1000w 行,则读写性能会显著下降。因此可以根据一定规则来对所有数据进行水平切割,也即分表分库。分割后可以降低在查询时需要读的数据和索引的页数,同时也降低了索引的层数,提高查询速度。

垂直切割

和水平切割对应的就是垂直切割了,意思是对数据库里的列进行分类,单表不能包含太多的列,可以划分成很多张表,列少的小表相对于列多的大表性能会好很多。

一个例子是,如果表中有一些很不常用的列,可以将这些单独拎出来组成一张表。

还有个例子是,假如有一张表拥有一个 last_login 的字段,每次登录之后都会更新该字段。更新该字段意味着数据的查询缓存每次都会被清空。因此将该字段单独出来,保证查询缓存的有效性也是个不错的主意。

另外,你需要注意的是,这些被分出去的字段所形成的表,你不会经常性地去 Join 他们,不然的话,这样的性能会比不分割时还要差。

自助分析

如果现有数据库表已经有一定的数据量了, 则 MySQL 的自助分析工具 PROCEDURE ANALYSE()会显得很有用处。 它会根据表中列的数据类型和实际存储的数据来分析,并给出优化建议。

定期优化

对含有 TEXT 和 BLOB 字段的表,如果经常做删除和修改记录的操作要定时执行 OPTIMIZE TABLE 功能对表进行碎片整理。

删除操作会在数据表中留下很大的『空洞』,以后填入这些『空洞』的记录在插入的性能上会有影响。为了提高性能建议定期使用 OPTIMIZE TABLE 功能对这类表进行碎片整理,避免因为『空洞』导致性能问题。

例如

1
mysql> OPTIMIZE TABLE t_territory_action;

应用优化

  • 使用连接池
  • 增加 cache 层,如 redis、memcache 等
  • 主从读写分离
  • 分布式

参考资料

<深入浅出 MySQL>

Top 20+ MySQL Best Practices

Linux内存Cache问题定位

发表于 2018-09-01 |

引子

最近在做服务器压力测试,经常关注的就是服务器的负载情况。我们目前服务器的逻辑进程主要使用 lua 编写,开启定期 GC 后单个用户占用内存大概在1M 左右,开多逻辑进程后,单个进程占用内存都不大。

这次的小范围内部测试,只申请了一台 4 核 8 G的机器,测试开始后,cpu 和其他负载都不大。但是跑了大概1个小时左右,查看内存发现 free 的很少,只有几百兆了。其实实际占用的也并不多,但是很大一部分内存被 cache 住了,大概有 4 个多 G。

1
2
3
4
5
$ free -m
total used free shared buffers cached
Mem: 7870 7698 172 0 153 4852
-/+ buffers/cache: 2691 5179
Swap: 0 0 0

以前只知道 cached 的内存一般是缓存的磁盘文件,如果后续需要内存,内核会采取策略释放这些 cached 的内存,不会出什么问题。但是这次很想知道是什么文件被 cache 住了,有没有优化措施。于是决定查下资料定位一下是哪些文件导致内存被大量 cache 的。

内存使用

首先通过 proc 文件系统的 meminfo 看下当前内存的使用情况。由于 top、vmstat、free 命令都是采样的 proc 文件系统的数据,所以直接看 meminfo 会更全面准确。

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
$ cat /proc/meminfo 
MemTotal: 8059260 kB
MemFree: 176040 kB
Buffers: 157708 kB
Cached: 4969824 kB
SwapCached: 0 kB
Active: 6487916 kB
Inactive: 1093960 kB
Active(anon): 2454360 kB
Inactive(anon): 536 kB
Active(file): 4033556 kB
Inactive(file): 1093424 kB
Unevictable: 0 kB
Mlocked: 0 kB
SwapTotal: 0 kB
SwapFree: 0 kB
Dirty: 24 kB
Writeback: 0 kB
AnonPages: 2454348 kB
Mapped: 66424 kB
Shmem: 552 kB
Slab: 214400 kB
SReclaimable: 188528 kB
SUnreclaim: 25872 kB
KernelStack: 4176 kB
PageTables: 10540 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 4029628 kB
Committed_AS: 10610552 kB
VmallocTotal: 34359738367 kB
VmallocUsed: 30988 kB
VmallocChunk: 34359664624 kB
HardwareCorrupted: 0 kB
AnonHugePages: 2281472 kB
HugePages_Total: 0
HugePages_Free: 0
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
DirectMap4k: 8180 kB
DirectMap2M: 8380416 kB

其中anno 匿名内存应该就是我们程序分配的堆栈内存,而 file 应该就是和文件关联的内存,比如我们的程序代码,打开的文件等。可以看到 Active(file)项已经很大了。

那怎么知道是哪个进程占用的比较多呢?这时候就要借助 top 命令的输出了。

image-20180901122607751

shift + M 按内存占用排序后可以看到,果然是我们的游戏进程占用最多。

看 RES 列的实际占用内存并不高,但是虚拟内存 VIRT 就将近3倍于实际使用内存,多出来的内存基本就是 cache 住的文件缓存了。

为了查看进程打开了哪些文件,我们 lsof 看下

1
lsof -p PID

具体结果由于涉及业务这里就不贴了, 打开的文件主要是程序可执行文件、连接的 so、网络连接、日志文件等。从 size 大小看除日志文件外,其他都很小。我们的日志轮转大小是100M,打开的日志文件大概有 200M 左右,总的加起来也不会有4G 大小。

于是猜想,是不是可能缓存了其他日志文件。由于我们测试为了方便查找问题,是开启 DEBUG 级别日志的,因此日志量比较大,一个小时单个进程轮转了9个日志文件,如果缓存了部分,那就很可观了。为了证实这个猜想,我查找了一些工具来统计 cache 内存的大小。

统计大小

网上推荐了 linux-ftools 这个工具来进行统计,fincore 是其中的一个。fincore 的工作原理是将指定的文件的相应 inode data 与 kernel 的 page cache table 做对比,如果 page cache table 有这个 inode 信息,就找该inode 对应的 data block 的大小。因为 kernel 的 page cache table 只存储 data block 的引用而不是文件名,即文件的 inode 信息。所以并没有任何一个工具运行一次就可以找出所有的文件使用缓存的情况。

不过遗憾的是,linux-ftools 不再维护了,编译这个程序会出问题。所幸的是,还有个 Go 实现的类似功能的工具,叫 pcstat,下载方式是

1
2
3
curl -L -o pcstat https://github.com/tobert/pcstat/raw/2014-05-02-01/pcstat.x86_64
或者
curl -L -o pcstat https://github.com/tobert/pcstat/raw/2014-05-02-01/pcstat.x86_32

这里感谢下作者的付出。

我们列出 log 目录下的所有目录文件,然后运行 pcstat 命令得到统计结果如下

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
$ ~/pcstat `ls *.0831*.log`
|------------------------------+----------------+------------+-----------+---------|
| Name | Size | Pages | Cached | Percent |
|------------------------------+----------------+------------+-----------+---------|
| dbsvr.0831.00.log | 91415 | 23 | 6 | 026.087 |
| dbsvr.error.0831.00.log | 2 | 1 | 1 | 100.000 |
| gamesvr1.0831.00.log | 104861059 | 25601 | 8040 | 031.405 |
| gamesvr1.0831.01.log | 104940405 | 25621 | 20750 | 080.988 |
| gamesvr1.0831.02.log | 105522786 | 25763 | 23659 | 091.833 |
| gamesvr1.0831.03.log | 105247528 | 25696 | 25693 | 099.988 |
| gamesvr1.0831.04.log | 105221709 | 25689 | 25689 | 100.000 |
| gamesvr1.0831.05.log | 105098602 | 25659 | 25659 | 100.000 |
| gamesvr1.0831.06.log | 105151912 | 25672 | 22430 | 087.371 |
| gamesvr1.0831.07.log | 104904682 | 25612 | 3266 | 012.752 |
| gamesvr1.0831.08.log | 78897232 | 19263 | 4666 | 024.223 |
| gamesvr1.error.0831.00.log | 600715 | 147 | 10 | 006.803 |
| gamesvr2.0831.00.log | 104963951 | 25626 | 9292 | 036.260 |
| gamesvr2.0831.01.log | 104965184 | 25627 | 19936 | 077.793 |
| gamesvr2.0831.02.log | 105106982 | 25661 | 14924 | 058.158 |
| gamesvr2.0831.03.log | 105292165 | 25707 | 24521 | 095.386 |
| gamesvr2.0831.04.log | 104927914 | 25618 | 23745 | 092.689 |
| gamesvr2.0831.05.log | 105090378 | 25657 | 13332 | 051.962 |
| gamesvr2.0831.06.log | 105175478 | 25678 | 10070 | 039.216 |
| gamesvr2.0831.07.log | 104893356 | 25609 | 21299 | 083.170 |
| gamesvr2.0831.08.log | 73241449 | 17882 | 10886 | 060.877 |
| gamesvr2.error.0831.00.log | 618867 | 152 | 9 | 005.921 |
| gamesvr3.0831.00.log | 105143734 | 25670 | 10861 | 042.310 |
| gamesvr3.0831.01.log | 104923026 | 25616 | 19485 | 076.066 |
| gamesvr3.0831.02.log | 104971350 | 25628 | 21631 | 084.404 |
| gamesvr3.0831.03.log | 104984690 | 25632 | 23985 | 093.574 |
| gamesvr3.0831.04.log | 105210760 | 25687 | 23336 | 090.848 |
| gamesvr3.0831.05.log | 104935556 | 25620 | 12325 | 048.107 |
| gamesvr3.0831.06.log | 105270020 | 25701 | 17420 | 067.779 |
| gamesvr3.0831.07.log | 105085266 | 25656 | 23922 | 093.241 |
| gamesvr3.0831.08.log | 50092509 | 12230 | 11285 | 092.273 |
| gamesvr3.error.0831.00.log | 563414 | 138 | 6 | 004.348 |
| gamesvr4.0831.00.log | 104876090 | 25605 | 23576 | 092.076 |
| gamesvr4.0831.01.log | 105038856 | 25645 | 22705 | 088.536 |
| gamesvr4.0831.02.log | 105311253 | 25711 | 23118 | 089.915 |
| gamesvr4.0831.03.log | 105305787 | 25710 | 24598 | 095.675 |
| gamesvr4.0831.04.log | 104948309 | 25623 | 24237 | 094.591 |
| gamesvr4.0831.05.log | 104858573 | 25601 | 24100 | 094.137 |
| gamesvr4.0831.06.log | 105359076 | 25723 | 24075 | 093.593 |
| gamesvr4.0831.07.log | 105010884 | 25638 | 23978 | 093.525 |
| gamesvr4.0831.08.log | 58704318 | 14333 | 13332 | 093.016 |
| gamesvr4.error.0831.00.log | 605733 | 148 | 11 | 007.432 |
| gatesvr.0831.00.log | 104982018 | 25631 | 802 | 003.129 |
| gatesvr.0831.01.log | 30392638 | 7421 | 395 | 005.323 |
| gatesvr.error.0831.00.log | 364959 | 90 | 4 | 004.444 |
| gmtool.0831.00.log | 1818 | 1 | 0 | 000.000 |
| gmtool.error.0831.00.log | 2 | 1 | 1 | 100.000 |
| sessionsvr.0831.00.log | 104899344 | 25611 | 3047 | 011.897 |
| sessionsvr.0831.01.log | 21431262 | 5233 | 1166 | 022.282 |
| sessionsvr.error.0831.00.log | 25675 | 7 | 1 | 014.286 |
|------------------------------+----------------+------------+-----------+---------|

可以看到大量的轮转过的日志文件,虽然没有再被打开使用,仍然被操作系统缓存了起来,百分比不等。

我们可以使用 linux 的清除 cache 命令来手动释放一下试试,使用 root 执行

1
2
3
$ sync
$ echo 3 > /proc/sys/vm/drop_caches
$ echo 0 > /proc/sys/vm/drop_caches # 恢复0

再查看内存使用情况

1
2
3
4
5
$ free -m
total used free shared buffers cached
Mem: 7870 2587 5282 0 2 62
-/+ buffers/cache: 2521 5348
Swap: 0 0 0

cache 的大量内存被释放了,再查看 pcstat 的统计结果

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
$ ~/pcstat `ls *.0831*.log`
|------------------------------+----------------+------------+-----------+---------|
| Name | Size | Pages | Cached | Percent |
|------------------------------+----------------+------------+-----------+---------|
| dbsvr.0831.00.log | 91415 | 23 | 0 | 000.000 |
| dbsvr.error.0831.00.log | 2 | 1 | 0 | 000.000 |
| gamesvr1.0831.00.log | 104861059 | 25601 | 0 | 000.000 |
| gamesvr1.0831.01.log | 104940405 | 25621 | 0 | 000.000 |
| gamesvr1.0831.02.log | 105522786 | 25763 | 0 | 000.000 |
| gamesvr1.0831.03.log | 105247528 | 25696 | 0 | 000.000 |
| gamesvr1.0831.04.log | 105221709 | 25689 | 0 | 000.000 |
| gamesvr1.0831.05.log | 105098602 | 25659 | 0 | 000.000 |
| gamesvr1.0831.06.log | 105151912 | 25672 | 0 | 000.000 |
| gamesvr1.0831.07.log | 104904682 | 25612 | 0 | 000.000 |
| gamesvr1.0831.08.log | 78897232 | 19263 | 0 | 000.000 |
| gamesvr1.error.0831.00.log | 600715 | 147 | 0 | 000.000 |
| gamesvr2.0831.00.log | 104963951 | 25626 | 0 | 000.000 |
| gamesvr2.0831.01.log | 104965184 | 25627 | 0 | 000.000 |
| gamesvr2.0831.02.log | 105106982 | 25661 | 0 | 000.000 |
| gamesvr2.0831.03.log | 105292165 | 25707 | 0 | 000.000 |
| gamesvr2.0831.04.log | 104927914 | 25618 | 0 | 000.000 |
| gamesvr2.0831.05.log | 105090378 | 25657 | 0 | 000.000 |
| gamesvr2.0831.06.log | 105175478 | 25678 | 0 | 000.000 |
| gamesvr2.0831.07.log | 104893356 | 25609 | 0 | 000.000 |
| gamesvr2.0831.08.log | 73241449 | 17882 | 0 | 000.000 |
| gamesvr2.error.0831.00.log | 618867 | 152 | 0 | 000.000 |
| gamesvr3.0831.00.log | 105143734 | 25670 | 0 | 000.000 |
| gamesvr3.0831.01.log | 104923026 | 25616 | 0 | 000.000 |
| gamesvr3.0831.02.log | 104971350 | 25628 | 0 | 000.000 |
| gamesvr3.0831.03.log | 104984690 | 25632 | 0 | 000.000 |
| gamesvr3.0831.04.log | 105210760 | 25687 | 0 | 000.000 |
| gamesvr3.0831.05.log | 104935556 | 25620 | 0 | 000.000 |
| gamesvr3.0831.06.log | 105270020 | 25701 | 0 | 000.000 |
| gamesvr3.0831.07.log | 105085266 | 25656 | 0 | 000.000 |
| gamesvr3.0831.08.log | 50092509 | 12230 | 0 | 000.000 |
| gamesvr3.error.0831.00.log | 563414 | 138 | 0 | 000.000 |
| gamesvr4.0831.00.log | 104876090 | 25605 | 0 | 000.000 |
| gamesvr4.0831.01.log | 105038856 | 25645 | 0 | 000.000 |
| gamesvr4.0831.02.log | 105311253 | 25711 | 0 | 000.000 |
| gamesvr4.0831.03.log | 105305787 | 25710 | 0 | 000.000 |
| gamesvr4.0831.04.log | 104948309 | 25623 | 0 | 000.000 |
| gamesvr4.0831.05.log | 104858573 | 25601 | 0 | 000.000 |
| gamesvr4.0831.06.log | 105359076 | 25723 | 0 | 000.000 |
| gamesvr4.0831.07.log | 105010884 | 25638 | 0 | 000.000 |
| gamesvr4.0831.08.log | 58704318 | 14333 | 0 | 000.000 |
| gamesvr4.error.0831.00.log | 605733 | 148 | 0 | 000.000 |
| gatesvr.0831.00.log | 104982018 | 25631 | 0 | 000.000 |
| gatesvr.0831.01.log | 30392638 | 7421 | 0 | 000.000 |
| gatesvr.error.0831.00.log | 364959 | 90 | 0 | 000.000 |
| gmtool.0831.00.log | 1818 | 1 | 0 | 000.000 |
| gmtool.error.0831.00.log | 2 | 1 | 0 | 000.000 |
| sessionsvr.0831.00.log | 104899344 | 25611 | 0 | 000.000 |
| sessionsvr.0831.01.log | 21431262 | 5233 | 0 | 000.000 |
| sessionsvr.error.0831.00.log | 25675 | 7 | 0 | 000.000 |
|------------------------------+----------------+------------+-----------+---------|

注意,这里只是通过清除命令展示一下 cache 前后的统计结果,生产环境在运行时,不要手动释放这些缓存,否则可能带来性能方面的问题,应该放心的由操作系统去实时他的策略。

参考资料

/proc/meminfo官方文档

谁吃了我的Linux内存?

linux下服务器端口冲突

发表于 2018-08-30 |

引子

在生产环境部署服务器集群的时候,由于服务器进程众多,需要监听的端口也非常多,通常我们会通过一定规则为每个进行指定特定的端口来绑定,这没什么问题。

但是由于服务器之间需要通信,因此服务器进程之间会建立大量的 TCP 连接。在主动连接的一方,我们不必类似于监听一样手动 bind 固定的端口,操作系统会为我们随机选择一个端口,来与目的端口进行通信。当连接数量很多时,这种随机选择的端口会和我们指定的端口冲突。(注意 UDP 虽然没有连接的概念,也是要占用端口的)

一种典型的情况是

  • 进程 A 和 B 启动完成,B 建立了一条连接到 A,本地端口选择为10000

  • 进程 C 恰好监听在端口10000,于是 C 进程启动时就会因为端口被占用而启动失败

解决方案

为了解决这个问题,我们通常分别约定监听端口和随机选择端口的范围。

例如,在指定监听端口的时候,我们可以指定 5000 - 15000 是可用的监听范围。而本地随机选择的端口范围设置为 15000 - 65000。这样就可以有效避免冲突。

在 linux 下有内核选项可以设置本地端口的范围:

1
2
$ cat /proc/sys/net/ipv4/ip_local_port_range 
4000 65000

关于 ip_local_port_range 的定义如下

1
2
3
The /proc/sys/net/ipv4/ip_local_port_range defines the local port range that is used by TCP and UDP traffic to choose the local port. 
You will see in the parameters of this file two numbers: The first number is the first local port allowed for TCP and UDP traffic on the server, the second is the last local port number.
For high-usage systems you may change its default parameters to 32768-61000 -first-last.

我们利用 sysctl 命令修改其值为15000 65000

1
sudo sysctl -w net.ipv4.ip_local_port_range="15000 65000"

TIME_WAIT

还有一个很常见的影响端口使用的是连接 TIME_WAIT 状态。

在 TCP 连接关闭时,需要经历四次挥手的过程,而主动发起关闭的一方,发送完最后一个 ACK (最后一步) 后,进入 TIME_WAIT 状态,且需要等待 2MSL 的时间。等待时间过去后,连接关闭。

示意图如下

D0D404D6-3813-412A-B5C5-4333E7F20F5D

MSL(Maximum Segment Lifetime):报文最大生存时间,用于限制 TCP 包在网络中最大留存时间,超过这个时间,包将被丢弃。IP 层有个类似的 TTL 跳数来决定 IP 报文的去留,MSL 和 TTL 共同限制了 TCP 包的生存时间。由此可知,当网络拥塞时,超过 TCP 生存时间的包会被丢弃,导致丢包。RFC 建议 MSL 为 2 分钟,而 Linux 下为 30 秒。

TIME_WAIT 等待的时间 2MSL 是常量 TCP_TIMEWAIT_LEN(linux 下就是1分钟)定义的,除非重新编译内核,否则不能修改。

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

那为什么需要等待 2MSL 的时间呢?

第一个原因,是为了正常终止通信的一半通道(我方已关闭一半,确保对方也正确关闭另一半)。由于 TCP 是全双工,连接终止时需要双方分别关闭对应的通道。在收到进入 TIME_WAIT 时,肯定一方已经关闭了一半通道且本方收到了关闭另一半通道的 FIN 包,剩下的就是本方发出 FIN 包的 ACK,然后等待接收,完成后整个 TCP 连接就关闭了。

也就是说,进入 TIME_WAIT 状态之后,唯一的步骤就是对方收到 ACK 包之后关闭 TCP。这里的问题就在于万一 ACK 包由于网络拥塞没有及时被对方收到,那一段时间之后,对方会认为是之前发的 FIN 包没有被收到造成的,采取的措施就是重发 FIN 包到本方,那之前丢的那个 ACK 包的最大存活时间是 MSL,重发的 FIN 包也是在 MSL 时间内存活,加起来就是 2MSL 的时间。只要在 2MSL 时间内,其状态一直是 TIME_WAIT,那么就能处理这种丢包的情况。处理完了,就能关闭了(仁至义尽,如果再丢包,那就不管了)。

可以想像一下,如果没有这个 TIME_WAIT 状态而直接关闭呢?如果最后一个 ACK 丢失,那对方会处于 LAST_ACK 状态。这种情况下,如果主动关闭的一方再次发起一次连接到对方,且双方端口也一样,那该条连接相当于被复用,对方在该连接收到新的三次握手的 SYN 包 (并且序列号满足要求) 后,会直接返回 RST,认为包非法。

还有一个原因,就是让被关闭的连接上所有的包都消逝掉,防止新建的连接误收了之前连接的包。这种情况可能发生吗?

我们假设进入 TIME_WAIT 状态之后只等很短的时间就关闭连接,释放资源,在这里就是之前连接的本方端口可以再次被使用了。如果是客户端主动关闭的,那可以复用的就是之前随机分配的本地端口(客户端 connect 服务器的时候,操作系统随机选择一个本地端口与服务器的固定端口建立连接);如果是服务器主动关闭的,可以复用的就是之前监听的固定端口。

如果端口被复用后,连接的五元组(双方 IP,双方端口,协议类型)都一样,那么这条连接建立后就很可能接收到之前被关闭的同样五元组的连接的残余包了。(其实我在想,用五元组标识连接有隐患,为啥不再搞个序列号加以区分)。

残余包很可能是比较危险的,例如上面提到的如果本方发出的最后一个 ACK 包没有收到,那对方重发的 FIN 包被新建的连接收到了,那就麻烦了。此外,还可能有延迟收到的普通包。

有了持续 2MSL 时间的 TIME_WAIT 状态,就可以处理延迟包的情况了:收到重发的 FIN 包,再回一个 ACK;收到其他包,直接丢弃。

了解了 TIME_WAIT 状态的原理,我们再来说下其造成的端口冲突情况。

TIME_WAIT 状态,如果是客户端主动断开连接,影响并不大,主要是之前随机选择的本地端口 2MSL 时间内不可用,再建立连接时选一个其他的就可以了。但是如果是服务器主动断开连接呢?我们经常遇到的一种情况就是服务器临时关闭然后重启,此时服务器会主动关闭所有监听端口上已经建立的连接,然后重新启动监听在同一端口。

前面我们也分析了,如果是服务器主动断开的连接,本方端口,也就是监听的端口,在 2MSL 时间内不能再被使用,这造成了我们重启启动进程并监听时,提示『端口已被使用』,启动失败。

除了 TIME_WAIT,其他状态如 CLOSE_WAIT、ESTABLISHED 状态的连接对应的本方端口也是不可用的

为了解决这个问题,通常服务器的做法是在设置监听 socket 启用 SO_REUSEADDR 选项重用处于 2MSL 时间内的连接资源。

具体使用可以这样

1
2
int reuseaddr = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuseaddr, sizeof(int));

事实上 SO_REUSEADDR 选项不仅能重用端口,还能重用 IP 资源。例如一个进程在监听在地址 0.0.0.0:80,而另一个进程可以监听地址 10.1.164.1:80。

另一种方式是修改 linux 的内核参数 net.ipv4.tcp_tw_reuse,缺省是 0 不开启重用。

1
$ sudo echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse

从名字可以想到,这个选项是允许重用处于 TIME_WAIT 状态的 socket 连接。man 7 tcp 的文档中有如下解释

1
2
tcp_tw_reuse (Boolean; default: disabled; since Linux 2.4.19/2.6)
Allow to reuse TIME_WAIT sockets for new connections when it is safe from protocol viewpoint.

虽然选项设置里名字里是 ipv4,对 ipv6 也是适用的。

其重用原理可能是根据 tcp 的时间戳来区分新老连接的包,具体可以查看 这篇文章。

而另外一个选项 net.ipv4.tcp_tw_recycle意思是开启 TIME_WAIT 状态 sockets 的快速回收。之前 2MSL 时间才回收的连接,很可能很快就被回收(一种说法回收时间是连接的 RTT)。启用之后,对于涉及 NAT 的网络情况会产生一些问题,因此不建议使用。似乎该选项在新版的 linux 内核中已经被废弃。

1
2
tcp_tw_recycle (Boolean; default: disabled; since Linux 2.4)
Enable fast recycling of TIME_WAIT sockets. Enabling this option is not recommended since this causes problems when working with NAT (Network Address Translation).

参考资料

网络编程(六):端口那些事儿

TCP服务器参见问题和参数设置

Coping with the TCP TIME-WAIT state on busy Linux servers

Go调度模型

发表于 2018-08-21 |

Go 的 Runtime

下面这张图说明了 Go 程序在执行时与操作系统的大致交互原理。

image-20180822155050672

我们编写的 Go 程序除了运行自己的逻辑之外,还会涉及到与操作系统的交互,例如分配内存、创建 goroutine、通过channel与其他 goroutine 进行交互等。那么程序代码和操作系统之间是怎么交互的呢?这就用到了 go 里的 runtime 库。runtime 库主要的功能包含垃圾回收、调度、goroutine 管理等。

上图中程序代码和 runtime 库一起运行在用户空间,并在必要时(例如触发系统调用) 时与操作系统进行交互。

本文我们将只讲述 Runtime 库的调度器,其他不做涉及。

Runtime Scheduler

调度器的必要性

既然操作系统可以帮助我们来调度线程,那么为什么Go 还要实现一个用户空间级别的调度器呢?

Posix 的线程 API 本质上是对 Unix 进程模型的逻辑扩展,所以有很多类似于进程的控制机制,例如线程有自己的信号掩码,可以被指定到固定的 CPU 上执行,可以被放入 cgroups等等,这些控制机制使得线程运行时有很大的开销(overhead)。想想一下如果随着 goroutine 的增加,你的进程里有100,000个线程的情景。其实对于 goroutine,这些开销并不那么必要。

另外一个问题是垃圾回收时,调度会发生在任意时间点,需要所有线程停止执行,直到待回收的 memory 处于一致性的状态,中间只能等待。而有了 go 的调度器,它只会在线程内存处于一致性的时候进行垃圾回收,也就是说只需要等待那些正在 CPU 上运行的线程(等待他们的内存趋于一致性)。

调度模型

通常有三种线程调度模型:

  • N:1模型。N 个用户级的线程运行在一个 OS 线程上。由于是多个用户级线程,因此上下文切换很快;但是不能利用到CPU 的多核特性。
  • 1:1模型。本质上是 N:N 的模型,每个用户线程唯一对应了一个 OS 线程,好处是利用了所有的核心,但是线程间上下文切换非常慢,因为要跨越 OS。
  • M:N 模型。这是 go使用的调度模型。它将任意多个 goroutine 运行在任意数量的 OS 线程上(通常 goroutine 数量要远多于OS线程数量),既充分利用了多核,又能很快的调度(goroutine 调度)。

调度原理

为了完成 goroutine 的调度,go 给出了三个概念M、P、G

image-20180822171005278

G 就是我们的 goroutine,是用户级别的调度单位。它有自己的栈、指令指针(IP)及调度器需要的信息、状态等。

M 代表 OS 线程,由操作系统负责管理。M 用来实际执行 G。

P 是一个抽象概念。在 G 被 M 实际执行前,P 负责管理多个待执行的 G。如果 M 中的 G 执行完毕,则会从 P 中 顺序取出另一个待执行的 G 来执行。

通过多个 P 可以将 N:1的调度模型转为 M:N 的模型。每个 P 对应了一个 M 来实际执行 G。

大致的调度示意图如下

image-20180822173934726

图中一共有6个 goroutine,被放入 3 个队列 P1、P2、P3 来调度。其中有 3 个 G 已经在对应的 M 中运行,另外 3 个在队列中等待执行。

那么程序启动后如何选择 P 的数量呢?默认情况下,它等于 CPU 的逻辑核心数。例如我的 Mac 是单 CPU 双核四线程的,则 P 的数量就是 4。这个数量也可以通过 GOMAXPROCS 环境变量或者 runtime.GOMAXPROCS() 函数来进行设置,一般来说使用默认的即可。

下面我们分别来看看以下几种场景,调度器如何工作:

场景1:创建 goroutine

goroutine 的创建通过 go func()语句来触发,实际调用的是 runtime.newproc 函数

1
2
3
4
5
6
7
func newproc(siz int32, fn *funcval) {
argp := add(unsafe.Pointer(&fn), sys.PtrSize)
pc := getcallerpc()
systemstack(func() {
newproc1(fn, (*uint8)(argp), siz, pc)
})
}

然后调用newproc1函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func newproc1(fn *funcval, argp *uint8, narg int32, callerpc uintptr) {
_g_ := getg() // 获取当前运行的 goroutine G
....
....
_p_ := _g_.m.p.ptr() // 找到当前 G 所在工作线程 M 所关联的 P
newg := gfget(_p_) // 如果 P 有之前创建并回收了的空闲的 G,则复用
if newg == nil { // 没有则新建一个
newg = malg(_StackMin) // new 一个栈大小为_StackMin(2k)的 G
casgstatus(newg, _Gidle, _Gdead)
allgadd(newg)
}
....
....
runqput(_p_, newg, true) //新 G 放入 P 的待执行队列中
}

从代码可知,新的 goroutine 会被放入到特定 P 的本地执行队列中,如果 P 的本地队列已满,则会放入一个称为『全局队列』的队列,通常全局队列很少用到,大部分操作在本地队列中。

场景2:队列调度

队列的调度循环主要是schedule函数。

M 执行 G 的时候,需要关联一个 P。当 M 执行完某个 G 时,它会从 P 的等待队列中 pop 出一个新的 G 来执行。注意 P 的待执行本地队列是一个无锁队列,存取都是比较高效。而全局队列会被很多 P 访问,因此加入了锁保证安全性。选择可以执行的 G 通过函数findrunnable()来完成。

1
2
3
// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from global queue, poll network.
func findrunnable() (gp *g, inheritTime bool)

在findrunnable()中,并不总是从本地队列里取,否则全局队列里的 G 将被『饿死』,永远无法执行。为了公平起见,每61次调度检查一下全局队列。此外, go 的网络操作也是整合到 runtime 中的,findrunnable 也会从 network 从寻找阻塞的 G 来执行。

如果以上所有都没有可供执行的 G 呢?我们不能让该工作线程 M 处于空闲状态,必须充分利用。为此,就用到了一种被称为Work-Stealing 的调度算法,当前 P 会尝试从其他的 P 随机选择一个,从中『偷取』一半的 G 放入本队列,于是当前 P 又可以继续调度了。Work-Stealing 保证了整个调度系统内队列的平衡。示意图如下

image-20180822194002035

场景3:执行中断

我们知道,当一个 G 在 M 中执行时,队列中的其他 G 会处于等待状态,那么如果这个 G 中的代码是个长时间执行的代码,那其他 G 会一直等待,直到 G 执行完成吗?我们通过一段代码来看看。简单起见,我们设置 P 的数量为1。

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
package main

import (
"fmt"
"runtime"
"sync"
)

func main() {
runtime.GOMAXPROCS(1)

var wg sync.WaitGroup
wg.Add(2)

fmt.Println("Starting Go Routines")
go func() {
defer wg.Done()

for number := 1; number < 27; number++ {
fmt.Printf("%d ", number)
}
}()

go func() {
defer wg.Done()

for char := ‘a’; char < ‘a’+26; char++ {
fmt.Printf("%c ", char)
}
}()

fmt.Println("Waiting To Finish")
wg.Wait()

fmt.Println("\nTerminating Program")
}

执行结果如下:

1
2
3
4
5
$ ./runtime2 
Starting Go Routines
Waiting To Finish
a b c d e f g h i j k l m n o p q r s t u v w x y z 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
Terminating Program

可见,前一个 goroutine 执行完成之后才会执行下一个 goroutine。

这里注意,goroutine 的执行顺序和代码里的顺序正好相反,是因为最新创建的 goroutine 被放入了优先执行队列 runnext 里。所以后创建的 goroutine 反而最优先执行。

有没有可能是前一个 G 执行太快导致后一个G 没来得及执行?为此我们将后一个 goroutine 改为死循环

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
package main

import (
"fmt"
"runtime"
"sync"
)

func main() {
runtime.GOMAXPROCS(1)

var wg sync.WaitGroup
wg.Add(2)

fmt.Println("Starting Go Routines")
go func() {
defer wg.Done()

for number := 1; number < 27; number++ {
fmt.Printf("%d ", number)
}
}()

go func() {
defer wg.Done()

a := 0
for {
a++
}
}()

fmt.Println("Waiting To Finish")
wg.Wait()

fmt.Println("\nTerminating Program")
}

执行结果显示,打印数字的 goroutine 永远得不到执行。

那 goroutine 可能被中断执行吗?答案是 yes。正在运行的 goroutine 中如果有以下操作,将会导致其被中断执行

  • 系统调用 (例如打开一个文件)
  • time.Sleep
  • channel 读写操作
  • sync 包中的锁操作
  • 网络 IO
  • 主动调用 runtime.Sched()

中断执行的 M 及其 G,会从对应的 P 上 detach,进入阻塞状态,等待系统调用或者其他操作的完成。而 P 会尝试寻找另一个可用的 M,使其可以继续调度后续的 G 来执行。一般可以从空闲的 M 中选择一个,如果没有空闲的 M,则新创建一个 M(OS 线程)来 attach 到 P 上,如下图所示

image-20180822205943304

我们看到

  • 线程 M0 放弃了它关联的 P,开始等待阻塞的 OS 调用的完成;
  • P 找到了一个线程 M1(空闲的或者新建的) 继续调度;
  • 当 M0上的 OS 调用完成后,它需要找到一个可用的 P 来继续执行返回的 G 上的代码
  • 如果没有找到可用的 P,则 M0 会将其上的 G 放到全局队列,自身进入空闲状态,被动等待其他 P 调用

这里我们利用一段代码来实验下这个特性。现在在死循环过程中周期性的插入系统调用代码。常用的 fmt.Printf 打印信息到标准输出,就包含一个典型的系统调用 syscall.Write (syscall 包里还有很多其他系统调用)

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
package main

import (
"fmt"
"sync"
"runtime"
)


func main() {
runtime.GOMAXPROCS(1)

var wg sync.WaitGroup
wg.Add(2)

fmt.Println("Starting Go Routines")
go func() {
defer wg.Done()

for number := 1; number < 27; number++ {
fmt.Printf("%d ", number)
}
}()

go func() {
defer wg.Done()

a := 0
for {
a++
if a % 1e9 == 0 {
fmt.Println("\nperiodly syscall.")
}
}
}()

fmt.Println("Waiting To Finish")
wg.Wait()

fmt.Println("\nTerminating Program")
}

执行结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./runtime6
Starting Go Routines
Waiting To Finish
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
periodly syscall.

periodly syscall.

periodly syscall.

periodly syscall.

...

显然另一个 goroutine 也得到了调度运行。

而如果 G 被阻塞在某个 channel 操作或 network I/O 操作上时,G 会被放置到一个 wait 队列中,这个队列会是 channel 的发送或接收队列或者网络 IO 的等待队列;而 M 会尝试运行下一个 runnable 的 G;如果此时没有 runnable 的 G 供 M 运行,那么 M 将解绑 P,并进入 sleep 状态。当 I/O available 或 channel 操作完成,在 wait 队列中的 G 会被唤醒,标记为runnable,放入到某P的队列中,绑定一个 M 继续执行。

这里我们使用 http 包来拉取百度首页为例来测试网络 IO 的情况

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"
"time"
"runtime"
"net/http"
)


func main() {
runtime.GOMAXPROCS(1)

fmt.Println("Starting Go Routines")

for i := 0; i < 5; i++ {
go func(idx int) {
fmt.Printf("%v start\n", idx)
_, err := http.Get("http://www.baidu.com")
if err != nil {
fmt.Printf("%v error %v\n", idx, err)
}
fmt.Printf("%v end\n", idx)
}(i)
}

time.Sleep(3 * time.Second)

fmt.Println("Terminating Go Routines")
}

执行结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
$ go run runtime6.go 
Starting Go Routines
0 start
1 start
2 start
3 start
4 start
2 end
1 end
0 end
3 end
4 end
Terminating Go Routines

可见网络 IO 确实触发了 goroutine 的执行中断。

综上可以知道:

即使设置 GOMAXPROCS 为1,程序仍然可能会执行在多个OS线程上,即实际上的并发执行(非并行执行)

抢占调度

G-P-M模型的实现算是Go scheduler的一大进步,但Scheduler仍然有一个头疼的问题,那就是不支持抢占式调度,导致一旦某个G中出现死循环或永久循环的代码逻辑,也没有导致中断执行的调用,那么G将永久占用分配给它的P和M,位于同一个P中的其他G将得不到调度,出现『饿死』的情况。更为严重的是,当只有一个P时(GOMAXPROCS=1)时,整个Go程序中的其他G都将“饿死”。于是Dmitry Vyukov又提出了《Go Preemptive Scheduler Design》并在Go 1.2中实现了『抢占式』调度。

这个抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码 (morestack调用),让runtime有机会检查是否需要执行抢占调度。这种解决方案只能说局部解决了“饿死”问题,对于没有函数调用,纯算法循环计算的G,scheduler依然无法抢占。为此,我们利用下面这段代码看看抢占式的调用是否起效

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
package main

import (
"fmt"
"sync"
"runtime"
)

func myfn(ptr *int) {
if ptr != nil {
(*ptr)++
}
}

func dummy(ptr *int) {
myfn(ptr)
}

func main() {
runtime.GOMAXPROCS(1)

var wg sync.WaitGroup
wg.Add(2)

fmt.Println("Starting Go Routines")
go func() {
defer wg.Done()

for number := 1; number < 27; number++ {
fmt.Printf("%d ", number)
}
}()

go func() {
defer wg.Done()

a := 0
for {
dummy(&a)
}
}()

fmt.Println("Waiting To Finish")
wg.Wait()

fmt.Println("\nTerminating Program")
}

这段程序是对前面一段死循环赋值程序的改写,使用了函数调用。需要注意的是,在编译时候需要禁用编译器优化,否则简单函数将被内联优化掉

1
$ go build -gcflags="-N -l" runtime5.go

另外,为什么不直接调用 myfn 函数,而是中间再插入一个dummy函数呢?那是因为 myfn 函数位于调用树的leaf(叶子)位置,compiler可以确保其不再有新栈帧生成,不会导致栈分裂或超出现有栈边界,于是就不再插入morestack 来检查是否需要进行抢占式调度。具体实验过程可以参考 TonyBai 的 这篇文章

调度追踪

golang 的 runtime 库提供了 GODEBUG 来输出调试信息,通过 schedtrace=X 选项可以使调度器每隔一定时间向标准错误输出调度状态。我们利用该选项追踪一下下面这段代码的调度过程

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
package main

import (
"sync"
"time"
)

func main() {
var wg sync.WaitGroup

num := 10
wg.Add(num)
for i := 0; i < num; i++ {
go work(&wg)
}


wg.Wait()

// Wait to see the global run queue deplete.
time.Sleep(3 * time.Second)
}

func work(wg *sync.WaitGroup) {
time.Sleep(time.Second)

var counter int
for i := 0; i < 1e10; i++ {
counter++
}

wg.Done()
}

使用 GODEBUG 来观察调度器的执行情况 (使用默认的 GOMAXPROCS = 4)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ GODEBUG=schedtrace=1000 ./runtime4
SCHED 0ms: gomaxprocs=4 idleprocs=3 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0]
SCHED 1000ms: gomaxprocs=4 idleprocs=4 threads=6 spinningthreads=0 idlethreads=4 runqueue=0 [0 0 0 0]
SCHED 2006ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [2 1 2 1]
SCHED 3008ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [2 1 2 1]
SCHED 4012ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [2 1 2 1]
SCHED 5018ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [2 1 2 1]
SCHED 6026ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [2 1 2 1]
SCHED 7035ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=3 [2 0 1 0]
SCHED 8041ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=4 [1 0 1 0]
SCHED 9046ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=4 [1 0 1 0]
SCHED 10055ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=4 [1 0 1 0]
SCHED 11055ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=4 [1 0 1 0]
SCHED 12056ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=4 [1 0 1 0]
SCHED 13064ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [0 0 0 0]
SCHED 14072ms: gomaxprocs=4 idleprocs=2 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
SCHED 15074ms: gomaxprocs=4 idleprocs=2 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
SCHED 16083ms: gomaxprocs=4 idleprocs=2 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0]
SCHED 17087ms: gomaxprocs=4 idleprocs=4 threads=6 spinningthreads=0 idlethreads=4 runqueue=0 [0 0 0 0]
SCHED 18095ms: gomaxprocs=4 idleprocs=4 threads=6 spinningthreads=0 idlethreads=4 runqueue=0 [0 0 0 0]
SCHED 19097ms: gomaxprocs=4 idleprocs=4 threads=6 spinningthreads=0 idlethreads=4 runqueue=0 [0 0 0 0]

首先看下 1000ms

1
SCHED 1000ms: gomaxprocs=4 idleprocs=4 threads=6 spinningthreads=0 idlethreads=4 runqueue=0 [0 0 0 0]

此时,4个工作线程 M 刚刚被创建,对应 4 个 P,并处于 idle 状态;另有 2 个线程被 runtime 使用,一共是 6 个线程

1
SCHED 2006ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [2 1 2 1]

在 2006ms,idleprocs 为 0,表示 4 个 procs 均处于工作状态,各持有一个 G,剩余的 6 个 G 分别放置在4个 P 对应的本地队列中 ( 2 + 1 + 2 + 1),全局队列中有 0 个 G

1
SCHED 7035ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=3 [2 0 1 0]

在 7035ms,有 3 个 G 执行完成,放入全局队列中等待结束,再从 3 个 P 中取出 3 个 G 放入 M 中执行,因此全局队列和本地队列数量分别是 runqueue=3 [2 0 1 0]

1
SCHED 8041ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=4 [1 0 1 0]

在 8041ms,又有一个 G 执行完成,放入全局队列中,再从其关联的 P 中取出一个 G 放入 M 中执行,因此全局队列和本地队列数量分别是 runqueue=4 [1 0 1 0]

1
SCHED 13064ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=0 [0 0 0 0]

在 13064ms,此时没有待执行的 G 了,只有 4 个正在执行的 G,其余 6 个都已经执行完毕并退出

1
SCHED 17087ms: gomaxprocs=4 idleprocs=4 threads=6 spinningthreads=0 idlethreads=4 runqueue=0 [0 0 0 0]

在17087ms,所有 G 执行完毕,idleprocs=4,所有 proc 处于空闲状态

更详细的关于追踪的情况,可以参考 William Kennedy 写的博客 [ Scheduler Tracing In Go]https://www.ardanlabs.com/blog/2015/02/scheduler-tracing-in-go.html

参考资料

Scheduler Tracing In Go

The Go scheduler

Analysis of the Go runtime scheduler

Golang源码探索(二) 协程的实现原理

也谈goroutine调度器

Goroutine调度实例简要分析

c/c++编译过程

发表于 2018-08-21 |

C/C++ 是高级编译型语言,执行的时候需要将容易阅读的源码编译成机器可以识别的机器指令,交由 CPU 执行。这篇文章我们来研究一下编译是怎样一个过程。

绝大多数编译器并不是一个单一的庞大程序,它通常由六七个稍小的程序组成,这些程序由一个叫做『编译器驱动器』(compiler driver) 的控制程序来调度。整个编译过程大致可以分为四个主要阶段:

  • 预处理
  • 编译
  • 汇编
  • 链接

我们以一个简单的 c 程序来举例,c 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

#define add(a, b) (a + b)

// here is comment

#ifdef DEBUG
void myfunc() {
printf("DEBUG defined\n");
}
#else
#error DEBUG not defined!
#endif

int main(void) {
int a = 5, b = 4;
printf("addition is: %d\n", add(a, b));
return 0;
}

在 centos 环境利用 gcc 进行编译,添加选项 -save-temps 我们可以获取编译过程每个阶段产生的临时文件

1
$ gcc -Wall -save-temps -DDEBUG filename.c -o filename

可以看到分别生成了以下文件

image-20180821150535977

其中

filename.c 源码文件

filename.i 预编译后文件

filename.s 汇编文件

filename.o 编译目标文件

filename 链接后的可执行文件

下面我们分别看看每个阶段具体在进行哪些操作。

预处理

预处理阶段,编译器驱动调动预处理器处理 预处理指令 ,所谓预处理指令是指以 # 开头的指令,主要有注释、宏、头文件包含、条件编译等,因此这个阶段包括以下几个操作

  • 去除注释
  • 展开宏定义
  • 展开头文件包含
  • 条件编译#ifdef、#ifndef 处理
  • error 抛出错误信息

我们打开预处理后的 filename.i 文件可以看到:注释被去除了,参数宏 add(a, b) 也被 (a + b) 替换,包含的头文件 <stdio.h> 中的内容被拷贝到源文件 filename.c 中;此外编译时候有没有指定 -DDEBUG 得到的结果 (是否有myfunc) 也不一样。

其他的预处理指令还包括 pragma、 line 等,这里就不详细说明了

编译

编译阶段,主要将扩展的源码文件如filename.i 编译成汇编文件filename.s。其中又分为三个主要步骤:

  • Lexical Analysis / tokenization 词法分析。

    主要就是将输入的源码文件从左到右逐个字符(character)的分析,划分成不同的 token:symbols, numbers, identifiers, strings, operators 等。下面看个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int main() {
    int a;
    int b;
    a = b = 4;
    return a - b;
    }

    //经过词法分析,会产生以下 token
    Scanner production:
    [Keyword(Int), Id("main"), Symbol(LParen), Symbol(RParen), Symbol(LBrace), Keyword(Int), Id("a"), Symbol(Semicolon), Keyword(Int), Id("b"), Symbol(Semicolon), Id("a"), Operator(Assignment), Id("b"),
    Operator(Assignment), Integer(4), Symbol(Semicolon), Keyword(Return), Id("a"), Operator(Minus), Id("b"), Symbol(Semicolon), Symbol(RBrace)]
  • Parsing 语法解析。

    该过程是将上面词法分析产生的 tokens 进行匹配,看是否符合特定的模式:如函数调用、变量赋值、数学运算、表达式求值等等。最终,语法分析会产生一种称为 AST (abstract syntax tree) 的语法树结构。

    image-20181217144627779

    上面就是 12 + 3 对应的AST结构

    image-20181216215749680

    上面是 C 代码 if(net>0.0)total+=net*(1.0+tax/100.0); 经过词法分析和语法分析生成的AST 结构

  • Code generator 代码生成。

    代码生成的主要任务,就是将上述的中间结果即 AST 树结构转换成一行行线性的机器指令。包括

    • 选用什么样的指令。通过对 AST 树执行后续遍历 (postorder traversal) 依次选用指令。例如,对于 AST W := ADD(X,MUL(Y,Z)) ,根是 ADD ,左右节点分别是 X 和 MUL(Y,Z),指令会是 t1 := X 和 t2 := MUL(Y,Z) ,最后是 ADD W, t1, t2

    • 安排指令顺序。由于CPU流水线机制,可能会有乱序执行以提高代码执行效率。

    • 变量的寄存器分配

    • 调试代码生成

    此外它还会对生成的指令进行一定的优化。

很多情况下,代码生成的机器指令为汇编代码,包含了很多汇编指令。

下面是 filename.c 编译生成的汇编指令代码 filename.s

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
  1     .file   "filename.c"
2 .section .rodata
3 .LC0:
4 .string "DEBUG defined"
5 .text
6 .globl myfunc
7 .type myfunc, @function
8 myfunc:
9 .LFB0:
10 .cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size myfunc, .-myfunc
.section .rodata
.LC1:
.string "addition is: %d\n"
.text
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $5, -8(%rbp)
movl $4, -4(%rbp)
movl -4(%rbp), %eax
movl -8(%rbp), %edx
addl %eax, %edx
movl $.LC1, %eax
movl %edx, %esi
movq %rax, %rdi
movl $0, %eax
call printf
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-17)"
.section .note.GNU-stack,"",@progbits

编译过程还可以被分为 前端、后端,前端包括前面提到的词法分析、语法分析,生成 AST 结构;后端指代码生成,输入是 AST,输出机器指令代码或者汇编代码。其实,前后端中间还有个中端,做些优化的工作。

前后端的工作是独立的,互相不依赖。对于不同的语言,编译器可能使用不同类型的前端来生成 AST,而后端其输入只要求是AST,因此可以复用。举例来说,对于知名编译器 GCC (the GNU Compiler Collection),其不同的前端可以编译 C,C++,Objective-C,Fortran,Ada,Go,D 成 AST,而通过统一的后端将 AST 转换成汇编代码。

编译的整个过程用一张图来表示就是

image-20181217143846903

汇编(Assembly)

该过程通过调度汇编器(as)来完成,将汇编指令文件filename.s 翻译成与处理器结构有关的机器指令文件filename.o,这是一个二进制文件,不能直接查看,用 file 命令查看可知

1
2
$ file filename.o 
filename.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

这一个64位 ELF 文件,可以使用 objdump -x 命令来查看所有文件头,包含符号表和重定位信息等

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
$ objdump -x filename.o

filename.o: file format elf64-x86-64
filename.o
architecture: i386:x86-64, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x0000000000000000

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000049 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 0000008c 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 0000008c 2**2
ALLOC
3 .rodata 0000001f 0000000000000000 0000000000000000 0000008c 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002e 0000000000000000 0000000000000000 000000ab 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d9 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 filename.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g F .text 0000000000000010 myfunc
0000000000000000 *UND* 0000000000000000 puts
0000000000000010 g F .text 0000000000000039 main
0000000000000000 *UND* 0000000000000000 printf


RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000005 R_X86_64_32 .rodata
000000000000000a R_X86_64_PC32 puts-0x0000000000000004
000000000000002f R_X86_64_32 .rodata+0x000000000000000e
000000000000003e R_X86_64_PC32 printf-0x0000000000000004


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
0000000000000040 R_X86_64_PC32 .text+0x0000000000000010

上面符号表里的 text 表示在代码段找到了定义,而有 UND 标识的 puts函数和printf函数因为属于库函数,尚未链接进来,因此属于未定义。

上面的filename.o 文件虽然是二进制指令文件,但是还不能被执行。最终的可执行文件还要经过下面的链接过程。

链接

该阶段由编译器驱动程序驱动链接器 linker 来完成。linker 只和平台有关,是不同操作系统下的工具程序。对于windows平台,linker.exe 是链接器 (集成在Visual Studio中),它将多个.obj文件和库文件一起链接成 .exe可执行文件或库文件;对于 linux 和 mac 平台,ld (也是GNU出品) 是链接器,它将多个 .o 文件和库文件一起链接成二进制可执行文件或库文件。

链接器的工作主要包含:地址和空间分配(Address and Storage Allocation),符号决议(Symbol Resolution),重定位(Relocation)等。

image-20181217141632692

链接主要分为动态连接和静态链接。对于静态链接,linker 会将静态库的代码直接加到可执行文件中,因此文件大小比较大。而动态链接则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。

GCC 默认使用动态链接方式链接库文件。对比目标文件filename.o 和我们链接完成的可执行文件 filename大小可知,text 部分增加的大小为必要的描述信息

1
2
3
4
5
6
$ size filename.o
text data bss dec hex filename
192 0 0 192 c0 filename.o
$ size filename
text data bss dec hex filename
1319 500 16 1835 72b filename

再看file命令的输出

1
2
$ file filename
filename: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.18, not stripped

可见与上面的filename.o不同的是该文件已经链接(GCC 默认动态连接 Linux 的 c 运行时库 libc.so.6,即 glibc )

此外,利用 ldd 命令可以看到可执行程序依赖的动态链接库

1
2
3
4
5
6
7
8
9
10
11
$ ldd -v filename
linux-vdso.so.1 => (0x00007fff36f24000)
libc.so.6 => /lib64/libc.so.6 (0x00007f8f61001000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8f613a0000)

Version information:
./filename:
libc.so.6 (GLIBC_2.2.5) => /lib64/libc.so.6
/lib64/libc.so.6:
ld-linux-x86-64.so.2 (GLIBC_PRIVATE) => /lib64/ld-linux-x86-64.so.2
ld-linux-x86-64.so.2 (GLIBC_2.3) => /lib64/ld-linux-x86-64.so.2

总结

用一个图来表示整个编译的过程如下

image-20180821172053123

参考资料

<C专家编程>

Compiling a C program:- Behind the Scenes

Understanding Compilers — For Humans (Version 2)

C程序编译过程浅析

1234

philipyao

35 日志
20 标签
GitHub E-Mail
© 2020 philipyao
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
备案号:沪ICP备17048801号