WinSock : муки выбора
От: Ignoramus  
Дата: 06.04.03 21:03
Оценка:
Господа!

Есть вопросы по WinSock.

1) Какой тип реализации сервера предпочтителен при большом (тысячи – десятки тысяч) и при малом (десятки) предполагаемом количестве клиентских подключений? (Варианты: multi-thread blocking synchronous или single-thread non-blocking asynchronous)? Т.е. как сделать, чтобы сервер не заглючил при большом кол-ве клиентов и как не сделать слишком навороченный сервер если клиентов раз два и обчелся?

2) Какой тип реализации клиента (blocking synchronous, non-blocking asynchronous) самый практичный с точки зрения производительности и простоты реализации?

Как в клиенте, так и в сервере я склоняюсь к асинхронному single-thread non-blocking (т.е. путем указания окна с помощью WSAAsyncSelect), как наиболее простому, универсальному и функциональному, насколько это обосновано?

3) В связи с этим вопрос по использованию WSAAsyncSelect: Не порекомендуете ли уже отработанную схему обработки сообщений FD_CONNECT, FD_ACCEPT, FD_READ, FD_WRITE, FD_CLOSE в клиенте и сервере (если у обоих asynchronous single-thread non-blocking, протокол TCP), в правильной последовательности, с правильными функциями и обработкой ошибок? Я не уверен, что до конца правильно понял то, что описано в MSDN, а проверить все возможные ситуации нет возможности (например, пакеты данных маленькие, но нельзя рассчитывать, что они будут обязательно приняты за один recv(), в этом случае нужно пододать следующего FD_READ, и т.д. и т.п.). К тому же я слышал, что у какой-то версии винды без сервис-пака есть баг с FD_WRITE. В общем, все это меня малость пугает .

Спасибо.
Re: WinSock : муки выбора
От: TepMuHyc  
Дата: 07.04.03 13:45
Оценка: 5 (1) -1
Здравствуйте, Ignoramus, Вы писали:

I>1) Какой тип реализации сервера предпочтителен

Multithreadded + blocking проще в реализации, но реально не вынесет больше 500 сеансов.
Если требуется больше сеансов, то есть смысл завести один или несколько дочерних процессов и передавать новые сокеты туда... Такой "гибридный" подход использует Apache.

Этот сопособ хорош тем, что он прост как 5 копеек — 1 поток == 1 сеанс.
Все проблемы конкурентности отпадают сами собой.

Single-thread + non-blocking должен быть более производителен, но только при очень и очень аккуратном и грамотном программировании. Т.к. все расчеты ведутся в одном потоке, то программист сам должен писать "планировщик сеансов" (задача д-а-а-леко нетривиальная и чреватая всякими чудесами). И, так как сеанс постоянно прерывается другими сеансами, то надо определять специальную структуру в которой его состояние и логику работы с ней.

Если проект большой и ТЗ постоянно меняется, этот подход может привести к тому что код станет совершенно невозможно поддерживать. В общем, если на сервер выдано толковое ТЗ (которое не будет слишком сильно меняться "в процессе") и команда толковыя и аккуратная и, (самое главное) небольшая, то есть смысл пойти по этому пути.
____________________
God obviously didn't debug, hasn't done any maintenance, and no documentation can be found. Truly amateur work.
Re[2]: WinSock : муки выбора
От: Ignoramus  
Дата: 07.04.03 19:49
Оценка:
Здравствуйте, TepMuHyc, Вы писали:

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


Ок, в моем случае последние условия выполняются .

Я с Вами согласен. У multi-thread реализации есть еще недостаток – потоки нельзя корректно прервать, если будет нужно, по крайней мере это не так просто. Синхронизация потоков также не тривиальная задача даже в сравнении с «планировщиком сеансов», если сервер/клиент не совсем уж примитивный. Про многопроцессное приложение я вообще молчу. А передача сообщений окну привычна и удобна для Windows-программистов. Если это решение к тому же и более scalable, то это именно то, что мне нужно.

На форумах я часто встречал вопросы от разных участников по примерам реализации асинхронных сокетов. Особенно проблематичным, в частности, оказывается порядок поступления сообщений в окно и их правильной обработки. Предлагаю обсудить предлагаемый ниже пример реализации. Спорные моменты отмечены фразами «я надеюсь» . Хотелось бы услышать комментарии профессионалов .

Итак, сервер и клиент используют non-blocking asynchronous single-thread сокеты, протокол TCP.

Общение между клиентом и сервером заключается в инициации запроса клиентом серверу и получении некоторого ответа, после чего сеанс связи завершается.

Пакеты данных состоят из заголовка и тела. Общий размер пакета данных известен и содержится в заголовке каждого пакета.

Действительно, в случае использования WSAAsyncSelect требуется организация «планировщика сеансов», который, как мне видится, будет представлять собой список, каждый элемент которого будет включать сокет, текущий режим сокета (прием/передача), количество уже принятых/переданных байт, и буфер, который первоначально имеет размер заголовка пакета.

Для сервера:

Сервер вызывает listen.

При появление incoming соединения возникает FD_ACCEPT. Вызываю accept(), который создает новый сокет. Создаю элемент списка, для которого выделяю буфер размером с заголовок, инициализирую указатель в буфере, устанавливаю режим приема. Заношу элемент в список.

После установления соединения вначале возникает FD_READ (а не FD_WRITE, я надеюсь!). По сокету идентифицируется элемент списка «планировщика сеансов». Вначале recv заполняет буфер заголовка. Если это не произойдет за один раз (что маловероятно, но все же), жду новых FD_READ и вызываю recv, передвигая указатель в буфере, пока весь заголовок не будет прочитан. Затем увеличиваю буфер до размера, указанного в заголовке пакета и продолжаю ловить FD_READ и вызывать recv, пока он не заполнится. При всех вызовах recv не должно возникнуть WSAEWOULDBLOCK, т.к. recv вызывается только один раз по FD_READ. Если последний кусок принят с WSAEMSGSIZE (впрочем, равно как и с любой другой ошибкой), то это неправильный пакет и его нужно выбросить, а сокет закрыть и удалить из списка. Когда весь пакет принят, он передается приложению, которое его обрабатывает и генерирует ответный пакет. Режим для данного сокета переключается на передачу, указатель в буфере сбрасывается.

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

Приходит «правильный» FD_WRITE (когда сокет в режиме передачи). Идентифицирую элемент «планировщика сеансов» по сокету. Вызываю send(), передавая весь буфер с пакетом. Опять же, WSAEWOULDBLOCK не должен возникнуть (я надеюсь), так как передача идет в момент FD_WRITE, т.е. когда можно передавать прямо сейчас. Единственное что может случиться – не все байты пакета могут быть переданы за один раз. Тогда нужно передвинуть указатель в буфере и ждать новых FD_WRITE, вызывая по ним send пока весь пакет не будет передан. Когда это произойдет, вызываю shutdown(SD_SEND) для «грациозного» закрытия сокета, сразу после этого closesocket.

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

Работа клиента во многом напоминает работу сервера кроме следующих моментов:

Вместо listen() вызывается connect()

Вместо FD_ACCEPT приходит FD_CONNECT, по которому пополняется список «планировщика сеансов».

Вместо FD_READ первым приходит FD_WRITE (я надеюсь). Передача осуществляется так же, как у сервера.

Когда передача закончена, вызывается shutdown(SD_SEND).

Приходит FD_READ. Прием осуществляется так же, как у сервера.

Если FD_READ или FD_WRITE приходят невовремя, сокет закрывается и удаляется из списка.

Приходит FD_CLOSE, вызываю closesocket. Если FD_CLOSE приходит невовремя, сокет закрывается и удаляется из списка.

Оставшиеся вопросы:
1) Есть ли гарантия, что все сообщения будут возникать именно в указанном порядке или последовательность может быть другая?
2) Правильная ли последовательность действий при обработке сообщений?
3) Off-topic: Как быть, если вызов, к примеру, recv два раза на одно событие FD_READ приводит к WSAEWOULDBLOCK? В следующий раз, когда придет FD_READ, по причине последнего recv, нужно ли вызывать recv еще раз? Если нет, то как определить сколько байт было принято?

Заранее спасибо.
Re[3]: WinSock : муки выбора
От: TepMuHyc  
Дата: 07.04.03 20:58
Оценка:
Здравствуйте, Ignoramus, Вы писали:

I>После установления соединения вначале возникает FD_READ

Ты сначала должен вызывать WSAAsyncSelect с флагом FD_READ и ждать пока "тебя не позовут" —
т.е. покуда не прибудут данные от клиента.

I>(а не FD_WRITE, я надеюсь!).

Зря надеешься — FD_WRITE возникнет СРАЗУ ЖЕ — так как новый сокет УЖЕ ГОТОВ к отправке данных.
Ты не забывай — сокет он дуплексный, то есть он может передавать данные в обе стороны одновременно.

Короче, это будет выглядеть примерно так:
LRESULT YourWndProc(.........) {
case WM_ASYNCSELECT:
   switch( WSAGETSELECTEVENT(lParam) )
   {
   case FD_ACCEPT://создание нового сеанса.
      {
          if( WSAGETSELECTERROR(lParam) != 0 )
          {
              //глобальная паника - серверный сокет сдох!!!!
          }
          if( wParam != listeningSocket )
          {
              //глобальная паника - пришел непонятный ACCEPT!!!!
          }
          SOCKET newSocket = accept(listeningSocket, .......);
          SocketData* sockData = CreateControlStructureForSocket(newSocket);//создать управляющую структуру для сокета
          WSAAsyncSelect(newSocket, hYourWnd, WM_ASYNCSELECT, FD_READ);//ждем "привет" от клиента
          break;
      }
   case FD_READ://читаем данные из сокета
      {
          if( WSAGETSELECTERROR(lParam) != 0 )
          {
              //какой-то сеанс загнулся - убить управляющую структуру сокета
              break;
          }
          SocketData* sockData = GetControlStructureForSocket(wParam);
          if( sockData == NULL ) 
              //глобальная паника - сокет без управляющей структуры!!!!
 
          char arrDataChunk[255];
          int size = recv(wParam, arrDataChunk, 255, 0);
          sockData->AddDataChunk(arrDataChunk, size);

          if( sockData->EndOfDataDetected() )
          {
              //засекли что клиент закончил "посылку". 
              //значит надо отвечать...
              WSAAsyncSelect(wParam, hYourWnd, WM_ASYNCSELECT, FD_WRITE); 
              sockData->AddDataToSend("Hello, poor bastard!");
          } else {
              //ответ клиента пришел не полностью - ждем дальше...
              WSAAsyncSelect(newSocket, hYourWnd, WM_ASYNCSELECT, FD_READ);
          }
          break;
      }
   case FD_WRITE://пишем данные в сокет
      {
          if( WSAGETSELECTERROR(lParam) != 0 )
          {
              //какой-то сеанс загнулся - убить управляющую структуру сокета
              break;
          }
          SocketData* sockData = GetControlStructureForSocket(wParam);
          if( sockData == NULL ) 
              //глобальная паника - сокет без управляющей структуры!!!!
          
          int size = sockData->GetDataToSendLength();
          if( size > 0 )
          {
              // есть что посылать...
              send(wParam, sockData->GetDataBuffer(), size, 0);
              WSAAsyncSelect(wParam, hYourWnd, WM_ASYNCSELECT, FD_WRITE);
          } else {
              //все данные ушли - переходим в режим чтения
              WSAAsyncSelect(newSocket, hYourWnd, WM_ASYNCSELECT, FD_READ);          }
          }
          break;
       }
   case FD_CLOSE:
       //сеанс закрылся - прибить управляющую структуру сокета
       //и закрыть сокет

   }
}


I>3) Off-topic: Как быть, если вызов, к примеру, recv два раза на одно событие FD_READ

приводит к WSAEWOULDBLOCK?
Не вызывай recv два раза подряд

I>В следующий раз, когда придет FD_READ, по причине последнего recv,

FD_READ приходит не потому что ты recv() вызвал, а потому, что данные в сокет пришли.

I>нужно ли вызывать recv еще раз?

Нет, не нужно.

I>Если нет, то как определить сколько байт было принято?

А MSDN почитать слабо?

ЗЫ: Оффтопик. Если хочешь чтобы у тебя все работало и голова не болела, делай все в следующей последовательности:
1) Внимательно читай документацию
2) Думай как применить полученные знания на практике
А не наоборот. И сразу же солнце станет светить ярче....
I>Заранее спасибо.
____________________
God obviously didn't debug, hasn't done any maintenance, and no documentation can be found. Truly amateur work.
Re: WinSock : муки выбора
От: masta Россия  
Дата: 08.04.03 10:53
Оценка:
Используйте порты завершения ввода-вывода применительно к сокетам. Работают исключительно хорошо при любом количестве подключений. Правда есть один минус — существуют исключительно под NT. В MSDN про это хорошо написано.
Re[4]: WinSock : муки выбора
От: Ignoramus  
Дата: 08.04.03 21:21
Оценка:
Здравствуйте, TepMuHyc, Вы писали:

Спасибо, что нашли время на столь развернутый ответ

Примерно так, как Вы написали, и выглядит мой сервер . Единственно что мне не пришло в голову – вызывать WSAAsyncSelect при обработке одного из FD, меняя таким образом статус сокета с чтения на запись и наоборот. Вместо этого я вызываю ее единожды, перед listen(), а на каждое FD анализирую текущий статус клиентского сокета (хранимый в sockData) и игнорирую, если оно пришло невовремя. Я подумал над этим, и мне кажется, что мой способ все же более надежный, ведь не исключен случай, когда во время обработки одного FD в очередь поступает еще один FD, который нас уже не будет интересовать после обработки текущего сообщения, но тем не менее, будет принят. Что Вы об этом думаете?

С FD_WRITE я уже разобрался, действительно, он может возникнуть когда угодно. К счастью, анализируя статус sockData несвоевременные FD_WRITE можно отсеять.

По поводу повторного вызова recv() я спрашивал не потому, что я собираюсь это делать, а из спортивного интереса, так как в MSDN этот вопрос не освещен. Может быть, recv не очень удачный пример. Ну скажем, что будет, если на FD_CLOSE вызвать send() (Это, кстати, рекомендуется делать для graceful shutdown в MSDN, чтобы выслать remaining response data)? Если в данный момент передача невозможна, возникнет WSAEWOULDBLOCK, а когда станет возможна, появится FD_WRITE. Нужно ли при этом вызывать еще раз send, или он уже в процессе? Если да, то как узнать, сколько байт будет реально передано? Конечно, я буду избегать таких вариантов, но просто интересно . В MSDN среди прочего написано: “FD_WRITE:… After send or sendto fail with WSAEWOULDBLOCK, when send or sendto are likely to succeed” И что из этого следует?

И еще, в Вашем коде я не нашел вызовов shutdown. Как правильно организовать окончание сеанса связи? Напоминаю, что в моем случае сеанс связи всегда заканчивается сразу после ответа сервера (Ваш код, похоже, рассчитан на более длительный сеанс).
В MSDN (WinSock->”Graceful Shutdown, Linger Options, and Socket Closure”) написано, что нужно вызывать shutdown с обеих сторон, сервера и клиента. Мне, кстати, кажется неудачной идея отсылать ответ сервера как "remaining response data” на FD_CLOSE, как там написано (см. причину выше).

Вот и получается, что не все в MSDN написано так, чтобы не возникало вопросов. На самом деле я достаточно много времени провел в изучении матчасти . Кроме MSDN Winsock API (который, кстати, достаточно скупой на методику, есть только описание функций, а также чрезвычайно важных отличий Winsock 2 от WinSock 1 и от Berkeley) прочитал еще кучу всего www.sockets.com, www.tangentsoft.net/wskfaq/ и другие. Мои вопросы, в основном, касаются нюансов практической реализации. Авторы примеров, которые можно найти в сети, обычно не утруждают себя написанием действительно практически применимого кода, ограничиваясь, как правило фразой «sorry, my program is not smart enough to deal with this»).

Буду признателен за любые комментарии.
Re[2]: WinSock : муки выбора
От: Ignoramus  
Дата: 08.04.03 21:23
Оценка:
Здравствуйте, masta, Вы писали:

M>Используйте порты завершения ввода-вывода применительно к сокетам. Работают исключительно хорошо при любом количестве подключений. Правда есть один минус — существуют исключительно под NT. В MSDN про это хорошо написано.


Спасибо, охотно верю. К сожалению, я мало знаком с completion port i/o, так что пришлось бы долго разбираться. К тому же NT ограничение достаточно весомое в моем случае.
Re[5]: WinSock : муки выбора
От: TepMuHyc  
Дата: 10.04.03 12:11
Оценка:
Здравствуйте, Ignoramus, Вы писали:

I>Ну скажем, что будет, если на FD_CLOSE вызвать send()

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

I>Если в данный момент передача невозможна, возникнет WSAEWOULDBLOCK, а когда станет возможна, появится FD_WRITE. Нужно ли при этом вызывать еще раз send, или он уже в процессе?

Вызывай send() тогда и только тогда когда приходит FD_WRITE. И только один раз. Когда сокет закончит с посылкой — он тебе скажет (пошлет FD_WRITE). То же самое с recv().

I>Если да, то как узнать, сколько байт будет реально передано?

send() закончит работу только тогда когда ВСЕ данные будут переданы. Тогда он опять пошлет FD_WRITE (типа, "я готов"). Кстати, буфер данных должен существовать и быть неизменным до тех пор пока все данные из него не уйдут. Поэтому, боже упаси располагать этот буфер на стеке.

I>В MSDN среди прочего написано: “FD_WRITE:… After send or sendto fail with WSAEWOULDBLOCK, when send or sendto are likely to succeed

В переводе на русский: Ты попробовал послать данные — не получилось. Дождись FD_WRITE и пробуй еще.

I>И еще, в Вашем коде я не нашел вызовов shutdown.

Его и не надо.
Дело в том, что если не играться с опцией Linger, что-то похожее на "graceful shutdown" произодет автоматически на closesocket().

То есть: все данные переданные в функцию send() уйдут. Все данные еще не принятые функцией recv() будут потеряны. Так как программист вызывает closesocket() вполне сознательно, он должен понимать,
что после вызова этой функции любая каммуникация невозможна — следовательно, тем самым он согласен на "потерю" еще не приятых данных .

I>Как правильно организовать окончание сеанса связи?

Это зависит от протокола который ты реализуешь.
Проблемы graceful shutdown реально важны только для полностью дуплексных протоколов (т.е. когда inbound и outbound потоки данных независимы в той или иной степени). Вот здесь и надо делать полный Graceful Shutdown.

Большинство же Internet протоколов полудуплексные (т.е. клиент дал запрос, и ждет ответа сервера, дождался — опять запрос). В этом случае — graceful shutdown — излишество. Если клиент закрыл сокет, значит он уже не ждет от сервера ответа. Следовательно, возиться с оставшимися данными (и graceful shutdown) — просто лишняя работа.

I>Напоминаю, что в моем случае сеанс связи всегда заканчивается сразу после ответа сервера .

Тем более, никакого graceful shutdown'а и близко не надо.
____________________
God obviously didn't debug, hasn't done any maintenance, and no documentation can be found. Truly amateur work.
Re[6]: WinSock : муки выбора
От: Ignoramus  
Дата: 10.04.03 23:25
Оценка:
Здравствуйте, TepMuHyc, Вы писали:

TMH>В переводе на русский: Ты попробовал послать данные — не получилось. Дождись FD_WRITE и пробуй еще.


Я понял, действительно, “WSAEWOULDBLOCK” это ведь фактически сообщение об ОШИБКЕ, означающее «выполнение функции невозможно, поскольку это привело бы к блокированию потока, а сокет находится в не-блокирующем режиме». Именно поэтому нужно вызывать recv() и send() на FD_READ и FD_WRITE соответственно. Вопросы, подобные моему, вызваны тем фактом, что не во всех случаях сообщение WSAEWOULDBLOCK действительно свидетельствует об ошибке, например, connect() всегда возвращает WSAEWOULDBLOCK, тем не менее, функция выполняется и позднее генерирует FD_CONNECT (вызывать connect() еще раз не нужно, вызов завершен успешно). Такая, панимаешь, загогулина.

I>И еще, в Вашем коде я не нашел вызовов shutdown.

TMH>Его и не надо.
TMH>Дело в том, что если не играться с опцией Linger, что-то похожее на "graceful shutdown" произодет автоматически на closesocket().

Ок, слава богу. Снова, похоже я стал жертвой MSDN-а .

Все же, Вы не ответили на вопрос, лучше ли вызывать WSAAsyncSelect при обработке FD_, или единожды перед listen(), как это предложил я.

Весьма признателен за Ваши квалифицированные и исчерпывающие ответы. Приятно удивляет уровень профессионализма Ваш и форумов на этом сайте в целом.
Re[7]: WinSock : муки выбора
От: TepMuHyc  
Дата: 11.04.03 10:40
Оценка:
Здравствуйте, Ignoramus, Вы писали:

I>Все же, Вы не ответили на вопрос, лучше ли вызывать WSAAsyncSelect при обработке FD_, или единожды перед listen(), как это предложил я.

На listening сокете достаточно вызвать один раз с FD_CONNECT.

На сеансовых сокетах лучше вызывать каждый раз перед сменой режима работы (т.е. чтение или запись)
с соответствующим флагом.
____________________
God obviously didn't debug, hasn't done any maintenance, and no documentation can be found. Truly amateur work.
Re[8]: WinSock : муки выбора
От: Ignoramus  
Дата: 13.04.03 16:02
Оценка:
Здравствуйте, TepMuHyc:

Спасибо за помощь, теперь я уже на стадии отладки .

Проблема следующая: клиент не получает FD_READ, когда сервер отвечает на его запрос. Последовательность действий такая:

1) Клиент вызывает connect и получает FD_CONNECT.
2) Сервер в это время получает FD_ACCEPT и вызывает accept, создавая новый сокет.
3) Клиент получает FD_WRITE и отсылает свой пакет send. Функция возвращает размер пакета, т.е. успех.
4) Сервер получает FD_READ и принимает пакет recv. Функция также возвращает размер пакета, т.е. успех.
5) Сервер получает FD_WRITE и отсылает в сокет, созданный функцией accept, ответный пакет send. Функция возвращает размер пакета.
6) Сразу после этого сервер вызывает closesocket и закрывает сокет, созданный accept. (пробовал отключать эту команду, эффект тот же).
7) Клиент не получает FD_READ . Сокет остается висеть незакрытым.

Что делать? Какие могут быть причины? Какие Вы порекомендуете мне утилиты для контроля реального трафика в сети, чтобы локализовать ошибку?
Re[6]: WinSock : муки выбора
От: NeuroVirus Россия  
Дата: 15.04.03 05:57
Оценка:
Здравствуйте, TepMuHyc, Вы писали:

...

>I>Если да, то как узнать, сколько байт будет реально передано?

>TMH>send() закончит работу только тогда когда ВСЕ данные будут переданы. Тогда он опять пошлет FD_WRITE (типа, "я готов"). Кстати, буфер >данных должен существовать и быть неизменным до тех пор пока все данные из него не уйдут. Поэтому, боже упаси располагать этот буфер на стеке.

Не согласен с Вами, коллега. send() копирует пользовательский буфер в свой внутренний буфер стека. Это еще и Шнэйдер говорит, и просто здравый смысл.
Re: WinSock : муки выбора
От: Аноним  
Дата: 15.04.03 20:45
Оценка:
Здравствуйте, Ignoramus, Вы писали:

I> Какой тип реализации сервера предпочтителен при большом (тысячи – десятки тысяч) и при малом (десятки) предполагаемом количестве клиентских подключений?


— не совсем по существу вопроса (винсок), но рекомендую ознакомиться:

http://www.kegel.com/c10k.html

(...here are a few notes on how to configure operating systems and write code to support thousands of clients...)

regards
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.