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() блокирует мьютекс. Без этого одна горутина может выполнять чтение, пока другая выполняет запись, что приведет к состоянию гонки (data race). Если операции чтения происходят гораздо чаще, чем записи, используйте вместо этого 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 обновляются атомарно — ни одна goroutine не может наблюдать несогласованное состояние, при котором одно значение изменилось, а другое — нет.

challenge icon

Задание

Легко

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Строка 1: Начальный баланс (целое число)
  • Строка 2: Количество операций (целое число)
  • Последующие строки: Для каждой операции — тип (deposit, withdraw или 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
}

Основные принципы:

  • Блокируйте мьютекс во всех методах, которые обращаются к общему состоянию, включая методы только для чтения, чтобы предотвратить состояние гонки (data races)
  • Используйте 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))
}

Это гарантирует, что и balance, и history обновляются атомарно — ни одна горутина не сможет увидеть несогласованное состояние.

Попробуйте сами

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Проверьте себя

В этом уроке есть небольшой тест. Начните урок, чтобы ответить на вопросы и сохранить прогресс.

Все уроки раздела Объектно-ориентированное программирование