8

Go tips-笔记: 标准库 75-81 mistakes

 2 years ago
source link: https://weedge.github.io/post/notions/go-tips/go-tips-10-standard-lib/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

从程序中产生的错误中大多是使用姿势的不对,以及没有仔细阅读标准库相关包的开发文档,未查看源码导致,但是没有实践过这些问题,即使熟读文档和源码也可能避免不了。笔记中会以书中的mistake为切入点,结合源码升入分析其背后产生的原因,以及提出解决方案来避免。

75.提供错误的持续时间

记住使用time.DurationAPI 和提供int64一个时间单位, 默认最小时间单位是微妙

76.time.After 和内存泄漏 (重要)

常见问题之一,将time.After函数进行循环调用,导致内存泄露。

for {
		select {
		case event := <-ch:
			handle(event)
		case <-time.After(time.Hour):
			log.Println("warning: no messages received")
		}
	}

通过time.After源码可以看出,每次会通过time.NewTimer新建一个timer, 但是time.After返回的是一个C ← chan Time 只读channel,不能释放掉每次新建的timer, 可以使用Stop,如果一直循环使用,Go 1.15 中,每次调用使用大约 200 字节的内存,如果设置的时间间隔小,比如每小时500w条,则在一小时消耗1G左右的内存空间。

那如果直接使用time.NewTimer来处理,需要处理好Stop和Reset的情况:

一种方式是直接每次循环中NewTimer, 然后使用Stop方法从最小堆timer数组中删除底层的运行时timer(如果timer 没有expire 到期 以及有复用timer reuse active timer),这样可以防止内存泄露,但是这些timer结构对象需要GC来标记扫描释放,带来了额外的GC压力以及最小堆timer管理压力;这里需要注意Stop方法的使用,按照 Timer.Stop 文档 的使用说明,每次调用 Stop 后需要判断返回值,如果返回 false(表示 Stop 失败,Timer 已经在 Stop 前到期)则需要排掉(drain)一次 channel 中的Time数据(C 是长度为1的缓冲channel):

if !t.Stop() {
	<-t.C
}

但是如果之前程序已经从 channel 中接收过事件,那么上述 <-t.C 就会发生阻塞。可能的解决办法是借助 select 进行 非阻塞 排放(draining):

if !t.Stop() {
	select {
	case <-t.C: // try to drain the channel
	default:
	}
}

但是因为 channel 的发送和接收发生在不同的 goroutine,所以 存在竞争条件(race condition),最终可能导致 channel 中的事件未被排掉。因为sendTimer 和 操作Stop函数是在两个goroutine中执行,当timer刚好到期,已从最小堆中删除,操作Stop函数返回false, 在执行 ←t.C 接受操作和 sendTime 发送操作 分别在两个goroutine中执行相互之间执行是无序的,可能会发生先从t.C接受数据,没有,由于是非阻塞继续执行,这个时候sendTime发送一条Time数据到C中,后面执行Reset虽然重置一个Timer, 但是在select + case ←timer.C时,C中有数据选中直接执行了,和通过Reset重置的一个Timer间隔时间执行的预期期望不同,这样存在race condition,但是这种情况出现机率比较低,可参考 Russ Cox 的回复 ,目前 Timer 可能合理的使用方式是:程序需要维护一个状态变量(在同一个goroutine中),用于记录它是否已经从 channel 中接收过事件,进而作为 Stop 中 draining 操作的判断依据。可以订阅golang-dev组查看相关进展。

另外一种方式是把 NewTimer 放在循环外,在for循环中通过Reset函数来复用原有Timer结构,按照 Timer.Reset 文档 的使用说明,要正确地 Reset Timer,首先需要正确地 Stop Timer;因此 Reset 的问题跟 Stop 基本相同。

tips: 具体详情见源码客观分析:

go1.20/src/time/sleep.go (time标准中提供使用的Timer)

go1.20/src/runtime/time.go (运行时的timer)

go1.20/src/runtime/runtime2.go , go1.20/src/runtime/proc.go(p结构上最小四叉堆 timer数组, 以及运行时相关timer的调度;调用流程:findRunnable/stealWork → checkTimers → runtimer → runOneTimer → f (sendTime or goFunc) , lock free的方式调用f, CAS原子操作timer的状态)

每次新生成Timer的时候,会往p上的最小堆上添加timer(O(logN)),将等待可读事件放入netpoll异步事件中监听,netpoll是在程序启动时初始化绑定一个单独的M进行事件轮训;Go1.14之前使用timerproc函数会调用一些系统调用来来让 goroutine 进入睡眠状态并唤醒 goroutine,系统调用意味着它为此生成OS线程,如果创建timer比较多,那就会发生比较多的系统调用,大大降低性能;之后改成异步事件轮训机制netpoll的方式多路复用,只需要一个OS线程来监听事件即可;系统调用因系统平台而异,通过runtime.nanotime1函数进行了封装;

如果时间到了,将最小堆顶timer删除(O(logN)),通过netpoll 异步事件机制 将 可执行的G调度到runnext中,然后绑定M运行f;

77.常见的 JSON 处理错误 (重要)

case1 类型嵌入导致的意外行为

需要了解json.Marshal 方法,在对结构类型对象进行Marshal操作时,如果实现了json.Marshaler接口方法MarshalJSON, 则会调用对应MarshalJSON方法进行encode操作,可以看具体源码: go1.20/src/encoding/json/encode.go (调用流程:Marshal→ marshal → reflectValue → valueEncoder → typeEncoder → newTypeEncoder → marshalerEncoder → MarshalJSON) ; 所以在json.Marshal操作的时候需要注意结构体的嵌入成员是否实现了json.Marshaler接口方法MarshalJSON, 比如: time.Time 实现了MarshalJSON这个方法; 如果不想直接使用组合嵌入成员的方法,则将其定义为对应类型成员,或者实现MarshalJSON方法覆盖嵌入成员的实现;

tips: 对结构类型对象进行UnMarshal操作也是同样情况。

case2 JSON 和单调时钟

对包含一个time.Time类型的结构encode或decode,有时会遇到意想不到的比较错误。

首先需要弄清楚操作系统处理两种不同的时钟类型:wall clock(挂钟)和 monotonic clock(单调时钟)。挂钟用于确定一天中的当前时间。此时钟可能会有所变化。例如,如果时钟使用同步网络时间协议 (NTP),它可以及时向后或向前跳转。不应该使用挂钟测量持续时间,因为可能会遇到奇怪的行为,例如负持续时间(润秒重置的情况)。这就是操作系统提供第二种时钟类型的原因:单调时钟。单调时钟保证时间总是向前移动并且不受时间跳跃的影响。它可能会受到频率调整的影响(例如,如果服务器检测到本地石英钟的移动速度与 NTP 服务器不同),但不会受到时间跳跃的影响。

以前Go Time包的相关时间读取函数实现仅读取系统挂钟,从不读取单调时钟,从而在时钟重置时导致测量不正确。比如 一个 Go 程序在闰秒期间测量负的经过时间导致CloudFlare 最近的 DNS 中断. 维基百科与闰秒相关的问题示例列表现在包括 CloudFlare 的中断,并将 Go 的时间 API 列为根本原因。除了闰秒问题之外,Go 还扩展到非生产环境中的系统,这些环境中的时钟可能不太好调节,因此时钟重置更频繁。Go 必须优雅地处理时钟重置。Go语言作者Russ Cox提出了提案设计**Proposal: Monotonic Elapsed Time Measurements in Go** (golang的开发规范和提案设计文档值得借鉴学习的,背景原因,验证评估影响面,尽量向前兼容,提案通过,再安排开发计划); 将monotonic clock 单调时钟引入time.Time结构体中,具体CR: https://go-review.googlesource.com/c/go/+/36255 , HN也有对应讨论: https://news.ycombinator.com/item?id=13566110 ;

tips: 测量持续时间,使用单调时钟;仅对本地持续时间测量有效;两个不同服务器的单调时钟根据定义是不同步的。因此,基于这些时钟测量分布式执行将不准确;这就涉及到分布式时钟同步的问题了。

ok了解了背景,回归正题,比如对一个结构体有time.Time类型成员,time.Time可能同时包含一个挂钟和一个单调时间,使用time.Now方法返回的时间就包括挂钟读数和单调时钟读数,具体见time包开发文档:https://pkg.go.dev/time#section-documentation; 以及查看源码客观分析:go1.20/src/time/time.go (Now→time.now→runtime.now in assembly → 如果可以使用vdso 调用 runtime·vdsoClockgettimeSym 减少系统调用提升性能,否则执行系统调用SYSCALL SYS_clock_gettime(228) 指令,见:linux系统调用指令集)。time.Now返回的Duration值打印如下:

2023-02-19 15:37:08.218505 +0800 CST m=+0.000118444
------------------------------------ --------------
             Wall time               Monotonic time

在执行json.UnMarshal 解码包涵time.Time类型公开成员结构体进行格式化解析时, 也会调用time.Time的UnMarshalJSON函数,最终会调用Time.stripMono, 去掉Monotonic time;导致前后结构体对象不一致,一个从time.Now中返回有Monotonic time,解析后的没有;通过Time.Truncate方法,去掉Monotonic time,可以解决;需要注意带有time.Time的结构体在encode/decode时,前后对象会不一致的情况;

case3 any map

any是空接口interface{}的别名,在对map[string]any类型对象进行 json.UnMarshal时,json字符串中的整数类型会解析成默认的float64类型,这样可能会导致数据判断时出现问题,对类型转换做出错误的假设可能会导致 goroutine panic。

78.常见的 SQL 错误

database/sql包提供SQL(或类似 SQL)数据库的标准通用接口;依赖具体数据操作,由三方来实现;接口与实现分离的很好例子;

tips:在设计通用中台和平台项目中的模块时,经常需要将抽象与实现分离,驱动化设计,方便具体领域场景的定制化开发。

具体查看开发文档:https://pkg.go.dev/database/sql;在使用这个包时看到一些模式或错误也很常见;深入研究五个常见错误case。

case1 忘记 sql.Open 不一定建立到数据库的连接 (工程规范)

Open 可能只是验证其参数而不创建与数据库的连接。要验证数据源名称是否有效,请调用 Ping。在使用的时候,和数据库进行交互的时候才建立连接。比如go-redis issues-2085 , 这个issue是因为使用go-redis v8 版本 通过ping请求访问 7.0 redis redis-cluster, v8版本还不支持新的协议返回的数据导致,需要升级使用go-redis v9版本来支持,所以使用ping功能即可以测试生成有效连接,而且可以验证客户端和服务端协议的一致性。

case2 忘记使用连接池 (工程规范)

应为数据库是底层存储数据资源,如果不限制使用有限的底层数据库连接资源,会增加底层数据库服务的负载;需要设置连接池,进行连接复用,以及结合数据库服务能力限制设置最大连接数,具体参数:

  • SetMaxOpenConns最大限度打开的数据库连接数(默认值unlimited);设置SetMaxOpenConns对于生产级应用程序很重要。因为默认值是无限的,应该设置它以确保它适合底层数据库可以处理的内容。
  • SetMaxIdleConns最大限度空闲连接数(默认值2);如果应用程序生成大量并发请求,则应增加SetMaxIdleConns(default: )的值。2否则,应用程序可能会经历频繁的重新连接。
  • SetConnMaxIdleTime最大限度连接关闭前可以空闲的时间量(默认值unlimited);如果应用程序可能面临大量请求,那么设置就很重要。当应用程序返回到更和平的状态时,希望确保创建的连接最终被释放。
  • SetConnMaxLifetime最大限度连接在关闭之前可以保持打开状态的时间(默认值unlimited);如果连接到负载平衡的数据库服务器,设置会很有帮助。在这种情况下,要确保应用程序永远不会使用连接太久。

如果应用程序面临不同的用例,可以使用多个连接池。这些值需要根据不同环境进行配置,对这些值进行可配置化管理,或者放在配置中心。

case3 不使用Prepare语句 (工程规范)

生产环境中,应该使用Prepare对sql 进行预处理,以防sql 注入,并且重复的sql语句不需要重新解析处理。

case4 错误处理空值 (工程规范)

在设计数据库表时,如果允许字段为NULL的话;查询这个字段scan row时,需要考虑NULL的情况,如果直接使用类型,则会报错; 解决方法,使用指针类型,以及sql包中封装的类型sql.NullXXX

比指针类型更清楚地表达了意图。

case5 不处理行迭代错误 (工程规范)

这是要牢记的最佳实践:因为rows.Next可以在遍历所有行或准备下一行时发生错误时停止,所以应该在迭代后使用rows.Err进行检查。

79.不关闭临时资源

开发者经常在代码中的某个点关闭申请的临时资源,以避免磁盘或内存,连接等资源泄漏。结构通常实现io.Closer接口表示必须关闭临时资源。列举3个不关闭临时资源的case:

case1 HTTP Response body (重要)

如果使用Go语言编写HTTP协议相关的代码,经常会遇到的问题,忘记关闭返回的http.Response.Body, 导致资源泄露,其实开发文档中已经给出了说明 https://pkg.go.dev/net/http#Response.Body

The http Client and Transport guarantee that Body is always non-nil, even on responses without a body or responses with a zero-length body.  It is the caller's responsibility to close Body.  The default HTTP client's Transport may not reuse HTTP/1. x "keep-alive" TCP connections if the Body is not read to completion and closed.

http客户端和传输保证Body总是非空的,即使响应没有Body或者响应的Body长度为零。关闭Body是调用者的责任。如果Body没有读到完成并且关闭,缺省HTTP客户端的传输(DefaultTransport 默认打开了Keep-Alive)不能复用HTTP/1.x “keep-alive"tcp 连接。并且查看源码分析:

go1.20/src/net/http/client.go

go1.20/src/net/http/transport.go

go1.20/src/net/http/transfer.go (body Read from bufio Read)

go1.20/src/bufio/bufio.go

调用流程:初始化Client, 调用 Client.Do/do (Get/Post/Head方法NewRequest之后都会调用Do方法)→ Client.send → send → Transport.RoundTrip 接口方法 → Transport.roundTrip

→ Transport.getConn → Transport.queueForDial -》 go Transport.dialConnFor → go persistConn.readLoop (将连接响应数据写入transferReader Body中, 发送responseAndError给roundTrip) 和 go persistConn.writeLoop (往连接中写请求数据,将writeErr结果分别发送一份到writeErrCh中,由readLoop接收处理,发送一份给roundTrip处理)

→ persistConn.roundTrip (发送persistConn.requestAndChan 到 reqch中,用于readLoop接收;发送writeRequest到writech中,由writeLoop 接收;从writeErrCh 处理write错误;从responseAndError chan中处理read错误)

整体过程是一个建立长连接(KeepAlive开启), 并在长连接中通过读写管道和错误结果管道来协同处理,管道是可缓冲的,长度是1个buffer,刚好用于存放一个数据,发送和接收等待管道中的数据进行处理。

在KeepAlive开启的情况下,长连接如果不关闭Response.Body,并且不读取Body中的数据,不会复用原有长连接,通过上面分析,会导致协程泄露;如下代码:

for i := 0; i < 10; i++ {
		fmt.Println("go nums", runtime.NumGoroutine())
		resp, _ := http.Get("<http://www.baidu.com>")
		if resp != nil && resp.Body != nil {
			//_, _ = ioutil.ReadAll(resp.Body)
			//_ = resp.Body.Close()
		}
	}
	fmt.Println("go nums", runtime.NumGoroutine())

如果复用的话,这里请求是串行处理,会复用同一个连接,所以只会有3个协程在工作;如果不能复用连接的话,每处理一个请求会新开连接,导致协程泄露。Client不初始化,Transport默认是开启keep-alive;

// DefaultTransport is the default implementation of Transport and is
// used by DefaultClient. It establishes network connections as needed
// and caches them for reuse by subsequent calls. It uses HTTP proxies
// as directed by the $HTTP_PROXY and $NO_PROXY (or $http_proxy and
// $no_proxy) environment variables.
var DefaultTransport RoundTripper = &Transport{
	Proxy: ProxyFromEnvironment,
	DialContext: defaultTransportDialContext(&net.Dialer{
		Timeout:   30 * time.Second,
		KeepAlive: 30 * time.Second,
	}),
	ForceAttemptHTTP2:     true,
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

生产环境中,使用tcp连接资源都是需要根据调用 资源服务放的系统负载吞吐能力来配置的。也是需要配置化。

  • 如果在没有读取的情况下也没有关闭Body,会发生协程泄露,同时tcp连接也不会复用,本质上是连接资源未释放至连接池中,存在连接泄露。

还需要记住的重要事情是,如开发文档net/http中提到的,当关闭 Response Body时,是否复用连接,这取决于是否从中读取完body中的值:

  • 如果在没有读取的情况下关闭Body,虽然不会发生协程泄露,但是默认的 HTTP 传输可能会关闭连接。
  • 如果在读取后关闭Body,默认的 HTTP 传输不会关闭连接;因此,它可以重复使用。

所以不管如何,最好的方式是都应该关闭Response Body, 尽管Body没有数据,或者已经读取完了,都应该关闭。

tips: 是否连接复用的判定,可以通过tcpdump 或者 wireshark 来抓包,通过是否使用同一个连接四元组来确定是否复用了同一连接。可以使用类似如下命令:

tcpdump -i utun2 -tnn dst host www.baidu.com //per host pool

case2 sql.Rows

sql.Rows是用作 SQL 查询结果的结构。因为这个结构实现了io.Closer,所以它必须被关闭。忘记关闭行意味着连接泄漏,这会阻止数据库连接被放回连接池。

case3 os.File

如果最终没有关闭一个os.File,它本身不会导致泄漏:文件将在os.File垃圾收集时自动关闭。但是,最好Close显式调用,因为不知道下一次 GC 何时会被触发(除非手动运行它)。

总结本节,已经看到关闭临时资源从而避免泄漏的重要性。临时资源必须在正确的时间和特定情况下关闭。事先并不总是清楚什么必须关闭。只能通过仔细阅读 API 文档和/或通过经验来获取这些信息。但是应该记住,如果一个结构实现了io.Closer接口,最终必须调用Close方法。最后但并非最不重要的一点是,了解如果闭包失败该怎么办非常重要:是否足以记录一条消息,或者是否也应该传播它?适当的操作取决于具体错误err是否需要处理。

80.在回复 HTTP 请求后忘记返回语句 (凑数)

如果有适当的覆盖率,这样的问题可以而且应该在测试期间被发现。这个属于err≠nil, 需要check遇到错误不为nil,是否直接return返回。这总低级错误,可以交给测试用例来覆盖到。

81.使用默认的 HTTP 客户端和服务器 (工程规范)

在讨论http包的时候提到, 如果不初始化http.Client,Client结构中的RoundTripper会默认使用DefaultTransport, 而DefaultTransport 只能用于开发测试时使用;对于生产环境, 需要更具依赖的资源服务进行配置,保证其配置过大的连接数而超出资源服务的负载能力,以及在网络不稳定情况下,连接超时,读写超时的设定,以便是否重试,这样不会一直hang住连接不释放,并发场景下,会导致服务负载增加, 连接过多导致服务拒绝。所以对于网络tcp请求,都需要根据具体的生产情况进行合理配置,而且是可配置化, 或者引入配置中心动态下发配置。对于服务端的tcp连接配置也是如此,也需要配置读写超时时间,进行可配置化管理。

  • 对接受time.Duration. 即使允许传递整数,也要尽量使用时间 API 来防止任何可能的混淆。
  • 避免调用time.After重复函数(例如循环或 HTTP 处理程序)可以避免峰值内存消耗。由创建的资源time.After只有在定时器到期时才会被释放。
  • 在 Go 结构中使用嵌入式字段时要小心。这样做可能会导致偷偷摸摸的错误,例如time.Time实现json .Marshaler接口的嵌入式字段,从而覆盖默认的封送处理行为。
  • 比较两个time.Time结构时,回想一下它time.Time同时包含一个挂钟和一个单调时钟,并且使用运算符的比较==是在两个时钟上完成的。
  • 为避免在解组 JSON 数据时提供地图时出现错误假设,请记住float64默认情况下会将数字转换为。
  • 如果需要测试配置并确保数据库可访问,请调用Pingor方法。PingContext
  • 配置生产级应用程序的数据库连接参数。
  • 使用 SQL 预处理语句可以使查询更高效、更安全。
  • 使用指针或类型处理表中可为空的列sql.NullXXX
  • 调用行后迭代Err的方法sql.Rows以确保您在准备下一行时没有遗漏任何错误。
  • 最终关闭所有实现的结构io.Closer以避免可能的泄漏。
  • return为避免 HTTP 处理程序实现中的意外行为,如果您希望处理程序在 之后停止,请确保您没有错过该语句http.Error
  • 对于生产级应用程序,不要使用默认的 HTTP 客户端和服务器实现。这些实现缺少在生产中应该强制执行的超时和行为。

</div


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK