Thiết kế struct an toàn luồng
Thuộc phần Lập trình hướng đối tượng trong hành trình GO của Coddy. Bài học 65 trên 107.
Giờ bạn đã hiểu về mutex và WaitGroup, hãy kết hợp chúng để thiết kế các struct an toàn khi được nhiều goroutine sử dụng đồng thời. Một struct an toàn với luồng đóng gói cơ chế đồng bộ hóa bên trong các phương thức, nhờ đó bên gọi không cần lo việc khóa.
Cách làm rất đơn giản: nhúng một mutex vào struct và khóa nó trong mọi phương thức truy cập trạng thái dùng chung:
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
}Lưu ý rằng ngay cả phương thức chỉ đọc Value() cũng khóa mutex. Nếu không khóa, một goroutine có thể đọc trong khi goroutine khác đang ghi, gây ra tình trạng tranh chấp dữ liệu. Nếu số lần đọc nhiều hơn số lần ghi đáng kể, hãy dùng sync.RWMutex thay thế và gọi RLock() khi đọc.
Một nguyên tắc thiết kế quan trọng: giữ mutex ở chế độ riêng tư. Khi dùng tên trường viết thường (mu), bạn ngăn code bên ngoài truy cập trực tiếp vào nó. Mọi hoạt động đồng bộ hóa đều diễn ra thông qua các phương thức của bạn, giúp bạn kiểm soát hoàn toàn tính an toàn của luồng.
Với các cấu trúc có nhiều trường, hãy bảo vệ mọi trường liên quan bằng cùng một mutex để đảm bảo trạng thái nhất quán:
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))
}Cả balance và history đều được cập nhật một cách nguyên tử: không goroutine nào có thể thấy trạng thái không nhất quán, trong đó một biến đã thay đổi còn biến kia thì chưa.
Thử thách
DễHãy xây dựng một hệ thống tài khoản ngân hàng an toàn khi chạy đồng thời, minh họa cách đóng gói việc đồng bộ hóa trong các phương thức của struct. Tài khoản của bạn sẽ xử lý an toàn các giao dịch gửi tiền, rút tiền và kiểm tra số dư đồng thời mà không để lộ chi tiết khóa cho bên gọi.
Bạn sẽ tổ chức code trong hai tệp:
account.go: Định nghĩa tài khoản ngân hàng an toàn khi chạy đồng thời.Tạo một struct
BankAccountcó nhúngsync.Mutex, một trườngbalance(int) và một slicetransactionsghi lại mọi thao tác thành công dưới dạng chuỗi.Triển khai các phương thức sau:
NewBankAccount(initial int) *BankAccount: Tạo tài khoản mới với số dư ban đầu đã cho và một slice transactions rỗngDeposit(amount int): Cộng số tiền vào số dư và ghi lại giao dịch dưới dạng+[amount]Withdraw(amount int) bool: Nếu có đủ tiền, trừ số tiền đó, ghi lại-[amount]và trả vềtrue. Nếu không, trả vềfalsemà không thay đổi gìBalance() int: Trả về số dư hiện tạiHistory() []string: Trả về bản sao của slice transactions
Mỗi phương thức truy cập các trường của struct phải khóa mutex để đảm bảo an toàn khi chạy đồng thời. Dùng
deferđể mở khóa. Giữ mutex và tất cả các trường ở dạng không xuất ra ngoài (chữ thường) để code bên ngoài phải dùng các phương thức của bạn.main.go: Xử lý các thao tác ngân hàng và minh họa tài khoản an toàn khi chạy đồng thời.Đọc số dư ban đầu, sau đó đọc số lượng thao tác. Với mỗi thao tác, đọc loại thao tác (
deposit,withdrawhoặcbalance), và với thao tác gửi hoặc rút tiền, đọc số tiền.In kết quả của từng thao tác:
deposit: InDeposited [amount], Balance: [new balance]withdraw: Nếu thành công, inWithdrew [amount], Balance: [new balance], nếu không thì inWithdrawal failed: insufficient fundsbalance: InCurrent balance: [balance]
Sau khi xử lý tất cả thao tác, in lịch sử giao dịch, mỗi mục trên một dòng mới. Chỉ thêm tiền tố
History:vào mục đầu tiên.
Đầu vào sẽ có dạng như sau:
- Dòng 1: Số dư ban đầu (số nguyên)
- Dòng 2: Số lượng thao tác (số nguyên)
- Các dòng tiếp theo: Với mỗi thao tác, loại thao tác (
deposit,withdrawhoặcbalance), và với thao tác gửi hoặc rút tiền, số tiền ở dòng tiếp theo
Ví dụ, với đầu vào:
100
5
deposit
50
balance
withdraw
30
withdraw
200
balanceKết quả đầu ra sẽ là:
Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30Nguyên tắc cốt lõi ở đây là mọi thao tác đồng bộ đều được ẩn bên trong các phương thức BankAccount. Bên gọi chỉ cần dùng Deposit(), Withdraw() và Balance() mà không cần nghĩ đến các khóa. Struct của bạn tự xử lý việc đảm bảo an toàn khi chạy đồng thời.
Tự làm thử
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
reader := bufio.NewReader(os.Stdin)
// Đọc số dư ban đầu
initialStr, _ := reader.ReadString('\n')
initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))
// Đọc số lượng thao tác
numOpsStr, _ := reader.ReadString('\n')
numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))
// Tạo tài khoản ngân hàng
account := NewBankAccount(initial)
// Xử lý từng thao tác
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: Gọi Deposit và in ra kết quả
// Định dạng: "Deposited [amount], Balance: [new balance]"
case "withdraw":
amountStr, _ := reader.ReadString('\n')
amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
// TODO: Gọi Withdraw và in ra kết quả phù hợp
// Nếu thành công: "Withdrew [amount], Balance: [new balance]"
// Nếu thất bại: "Withdrawal failed: insufficient funds"
_ = amount // Xóa dòng này khi bạn triển khai
case "balance":
// TODO: Gọi Balance và in ra kết quả
// Định dạng: "Current balance: [balance]"
}
}
// TODO: In ra lịch sử giao dịch
// Thêm "History: " trước mục đầu tiên
// In các mục tiếp theo trên dòng mới, không thêm tiền tố
}
Bài học này có một bài trắc nghiệm ngắn. Bắt đầu bài học để trả lời và theo dõi tiến độ.
Tất cả bài học trong Lập trình hướng đối tượng
1Nền tảng OOP trong Go
Tệp bên ngoàiWorkspace và module trong GoPackage và importTên được export và không được exportGiới thiệu về OOP trong GoStruct như lớpĐịnh nghĩa phương thức cho structReceiver kiểu con trỏ và kiểu giá trịKhởi tạo structHàm khởi tạoÔn tập: máy tính đơn giản4Interface
Giới thiệu về interfaceTriển khai ngầm địnhInterface như một hợp đồngInterface rỗng (any)Khẳng định kiểuswitch kiểuKết hợp interfaceInterface Stringer và ErrorÔn tập: Máy tính hình học7Tính đóng gói
Trường được xuất và không được xuấtĐóng gói ở cấp packagePhương thức getter và setterẨn thông tin trong GoÔn tập: hồ sơ học sinh10Generics (Go 1.18 trở lên)
Giới thiệu về GenericsTham số kiểuRàng buộc kiểuStruct tổng quátGiải pháp thay thế cho phương thức tổng quátÔn tập: tập hợp tổng quát13Các mẫu thiết kế phần 1
Giới thiệu về mẫu thiết kếMẫu SingletonMẫu FactoryMẫu Abstract FactoryMẫu ObserverMẫu Strategy2Tìm hiểu chuyên sâu về kiểu dữ liệu và struct
Kiểu cơ bản và kiểu tổng hợpĐịnh nghĩa kiểu dữ liệu tùy chỉnhThẻ structStruct ẩn danhStruct lồng nhauGiá trị zero và giá trị mặc địnhÔn tập: sổ liên lạc5Ưu tiên kết hợp hơn kế thừa
Vì sao Go không có kế thừaNhập môn nhúng structTự động nâng phương thứcNhúng nhiều structNhúng và kết tậpChe khuất phương thức được nhúngÔn tập: hệ phân cấp nhân viên8Xử lý lỗi và lập trình hướng đối tượng
Giao diện errorCác kiểu lỗi tùy chỉnhBọc lỗi với fmt.ErrorfLỗi sentinelerrors.Is() và errors.As()Panic, defer và recoverÔn tập: trình phân tích tệp3Con trỏ và bộ nhớ
Cơ bản về con trỏ trong GoCon trỏ đến structTruyền theo giá trị và tham chiếuHàm new()Thu gom rác trong GoÔn tập: xây dựng danh sách liên kết6Đa hình trong Go
Đa hình qua interfaceĐịnh kiểu vịt trong GoQuy tắc thỏa mãn interfaceCác tập hợp đa hìnhTiêm phụ thuộcÔn tập: bộ xử lý thanh toán9Lập trình đồng thời và lập trình hướng đối tượng
Cơ bản về goroutineChannel và giao tiếpChannel có bộ đệm và không có bộ đệmCâu lệnh selectsync.Mutex và sync.RWMutexsync.WaitGroupThiết kế struct an toàn luồngÔn tập: worker poolTự luyện tập: Trình biên dịch Go online