راهنمای عملی برنامهنویسی سوکت¶
- نویسنده:
گوردون مکمیلان
سوکتها¶
من فقط قصد دارم دربارهی سوکتهای INET (یعنی IPv4) صحبت کنم، اما این سوکتها دستکم ۹۹٪ از سوکتهای مورد استفاده را تشکیل میدهند. و فقط دربارهی سوکتهای STREAM (یعنی TCP) صحبت خواهم کرد — مگر اینکه واقعاً بدانید چه کار میکنید (که در این صورت این راهنمای عملی بدرد شما نمیخورد!)، از یک سوکت STREAM رفتار و کارایی بهتری نسبت به هر چیز دیگری خواهید گرفت. سعی میکنم ابهام دربارهی چیستی سوکت را برطرف کنم، و همچنین نکاتی دربارهی نحوهی کار با سوکتهای مسدودکننده و غیرمسدودکننده ارائه دهم. اما صحبت را با سوکتهای مسدودکننده آغاز میکنم. پیش از کار با سوکتهای غیرمسدودکننده، باید بدانید که آنها چگونه کار میکنند.
بخشی از دشواری درک این موارد آن است که «سوکت» میتواند بسته به زمینه، به چند چیز با تفاوتهای ظریف اشاره داشته باشد. بنابراین ابتدا، بیایید میان یک سوکت «کلاینت»، یعنی پایانهی یک مکالمه، و یک سوکت «سرور» که بیشتر شبیه یک اپراتور تلفنخانه است، تمایز قائل شویم. برنامهی کلاینت (برای مثال، مرورگر شما) منحصراً از سوکتهای «کلاینت» استفاده میکند؛ سرور وبی که با آن گفتوگو میکند، هم از سوکتهای «سرور» و هم از سوکتهای «کلاینت» استفاده میکند.
تاریخچه¶
از میان اشکال گوناگون IPC، سوکتها با اختلاف محبوبترین هستند. در هر سکوی معین، احتمال دارد اشکال دیگری از IPC وجود داشته باشند که سریعتر باشند، اما برای ارتباط بینسکویی، سوکتها تقریباً تنها گزینه موجود هستند.
آنها در برکلی بهعنوان بخشی از نسخهی BSD از یونیکس ابداع شدند. با اینترنت، مانند آتش در علف خشک گسترش یافتند. بهدلیل موجهی --- ترکیب سوکتها با INET، ارتباط با هر ماشین دلخواهی در سراسر جهان را بهطرز باورنکردنی آسان میکند (حداقل در مقایسه با روشهای دیگر).
ایجاد یک سوکت¶
بهطور تقریبی، هنگامی که روی پیوندی که شما را به این صفحه آورد کلیک کردید، مرورگر شما کاری مشابه زیر انجام داد:
# create an INET, STREAMing socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now connect to the web server on port 80 - the normal http port
s.connect(("www.python.org", 80))
هنگامی که connect کامل میشود، میتوان از سوکت s برای ارسال درخواستی برای متن صفحه استفاده کرد. همان سوکت پاسخ را میخواند و سپس نابود میشود. درست است، نابود میشود. سوکتهای کلاینت معمولاً تنها برای یک تبادل (یا مجموعه کوچکی از تبادلهای متوالی) استفاده میشوند.
آنچه در وبسرور رخ میدهد کمی پیچیدهتر است. ابتدا، وبسرور یک «سوکت سرور» ایجاد میکند:
# create an INET, STREAMing socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to a public host, and a well-known port
serversocket.bind((socket.gethostname(), 80))
# become a server socket
serversocket.listen(5)
چند نکته قابل توجه: ما از socket.gethostname() استفاده کردیم تا سوکت برای دنیای بیرون قابل مشاهده باشد. اگر از s.bind(('localhost', 80)) یا s.bind(('127.0.0.1', 80)) استفاده کرده بودیم، همچنان یک سوکت «سرور» داشتیم، اما سوکتی که فقط در همان ماشین قابل مشاهده بود. s.bind(('', 80)) مشخص میکند که سوکت از طریق هر نشانی که ماشین ممکن است داشته باشد، قابل دسترسی است.
دومین نکته: پورتهای با شماره کم معمولاً برای سرویسهای «شناختهشده» (HTTP، SNMP و غیره) رزرو شدهاند. اگر در حال آزمایش هستید، از یک شماره بالا و مناسب (۴ رقمی) استفاده کنید.
در نهایت، آرگومان listen به کتابخانهی سوکت میگوید که ما میخواهیم تا ۵ درخواست اتصال (حداکثر معمول) را پیش از رد کردن اتصالهای بیرونی در صف قرار دهد. اگر بقیهی کد بهدرستی نوشته شده باشد، این مقدار باید کافی باشد.
اکنون که یک سوکت «سرور» داریم که به پورت ۸۰ گوش میدهد، میتوانیم وارد حلقهی اصلی وبسرور شویم:
while True:
# accept connections from outside
(clientsocket, address) = serversocket.accept()
# now do something with the clientsocket
# in this case, we'll pretend this is a threaded server
ct = make_client_thread(clientsocket)
ct.start()
در واقع این حلقه میتواند به ۳ روش کلی کار کند - راهاندازی یک نخ برای رسیدگی به clientsocket، ایجاد یک فرایند جدید برای رسیدگی به clientsocket، یا تغییر ساختار این برنامه برای استفاده از سوکتهای غیرمسدودکننده و مالتیپلکس کردن (multiplex) بین سوکت «سرور» خودمان و هر clientsocket فعالی با استفاده از select. بعداً دربارهی آن بیشتر صحبت خواهیم کرد. نکته مهمی که اکنون باید درک کنید این است: این تمام کاری است که یک سوکت «سرور» انجام میدهد. هیچ دادهای ارسال نمیکند. هیچ دادهای دریافت نمیکند. فقط سوکتهای «کلاینت» تولید میکند. هر clientsocket در پاسخ به connect() کردن یک سوکت «کلاینت» دیگر به میزبان و پورتی که به آنها مقید شدهایم، ایجاد میشود. بهمحض ایجاد آن clientsocket، دوباره به گوشدادن برای اتصالهای بیشتر بازمیگردیم. دو «کلاینت» آزادند با یکدیگر گفتگو کنند - آنها از پورتی که بهصورت پویا تخصیصیافته است استفاده میکنند، که پس از پایان گفتگو بازیافت میشود.
ارتباط بینفرایندی (IPC)¶
اگر به ارتباط بینفرایندی (IPC) سریع بین دو پردازه روی یک ماشین نیاز دارید، باید پایپها یا حافظه مشترک را بررسی کنید. اگر تصمیم گرفتید از سوکتهای AF_INET استفاده کنید، سوکت «سرور» را به 'localhost' اختصاص دهید. در بیشتر پلتفرمها، این کار با میانبر زدن چند لایه از کد شبکه، بهمراتب سریعتر خواهد بود.
همچنین ملاحظه نمائید
multiprocessing ارتباط بینفرایندی بینسکویی (IPC) را در یک API سطح بالاتر ادغام میکند.
استفاده از سوکت¶
نخستین نکتهای که باید به آن توجه کنید این است که سوکت «کلاینت» مرورگر وب و سوکت «کلاینت» وبسرور، هر دو کاملاً یکسان هستند. یعنی این یک گفتوگوی «همتا به همتا» است. یا به بیان دیگر، بهعنوان طراح، شما باید تصمیم بگیرید که آداب گفتوگو چیست. بهطور معمول، سوکتی که connect را فراخوانی میکند، گفتوگو را با ارسال یک درخواست، یا شاید یک ورود (signon)، آغاز میکند. اما این یک تصمیم طراحی است؛ قانون سوکتها نیست.
اکنون دو مجموعه از فعلها برای استفاده در ارتباط وجود دارد. شما میتوانید از send و recv استفاده کنید، یا میتوانید سوکت کلاینت خود را به چیزی شبیه پرونده تبدیل کنید و از read و write استفاده کنید. روش دوم شیوهای است که Java سوکتهای خود را ارائه میدهد. من قصد ندارم اینجا درباره آن صحبت کنم، مگر برای هشدار دادن به شما که باید روی سوکتها از flush استفاده کنید. اینها «پروندههایی» دارای بافر هستند، و یک اشتباه رایج این است که چیزی را write کنید و سپس برای دریافت پاسخ read کنید. بدون یک flush در این میان، ممکن است برای همیشه منتظر پاسخ بمانید، زیرا ممکن است درخواست هنوز در بافر خروجی شما باشد.
اکنون به مانع اصلی سوکتها میرسیم — send و recv بر روی بافرهای شبکه عمل میکنند. آنها لزوماً همهی بایتهایی را که به آنها میسپارید (یا از آنها انتظار دارید) پردازش نمیکنند، زیرا تمرکز اصلی آنها رسیدگی به بافرهای شبکه است. بهطور کلی، آنها زمانی برمیگردند که بافرهای شبکهی مرتبط پر شده (send) یا خالی شده (recv) باشند. سپس به شما میگویند که چند بایت را پردازش کردهاند. این وظیفهی شما است که آنها را دوباره فراخوانی کنید تا پیام شما بهطور کامل رسیدگی شود.
وقتی recv ۰ بایت برمیگرداند، به این معنا است که طرف مقابل اتصال را بسته است (یا در حال بستن آن است). شما دیگر هیچ دادهای را بر روی این اتصال دریافت نخواهید کرد. هرگز. ممکن است بتوانید دادهها را با موفقیت ارسال کنید؛ بعداً بیشتر در این مورد صحبت خواهم کرد.
پروتکلی مانند HTTP از یک سوکت فقط برای یک انتقال استفاده میکند. کلاینت درخواستی ارسال میکند، سپس پاسخی را میخواند. همین کافی است. سوکت دور انداخته میشود. این بدان معناست که یک کلاینت میتواند با دریافت ۰ بایت، پایان پاسخ را تشخیص دهد.
اما اگر قصد دارید از سوکت خود برای انتقالهای بعدی دوباره استفاده کنید، باید بدانید که هیچ EOT روی یک سوکت وجود ندارد. تکرار میکنم: اگر send یا recv یک سوکت پس از پردازش ۰ بایت بازگشت کند، اتصال قطع شده است. اگر اتصال قطع نشده باشد، ممکن است برای همیشه منتظر recv بمانید، زیرا سوکت به شما نخواهد گفت که دیگر چیزی برای خواندن وجود ندارد (فعلاً). حال اگر کمی درباره آن بیندیشید، به یک حقیقت بنیادی سوکتها پی خواهید برد: پیامها باید یا طول ثابتی داشته باشند (چندشآور)، یا جداشده باشند (شانه بالا انداختن)، یا طول خود را مشخص کنند (بسیار بهتر)، یا با خاموش کردن اتصال پایانیابند. انتخاب کاملاً با شماست، (اما برخی روشها از سایر روشها درستتر هستند).
با فرض اینکه نمیخواهید اتصال را پایان دهید، سادهترین راهحل، پیامی با طول ثابت است:
class MySocket:
"""demonstration class only
- coded for clarity, not efficiency
"""
def __init__(self, sock=None):
if sock is None:
self.sock = socket.socket(
socket.AF_INET, socket.SOCK_STREAM)
else:
self.sock = sock
def connect(self, host, port):
self.sock.connect((host, port))
def mysend(self, msg):
totalsent = 0
while totalsent < MSGLEN:
sent = self.sock.send(msg[totalsent:])
if sent == 0:
raise RuntimeError("socket connection broken")
totalsent = totalsent + sent
def myreceive(self):
chunks = []
bytes_recd = 0
while bytes_recd < MSGLEN:
chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
if chunk == b'':
raise RuntimeError("socket connection broken")
chunks.append(chunk)
bytes_recd = bytes_recd + len(chunk)
return b''.join(chunks)
کد ارسال در اینجا برای تقریباً هر روش پیامرسانی قابل استفاده است - در پایتون شما رشتهها را ارسال میکنید، و میتوانید از len() برای تعیین طول آنها استفاده کنید (حتی اگر دارای نویسههای \0 نهفته باشند). این عمدتاً کد دریافت است که پیچیدهتر میشود. (و در C، خیلی بدتر نیست، به جز اینکه اگر پیام دارای نویسههای \0 نهفته باشد، نمیتوانید از strlen استفاده کنید.)
سادهترین بهبود این است که نخستین نویسهی پیام را نشانگر نوع پیام قرار دهید، و نوع، طول را تعیین کند. اکنون دو recvs دارید — اولی برای دریافت (حداقل) همان نخستین نویسه است تا بتوانید طول را پیدا کنید، و دومی در یک حلقه برای دریافت باقیمانده است. اگر تصمیم بگیرید از روش جداکنندهدار استفاده کنید، دریافت در اندازهی تکهی دلخواهی (chunk) انجام خواهد شد، (۴۰۹۶ یا ۸۱۹۲ اغلب تطابق خوبی با اندازههای بافر شبکه دارد)، و آنچه را دریافت کردهاید برای یافتن یک جداکننده پویش خواهید کرد.
یک پیچیدگی که باید از آن آگاه باشید: اگر پروتکل گفتگویی شما اجازه دهد چندین پیام بهصورت پشتسرهم (بدون دریافت پاسخی) ارسال شوند و به recv یک اندازهی تکهی دلخواه (chunk size) بدهید، ممکن است در نهایت آغاز پیام بعدی را بخوانید. باید آن را کنار بگذارید و نگه دارید، تا زمانی که به آن نیاز باشد.
قرار دادن طول پیام بهعنوان پیشوند آن (مثلاً بهصورت ۵ نویسه عددی) پیچیدهتر میشود، زیرا (باور کنید یا نه)، ممکن است همه ۵ نویسه را در یک recv دریافت نکنید. در آزمایشهای ساده، ممکن است مشکلی پیش نیاید؛ اما در بارهای شبکهای بالا، کد شما بسیار سریع از کار میافتد، مگر اینکه از دو حلقه recv استفاده کنید: اولی برای تعیین طول و دومی برای دریافت بخش دادهای پیام. آزاردهنده است. در همین زمان است که متوجه میشوید send همیشه نمیتواند همهچیز را در یک مرحله ارسال کند. و با وجود خواندن این مطلب، در نهایت گرفتار آن خواهید شد!
بهخاطر صرفهجویی در فضا، پرورش شخصیت شما، (و حفظ جایگاه رقابتی من)، این بهبودها بهعنوان تمرینی برای خواننده باقی گذاشته شدهاند. بیایید به پاکسازی بپردازیم.
دادههای دودویی¶
ارسال دادههای دودویی از طریق یک سوکت کاملاً ممکن است. مشکل اصلی این است که همهی ماشینها از قالبهای یکسانی برای دادههای دودویی استفاده نمیکنند. برای مثال، ترتیب بایت شبکه بهصورت بزرگاندیان (big-endian) است و پرارزشترین بایت در ابتدا قرار میگیرد؛ بنابراین یک عدد صحیح ۱۶ بیتی با مقدار 1 بهصورت دو بایت مبنای شانزده 00 01 خواهد بود. با این حال، بیشتر پردازندههای رایج (x86/AMD64، ARM، RISC-V) کوچکاندیان (little-endian) هستند و کمارزشترین بایت در ابتدا قرار میگیرد — همان 1 بهصورت 01 00 خواهد بود.
کتابخانههای سوکت فراخوانیهایی برای تبدیل اعداد صحیح ۱۶ بیتی و ۳۲ بیتی دارند - ntohl, htonl, ntohs, htons که در آنها «n» به معنای network (شبکه)، «h» به معنای host (میزبان)، «s» به معنای short (کوتاه) و «l» به معنای long (بلند) است. هر جا که ترتیب شبکه همان ترتیب میزبان باشد، این فراخوانیها هیچ کاری انجام نمیدهند، اما هر جا که ترتیب بایتهای ماشین معکوس باشد، این فراخوانیها بایتها را بهشکل مناسب جابهجا میکنند.
در این روزگار ماشینهای ۶۴ بیتی، نمایش ASCII دادههای دودویی اغلب کوچکتر از نمایش دودویی آنها است. دلیلش این است که در کمال تعجب، در بیشتر مواقع، اغلب اعداد صحیح مقدار ۰ دارند، یا شاید ۱. رشتهی "0" دو بایت خواهد بود، در حالی که یک عدد صحیح ۶۴ بیتی کامل ۸ بایت خواهد بود. البته، این موضوع با پیامهای با طول ثابت سازگاری خوبی ندارد. تصمیمها، تصمیمها.
قطع اتصال¶
اگر بخواهیم دقیق باشیم، شما باید پیش از close کردن یک سوکت، از shutdown بر روی آن استفاده کنید. shutdown یک اعلان به سوکت سمت دیگر است. بسته به آرگومانی که به آن میدهید، میتواند به معنای «دیگر چیزی ارسال نخواهم کرد، اما همچنان گوش میدهم» یا «دیگر گوش نمیدهم، راحت شدم!» باشد. با این حال، بیشتر کتابخانههای سوکت چنان به غفلت برنامهنویسان از رعایت این آداب عادت کردهاند که معمولاً close همان shutdown(); close() است. بنابراین در بیشتر موارد، به shutdown صریح نیازی نیست.
یکی از راههای استفادهی مؤثر از shutdown، در یک تبادل شبیه به HTTP است. کلاینت درخواستی ارسال میکند و سپس shutdown(1) را فراخوانی میکند. این کار به سرور میگوید: «این کلاینت ارسال را به پایان رسانده است، اما هنوز میتواند دریافت کند.» سرور میتواند با دریافت ۰ بایت، «EOF» را تشخیص دهد. میتواند فرض کند که درخواست کامل را دریافت کرده است. سرور پاسخی میفرستد. اگر send با موفقیت کامل شود، در واقع کلاینت هنوز در حال دریافت بوده است.
پایتون خاموش شدن خودکار را یک گام فراتر میبرد و میگوید که وقتی یک سوکت زبالهروبی میشود، در صورت نیاز بهطور خودکار close را انجام میدهد. اما اتکا به این موضوع عادت بسیار بدی است. اگر سوکت شما بدون انجام close صرفاً ناپدید شود، سوکت سمت مقابل ممکن است بهطور نامعلومی معلق بماند، به این گمان که شما صرفاً کند هستید. لطفاً پس از اتمام کار، سوکتهای خود را close کنید.
هنگامی که سوکتها میمیرند¶
احتمالاً بدترین نکته در استفاده از سوکتهای مسدودکننده همان چیزی است که وقتی طرف مقابل بهطور ناگهانی از کار میافتد (بدون انجام یک close) رخ میدهد. بهاحتمال زیاد سوکت شما آویزان میشود. TCP پروتکلی قابلاعتماد است و مدت بسیار، بسیار طولانی منتظر میماند تا از یک اتصال دست بکشد. اگر از نخها استفاده میکنید، آن نخ عملاً بهطور کامل مرده است. کار زیادی از دست شما برنمیآید. تا زمانی که کار احمقانهای مانند نگهداشتن یک قفل هنگام انجام یک خواندن مسدودکننده انجام نمیدهید، نخ واقعاً منابع زیادی مصرف نمیکند. سعی نکنید نخ را بکشید — بخشی از دلیل کارآمدتر بودن نخها نسبت به فرایندها این است که آنها از سربار مرتبط با بازیافت خودکار منابع اجتناب میکنند. به عبارت دیگر، اگر واقعاً موفق به کشتن نخ شوید، احتمالاً کل فرایند شما دچار مشکل جدی میشود.
سوکتهای غیرمسدودکننده¶
اگر مطالب پیشین را درک کرده باشید، از پیش بیشترِ آنچه را که باید دربارهی سازوکار استفاده از سوکتها بدانید، میدانید. شما همچنان از همان فراخوانیها، به تقریباً همان شیوهها استفاده خواهید کرد. فقط این است که اگر این کار را درست انجام دهید، برنامهی شما تقریباً وارونه خواهد شد.
در پایتون، برای غیرمسدود کردن آن از socket.setblocking(False) استفاده میکنید. در C، این کار پیچیدهتر است، (برای مثال، باید بین نوع BSD یعنی O_NONBLOCK و نوع POSIX یعنی O_NDELAY که تقریباً غیرقابلتشخیص است و کاملاً با TCP_NODELAY تفاوت دارد، یکی را انتخاب کنید)، اما ایده دقیقاً همان است. شما این کار را پس از ایجاد سوکت، اما پیش از استفاده از آن انجام میدهید. (البته اگر دیوانه باشید، میتوانید بین این دو حالت جابهجا شوید.)
تفاوت سازوکاری اصلی این است که send، recv، connect و accept میتوانند بدون اینکه کاری انجام داده باشند، بازگردند. شما (البته) گزینههای متعددی دارید. میتوانید کد بازگشتی و کدهای خطا را بررسی کنید و بهطور کلی خود را دیوانه کنید. اگر باور نمیکنید، گاهی آن را امتحان کنید. برنامهتان بزرگ و پر از اشکال خواهد شد و منابع پردازنده را بهشدت مصرف خواهد کرد. پس بیایید راهحلهای نابخردانه را کنار بگذاریم و کار را درست انجام دهیم.
از select استفاده کنید.
در C، کدنویسی select نسبتاً پیچیده است. در پایتون، این کار بسیار آسان است، اما به اندازه کافی به نسخه C نزدیک است که اگر select را در پایتون درک کنید، در C با مشکل چندانی مواجه نخواهید شد:
ready_to_read, ready_to_write, in_error = \
select.select(
potential_readers,
potential_writers,
potential_errs,
timeout)
شما به select سه فهرست میدهید: اولی شامل همه سوکتهایی است که ممکن است بخواهید خواندن از آنها را امتحان کنید؛ دومی شامل همه سوکتهایی است که ممکن است بخواهید نوشتن به آنها را امتحان کنید، و سومی (که معمولاً خالی گذاشته میشود) شامل سوکتهایی است که میخواهید آنها را از نظر خطاها بررسی کنید. باید توجه داشته باشید که یک سوکت میتواند در بیش از یک فهرست قرار بگیرد. فراخوانی select مسدودکننده است، اما میتوانید برای آن یک مهلت تعیین کنید. این کار معمولاً عاقلانهای است — مگر اینکه دلیل خوبی برای انجام خلاف این کار داشته باشید، یک مهلت طولانی و مناسب برای آن تعیین کنید (مثلاً یک دقیقه).
در ازای آن، سه فهرست دریافت خواهید کرد. این فهرستها شامل سوکتهایی هستند که در واقع قابلخواندن، قابلنوشتن و دارای خطا هستند. هر یک از این فهرستها زیرمجموعهای (احتمالاً خالی) از فهرست متناظری است که شما بهعنوان ورودی دادهاید.
اگر یک سوکت در فهرست خروجی خواندنی باشد، میتوانید تا حدی که در این کار ممکن است مطمئن باشید که یک recv روی آن سوکت چیزی را برمیگرداند. همین مفهوم برای فهرست نوشتنی نیز صادق است. شما میتوانید چیزی ارسال کنید. شاید نه همهی آن چیزی که میخواهید، اما چیزی بهتر از هیچ است. (در واقع، هر سوکت نسبتاً سالمی بهعنوان نوشتنی بازگردانده میشود؛ این صرفاً به این معناست که فضای بافر شبکهی خروجی در دسترس است.)
اگر یک سوکت «سرور» دارید، آن را در فهرست potential_readers قرار دهید. اگر در فهرست readable ظاهر شود، accept شما (تقریباً بهطور قطع) کار خواهد کرد. اگر یک سوکت جدید برای connect به طرف دیگر ایجاد کردهاید، آن را در فهرست potential_writers قرار دهید. اگر در فهرست writable ظاهر شود، احتمال مناسبی وجود دارد که متصل شده باشد.
در واقع، select حتی با سوکتهای مسدودکننده هم میتواند مفید باشد. این یکی از راههای تعیین این است که آیا مسدود خواهید شد یا خیر؛ هنگامی که چیزی در بافرها وجود دارد، سوکت بهعنوان قابلخواندن بازگردانده میشود. با این حال، این هنوز به مسئلهی تعیین اینکه آیا طرف مقابل کار را تمام کرده است یا صرفاً مشغول کار دیگری است، کمکی نمیکند.
هشدار قابلیت حمل: در یونیکس، select هم با سوکتها و هم با پروندهها کار میکند. این کار را در ویندوز امتحان نکنید. در ویندوز، select فقط با سوکتها کار میکند. همچنین توجه داشته باشید که در C، بسیاری از گزینههای پیشرفتهتر سوکت در ویندوز به شکل متفاوتی انجام میشوند. در واقع، در ویندوز من معمولاً از نخها (که بسیار، بسیار خوب کار میکنند) همراه با سوکتهایم استفاده میکنم.