本文主要介绍如何利用go的闭包机制实现一个函数缓存,并用 sync.Map 优化并发性能,weak 优化GC流程,同时通过泛型机制方便缓存的创建。

调用结果缓存

如果需要减少重复计算,节省cpu资源,可以给一个函数或者方法加上缓存层,在调用缓存时先检查是否已经有结果,如果没有,再调用具体的函数进行计算,并将计算的结果存入缓存层,在一定时间内,后续的请求会通过缓存层取值。以一个非常简单并且没有定时器的缓存为例:


type FnType func(FnParams) (FnReturns, error)

// 存储的函数所有参数
type FnParams strcut {
    ...
}

// 函数/方法返回值,不包括error
type FnReturns struct {
    ...
}

func FnCache(fn FnType) FnType {
	cacheMap := make(map[FnParams]FnResult)

	return func(fp FnParams) (FnResult, error) {

		if res, ok := cacheMap[fp]; ok {
			fmt.Println("hit cache")
			return res, nil
		}

		fmt.Println("cache miss")

		res, err := fn(fp)

		if err == nil {
			cacheMap[fp] = res
		}

		return res, err
	}
}

在这里一个简单的加法函数为例, 令FnParams 为两个int变量,FnRetruns包含一个int变量,Fn 函数返回int变量的和。

type FnParams struct {
	Param1 int
	Param2 int
}

type FnReturns struct {
	Result int
}

func Fn(fp FnParams) (FnReturns, error) {
	return FnReturns{fp.Param1 + fp.Param2}, nil
}

func main() {

	cachedFn := FnCache(Fn)

	p1 := FnParams{1, 2}

	r1, _ := cachedFn(p1)

	fmt.Println(r1.Result)

	_, _ = cachedFn(p1)

	_, _ = cachedFn(p1)
}

运行程序,显示如下结构,可以看到,后面的调用都命中了缓存,

$ go run main.go
cache miss
3
hit cache
hit cache

如果希望添加 TTL/Expiration 机制的话可以在 FnReturns 的基础上创建结构 cacheEntry 包含过期时间

type cacheEntry struct {
	result     FnReturns
	expiration time.Time
}

并对 FnCache 的缓存命中判断部分进行修改函数进行修改

    if entry, ok := cacheMap[fp]; ok {
                if time.Now().Before(entry.expiration) {
                    fmt.Println("hit cache")
                    return entry.result, nil
                }
                // Remove expired entry.
                delete(cacheMap, fp)
            }

generic type 泛型

Go 在 1.18 版本添加了对于 generic type 的支持,并在 1.24 版本添加了对于 generic type alias

泛型语法

通过泛型能够定义一个新的类型,泛型的定义需要包含新的类型名,类型参数列表(type parameters),其中每一项都由类型名称(与底层类型的列表相对应)和限制(constraint)构成,以及底层类型(UnderlyingType)。对于类型的定义如下:

type TypeName[<type parameters>] UnderlyingType

现在就可以对函数类型 FnType 以及缓存map的类型 FnCacheMapType 进行定义:

type FnType[FnParams comparable, FnReturns any] func(FnParams) (FnReturns, error)

其中 FnParamsFnReturns 后面的字段都是限制(constraint)字段,comparable 代表可以比较的,因为要作为字典的键,any 则是任意的,即任何类型都行,因为需要作为字典的值。

在函数中使用泛型时,则需要在函数名和参数列表之间加上类型参数列表,如下:

func Function[<type parameters>](<parameters>) (<parameters>)

利用泛型语法让 FnCache 更加灵活,对 FnCache 进行以下修改:

func FnCache[FnParams comparable, FnReturns any](fn FnType[FnParams, FnReturns]) FnType[FnParams, FnReturns] {
	cacheMap := make(map[FnParams]FnReturns)
}

泛型别名

泛型别名可以为已有的泛型附上具体的类型,并创建别名,语法规则如下:

type AliasName[<type parameters>] = ExistingTypeExpression

仍然以两数相加为例,这里的泛型被附上的具体的类型,作为一个别名:

type Fn1 struct {
	i1 int
	i2 int
}

type Fn2 struct {
	r int
}

// AddFnType并不是一个新的泛型
type AddFnType = FnType[Fn1, Fn2]

weak pointer 弱指针

weak 标准库实现了 weak pointerweak pointer 像正常的指针一样可以引用对象,但是允许对象被Go Runtime的GC程序清理掉。

使用弱指针能够让内存管理更为灵活,如果内存不足,runtime在触发GC时会自动将这些缓存对象清楚,以确保程序的正常运行,而不是等到缓存的内容过期。

通过 weak.Make[T any](ptr *T) 可以创建 weak.Pointer[T],并通过 Value() 方法返回指针(具有强引用的),如果内存已经被GC清理,则返回 nil

func getIntPointer(val int) *int {
	return &val
}

func main() {
	a := getIntPointer(2)

	b := a

	fmt.Println("a:", a)

	weakPtr := weak.Make(b)

	p1 := weakPtr.Value()

	fmt.Println("初始的weak pointer: ", p1)

	a = nil

	// 触发GC,清理a,b
	runtime.GC()

	p2 := weakPtr.Value()

	fmt.Println("GC之后的weak pointer: ", p2)

}

返回结果

a: 0xc000010140
weak pointer:  0xc000010140
weak pointer:  <nil>

但是如果不触发GC

    // 不发触发GC
	// runtime.GC()

注释掉runtime.GC()

a: 0xc000010140
初始的weak pointer:  0xc000010140
未GC的weak pointer:  0xc000010140

现在可以修改 FnCache 函数使用 weak.Pointer 更高效地管理缓存。

修改cacheMap的定义,使用 weak pointer

cacheMap := make(map[FnParams]weak.Pointer[FnReturns])

通过 weakp.Value() 获取强引用的指针,如果缓存的内容已经被清除(返回 nil ),则不使用。

		if weakp, ok := cacheMap[fp]; ok {
			fmt.Println("hit cache")

			res := weakp.Value()

			if res != nil {
				return *res, nil
			}
		}

如果缓存未命中或者缓存内容已时效,则需要获取 fn 的返回结果,并创建 weak.Point

		res, err := fn(fp)

		if err == nil {
			weakp := weak.Make(&res)
			cacheMap[fp] = weakp
		}

通过 sync.Map 处理并发

sync.Map 提供了一个内置的并发安全的map实现,不需要再添加额外的锁,同时对读密集型的任务非常高效。sync.Map 在内部维护两张map:一张只读map,能够无锁地进行读取,如果在这里找不到需要的键,则会到脏表中查找;一张脏map,记录所有的写入,每个一段时间或者已经有足够多的更新,脏map就会合并到只读map中。

为了能够让 FnCache 使用 sync.Map,除了修改 cacheMap 的类型为sync.Map以外,还需要修改其存储和读取的流程

通过 cacheMapLoad() 方法读取弱指针:

        if v, ok := cacheMap.Load(fp); ok {
                    if weakp, ok := v.(weak.Pointer[FnReturns]); ok {
                        fmt.Println("hit cache")
                        if res := weakp.Value(); res != nil {
                            return *res, nil
                        }
                    }
                }

通过 Store 方法存入弱指针:

			weakp := weak.Make(&res)
			cacheMap.Store(fp, weakp)

完整代码

package main

import (
	"fmt"
	"sync"
	"weak"
)

type FnType[FnParams comparable, FnReturns any] func(FnParams) (FnReturns, error)

func FnCache[FnParams comparable, FnReturns any](fn FnType[FnParams, FnReturns]) FnType[FnParams, FnReturns] {
	var cacheMap sync.Map // keys: FnParams, values: weak.Pointer[FnReturns]

	return func(fp FnParams) (FnReturns, error) {
		// Try to load the cached weak pointer.
		if v, ok := cacheMap.Load(fp); ok {
			if weakp, ok := v.(weak.Pointer[FnReturns]); ok {
				fmt.Println("hit cache")
				if res := weakp.Value(); res != nil {
					return *res, nil
				}
			}
		}

		fmt.Println("cache miss")
		// Call the underlying function.
		res, err := fn(fp)
		if err == nil {
			// Create a weak pointer for the result and store it.
			weakp := weak.Make(&res)
			cacheMap.Store(fp, weakp)
		}
		return res, err
	}
}

type Fn1 struct {
	i1 int
	i2 int
}

type Fn2 struct {
	r int
}

type AddFnType = FnType[Fn1, Fn2]

func AddFn(fp Fn1) (Fn2, error) {
	var r int
	r = fp.i1 + fp.i2
	return Fn2{r: r}, nil
}

func main() {

	cachedFn := FnCache(AddFn)

	fn1 := Fn1{
		i1: 1,
		i2: 2,
	}

	r1, _ := cachedFn(fn1)

	fmt.Println(r1.r)

	_, _ = cachedFn(fn1)

	_, _ = cachedFn(fn1)
}