HOWTO з програмування сокетів

Автор:

Gordon McMillan

Сокети

Я збираюся говорити лише про сокети INET (тобто IPv4), але на них припадає щонайменше 99% використовуваних сокетів. І я буду говорити лише про сокети STREAM (тобто TCP) - якщо ви дійсно не знаєте, що робите (у цьому випадку це HOWTO не для вас!), ви отримаєте кращу поведінку та продуктивність від сокета STREAM, ніж будь-що інше. Я спробую розкрити таємницю того, що таке сокет, а також дам деякі підказки щодо роботи з блокуючими та неблокуючими сокетами. Але я почну з розмови про блокування сокетів. Вам потрібно знати, як вони працюють, перш ніж мати справу з неблокуючими сокетами.

Частково проблема з розумінням цих речей полягає в тому, що «розетка» може означати декілька дещо різних речей, залежно від контексту. Отже, спочатку давайте розрізнимо «клієнтський» сокет — кінцеву точку розмови, і «серверний» сокет, який більше схожий на оператора комутатора. Клієнтська програма (наприклад, ваш браузер) використовує виключно «клієнтські» сокети; веб-сервер, з яким він спілкується, використовує як «серверні» сокети, так і «клієнтські» сокети.

історія

З різних форм IPC сокети є, безумовно, найпопулярнішими. На будь-якій платформі, імовірно, існують інші форми IPC, які є швидшими, але для міжплатформного зв’язку сокети – це майже єдина гра в місті.

Вони були винайдені в Берклі як частина різновиду BSD Unix. Вони поширюються з Інтернетом як лісова пожежа. З поважною причиною — поєднання сокетів з 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 тощо). Якщо ви граєтесь, використовуйте гарне високе число (4 цифри).

Нарешті, аргумент listen повідомляє бібліотеці сокетів, що ми хочемо поставити в чергу щонайменше 5 запитів на з’єднання (звичайний максимум), перш ніж відхилити зовнішні з’єднання. Якщо решта коду написана належним чином, цього має бути достатньо.

Тепер, коли у нас є «серверний» сокет, який слухає порт 80, ми можемо увійти в основний цикл веб-сервера:

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 = client_thread(clientsocket)
    ct.run()

Насправді існує 3 загальні способи роботи цього циклу: відправка потоку для обробки clientsocket, створення нового процесу для обробки clientsocket або реструктуризація цієї програми для використання неблокуючих сокетів і мультиплексування між нашими «серверний» сокет і будь-який активний clientocketз використанням select. Про це пізніше. Зараз важливо розуміти наступне: це все, що робить «серверний» сокет. Він не надсилає жодних даних. Він не отримує жодних даних. Він просто створює «клієнтські» сокети. Кожен clientocket створюється у відповідь на те, що якийсь інший «клієнтський» сокет виконує connect() до хосту та порту, до якого ми прив’язані. Як тільки ми створили цей clientsocket, ми повертаємося до прослуховування додаткових з’єднань. Два «клієнти» можуть вільно спілкуватися - вони використовують якийсь динамічно виділений порт, який буде повторно використано після завершення розмови.

IPC

Якщо вам потрібен швидкий IPC між двома процесами на одній машині, вам слід звернути увагу на канали або спільну пам’ять. Якщо ви вирішите використовувати сокети AF_INET, прив’яжіть сокет «сервер» до 'localhost'. На більшості платформ це займе ярлик навколо кількох шарів мережевого коду та буде трохи швидшим.

Дивись також

multiprocessing інтегрує міжплатформенний IPC у API вищого рівня.

Використання сокета

Перше, що слід зазначити, це те, що «клієнтський» сокет веб-браузера та «клієнтський» сокет веб-сервера є ідентичними звірами. Тобто це розмова «рівний». Або інакше кажучи, як дизайнеру, вам доведеться вирішити, які правила етикету для розмови. Зазвичай сокет connectпочинає розмову, надсилаючи запит або, можливо, авторизуючись. Але це дизайнерське рішення - це не правило розеток.

Тепер є два набори дієслів для спілкування. Ви можете використовувати send і recv, або ви можете перетворити свій клієнтський сокет на звіра, схожого на файл, і використовувати read і write. Останнім є те, як Java представляє свої сокети. Я не збираюся говорити про це тут, окрім того, щоб попередити вас, що вам потрібно використовувати промивання для сокетів. Це буферизовані «файли», і поширеною помилкою є написати щось, а потім читати для відповіді. Без скидання ви можете вічно чекати відповіді, оскільки запит може все ще перебувати у вашому вихідному буфері.

Тепер ми підходимо до основного каменю спотикання сокетів - send і recv працюють з мережевими буферами. Вони не обов’язково обробляють усі байти, які ви їм передаєте (або очікуєте від них), тому що їх основна увага приділяється роботі з мережевими буферами. Зазвичай вони повертаються, коли пов’язані мережеві буфери заповнено (send) або спустошено (recv). Потім вони повідомлять вам, скільки байтів вони обробили. Це ваш обов’язок зателефонувати їм знову, доки ваше повідомлення не буде повністю розглянуто.

Коли recv повертає 0 байтів, це означає, що інша сторона закрила (або знаходиться в процесі закриття) з’єднання. Ви більше не отримуватимете жодних даних за цим з’єднанням. Коли-небудь. Можливо, ви зможете успішно надіслати дані; Я розповім про це пізніше.

Такий протокол, як HTTP, використовує сокет лише для однієї передачі. Клієнт надсилає запит, потім читає відповідь. Це воно. Розетка викидається. Це означає, що клієнт може виявити кінець відповіді, отримавши 0 байтів.

Але якщо ви плануєте повторно використовувати свій сокет для подальших передач, ви повинні розуміти, що немає EOT у сокеті. Я повторюю: якщо сокет надсилає або recv повертає після обробки 0 байт, з’єднання було розірвано. Якщо з’єднання не розірвано, ви можете чекати 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)

Код надсилання тут можна використовувати майже для будь-якої схеми обміну повідомленнями - у Python ви надсилаєте рядки, і ви можете використовувати len(), щоб визначити його довжину (навіть якщо він містить символи \0). Здебільшого код отримання стає складнішим. (У C це не набагато гірше, за винятком того, що ви не можете використовувати strlen, якщо повідомлення містить \0s.)

Найпростішим удосконаленням є зробити перший символ повідомлення індикатором типу повідомлення, а тип визначати довжину. Тепер у вас є два recv- перший, щоб отримати (принаймні) перший символ, щоб ви могли переглянути довжину, а другий у циклі, щоб отримати решту. Якщо ви вирішите скористатися розмежованим маршрутом, ви отримуватимете довільний розмір фрагмента (4096 або 8192 часто добре підходить для розмірів мережевого буфера) і скануватимете отримане як роздільник.

Одна складність, про яку слід пам’ятати: якщо ваш протокол розмови дозволяє надсилати декілька повідомлень один за одним (без відповіді), і ви передаєте recv довільного розміру фрагмента, ви можете завершити читання початку наступне повідомлення. Вам потрібно буде відкласти це вбік і тримати, поки воно не знадобиться.

Додавання до повідомлення довжини (скажімо, 5 цифрових символів) стає складнішим, оскільки (вірте чи ні), ви можете не отримати всі 5 символів в одному recv. Граючись, ви вийдете з рук; але за високого навантаження на мережу ваш код дуже швидко зламається, якщо ви не використовуєте два цикли recv - перший для визначення довжини, другий для отримання частини даних повідомлення. Огидно. Тут також ви побачите, що send не завжди вдається позбутися всього за один прохід. І, незважаючи на те, що ви прочитали це, ви зрештою розчулитесь!

В інтересах простору, побудови вашого персонажа (і збереження моєї конкурентної позиції), ці вдосконалення залишаються як вправа для читача. Переходимо до прибирання.

Двійкові дані

Цілком можливо надсилати двійкові дані через сокет. Основна проблема полягає в тому, що не всі машини використовують однакові формати для двійкових даних. Наприклад, мережевий порядок байтів є старшим байтом, із старшим байтом першим, тому 16-бітове ціле число зі значенням 1 буде двома шістнадцятковими байтами 00 01. Однак більшість поширених процесорів (x86/AMD64, ARM, RISC-V) мають байт від маленького байта з молодшим байтом першим - той самий 1 буде 01 00.

Бібліотеки сокетів містять виклики для перетворення 16- та 32-розрядних цілих чисел — ntohl, htonl, ntohs, htons, де «n» означає мережа, а «h» означає host, «s» означає short і «l». « означає довгий. Там, де мережевий порядок є порядком хостів, вони нічого не роблять, але там, де машина реверсована, байти міняються місцями належним чином.

У наші дні 64-розрядних машин представлення двійкових даних у форматі ASCII часто менше, ніж у двійковому. Це тому, що на диво більшість цілих чисел мають значення 0 або, можливо, 1. Рядок "0" складатиметься з двох байтів, тоді як повне 64-розрядне ціле число буде 8. Звичайно, це не не підходить для повідомлень фіксованої довжини. Рішення, рішення.

Відключення

Власне кажучи, ви повинні використовувати shutdown для сокета перед тим, як закрити його. вимкнення є порадою для розетки на іншому кінці. Залежно від аргументу, який ви передаєте, це може означати «Я більше не надсилаю, але я все одно вислухаю», або «Я не слухаю, ну добре!». Однак більшість бібліотек сокетів настільки звикли до того, що програмісти нехтують використанням цього правила етикету, що зазвичай close є таким самим, як shutdown(); close(). Тож у більшості ситуацій явне вимкнення не потрібне.

Один із способів ефективного використання shutdown - це HTTP-подібний обмін. Клієнт надсилає запит, а потім виконує shutdown(1). Це повідомляє серверу: «Цей клієнт завершив надсилання, але все ще може отримувати». Сервер може виявити «EOF» за отриманням 0 байтів. Він може вважати, що має повний запит. Сервер надсилає відповідь. Якщо надсилання завершилося успішно, тоді клієнт справді все ще отримував.

Python робить автоматичне завершення роботи на крок далі, і каже, що коли сокет збирає сміття, він автоматично виконає закриття, якщо це необхідно. Але покладатися на це – дуже погана звичка. Якщо ваш сокет просто зникне, не виконавши закриття, сокет на іншому кінці може зависнути нескінченно, вважаючи, що ви просто повільні. Будь ласка закрийте свої сокети, коли закінчите.

Коли сокети вмирають

Ймовірно, найгірше у використанні блокування сокетів – це те, що відбувається, коли інша сторона сильно опускається (без виконання закриття). Ваша розетка, швидше за все, зависла. TCP є надійним протоколом, і він чекатиме дуже довго, перш ніж відмовитися від з’єднання. Якщо ви використовуєте потоки, весь потік фактично мертвий. З цим мало що можна зробити. Поки ви не робите щось дурне, наприклад, утримуєте блокування під час виконання блокуючого читання, потік насправді не споживає багато ресурсів. Не намагайтеся припинити потік – одна з причин того, що потоки ефективніші за процеси, полягає в тому, що вони уникають накладних витрат, пов’язаних з автоматичною переробкою ресурсів. Іншими словами, якщо вам таки вдасться припинити потік, весь ваш процес, швидше за все, буде зіпсовано.

Неблокуючі сокети

Якщо ви зрозуміли попереднє, ви вже знаєте більшість того, що вам потрібно знати про механізми використання розеток. Ви й надалі використовуватимете ті самі дзвінки майже тими самими способами. Просто якщо ви зробите це правильно, ваш додаток буде майже навиворіт.

У Python ви використовуєте socket.setblocking(False), щоб зробити його неблокуючим. У C це складніше (з одного боку, вам потрібно буде вибирати між варіантом BSD O_NONBLOCK і майже невідмітним варіантом POSIX O_NDELAY, який повністю відрізняється від TCP_NODELAY) , але це та сама ідея. Ви робите це після створення сокета, але перед його використанням. (Насправді, якщо ви божевільні, ви можете перемикатися вперед і назад.)

Основна механічна відмінність полягає в тому, що send, recv, connect і accept можуть повернутися, не зробивши нічого. У вас є (звичайно) кілька варіантів. Можна перевірити код повернення і коди помилок і взагалі звести себе з розуму. Якщо ви мені не вірите, спробуйте якось. Ваша програма буде рости, матиме помилки та завантажуватиме процесор. Тож давайте пропустимо рішення, що призводять до смерті мозку, і зробимо це правильно.

Використовуйте вибрати.

У C кодування select є досить складним. У Python це непросто, але він досить близький до версії C, тому якщо ви розумієте select у Python, у вас не буде проблем з цим у C:

ready_to_read, ready_to_write, in_error = \
               select.select(
                  potential_readers,
                  potential_writers,
                  potential_errs,
                  timeout)

Ви передаєте select три списки: перший містить усі сокети, які ви можете спробувати прочитати; другий – усі сокети, до яких ви можете спробувати записати, а останній (зазвичай залишають порожнім) ті, які ви хочете перевірити на наявність помилок. Зверніть увагу, що сокет може входити до кількох списків. Виклик select блокується, але ви можете дати йому тайм-аут. Загалом це розумна річ — дайте гарний довгий тайм-аут (скажімо, хвилину), якщо у вас немає вагомих причин для цього.

Натомість ви отримаєте три списки. Вони містять сокети, які фактично доступні для читання, запису та помилок. Кожен із цих списків є підмножиною (можливо, порожньою) відповідного списку, який ви передали.

Якщо сокет є у вихідному списку доступних для читання, ви можете бути настільки ж близькі до впевненості, що recv для цього сокета поверне щось. Така сама ідея для списку доступного для запису. Ви зможете надіслати щось. Можливо, не все, що ви хочете, але щось краще, ніж нічого. (Насправді, будь-який досить справний сокет повернеться як доступний для запису - це просто означає, що вихідний мережевий буфер доступний.)

Якщо у вас є «серверний» сокет, додайте його до списку potencial_readers. Якщо він з’явиться у списку доступних для читання, ваш accept (майже напевно) спрацює. Якщо ви створили новий сокет для з’єднання з кимось іншим, додайте його до списку potencijal_writers. Якщо він відображається у списку доступних для запису, у вас є пристойний шанс, що він підключився.

Фактично, select може бути зручним навіть з блокуванням сокетів. Це один із способів визначити, чи будете ви блокувати – сокет повертається як читабельний, коли щось є в буферах. Однак це все ще не допомагає вирішити проблему визначення того, чи інший кінець готовий, чи він просто зайнятий чимось іншим.

Попередження про переносимість: в Unix select працює як з сокетами, так і з файлами. Не пробуйте це на Windows. У Windows select працює лише з сокетами. Також зауважте, що в C багато розширені параметри сокетів виконуються інакше у Windows. Фактично, у Windows я зазвичай використовую потоки (які дуже, дуже добре працюють) зі своїми сокетами.