برمجة المتحكّمات بلغة مايكروبايثون · الفصل 19 — البرمجة غير المتزامنةתכנות אסינכרוני · תכנות מיקרו-בקרים בשפת MicroPython⌂📚🏠
الفصل 19

البرمجة غير المتزامنة

תכנות אסינכרוני – Asynchronous Programming
3 ساعات تعليمية

نريد من اللوحة أن تومض، وتراقب زرا، وتجيب على المتصفّح، وكل ذلك معا. لكنها تملك برنامجا واحدا يسير سطرا بعد سطر. في الفصل 4 حللنا مسألة صغيرة كهذه بعدّاد زمن لكل مهمة، وهو حل يتعقّد بسرعة. هنا نتعلم الأداة التي صُنعت لهذا: مهام تتنازل عن الدور حين تنتظر، فتبدو كأنها تعمل في وقت واحد.

في هذا الفصل:
  • نشرح لماذا تعطّل الأوامر الحاجبة كل ما حولها.
  • نكتب مهام بالكلمتين async وawait، ونشغّلها بالدوال asyncio.run وgather وcreate_task.
  • نحسب الزمن الكلي، ونتنبأ بترتيب الأحداث في المُخرَج.
  • نربط مهمتين بالصنف Event.
  • نبني نظاما فيه ثنائي باعث للضوء وزر وحسّاس وخادم.
  • نتجنّب المصيدتين: أمر حاجب داخل مهمة، ومهمة منسية.
  • نختار بين المقاطعة والمؤقّت وasyncio بحسب المسألة.

19.1 المشكلة: انتظار يوقف كل شيء – הבעיה: חסימה (Blocking)

أمر مثل time.sleep(1) هو أمر حاجب (פקודה חוסמת): ما دام ينتظر، لا يُنفَّذ أي سطر آخر. لنضغط الزر لمدة 0.3 s في برنامج يومض كل ثانية:

3V3board.POTboard.BTNGNDboard.LEDon the board
توصيلات الفصل: مقياس الجهد والزر. والثنائي الباعث للضوء موجود على اللوحة.
الزر مضغوط بين 1.3 s و1.6 s، والبرنامج لا يرى ذلك أبدا.
import time
from machine import Pin
import board

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
t0 = time.ticks_ms()

for i in range(4):
    led.value(1 - led.value())
    time.sleep(1)
    now = time.ticks_diff(time.ticks_ms(), t0) / 1000
    print("%.1f s  button %d" % (now, btn.value()))
ESP32 + Pico W · MicroPython 1.29 · simulated board
1.0 s  button 1
2.0 s  button 1
3.0 s  button 1
4.0 s  button 1

البرنامج يقرأ الزر عند كل ثانية كاملة فقط، والضغطة وقعت بين قراءتين. لاحظوا أن معظم زمن اللوحة يضيع هنا في انتظار لا يحتاج إلى أي حساب. والفكرة كلها في جملة واحدة: من ينتظر يترك الدور لغيره.

19.2 المهمة: async وawait – משימה: async ו-await (Coroutines)

البرمجة غير المتزامنة (תכנות אסינכרוני): أسلوب نكتب فيه البرنامج مهامّ (משימות) تعمل بالتناوب على معالج واحد. كل مهمة تعمل حتى تصل إلى await، وهناك تُرجع الدور.
المجدول (מתזמן): جزء من المكتبة asyncio يحفظ قائمة المهام ووقت استيقاظ كل منها، ويعطي الدور للمهمة الجاهزة.
الأمرعمله
async def f():دالة غير متزامنة. استدعاؤها لا يشغّلها، بل يعطينا كائنا ننتظره.
await xانتظر x واترك الدور في هذه الأثناء.
await asyncio.sleep(s)انتظار لا يحجب: هذه المهمة تنام، وغيرها يعمل.
asyncio.run(main())يشغّل المجدول حتى تنتهي main.
asyncio.gather(a, b)يشغّل المهام معا، وينتهي حين تنتهي كلها، ويعيد قائمة بنتائجها.
مهمتان بفاصلين مختلفين. العدد في بداية السطر هو الزمن بالثواني.
import asyncio
import time

t0 = time.ticks_ms()


def stamp():
    return time.ticks_diff(time.ticks_ms(), t0) / 1000


async def work(name, n, gap):
    for i in range(n):
        print("%.1f  %s %d" % (stamp(), name, i))
        await asyncio.sleep(gap)


async def main():
    await asyncio.gather(work("A", 3, 1.0), work("B", 3, 0.6))
    print("%.1f  done" % stamp())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
0.0  A 0
0.0  B 0
0.6  B 1
1.0  A 1
1.2  B 2
2.0  A 2
3.0  done
AB0.00.61.01.21.82.03.0t (s)
المستطيلات هي اللحظات التي تعمل فيها كل مهمة، والخط المتقطّع هو نومها. والمعالج فارغ معظم الوقت.

حين تصل A إلى await asyncio.sleep يبدأ دور B، وعند 0.6 s تستيقظ B ولا تحتاج إلى أي شيء من A. لا يوجد هنا تنفيذ متزامن حقيقي: سطر واحد فقط يعمل في كل لحظة.

الزمن الكلي
T = max(TA, TB)
الرمزالمعنىوحدة القياس
TAمجموع زمن انتظار المهمة الأولى s
TBمجموع زمن انتظار المهمة الثانية s
مثال 19.1
في البرنامج السابق: 3 دورات بفاصل 1 s و3 دورات بفاصل 0.6 s. كم الزمن لو نفّذنا المهمتين واحدة بعد الأخرى؟ وكم الزمن مع gather؟
الحل:
TA = 3 · 1
TA = 3 s
TB = 3 · 0.6
TB = 1.8 s
Tseq = TA + TB
Tseq = 4.8 s
T = max(TA, TB)
T = 3 s

وهذا هو السطر الأخير في مُخرَج البرنامج. أزمنة الحساب بين الانتظارين صغيرة، ولذلك نهملها.

Tseq = 4.8 s, T = 3 s
سؤال 19.1: نغيّر فاصل B إلى 1.5 s. متى تنتهي المهمتان؟

19.3 إنشاء مهمة: create_task – יצירת משימה (create_task)

الدالة gather تنتظر الجميع. أحيانا نريد أن نطلق مهمة ونكمل عملنا، ثم ننتظرها لاحقا أو لا ننتظرها أبدا. هذا عمل create_task. وهنا أهم درس عن الترتيب:

اقرأوا ترتيب الأسطر: A لا تبدأ عند إنشائها، بل عند أول await في main.
import asyncio


async def job(name):
    print(name, "starts")
    await asyncio.sleep(1)
    print(name, "ends")


async def main():
    t = asyncio.create_task(job("A"))
    print("main: task created")
    await asyncio.sleep(0.1)
    print("main: back after 0.1 s")
    await t
    print("main: A finished")


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
main: task created
A starts
main: back after 0.1 s
A ends
main: A finished
تحذير: إذا استيقظت مهمتان في اللحظة نفسها بالضبط، فلا نبني البرنامج على الترتيب بينهما. برامج هذا الفصل تعمل على مجدول صغير في المحاكاة، بالأسماء نفسها وبساعة افتراضية. أما على اللوحة فالمجدول مختلف، وزمن النوم هو أقل زمن وليس زمنا دقيقا.
سؤال 19.2: لو حذفنا السطر await asyncio.sleep(0.1) من البرنامج، فما السطر الثاني الذي يطبعه البرنامج؟ ولماذا؟

19.4 مهمة تنبّه مهمة: Event – אירוע (Event)

مهمة تراقب الزر، ومهمة أخرى تنفّذ العمل. كيف تخبرها دون أن تسأل باستمرار؟ بالحدث (אירוע): كائن فيه علامة واحدة، set() يرفعها، وwait() ينتظرها، وclear() يخفضها.

ثلاث ضغطات. قراءة الزر كل 20 ms تكفي لضغطة بشرية، وهي أطول من ارتداد التلامس المعتاد، فلا نعدّ الضغطة مرتين.
import asyncio
from machine import Pin
import board

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
pressed = asyncio.Event()


async def watch_button():
    last = 1
    while True:
        now = btn.value()
        if now == 0 and last == 1:
            pressed.set()
        last = now
        await asyncio.sleep_ms(20)


async def worker():
    while True:
        await pressed.wait()
        pressed.clear()
        led.value(1 - led.value())
        print("press: led", led.value())


async def main():
    await asyncio.gather(watch_button(), worker())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
press: led 1
press: led 0
press: led 1
...
خطأ شائع: إذا نسينا pressed.clear() تبقى العلامة مرفوعة، فلا ينتظر wait() بعد ذلك، ويدور العامل دون توقف.
ملاحظة: الزر هنا يُقرأ داخل مهمة. وإذا أردنا أن نوقظ مهمة من مقاطعة، فلا نستعمل Event: التوثيق يقول إن set() غير آمن من داخل المقاطعة، ويوجّهنا إلى الصنف asyncio.ThreadSafeFlag. والمحاكاة في هذا الكتاب لا توفّره.
سؤال 19.3: ضغطتان متتاليتان بينهما زمن أقل من دورة عمل المهمة worker، بينما كانت مشغولة. كم مرة يُنفَّذ العمل؟

19.5 ثنائي وزر وحسّاس وخادم – נורית, כפתור ושרת במקביל

ثلاث مهام، لكل واحدة إيقاعها، وعدّادان يشتركان فيهما. لا تعرف أي مهمة شيئا عن إيقاع الأخرى، وهذا هو ما كان صعبا بعدّادات الفصل 4:

تقرير كل ثانيتين، بينما يومض الثنائي الباعث للضوء كل 300 ms وتُحسب كل الضغطات، حتى القصيرة منها.
import asyncio
from machine import Pin
import board
from convert import to_percent

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
pot = board.analog_in(board.POT)
blinks = 0
presses = 0


async def blink():
    global blinks
    while True:
        led.value(1 - led.value())
        blinks += 1
        await asyncio.sleep_ms(300)


async def button():
    global presses
    last = 1
    while True:
        now = btn.value()
        if now == 0 and last == 1:
            presses += 1
        last = now
        await asyncio.sleep_ms(20)


async def report():
    while True:
        await asyncio.sleep(2)
        level = to_percent(pot.read_u16())
        print("blinks", blinks, "presses", presses, "pot", level)


async def main():
    await asyncio.gather(blink(), button(), report())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
blinks 7 presses 2 pot 50
blinks 14 presses 3 pot 50
...

والخادم مهمة أخرى. المكتبة تعطينا asyncio.start_server، وهي تستدعي دالة لكل متصفّح يتصل، وكل دالة منها مهمة مستقلة. البرنامج التالي لم يُشغَّل عند بناء الكتاب، لأن مُحاكاتنا لا توفّر هذه الدالة، وقد فحصناه بالترجمة فقط:

خادم غير متزامن إلى جانب الوميض. طريقة الكتابة حسب توثيق asyncio.
import asyncio
from machine import Pin
import board
import wifi

led = Pin(board.LED, Pin.OUT)
blinks = 0


async def blink():
    global blinks
    while True:
        led.value(1 - led.value())
        blinks += 1
        await asyncio.sleep_ms(300)


async def handle(reader, writer):
    await reader.readline()
    while await reader.readline() not in (b"\r\n", b""):
        pass
    writer.write(b"HTTP/1.1 200 OK\r\n\r\n")
    writer.write(b"blinks %d\n" % blinks)
    await writer.drain()
    writer.close()
    await writer.wait_closed()


async def main():
    wifi.connect()
    await asyncio.start_server(handle, "0.0.0.0", 80)
    await blink()


asyncio.run(main())
سؤال 19.4: في تقرير البرنامج async_system.py: كم وميضا نتوقع عند 2 s؟ قارنوا بالمُخرَج.

19.6 مصيدتان – מלכודות (Pitfalls)

أمر حاجب داخل مهمة:

المهمة slow تستعمل هنا time.sleep بدلا من await asyncio.sleep:

النبضات تأتي كل 0.5 s، ثم يقفز الزمن من 1.0 إلى 3.2.
import asyncio
import time

t0 = time.ticks_ms()


def stamp():
    return time.ticks_diff(time.ticks_ms(), t0) / 1000


async def beat():
    for i in range(6):
        print("%.1f  beat" % stamp())
        await asyncio.sleep(0.5)


async def slow():
    await asyncio.sleep(1.2)
    print("%.1f  slow starts" % stamp())
    time.sleep(2)
    print("%.1f  slow ends" % stamp())


async def main():
    await asyncio.gather(beat(), slow())


asyncio.run(main())
ESP32 + Pico W · MicroPython 1.29 · simulated board
0.0  beat
0.5  beat
1.0  beat
1.2  slow starts
3.2  slow ends
3.2  beat
3.7  beat
4.2  beat

المجدول لا يقطع أي مهمة: هو ينتظر أن تعيد الدور. فإذا استبدلنا السطر بـawait asyncio.sleep(2) عادت النبضات إلى إيقاعها:

After the fix
0.0  beat
0.5  beat
1.0  beat
1.2  slow starts
1.5  beat
2.0  beat
2.5  beat
3.2  slow ends
خطأ شائع: كل أمر طويل يحجب بالطريقة نفسها: requests.get من الفصلين 15 و18، وaccept()، وحلقة while True بلا await. وإذا كانت الحلقة طويلة ولا بد، نضع فيها await asyncio.sleep(0) حتى تترك الدور.

مهمة منسية:

A لا تعمل إطلاقا، وB تبدأ ولا تنتهي. ولا توجد أي رسالة خطأ.
import asyncio


async def job(name):
    print(name, "starts")
    await asyncio.sleep(1)
    print(name, "ends")


async def main():
    a = job("A")
    asyncio.create_task(job("B"))
    await asyncio.sleep(0.5)
    print("main ends")


asyncio.run(main())
print("program ends")
ESP32 + Pico W · MicroPython 1.29 · simulated board
B starts
main ends
program ends
سؤال 19.5: المهمة blink توقفت عن الوميض بعد أن أضفنا إليها سطر قراءة من UART بالأمر uart.readline() الذي ينتظر بيانات. ما السبب المحتمل؟

19.7 متى نستعمل كل أداة؟ – מתי להשתמש בכל כלי

الأداةمتى تعملتناسبحدودها
المقاطعة (الفصل 5)فور الحدث، وتقطع البرنامجحدث قصير لا يجوز أن يضيع: نبضة، حافةمعالج قصير جدا، ولا انتظار داخله
المؤقّت (الفصل 4)بدورة ثابتة، وتقطع البرنامجعمل قصير منتظم: قراءة أو وميضمعالج قصير، وعدد المؤقّتات محدود
ticks_diff في حلقةحين تصل الحلقة إليهامهمة أو اثنتان بسيطتانتتعقّد مع كل مهمة جديدة
asyncioحين تعيد المهمة الدورمهام كثيرة بطيئة تنتظر: شبكة، زر، شاشةأمر حاجب واحد يعطّل الجميع

والقاعدة العملية: زمن الاستجابة في asyncio لا يقل عن أطول مقطع بين await وawait. فإذا كان الحدث أقصر من ذلك فهو للمقاطعة، وتستطيع المقاطعة أن تسجّله في متغيّر تقرؤه مهمة لاحقا. وكثيرا ما نجمع الأدوات في برنامج واحد.

_thread: توجد في مايكروبايثون أيضا الوحدة _thread، ولا نستعملها في هذا الكتاب لثلاثة أسباب: توثيقها يصفها بأنها تجريبية جدا وأن واجهتها لم تستقر بعد؛ والخيوط يمكن أن تتبدّل في أي سطر، فتحتاج البيانات المشتركة إلى أقفال وتظهر أخطاء يصعب تكرارها؛ ومُحاكاتنا لا تشغّلها، بينما كل برنامج في هذا الكتاب يُفحص بالتشغيل.

أهمّ أفكار الفصل:

ملخّص قوانين الفصل:

القانونالصيغة
الزمن الكلي بالتتابعTseq = TA + TB
الزمن الكلي مع gatherT = max(TA, TB)

أوامر الفصل:

الأمرعمله
asyncio.run(main())يشغّل المجدول حتى تنتهي main.
await asyncio.sleep(s)نوم لا يحجب؛ ومعه sleep_ms.
asyncio.gather(a, b)ينتظر الجميع، ويعيد قائمة النتائج.
asyncio.create_task(c)يطلق مهمة، ويعيد كائنا ننتظره أو نلغيه.
t.cancel()يلغي المهمة.
asyncio.Event()علامة، ولها set وwait وclear.
asyncio.start_server(f, host, port)خادم يستدعي f(reader, writer) لكل اتصال.

قاموس المصطلحات – מילון מונחים:

بالعربيةبالعبريةEnglish
البرمجة غير المتزامنةתכנות אסינכרוניAsynchronous Programming
مهمةמשימהTask
المجدولמתזמןScheduler
أمر حاجبפקודה חוסמתBlocking Call
حدثאירועEvent
الإجابات النهائية لأسئلة الفصل
19.1 T = max(3, 4.5) = 4.5 s.
19.2 main: back after 0.1 s وليس A starts، لأن المهمة A لا تأخذ الدور قبل أول await في main، وهو الآن await t.
19.3 مرة واحدة: Event علامة وليس عدّادا.
19.4 الوميض عند 0, 0.3, ..., 1.8، أي 7 مرات.
19.5 أمر يحجب داخل مهمة، فالمجدول لا يستعيد الدور حتى ينتهي الأمر.

أسئلة للتمرين:

المستوى الأول — أساسي
1.
ما الفرق بين time.sleep(1) وawait asyncio.sleep(1)؟
الإجابات: الأول يوقف كل البرنامج، والثاني يوقف المهمة الحالية ويترك الدور لغيرها.
2.
ثلاث مهام مع gather أزمنتها 1 s و4 s و2.5 s. ما الزمن الكلي؟ وما هو بالتتابع؟
الإجابات: T = 4 s، وبالتتابع 7.5 s.
3.
ماذا يطبع السطر asyncio.run(main()) إذا كانت main تستدعي job() دون await، وليس فيها شيء آخر؟
الإجابات: لا يطبع شيئا: الاستدعاء ينشئ كائنا فقط ولا يشغّل الدالة.
المستوى الثاني — تطبيق
4.
اكتبوا برنامجا يومض بثنائيين باعثين للضوء: LED كل 500 ms وLED_EXT كل 130 ms، بدالة واحدة.
الإجابات:
import asyncio
from machine import Pin
import board


async def blink(pin, ms):
    led = Pin(pin, Pin.OUT)
    while True:
        led.value(1 - led.value())
        await asyncio.sleep_ms(ms)


async def main():
    await asyncio.gather(blink(board.LED, 500),
                         blink(board.LED_EXT, 130))


asyncio.run(main())
5.
في البرنامج async_first.py نجعل فاصل A يساوي 0.5 s. رتّبوا أسطر المُخرَج قبل التشغيل.
الإجابات: 0.0 A 0
0.0 B 0
0.5 A 1
0.6 B 1
1.0 A 2
1.2 B 2
1.8 done
6.
في البرنامج async_system.py نحذف await asyncio.sleep_ms(20) من المهمة button. ماذا يحدث؟
الإجابات: لا تترك المهمة الدور أبدا: يتوقف الوميض والتقرير، وتبقى قراءة الزر وحدها.
المستوى الثالث — تحدٍّ
7.
اكتبوا برنامجا يغيّر فيه الزر سرعة الوميض بين 500 ms و100 ms، بمتغيّر مشترك. لماذا لا نحتاج إلى قفل؟
الإجابات:
import asyncio
from machine import Pin
import board

led = Pin(board.LED, Pin.OUT)
btn = Pin(board.BTN, Pin.IN, Pin.PULL_UP)
period = 500


async def blink():
    while True:
        led.value(1 - led.value())
        await asyncio.sleep_ms(period)


async def button():
    global period
    last = 1
    while True:
        now = btn.value()
        if now == 0 and last == 1:
            period = 100 if period == 500 else 500
        last = now
        await asyncio.sleep_ms(20)


async def main():
    await asyncio.gather(blink(), button())


asyncio.run(main())
لأن الدور لا يتبدّل إلا عند await، فلا تُكتب القيمة وتُقرأ في اللحظة نفسها.
8.
نريد ضمّ بوت الفصل 18 إلى وميض بفاصل 300 ms. ماذا يحدث للوميض في كل طلب؟ وماذا نفعل؟
الإجابات: الوميض يتوقف طوال الطلب، لأن requests حاجبة. نقبل بذلك ونطيل زمن الاستطلاع، أو ننقل الوميض إلى مؤقّت ونفحص النتيجة على اللوحة، لأن زمن الطلب الحقيقي غير معروف من المحاكاة.
لوحة القوانين: كل قوانين هذا الفصل مجموعة مع رسومها وجداول رموزها في الفصل 25 — لوحة القوانين الشاملة. (נוסחאון הספר)