Чтобы эффективно писать приложения, общающиеся через сокеты, мне пришлось понять кое-что, чего мне не сообщал никто и что не написано ни в какой документации.
Если у вас есть опыт написания приложения с использованием сокетов, то вся эта информация должна быть для вас очевидной. Она неочевидна для меня как абсолютного новичка, поэтому я попытаюсь как можно подробнее объяснить это, чтобы ускорить процесс освоения сокетов для других новичков.
Надёжность 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).
Благодаря использованию выполнения подпроцессов и динамической загрузки вы можете сделать, например, так, чтобы приложения вызывали компилятор для сборки динамической библиотеки, а затем сразу же загружали эту библиотеку в то же приложение. Это единственный способ, при помощи которого пользователи смогут изменять и расширять приложение в процессе его работы.
Автор:
boris-the-blade