忽视字符串格式化产生的副作用
格式化字符串是开发人员常用到的操作,无论是返回错误信息还是在记录日志信息时。但是在编写并发应用程序时,很容易忘记字符串格式化潜在的副作用。本节将举两个示例进行说明,一个来自etcd库中格式化字符串产生的数据竞争,另一个是格式化字符串导致的死锁问题。
etcd数据竞争
etcd是一个Go语言实现的分布式键值存储,很多知名的项目都有使用它,例如Kubernetes用etcd存储所有机器数据。etcd库提供了与集群交互的API,下面的Watcher接口用于在数据更改时发送通知。
代码语言:javascript复制type Watcher interface {
// Watch watches on a key or prefix. The watched events will be returned
// through the returned channel.
// ...
Watch(ctx context.Context, key string, opts ...OpOption) WatchChan
Close() error
}
上面接口依赖gRPC流,如果对gRPC数据流不熟悉也没关系,知道它是一种在客户端和服务端不断交换数据的技术即可。服务端必须维护所有使用此功能的所有客户端列表信息。下面的watcher结构体实现了Watcher接口,所以它需要有字段(streams)存储所有活动流。
代码语言:javascript复制type watcher struct {
// ...
// streams hold all the active gRPC streams keyed by ctx value.
streams map[string]*watchGrpcStream
}
在watcher的Watch方法中,访问map的key来自上下文ctx.下面代码中,ctxKey是map的key, 它是通过来自客户端的上下文context格式化得到的。在使用携带有键值信息ctx(context.WithValue)格式化为字符串时,Go将尝试访问读取ctx中所有字段值。在这种场景下,开发人员发现提供给Watch的上下文在某些情况下包含可变值,例如指向结构体的指针。当一个goroutine正在更新上下文中的值,另一个正在执行Watch操作时,产生了数据竞争。详细问题讨论见https://github.com/etcd-io/etcd/pull/7816
代码语言:javascript复制func (w *watcher) Watch(ctx context.Context, key string,
opts ...OpOption) WatchChan {
// ...
ctxKey := fmt.Sprintf("%v", ctx)
// ...
wgs := w.streams[ctxKey]
// ...
修复思路是不通过fmt.Sprintf来格式化访问map的键,防止同时有修改和读取上下文内容操作。一种可行的解决方法是实现一个自定义的streamKeyFromCtx函数来从特定的上下文中提取键,防止键变化。上面讨论的问题已修复,详情见https://github.com/etcd-io/etcd/blob/3ce31acda410db937408ac1c1011fe7b0babd8a7/etcdserver/api/v3client/v3client.go#L57。下面是fix的关键代码。
代码语言:javascript复制func New(s *etcdserver.EtcdServer) *clientv3.Client {
c := clientv3.NewCtxClient(context.Background())
...
c.Watcher = &watchWrapper{clientv3.NewWatchFromWatchClient(wc)}
...
return c
}
// BlankContext implements Stringer on a context so the ctx string doesn't
// depend on the context's WithValue data, which tends to be unsynchronized
// (e.g., x/net/trace), causing ctx.String() to throw data races.
type blankContext struct{ context.Context }
func (*blankContext) String() string { return "(blankCtx)" }
// watchWrapper wraps clientv3 watch calls to blank out the context
// to avoid races on trace data.
type watchWrapper struct{ clientv3.Watcher }
func (ww *watchWrapper) Watch(ctx context.Context, key string, opts ...clientv3.OpOption) clientv3.WatchChan {
return ww.Watcher.Watch(&blankContext{ctx}, key, opts...)
}
「NOTE:为了防止数据竞争问题,处理上下文中潜在的可变值可能会导致程序额外的复杂性,这需要在设计时谨慎考虑」
上述这个例子说明在程序中进行格式化字符串操作时,需要小心它可能带来的副作用,像这里的数据竞争问题。
死锁
现在有一个Customer结构体,为了可以并发地对其进行读写,在访问的时候通过sync.RWMutex来保护。该结构体有一个UpdateAge方法用来更新Customer的年龄并检查age的合法性。同时为了打印输出,Customer也实现了Stringer接口的String() string
方法。下面是实现代码,你能看出这段代码有什么问题吗?
type Customer struct {
mutex sync.RWMutex
id string
age int
}
func (c *Customer) UpdateAge(age int) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if age < 0 {
return fmt.Errorf("age should be positive for customer %v", c)
}
c.age = age
return nil
}
func (c *Customer) String() string {
c.mutex.RLock()
defer c.mutex.RUnlock()
return fmt.Sprintf("id %s, age %d", c.id, c.age)
}
上述代码存在的问题并不是那么容易发现。如果传入的age值为负数,将返回一个错误。该错误是通过fmt.Errorf对c进行格式化,这会调用c的String方法。然而,由于UpdateAge已经获取了互斥锁,String方法将无法获取读锁,会导致程序死锁卡死。
运行上述程序,如果所有的goroutine都无法推进运行,程序会panic挂掉。
代码语言:javascript复制fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
sync.runtime_SemacquireMutex(0xc00005e9f8, 0x13, 0x1775048)
如何处理上面的这种情况呢?首先,通过这段代码说明了单元测试的重要性。也许有人会争辩说,创建一个负值年龄进行测试不值得,因为这段代码逻辑非常简单,用不着这么麻烦处理。但是,如果没有适当的测试覆盖率,可能错过这个问题。
上面代码存在的问题可以通过限制互斥锁的范围来修复。可以看到,上述代码UpdateAge中是先获取锁然后检查age的合法性。相反,我们应该先检查age的合法性然后再获取锁。这样减少了锁锁住的范围,能够减少竞争冲突,也会提高程序的性能,只有在必要的时候获取锁,而不是提前获取锁。修改后的代码如下:
代码语言:javascript复制func (c *Customer) UpdateAge(age int) error {
if age < 0 {
return fmt.Errorf("age should be positive for customer %v", c)
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.age = age
return nil
}
上面的程序先对年龄进行检查,如果年龄合法再获取锁,避免了死锁问题产生。如果年龄为负数,则调用它的String方法时无需先获取互斥锁。但是在某些情况下,限制互斥锁的范围并不是那么简单,甚至不可能。在这种情况下,必须非常小心字符串格式化。这时可以换一种思路处理,调用一个不尝试获取互斥锁的函数,或者改变格式化打印的内容,让它不调用String方法。例如,像下面这样直接访问id字段,就不会产生死锁。
代码语言:javascript复制func (c *Customer) UpdateAge(age int) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if age < 0 {
return fmt.Errorf("age should be positive for customer id %s", c.id)
}
c.age = age
return nil
}
总结,本文通过两个具体的例子,一个是从上下文中格式化一个键,另一个是返回一个格式化结构体的错误,说明在这两种情况下,格式化字符串都会导致问题。第一个例子会导致数据竞争,第二个例子会产生死锁。因此,在编写并发应用程序时,对字符串格式化操作需要小心,防止它产生副作用。