Golang 定时自动更新配置(Channel 通知、读写锁 RWMutex 或 Atomic)

Published: 2023-01-27

Tags: Golang

本文总阅读量

本篇整理了些程序自动加载远程配置时的注意事项。

通过 Channel 发送消息(可选)

之前在修改一处程序逻辑时需要将配置初始化完成的事件发送给另一个定时任务模块,定时任务模块获取配置启动任务,通常更好的方式是定时任务模块提供一个函数。

本例使用 Channel 发送通知的方式来启动任务,这在一些场景下灵活性较高,例如可以解决老项目中的循环引用问题。

main-unbuffered-channel.go

package main

import (
  "fmt"
  "time"
  "sync"
)

var cfgFilename string

var ch = make(chan int)
var wg sync.WaitGroup

func task() {
  fmt.Println("⌛️ [task] 等待配置初始化完成")
  for range ch {
    fmt.Println("✅ [task] 获取配置成功: " + cfgFilename)
    wg.Done()
  }
}

func SyncConfig() {
  fmt.Println("⌛️ [init] 初始化配置")  
  time.AfterFunc(time.Duration(3) * time.Second, func() {
    cfgFilename = "base.yaml"
    fmt.Println("✅ [init] 初始化配置完成")
    ch <- 1
    close(ch)
  })
}

func main() {
  wg.Add(1)
  SyncConfig()
  task()
  wg.Wait()
}

输出

⌛️ [task] 等待配置初始化完成
⌛️ [init] 初始化配置
✅ [init] 初始化配置完成
✅ [task] 获取配置成功: base.yaml

通过 Channel 传递消息,任务协程可以等配置加载完成后再执行。 另外在上例中使用带缓存及不带缓存的 Channel 没有什么区别,因为 time.AfterFunc() 函数使用新的协程来执行,不会阻塞住程序的运行。

定时触发更新配置

这是一个循环更新配置的例子,使用 time.Tick() 函数来间隔触发,模拟定时更新配置,如果需要功能更丰富的定时任务,可以使用 robfig/cron 包。

package main

import (
  "fmt"
  "time"
  "sync"
)

var cfgFilename string

var ch = make(chan int, 1)
var wg sync.WaitGroup

func task() {
  for range ch {
    fmt.Println("✅ [task] 使用新配置成功: " + cfgFilename)
  }
}

func SyncConfig() {
  for range time.Tick(time.Second * 2) {
    cfgFilename = "base.yaml"
    fmt.Println("✅ [init] 获取配置成功")
    ch <- 1
  }
}

func main() {
  wg.Add(1)
  go SyncConfig()
  task()
  wg.Wait()
}

输出

✅ [init] 获取配置成功
✅ [task] 使用新配置成功: base.yaml
✅ [init] 获取配置成功
✅ [task] 使用新配置成功: base.yaml
✅ [init] 获取配置成功
✅ [task] 使用新配置成功: base.yaml
...

更新配置加读写锁

从远端同步配置更新到内存的过程,配置未完全更新完成,可能多个协程就会读取,造成读写不一致,可以引入读写锁来解决。

读写锁的特性是加写锁时不可读,加读锁时不可写,加读锁时可同时读。

var rwMutex sync.RWMutex

// SyncConfig 从远端获取数据加载到内存
func SyncConfig() {
    data, err := RequestRemoteConfig()
    if err != nil {
        log.Error(err)
    }
    rwMutex.Lock()
    globalConfig = data
    syncTime = time.Now()
    defer rwMutex.Unlock()
}

// GetConfigFromCache 从内存中获取 Config 数据
func GetConfigFromCache() (GlobalConfig, error) {
    rwMutex.RLock()
    defer rwMutex.RUnlock()
    return globalConfig, nil
}

上例中函数,通过 Lock() 写锁及 RLock() 读锁实现了这一目标。

当我们需要存储的内容不需要太多的逻辑处理时,可以使用 Atomic 原子操作来替代锁。

使用 Atomic.Value 替代读写锁

var globalConfig atomic.Value

// SyncConfig 从远端获取数据加载到内存
func SyncConfig() {
    data, err := RequestRemoteConfig()
    if err != nil {
        log.Error(err)
    }
    globalConfig.Store(data)
}

// GetConfigFromCache 从内存中获取 Config 数据
func GetConfigFromCache() (map[string]string, error) {
    return globalConfig.Load().(map[string]string), nil
}

锁与原子化操作

什么时候使用锁,什么时候应用原子化操作可以参考

原子操作由底层硬件支持,对于一个变量更新的保护,原子操作通常会更有效率,并且更能利用计算机多核的优势,如果要更新的是一个复合对象,则应当使用 atomic.Value 封装好的实现。 而我们做并发同步控制常用到的 Mutex 锁,则是由操作系统的调度器实现,锁应当用来保护一段逻辑。

对比来看 atomic 的粒度更小,性能通常更好,而锁用来保护一段逻辑,粒度稍大。

Mutex 由操作系统实现,而 atomic 包中的原子操作则由底层硬件直接提供支持。

自动加载更新配置的注意事项总结

触发配置更新方式

  1. 程序定时到远程通过 HTTP 请求拉取
  2. 程序监听本地配置文件的变化
  3. 提供接口触发程序更新

配置的载入

这一过程需要考虑到读写不一致问题,根据配置更新的规模和粒度选择使用锁还是原子化操作的方式来更新。

参考