Go实践笔记
目前在用go写同步对战服务器这块。这门编程语言很久以前虽然学过,不过时间久了也用的较少,时间久了忘了很多。在写服务器过程中,可以享受到go对高性能并发的内置支持,但同时也会遇到一些坑,这里针对go的一些问题,记录一些笔记,包括一些坑的解决办法。
1. go语言的异常处理机制不够完善。
(1)go的异常处理通过defer recover来实现,不支持函数局部代码块异常处理。
(2)go的异常不像java那样区分类型,包括官方的标准库也是,更多的是通过运行时错误码来判断。处理异常的时候,代码维护和可读方面没有java那么直观。
针对这一点,我思考了下,实际上因为go支持闭包 可以通过闭包来代替代码块处理局部异常;然后go panic抛出的异常可以是任意类型的,而go支持通过反射来动态判断类型,所以可以通过这两点模拟go版的区分类型的try catch机制。
2. go语言内置的一些数据类型都不是线程安全的。
go语言内置的一些数据类型支持,包括slice,map,list等都存在线程安全问题。对于大部分通用场景来说,使用go内置的读写锁即可满足需求;不过也可以使用channel代替锁,通过channel协调读写线程,实现线程安全。此外,也可以使用atomic库实现更灵活的功能。
参考相关文章:https://www.jianshu.com/p/df973e890663
3. go语言不支持宏,不支持泛型,也不支持重载
go语言为了加快编译速度,目前都没有支持宏和泛型,甚至不支持重载,而内置的数学库也只是支持float64型,对于复杂的业务场景来说,非常不便。
所幸有些第三方库帮我们处理了一些脏活累活,比如数学扩展工具库 https://godoc.org/modernc.org/mathutil
4. 数据类型转换不方便
由于go语言不支持泛型,而go对于非基本数据类型的强制类型转换也没有提供专门的支持,在转换非基本数据类型时,显得很繁琐。例如下面这个例子,正确的转换很冗长,也无法使用宏简化(因为go也不支持宏):
type _Int32 int32
var a *[]_Int32
var b *[]int32=(*[]int32)(a) // 语法报错
var d *[]int32=(*[]int32)(unsafe.Pointer(a)) //能正确转换
同样的,结构体的串行化也不方面,显得冗长,存在性能消耗:
type Struct struct{
a int64
b int32
}
sample:=Struct{}
size:=int(unsafe.Sizeof(&sample))
sh := &reflect.SliceHeader{
Data: uintptr(unsafe.Pointer(&sample)),
Len: size,
Cap: size,
}
// 结构体->[]byte
data := *(*[]byte)(unsafe.Pointer(sh))
// []byte->结构体
originStruct:=(*Struct)(unsafe.Pointer(&data));
5. 对goroutine调度控制较弱,需要注意安排好不同goroutine之间的依赖关系
特别常见的一点是当main函数退出之后,程序就会退出,不会等待其他线程退出。通常会适用wait.group来实现线程依赖等待。
6. 关于 go版本的protobuf的性能问题
最近对比了messagepack和protobuf在go上面的性能表现。
序列化耗时 | 反序列化耗时 | 序列化数据大小 | |
---|---|---|---|
原始数据 | \ | \ | 180 |
protobuf | 100 | 120 | 45 |
messagepack | 200 | 240 | 210 |
protobuf在性能上确实非常优越,速度快,体积小;但是也存在一些明显的缺陷:
1.protobuf导出的数据类型内部如果存在嵌套结构,那么嵌套结构都是使用指针关联的,一方面在反序列化过程中会发生大量内存分配操作,容易产生大量内存碎片,对于内存相对较小的机器非常不利,另一方面不利于结构体深拷贝操作,强行深拷贝会存在性能问题。
2.对于所有没有赋值或者赋了默认值的字段,在反序列化过程中都会初始化为空指针或者零值,会存在两个隐患:
- 1.一旦产生字段赋值操作,很容易出现空指针异常。
- 2.未赋值的数据结构指针(空指针)依旧可以正常使用,只不过获取的字段值都是空指针或零值,容易误导开发者以为该结构体实例是存在实体的。
7. go中所有的对象传递全部都是值传递,包括所谓的指针引用,实际上也是基于值传递实现的。
go初学者稍不注意就会因此引发结构体副本之间的状态同步bug或者产生对一些第三方库(包括官方库)的误用。
比如socket编程中:
listener, err := net.Listen("tcp", "127.0.0.1:8001")
if err != nil {
fmt.Println("err = ", err)
return
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("err = ", err)
return
}
// 处理用户请求, 新建一个协程
// 为什么conn不使用指针传递呢,不会产生状态同步问题吗?
// 这是因为net.Conn类型的数据实际上只保存了一个指针变量 `fd *netFD`,而所有的操作实现都是通过该指针来间接访问,所以无论conn拷贝几份,内部的fd指针值都一样。
// 这应该是官方为了方便使用特意这样设计的
// 但这用设计同时会引发另一个问题,就是 (&conn) 指针无法直接使用,虽然目前这不是什么大问题
go HandleConn(conn)
}
// net.Conn的实际数据结构
type conn struct {
fd *netFD
}
实践中,总结了一下指针引用和值引用的适用场景:
- 如果对象包含持续的状态变化,那么应当使用指针引用,避免产生副本导致状态不同步、锁失效等“离奇”问题。
- 如果对象是简单的无状态数据:
- 如果对象非常小,那么适合使用值引用。
- 如果对象较大,那么适合使用指针引用,避免数据拷贝导致执行效率降低、内存占用明显升高。
8. uintptr和gc
使用uintptr类型的值保存原始对象指针不会阻止该对象gc,需要结合runtime.KeepAlive()来使用。要尽量避免这种用法。
9. foreach常见bug
下面这段代码会导致后续go线程中调用的handler是后面遍历到的其他handler
func HandleRequestParallalThreadUnsafe(jobInfo *HandleJobInfo) {
for _, handler := range this.handlers {
if handler != nil {
go func() {
defer func() {
if err := recover(); err != nil {
log.Println("HandleRequestParallalThreadUnsafe-error:", err)
}
}()
handler(jobInfo)
}()
}
}
}
正确写法:
func HandleRequestParallalThreadUnsafe(jobInfo *HandleJobInfo) {
handlers := make([]RequestHandler, len(this.handlers))
//通过创建副本,避免this.handlers被动态修改出现遍历bug
copy(handlers, this.handlers)
for _, handler := range handlers {
if handler != nil {
//不使用闭包变量会导致后续go中调用的handler是后面遍历到的handler
thandler := handler
go func() {
defer func() {
if err := recover(); err != nil {
log.Println("HandleRequestParallalThreadUnsafe-error:", err)
}
}()
thandler(jobInfo)
}()
}
}
}
10. go服务器性能优化
11. 线程锁改进
go内置提供的锁通常是这样用的:
var lock sync.RWMutex
func TestTreadLock1(){
lock.Lock()
defer lock.Unlock()
...
}
func TestTreadLock2(){
lock.Lock()
...
lock.Unlock()
...
}
这里其实可以封装改进一下,让锁的使用更严格,不容易出现死锁:
import "sync"
type RWLock struct {
lock sync.RWMutex
}
func (this *RWLock) LockWrite(call func()) {
defer this.lock.Unlock()
this.lock.Lock()
call()
}
func (this *RWLock) LockRead(call func()) {
defer this.lock.RUnlock()
this.lock.RLock()
call()
}
var lock RWLock{}
func TestTreadLock3(){
lock.LockWrite(func() {
...
})
lock.LockRead(func() {
...
})
}
12. atomic 和读写锁的性能对比
var rwlock myserver.RWLock
const uniqueIdAccMax uint64 = 1000000
const GenUniqueId64Panic = "GenUniqueId64_acc_overflow"
func GenUniqueId64WithLock() int64 {
var timeUnix int64
rwlock.LockWrite(func() {
nowTime := time.Now().Unix()
if nowTime == lastTime {
uniqueIdAcc++
} else {
lastTime = nowTime
uniqueIdAcc = 0
}
acc := uniqueIdAcc
if acc >= uniqueIdAccMax {
panic(GenUniqueId64Panic)
}
timeUnix = int64((nowTime-1593426002)*int64(uniqueIdAccMax) + int64(acc))
})
return int64(timeUnix)
}
var alock int32=0
func GenUniqueId64WithAtomic() int64 {
var timeUnix int64
//加锁
for{
if(atomic.CompareAndSwapInt32(&alock,0,1)){
break
}
}
nowTime := time.Now().Unix()
if nowTime == lastTime {
uniqueIdAcc++
} else {
lastTime = nowTime
uniqueIdAcc = 0
}
acc := uniqueIdAcc
if acc >= uniqueIdAccMax {
panic(GenUniqueId64Panic)
}
timeUnix = int64((nowTime-1593426002)*int64(uniqueIdAccMax) + int64(acc))
//解锁
atomic.StoreInt32(&alock,0)
return int64(timeUnix)
}
在个人笔记本上并发测试,耗时如下,可以看出并发越高,atomic性能越不如lock。另外经过反复测试,发现atomic的耗时波动非常大,不如lock稳定。
func BenchmarkGenUniqueId64(b *testing.B) {
var wg sync.WaitGroup
count:=1
wg.Add(count)
for k:=0;k<count;k++{
go func() {
for i := 0; i < 100000; i++ {
GenUniqueId64()
}
wg.Done()
}()
}
wg.Wait()
}
func BenchmarkGenUniqueId64_2(b *testing.B) {
var wg sync.WaitGroup
count:=1
wg.Add(count)
for k:=0;k<count;k++{
go func() {
for i := 0; i < 100000; i++ {
GenUniqueId64_2()
}
wg.Done()
}()
}
wg.Wait()
}
测试结果:
count == 1: | |
---|---|
BenchmarkGenUniqueId64 | 0.00660 ns/op |
BenchmarkGenUniqueId64_2 | 0.00330 ns/op |
count == 2: | |
---|---|
BenchmarkGenUniqueId64 | 0.0240 ns/op |
BenchmarkGenUniqueId64_2 | 0.0280 ns/op |
count == 3: | |
---|---|
BenchmarkGenUniqueId64 | 0.0500 ns/op |
BenchmarkGenUniqueId64_2 | 0.0730 ns/op |
count == 4: | |
---|---|
BenchmarkGenUniqueId64 | 0.0790 ns/op |
BenchmarkGenUniqueId64_2 | 0.120 ns/op |
count == 6: | |
---|---|
BenchmarkGenUniqueId64 | 0.139 ns/op |
BenchmarkGenUniqueId64_2 | 0.201 ns/op |
count == 8: | |
---|---|
BenchmarkGenUniqueId64 | 0.197 ns/op |
BenchmarkGenUniqueId64_2 | 0.398 ns/op |
13. 单元测试
go的单元测试推荐goland内置单元测试功能。goland内置提供生成表单驱动单元测试的功能,提供单元测试覆盖率统计。
14.Go内存泄漏分析的坑
这几天观察项目内存泄漏问题时,发现内存占用居高不下,以为有严重的内存泄漏问题,但是细查之后并没有什么发现,怀疑go内存回收方面可能有奇怪的设定。后来参考下文,发现确实如此,使用 debug.FreeOSMemory()
强制返还系统内存,内存占用立即降到 135MB,反复返回测试同样稳定在135MB。说明没有明显的内存泄漏问题。