Menu
CoddyTech

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.

challenge icon

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 BankAccount có nhúng sync.Mutex, một trường balance (int) và một slice transactions ghi 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ỗng
    • Deposit(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ề false mà không thay đổi gì
    • Balance() int: Trả về số dư hiện tại
    • History() []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, withdraw hoặc balance), 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: In Deposited [amount], Balance: [new balance]
    • withdraw: Nếu thành công, in Withdrew [amount], Balance: [new balance], nếu không thì in Withdrawal failed: insufficient funds
    • balance: In Current 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, withdraw hoặc balance), 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
balance

Kế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
-30

Nguyê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ố
}
quiz iconTự kiểm tra

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

Tự luyện tập: Trình biên dịch Go online