«Address Already in Use» или как избежать проблем при завершении TCP соединения

Автор оригинала: Tom Fine
  • Перевод
  • Tutorial
Корректное отключение

Для корректного завершения сетевого подключения обе стороны должны послать пакеты с сигналом о завершении (FIN), которые указывают что стороны не будут больше отсылать данные, также каждая сторона должна подтвердить (ACK) получение сигнала о завершении сетевого обмена данными. FIN инициируется когда приложение вызывает метод close(), shutdown() или exit(). После завершения работы метода close() ядро переходит в режим ожидания подтверждения от второй стороны приема сигнала о завершении. Это делает возможной ситуацию когда процесс инициировавший отключение будет завершен прежде чем ядро освободит рессурсы связанные с подключением, и снова разрешит использовать порт для связывания с другим процесоом (в этом случае, при попытке использования порта мы получим исключение AddressAlreadyInUse).



На изображении:
  1. Имеется установленное соединение, состояние ESTABLISHED
  2. Клиент инициирует окончание подключения, посылает серверу сигнал о завершении подключения (FIN), переходит в состояние ожидания ответа сервера (FIN_WAIT_1)
  3. Сервер получает сигнал о завершении подключения и отправляет подтверждение (ACK), переходит в состояние ожидания завершения подключения (CLOSE_WAIT) (вызывает close())
  4. Сервер отсылает клиенту сигнал о том что успешно закрыл подключение (FIN) и пробует прочитать подтверждение клиента (ACK), после чего не дожидаясь его отключается.
  5. Теперь клиенту может придти два сигнала в разной очередности

    ACK — клиент получил подтверждение о том что сервер понял его намерение закрыть подключение
    • Клиент переходит в состояние ожидания сигнала об окончании закрытия подключения (FIN) от сервера (FIN_WAIT_2)
    • Клиент получает сигнал о закрытии подключения сервером (FIN), отправляет подтверждение (ACK), некоторое время ждет(TIME_WAIT) и отключается (ядро освобождает рессурсы) (CLOSED)


    FIN — клиент получает сигнал о закрытии подключения на стороне сервера(FIN), раньше чем подтверждение от сервера (ACK), о получении инициирующего сигнала о закрытии от клиента (FIN)
    1. Клиент отправляет подтверждение приема сигнала о том что сервер закрывает соединение, и переходит в состояние отключения (CLOSING)
    2. После отключения пробует считать сигнал подтверждения от сервера (который был отправлен сервером сразу после получения от клиента сигнала о завершении работы, пункт 2), некоторое время ожидает(TIME_WAIT) и ядро освобождает рессурсы (CLOSING).



На рисунке показаны все воможные состояния, которые могут быть во время корректного завершения, в зависимости от порядка получения пакетов FIN и ACK от удаленной стороны. Обратите внимание, если вы инициировали завершение подключения (левая половина рисунка), то другая сторона не будет ожидать подтверждения получения вами пакета FIN (правая половина рисунка). Сотояние TIME_WAIT требуется на случай если подтверждение (ACK) которое вы отправили не было получено на другой стороне, или на случай появления ложных пакетов по какой-то причине. Я не знаю почему на стороне сервера не сделали состояние TIME_WAIT, хотя если клиент инициирует закрытие, это безусловно и не должно требовать ожидания. Состояние TIME_WAIT может удерживать порт в течение нескольких минут после завершения процесса. Время удержания варьируется в зависимости от операционной системы, в некоторых операционных системах оно является динамическим, стандартные значения лежат в диапазоне от 1 до 4 минут.

Если обе стороны успеют инициировать сигнал завершения, раньше чем получат его от другой стороны, то обе стороны будут вынуждены пройти через ожидание (TIME_WAIT).

Корректное отключение слушающей стороны

Слушающий сокет может быть закрыт немедленно, при отсутствии входящих подключений, его состояние переходит сразу в CLOSED. При наличии входящих подключений, будет произведен переход к FIN_WAIT_1 и затем к TIME_WAIT.

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

Непредвиденное отключение удаленной стороны

При внезапном отключении сервера, локальная сторона инициирует закрытие соединения, и в этом случае TIME_WAIT неизбежен. Если удаленная сторона исчезает из-за сбоя сети или перезагрузки машины (редкие случаи), локальный порт будет оставаться привязанным вплоть до истечения таймаута состояния TIME_WAIT. Хуже того, некоторые старые операционные системы, не реализуют таймаут для состояния FIN_WAIT_2, и могут оставаться в нем бесконечно долго, в этом случае спасти может только перезагрузка системы.

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

Способы избежания проблем


Опция SO_REUSEADDR

Можно использовать метод setsockopt(), для установки опции SO_REUSEADDR, что позволит создавать привязку к порту даже если он еще находится в состоянии TIME_WAIT (привязка к порту будет разрешена только для одного процесса). Это самый простой и эффективный метод избежать сообщения «address already in use».

Но, как ни странно, использование опции SO_REUSEADDR, может привести к более трудноотлавливаемым ошибкам чем «address already in use». SO_REUSEADDR позволяет использовать порт застрявший в TIME_WAIT, но вы все еще сможете использовать этот порт в том процессе, в котором он привязан изначально.

WHAT?

Предположим я использую локальный порт 1010 и подключаюсь на порт 300 сервера foobar.com, затем клиент отключается и порт переходит в состояние TIME_WAIT, и я могу использовать этот порт (1010) в любом подключении за исключением подключения к foobar.com на порт 300.
Ситуация в которой это может вызвать проблему может быть такой: моя программа пытается найти зарезервированный локальный порт(<1024) для привязки, чтобы подключиться к службе которая требует зарезервированный порт, и если я буду использовать опцию SO_REUSEADDR, то при каждом запуске программы на моей машине я буду получать тот же зарезервированный порт, даже если он висит в TIME_WAIT, и могу получить «Address already in use», в том месте где порт был использован в последний раз. В этом случае нужно отказаться от использования опции SO_REUSEADDR.

Некоторые не любят использовать SO_REUSEADDR, т.к. эта опция имеет проблемы с безопасностью. В некоторых операционных системах эта опция может позволить разным процессам использовать один и тот же порт одновременно. И это проблема, потому что большинство серверов привязываются к порту не используя конкретный адрес, вместо этого они используют INADDR_ANY (команда netstat отобразит их как *.8080). Таким образом, если сервер связывается с адресом *.8080, то другой процесс, от другого пользователя локальной машины, может подключиться к адресу local_machine.8080 (и намерения его могут быть совсем не хорошими), и перехватывать все ваши подключения, т.к. он указал более конкретный адрес. Эта проблема проявляется только на многопользовательских системах, не имеющих ограничений для учетных записей, и это не является уязвимостью которая доступна снаружи локальной машины, ее можно легко избежать используя привязку к конкретному адресу машины (не используя INADDR_ANY).

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

Клиент отключается первым

Глядя на рисунок выше, мы видим, что состояния TIME_WAIT можно избежать когда закрытие инициируется на удаленной стороне, а значит проблем можно избежать, если сервер позволяет клиенту инициировать отключение первым. Для этого можно построить архитектуру пользовательского протокола таким образом, что клиент знает когда ему нужно инициировать закрытие. Сервер может произвести безопасное отключение получив команду EOF от клиента, однако нам все равно придется установить таймаут ожидания отключения клиента, чтобы тот смог корректно завершить работу. Почти всегда достаточно подождать несколько секунд, пока соединение с сервером не будет корректно завершено.

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

К примеру, я написал скрипт который использует remote shell(rsh), для общения со всеми машинами моей сети, и он выполняет работу параллельно, постоянно используя несколько открытых соединения. Для rsh доступно меньше 1024 портов. Сначала я использовал команду «rsh -n», которая вызывает отключение локальной стороны в первую очередь. После нескольких тестов все свободные порты меньше 1024, оказались в состоянии TIME_WAIT, и процесс остановился. Удаление опции -n приводит к инициированию отключения на удаленной стороне, и проблема TIME_WAIT устраняется, однако это может привести rsh к зависанию в ожидании входаящего подключения. И если вы закрываете входящее подключение локально, порт снова окажется в состояние TIME_WAIT. В конечном счете я просто отказался от использования rsh и написал свою реализацию на perl (текущую версию можно скачать тут)

Уменьшение таймаута

Если, по какой-то причине, ни один из изложенных вариантов вам не подходит, есть возможность сократить таймаут состояния TIME_WAIT. Возможность и реализация такой операции зависит от операционной системы которую вы используете. Стоить помнить, что слишком короткий таймаут может иметь негативные последствия, в частности при потере пакетов или в перегруженных сетях.
  • +25
  • 21,6k
  • 3
Поддержать автора
Поделиться публикацией

Комментарии 3

    0
    Для общения со всеми машинами сети хорошо подходит pdsh
      0
      Я в своём софте обычно ставлю таймер и если после 200...500 мС после закрытия сокета, он ещё не закрыт, то делаю аборт, что гарантировано и мгновенно освобождает сокет. Да, приходится хранить список закрытых сокетов и периодически по нему проходить, чтобы закрыть зависшие. Зато никаких проблем. Принудительное закрытие сокета зависит от ОС/языка/библиотеки. У некоторых явно такая функция есть, а у некоторых приходится сначала опции сокета выставлять (всякие там linger off и тому подобное), а потом делать повторный close, который приведёт к мгновенному закрытию.
        +1
        Да и, кстати, для открытия сокетов такой же подход — если accept не привел к открытию сокета за установленный таймаут, то сокет принудительно закрывается. Стандартные таймауты очень большие и сервер легко зафлудить. Когда писал нагруженный сервер, то там вообще для полу-открытых / полу-закрытых сокетов таймаут зависел от количества ожидающих этих самых сокетов. Если много таких сокетов, то таймаут уменьшался вплоть до 50 мС.

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

      Самое читаемое