前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go语言中常见100问题-#68 Forgetting about possible side-effects with ...

Go语言中常见100问题-#68 Forgetting about possible side-effects with ...

作者头像
数据小冰
发布2022-08-15 15:25:58
2820
发布2022-08-15 15:25:58
举报
文章被收录于专栏:数据小冰
忽视字符串格式化产生的副作用

格式化字符串是开发人员常用到的操作,无论是返回错误信息还是在记录日志信息时。但是在编写并发应用程序时,很容易忘记字符串格式化潜在的副作用。本节将举两个示例进行说明,一个来自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方法。下面是实现代码,你能看出这段代码有什么问题吗?

代码语言:javascript
复制
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
}

总结,本文通过两个具体的例子,一个是从上下文中格式化一个键,另一个是返回一个格式化结构体的错误,说明在这两种情况下,格式化字符串都会导致问题。第一个例子会导致数据竞争,第二个例子会产生死锁。因此,在编写并发应用程序时,对字符串格式化操作需要小心,防止它产生副作用。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-06-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 忽视字符串格式化产生的副作用
    • etcd数据竞争
      • 死锁
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档