Чтобы эффективно писать приложения, общающиеся через сокеты, мне пришлось понять кое-что, чего мне не сообщал никто и что не написано ни в какой документации.

Если у вас есть опыт написания приложения с использованием сокетов, то вся эта информация должна быть для вас очевидной. Она неочевидна для меня как абсолютного новичка, поэтому я попытаюсь как можно подробнее объяснить это, чтобы ускорить процесс освоения сокетов для других новичков.

Надёжность TCP и надёжность приложений


TCP гарантирует надёжность с точки зрения потока; он не гарантирует, что каждый send() будет принят (recv()) соединением. Это отличие очень важно. Чтобы понять это, мне понадобилось время.

Фундаментальная задача, которую я пытался решить, — это чистая обработка нарушения связности сети, то есть когда машина А и машина Б оказываются полностью разъединены. Разумеется, TCP не может гарантировать, что сообщения будут доставлены, если машина выключена или отсоединена от сети. TCP какое-то время будет хранить данные в своём буфере отправки, но в конечном итоге сбросит данные по таймауту. Я уверен, что там происходит ещё и что-то ещё, но с точки зрения приложения это всё, что мне нужно знать.

Здесь важно то, что если я отправлю при помощи send() сообщение, нет никаких гарантий, что другая машина получит его при помощи recv(), если внезапно отключится от сети. Это опять-таки может быть очевидно для опытного сетевого программиста, но для абсолютного новичка это было непривычно. Когда я прочитал, что TCP гарантирует надёжную доставку, то ошибочно посчитал, что, например, send() блокируется и возвращает успешное выполнение только после успешного получения сообщения на стороне recv().

Такую send() можно написать, и она будет гарантировать на уровне приложения, что сообщения точно попадут к получающему приложению, и что они считаются получающим приложением. Однако скорость взаимодействия приложения существенно упадёт, потому что каждый вызов send() будет заставлять приложение ожидать подтверждения того, что другое приложение получило сообщение.

Вместо этого мы будем надеяться, что другое приложение по-прежнему подключено и будем отправлять один или несколько вызовов send() в буфер, который за нас обрабатывает TCP. Затем TCP сделает всё возможное, чтобы передать данные другому приложению, но в случае разъединения мы, по сути, потеряем их полностью.

Надёжность приложений


Разработчики приложений должны решить, как их приложение реагирует на неожиданные разъединения. Нужно решить, насколько сильно вы будете пытаться узнать, действительно ли получающее приложение получило каждый бит передаваемых данных.

Противоречит интуитивному пониманию здесь то, что для этого нужно будет реализовать подтверждающие сообщения, разметку сообщений идентификаторами, создание буфера и системы для повторной отправки сообщений и/или, возможно, даже таймауты, связанные с каждым сообщением. По описанию очень похоже на TCP, не так ли? Разница в том, что вы не имеете дело с ненадёжностью UDP, которую приходится обрабатывать TCP. Вы имеете дело с ненадёжностью машин в сети, в общем случае включенных и находящихся онлайн.

Возможно, вам покажется сложной реализация всех этих вещей, однако они позволят вашему приложению иметь интересные способности:

  • Можно хранить пакеты данных на жёстком диске, тогда если возникнет сбой приложения или машины, то вы всё равно можете попытаться отправить эти данные, когда всё придёт в норму.
  • Можно допустить разъединения во время длительных операций; когда две машины наконец восстановят связь, можно будет передать результаты операции.
  • Можно определять насколько важно подтверждать доставку в каждом конкретном случае. Например, можно прикладывать большие усилия к тому, чтобы подтвердить завершение длительной операции, но смириться с утерей данных, сообщающих о степени выполнения операции. Из-за первого пользователь может подумать, что ему придётся перезапустить потециально требовательную операцию, а из-за второго всего лишь будет немного дёргаться полоса прогресса.

Возможно, вам не требуется надёжность на уровне приложения. Многие приложения просто завершают работу, если происходит отключение в неожиданный момент. Мне же нужно было, чтобы мои приложения спокойно продолжали работу, пытаясь восстановить подключение. Это означало, что мне требуется отдельный цикл переподключения, который будет какое-то время спать, а затем пытаться переподключиться и если это удастся, продолжить обычную работу.

Я пока не реализовал надёжность уровня приложения в своём приложении, потому что меня не особо волнует, что какие-то данные не будут получены. Однако это решение следует принимать в каждом конкретном случае. Например, если я выполняю сборку, которая занимает два часа, но сообщение «сборка выполнена» будет утеряло из-за отключения, мне может потребоваться ещё два часа на ненужное повторное выполнение сборки. Если бы у меня была надёжность на уровне приложения, то я знал бы, что сборка успешно завершена. Однако за это пришлось бы заплатить увеличением времени разработки и сложности системы, но, вероятно, в каких-то ситуациях оно того стоит.

recv() и SIGPIPE


Поначалу меня очень сбивало с толку то, что мне нужно было пробовать безуспешно выполнить recv() из сокета только для того, чтобы понять, что соединение больше неактивно. Я ожидал, что можно будет вызывать, например, isconnected() для сокетов после того, как accept() сообщит, что с ним что-то произошло. Теперь мне кажется логичным то, что лучше безуспешно выполнить recv(), чтобы получить информацию о разъединении. В противном случае я мог бы ошибочно предположить, что если я вызову isconnected(), то гарантированно буду иметь хороший recv(). Благодаря тому, что разъединение связано с безуспешным recv(), я знаю, что мне нужно обрабатывать потенциальные разъединения при любом вызове recv(). То же самое относится и к send().

В Linux мне также нужно отключить оповещения при recv(), чтобы я мог обрабатывать ошибку подключения линейно, а не регистрировать для этого обработчик сигналов. Я решил добавить к send() и recv() MSG_NOSIGNAL, и обрабатывать потенциальные ошибки разъединения при каждом вызове. Возможно, это не так характерно для Linux, где обработчик сигнала может быть более общим, однако это даёт мне гораздо больше контроля при разработке приложений. Также это лучше работает при портировании в Windows, которая не использует сигналы для сообщений об разъединениях.

Не используйте с сокетами API Linux, где «всё — это файл»


Linux позволяет работать с сокетами так, как будто это дескрипторы файлов. Это удобно, потому что можно реализовать в приложение поддержку потоковой передачи в/из файла и сокета при помощи одного кода.

Однако Windows не работает с сокетами так же, как с файлами. Если вы хотите использовать нативные Windows API, то нужно применять специализированные функции сокетов: send(), recv(), closesocket() и так далее.

Я считаю, что абстракцию Linux не стоит использовать с точки зрения надёжности. Обработка уже несуществующего файла и разъединения сокета, скорее всего, будет очень разной. Я уверен, что у кого-то найдутся возражения, и что кому-то нужно писать приложения так, чтобы они обрабатывались одинаково. Мне важна качественная поддержка Windows, поэтому даже если я и ошибаюсь, мои руки всё равно связаны.

Разумеется, вы можете написать для них собственный уровень абстракции, но повторюсь, факторы надёжности и производительности файлов и сокетов сильно отличаются. Мне кажется, что если можно работать с ними по-разному, то это и нужно делать, хотя бы для понимания и контроля. Можно ещё задать вопрос: как часто в пишете приложения, которым нужно принимать и файлы, и сокеты? По моему опыту подобные вещи случаются в подавляющем меньшинстве случаев. Обычно я знаю, куда передаются мои данные, и хочу это знать, чтобы принимать более осознанные решения о производительности.

Главный цикл select() приложения


Приложение знает, когда ему нужно выполнить запись в сокет. Но оно не всегда знает, когда ему нужно выполнить чтение из сокета. Это значит, что я должен добавлять сокеты в список записи select() только тогда, когда у меня есть готовое к отправке сообщение. Следует всегда добавлять все сокеты в список чтения select(), если я хочу, чтобы приложение было достаточно гибким для получения сообщений в любой момент.

Если для одной операции есть множество передач туда и обратно, то я всё равно могу это закодировать, однако код становится менее гибким. Проще попытаться обойтись одной отправкой, а затем обрабатывать получение в главном цикле select. Для этого может потребоваться хранение состояния в метаданных, связанных с каждым соединением, или добавления в сообщения идентификаторов, чтобы связывать их с другим состоянием.

Если select() будет только выполнять отправку или получения в каждом сокете, то это улучшит работу с несколькими соединениями. Например, можно отправить команду начать длительную операцию на другой машине, затем получить сообщения от других соединений, пока эта длительная операция выполняется. В противном случае придётся выносить код отправки и получения этой длительной операции в другой поток или что-то иное, чтобы можно было обрабатывать другие подключения.

Это вызывает меньше проблем, если вы, например, получаете запрос, а затем можете быстро составить и отправить ответ. В таких случаях можно просто получать и отправлять ту же итерацию select() по этому соединению, чтобы ничего не усложнять. Если код получающего приложения имеет схожую структуру, то оно тоже может решать, нужно ли выполнять получение сразу после отправки или вернуться в свой цикл select().

Сокеты — это всё равно круто


Мне понадобилось довольно много времени, чтобы понять, что необходимо для написания приложений, эффективно использующих сокеты. Заплатив эту цену, я чувствую, как будто обрёл новую сверхспособность.

Похожие ощущения у меня были, когда я узнал, как выполнять подпроцессы, и когда я научился загружать код динамически1. Подобные вещи ломают преграды и открывают двери к новым восхитительным возможностям.

И хотя я потратил гораздо больше времени, чем ожидалось, на создание проекта, для которого мне пришлось изучать сокеты, я рад, что это сделал.



1. Если вы не изучили их, то это определённо стоит сделать. Вот функции, которые можно поискать:

Для запуска подпроцессов (sub-process):

Платформа Функция
Windows CreateProcess
Linux fork, exec

Для динамической загрузки:

Платформа Функция
Windows LoadLibrary, GetProcAddress
Linux dlopen, dlsym

Если вы хотите загружать код без использования динамического связывания, то вам стоит изучить виртуальную память и mmap() (Linux) или VirtualAlloc() (Windows).

Благодаря использованию выполнения подпроцессов и динамической загрузки вы можете сделать, например, так, чтобы приложения вызывали компилятор для сборки динамической библиотеки, а затем сразу же загружали эту библиотеку в то же приложение. Это единственный способ, при помощи которого пользователи смогут изменять и расширять приложение в процессе его работы.

Комментарии (18)


  1. screwer
    02.09.2022 18:24
    +9

    Однако Windows не работает с сокетами так же, как с файлами

    Конечно же работает. В Windows по-умлочанию для сокета возвращается точно такой же дескриптор ядра.

    Надо понимать две вещи:

    1. в Linux вызов write это системный вызов. В Windows системным вызовом будет ZwWriteFile, на худой конец его win32 обёртка WrteFile.

    2. в Windiws существует концепция Layered Socket Providers (LSP). Объединяющиеся в цепочки, через которые последовательно проходят вызовы сетевого API. Базовый провайдер возвращает описатели. Но вполне возможна ситуация, когда сторонний провайдер в цепочке решит вернуть псевдо-описатель, вместо настоящего, ядерного. И будет перекодировать их "на лету". Разумеется, с таким псевдо-описателем работать напрямую уже не получится, только проходя через всю цепочку. Выходом может быть просмотр цепочки и работа с нижним уровнем, минуя все установленные фильтры.

    Далее, select это тормозно. Для производительных приложений надо использовать epoll в ,Linux и completion ports в windiows.

    Если вы хотите загружать код без использования динамического связывания,

    Очень плохой совет. Внезапное появление executable памяти в процессе это красная тряпка для средств защиты. Кроме того, приложению может быть вообще запрещено самому выделять исполняемую память (и/или модифицировать атрибуты страниц). Например SELinux отлично различает кейсы по загрузке динамических библиотек, выделению исполняемой памяти, изменению атрибутов страниц, причем для стека отдельно.


    1. lieff
      02.09.2022 20:43

      Про select дополню что на винде он тоже не рекомендуется, если дескрипторов может быть много (хотя их и можно расширить на этапе компиляции). Помимо completion ports есть специальные WSAEnumNetworkEvents для этого.


    1. Nils2
      05.09.2022 14:21

      Epoll уже можно заменить на io_uring.


  1. Biga
    02.09.2022 19:32
    +12

    Вот, что ещё нужно знать о сокетах:

    Если recv() не возвращает ошибку, и соединение выглядит как живое, это ещё не значит, что оно живое. Ни на какие системные таймауты полагаться нельзя. Нужно самостоятельно слать "пинги" и считать соединение потерянным, если данные не приходят дольше определённого порога времени.

    Если одна сторона закрывает соединение, это не значит, что другая сторона об этом узнает. То есть реально может произойти так, что одна сторона корректно полностью завершила соединение, закрыла сокет, всё что нужно, а другая сторона при этом считает, что соединение ещё живо, сколь угодно долгое время. Опять же, тут надежда только на "пинги".

    Если вы посылаете набор байтов, то на другую сторону они придут не одним куском, а иногда будут разбиты на куски произвольного размера. Всегда стоит рассчитывать на получение только части данных за один recv().


    1. MrKirushko
      02.09.2022 21:22

      Ну, системные таймауты (по крайней мере в Linux) - вещь вполне себе рабочая и полагаться на них вполне можно. Надо только понимать что информация об ошибке к вам спокойно может прийти только через 5 минут или даже более, это - вполне нормальные величины по умолчанию, и такие оповещения - не самые приоритетные. Если для вашего применения это неприемлемо но автоматический контроль целостности установленного соединения важен - тогда да, действительно надо это все иметь в самом вашем протоколе (том что будет поверх TCP) и учитывать. В большинстве же простых случаев это не обязательно.


  1. tsvetkovpa
    02.09.2022 20:25
    +2

    Еще стоит упопянуть особенность TCP которая не очевидна новичкам. Количество вызовов send() и recv() могут не совпадать.

    Два коротких send() можно вычитать одним recv(), и наоборот большой send() придется вычитывать несколькими recv().


    1. Sazonov
      02.09.2022 20:31
      +1

      Я недавно по привычке решил что это же правило работает и для вебсокетов. Но там таких проблем нет, на каждый send будет один receive


      1. me21
        03.09.2022 21:33
        +1

        А это потому что вебсокеты - прикладной протокол поверх TCP.


  1. Moraiatw
    02.09.2022 22:07
    -1

    ошибочно посчитал, что, например, send() блокируется и возвращает успешное выполнение только после успешного получения сообщения на стороне recv()

    Зависит от типа сокета - синхронный или асинхронный.

    Синхронный вполне себе блокирует.


    1. thevlad
      03.09.2022 00:29
      +4

      Синхронный сокет блокируется на отправку только, если переполнен буфер внутри ОС, никакой связи блокировки с recv() на другом конце там нет.


  1. mbait
    03.09.2022 04:40
    +6

    Уже начинают напрягать такие статьи. То, что send() не гарантирует recv() так же очевидно, как и то, что если я отправлю посылку почтой России, то не факт, что на том конце её примут. Ну то есть тут не просто не нужно быть "опытным сетевым программистом", а достаточно всего лишь дружить с логикой. Если это неочевидно, то, возможно, стоит рассмотреть другие области программирования.


  1. random_villain
    03.09.2022 09:27

    Зашёл почитать про LGA 1700
    :(


  1. cherv2
    03.09.2022 12:25

    Не совсем понятно почему приложение не знает когда ему прочитать из сокета? Можно просто в бекгрунд очереди запустить цикл, который ждёт чтение из сокета, вот простейший класс с этой идеей, который читает с клавиатуры, отправляет на сокет, читает из сокета - выводит в терминал.

    public struct ChatClientString {
        
        public init() {}
        
        public func work() {
            print("Представьтесь:")
            let message = readLine()
            let name = message ?? "User"
            
            do {
                let dog = try Leash()
            
                let queue = DispatchQueue(label: "keyboard listen")
                queue.async {
                    keyinput: while true {
                        let message = readLine()
                        guard let line = message else { continue keyinput }
                        do { try dog.clientSocket.write(from: name + ": " + line) }
                        catch let error { print(error) }
                    }
                }
            
                while true {
                    let incom = try dog.clientSocket.readString()
                    print("from sever: \(incom ?? "")")
                }
            
            } catch let error {
                print(error)
            }
        }
    }


  1. vadimr
    03.09.2022 19:46

    Фундаментальная задача, которую я пытался решить, — это чистая обработка нарушения связности сети, то есть когда машина А и машина Б оказываются полностью разъединены.

    Для решения такой задачи лучше использовать UDP. И ещё в ARP может понадобиться немножко залезть.

    Такую send() можно написать, и она будет гарантировать на уровне приложения, что сообщения точно попадут к получающему приложению, и что они считаются получающим приложением. Однако скорость взаимодействия приложения существенно упадёт, потому что каждый вызов send() будет заставлять приложение ожидать подтверждения того, что другое приложение получило сообщение.

    Этого недостаточно, потому что потеряться может само подтверждение. Тут нужен двухфазный коммит.


  1. mvv-rus
    04.09.2022 05:36
    +1

    Однако Windows не работает с сокетами так же, как с файлами. Если вы хотите использовать нативные Windows API, то нужно применять специализированные функции сокетов: send(), recv(), closesocket() и так далее.

    Странные какие-то вещи вы пишете. Потому что все поставляемые MS провайдеры базовых сервисов (TCP, UDP, RAW для IP и IPv6) используют в качестве описателя (descriptor) сокета Handle файловой системы (IFS), то есть для системы эти сокеты — файлы, и с ними можно работать как с файлами, используя системные функции типа ReadFile/WriteFile. Это легко видеть, если посмотреть в их записях каталога (командой netsh Winsock show catalog) содержимое поля Служебные флаги: оно содержит флаг XP1_IFS_HANDLES (0x00020000)


    1. cherv2
      04.09.2022 15:11

      а в винде есть какая нибудь функция, которая ждёт что в файл что нибудь запишут, чтобы тут же прочитать, по типу let i = readLine() с клавиатуры, только из файла?


      1. mvv-rus
        04.09.2022 20:05

        Дык, ReadFile в простейшей форме себя именно так и ведет: если данных для чтения нет, то она блокируется на ожидании момента, когда данные появятся. А более сложные формы ReadFile могут вместо блокировки запукать асинхронную операцию чтения с ожиданием. В Windows для этого не надо никаких особых условий — там изначально архитектура ввода-вывода асинхронная, и даже синхроные операции чтения/записи реализованы как надстройка над асинхронными.


  1. Siegurd1
    05.09.2022 14:18

    Сетевое программирование - это целая область, со своими "механиками". Новичкам может показаться, что мол "я сейчас возьму либы и примеры и начну слать байты по сети, а обо всем остальном позаботятся высокоуровневые интерфейсы, OSI и т.д.". Но суровая действительность такова, что Pipe - это не просто абстрактное название. Это почти буквально труба(трубопровод) и никто не знает, что там с этим трубопроводом происходит на пути TCP/UDB пакета. Он может оборваться и ни клиент ни сервер об этом не узнают "автоматически". Поэтому иногда к сетевому программированию (особенно для подвижных средств связи с изменяемой пропускной способностью канала) я подхожу как к программированию UART/RS232/485.