ทำความเข้าใจการสร้างเครือข่ายระดับต่ำของ asyncio ใน Python คู่มือนี้ครอบคลุม Transports และ Protocols พร้อมตัวอย่างการสร้างแอปพลิเคชันเครือข่ายประสิทธิภาพสูงแบบกำหนดเอง
ถอดรหัส Asyncio Transport ของ Python: เจาะลึกการสร้างเครือข่ายระดับต่ำ
ในโลกของ Python ยุคใหม่ asyncio
ได้กลายเป็นรากฐานสำคัญของการเขียนโปรแกรมเครือข่ายประสิทธิภาพสูง นักพัฒนาส่วนใหญ่มักเริ่มต้นด้วย High-level API ที่สวยงาม โดยใช้ async
และ await
ร่วมกับไลบรารีอย่าง aiohttp
หรือ FastAPI
เพื่อสร้างแอปพลิเคชันที่ตอบสนองได้อย่างง่ายดาย วัตถุ StreamReader
และ StreamWriter
ที่มาจากฟังก์ชันอย่าง asyncio.open_connection()
นำเสนอวิธีที่ง่ายและเป็นลำดับในการจัดการ I/O เครือข่าย แต่จะเกิดอะไรขึ้นเมื่อ abstraction ไม่เพียงพอ? จะเกิดอะไรขึ้นถ้าคุณต้องการใช้โปรโตคอลเครือข่ายที่ซับซ้อน มีสถานะ หรือไม่เป็นไปตามมาตรฐาน? จะเกิดอะไรขึ้นถ้าคุณต้องการดึงประสิทธิภาพสูงสุดโดยการควบคุมการเชื่อมต่อพื้นฐานโดยตรง? นี่คือจุดที่รากฐานที่แท้จริงของความสามารถด้านเครือข่ายของ asyncio อยู่: นั่นคือ Low-level Transport และ Protocol API แม้ว่าในตอนแรกอาจดูน่ากลัว แต่การทำความเข้าใจคู่หูที่ทรงพลังนี้จะปลดล็อกระดับการควบคุมและความยืดหยุ่นใหม่ ช่วยให้คุณสามารถสร้างแอปพลิเคชันเครือข่ายใดๆ ที่จินตนาการได้ คู่มือที่ครอบคลุมนี้จะถอดรหัสเลเยอร์ abstraction สำรวจความสัมพันธ์แบบพึ่งพาอาศัยกันระหว่าง Transports และ Protocols และนำเสนอตัวอย่างที่เป็นประโยชน์เพื่อเสริมสร้างความสามารถของคุณในการเชี่ยวชาญการสร้างเครือข่ายแบบอะซิงโครนัสระดับต่ำใน Python
สองด้านของการสร้างเครือข่ายด้วย Asyncio: ระดับสูง vs. ระดับต่ำ
ก่อนที่เราจะเจาะลึกไปใน Low-level APIs สิ่งสำคัญคือต้องเข้าใจตำแหน่งของมันภายในระบบนิเวศของ asyncio Asyncio ได้จัดเตรียมเลเยอร์ที่แตกต่างกันสองเลเยอร์สำหรับการสื่อสารเครือข่ายอย่างชาญฉลาด โดยแต่ละเลเยอร์ถูกปรับแต่งให้เหมาะกับกรณีการใช้งานที่แตกต่างกัน
High-Level API: Streams
High-level API ซึ่งมักจะเรียกกันว่า "Streams" คือสิ่งที่นักพัฒนาส่วนใหญ่จะเจอเป็นอันดับแรก เมื่อคุณใช้ asyncio.open_connection()
หรือ asyncio.start_server()
คุณจะได้รับวัตถุ StreamReader
และ StreamWriter
API นี้ออกแบบมาเพื่อความเรียบง่ายและใช้งานง่าย
- รูปแบบเชิงคำสั่ง (Imperative Style): ช่วยให้คุณสามารถเขียนโค้ดที่ดูเป็นลำดับ คุณ
await reader.read(100)
เพื่อรับข้อมูล 100 ไบต์ จากนั้นwriter.write(data)
เพื่อส่งการตอบกลับ รูปแบบasync/await
นี้เป็นธรรมชาติและเข้าใจง่าย - ตัวช่วยอำนวยความสะดวก (Convenient Helpers): มีเมธอดเช่น
readuntil(separator)
และreadexactly(n)
ที่จัดการงานการจัดเฟรมข้อมูลทั่วไป ช่วยให้คุณไม่ต้องจัดการบัฟเฟอร์ด้วยตนเอง - กรณีการใช้งานที่เหมาะสม (Ideal Use Cases): เหมาะสำหรับโปรโตคอลแบบคำขอ-ตอบกลับที่เรียบง่าย (เช่น ไคลเอนต์ HTTP พื้นฐาน), โปรโตคอลแบบบรรทัด (เช่น Redis หรือ SMTP) หรือสถานการณ์ใดๆ ที่การสื่อสารเป็นไปตามการไหลเชิงเส้นที่คาดการณ์ได้
อย่างไรก็ตาม ความเรียบง่ายนี้ก็มาพร้อมกับการแลกเปลี่ยน แนวทางแบบสตรีมอาจมีประสิทธิภาพน้อยลงสำหรับโปรโตคอลที่ขับเคลื่อนด้วยเหตุการณ์และมีการทำงานพร้อมกันสูง ซึ่งข้อความที่ไม่พึงประสงค์สามารถมาถึงได้ตลอดเวลา โมเดล await
แบบลำดับอาจทำให้การจัดการการอ่านและการเขียนพร้อมกัน หรือการจัดการสถานะการเชื่อมต่อที่ซับซ้อนเป็นเรื่องยาก
Low-Level API: Transports และ Protocols
นี่คือเลเยอร์พื้นฐานที่ High-level Streams API สร้างขึ้นมา Low-level API ใช้รูปแบบการออกแบบที่อิงตามส่วนประกอบที่แตกต่างกันสองส่วน: Transports และ Protocols
- รูปแบบที่ขับเคลื่อนด้วยเหตุการณ์ (Event-Driven Style): แทนที่คุณจะเรียกใช้ฟังก์ชันเพื่อรับข้อมูล asyncio จะเรียกใช้เมธอดบนวัตถุของคุณเมื่อเกิดเหตุการณ์ขึ้น (เช่น มีการเชื่อมต่อ, ได้รับข้อมูล) นี่คือแนวทางที่ใช้ callback
- การแยกส่วนความรับผิดชอบ (Separation of Concerns): มันแยก "อะไร" ออกจาก "อย่างไร" อย่างชัดเจน Protocol กำหนด ว่าจะทำอะไร กับข้อมูล (ตรรกะแอปพลิเคชันของคุณ) ในขณะที่ Transport จัดการ ว่าจะส่งและรับข้อมูลอย่างไร ผ่านเครือข่าย (กลไก I/O)
- การควบคุมสูงสุด (Maximum Control): API นี้ให้การควบคุมที่ละเอียดอ่อนเหนือการบัฟเฟอร์, การควบคุมการไหล (backpressure) และวงจรชีวิตของการเชื่อมต่อ
- กรณีการใช้งานที่เหมาะสม (Ideal Use Cases): จำเป็นสำหรับการนำโปรโตคอลไบนารีหรือข้อความแบบกำหนดเองไปใช้, การสร้างเซิร์ฟเวอร์ประสิทธิภาพสูงที่จัดการการเชื่อมต่อถาวรหลายพันรายการ หรือการพัฒนาเฟรมเวิร์กและไลบรารีเครือข่าย
ลองนึกภาพแบบนี้: Streams API เหมือนกับการสั่งบริการชุดทำอาหาร คุณจะได้รับส่วนผสมที่แบ่งไว้แล้วและสูตรอาหารง่ายๆ ให้ปฏิบัติตาม ส่วน Transport และ Protocol API นั้นเหมือนกับการเป็นเชฟในครัวมืออาชีพที่มีวัตถุดิบดิบและควบคุมทุกขั้นตอนของกระบวนการได้อย่างเต็มที่ ทั้งสองวิธีสามารถสร้างสรรค์อาหารมื้ออร่อยได้ แต่แบบหลังให้ความคิดสร้างสรรค์และการควบคุมที่ไร้ขีดจำกัด
ส่วนประกอบหลัก: เจาะลึก Transports และ Protocols
พลังของ Low-level API มาจากการทำงานร่วมกันอย่างลงตัวระหว่าง Protocol และ Transport ทั้งสองเป็นส่วนที่แตกต่างแต่แยกจากกันไม่ได้ในแอปพลิเคชันเครือข่าย asyncio ระดับต่ำใดๆ
The Protocol: สมองของแอปพลิเคชันของคุณ
Protocol คือคลาสที่คุณเขียนขึ้นมาเอง มันสืบทอดมาจาก asyncio.Protocol
(หรือรูปแบบอื่นๆ) และประกอบด้วยสถานะและตรรกะสำหรับการจัดการการเชื่อมต่อเครือข่ายเดียว คุณไม่ได้สร้างอินสแตนซ์คลาสนี้ด้วยตัวเอง คุณจัดเตรียมมันให้กับ asyncio (เช่น ไปยัง loop.create_server
) และ asyncio จะสร้างอินสแตนซ์ใหม่ของโปรโตคอลของคุณสำหรับการเชื่อมต่อไคลเอนต์ใหม่แต่ละรายการ
คลาส protocol ของคุณถูกกำหนดโดยชุดของเมธอด event handler ที่ event loop เรียกใช้ ณ จุดต่างๆ ในวงจรชีวิตของการเชื่อมต่อ ที่สำคัญที่สุดคือ:
connection_made(self, transport)
ถูกเรียกใช้เพียงครั้งเดียวเมื่อมีการสร้างการเชื่อมต่อใหม่สำเร็จ นี่คือจุดเริ่มต้นของคุณ เป็นจุดที่คุณได้รับวัตถุ transport
ซึ่งแสดงถึงการเชื่อมต่อ คุณควรบันทึกการอ้างอิงถึงมันเสมอ โดยปกติคือ self.transport
เป็นสถานที่ที่เหมาะสำหรับการดำเนินการเริ่มต้นต่อการเชื่อมต่อใดๆ เช่น การตั้งค่าบัฟเฟอร์หรือการบันทึกที่อยู่ของอีกฝ่าย
data_received(self, data)
หัวใจสำคัญของ protocol ของคุณ เมธอดนี้จะถูกเรียกเมื่อใดก็ตามที่มีข้อมูลใหม่ได้รับจากปลายอีกด้านหนึ่งของการเชื่อมต่อ อาร์กิวเมนต์ data
คือวัตถุ bytes
สิ่งสำคัญคือต้องจำไว้ว่า TCP เป็นโปรโตคอลแบบสตรีม ไม่ใช่โปรโตคอลแบบข้อความ ข้อความเชิงตรรกะเดียวจากแอปพลิเคชันของคุณอาจถูกแบ่งออกเป็นการเรียก data_received
หลายครั้ง หรือข้อความขนาดเล็กหลายข้อความอาจถูกรวมเข้าในการเรียกครั้งเดียว โค้ดของคุณต้องจัดการการบัฟเฟอร์และการแยกวิเคราะห์นี้
connection_lost(self, exc)
ถูกเรียกเมื่อการเชื่อมต่อถูกปิด สิ่งนี้อาจเกิดขึ้นได้จากหลายสาเหตุ หากการเชื่อมต่อถูกปิดอย่างสมบูรณ์ (เช่น อีกฝ่ายปิดการเชื่อมต่อ หรือคุณเรียกใช้ transport.close()
) exc
จะเป็น None
หากการเชื่อมต่อถูกปิดเนื่องจากข้อผิดพลาด (เช่น ความล้มเหลวของเครือข่าย, การรีเซ็ต) exc
จะเป็นวัตถุข้อยกเว้นที่ให้รายละเอียดข้อผิดพลาด นี่คือโอกาสของคุณในการดำเนินการล้างข้อมูล, บันทึกการตัดการเชื่อมต่อ หรือพยายามเชื่อมต่อใหม่หากคุณกำลังสร้างไคลเอนต์
eof_received(self)
นี่คือ callback ที่ละเอียดอ่อนกว่า มันถูกเรียกเมื่อปลายอีกด้านส่งสัญญาณว่าจะไม่ส่งข้อมูลเพิ่มเติมอีกต่อไป (เช่น โดยการเรียก shutdown(SHUT_WR)
บนระบบ POSIX) แต่การเชื่อมต่ออาจยังคงเปิดอยู่เพื่อให้คุณส่งข้อมูลได้ หากคุณส่งคืน True
จากเมธอดนี้ transport จะถูกปิด หากคุณส่งคืน False
(ค่าเริ่มต้น) คุณมีหน้าที่รับผิดชอบในการปิด transport ด้วยตัวเองในภายหลัง
The Transport: ช่องทางการสื่อสาร
Transport คือวัตถุที่ asyncio จัดหาให้ คุณไม่ได้สร้างมัน คุณได้รับมันในเมธอด connection_made
ของ protocol ของคุณ มันทำหน้าที่เป็น abstraction ระดับสูงเหนือซ็อกเก็ตเครือข่ายพื้นฐานและการจัดตารางเวลา I/O ของ event loop หน้าที่หลักของมันคือการจัดการการส่งข้อมูลและการควบคุมการเชื่อมต่อ
คุณจะโต้ตอบกับ transport ผ่านเมธอดของมัน:
transport.write(data)
เมธอดหลักสำหรับการส่งข้อมูล data
ต้องเป็นวัตถุ bytes
เมธอดนี้เป็นแบบ non-blocking มันไม่ได้ส่งข้อมูลทันที แต่จะวางข้อมูลลงในบัฟเฟอร์การเขียนภายใน และ event loop จะส่งข้อมูลผ่านเครือข่ายอย่างมีประสิทธิภาพที่สุดเท่าที่จะเป็นไปได้ในเบื้องหลัง
transport.writelines(list_of_data)
วิธีที่มีประสิทธิภาพมากขึ้นในการเขียนลำดับของวัตถุ bytes
ลงในบัฟเฟอร์พร้อมกัน ซึ่งอาจลดจำนวนการเรียกใช้ระบบ
transport.close()
นี่เป็นการเริ่มต้นการปิดระบบอย่างสง่างาม transport จะล้างข้อมูลที่เหลืออยู่ในบัฟเฟอร์การเขียนก่อน จากนั้นจึงปิดการเชื่อมต่อ จะไม่สามารถเขียนข้อมูลเพิ่มเติมได้หลังจากเรียกใช้ close()
transport.abort()
นี่เป็นการดำเนินการปิดระบบอย่างรุนแรง การเชื่อมต่อจะถูกปิดทันที และข้อมูลใดๆ ที่ค้างอยู่ในบัฟเฟอร์การเขียนจะถูกทิ้ง ควรใช้ในสถานการณ์พิเศษเท่านั้น
transport.get_extra_info(name, default=None)
เมธอดที่มีประโยชน์มากสำหรับการตรวจสอบ คุณสามารถรับข้อมูลเกี่ยวกับการเชื่อมต่อ เช่น ที่อยู่ของอีกฝ่าย ('peername'
), วัตถุซ็อกเก็ตพื้นฐาน ('socket'
) หรือข้อมูลใบรับรอง SSL/TLS ('ssl_object'
)
ความสัมพันธ์แบบพึ่งพาอาศัยกัน
ความสวยงามของการออกแบบนี้คือการไหลของข้อมูลที่ชัดเจนเป็นวัฏจักร:
- การตั้งค่า: event loop ยอมรับการเชื่อมต่อใหม่
- การสร้างอินสแตนซ์: loop สร้างอินสแตนซ์ของคลาส
Protocol
ของคุณและวัตถุTransport
ที่แสดงถึงการเชื่อมต่อ - การเชื่อมโยง: loop เรียกใช้
your_protocol.connection_made(transport)
ซึ่งเชื่อมโยงวัตถุทั้งสองเข้าด้วยกัน Protocol ของคุณตอนนี้มีวิธีส่งข้อมูลแล้ว - การรับข้อมูล: เมื่อข้อมูลมาถึงซ็อกเก็ตเครือข่าย event loop จะตื่นขึ้น, อ่านข้อมูล และเรียกใช้
your_protocol.data_received(data)
- การประมวลผล: ตรรกะของ Protocol ของคุณจะประมวลผลข้อมูลที่ได้รับ
- การส่งข้อมูล: ตามตรรกะของมัน Protocol ของคุณจะเรียกใช้
self.transport.write(response_data)
เพื่อส่งการตอบกลับ ข้อมูลจะถูกบัฟเฟอร์ - I/O เบื้องหลัง: event loop จัดการการส่งข้อมูลที่บัฟเฟอร์แบบ non-blocking ผ่าน transport
- การรื้อถอน: เมื่อการเชื่อมต่อสิ้นสุดลง event loop จะเรียกใช้
your_protocol.connection_lost(exc)
สำหรับการล้างข้อมูลขั้นสุดท้าย
การสร้างตัวอย่างเชิงปฏิบัติ: เซิร์ฟเวอร์และไคลเอนต์ Echo
ทฤษฎีนั้นยอดเยี่ยม แต่การทำความเข้าใจ Transports และ Protocols ที่ดีที่สุดคือการสร้างบางสิ่งบางอย่าง ลองสร้างเซิร์ฟเวอร์ echo แบบคลาสสิกและไคลเอนต์ที่เกี่ยวข้อง เซิร์ฟเวอร์จะยอมรับการเชื่อมต่อและส่งข้อมูลใดๆ ที่ได้รับกลับคืนไป
การใช้งาน Echo Server
อันดับแรก เราจะกำหนด protocol ฝั่งเซิร์ฟเวอร์ของเรา มันง่ายอย่างน่าทึ่ง โดยแสดงให้เห็นถึง event handler หลักๆ
import asyncio
class EchoServerProtocol(asyncio.Protocol):
def connection_made(self, transport):
# มีการสร้างการเชื่อมต่อใหม่แล้ว
# รับที่อยู่ระยะไกลสำหรับการบันทึก
peername = transport.get_extra_info('peername')
print(f"Connection from: {peername}")
# เก็บ transport ไว้ใช้ภายหลัง
self.transport = transport
def data_received(self, data):
# ได้รับข้อมูลจากไคลเอนต์
message = data.decode()
print(f"Data received: {message.strip()}")
# ส่งข้อมูลคืนกลับไปยังไคลเอนต์
print(f"Echoing back: {message.strip()}")
self.transport.write(data)
def connection_lost(self, exc):
# การเชื่อมต่อถูกปิดแล้ว
print("Connection closed.")
# transport ถูกปิดโดยอัตโนมัติ ไม่จำเป็นต้องเรียก self.transport.close() ที่นี่
async def main_server():
# รับการอ้างอิงถึง event loop เนื่องจากเราวางแผนที่จะรันเซิร์ฟเวอร์ตลอดไป
loop = asyncio.get_running_loop()
host = '127.0.0.1'
port = 8888
# coroutine `create_server` สร้างและเริ่มต้นเซิร์ฟเวอร์
# อาร์กิวเมนต์แรกคือ protocol_factory ซึ่งเป็น callable ที่ส่งคืนอินสแตนซ์ protocol ใหม่
# ในกรณีของเรา การส่งผ่านคลาส `EchoServerProtocol` ก็ใช้ได้
server = await loop.create_server(
lambda: EchoServerProtocol(),
host,
port)
addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets)
print(f'Serving on {addrs}')
# เซิร์ฟเวอร์ทำงานอยู่เบื้องหลัง เพื่อให้ coroutine หลักยังคงทำงานอยู่
# เราสามารถ await สิ่งที่ไม่เสร็จสมบูรณ์ เช่น Future ใหม่
# สำหรับตัวอย่างนี้ เราจะรันมัน "ตลอดไป"
async with server:
await server.serve_forever()
if __name__ == "__main__":
try:
# เพื่อรันเซิร์ฟเวอร์:
asyncio.run(main_server())
except KeyboardInterrupt:
print("Server shut down.")
การใช้งาน Echo Client
protocol ของไคลเอนต์จะซับซ้อนกว่าเล็กน้อยเพราะต้องจัดการสถานะของตัวเอง: ข้อความที่จะส่งและเมื่อไรที่มันถือว่างานของมัน "เสร็จสิ้น" รูปแบบที่พบบ่อยคือการใช้ asyncio.Future
หรือ asyncio.Event
เพื่อส่งสัญญาณการเสร็จสิ้นกลับไปยัง coroutine หลักที่เริ่มต้นไคลเอนต์
import asyncio
class EchoClientProtocol(asyncio.Protocol):
def __init__(self, message, on_con_lost):
self.message = message
self.on_con_lost = on_con_lost
self.transport = None
def connection_made(self, transport):
self.transport = transport
print(f"Sending: {self.message}")
self.transport.write(self.message.encode())
def data_received(self, data):
print(f"Received echo: {data.decode().strip()}")
def connection_lost(self, exc):
print("The server closed the connection")
# ส่งสัญญาณว่าการเชื่อมต่อขาดหายและงานเสร็จสมบูรณ์
self.on_con_lost.set_result(True)
def eof_received(self):
# สิ่งนี้สามารถถูกเรียกได้หากเซิร์ฟเวอร์ส่ง EOF ก่อนที่จะปิด
print("Received EOF from server.")
async def main_client():
loop = asyncio.get_running_loop()
# future on_con_lost ใช้เพื่อส่งสัญญาณการเสร็จสิ้นการทำงานของไคลเอนต์
on_con_lost = loop.create_future()
message = "Hello World!"
host = '127.0.0.1'
port = 8888
# `create_connection` สร้างการเชื่อมต่อและเชื่อมโยง protocol
try:
transport, protocol = await loop.create_connection(
lambda: EchoClientProtocol(message, on_con_lost),
host,
port)
except ConnectionRefusedError:
print("Connection refused. Is the server running?")
return
# รอจนกว่า protocol จะส่งสัญญาณว่าการเชื่อมต่อขาดหายไป
try:
await on_con_lost
finally:
# ปิด transport อย่างสง่างาม
transport.close()
if __name__ == "__main__":
# เพื่อรันไคลเอนต์:
# อันดับแรก ให้เริ่มเซิร์ฟเวอร์ในเทอร์มินัลหนึ่ง
# จากนั้น รันสคริปต์นี้ในอีกเทอร์มินัลหนึ่ง
asyncio.run(main_client())
แนวคิดขั้นสูงและสถานการณ์จริง
ตัวอย่าง echo ครอบคลุมพื้นฐานแล้ว แต่โปรโตคอลในโลกแห่งความเป็นจริงนั้นไม่ค่อยจะง่ายดายนัก ลองสำรวจหัวข้อขั้นสูงบางอย่างที่คุณจะพบเจออย่างหลีกเลี่ยงไม่ได้
การจัดการ Message Framing และ Buffering
แนวคิดที่สำคัญที่สุดอย่างหนึ่งที่ต้องเข้าใจหลังจากพื้นฐานคือ TCP เป็นสตรีมของไบต์ ไม่มีขอบเขต "ข้อความ" โดยธรรมชาติ หากไคลเอนต์ส่ง "Hello" แล้วตามด้วย "World" เมธอด data_received
ของเซิร์ฟเวอร์ของคุณอาจถูกเรียกเพียงครั้งเดียวด้วย b'HelloWorld'
, สองครั้งด้วย b'Hello'
และ b'World'
หรือแม้แต่หลายครั้งด้วยข้อมูลบางส่วน
โปรโตคอลของคุณมีหน้าที่ในการ "จัดเฟรม" — การประกอบสตรีมไบต์เหล่านี้ให้เป็นข้อความที่มีความหมาย กลยุทธ์ทั่วไปคือการใช้ตัวคั่น เช่น อักขระขึ้นบรรทัดใหม่ (\n
)
นี่คือ protocol ที่ถูกปรับปรุงซึ่งบัฟเฟอร์ข้อมูลจนกว่าจะพบอักขระขึ้นบรรทัดใหม่ โดยประมวลผลทีละบรรทัด
class LineBasedProtocol(asyncio.Protocol):
def __init__(self):
self._buffer = b''
self.transport = None
def connection_made(self, transport):
self.transport = transport
print("Connection established.")
def data_received(self, data):
# เพิ่มข้อมูลใหม่ลงในบัฟเฟอร์ภายใน
self._buffer += data
# ประมวลผลบรรทัดที่สมบูรณ์ทั้งหมดที่มีอยู่ในบัฟเฟอร์
while b'\n' in self._buffer:
line, self._buffer = self._buffer.split(b'\n', 1)
self.process_line(line.decode().strip())
def process_line(self, line):
# นี่คือที่ที่ตรรกะแอปพลิเคชันของคุณสำหรับข้อความเดียวทำงาน
print(f"Processing complete message: {line}")
response = f"Processed: {line}\n"
self.transport.write(response.encode())
def connection_lost(self, exc):
print("Connection lost.")
การจัดการ Flow Control (Backpressure)
จะเกิดอะไรขึ้นหากแอปพลิเคชันของคุณเขียนข้อมูลลงใน transport เร็วกว่าที่เครือข่ายหรือ peer ระยะไกลสามารถจัดการได้? ข้อมูลจะสะสมอยู่ในบัฟเฟอร์ภายในของ transport หากสิ่งนี้ยังคงดำเนินต่อไปโดยไม่มีการตรวจสอบ บัฟเฟอร์อาจเติบโตอย่างไม่มีที่สิ้นสุดและใช้หน่วยความจำทั้งหมดที่มีอยู่ ปัญหานี้เรียกว่าการขาด "backpressure"
Asyncio มีกลไกในการจัดการเรื่องนี้ transport จะตรวจสอบขนาดบัฟเฟอร์ของตัวเอง เมื่อบัฟเฟอร์เติบโตเกินขีดจำกัดสูงสุดที่กำหนด (high-water mark) event loop จะเรียกเมธอด pause_writing()
ของ protocol ของคุณ นี่คือสัญญาณให้แอปพลิเคชันของคุณหยุดส่งข้อมูล เมื่อบัฟเฟอร์ถูกระบายจนต่ำกว่าขีดจำกัดต่ำสุดที่กำหนด (low-water mark) loop จะเรียก resume_writing()
ซึ่งส่งสัญญาณว่าปลอดภัยที่จะส่งข้อมูลอีกครั้ง
class FlowControlledProtocol(asyncio.Protocol):
def __init__(self):
self._paused = False
self._data_source = some_data_generator() # สมมติว่ามีแหล่งข้อมูล
self.transport = None
def connection_made(self, transport):
self.transport = transport
self.resume_writing() # เริ่มกระบวนการเขียน
def pause_writing(self):
# บัฟเฟอร์ transport เต็มแล้ว
print("Pausing writing.")
self._paused = True
def resume_writing(self):
# บัฟเฟอร์ transport ได้ระบายออกไปแล้ว
print("Resuming writing.")
self._paused = False
self._write_more_data()
def _write_more_data(self):
# นี่คือลูปการเขียนของแอปพลิเคชันของเรา
while not self._paused:
try:
data = next(self._data_source)
self.transport.write(data)
except StopIteration:
self.transport.close()
break # ไม่มีข้อมูลที่จะส่งอีกแล้ว
# ตรวจสอบขนาดบัฟเฟอร์เพื่อดูว่าเราควรหยุดพักทันทีหรือไม่
if self.transport.get_write_buffer_size() > 0:
self.pause_writing()
นอกเหนือจาก TCP: Transports อื่นๆ
แม้ว่า TCP จะเป็นกรณีการใช้งานที่พบบ่อยที่สุด แต่รูปแบบ Transport/Protocol ก็ไม่ได้จำกัดอยู่เพียงแค่ TCP Asyncio มี abstraction สำหรับประเภทการสื่อสารอื่นๆ:
- UDP: สำหรับการสื่อสารแบบ connectionless คุณใช้
loop.create_datagram_endpoint()
สิ่งนี้จะให้DatagramTransport
แก่คุณ และคุณจะต้องใช้asyncio.DatagramProtocol
ด้วยเมธอดเช่นdatagram_received(data, addr)
และerror_received(exc)
- SSL/TLS: การเพิ่มการเข้ารหัสทำได้ง่ายอย่างไม่น่าเชื่อ คุณส่งวัตถุ
ssl.SSLContext
ไปยังloop.create_server()
หรือloop.create_connection()
Asyncio จะจัดการการจับมือ TLS โดยอัตโนมัติ และคุณจะได้รับการส่งที่ปลอดภัย โค้ด protocol ของคุณไม่จำเป็นต้องเปลี่ยนแปลงเลย - Subprocesses: สำหรับการสื่อสารกับ child processes ผ่าน I/O pipes มาตรฐานของพวกมัน สามารถใช้
loop.subprocess_exec()
และloop.subprocess_shell()
ร่วมกับasyncio.SubprocessProtocol
ได้ สิ่งนี้ช่วยให้คุณสามารถจัดการ child processes ในลักษณะที่ไม่บล็อกและอะซิงโครนัสได้อย่างสมบูรณ์
การตัดสินใจเชิงกลยุทธ์: เมื่อใดควรใช้ Transports vs. Streams
ด้วย API ที่ทรงพลังสองชุดที่คุณมีอยู่ การตัดสินใจด้านสถาปัตยกรรมที่สำคัญคือการเลือก API ที่เหมาะสมกับงาน นี่คือคำแนะนำที่จะช่วยคุณตัดสินใจ
เลือก Streams (StreamReader
/StreamWriter
) เมื่อ...
- โปรโตคอลของคุณเรียบง่ายและเป็นแบบ request-response. หากตรรกะคือ "อ่านคำขอ, ประมวลผล, เขียนการตอบกลับ" streams เหมาะสมที่สุด
- คุณกำลังสร้างไคลเอนต์สำหรับโปรโตคอลที่รู้จักกันดี ซึ่งเป็นแบบบรรทัด (line-based) หรือแบบความยาวคงที่ (fixed-length message). ตัวอย่างเช่น การโต้ตอบกับเซิร์ฟเวอร์ Redis หรือเซิร์ฟเวอร์ FTP แบบง่ายๆ
- คุณให้ความสำคัญกับการอ่านโค้ดและรูปแบบเชิงเส้นตรง (linear, imperative style). ไวยากรณ์
async/await
ร่วมกับ streams มักจะง่ายต่อการทำความเข้าใจสำหรับนักพัฒนาที่เพิ่งเริ่มต้นการเขียนโปรแกรมแบบอะซิงโครนัส - การสร้างต้นแบบอย่างรวดเร็วเป็นสิ่งสำคัญ. คุณสามารถสร้างไคลเอนต์หรือเซิร์ฟเวอร์ง่ายๆ ด้วย streams ได้ด้วยโค้ดเพียงไม่กี่บรรทัด
เลือก Transports และ Protocols เมื่อ...
- คุณกำลังนำโปรโตคอลเครือข่ายที่ซับซ้อนหรือกำหนดเองไปใช้ตั้งแต่เริ่มต้น. นี่คือกรณีการใช้งานหลัก ลองนึกถึงโปรโตคอลสำหรับเกม, ฟีดข้อมูลทางการเงิน, อุปกรณ์ IoT หรือแอปพลิเคชันแบบ peer-to-peer
- โปรโตคอลของคุณขับเคลื่อนด้วยเหตุการณ์สูงและไม่เป็นเพียงแค่ request-response. หากเซิร์ฟเวอร์สามารถส่งข้อความที่ไม่พึงประสงค์ไปยังไคลเอนต์ได้ตลอดเวลา ลักษณะการทำงานแบบ callback-based ของ protocols จะเหมาะสมกว่า
- คุณต้องการประสิทธิภาพสูงสุดและโอเวอร์เฮดน้อยที่สุด. Protocols ให้เส้นทางที่ตรงไปยัง event loop มากกว่า โดยเลี่ยงโอเวอร์เฮดบางอย่างที่เกี่ยวข้องกับ Streams API
- คุณต้องการการควบคุมการเชื่อมต่อที่ละเอียด. ซึ่งรวมถึงการจัดการบัฟเฟอร์ด้วยตนเอง, การควบคุมการไหลอย่างชัดเจน (
pause/resume_writing
) และการจัดการวงจรชีวิตของการเชื่อมต่ออย่างละเอียด - คุณกำลังสร้างเฟรมเวิร์กหรือไลบรารีเครือข่าย. หากคุณกำลังจัดหาเครื่องมือสำหรับนักพัฒนาคนอื่นๆ ลักษณะที่แข็งแกร่งและยืดหยุ่นของ Protocol/Transport API มักจะเป็นรากฐานที่ถูกต้อง
บทสรุป: ทำความเข้าใจรากฐานของ Asyncio
ไลบรารี asyncio
ของ Python เป็นผลงานชิ้นเอกของการออกแบบที่มีเลเยอร์ซ้อนกัน แม้ว่า High-level Streams API จะเป็นจุดเริ่มต้นที่เข้าถึงได้ง่ายและมีประสิทธิภาพ แต่ Low-level Transport และ Protocol API ต่างหากที่เป็นรากฐานที่แท้จริงและทรงพลังของความสามารถด้านเครือข่ายของ asyncio การแยกกลไก I/O (Transport) ออกจากตรรกะของแอปพลิเคชัน (Protocol) ทำให้มีโมเดลที่แข็งแกร่ง ปรับขนาดได้ และยืดหยุ่นอย่างเหลือเชื่อสำหรับการสร้างแอปพลิเคชันเครือข่ายที่ซับซ้อน
การทำความเข้าใจ abstraction ระดับต่ำนี้ไม่ใช่เพียงแค่การศึกษาทางวิชาการเท่านั้น แต่ยังเป็นทักษะที่นำไปใช้ได้จริงซึ่งช่วยให้คุณสามารถก้าวข้ามไคลเอนต์และเซิร์ฟเวอร์แบบง่ายๆ ได้ มันให้ความมั่นใจในการจัดการกับโปรโตคอลเครือข่ายใดๆ การควบคุมเพื่อเพิ่มประสิทธิภาพภายใต้ความกดดัน และความสามารถในการสร้างบริการอะซิงโครนัสประสิทธิภาพสูงรุ่นต่อไปใน Python ครั้งต่อไปที่คุณเผชิญกับปัญหาเครือข่ายที่ท้าทาย โปรดจำพลังที่ซ่อนอยู่ใต้พื้นผิว และอย่าลังเลที่จะเลือกคู่หูที่สง่างามของ Transports และ Protocols