Ввод/вывод данных

121

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

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

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

Синхронный и асинхронный ввод/вывод

При выполнении в синхронном режиме, функции ввода/вывода Win32 API (например, ReadFile, WriteFile или DeviceloControl) блокируют выполнение программы до завершения операции. Хотя эта модель очень удобна в использовании, она не слишком эффективна. В промежутках времени между выполнением последовательных запросов на ввод/вывод устройство может простаивать, то есть, использоваться недостаточно полно.

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

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

Большинство устройств поддерживают возможность прямого доступа к памяти (Direct Memory Access, DMA) для передачи данных между устройством и ОЗУ компьютера, не требуя участия процессора в операции, и генерируют прерывание по завершении передачи данных. Синхронный режим ввода/вывода, который внутренне является асинхронным, поддерживается только на уровне приложений Windows.

В Win32 асинхронный ввод/вывод называется перекрывающимся вводом/выводом (overlapped I/O), сравнение синхронного и перекрывающегося режимов ввода/вывода приводится на рисунке ниже:

Сравнение синхронного и асинхронного режимов ввода/вывода

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

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

Порты завершения ввода/вывода

Windows поддерживает эффективный механизм извещений о завершении асинхронных операций ввода/вывода под названием порт завершения ввода/вывода (I/O Completion Ports, IOCP). В приложениях для .NET он доступен посредством метода ThreadPool.BindHandle(). Этот механизм используется внутренними реализациями некоторых типов в .NET, выполняющих операции ввода/вывода: FileStream, Socket, SerialPort, HttpListener, PipeStream и некоторые каналы .NET Remoting.

Организация и принцип действия порта завершения ввода/вывода

Механизм IOCP, показанный на рисунке выше, связывается с несколькими дескрипторами ввода/вывода (сокетами, файлами и специализированными объектами драйверов устройств), открытыми в асинхронном режиме, и с определенным потоком выполнения. Как только операция ввода/вывода, связанная с таким дескриптором, завершится, Windows добавит извещение в соответствующий порт IOCP и передаст для обработки связанному с ним потоку выполнения.

Использование пула потоков, обслуживающих извещения и возобновляющих выполнение потоков, инициализировавших асинхронные операции ввода/вывода, снижает количество переключений контекста в единицу времени и увеличивает использование процессора. Неудивительно, что высокопроизводительные серверы, такие как Microsoft SQL Server, используют порты завершения ввода/вывода.

Порт завершения создается вызовом функции Win32 API CreateIoCompletionPort, которой передается максимальное значение параллелизма (количество потоков), ключ завершения и необязательный дескриптор объекта ввода/вывода. Ключ завершения - это определяемое пользователем значение, которое служит для идентификации различных дескрипторов ввода/вывода. С одним и тем же портом IOCP можно связать несколько дескрипторов, повторно вызывая функцию CreateIoCompletionPort и передавая ей дескриптор существующего порта завершения.

Чтобы установить связь с указанным портом IOCP, пользовательские потоки выполнения вызывают функцию GetCompletionStatus и ожидают ее завершения. В каждый конкретный момент времени поток выполнения может быть связан только с одним портом IOCP.

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

Механизм IOCP способен определить, что какой-то из «занятых» потоков фактически выполняет синхронный ввод/вывод, и запустить дополнительный поток, возможно превысив максимальное значение параллелизма. Извещения можно также посылать вручную, без выполнения ввода/вывода, вызовом функции PostQueuedCompletionStatus.

В следующем коде демонстрируется пример использования ThreadPool.BindHandle() с файловым дескриптором Win32:

using System;
using System.Threading;
using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;

public class Extensions
{
    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    internal static extern SafeFileHandle CreateFile(string lpFileName,
        EFileAccess dwDesiredAccess,
        EFileShare dwShareMode,
        IntPtr lpSecurityAttributes,
        ECreationDisposition dwCreationDisposition,
        EFileAttributes dwFlagsAndAttributes,
        IntPtr hTemplateFile);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    static unsafe extern bool WriteFile(SafeFileHandle hFile, byte[] lpBuffer,
        uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten,
        System.Threading.NativeOverlapped* lpOverlapped);


    [Flags]
    enum EFileShare : uint
    {
        None = 0x00000000,
        Read = 0x00000001,
        Write = 0x00000002,
        Delete = 0x00000004
    }

    enum ECreationDisposition : uint
    {
        New = 1,
        CreateAlways = 2,
        OpenExisting = 3,
        OpenAlways = 4,
        TruncateExisting = 5
    }

    [Flags]
    enum EFileAttributes : uint
    {
        // ... Некоторые флаги не показаны
        Normal = 0x00000080,
        Overlapped = 0x40000000,
        NoBuffering = 0x20000000,
    }

    [Flags]
    enum EFileAccess : uint
    {
        // ... Некоторые флаги не показаны
        GenericRead = 0x80000000,
        GenericWrite = 0x40000000,
    }

    static long _numBytesWritten;

    // Тормоз для потока записи
    static AutoResetEvent _waterMarkFullEvent;
    static int _pendingIosCount;
    const int MaxPendingIos = 10;

    // Процедура завершения, вызывается потоками ввода/вывода
    static unsafe void WriteComplete(uint errorCode, uint numBytes, NativeOverlapped* pOVERLAP)
    {
        _numBytesWritten += numBytes;
        Overlapped ovl = Overlapped.Unpack(pOVERLAP);
        Overlapped.Free(pOVERLAP);

        // Известить поток записи, что количество ожидающих операций ввода/вывода
        // уменьшилось до допустимого предела
        if (Interlocked.Decrement(ref _pendingIosCount) < MaxPendingIos)
            _waterMarkFullEvent.Set();
    }

    static unsafe void TestIOCP()
    {
        int ERROR_IO_PENDING = 1;

        // Открыть файл в асинхронном режиме
        var handle = CreateFile(@"F:\largefile.bin",
            EFileAccess.GenericRead | EFileAccess.GenericWrite,
            EFileShare.Read | EFileShare.Write,
            IntPtr.Zero, ECreationDisposition.CreateAlways,
            EFileAttributes.Normal | EFileAttributes.Overlapped, 
            IntPtr.Zero);

        _waterMarkFullEvent = new AutoResetEvent(false);
        ThreadPool.BindHandle(handle);

        for (int k = 0; k < 1000000; k++)
        {
            byte[] fbuffer = new byte[4096];

            // Аргументы: смещение в файле нижнее/верхнее, дескриптор 
            // события объект IAsyncResult
            Overlapped ovl = new Overlapped(0, 0, IntPtr.Zero, null);

            // CLR автоматически закрепит буфер
            NativeOverlapped* pNativeOVL = ovl.Pack(WriteComplete, fbuffer);
            uint numBytesWritten;

            // Проверить количество ожидающих операций ввода/вывода
            if (Interlocked.Increment(ref _pendingIosCount) < MaxPendingIos)
            {
                if (WriteFile(handle, fbuffer, (uint)fbuffer.Length, out numBytesWritten,
                    pNativeOVL))
                {
                    //  ввод/вывод завершился синхронно
                    _numBytesWritten += numBytesWritten;
                    Interlocked.Decrement(ref _pendingIosCount);
                }
                else
                {
                    if (Marshal.GetLastWin32Error() != ERROR_IO_PENDING)
                    {
                        return; // Ошибка
                    }
                }
            }
            else
            {
                Interlocked.Decrement(ref _pendingIosCount);
                while (_pendingIosCount >= MaxPendingIos)
                {
                    _waterMarkFullEvent.WaitOne();
                }
            }
        }
    }
}

Сначала рассмотрим метод TestIOCP. Здесь вызывается функция CreateFile(), которая является функцией механизма P/Invoke, используемой для открытия или создания файла или устройства. Для выполнения операций ввода/вывода в асинхронном режиме, необходимо передать функции флаг EFileAttributes.Overlapped. В случае успеха функция CreateFile() возвращает файловый дескриптор Win32, который мы связываем с портом завершения ввода/вывода вызовом ThreadPool.BindHandle(). Далее создается объект события, используемый для временного блокирования потока, инициировавшего операцию ввода/вывода, если таких операций оказывается слишком много (предел устанавливается константой MaxPendingIos).

Затем начинается цикл асинхронных операций записи. В каждой итерации создается буфер с данными для записи и структура Overlapped, содержащая смещение внутри файла (в данном примере запись всегда выполняется со смещением 0), дескриптор события, передаваемый по завершении операции (не используется механизмом IOCP), и необязательный пользовательский объект IAsyncResult, который можно использовать для передачи состояния в функцию завершения.

Далее вызывается метод Overlapped.Pack(), принимающий функцию завершения и буфер с данными. Он создает эквивалентную низкоуровневую структуру операции ввода/вывода, размещая ее в неуправляемой памяти, и закрепляет буфер с данными. Освобождение неуправляемой памяти, занимаемой низкоуровневой структурой, и открепление буфера должны выполняться вручную.

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

Функция завершения WriteComplete вызывается потоком из пула потоков завершения ввода/вывода, как только операция будет выполнена. Ей передается указатель на низкоуровневую структуру асинхронного ввода/вывода, которую можно распаковать и преобразовать в управляемую структуру Overlapped.

Подводя итоги, отметим, что при работе с высокопроизводительными устройствами ввода/вывода, применяйте асинхронные операции ввода/вывода с портами завершения, либо непосредственно, создавая и используя собственный порт завершения в неуправляемой библиотеке, либо связывая дескрипторы Win32 с портом завершения в .NET с помощью метода ThreadPool.BindHandle().

Пул потоков в .NET

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

Имеется возможность инициировать операции ввода/вывода, которые кажутся асинхронными, но таковыми не являются. Например, вызов ThreadPool.QueueUserWorkItem делегата с последующим выполнением синхронной операции ввода/вывода не является по-настоящему асинхронной операцией и такое решение ничем не лучше выполнения той же операции в обычном потоке выполнения.

Копирование памяти

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

Неуправляемая память

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

Если применяемые вами функции или библиотеки позволяют явно указывать буфер в памяти или передавать им свою функцию обратного вызова для выделения буфера, распределите управляемый буфер и закрепите его в памяти, чтобы к нему можно было обращаться и по указателю, и по управляемой ссылке. Если буфер достаточно велик (> 85 000 байт), он будет создан в куче больших объектов (Large Object Heap), поэтому старайтесь повторно использовать уже имеющиеся буферы. Если повторное использование буфера осложнено неопределенностью срока жизни объекта, применяйте пулы памяти.

В других случаях, когда функции или библиотеки сами выделяют память (неуправляемую) для буферов, вы можете обращаться к этой памяти непосредственно по указателю (из небезопасного кода) или используя классы-обертки, такие как UnmanagedMemoryStream и UnmanagedMemoryAccessor. Однако, если необходимо передать буфер некоторому коду, который оперирует только массивами byte[] или строковыми объектами, копирование может оказаться неизбежным.

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

Экспортирование части буфера

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

Чтобы избежать ненужного копирования памяти, организуйте прием смещения и длины везде, где принимаете параметр byte[]. Используйте параметр длины вместо свойства Length массива, а значение смещения добавляйте к текущим индексам.

Чтение вразброс и запись со слиянием

Чтение вразброс и запись со слиянием - это возможность, поддерживаемая ОС Windows, выполнять чтение в несмежные области или записывать данные из несмежных областей, как если бы они занимали непрерывный участок памяти. Данная функциональность в Win32 API предоставляется в виде функций ReadFileScatter и WriteFileGather. Библиотека сокетов Windows также поддерживает возможность чтения вразброс и записи со слиянием, предоставляя собственные функции: WSASend, WSARecv и другие.

Чтение вразброс и запись со слиянием могут пригодиться в следующих ситуациях:

В сравнении с функциями ReadFileScatter и WriteFileGather, требующими, чтобы каждый буфер в точности соответствовал размеру одной страницы, а дескриптор был открыт в асинхронном и небуферизованном режиме (что является еще большим ограничением), функции чтения вразброс и записи со слиянием на основе сокетов выглядят более практичными, потому что не имеют этих ограничений. Фреймворк .NET Framework поддерживает чтение вразброс и запись со слиянием для сокетов посредством перегруженных методов Socket.Send() и Socket.Receive(), не экспортируя при этом универсальные функции чтения/записи.

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

Файловый ввод/вывод

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

Управление кешированием

Создавая или открывая файлы, программисты передают функции CreateFile флаги и атрибуты, часть из которых оказывает влияние на поведение механизма кеширования:

В .NET эти параметры поддерживаются (кроме последнего) с помощью перегруженного конструктора FileStream, принимающего параметр типа перечисления FileOptions.

Произвольный доступ отрицательно сказывается на производительности, особенно при работе с дисковыми устройствами, так как при этом возникает необходимость перемещать головки. В процессе развития технологий, пропускная способность дисков увеличивалась только за счет увеличения плотности хранения данных, но не за счет уменьшения задержек. Современные диски способны переупорядочивать выполнение запросов при произвольном доступе, чтобы уменьшить общее время, затрачиваемое на перемещение головок. Этот прием называется аппаратная установка очередности команд (Native Command Queuing, NCO). Для большей эффективности этого приема контроллеру диска необходимо отправить сразу несколько запросов на ввод/вывод. Иными словами, если это возможно, старайтесь иметь сразу несколько ожидающих асинхронных запросов ввода/вывода.

Небуферизованный ввод/вывод

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

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

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

Пройди тесты
Лучший чат для C# программистов