Winsock

Автор:

  • Дэрин Кили

На этой странице…

Преимущества быстродействующих сокетов в .NET Преимущества быстродействующих сокетов в .NET
Базовые сведения о сокетах Базовые сведения о сокетах
Установление соединений Установление соединений
Многопоточный сервер Многопоточный сервер
Применение метода Select для «мультиплексирования» ввода-вывода Применение метода Select для «мультиплексирования» ввода-вывода
Асинхронный ввод-вывод Асинхронный ввод-вывод
Как насчет масштабируемости? Как насчет масштабируемости?
Клиентские приложения, использующие сокеты Клиентские приложения, использующие сокеты
Что дальше? Что дальше?
Отладка приложений, использующих сокеты Отладка приложений, использующих сокеты

Преимущества быстродействующих сокетов в .NET

Сокеты — это транспортный механизм, который чаще всего используется в высокопроизводительных серверных приложениях. Библиотека Win32 Windows Sockets (Winsock) предоставляет механизмы, которые повышают быстродействие программ, использующих сокеты, а в Microsoft .NET Framework имеется более высокий по отношению к Winsock уровень, благодаря чему управляемые приложения также могут взаимодействовать через сокеты (рис. 1). Все это прекрасно, но, чтобы писать по-настоящему быстродействующие приложения, работающие с сокетами, вы должны знать кое-какую базовую информацию.

Уровни сокетов Windows Рис. 1. Уровни сокетов Windows

В этой статье мы обсудим написание серверных и клиентских приложений с применением базового класса System.Net.Sockets.Socket на примере простой серверной программы-чата. Хотя .NET предоставляет более абстрактные классы вроде TcpListener и TcpClient (тоже относящиеся к пространству имен System.Net.Sockets), они не поддерживают некоторые возможности, предлагаемые более низкоуровневым классом Socket. Тем не менее, они полезны во многих ситуациях. У класса TcpListener есть простые методы, позволяющие прослушивать и принимать входящие запросы на соединение в блокирующем синхронном режиме, а у класса TcpClient — простые методы установления соединения, передачи и приема потоковых данных по сети в синхронном блокирующем режиме.

Базовые сведения о сокетах

Сокеты определяют конечные точки взаимодействия (обычно через сеть). Они поддерживают целый ряд протоколов, самыми популярными из которых сегодня являются User Datagram Protocol (UDP) и Transmission Control Protocol (TCP).

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

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

TCP-сокеты могут быть либо клиентскими, либо серверными. Серверный сокет ожидает запросы на установление соединений, а клиентский — инициирует соединение. Как только соединение между сокетами установлено, клиент и сервер могут передавать и принимать данные или закрыть это соединение.

Чтобы настроить серверный TCP-сокет в управляемом коде, прежде всего нужно создать экземпляр класса Socket. Его конструктор принимает три параметра: AddressFamily, SocketType и ProtocolType. Параметр AddressFamily определяет используемую сокетом схему адресации. Чаще всего в качестве этого параметра используются значения InterNetwork (для адресов IPv4) и InterNetworkV6 (для адресов IPv6). Параметр SocketType определяет тип коммуникационной связи, осуществляемой при помощи сокета; к двум наиболее распространенным типам относятся Stream (для сокетов, ориентированных на логические соединения) и Dgram (если сокет не требует логических соединений). Параметр ProtocolType определяет применяемый сокетом протокол и принимает такие значения, как Tcp, Udp, Idp, Ggp и т. д. Например, создать сокет для взаимодействия по протоколу TCP можно так:

Socket s = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);

Как только сокет создан, его можно привязать к адресу. Привязка клиентского сокета к адресу не обязательна, но в случае серверных сокетов она необходима. Чтобы привязать сокет к адресу, вызовите метод Bind объекта Socket. Этому методу нужен адрес и порт, которые будут сопоставлены с сокетом, поэтому в качестве параметра он принимает экземпляр класса, производного от EndPoint. Как правило, это объект класса IPEndPoint (кроме него в .NET Framework входит только один класс, производный от EndPoint, — IrDAEndPoint, который служит для взаимодействия посредством инфракрасного порта).

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

Объект IPEndPoint является логическим представлением конечной точки при сетевой коммуникации и включает данные об IP-адресе и порте. Конструктор IPEndPoint принимает IP-адрес и номер порта. Номер порта - это просто целочисленный тип, определяющий используемый порт, а IP-адрес может быть представлен .NET-классом IPAddress или длинным целым числом (при этом каждый из сегментов IP-адреса представляется одним байтом целочисленного типа). Класс IPAddress содержит несколько предопределенных IP-адресов, для доступа к которым служат статические свойства; двумя самыми полезными адресами при работе с TCP-сокетами являются IPAddress.Loopback и IPAddress.Any.

Свойство IPAddress.Loopback представляет адрес обратной связи, т. е. локальный адрес компьютера (127.0.0.1). Этот адрес не имеет никакого отношения ни к оборудованию, ни к сетевой связи — он позволяет выполнять локальное тестирование даже в отсутствие сети и сетевого оборудования. Именно он используется, когда вы указываете в качестве имени хоста «localhost», хотя это можно изменить, отредактировав файл hosts в каталоге %windir%\system32\drivers\etc.

Свойство IPAddress.Any (0.0.0.0)  указывает серверу задействовать для прослушивания клиентских запросов все сетевые интерфейсы, а не только интерфейс, сопоставленный с конкретным IP-адресом (в этом отношении UDP- и TCP-сокеты немного различаются). Почему бы не использовать это свойство всегда? Ну, во-первых, тогда вы не сможете контролировать, кто подключается к вашему серверу. Если у сервера несколько сетевых адаптеров, вы можете прослушивать соединения на одном интерфейсе (например, подключенном к корпоративной сети) и блокировать другой интерфейс (скажем, подключенный к интернету), задав сетевой адрес, на котором нужно вести прослушивание.

Вторая причина связана с безопасностью. Если сокет настроен на повторное использование адреса (т. е. сокет можно привязать к уже задействованному адресу) и кто-то подключается к порту, с которым работает ваше серверное приложение, то подсистема управления сокетами решает, какой сокет должен получить пакет, поступивший в этот порт. Как правило, данные получает серверное приложение, более «тесно» связанное с портом (которое при вызове метода Bind указало более специфичный адрес). Пространство имен System.Net.NetworkInformation включает классы, позволяющие получить подробную информацию о каждом сетевом интерфейсе системы. Так, следующий код выводит все индивидуальные (unicast) IP-адреса каждого сетевого адаптера:

foreach (NetworkInterface nic in
    NetworkInterface.GetAllNetworkInterfaces())
{
    Console.WriteLine(nic.Name);
    foreach (UnicastIPAddressInformation addrInfo in
        nic.GetIPProperties().UnicastAddresses)
    {
        Console.WriteLine("\t" + addrInfo.Address);
    }
}

Чтобы привязать сокет к конкретному сетевому адаптеру, вы можете узнать IP-адрес этого адаптера и указать его при вызове конструктора IPEndPoint.

Установление соединений

Вероятно, приложению потребуется адрес сервера — для установления соединения или для привязки сокета к этому адресу. Один способ получения адреса сервера предоставляет класс System.Net.Dns. Он содержит ряд статических методов, служащих для преобразования IP-адреса в имя хоста и наоборот. Метод Dns.GetHostEntry возвращает объект IPHostEntry, содержащий соответствующую информацию о компьютере и позволяющий узнать все IP-адреса компьютера, которые известны DNS-серверу. Для упрощения в этой статье я буду использовать первый адрес из списка.

После вызова метода Bind метод Socket.Listen конфигурирует для сокета внутренние очереди. Когда клиент пытается подключиться к серверу, в очередь помещается запрос соединения. Метод Listen принимает один аргумент — максимальное число запросов соединений, которые могут находиться в очереди. Метод Socket.Accept извлекает из очереди первый запрос и возвращает новый объект Socket, который можно использовать для коммуникационного взаимодействия с клиентом.

В случае клиентского сокета после создания объекта Socket обычно вызывается метод Connect. Вы также можете сначала вызвать метод Bind, если нужно, чтобы клиент использовал конкретный порт. Если вы не связали клиентский сокет с портом, метод Connect выберет порт автоматически. При вызове Connect клиентское приложение пытается установить соединение с сервером. Метод Connect также принимает объект EndPoint, через который определяется целевой удаленный хост. Как только соединение установлено, клиент и сервер могут передавать данные методами Send и Receive.

Когда приложения завершат обмен данными, вы должны закрыть сокет. Чтобы корректно инициировать закрытие сокета, вызовите метод Shutdown. Это позволяет передать все неотправленные данные и получить еще не принятые данные. Если вы синхронно читаете данные из сокета и получаете 0 байтов, значит, вторая сторона закрыла сокет.

В табл. 1 обобщены этапы создания серверного и клиентского сокетов. Как видите, основы работы с сокетами на самом деле просты — гораздо сложнее использовать сокеты эффективно.

Табл.1.Стандартные вызовы клиентских и серверных сокетов

Сервер Клиент
Socket.Socket Socket.Socket
Socket.Bind Socket.Bind (необязательный вызов)
Socket.Listen Socket.Connect
Socket.Accept
Socket.Read/Socket.Write Socket.Read/Socket.Write
Socket.Shutdown (необязательный вызов) Socket.Shutdown (необязательный вызов)
Socket.Close Socket.Close

Если при выполнении метода сокета возникает сетевая ошибка, генерируется исключение SocketException. Этот объект является оболочкой кода ошибки, полученного от Win32. Если вы знакомы с разработкой под Win32 Winsock, вам не помешает знать, что SocketException.ErrorCode — тот же код ошибки, который вы получили бы, вызвав Winsock-функцию WSAGetLastError. Основные коды ошибок Winsock приведены в табл. 2. В .NET Framework 2.0 свойство SocketException.SocketErrorCode позволяет получить ту же информацию об ошибке в виде значения из перечислимого SocketError.

Табл. 2. Основные коды ошибок

Номер ошибки Значение WinsockЗначение SocketError Описание
10004 WSAEINTRInterrupted Системный вызов прерван. Это может произойти, если выполняется вызов сокета, а сокет закрыт
10048 WSAEADDRINUSEAddressAlreadyInUse Адрес, к которому вы пытаетесь привязать сокет или который вы хотите прослушивать, уже занят
10053 WSACONNABORTEDConnectionAborted Соединение было прервано локальным компьютером
10054 WSAECONNRESETConnectionReset Соединение было прервано удаленным компьютером
10061 WSAECONNREFUSEDConnectionRefused Соединение отклонено удаленным хостом. Это может произойти, если удаленный хост отключен или если он занят, а очередь запросов заполнена

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

Листинг 1.Настройка серверного сокета

private Socket _serverSocket;

private void SetupServerSocket()
{
    // Получаем информацию о локальном компьютере
    IPHostEntry localMachineInfo =
        Dns.GetHostEntry(Dns.GetHostName());
    IPEndPoint myEndpoint = new IPEndPoint(
        localMachineInfo.AddressList[0], _port);

    // Создаем сокет, привязываем его к адресу
    // и начинаем прослушивание
    _serverSocket = new Socket(
        myEndpoint.Address.AddressFamily,
        SocketType.Stream, ProtocolType.Tcp);
    _serverSocket.Bind(myEndpoint);
    _serverSocket.Listen((int)SocketOptionName.MaxConnections);
}

Сначала я напишу простой многопоточный сервер и объясню ограничения класса Socket. Базовый API состоит из блокирующих вызовов, поэтому при написании многопоточного сервера вы должны создавать новые потоки для приема соединений и выполнения ввода-вывода через сокет.

Избежать создания отдельных потоков для работы с серверными сокетами можно двумя способами. Первым является традиционный способ работы с несколькими потоками (streams) ввода-вывода в одном потоке (thread) приложения — применение метода Select. Второй способ - асинхронный ввод-вывод. Обсудим эти две методики написания сервера.

Многопоточный сервер

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

Листинг 2. Простой многопоточный сервер

class ThreadedServer
{
    private Socket _serverSocket;
    private int _port;

    public ThreadedServer(int port) { _port = port; }

    private class ConnectionInfo
    {
        public Socket Socket;
        public Thread Thread;
    }

    private Thread _acceptThread;
    private List<ConnectionInfo> _connections =
        new List<ConnectionInfo>();

    public void Start()
    {
        SetupServerSocket();
        _acceptThread = new Thread(AcceptConnections);
        _acceptThread.IsBackground = true;
        _acceptThread.Start();
    }

    private void SetupServerSocket()
    {
        // Получаем информацию о локальном компьютере
        IPHostEntry localMachineInfo =
            Dns.GetHostEntry(Dns.GetHostName());
         IPEndPoint myEndpoint = new IPEndPoint(
            localMachineInfo.AddressList[0], _port);

        // Создаем сокет, привязываем его к адресу
        // и начинаем прослушивание
        _serverSocket = new Socket(
            myEndpoint.Address.AddressFamily,
            SocketType.Stream, ProtocolType.Tcp);
        _serverSocket.Bind(myEndpoint);
        _serverSocket.Listen((int)
            SocketOptionName.MaxConnections);
    }

    private void AcceptConnections()
    {
        while (true)
        {
            // Принимаем соединение
            Socket socket = _serverSocket.Accept();
            ConnectionInfo connection = new ConnectionInfo();
            connection.Socket = socket;

            // Создаем поток для получения данных
            connection.Thread = new Thread(ProcessConnection);
            connection.Thread.IsBackground = true;
            connection.Thread.Start(connection);

            // Сохраняем сокет
            lock (_connections) _connections.Add(connection);
        }
    }

    private void ProcessConnection(object state)
    {
        ConnectionInfo connection = (ConnectionInfo)state;
        byte[] buffer = new byte[255];
        try
        {
            while (true)
            {
                int bytesRead = connection.Socket.Receive(
                    buffer);
                if (bytesRead > 0)
                {
                    lock (_connections)
                    {
                        foreach (ConnectionInfo conn in
                            _connections)
                        {
                            if (conn != connection)
                            {
                                conn.Socket.Send(
                                    buffer, bytesRead,
                                    SocketFlags.None);
                            }
                        }
                    }
                }
                else if (bytesRead == 0) return;
            }
        }
        catch (SocketException exc)
        {
            Console.WriteLine("Socket exception: " +
                exc.SocketErrorCode);
        }
        catch (Exception exc)
        {
             Console.WriteLine("Exception: " + exc);
        }
        finally
        {
            connection.Socket.Close();
            lock (_connections) _connections.Remove(
                connection);
        }
    }
}

Как видите, после создания сокета порождается новый поток, который будет получать данные, принимаемые этим сокетом. Этот поток почти ничего не делает — лишь получает данные из сокета и обрабатывает их (в нашем случае он отправляет их остальным сокетам, с которыми соединен сервер). В известном смысле, это очень простой сервер чата. Чтобы протестировать его, создайте простое консольное приложение .NET Framework 2.0 с таким методом Main:

class Server
{
  static void Main(string [] args)
  {
    ThreadedServer ts = new ThreadedServer(
      int.Parse(args[0]));
    ts.Start();
    Console.ReadLine();
  }
}

Этот код ожидает, что вы запустите приложение, указав в качестве первого аргумента серверный порт, с которым следует связать сокет. Запустите приложение, указав незанятый на вашем компьютере порт. Откройте несколько окон командной строки и выполните в каждом окне команду telnet для подключения к порту, заданному при запуске сервера (IP-адрес программа определит сама). Сразу после установления соединений любой текст, вводимый вами в одном из окон, будет немедленно отображаться в остальных окнах.

В случае небольшого сервера и нескольких клиентов такой подход работает хорошо, к тому же его легко реализовать. К сожалению, он плохо масштабируется. Главный его недостаток — большое число создаваемых и уничтожаемых потоков. На моем компьютере сервер может принять около 1000 соединений, после чего начинают генерироваться исключения, извещающие о нехватке памяти (память быстро заканчивается потому, что каждый поток имеет собственный стек, объем которого по умолчанию равен 1 МБ).

Применение метода Select для «мультиплексирования» ввода-вывода

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

Лучше использовать статический метод Socket.Select. Вы можете передать ему три списка сокетов, за которыми хотите следить: первый позволяет определить возможность чтения, второй — возможность записи, а третий — обнаруживать ошибки. Когда метод Select возвращает управление, оставшиеся в конкретном списке элементы готовы к выполнению соответствующей списку операции. Кроме того, метод Select принимает параметр microSeconds, сообщающий методу, сколько времени ждать ответа. Один из способов на основе метода Select предполагает, что вы создаете таймер, срабатывающий через небольшие интервалы, а в обработчике событий таймера вызываете Select с нулевым значением microSeconds, чтобы вызов был неблокирующим. Однако это неэффективно. Время, необходимое для обслуживания сокетов, зависит от частоты срабатывания таймера, что приводит к неоправданному снижению быстродействия кода. Как правило, лучше создать поток, который вызывает Select и обслуживает запросы, но больше ничего не делает.

Давайте сначала обсудим, как использовать метод Select при приеме соединений (листинг 3). Слушающий сокет следует перед вызовом Select поместить в список проверки возможности чтения. Если после вызова Select данный сокет все еще присутствует в этом списке, значит, у нас есть соединение, которое следует принять (такое использование Select для определения наличия запроса гарантирует, что метод Accept не заблокируется). Теперь вы можете вызвать метод Accept и начать работу с новым сокетом. Для обслуживания входящих данных новые сокеты, создаваемые при вызове Accept, нужно помещать в тот список, где находится сокет, для которого вы вызвали метод Accept (это довольно удобно). То есть в нашем сервере нужно поддерживать только один список.

Листинг 3. Сервер, использующий метод Select

class SelectBasedServer
{
    ... // метод SetupServerSocket и конструктор - те же,
        // что и в классе ThreadedServer
    public void Start()
    {
        Thread selectThread = new Thread(ProcessSockets);
        selectThread.IsBackground = true;
        selectThread.Start();
    }

    private void ProcessSockets()
    {
        byte[] buffer = new byte[255];
        List<Socket> readSockets = new List<Socket>();
        List<Socket> connectedSockets = new List<Socket>();
        try
        {
            SetupServerSocket();
            while (true)
            {
                // Заполняем список сокетов чтения
                readSockets.Clear();
                readSockets.Add(_serverSocket);
                readSockets.AddRange(connectedSockets);

                // Определяем статус сокетов
                Socket.Select(readSockets, null, null,
                    int.MaxValue);
                // Обрабатываем каждый сокет, требующий
                // каких-либо действий
                foreach (Socket readSocket in readSockets)
                {
                    if (readSocket == _serverSocket)
                    {
                        // Создаем новый сокет и сохраняем его
                        Socket newSocket = readSocket.Accept();
                        connectedSockets.Add(newSocket);
                    }
                    else
                    {
                        // Читаем и обрабатываем данные
                        int bytesRead =
                            readSocket.Receive(buffer);
                        if (0 == bytesRead)
                        {
                            connectedSockets.Remove(
                                readSocket);
                            readSocket.Close();
                        }
                        else
                        {
                            foreach (Socket connectedSocket in
                                connectedSockets)
                            {
                                if (connectedSocket !=
                                    readSocket)
                                {
                                    connectedSocket.Send(
                                        buffer, bytesRead,
                                        SocketFlags.None);
                                }
                            }
                        }
                    }
                }
            }
        }
        catch (SocketException exc)
        {
            Console.WriteLine("Socket exception: " +
                exc.SocketErrorCode);
        }
        catch (Exception exc)
        {
            Console.WriteLine("Exception: " + exc);
        }
        finally
        {
            foreach (Socket s in connectedSockets) s.Close();
            connectedSockets.Clear();
        }
    }
}

А теперь обсудим последствия применения метода Select. Перед каждым вызовом Select вы должны создать списки сокетов, за которыми вы хотите наблюдать. Когда метод Select возвращает управление, в списках остаются лишь те сокеты, которые требуют обслуживания. Звучит прекрасно, но на практике такой подход довольно неэффективен. Представьте, что будет, если сразу 100 сокетов требуют выполнения операций ввода-вывода: сотый сокет будет ждать, пока вы не завершите обслуживание или составление графика обслуживания первых 99 сокетов. Кроме того, обслуживание сокетов мешает выполнить реентерабельный вызов метода Select, что повышает вероятность «голодания» потоков. Прежде чем вызвать Select в очередной раз, вы должны убедиться, что все сокеты обслужены; если вы этого не сделаете, то получите уведомление о повторном выполнении тех же операций ввода-вывода.

Асинхронная модель ввода-вывода устраняет необходимость в создании потоков и управлении ими. Это позволяет значительно упростить код и повышает эффективность ввода-вывода.

У этого подхода есть и другие недостатки, связанные с производительностью. После подключения примерно тысячи клиентов быстродействие метода Select значительно снижается. Это объясняется тем, что для определения того, доступны ли сокетам данные, ядро должно опросить каждый сокет.

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

Асинхронный ввод-вывод

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

Как же в .NET Framework реализованы эти асинхронные вызовы? С каждым .NET-приложением сопоставлен пул потоков. Когда у функции асинхронного ввода-вывода имеются готовые к обработке данные, поток из пула потоков .NET выполняет функцию обратного вызова. После завершения обратного вызова поток возвращается в пул. Это отличается от вышеописанного подхода, при котором поток из пула использовался для обработки конкретного запроса; сейчас речь идет о том, что поток из пула служит для выполнения только одной операции ввода-вывода.

В .NET применяется довольно простая модель асинхронных операций. Вы должны лишь вызвать нужный Begin-метод (BeginAccept, BeginSend, BeginReceive и т. д.), передав ему соответствующий делегат метода обратного вызова, а в методе обратного вызова вызвать аналогичный End-метод (EndAccept, EndSend, EndReceive и т. п.) для получения результатов. Все асинхронные Begin-методы принимают объект состояния контекста, который может представлять все, что вы захотите. По завершении асинхронной операции этот объект является частью IAsyncResult, который передается методу обратного вызова.

В новом асинхронном сервере мы сначала вызываем метод BeginAccept. С первыми двумя серверами были связаны две проблемы. Многопоточный сервер был довольно быстрым, но не мог принять много соединений. Сервер, использующий метод Select, поддерживал больше соединений, но за счет меньшей производительности.

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

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

Листинг 4. Асинхронный сервер

class AsynchronousIoServer
{
    ... // метод SetupServerSocket и конструктор - те же,
        // что и в классе ThreadedServer

    private class ConnectionInfo
    {
        public Socket Socket;
        public byte[] Buffer;
    }

    private List<ConnectionInfo> _connections =
        new List<ConnectionInfo>();

    public void Start()
    {
        SetupServerSocket();
        for (int i = 0; i < 10; i++)
            _serverSocket.BeginAccept(new
                AsyncCallback(AcceptCallback), _serverSocket);
    }

    private void AcceptCallback(IAsyncResult result)
    {
        ConnectionInfo connection = new ConnectionInfo();
        try
        {
            // Завершение операции Accept
            Socket s = (Socket)result.AsyncState;
            connection.Socket = s.EndAccept(result);
            connection.Buffer = new byte[255];
            lock (_connections) _connections.Add(connection);

            // Начало операции Receive и новой операции Accept
            connection.Socket.BeginReceive(connection.Buffer,
                0, connection.Buffer.Length, SocketFlags.None,
                new AsyncCallback(ReceiveCallback),
                connection);
            _serverSocket.BeginAccept(new AsyncCallback(
                AcceptCallback), result.AsyncState);
        }
        catch (SocketException exc)
        {
            CloseConnection(connection);
            Console.WriteLine("Socket exception: " +
                exc.SocketErrorCode);
        }
        catch (Exception exc)
        {
            CloseConnection(connection);
            Console.WriteLine("Exception: " + exc);
        }
    }

    private void ReceiveCallback(IAsyncResult result)
    {
        ConnectionInfo connection =
            (ConnectionInfo)result.AsyncState;
        try
        {
            int bytesRead =
                connection.Socket.EndReceive(result);
            if (0 != bytesRead)
            {
                lock (_connections)
                {
                    foreach (ConnectionInfo conn in
                        _connections)
                    {
                        if (connection != conn)
                        {
                            conn.Socket.Send(connection.Buffer,
                                bytesRead, SocketFlags.None);
                        }
                    }
                }
                connection.Socket.BeginReceive(
                    connection.Buffer, 0,
                    connection.Buffer.Length, SocketFlags.None,
                    new AsyncCallback(ReceiveCallback),
                    connection);
            }
            else CloseConnection(connection);
        }
        catch (SocketException exc)
        {
            CloseConnection(connection);
            Console.WriteLine("Socket exception: " +
                exc.SocketErrorCode);
        }
        catch (Exception exc)
        {
            CloseConnection(connection);
            Console.WriteLine("Exception: " + exc);
        }
    }

    private void CloseConnection(ConnectionInfo ci)
    {
        ci.Socket.Close();
        lock (_connections) _connections.Remove(ci);
    }
}

Как насчет масштабируемости?

Какую бы серверную модель я ни применял, на своем компьютере я не сумел создать более 4000 одновременных соединений или около того. В этот момент в ответ на новые клиентские запросы начинали генерироваться довольно бесполезные сообщения об ошибках, извещавшие о нехватке места в буфере или о переполнении очередей. Ясно, что этот предел неприемлем, если ваш сервер должен работать в какой-либо требовательной среде. Почему же мы упираемся в этот потолок? Оказывается, ограничивающим фактором является объем доступных ресурсов памяти, а именно, пул неподкачиваемой памяти (nonpaged pool). Если процесс исчерпал свой пул этой памяти, создать новое соединение с сокетом не удастся.

Клиентские приложения, использующие сокеты

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

Как и во всех приложениях на основе сокетов, сначала вы должны создать клиентский сокет. После этого вы можете явно привязать его к адресу и порту (но это не обязательно). Если вы решили не привязывать клиентский сокет к адресу, ему будет назначен адрес, выбранный сетевым уровнем. А если вы не указали порт, сетевой уровень предоставит сокету уникальный порт из диапазона 1024—5000.

Тогда зачем кому-то может понадобиться привязка клиентского сокета к адресу или порту? Допустим, у вас есть клиентский компьютер с несколькими сетевыми адаптерами и вы хотите выбрать адаптер, который должен использоваться клиентом. В этом случае вам следует привязать клиентский сокет к IP-адресу данного сетевого адаптера. К счастью, вы можете вызвать метод Bind, указав нулевой номер порта, и сетевой уровень все равно предоставит вам уникальный порт из диапазона 1024—5000. Привязка клиентского сокета к специфическому порту не рекомендуется, но возможна.

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

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

Что дальше?

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

Прежде всего сервер должен принимать соединения. Асинхронно принять соединение можно тремя основными способами: вы можете просто принять соединение, принять соединение и получить первые байты данных, переданные по этому соединению, или принять соединение с использованием специфического сокета и получить первые байты данных, переданные по соединению. Инфраструктура .NET Framework 1.x поддерживает только первый из этих подходов. В .NET Framework 2.0 реализованы все три подхода в форме перегруженных версий методов BeginAccept и EndAccept.

Асинхронная версия метода Connect полезна, если клиентскому приложению нужно установить соединения с несколькими серверами. Она позволяет устанавливать несколько соединений одновременно, а не по очереди.

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

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

Что можно сказать о третьем подходе, при котором вы принимаете соединение через специфический сокет и получаете первые байты данных? В мире .NET он дает серьезное преимущество, особенно в сочетании с методом Socket.Disconnect, входящим в .NET Framework 2.0.  Ранее, завершив работу с сокетом, вы должны были закрыть соединение и удалить объект-сокет. Метод Socket.Disconnect позволяет повторно использовать объекты Socket, благодаря чему вы можете реализовать собственный пул объектов-сокетов. В некоторых сценариях это может обеспечить повышение производительности на длительную перспективу.

Теперь подумаем, как обрабатываются соединения с сокетами. Сначала вы указываете операционной системе прослушивать входящие запросы соединений, и она создает очередь для хранения определенного числа соединений, ожидающих обработки. Предположим, что максимальный размер очереди равен 200. Это означает, что, если к серверу попытаются подключиться более 200 клиентов сразу, каждый клиент, виновный в переполнении очереди прослушивания запросов, получит ошибку WSAECONNREFUSED (сервер отклонил соединение). Как решить эту проблему? Следует организовать очередь из нескольких операций Accept — это позволит извлекать ожидающие обработки соединения, даже если вы пока обрабатываете предыдущие соединения и еще не повторили запрос Accept. В конечном счете это предотвращает переполнение очереди прослушивания запросов. Если вы еще раз взглянете в листинг 4, то заметите, что в начале программы я выполняю 10 асинхронных вызовов BeginAccept.

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

Отладка приложений, использующих сокеты

Одна из новых возможностей .NET Framework 2.0 — поддержка средств надежной трассировки сетевых операций. Это значительно упрощает отладку приложений, работающих с сокетами, по сравнению с былыми временами, когда мы, как правило, могли рассчитывать только на отладчик и анализатор пакетов. Задав кое-какие параметры в конфигурационном файле, вы можете получать информацию о критических событиях, ошибках, предупреждениях, моментах входа в методы и выхода из них, а также о сетевом трафике. Все это доступно в .NET как стандартная функциональность трассировки.

Одна из новых возможностей .NET Framework 2.0 — поддержка средств надежной трассировки сетевых операций. Это значительно упрощает отладку приложений, работающих с сокетами, по сравнению с былыми временами.

В разделе System.Diagnostics конфигурационного файла вы должны указать относящийся к пространству имен System.Net источник трассировочных данных (trace source), за которым вы хотите следить (в число допустимых источников входят «System.Net», «System.Net.Sockets» и «System.Net.Cache»). В нашем случае им будет пространство имен System.Net.Sockets. Пример конфигурационного файла приложения показан в листинге 5.

Листинг 5. Конфигурационный файл

<configuration>
  <system.diagnostics>
    <sources>
      <source name="System.Net.Sockets">
        <listeners>
          <add name="Sockets"/>
        </listeners>
      </source>
    </sources>
    <switches>
      <add name="System.Net.Sockets" value="31" />
    </switches>
    <sharedListeners>
    <add name="Sockets" type=
      "System.Diagnostics.TextWriterTraceListener"
      initializeData="Sockets.log"/>
    </sharedListeners>
    <trace autoflush="true" />
  </system.diagnostics>
</configuration>

Включив трассировку сетевых операций, вы начнете получать много внутренней информации о своем приложении, работающем с сокетами. Атрибут value для журнала трассировки на основе System.Net — это битовая маска, каждый бит которой представляет разный уровень протоколирования. Есть пять уровней: сетевой трафик (0x10), вход в методы и выход из них (0x8), предупреждения (0x4), ошибки (0x2) и критические события (0x1). В примере в листинге 5 мы регистрируем все, что охватывает комбинированное значение 31. Трассировка входа в методы и выхода из них — очень полезное средство, позволяющее узнавать, когда вы вызываете разные методы из пространства имен System.Net. Это не только помогает отлаживать программу, но и позволяет лучше понять внутреннюю работу API. Если метод возвращает значение, оно также будет зарегистрировано в журнале. Ниже дан пример журнала трассировки входа и выхода из асинхронного метода BeginAccept; заметьте, что из этого журнала сразу становится ясно, что сокет был создан в API. Как видите, метод BeginAccept вернул значение AcceptOverlappedAsyncResult, а идентификатор объекта в случае этого вызова равен 48209832.

System.Net.Sockets Information: 0 :
 Socket#48285313::BeginAccept()
System.Net.Sockets Information: 0 :
 Socket#59817589::Socket(InterNetwork#2)
System.Net.Sockets Information: 0 :
 Exiting Socket#59817589::Socket()
System.Net.Sockets Information: 0 :
 Exiting Socket#48285313::BeginAccept()
-> AcceptOverlappedAsyncResult#48209832

Учтите, что не все методы поддерживают ловушки трассировки. Так, в отличие от метода Bind метод Select в журналах трассировки не появляется. Информацию о том, какие методы поддерживают трассировку, см. в документации (EN).

Другой крайне полезной возможностью является журнал сетевого трафика. Он очень прост: это дамп буфера переданных или полученных данных. Помните, что в журнал записываются полные дампы буферов (при этом используется заданный максимальный объем буфера), что может вводить в заблуждение. Например, даже если в 512-байтном буфере принятых данных находится только один байт, в журнале вы увидите содержимое всего буфера. Чтобы удостовериться в том, что байты, на которые вы смотрите, имеют смысл, проверьте значение, возвращенное методом передачи или приема данных. Следующие трассировочные данные были сгенерированы приложением, отправившим один байт данных (символ «a») через сокет 60504909:

System.Net.Sockets Information: 0 : Socket#60504909::Send()
System.Net.Sockets Verbose: 0 : 00000000 : 61 : a
System.Net.Sockets Information: 0 :
 Exiting Socket#60504909::Send()     ->
1#1

Три других уровня протоколирования (предупреждения, ошибки и критические события) позволяют получить информацию о неудачных операциях в API. При каждом из этих типов протоколирования в журнал записывается уровень протоколирования и сообщение об ошибке. Например, при неудачном вызове Socket.GetSocketOptions журнал может выглядеть так:

System.Net.Sockets Error: 0 : Exception in the
Socket#48285313::GetSocketOption - An unknown, invalid, or
unsupported option or level was specified in a getsockopt
or setsockopt call

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

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


К началу страницы К началу страницы

Показ: