0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goのオブジェクトキャッシュ(TTLとGC付き)

Posted at

はじめに

Goでシンプルな**オブジェクトキャッシュ(ocache)**を実装してみました。
このキャッシュには以下の特徴があります:

  • ✅ 遅延読み込み(必要になるまでロードしない)
  • ✅ 同時アクセス時の排他処理
  • ✅ TTL(一定時間未使用で削除)
  • ✅ GC(定期的に不要なものを掃除)

🎯 この記事で学べること

  • goroutine / channel / mutex を組み合わせた並行キャッシュ制御
  • 一定時間で自動削除されるTTLキャッシュの仕組み
  • sync.Mutex による排他ロック制御
  • Goらしい非同期設計の基本

🧠 「オブジェクトキャッシュ」とは?

よくある「オブジェクトキャッシュ」とは、以下のような役割を持つコンポーネントです:

  • オブジェクト(構造体やデータ)をメモリ上に保持する
  • 同じIDに対するアクセスが複数あっても、同じインスタンスを返す
  • 不要になったら自動で削除する

🧩 今回の構成

                 +------------------+
                 |    Global Cache  |
                 |  map[string]entry|
                 +--------+---------+
                          |
             +------------+------------+
             |                         |
      +------+-----+           +-------+-----+
      |   entry     |           |   entry     |
      | (Object)    |           | (Object)    |
      |   state     |           |   state     |
      |   channel   |           |   channel   |
      +------------+           +-------------+

entry が 1 つのキャッシュ対象を表し、状態やアクセス時刻を管理します。

🛠️ 実装内容

① 最初の1回だけロード

if _, ok := entries[id]; !ok {
    e = newEntry()
    entries[id] = e
    go func() {
        obj := loadFunc(id)
        e.value = obj
        e.state = "active"
        e.lastUsage = time.Now()
        close(e.load)
    }()
}
  • 初めてアクセスされた ID に対しては loadFunc() を実行します。
  • load チャンネルを使って、他の goroutine はロード完了を待つようになっています。

② ロード中のアクセスは waitLoad() で待機

func (e *entry) waitLoad(ctx context.Context) (*Object, error) {
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    case <-e.load:
        return e.value, e.loadErr
    }
}
  • load チャンネルが閉じられるまで待機
  • この実装によって重複ロードを防ぎつつ並行処理も安全に対応できます

③ TTL(一定時間未使用なら削除)

func (e *entry) TryClose() bool {
    if time.Since(e.lastUsage) > ttl {
        e.state = "closed"
        close(e.closed)
        return true
    }
    return false
}

④ GC(定期的にキャッシュをクリーンアップ)

func GC(entries map[string]*entry, mu *sync.Mutex) {
    mu.Lock()
    defer mu.Unlock()
    for id, e := range entries {
        if e.state == "active" && e.TryClose() {
            delete(entries, id)
        }
    }
}

waitClose() でクローズ中も安全に待つ

func (e *entry) waitClose(ctx context.Context) (bool, error) {
    e.mu.Lock()
    switch e.state {
    case "closed":
        return true, nil
    case "closing":
        ch := e.closed
        e.mu.Unlock()
        <-ch
        return true, nil
    default:
        e.mu.Unlock()
        return false, nil
    }
}

💻 フルコード(Go Playground)

🔗 Go Playgroundでうごかせるサンプルコード

package main

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

const ttl = 3 * time.Second

type Object struct {
	id string
}

func (o *Object) Close() error {
	fmt.Println("Closing:", o.id)
	return nil
}

type entry struct {
	value     *Object
	loadErr   error
	load      chan struct{}
	state     string // "loading", "active", "closing", "closed"
	lastUsage time.Time
	mu        sync.Mutex
	closed    chan struct{}
}

func newEntry() *entry {
	return &entry{
		load:   make(chan struct{}),
		state:  "loading",
		closed: make(chan struct{}),
	}
}

func (e *entry) waitLoad(ctx context.Context) (*Object, error) {
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case <-e.load:
		return e.value, e.loadErr
	}
}

func (e *entry) waitClose(ctx context.Context) (bool, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	switch e.state {
	case "closed":
		return true, nil
	case "closing":
		ch := e.closed
		e.mu.Unlock()
		select {
		case <-ctx.Done():
			return false, ctx.Err()
		case <-ch:
			return true, nil
		}
	default:
		return false, nil
	}
}

func (e *entry) TryClose() bool {
	if time.Since(e.lastUsage) > ttl {
		fmt.Println("TryClose: TTL expired for", e.value.id)
		e.state = "closed"
		close(e.closed)
		return true
	}
	return false
}

func GC(entries map[string]*entry, mu *sync.Mutex) {
	mu.Lock()
	defer mu.Unlock()
	for id, e := range entries {
		if e.state == "active" && e.TryClose() {
			delete(entries, id)
			fmt.Println("GC: removed", id)
		}
	}
}

func startGCTicker(entries map[string]*entry, mu *sync.Mutex) {
	ticker := time.NewTicker(2 * time.Second)
	go func() {
		for range ticker.C {
			GC(entries, mu)
		}
	}()
}

func main() {
	entries := make(map[string]*entry)
	var mu sync.Mutex
	ctx := context.Background()

	loadFunc := func(id string) *Object {
		fmt.Println("Loading object:", id)
		time.Sleep(1 * time.Second)
		return &Object{id: id}
	}

	startGCTicker(entries, &mu)

	get := func(id string) (*Object, error) {
	Load:
		mu.Lock()
		e, ok := entries[id]
		if !ok {
			e = newEntry()
			entries[id] = e
			go func() {
				obj := loadFunc(id)
				e.value = obj
				e.state = "active"
				e.lastUsage = time.Now()
				close(e.load)
			}()
		}
		e.lastUsage = time.Now()
		mu.Unlock()

		reload, err := e.waitClose(ctx)
		if err != nil {
			return nil, err
		}
		if reload {
			goto Load
		}
		return e.waitLoad(ctx)
	}

	// 3つ並列で取得
	for i := 0; i < 3; i++ {
		go func(i int) {
			obj, err := get("example")
			if err != nil {
				fmt.Println("Error:", err)
				return
			}
			fmt.Printf("Goroutine %d got object: %s\n", i, obj.id)
		}(i)
	}

	// GC動作を観察するためしばらく待機
	time.Sleep(10 * time.Second)
}

🔄 実行結果イメージ

Loading object: example
Goroutine 1 got object: example
Goroutine 2 got object: example
Goroutine 0 got object: example
TryClose: TTL expired for example
GC: removed example

📎 関連リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?