Menu
Coddy logo textTech

Проектирование потокобезопасных структур

Часть раздела Объектно-ориентированное программирование путешествия по GO на Coddy. Урок 65 из 107.

Теперь, когда вы понимаете мьютексы и WaitGroups, давайте объединим их, чтобы разработать структуры, безопасные для одновременного использования из нескольких горутин. Потокобезопасная структура инкапсулирует синхронизацию в своих методах, поэтому вызывающим сторонам не нужно беспокоиться о блокировках.

Шаблон прост: встроить мьютекс в вашу структуру и блокировать его в каждом методе, который обращается к общему состоянию:

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))
}

И balance, и history обновляются атомарно: ни одна горутина не может увидеть несогласованное состояние, при котором одно изменилось, а другое — нет.

challenge icon

Задание

Легко

Создадим потокобезопасную систему банковского счёта, демонстрирующую правильную инкапсуляцию синхронизации внутри методов структуры. Ваш account будет безопасно обрабатывать параллельные пополнения, снятия средств и проверки баланса, не раскрывая вызывающему коду детали блокировок.

Вы организуете код в двух файлах:

  • account.go: Определите потокобезопасный банковский счёт.

    Создайте структуру BankAccount со встроенным sync.Mutex, полем balance (int) и срезом transactions, который записывает все успешные операции в виде строк.

    Реализуйте следующие методы:

    • NewBankAccount(initial int) *BankAccount — создаёт новый account с заданным начальным балансом и пустым срезом transactions
    • Deposit(amount int) — добавляет amount к балансу и записывает операцию как +[amount]
    • Withdraw(amount int) bool — если достаточно средств, вычитает amount, записывает -[amount] и возвращает true. В противном случае возвращает false, ничего не изменяя
    • Balance() int — возвращает текущий баланс
    • History() []string — возвращает копию среза transactions

    Каждый метод, обращающийся к полям структуры, должен блокировать mutex для обеспечения потокобезопасности. Используйте defer для разблокировки. Оставьте mutex и все поля неэкспортируемыми (с именами в нижнем регистре), чтобы внешний код был вынужден использовать ваши методы.

  • main.go: Обработайте банковские операции и продемонстрируйте работу потокобезопасного account.

    Прочитайте начальный баланс, затем количество операций. Для каждой операции прочитайте её тип (deposit, withdraw или balance), а для deposit/withdraw прочитайте amount.

    Выведите результаты каждой операции:

    • deposit: выведите Deposited [amount], Balance: [new balance]
    • withdraw: при успешном выполнении выведите Withdrew [amount], Balance: [new balance], а если операция не выполнена — Withdrawal failed: insufficient funds
    • balance: выведите Current balance: [balance]

    После всех операций выведите историю транзакций, поместив каждую entry на новую строку и добавив префикс History: только к первой entry.

Будут предоставлены следующие входные данные:

  • Строка 1: начальный баланс (целое число)
  • Строка 2: количество операций (целое число)
  • Следующие строки: для каждой операции — её тип (deposit, withdraw или balance), а для deposit/withdraw — amount в следующей строке

Например, для следующих данных:

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