البرمجة غير المتزامنة
درس 2 من 5
2البرمجة غير المتزامنة (Async): الكوروتين وحلقة الأحداث
الصورة الكبيرةكيف يتوقف برنامجك عن إهدار الوقت في الانتظار: تفهم الفرق بين الانتظار والحساب، وتكتب كوروتينات بـasync/await، وتشغّل عشرات العمليات معًا بـgather وas_completed، وتتحكم بها كمهام Tasks، وتقيس الفرق بالثواني بنفسك.
ماذا تشاهد في الرسم: A تعمل قليلًا ثم تنتظر، فتنتقل الحلقة إلى B ثم C، ثم تعود إلى A حين يصل ما كانت تنتظره. عمل واحد في كل لحظة، والانتظار لا يضيع.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
تخيّل نادلًا في مقهى. النادل البطيء يأخذ طلبك ويقف أمام آلة القهوة حتى تجهز، ثم يأخذ طلب الزبون الثاني. النادل الذكي يشغّل الآلة ويذهب ليأخذ طلبًا آخر، ثم يعود حين تصفّر. نفس الشخص ونفس الآلات، لكن الانتظار لم يعد يضيع الوقت.
async def يعرّف كوروتينًا، وawait يتنازل عن الدور لحلقة الأحداث event loop أثناء انتظار الشبكة أو القرص. asyncio.gather يشغّل عدة كوروتينات معًا، وcreate_task يجدولها كمهام، وas_completed يعيدها بترتيب انتهائها. هذا تزامن لا توازٍ، ويفيد في الانتظار لا في الحساب الثقيل.
الأساسيات المطلوبة
الدوال وقيم الإرجاع (درس الدوال)
الكوروتين دالة قبل كل شيء: تأخذ وسائط وتُرجع قيمة. الجديد الوحيد أنها تستطيع التوقف في المنتصف ثم تكمل.
الاستثناءات (درس الاستثناءات)
الإلغاء في asyncio يصل إليك على شكل استثناء CancelledError، ومن يعرف try/except/finally يعرف كيف ينظّف بعده.
القوائم والتفريغ (درس هياكل البيانات)
gather(*coros) يعتمد على تفريغ قائمة من الكوروتينات، والنتيجة قائمة مرتبة بترتيب الوسائط.
درس واجهات REST: الواجهات
أوضح مكان يظهر فيه الربح: عشرة طلبات HTTP بالتتابع تأخذ مجموع أزمنتها، وبالتزامن تأخذ زمن أبطئها.
المفاهيم الأساسية
فكرة هذا الجزء كلها في جملة واحدة: معظم بطء البرامج ليس حسابًا، بل انتظارًا. وحين يكون البطء انتظارًا، لا تحتاج معالجًا أسرع ولا خيوطًا إضافية؛ تحتاج فقط أن يستفيد البرنامج من فترات الانتظار بدل أن يقف فيها.
1نوعان من البطء: انتظار أم حساب؟
قبل أي شيء، صنّف مشكلتك، لأن العلاج يختلف تمامًا:
| النوع | أين يضيع الوقت | أمثلة | العلاج |
|---|---|---|---|
| مقيّد بالإدخال/الإخراج (I/O-bound) | في الانتظار: المعالج فارغ تمامًا | طلب شبكة، قراءة ملف، استعلام قاعدة بيانات، انتظار رد واجهة | asyncio أو الخيوط |
| مقيّد بالمعالج (CPU-bound) | في الحساب: المعالج مشغول 100% | معالجة صور، تشفير، ضغط، حلقة رياضية طويلة | multiprocessing أو لغة أسرع |
2التزامن ليس التوازي
الخلط بينهما سبب معظم التوقعات الخاطئة:
التزامن (Concurrency)
التعامل مع عدة أعمال في نفس الفترة، بالتنقّل بينها. باريستا واحد يبدأ ماكينة الإسبريسو، وبينما تعمل يسخّن الحليب. عامل واحد، لكن لا وقت ضائع.
التوازي (Parallelism)
تنفيذ عدة أعمال في نفس اللحظة فعليًا. باريستان اثنان يعملان جنبًا إلى جنب. يحتاج أنوية معالج متعددة.
asyncio يعطيك تزامنًا بلا توازٍ: خيط واحد، لحظة واحدة ينفّذ فيها كوروتين واحد فقط، لكن لا أحد يقف منتظرًا. ولهذا السبب تحديدًا لا يوجد في asyncio «سباق على المتغيّرات» بالمعنى الذي تخافه في الخيوط: التبديل بين المهام لا يحدث إلا عند await، وأنت من يكتب مكانه.
3الكوروتين: دالة تعرف كيف توقف نفسها
الدالة العادية حين تبدأ لا تتوقف حتى تنتهي. الكوروتين (coroutine) يستطيع أن يقول «سأنتظر شيئًا، خذوا الدور ولا تضيعوا وقتي»، ثم يُستأنف لاحقًا من نفس السطر بكل متغيّراته سليمة.
| المصطلح | ما هو بالضبط |
|---|---|
| دالة كوروتين | الدالة المعرّفة بـasync def. هي تعريف، لم يعمل منها شيء بعد. |
| كائن كوروتين | ما يعود من استدعائها: c = random_delay(5). لم ينفّذ سطرًا واحدًا بعد، مجرد وعد مُعبّأ. |
| مهمة Task | كوروتين سلّمته لحلقة الأحداث لتبدأ تشغيله فورًا في الخلفية. |
| قابل للانتظار (awaitable) | أي شيء يصحّ أن تضع await أمامه: كوروتين، مهمة، أو Future. |
async بلا await لا ينفّذ جسمها إطلاقًا، ولا يرفع خطأ: يعطيك كائنًا وتُهمله، فيطبع بايثون تحذير RuntimeWarning: coroutine ... was never awaited. البرنامج يكمل «بنجاح» وقد نسي نصف عمله.
4حلقة الأحداث: المدير الذي ينظّم الدور
حلقة الأحداث (event loop) هي قلب asyncio: حلقة واحدة تحتفظ بقائمة المهام الجاهزة، تعطي كل مهمة الدور حتى تتوقف عند await، فتأخذ الدور منها وتعطيه لمهمة أخرى. وحين لا يوجد أحد جاهز، تنام حتى يصل ردّ شبكة أو ينتهي مؤقّت.
asyncio.run(main()) هو من يفتح الحلقة ويشغّل main فيها ثم يغلقها. هذه نقطة الدخول الوحيدة الموصى بها، وتُستدعى مرة واحدة من كود متزامن عادي، لا من داخل كوروتين.
5async وawait: ماذا يفعل كل منهما
async def
يحوّل الدالة إلى دالة كوروتين. هذا كل ما يفعله: لا يجعل شيئًا متوازيًا ولا أسرع بنفسه. إنه مجرد إعلان «هذه الدالة قد تتوقف في المنتصف».
await x
يقول: «نفّذ x وأعطني نتيجته، وإن احتاج انتظارًا فأعد الدور للحلقة حتى يجهز». وهو ممنوع خارج دالة async.
asyncio.sleep(n)
انتظار يُعيد الدور: الحلقة تشغّل غيرك خلال الثواني n. نقيضه time.sleep(n) الذي يجمّد الخيط كله ومعه كل المهام.
asyncio.run(coro)
يفتح الحلقة، ينتظر انتهاء coro، يغلق الحلقة، ويُرجع القيمة. الجسر بين عالمك المتزامن وعالم asyncio.
6ثلاث طرق لتشغيل عدة كوروتينات (والفرق بينها جوهري)
| الطريقة | ماذا تفعل | الزمن الكلي | ترتيب النتائج |
|---|---|---|---|
await a(); await b() | واحدًا تلو الآخر، لا تزامن إطلاقًا | مجموع الأزمنة | ترتيب الكتابة |
await asyncio.gather(a(), b()) | تشغّلها كلها معًا وتنتظرها كلها | أطولها | ترتيب الوسائط دائمًا |
asyncio.as_completed([a(), b()]) | تسلّمك كل واحدة لحظة انتهائها | أطولها | ترتيب الانتهاء |
sort()»
حين تكون كل مهمة مجرد انتظار لمدة معيّنة، فأول من ينتهي هو صاحب أقصر مدة. ومعنى ذلك أن as_completed يسلّمك المدد مرتبة تصاعديًا بطبيعتها، لا لأنك رتّبتها بل لأن الزمن رتّبها. أما gather فيحتفظ بترتيب الوسائط فتأتي غير مرتبة. سنثبت الاثنين بالأرقام في الخطوة الخامسة.
7الكوروتين مقابل المهمة (Task)
الفرق في لحظة البدء:
| كائن كوروتين | مهمة Task | |
|---|---|---|
| كيف تُنشأ | c = coro() | t = asyncio.create_task(coro()) |
| متى تبدأ | عند await فقط | فورًا، تعمل في الخلفية |
| هل يمكن إلغاؤها | لا | نعم: t.cancel() |
| هل تعرف حالتها | لا | نعم: t.done()، t.result() |
| نوعها | coroutine | asyncio.Task |
لهذا تُكتب دالة تُرجع مهمة بـdef عادية لا async def: create_task لا يحتاج انتظارًا، فهو يسجّل المهمة ويعيدها فورًا؛ ولو جعلت الدالة async لأصبح على المستدعي أن ينتظرها ليحصل على المهمة، وهذه طبقة انتظار بلا فائدة.
8قياس الزمن: ماذا تقيس بالضبط
استخدم time.perf_counter() لا time.time(): الأول ساعة رتيبة مخصّصة لقياس الفروق ولا تتأثر بتعديل ساعة النظام. والقياس الصحيح يحيط بـasyncio.run(...) كاملًا، لأن ما تريد معرفته هو الزمن الحقيقي الذي انتظره المستخدم.
9التعليقات النوعية على الكوروتينات
مشاريعك تُصحّح على بايثون 3.8، وهذا يفرض قواعد محدّدة:
| الدالة | التعليق الصحيح في 3.8 | لماذا |
|---|---|---|
async def random_delay(...) | -> float | اكتب نوع القيمة النهائية، لا Coroutine؛ لأنك ستكتب await للحصول عليها. |
async def gather_delays(...) | -> List[float] | من typing: list[float] المدمجة لا تعمل كتعليق إلا من 3.9. |
def as_task(...) | -> asyncio.Task | دالة عادية تُرجع كائن مهمة، فالنوع هو المهمة نفسها. |
list[float] وstr | None كلاهما يرفع TypeError في 3.8. استورد من typing: List وOptional. وكذلك as_completed لا يُستعمل مع async for في 3.8؛ تكتب for عادية ثم await على كل عنصر، كما في الخطوة الخامسة.
كيف يعمل هذا فعليًا، خطوة بخطوة
أربع صور تختصر الفكرة: أين يذهب الوقت، وما الحالات التي يمرّ بها الكوروتين، وماذا يجري داخل الحلقة عند await، ومتى يكون asyncio هو الجواب أصلًا.
الرسم 36: أين يذهب الوقت: في الحالة A الانتظاران متتاليان فيتجمّعان، وفي الحالة B يتداخلان فيحكمهما أطولهما. لاحظ أن العمل نفسه لم يتغيّر، تغيّر ترتيب الانتظار فقط.
الرسم 37: دورة حياة الكوروتين. الحالة الأولى هي سبب تحذير «was never awaited»: كائن بقي عند C ولم ينتقل أبدًا.
الرسم 38: ما يحدث داخل gather. التبديل بين المهام لا يحدث إلا عند await؛ ولهذا لو احتوى أحدها على حساب ثقيل بلا await لتوقّف كل شيء.
الرسم 39: شجرة القرار. السؤال الأول ليس «هل أستخدم async؟» بل «أين يضيع الوقت؟»، والإجابة عليه تحدّد الأداة.
- اكتب دوالك التي تنتظر بـ
async defواجعل كل انتظار فيهاawait. - اجمع ما يمكن تشغيله معًا في
gatherأوas_completed، لا في حلقةforمعawaitداخلها. - استخدم
create_taskحين تريد أن يبدأ عمل في الخلفية بينما تكمل أنت. - شغّل كل شيء من نقطة دخول واحدة:
asyncio.run(main()). - قِس بـ
perf_counterقبل أن تصدّق أنك ربحت شيئًا.
مثال محلول: مقهى يحضّر الطلبات معًا
سنبقى في مقهانا من الأجزاء السابقة، لكن هذه المرة المشكلة هي الزمن. كل كود ومخرَج تحته نُفِّذ فعليًا، والأرقام حقيقية لا تقديرية.
الخطوة 1: المشكلة، طلبان بالتتابع
import asyncio, time
async def brew(name, secs):
await asyncio.sleep(secs) # يمثّل انتظارًا حقيقيًا: ماكينة، شبكة، قرص
return f"{name} ready after {secs}s"
async def sequential():
t0 = time.perf_counter()
a = await brew("espresso", 1) # نقف هنا ثانية كاملة
b = await brew("latte", 2) # ثم ثانيتين أخريين
return round(time.perf_counter() - t0, 2), [a, b]
print("sequential:", asyncio.run(sequential()))
# sequential: (3.0, ['espresso ready after 1s', 'latte ready after 2s'])ثلاث ثوانٍ. لاحظ أن الكود مكتوب بـasync بالكامل ومع ذلك لم يربح شيئًا: مجرد كتابة async لا تعطي تزامنًا، التزامن يأتي من تشغيل الأشياء معًا.
الخطوة 2: نفس العمل بـgather
async def concurrent():
t0 = time.perf_counter()
res = await asyncio.gather(brew("espresso", 1), brew("latte", 2))
return round(time.perf_counter() - t0, 2), res
print("concurrent:", asyncio.run(concurrent()))
# concurrent: (2.0, ['espresso ready after 1s', 'latte ready after 2s'])ثانيتان بدل ثلاث: الزمن صار أطول الانتظارين لا مجموعهما. ولو كان لدينا عشرة مشروبات أطولها ثانيتان، لبقي الزمن ثانيتين.
الخطوة 3: الكوروتين لا يعمل حتى تنتظره
c = brew("x", 0)
print("type:", type(c).__name__) # type: coroutine
c.close() # أغلقناه يدويًا حتى لا يطبع تحذيرًااستدعاء الدالة أنتج كائنًا فقط. لم يُنفَّذ سطر واحد من جسمها.
الخطوة 4: gather يحفظ ترتيب الوسائط لا ترتيب الانتهاء
async def finish_order():
order = []
async def job(n, d):
await asyncio.sleep(d)
order.append(n) # نسجّل من انتهى فعلًا ومتى
return n
got = await asyncio.gather(job("slow", 0.3), job("fast", 0.1))
return got, order
print(asyncio.run(finish_order()))
# (['slow', 'fast'], ['fast', 'slow'])النتيجة ['slow', 'fast'] بترتيب ما كتبته، بينما ترتيب الانتهاء الحقيقي كان ['fast', 'slow']. هذه ليست مشكلة، هذه ميزة: تستطيع مطابقة النتائج بمدخلاتها بالفهرس.
الخطوة 5: as_completed وسرّ الترتيب التصاعدي
import random
random.seed(7)
async def random_delay(max_delay=10):
d = random.uniform(0, max_delay) # 0 <= d <= max_delay
await asyncio.sleep(d)
return d
async def gather_delays(n, max_delay):
out = []
for done in asyncio.as_completed([random_delay(max_delay) for _ in range(n)]):
out.append(await done) # يصل كل واحد لحظة انتهائه
return out
res = asyncio.run(gather_delays(5, 3))
print([round(x, 3) for x in res]) # [0.217, 0.453, 0.971, 1.608, 1.953]
print(res == sorted(res)) # True بلا sort()وللمقارنة المباشرة، نفس الدالة بـgather:
async def wait_n_gather(n, max_delay):
return await asyncio.gather(*(random_delay(max_delay) for _ in range(n)))
res2 = asyncio.run(wait_n_gather(5, 3))
print([round(x, 3) for x in res2]) # [1.097, 0.174, 1.522, 0.112, 1.301]
print(res2 == sorted(res2)) # Falseas_completed لا «يرتّب» شيئًا. هو يسلّمك كل مهمة لحظة انتهائها، وبما أن مهمة «انتظر d ثانية» تنتهي بعد d ثانية بالضبط، فإن ترتيب الوصول هو ترتيب المدد تصاعديًا. لو كانت المهام أعمالًا مختلفة الطبيعة لما كانت النتيجة مرتبة إطلاقًا.
الخطوة 6: create_task يبدأ فورًا
async def demo():
t0 = time.perf_counter()
coro = random_delay(1) # لم يُجدول بعد
task = asyncio.create_task(random_delay(1)) # جُدول الآن وبدأ
await asyncio.sleep(0.4)
print(" after 0.4s -> task done?", task.done(), "| type:", type(task).__name__)
a = await task
b = await coro
return round(time.perf_counter() - t0, 2)
print("total:", asyncio.run(demo()), "s")
# after 0.4s -> task done? True | type: Task
# total: 0.95 sالمهمة تقدّمت وانتهت بينما كنا ننتظر شيئًا آخر، والكوروتين بقي ساكنًا حتى جاء دوره. انتبه: create_task يتطلّب حلقة تعمل، فلا تستدعه خارج asyncio.run.
الخطوة 7: الإلغاء
async def cancel_demo():
t = asyncio.create_task(asyncio.sleep(5))
await asyncio.sleep(0.1)
t.cancel()
try:
await t
except asyncio.CancelledError:
return "CancelledError raised, loop kept running"
print(asyncio.run(cancel_demo()))
# CancelledError raised, loop kept runningالإلغاء يصلك كاستثناء داخل المهمة، وهذا ما يجعل finally مكانًا موثوقًا لإغلاق اتصال أو ملف حتى لو أُلغيت المهمة في منتصفها.
الخطوة 8: نسيان await
async def oops():
random_delay(1) # لا await: الجسم لن يعمل أبدًا
return "body of random_delay never ran"
# المخرج: body of random_delay never ran | warning: RuntimeWarningلا خطأ ولا انهيار، فقط تحذير سهل التجاهل ونصف منطق مفقود. إذا رأيت coroutine ... was never awaited فابحث عن استدعاء ناقص await.
الخطوة 9: قياس متوسط الزمن لكل عملية
def time_runs(n, max_delay): # def عادية، لا async
t0 = time.perf_counter()
asyncio.run(gather_delays(n, max_delay)) # القياس يحيط بالتشغيل كاملًا
return (time.perf_counter() - t0) / nلماذا ينزل الناتج كلما كبر n؟ لأن البسط (الزمن الكلي) يقترب من أطول انتظار ويكاد لا يتغيّر، بينما المقام n يكبر. وهذا القسمة نفسها مقياس جميل لمقدار ما ربحته من التزامن.
الخطوة 10: استدعاء محجوب يجمّد كل شيء
async def good(): await asyncio.sleep(0.5) # يعيد الدور async def bad(): time.sleep(0.5) # يحتجز الخيط كله # await asyncio.gather(good(), good()) -> 0.5 s # await asyncio.gather(bad(), bad()) -> 1.0 s
نفس البنية ونفس gather، والفرق مضاعفة الزمن. القاعدة: داخل كوروتين، كل انتظار يجب أن يكون بـawait على نسخة غير متزامنة؛ وما لا يملك نسخة غير متزامنة يُنقل خارج الحلقة بـloop.run_in_executor.
أسطورة وحقيقة
| الأسطورة | الحقيقة |
|---|---|
| «async يجعل الكود أسرع.» | لا يجعل أي عملية مفردة أسرع بميلي ثانية واحدة. هو يمنع إهدار أوقات الانتظار فقط. كود async بلا gather له زمن الكود المتزامن نفسه، كما رأيت في الخطوة الأولى. |
| «async يشغّل الأشياء بالتوازي.» | خيط واحد، وكوروتين واحد ينفّذ في اللحظة. التوازي الحقيقي يحتاج multiprocessing. ما تحصل عليه هو تداخل فترات الانتظار. |
«await يعني انتظر هنا وأوقف البرنامج.» | يوقف هذا الكوروتين فقط ويعطي الدور لغيره. البرنامج يواصل العمل، وهذا جوهر الفكرة. |
«ما دمت كتبت async def فالدالة لا تحجب.» | الحجب يأتي من محتواها: time.sleep أو requests.get أو حلقة حسابية ثقيلة تجمّد الحلقة داخل أي دالة مهما كان تعريفها. |
«gather يُرجع النتائج بترتيب الانتهاء.» | بترتيب الوسائط دائمًا. من يريد ترتيب الانتهاء يستعمل as_completed. |
| «الكوروتينات تلغي الحاجة للخيوط تمامًا.» | تتفوّق عليها في الانتظار، وتعجز أمام الحساب الثقيل وأمام المكتبات المتزامنة التي لا نسخة async لها. الأنظمة الحقيقية تمزج الاثنين. |
أين يُستخدم هذا عمليًا؟
جلب عدة واجهات معًا
صفحة تحتاج بيانات من ثلاث خدمات: بالتتابع مجموع أزمنتها، وبـgather زمن أبطئها. هذا أكثر استعمال عملي للفكرة.
خوادم الويب الحديثة
FastAPI وStarlette وaiohttp مبنية على هذه الحلقة: خيط واحد يخدم آلاف الاتصالات المفتوحة لأن معظمها ينتظر.
قواعد البيانات (درس بايثون وقواعد البيانات)
برامج التشغيل غير المتزامنة تتيح تنفيذ استعلامات متعددة دون أن يقف التطبيق عند كل واحد.
استدعاء نماذج الذكاء الاصطناعي
كل استدعاء انتظار شبكة طويل نسبيًا؛ تشغيل عشرة استدعاءات بـgather يقلّص الزمن إلى زمن أبطئها.
زحف المواقع
مئات الصفحات بخيط واحد وذاكرة قليلة، مع Semaphore لتحديد عدد الطلبات المتزامنة حتى لا تُغرق الخادم.
المهلات الزمنية
asyncio.wait_for(coro, timeout=5) يلغي العملية تلقائيًا إذا تأخّرت، وهو أنظف بكثير من مؤقّتات يدوية.
جرّب بنفسك
ثلاث مهام كل واحدة تنتظر ثانية بـawait asyncio.sleep(1) وتُشغّل بـgather. كم يستغرق الكل تقريبًا؟
الانتظارات تتداخل في خيط واحد: الحلقة تبدأ الثلاثة ثم تنتظرها معًا، فينتهي الكل بعد ثانية تقريبًا.
بطاقات مراجعة سريعة
تحقّق من فهمك
1. حوّلت عشر دوال إلى async def ووضعت await قبل كل استدعاء في حلقة for، فلم يتغيّر الزمن. لماذا؟
لأن await داخل الحلقة يعني «انتظر هذه حتى تنتهي ثم ابدأ التالية»، وهذا تتابع لا تزامن. التزامن يبدأ حين تُسلّم العشرة معًا إلى gather أو as_completed، أو حين تحوّلها إلى مهام بـcreate_task قبل أن تنتظرها.
2. دالة تُرجع asyncio.Task: لماذا تُكتب بـdef عادية لا async def؟
لأن create_task لا ينتظر شيئًا: يسجّل الكوروتين في الحلقة ويعيد المهمة فورًا. لو جعلتها async لاضطُرّ المستدعي أن يكتب await ليحصل على المهمة، فتضيف طبقة انتظار بلا أي فائدة.
3. (اكتشف الخطأ) كوروتين يقرأ ملفًا بـopen().read() ثم يرسله بـawait session.post(...)، والخادم يتجمّد تحت الضغط. ما السبب؟
القراءة متزامنة وتحجب الخيط الوحيد. أثناءها لا تستطيع الحلقة خدمة أي طلب آخر. الحل إمّا مكتبة ملفات غير متزامنة، أو تنفيذ القراءة خارج الحلقة بـrun_in_executor. ووجود await في سطر لاحق لا يشفع للسطر المحجوب قبله.
4. لماذا ينتج as_completed قائمة مدد مرتبة تصاعديًا بلا sort()، ومتى تفشل هذه الحيلة؟
لأن كل مهمة مجرد انتظار لمدة معيّنة، فأقصرها ينتهي أولًا ويصل أولًا. تفشل الحيلة فورًا لو اختلفت طبيعة المهام (طلب شبكة مقابل قراءة قرص)، لأن ترتيب الانتهاء حينها لا يعكس أي قيمة تُرجعها.
5. (قرار) تريد جلب 1000 صفحة. هل تكتب gather على الألف دفعة واحدة؟
لا. ألف اتصال متزامن قد يستنزف المنافذ ويُغضب الخادم المستهدف وقد يُحظر عنوانك. استخدم asyncio.Semaphore لتحديد عدد متزامن معقول (20 مثلًا) وشغّل الألف خلاله، أو قسّمها دفعات. التزامن المفيد له سقف، وتجاوز السقف يعود بنتيجة عكسية.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
توثيق رسمي2
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.