Go实践笔记

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
protobuf10012045
messagepack200240210

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

13. 单元测试

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

14.Go内存泄漏分析的坑

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值