Проектирование потокобезопасных структур
Часть раздела Объектно-ориентированное программирование путешествия по 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 обновляются атомарно: ни одна горутина не может увидеть несогласованное состояние, при котором одно изменилось, а другое — нет.
Задание
ЛегкоСоздадим потокобезопасную систему банковского счёта, демонстрирующую правильную инкапсуляцию синхронизации внутри методов структуры. Ваш account будет безопасно обрабатывать параллельные пополнения, снятия средств и проверки баланса, не раскрывая вызывающему коду детали блокировок.
Вы организуете код в двух файлах:
account.go: Определите потокобезопасный банковский счёт.Создайте структуру
BankAccountсо встроеннымsync.Mutex, полемbalance(int) и срезомtransactions, который записывает все успешные операции в виде строк.Реализуйте следующие методы:
NewBankAccount(initial int) *BankAccount— создаёт новый account с заданным начальным балансом и пустым срезом transactionsDeposit(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 fundsbalance: выведите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: "
// Последующие записи должны быть на новых строках без префикса
}
В этом уроке есть небольшой тест. Начните урок, чтобы ответить на вопросы и сохранить прогресс.
Все уроки раздела Объектно-ориентированное программирование
1Основы ООП в Go
Внешние файлыРабочее пространство и модули GoПакеты и импортыЭкспортируемые и неэкспортируемые именаВведение в ООП в GoСтруктуры как классыОпределение методов структурПолучатели-указатели и получатели-значенияИнициализация структурФункции-конструкторыИтоги — Простой калькулятор4Интерфейсы
Введение в интерфейсыНеявная реализацияИнтерфейс как контрактПустой интерфейс (any)Утверждение типаПереключатель типовКомпозиция интерфейсовИнтерфейсы Stringer и ErrorПовторение: Калькулятор фигур7Инкапсуляция
Экспортируемые и неэкспортируемые поляИнкапсуляция на уровне пакетовГеттеры и сеттерыСокрытие информации в GoИтоги — Записи о студентах10Обобщения (Generics) (Go 1.18+)
Введение в GenericsПараметры типовОграничения типовОбобщенные структурыОбходной путь для обобщенных методовИтоги — Обобщенная коллекция13Паттерны проектирования. Часть 1
Введение в паттерны проектированияПаттерн SingletonПаттерн FactoryПаттерн Abstract FactoryПаттерн ObserverПаттерн Strategy2Глубокое погружение в типы и структуры
Базовые и составные типыОпределение пользовательских типовТеги структурАнонимные структурыВложенные структурыНулевые значения и значения по умолчаниюПовторение — Контактная книга5Композиция вместо наследования
Почему в Go нет наследованияОсновы встраивания структурПродвижение методовВстраивание нескольких структурВстраивание против агрегацииЗатенение встроенных методовИтоги — Иерархия сотрудников8Обработка ошибок и ООП
Интерфейс errorПользовательские типы ошибокОбертывание ошибок (fmt.Errorf)Sentinel-ошибкиerrors.Is() и errors.As()Panic, Defer и RecoverИтоги — Парсер файлов11Стандартная библиотека и ООП
io.Reader и io.Writersort.InterfaceИнтерфейс fmt.Stringerencoding/json со структурамиИнтерфейс http.HandlerПовторение: модели REST API14Паттерны проектирования. Часть 2
Паттерн КомандаПаттерн АдаптерПаттерн ДекораторПаттерн Шаблонный методПаттерн СостояниеПаттерн КомпоновщикMiddleware как Декоратор3Указатели и память
Основы указателей в GoУказатели на структурыПередача по значению и по ссылкеФункция new()Сборка мусора в GoПовторение: Конструктор связного списка6Полиморфизм в Go
Полиморфизм через интерфейсыУтиная типизация в GoПравила реализации интерфейсовПолиморфные коллекцииВнедрение зависимостейИтоги — Обработчик платежей9Конкурентность и ООП
Основы горутинКаналы и взаимодействиеБуферизованные и небуферизованные каналыОператор selectsync.Mutex и sync.RWMutexsync.WaitGroupПроектирование потокобезопасных структурПовторение — Worker PoolПотренируйтесь самостоятельно: Онлайн-компилятор Go