9.13. שקעי TCP

שקעי TCP מגיעים בשתי צורות שנראות שונות אך חולקות את אותו טיפוס בסיסי: שקעי client שמתחברים אל שרת מרוחק באמצעות connect(), ושקעי server שמשתמשים ב-bind(), ב-listen() וב-accept() כדי לקבל חיבורים נכנסים. שני התפקידים משתמשים באותה מחלקת socket שהוצגה ב-אובייקטי Socket; רק המתודות שנקראות עליהם שונות.

9.13.1. client של TCP

ה-client הפשוט ביותר פותח חיבור, שולח בקשה, קורא את התשובה וסוגר אותו:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.1.20", 9000))

s.send(b"hello\n")
reply = s.recv(1024)
print("reply:", reply)

s.close()

connect() מבצע את לחיצת היד התלת-שלבית המתוארת ב-TCP – זרם אמין של בתים וחוזר כשהחיבור פתוח. send() כותב בייטים אל החיבור; recv() קורא ממנו עד מספר נתון של בייטים. ברגע שהאפליקציה סיימה, close() סוגר את החיבור.

אותו סקריפט עטוף בניב משפט ה-with מתוך אובייקטי Socket, כך שהשקע נסגר גם אם משהו מעלה חריגה:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("192.168.1.20", 9000))
    s.send(b"hello\n")
    print(s.recv(1024))

9.13.1.1. קריאה עד הסוף

קריאה בודדת של recv() מחזירה עד המספר המבוקש של בייטים – היא יכולה להחזיר פחות, מכיוון ש-TCP הוא זרם ולא רצף של הודעות. האפליקציה חייבת להמשיך לקרוא עד שיש לה את התשובה המלאה:

chunks = []
while True:
    chunk = s.recv(1024)
    if not chunk:                  # empty bytes -> other side closed
        break
    chunks.append(chunk)
reply = b"".join(chunks)

הלולאה מסתיימת כאשר recv() מחזירה bytes ריק. זה קורה כאשר הצד השני סגר באופן נקי את חצי החיבור שלו; האפליקציה קוראת את ”סוף הזרם“ כזהה ל“סוף ההודעה“ בסגנון פרוטוקול זה.

9.13.1.2. שליחה עד הסוף

הסתייגות הפוכה חלה על send(): היא עשויה לשלוח פחות בייטים מהמבוקש, ומחזירה את מספר הבייטים שנכתבו בפועל. עבור מטענים גדולים, נסה שוב את היתרה שלא נשלחה:

payload = some_big_bytes
while payload:
    n = s.send(payload)
    payload = payload[n:]

sendall() מבצעת את הלולאה באופן פנימי, כך שרוב הקוד יכול פשוט לקרוא לה ולהימנע מהניסיון החוזר הידני:

s.sendall(some_big_bytes)

9.13.2. שרת TCP

צד השרת מורכב מארבעה שלבים: תפיסת פורט, החלפת השקע למצב האזנה, קבלת חיבורים אחד-אחד, ושיחה על כל שקע שהתקבל. שרת echo מינימלי:

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("0.0.0.0", 9000))
server.listen(1)
print("listening on port 9000")

while True:
    conn, addr = server.accept()
    print("connection from", addr)

    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.send(data)            # echo back

    conn.close()

שלב אחר שלב:

  • bind() תופס מארח ופורט על המצלמה. "0.0.0.0" מקבל בכל ממשק; החלפתו בכתובת IP מסוימת מגבילה את המאזין לאותו ממשק.

  • listen() מחליף את השקע משקע רגיל לשקע מאזין. הארגומנט הוא ה-backlog – כמה חיבורים ממתינים MicroPython תכניס לתור בזמן שהאפליקציה עסוקה. בחר מספר קטן; 1 מתאים לרוב המקרים.

  • accept() חוסם עד שלקוח מתחבר, ואז מחזיר (conn, addr): שקע חדש המייצג את החיבור הבודד הזה, ואת כתובת הלקוח. השקע המאזין עצמו נשאר פתוח כדי לקבל חיבורים נוספים.

  • כל הבייטים של השיחה זורמים דרך conn, השקע החדש. קריאות וכתיבות משתמשות באותן קריאות recv() / send() כמו בצד הלקוח.

  • כאשר הלקוח סוגר, recv() מחזירה b""; הלולאה הפנימית מסתיימת והשרת סוגר את קצהו באמצעות close().

ה-while True החיצוני קופץ בחזרה אל accept() כדי להמתין ללקוח הבא. השרת מטפל בלקוח אחד בכל פעם בצורה זו; הרצת מספר לקוחות במקביל דורשת או threads או asyncio. האחרון הוא נושא העמוד הבא.

9.13.3. מלכודות נפוצות

  • התייחסות אל recv() כמכוון להודעות. היא אינה כזו. שתי קריאות send(b"hi") עשויות להגיע כ-recv(4) אחד של b"hihi", או כשתי recv(2). האפליקציה צריכה להוסיף מסגור אם גבולות ההודעה חשובים – תו שורה חדשה, תחילית אורך, או כל דבר אחר.

  • שכחה לנסות שוב בשליחות קצרות. השתמש ב-sendall() עבור כל דבר מעבר לכמה מאות בייטים.

  • שכחה לסגור את השקע שהתקבל. כל conn הוא שקע נפרד; סגירת השקע המאזין אינה סוגרת את אלו שהתקבלו. בלוקי with על שניהם מקשים מאוד לטעות בכך:

    while True:
        with server.accept()[0] as conn:
            # ... talk on conn ...
    
  • קישור מחדש לפורט שעדיין במצב TIME_WAIT. כאשר שרת מופעל מחדש בתוך כמה שניות מסגירתו, bind() עלול להיכשל עם ”address in use“ מכיוון ש-MicroPython עדיין מחזיק את הפורט עבור החיבור הקודם. server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) לפני bind() מנקה זאת.

9.13.4. מה הלאה

חסימה על accept() משמעה שהשרת יכול לשרת רק לקוח אחד בכל פעם. חסימה על recv() משמעה שלקוח איטי בודד תוקע את כל הלולאה. התשובה הסטנדרטית במצלמה היא asyncio – הרץ כל חיבור כמשימה משלו, ותן ללולאת האירועים לנתב ביניהם. העמוד הבא מכסה את גרסאות ה-asyncio של כל מה שמופיע בעמוד זה.