Menu
Coddy logo textTech

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

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

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

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

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() を呼び出してください。

重要な設計原則:ミューテックスをプライベートに保つ。小文字のフィールド名(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.Mutexを埋め込み、balanceフィールド(int)、およびすべての成功した操作を文字列として記録するtransactionsスライスを持つBankAccount構造体を作成します。

    以下のメソッドを実装してください:

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

    構造体のフィールドにアクセスするすべてのメソッドは、スレッドセーフを確保するためにミューテックスをロックする必要があります。アンロックには defer を使用してください。外部コードがメソッドを使用するように、ミューテックスとすべてのフィールドは非公開(小文字)のままにしてください。

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

    初期残高、次に操作回数を読み取ります。各操作について、タイプ(depositwithdraw、または balance)を読み取り、預け入れ/引き出しの場合は金額を読み取ります。

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

    • deposit: Deposited [amount], Balance: [new balance] と出力します
    • withdraw: 成功した場合は Withdrew [amount], Balance: [new balance]、失敗した場合は Withdrawal failed: insufficient funds と出力します
    • balance: Current balance: [balance] と出力します

    すべての操作の後、トランザクション履歴を各エントリを新しい行に出力し、最初のエントリにのみ History: をプレフィックスとして付けます。

以下の入力が提供されます:

  • 1行目:初期残高(整数)
  • 2行目:操作回数(整数)
  • 続く行:各操作について、タイプ(depositwithdraw、または balance)、および預け入れ/引き出しの場合は次の行に金額

例えば、以下のように与えられた場合:

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() を使用します。構造体が内部でスレッドセーフを処理します。

チートシート

スレッドセーフな構造体は、ミューテックスを埋め込み、共有状態にアクセスするすべてのメソッドでそれをロックすることで、メソッド内での同期をカプセル化します。

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
}

主な原則:

  • データレースを防ぐため、読み取り専用メソッドを含む、共有状態にアクセスするすべてのメソッドでミューテックスをロックします
  • 関数が早期にリターンした場合でもミューテックスが確実にアンロックされるように、deferを使用します
  • 外部のコードが直接アクセスできないように、ミューテックスをプライベート(小文字のフィールド名)にします
  • 読み取りが多いワークロードの場合は、sync.RWMutexを使用し、読み取りにはRLock()を呼び出します

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

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の両方がアトミックに更新されることが保証され、どのゴルーチンも不整合な状態を観測することはありません。

自分で試してみよう

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]"
			// 失敗時: "Withdrawal failed: insufficient funds"
			_ = amount // 実装時にこの行を削除してください

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

	// TODO: 取引履歴を出力する
	// 最初の項目には "History: " という接頭辞を付けること
	// それ以降の項目は接頭辞なしで新しい行に出力すること
}
quiz icon腕試し

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

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