Проектирование потокобезопасных структур
Часть раздела Объектно-ориентированное программирование путешествия по 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 не может наблюдать несогласованное состояние, при котором одно значение изменилось, а другое — нет.
Задание
ЛегкоДавайте создадим потокобезопасную систему банковских счетов, которая демонстрирует правильную инкапсуляцию синхронизации внутри методов структур. Ваш счет будет безопасно обрабатывать параллельные депозиты, снятия средств и проверки баланса, не раскрывая деталей блокировки вызывающему коду.
Вы организуете свой код в двух файлах:
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: "
// Последующие записи должны быть на новых строках без префикса
}
В этом уроке есть небольшой тест. Начните урок, чтобы ответить на вопросы и сохранить прогресс.
Все уроки раздела Объектно-ориентированное программирование
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