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 של כל מה שמופיע בעמוד זה.