はじめに
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)
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