Menu
Coddy logo textTech
flag Ar iconالعربيةdown icon

تصميم الـ Structs الآمنة للخيوط (Thread-Safe)

جزء من قسم Object Oriented Programming في رحلة GO على Coddy — الدرس 65 من 107.

الآن بعد أن فهمت الـ mutexes والـ WaitGroups، دعنا نجمع بينهما لتصميم هياكل (structs) آمنة للاستخدام من عدة goroutines في وقت واحد. الـ struct الآمن برمجياً (thread-safe) يغلف التزامن داخل الـ methods الخاصة به، بحيث لا يحتاج المستدعون (callers) للقلق بشأن القفل (locking).

النمط بسيط: قم بتضمين mutex داخل الـ struct الخاص بك وقم بقفله في كل method تصل إلى الحالة المشتركة (shared state):

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() المخصصة للقراءة فقط تقوم بقفل الـ mutex. بدون ذلك، قد تقوم goroutine واحدة بالقراءة بينما تقوم أخرى بالكتابة، مما يتسبب في سباق بيانات (data race). إذا كانت عمليات القراءة أكثر شيوعاً بكثير من عمليات الكتابة، فاستخدم sync.RWMutex بدلاً من ذلك واستدعِ RLock() لعمليات القراءة.

مبدأ تصميم أساسي: اجعل الـ mutex خاصاً. باستخدام اسم حقل يبدأ بحرف صغير (mu)، فإنك تمنع الكود الخارجي من الوصول إليه مباشرة. تتم جميع عمليات المزامنة من خلال دوالك (methods)، مما يمنحك تحكماً كاملاً في سلامة الخيوط (thread safety).

بالنسبة للهياكل (structs) التي تحتوي على حقول متعددة، قم بحماية جميع الحقول ذات الصلة باستخدام نفس الـ mutex لضمان حالة متسقة:

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

التحدي

سهل

لنقم ببناء نظام حساب بنكي آمن برمجياً (thread-safe) يوضح التغليف الصحيح للمزامنة داخل دوال الهيكل (struct methods). سيتعامل حسابك مع عمليات الإيداع، السحب، وفحص الرصيد المتزامنة بأمان دون الكشف عن أي تفاصيل تتعلق بالقفل (locking) للمستدعين.

ستقوم بتنظيم الكود الخاص بك عبر ملفين:

  • 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 - يعيد نسخة من شريحة المعاملات

    يجب على كل دالة تصل إلى حقول الهيكل أن تقفل الـ mutex لضمان السلامة البرمجية (thread safety). استخدم defer لفك القفل. اجعل الـ mutex وجميع الحقول غير مصدرة (unexported - تبدأ بحرف صغير) بحيث يجب على الكود الخارجي استخدام دوالك فقط.

  • 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() دون التفكير أبداً في الأقفال (locks) - حيث يتعامل الهيكل الخاص بك مع السلامة البرمجية داخلياً.

ورقة مرجعية

تُغلف البنية الآمنة للخيوط (thread-safe struct) التزامن داخل دوالها من خلال تضمين mutex وقفله في كل دالة تصل إلى الحالة المشتركة:

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
}

المبادئ الأساسية:

  • قفل الـ mutex في جميع الدوال التي تصل إلى الحالة المشتركة، بما في ذلك دوال القراءة فقط، لمنع سباقات البيانات (data races)
  • استخدم defer لضمان إلغاء قفل الـ mutex حتى لو انتهت الدالة مبكراً
  • اجعل الـ mutex خاصاً (private) (اسم حقل يبدأ بحرف صغير) حتى لا يتمكن الكود الخارجي من الوصول إليه مباشرة
  • بالنسبة لأحمال العمل الكثيفة في القراءة، استخدم sync.RWMutex واستدعِ RLock() للقراءات

بالنسبة للبنيات (structs) التي تحتوي على حقول متعددة، قم بحماية جميع الحقول ذات الصلة باستخدام نفس الـ mutex لضمان حالة متسقة:

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 بشكل ذري (atomically) — بحيث لا يمكن لأي goroutine ملاحظة حالة غير متسقة.

جرّب بنفسك

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اختبر نفسك

يتضمن هذا الدرس اختبارًا قصيرًا. ابدأ الدرس للإجابة عليه وتتبّع تقدمك.

جميع دروس Object Oriented Programming