golang开发中遇到的问题

问题总结

在用golang编写服务器程序的时候,遇到下面一些问题,所以在这里做一些记录,尤其是slice隐藏的bug较隐性,若对slice工作原理不了解是较难发现的。

  • slice元素被莫名替换
  • index out of range问题
  • 空指针引用
  • map的并发读写导致进程挂掉
  • String方法导致的递归死循环
  • 通道多次close(close of closed channel)
  • 死锁
  • 下次gc内存回收条件
  • 定时任务的问题

slice元素被替换

slice在开发中运用较频繁的基础数据类型,和数组相似,与数组最大的区别在于他是系统自动扩容的。

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

上面是go runtime里面对slice实现数据结构,指向一块内存的一个指针、一个当前slice里面的长度以及当前slice里面的最大容量。可以预想下
若另外一个指针同之前slice的指针有内存重叠的话,修改重叠部分是会相互影响的,下面我们用实例来确定下这个问题。

test1.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sa := []byte{}
sa = append(sa, 1, 2, 3, 4, 6)
sb := sa
sb = append(sb, 5) // sb = append(sa, 5) 同样有问题
// 从小到大排序
algorithm.Sort(sb, 0, len(sb)-1)
fmt.Printf("len: %d cap: %d of slice sa: %v\n", len(sa), cap(sa), sa)
fmt.Printf("len: %d cap: %d of slice sb: %v\n", len(sb), cap(sb), sb)
// output:
// len: 5 cap: 8 of slice sa: [1 2 3 4 5]
// len: 6 cap: 8 of slice sb: [1 2 3 4 5 6]

test2.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sa := []byte{}
sa = append(sa, 1, 2, 3, 4, 5, 7, 8, 10)
sb := sa
sb = append(sb, 9)
// 从小到大排序
algorithm.Sort(sb, 0, len(sb)-1)
fmt.Printf("len: %d cap: %d of slice sa: %v\n", len(sa), cap(sa), sa)
fmt.Printf("len: %d cap: %d of slice sb: %v\n", len(sb), cap(sb), sb)
// output:
// len: 8 cap: 8 of slice sa: [1 2 3 4 5 7 8 10]
// len: 9 cap: 16 of slice sb: [1 2 3 4 5 7 8 9 10]

上面两个程序的输出可能看出,test1.go中对sb排完序后sa和sb第5个元素是被替换掉了的。
实际上sa和sb是两个指向同块内存的不同指针,只是它们的len值不一样,需要注意的是sb调用
append后cap的值还是和sa的一样。和test2.go形成鲜明的对比,只要slice的长度超过了默
认的容量后就会自动扩容。

sb:=sa之后的代码写在函数里面同样有这样的问题,因为go里面传参都是按值传递的

index out of range

  • 索引slice元素越界
1
2
3
4
5
6
7
8
9
10
11
12
13
14
sa := []byte{}
sa = append(sa, 1, 2, 3, 4, 6)
// sa[5] = 20 // 这里会panic
sb := sa[5:]
// sb[0] = 5 // 这里会panic
fmt.Printf("len: %d cap: %d of slice sa: %v\n", len(sa), cap(sa), sa)
fmt.Printf("len: %d cap: %d of slice sb: %v\n", len(sb), cap(sb), sb)
// output:
// len: 5 cap: 8 of slice sa: [1 2 3 4 6]
// len: 0 cap: 3 of slice sb: []

对于这种下标越界的问题是当索引的下标号超出了len(sa)的时候就抛出越界的错误

  • slice表达式越界问题

根据官方文档的说明slice表达式有两个,一种简单的表达式和参数更全的表达式
分别对应a[low : high]a[low : high : max]

spec说明
对于这类越界是下标不在0 <= low <= high <= max <= cap(a)这范围时会出现问题

空指针引用

相信不论是C系还是JAVA开发者都会遇到空指针的问题,C里面对空地址的访问是致命的,
并且不像JAVA能把这种异常捕获,go也不例外,为了保证服务正常运行同样可以通过
recover把所有runtime时的错捕获到,但并发读写的错误是不行的。所以我们项目里基本
在所有的协程里面有recover输出的。

项目中我们把一个自定义的interface的变量赋值一个nil,但去执行原先持有的类型的
函数时就panic掉了,所以对默认值为空值引用的都会出这种错误,比如:空指针和接口

有趣的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Stu struct {
Name string
}
func (s *Stu) GetName() int {
fmt.Printf("s is %v", s) // 这里能正常调用,但不要再去调Stu里面的成员
return 2
}
func main() {
var s *Stu
s.GetName()
}

上面的例子一开始出乎意料,但仔细一想其实是符合go的设计理念的,方法是属于类型的,
类型的变量当然是可以正常调用的,但变量为空再去调用里面的成员就是对空指针的访问了。

map的并发读写导致进程挂掉

对一个多线程程序来说对数据竞争的保护是必不可少的,但在处理日志输出的时候,如果
一个对象有map或数组之类的并对有并发读写这很容易导致进程panic掉。所以打印日志的
时候一定要注意不要输出一个对象。

String方法导致的递归死循环

在日志输出的时候打印了一个对象并且对象实现了String()方法,如果String方法里面
又调了自身,这种使用方法是很危险的,在我们开发期间是有同事犯了这个一个错误的。

死锁

既然是并发程序,难免会有数据竞争的问题,所以我们用了互斥锁来解决这个问题,
如果获取锁和释放锁没有成对使用或者是锁重入(递归获取锁)就会导致死锁的风险,
因为我们项目是针对房间的,虽然不影响所有的人,一出现死锁只会影响这个房间
的人,不过我们这种锁是睡眠锁,并不消耗CPU。

我们的产品是一款地方麻将,用golang开发的

死锁1:

我们的产品上线第二天就出现导致全服(多个逻辑服)服务不可用。后来经过排查是有死锁影响了全局。

一开始并不知道问题在哪,有以下两个解决方案:

  • 新增高版本的逻辑服(不影响新的玩家)
  • 停服维护

一开始我们选择的是一方案,但好像出了点问题,又换了二方案,二方案虽然可以但是已经开的房间的钻石没返还。
弄了好久才弄程序把需要返还钻石返还。

不过好在上线前有人说要开放pprof接口,经过日志分析是有很多goroutine阻塞在获取锁的地方。
上线前我们做了一个需求,10分钟没开局的房间需要解散掉。也没考虑太多,觉得这个需求比较
简单,所以逻辑服这边起了个定时任务1分钟扫下单个逻辑服的房间列表。

定时任务我们是用的读锁来执行的,从列表里面删除一个房间的是写锁的。读锁是可以多次获取的。
假如1分钟还没遍历完所有的房间,另外一个定时任务立即执行,又重新获取房间列表的读锁。1分
钟遍历的时候是有获取房间的锁的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
R*是获取房间里面的锁
RA ——> roomA
RB ——> roomB
再尝试获取房间列表写锁的:
RA ——> list
RB ——> list
T*是定时任务得到房间列表的读锁:
T1 ——> list
T2 ——> list
T3 ——> list
再获取房间里面的锁
TA ——> roomA
TB ——> roomB

定时任务是有获取房间的锁的,如果刚好TA的锁等着RA的锁就会产生死锁

后续我们的解决方案是为每一个房间启一个定时。能不用锁解决的问题千万不要不用,获取锁是会导致上下文的切换,
从一执行线程切换到另一个执行线程。

golang的channel就是通过锁来实现不同的协程之间可以通信。虽然用到了锁,但是这个锁对开发人员是无感的,所
以对使用者来说就安全了。

死锁2:

这个问题是异常情况下面导致的单个房间的死锁,当时写代码的时候能不用defer就不用,所以在一个加锁的函数的
地方没有用defer解锁,在函数执行当中有panic,所以没执行后面解锁的函数。如果用了defer解锁就不会有这个问
题了。所以在有锁的情况尽量用defer就用defer。

下次gc内存回收条件

线上因为通过pprof看到heap一直在增长,一天增长了一千多。用top观察实际res也增了几十兆的物理内存。
因此网上找了很多关于linux内存管理以及golang的pprof里面的heap各字段详细描述。

(MemStats)[https://golang.org/pkg/runtime/#MemStats]

通过线上实际表现,内存是一直往上增的情况,所以看了下哪里可能有内存没被回收掉,以及GC的回收机制。

一开始自己有以下疑问:

  • 有垃圾回收的语言不是会自动回收的吗?
  • 有回收的话哪些对象没被回收掉?

一开始对代码大概的看了一下,除了其它一些频繁make的切片(也就十几个byte)的长度,这些部分是由他底层内存分配
管理器来控制的,我是束手无从的,不过也对一些频繁用到的对像用了内置的pool对象池。所以从其它地方想下还有哪
些地方没被回收掉,后来想到定时器!定时器确实也是有泄漏的(有些没有Stop掉)。

1
2
export GODEBUG=GOGC=50,GCTRACE=1
./main

默认GOGC是100的,所以gc会在下次分配的内存和上次分配的内存的比是成倍的才会把内存回收掉,设置成50后
通过压测试后通过日志输出会发现回收的次数相对来说要频繁,所以内存没有出现大的增长,停下来的时候恢复
平稳的值,但设置成50的时候会偶发的卡顿。线上估计也只能设置成80左右吧!暂时这个值没放到线上去跑。

参数说明

定时任务的问题

参考:
实现说明

go里面使用定时器有以下几种方式:

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
func NewTimer(d Duration) *Timer {
c := make(chan Time, 1)
t := &Timer{
C: c,
r: runtimeTimer{
when: when(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
type runtimeTimer struct {
i int
when int64
period int64
f func(interface{}, uintptr) // NOTE: must not be closure
arg interface{}
seq uintptr
}
func AfterFunc(d Duration, f func()) *Timer {
t := &Timer{
r: runtimeTimer{
when: when(d),
f: goFunc,
arg: f,
},
}
startTimer(&t.r)
return t
}
func goFunc(arg interface{}, seq uintptr) {
go arg.(func())()
}
time.NewTicker(time.Second)
time.After(time.Second)
time.AfterFun(time.Second, func(){})

通过源码以及使用接口总结,主要有两种定时器。1) 执行一次的 2) 定期执行的。通过数据结构
我们看了下不同接口实际的区别只在period字段。以及这个f参数用的默认的sendTime是非
阻塞的调用,AfterFun和前面两个接口不同的时没通过管道来实现定时器到点了(可以执行我的
业务了),而它是直接用的goFunc包装在自己的协程里面执行

1
2
3
4
5
6
7
8
9
10
for i := 0; i <= 1000000; i++ {
go func() {
for {
select {
case <-time.Tick(time.Second):
return
}
}
}()
}

如果像上面的代码用time.Tick()创建的定时器(也就是管道)没Stop()掉,定时器任务
列表会一直规程,导致内存一直无法得到释放。尤其是循环条件加大到百万级别的时候就
更明显了。