Go实践笔记

    技术2026-08-25  10

    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上面的性能表现。

    序列化耗时反序列化耗时序列化数据大小原始数据\\180protobuf10012045messagepack200240210

    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服务器性能优化

    参考 百万 Go TCP 连接的思考: 正常连接下的吞吐率和延迟

    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:BenchmarkGenUniqueId640.00660 ns/opBenchmarkGenUniqueId64_20.00330 ns/op count == 2:BenchmarkGenUniqueId640.0240 ns/opBenchmarkGenUniqueId64_20.0280 ns/op count == 3:BenchmarkGenUniqueId640.0500 ns/opBenchmarkGenUniqueId64_20.0730 ns/op count == 4:BenchmarkGenUniqueId640.0790 ns/opBenchmarkGenUniqueId64_20.120 ns/op count == 6:BenchmarkGenUniqueId640.139 ns/opBenchmarkGenUniqueId64_20.201 ns/op count == 8:BenchmarkGenUniqueId640.197 ns/opBenchmarkGenUniqueId64_20.398 ns/op

    13. 单元测试

    go的单元测试推荐goland内置单元测试功能。goland内置提供生成表单驱动单元测试的功能,提供单元测试覆盖率统计。

    参考 golang 单元测试(gotests、mockery自动生成)参考 java单元测试代码,自动生成

    14.Go内存泄漏分析的坑

    这几天观察项目内存泄漏问题时,发现内存占用居高不下,以为有严重的内存泄漏问题,但是细查之后并没有什么发现,怀疑go内存回收方面可能有奇怪的设定。后来参考下文,发现确实如此,使用 debug.FreeOSMemory() 强制返还系统内存,内存占用立即降到 135MB,反复返回测试同样稳定在135MB。说明没有明显的内存泄漏问题。

    参考 Go内存泄漏?不是那么简单!

    Processed: 0.013, SQL: 9