Menu
Coddy logo textTech

スレッドセーフな構造体設計

CoddyのGOジャーニー「オブジェクト指向プログラミング」セクションの一部。レッスン 65/107。

mutex と WaitGroup を理解したので、これらを組み合わせて、複数の goroutine から同時に安全に使用できる struct を設計しましょう。スレッドセーフな struct はメソッド内に同期処理をカプセル化するため、呼び出し側はロックについて心配する必要がありません。

パターンは単純です。構造体にミューテックスを埋め込み、共有状態にアクセスするすべてのメソッドでロックします。

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

読み取り専用のValue()メソッドでさえミューテックスをロックしていることに注目してください。これを行わないと、あるゴルーチンが読み取っている間に別のゴルーチンが書き込みを行い、データ競合が発生する可能性があります。書き込みよりも読み取りのほうがはるかに多い場合は、代わりにsync.RWMutexを使用し、読み取りにはRLock()を呼び出してください。

重要な設計原則は、mutex を private に保つことです。小文字のフィールド名(mu)を使用することで、外部のコードがそれに直接アクセスするのを防ぎます。すべての同期はメソッドを通じて行われるため、スレッドセーフティを完全に制御できます。

複数のフィールドを持つ構造体では、一貫した状態を保証するため、関連するすべてのフィールドを同じミューテックスで保護します。

type Account struct {
    mu      sync.Mutex
    balance int
    history []string
}

func (a *Account) Deposit(amount int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    a.balance += amount
    a.history = append(a.history, fmt.Sprintf("+%d", amount))
}

balancehistory はアトミックに更新されます。一方だけが変更され、もう一方が変更されていない不整合な状態を、どのゴルーチンも観測することはできません。

challenge icon

チャレンジ

簡単

構造体のメソッド内に同期処理を適切にカプセル化する方法を示す、スレッドセーフな銀行口座システムを構築しましょう。口座では、ロックの詳細を呼び出し元に公開することなく、並行する入金、出金、残高確認を安全に処理します。

コードは2つのファイルに分けて構成します。

  • account.go:スレッドセーフな銀行口座を定義します。

    埋め込み型のsync.Mutexbalanceフィールド(int)、および成功したすべての操作を文字列として記録するtransactionsスライスを持つBankAccount構造体を作成します。

    次のメソッドを実装します。

    • NewBankAccount(initial int) *BankAccount - 指定された初期残高と空のtransactionsスライスを持つ新しい口座を作成します
    • Deposit(amount int) - 金額を残高に加算し、取引を+[amount]として記録します
    • Withdraw(amount int) bool - 十分な資金がある場合は金額を差し引き、-[amount]を記録してtrueを返します。それ以外の場合は何も変更せずにfalseを返します
    • Balance() int - 現在の残高を返します
    • History() []string - transactionsスライスのコピーを返します

    構造体のフィールドにアクセスするすべてのメソッドでは、スレッドセーフティを確保するためにmutexをロックする必要があります。ロック解除にはdeferを使用します。mutexとすべてのフィールドは非エクスポート(小文字)にして、外部コードが必ずメソッドを使用するようにします。

  • main.go:銀行操作を処理し、スレッドセーフな口座を実証します。

    初期残高を読み取り、続いて操作数を読み取ります。各操作について、種類(depositwithdraw、またはbalance)を読み取り、deposit/withdrawの場合は金額を読み取ります。

    各操作の結果を出力します。

    • depositDeposited [amount], Balance: [new balance]を出力します
    • withdraw:成功した場合はWithdrew [amount], Balance: [new balance]を、成功しなかった場合はWithdrawal failed: insufficient fundsを出力します
    • balanceCurrent balance: [balance]を出力します

    すべての操作の後、取引履歴を出力します。各エントリは新しい行に出力し、最初のエントリにのみHistory: という接頭辞を付けます。

次の入力が提供されます。

  • 1行目:初期残高(整数)
  • 2行目:操作数(整数)
  • それ以降の行:各操作の種類(depositwithdraw、またはbalance)。deposit/withdrawの場合は、次の行に金額が続きます

たとえば、次の入力が与えられた場合:

100
5
deposit
50
balance
withdraw
30
withdraw
200
balance

出力は次のようになります。

Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30

ここでの重要な原則は、すべての同期処理がBankAccountメソッド内に隠蔽されていることです。呼び出し元はロックについて一切考えることなく、単にDeposit()Withdraw()Balance()を使用します。構造体が内部でスレッドセーフティを処理します。

自分で試してみよう

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"
)

func main() {
	reader := bufio.NewReader(os.Stdin)

	// 初期残高を読み取る
	initialStr, _ := reader.ReadString('\n')
	initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))

	// 操作の数を読み取る
	numOpsStr, _ := reader.ReadString('\n')
	numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))

	// 銀行口座を作成する
	account := NewBankAccount(initial)

	// 各操作を処理する
	for i := 0; i < numOps; i++ {
		opType, _ := reader.ReadString('\n')
		opType = strings.TrimSpace(opType)

		switch opType {
		case "deposit":
			amountStr, _ := reader.ReadString('\n')
			amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
			// TODO: Deposit を呼び出して結果を出力する
			// 形式: "Deposited [amount], Balance: [new balance]"

		case "withdraw":
			amountStr, _ := reader.ReadString('\n')
			amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
			// TODO: Withdraw を呼び出して適切な結果を出力する
			// 成功した場合: "Withdrew [amount], Balance: [new balance]"
			// If failed: "Withdrawal failed: insufficient funds"
			_ = amount // 実装したらこの行を削除する

		case "balance":
			// TODO: Balance を呼び出して結果を出力する
			// Format: "Current balance: [balance]"
		}
	}

	// TODO: 取引履歴を出力する
	// 最初のエントリには "History: " の接頭辞を付ける
	// 後続のエントリはプレフィックスなしで新しい行に配置する
}
quiz icon腕試し

このレッスンには短いクイズがあります。レッスンを始めて解答し、進捗を記録しましょう。

オブジェクト指向プログラミングのすべてのレッスン

自分で練習してみよう: Goオンラインコンパイラ