Что такое асинхронное программирование. Асинхронные делегаты

Асинхронность в программировании

Иван Борисов

Традиционно в программировании используют синхронное программирование - последовательное выполнение инструкций с синхронными системными вызовами, которые полностью блокируют поток выполнения, пока системная операция, например чтение с диска, не завершится. В качестве примера ниже написан echo-сервер:

While (true) { std::string data; auto socket = Socket(localhost, port); socket.wait_connection(); while (!socket.end_of_connection()) { data = socket.read(); // Блокировка socket.write(data); // Блокировка } }

При вызове методов read() и write() текущий поток исполнения будет прерван в ожидании ввода-вывода по сети. Причём большую часть времени программа будет просто ждать. В высоконагруженных системах чаще всего так и происходит - почти всё время программа чего-то ждёт: диска, СУБД, сети, UI, в общем, какого-то внешнего, независимого от самой программы события. В малонагруженных системах это можно решить созданием нового потока для каждого блокирующего действия. Пока один поток спит, другой работает.

Но что делать, когда пользователей очень много? Если создавать на каждого хотя бы один поток, то производительность такого сервера резко упадёт из-за того, что контекст исполнения потока постоянно сменяется. Также на каждый поток создаётся свой контекст исполнения, включая память для стека, которая имеет минимальный размер в 4 КБ. Эту проблему может решить асинхронное программирование.

Асинхронность

Асинхронность в программировании - выполнение процесса в неблокирующем режиме системного вызова, что позволяет потоку программы продолжить обработку. Реализовать асинхронное программирование можно несколькими способами, о которых вы узнаете ниже.

Callbacks

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

While (true) { auto socket = Socket(localhost, port); socket.wait_connection(); // Всё ещё есть блокировка socket.async_read((auto &data) /* * Поток не блокируется, лямбда-функция будет вызвана * каждый раз после получения новых данных из сокета, * а основной поток пойдёт создавать новый сокет и * ждать новое соединение. */ { socket.async_write(data, (auto &socket) { if (socket.end_of_connection()) socket.close(); }); }); }

В wait_connection() мы всё ещё ждём чего-то, но теперь вместе с этим внутри функции wait_connection() может быть реализовано подобие планировщика ОС, но с callback-функциями (пока мы ждём нового соединения, почему бы не обработать старые? Например, через очередь). Callback-функция вызывается, если в сокете появились новые данные - лямбда в async_read() , либо данные были записаны - лямбда в async_write() .

В результате мы получили асинхронную работу нескольких соединений в одном единственном потоке, который намного реже будет ждать. Эту асинхронность можно также распараллелить, чтобы получить полный профит от утилизации процессорного времени.

У такого подхода есть несколько проблем. Первую в шутку называют callback hell. Достаточно погуглить картинки на эту тему, чтобы понять, насколько это нечитаемо и некрасиво. В нашем примере всего две вложенные callback-функции, но их может быть намного больше.

Вторая проблема заключается в том, что код перестал выглядеть как синхронный: появились «прыжки» из wait_connection() в лямбды, например лямбда, переданная в async_write() , что нарушает последовательность кода, из-за чего становится невозможно предсказать, в каком порядке будут вызваны лямбды. Это усложняет чтение и понимание кода.

Async/Await

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

Public async void work() { var db_conn = Db_connection(localhost); var socket = Socket(localhost, port); socket.wait_connection(); var data = socket.async_read(); var db_data = db_conn.async_get(await data); var file_data = File(await data).async_read(); await socket.async_write($”{await db_data} {await file_data}”); socket.close(); }

Пройдём по программе построчно:

  • Ключевое слово async в заголовке функции говорит компилятору, что функция асинхронная и её нужно компилировать по-другому. Каким именно образом он будет это делать, написано ниже.
  • Первые три строки функции: создание и ожидание соединения.
  • Следующая строка делает асинхронное чтение, не прерывая основной поток исполнения.
  • Следующие две строки делают асинхронный запрос в базу данных и чтение файла. Оператор await приостанавливает текущую функцию, пока не завершится выполнение асинхронной задачи чтения из БД и файла.
  • В последних строках производится асинхронная запись в сокет, но лишь после того, как мы дождёмся асинхронного чтения из БД и файла.

Это быстрее, чем последовательное ожидание сначала БД, затем файла. Во многих реализациях производительность async / await лучше, чем у классических callback-функций, при этом такой код читается как синхронный.

Корутины

Описанный выше механизм называется сопрограммой. Часто можно услышать вариант «корутина» (от англ. coroutine - сопрограмма).

Несколько точек входа

По сути корутинами называются функции, имеющие несколько точек входа и выхода. У обычных функций есть только одна точка входа и несколько точек выхода. Если вернуться к примеру выше, то первой точкой входа будет сам вызов функции оператором asynс, затем функция прервёт своё выполнение вместо ожидания БД или файла. Все последующие await будут не запускать функцию заново, а продолжать её исполнение в точке предыдущего прерывания. Да, во многих языках в корутине может быть несколько await ’ов.

Для большего понимания рассмотрим код на языке Python:

Def async_factorial(): result = 1 while True: yield result result *= i fac = async_factorial() for i in range(42): print(next(fac))

Программа выведет всю последовательность чисел факториала с номерами от 0 до 41.

Функция async_factorial() вернёт объект-генератор, который можно передать в функцию next() , а она продолжит выполнение корутины до следующего оператора yield с сохранением состояния всех локальных переменных функции. Функция next() возвращает то, что передаёт оператор yield внутри корутины. Таким образом, функция async_factorial() в теории имеет несколько точек входа и выхода.

Stackful и Stackless

В зависимости от использования стека корутины делятся на stackful, где каждая из корутин имеет свой стек, и stackless, где все локальные переменные функции сохраняются в специальном объекте.

Так как в корутинах мы можем в любом месте поставить оператор yield , нам необходимо где-то сохранять весь контекст функции, который включает в себя фрейм на стеке (локальные переменные) и прочую метаинформацию. Это можно сделать, например, полной подменой стека, как это делается в stackful корутинах.

На рисунке ниже вызов async создаёт новый стек-фрейм и переключает исполнение потока на него. Это практически новый поток, только исполняться он будет асинхронно с основным.

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

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

Более производительными, но вместе с тем и более ограниченными, являются stackless корутины. Они не используют стек, и компилятор преобразует функцию, содержащую корутины, в конечный автомат без корутин. Например, код:

Def fib(): a = 0 b = 1 while True: yield a a += b yield b b += a

Будет преобразован в следующий псевдокод:

Class fib: def __init__(self): self.a = 0 self.b = 1 self.__result: int self.__state = 0 def __next__(self): while True: if self.__state == 0: self.a = 0 self.b = 1 if self.__state == 0 or self.__state == 3: self.__result = self.a self.__state = 1 return self.__result if self.__state == 1: self.a += self.b self.__result = self.b self.__state = 2 return self.__result if self.__state == 2: self.b += a self.__state = 3 break

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

Симметричные и асимметричные

Корутины также делятся на симметричные и асимметричные.

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

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

Вывод

Асинхронное программирование является очень мощным инструментом для оптимизации высоконагруженных программ с частым ожиданием системы. Но, как и любую сложную технологию, её нельзя использовать только потому, что она есть. Необходимо всегда задавать себе вопрос: а нужна ли мне эта технология? Какую практическую пользу она мне даст? Иначе разработчики рискуют потратить очень много сил, времени и денег, не получив никакого профита.

Последнее обновление: 31.10.2015

В прошлых темах было рассмотрено применение асинхронности с использованием ключевым слов async и await. Но кроме подобной модели использования асинхронных вызовов в C# имеется и другая модель - использование асинхронных делегатов. Асинхронные делегаты широко использовались до появления в C# async и await, сейчас же async и await существенно упрощают написание асинхронного кода. Тем не менее асинхронные делегаты по прежнему могут применяться. Поэтому рассмотрим их.

Асинхронные делегаты позволяют вызывать методы, на которые эти делегаты указывают, в асинхронном режиме. В теме про делегаты говорилось, что делегаты могут вызываться как с помощью метода Invoke , так и в асинхронном режиме с помощью пары методов BeginInvoke/EndInvoke . Рассмотрим на примере. Вначале посмотрим, что будет, если мы будем использовать обычный синхронный код в нашем приложении:

Using System; using System.Threading; namespace AsyncApp { class Program { public delegate int DisplayHandler(); static void Main(string args) { DisplayHandler handler = new DisplayHandler(Display); int result = handler.Invoke(); Console.WriteLine("Продолжается работа метода Main"); Console.WriteLine("Результат равен {0}", result); Console.ReadLine(); } static int Display() { Console.WriteLine("Начинается работа метода Display...."); int result = 0; for (int i = 1; i < 10; i++) { result += i * i; } Thread.Sleep(3000); Console.WriteLine("Завершается работа метода Display...."); return result; } } }

Здесь создается специальный делегат DisplayHandler, который в качестве ссылки принимает метод без параметров, который возвращает число. В данном случае таким методом является метод Display, который выполняет какую-то работу. В этом случае мы получим примерно следующий вывод:

Начинается работа метода Display.... Завершается работа метода Display.... Продолжается работа метода Main Результат равен 285

В общем-то можно было и не использовать делегат и напрямую вызвать метод Display. Но в любом случае после его вызова дальше блокируется работа метода Main, пока не завершится выполнение метода Display.

Теперь изменим пример с применением асинхронных вызовов делегата:

Using System; using System.Threading; namespace AsyncApp { class Program { public delegate int DisplayHandler(); static void Main(string args) { DisplayHandler handler = new DisplayHandler(Display); IAsyncResult resultObj = handler.BeginInvoke(null, null); Console.WriteLine("Продолжается работа метода Main"); int result = handler.EndInvoke(resultObj); Console.WriteLine("Результат равен {0}", result); Console.ReadLine(); } static int Display() { Console.WriteLine("Начинается работа метода Display...."); int result = 0; for (int i = 1; i < 10; i++) { result += i * i; } Thread.Sleep(3000); Console.WriteLine("Завершается работа метода Display...."); return result; } } }

Суть действий практически не изменилась, тот же метод Display, только теперь он вызывается в асинхронном режиме с помощью методов BedinInvoke/EndInvoke . И теперь мы можем получить немного другой вывод:

Начинается работа метода Display.... Продолжается работа метода Main Завершается работа метода Display.... Результат равен 285

Таким образом, после вызова метода Display через выражение handler.BeginInvoke(null, null) работа метода Main не приостанавливается. А выполнение метода Display через делегат DisplayHandler происходит в другом потоке. И лишь когда выполнение в методе Main дойдет до строки int result = handler.EndInvoke(resultObj); он блокируется и ожидает завершения выполнения метода Display.

Теперь рассмотрим особенности использования методов BeginInvoke и EndInvoke и интерфейса IAsyncResult.

Последнее обновление: 17.10.2018

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

Ключевыми для работы с асинхронными вызовами в C# являются два ключевых слова: async и await , цель которых - упростить написание асинхронного кода. Они используются вместе для создания асинхронного метода.

Асинхонный метод обладает следующими признаками:

    В заголовке метода используется модификатор async

    Метод содержит одно или несколько выражений await

    В качестве возвращаемого типа используется один из следующих:

    • ValueTask

Асинхронный метод, как и обычный, может использовать любое количество параметров или не использовать их вообще. Однако асинхронный метод не может определеять параметры с модификаторами out и ref .

Также стоит отметить, что слово async , которое указывается в определении метода, не делает автоматически метод асинхронным. Оно лишь указывает, что данный метод может содержать одно или несколько выражений await .

Рассмотрим пример асинхронного метода:

Using System; using System.Threading; using System.Threading.Tasks; namespace HelloApp { class Program { static void Factorial() { int result = 1; for(int i = 1; i <= 6; i++) { result *= i; } Thread.Sleep(8000); Console.WriteLine($"Факториал равен {result}"); } // определение асинхронного метода static async void FactorialAsync() { Console.WriteLine("Начало метода FactorialAsync"); // выполняется синхронно await Task.Run(()=>Factorial()); // выполняется асинхронно Console.WriteLine("Конец метода FactorialAsync"); } static void Main(string args) { FactorialAsync(); // вызов асинхронного метода Console.WriteLine("Введите число: "); int n = Int32.Parse(Console.ReadLine()); Console.WriteLine($"Квадрат числа равен {n * n}"); Console.Read(); } } }

Здесь прежде всего определен обычный метод подсчета факториала. Для имитации долгой работы в нем используется задержка на 8 секунд с помощью метода Thread.Sleep() . Условно это некоторый метод, который выполняет некоторую работу продолжительное время. Но для упрощения понимания он просто подсчитывает факториал числа 6.

Также здесь определен асинхронный метод FactorialAsync() . Асинхронным он является потому, что имеет в определении перед возвращаемым типом модификатор async , его возвращаемым типом является void, и в теле метода определено выражение await .

Выражение await определяет задачу, которая будет выполняться асинхронно. В данном случае подобная задача представляет выполнение функции факториала:

Await Task.Run(()=>Factorial());

По негласным правилам в названии асинхроннных методов принято использовать суффикс Async - FactorialAsync () , хотя в принципе это необязательно делать.

Сам факториал мы получаем в асинхронном методе FactorialAsync . Асинхронным он является потому, что он объявлен с модификатором async и содержит использование ключевого слова await .

И в методе Main мы вызываем этот асинхронный метод.

Посмотрим, какой у программы будет консольный вывод:

Начало метода FactorialAsync Введите число: 7 Квадрат числа равен 49 Конец метода Main Факториал равен 720 Окончание метода FactorialAsync

Разберем поэтапно, что здесь происходит:

    Запускается метод Main, в котором вызывается асинхронный метод FactorialAsync.

    Метод FactorialAsync начинает выполняться синхронно вплоть до выражения await.

    Выражение await запускает асинхронную задачу Task.Run(()=>Factorial())

    Пока выполняется асинхронная задача Task.Run(()=>Factorial()) (а она может выполняться довольно продожительное время), выполнение кода возвращается в вызывающий метод - то есть в метод Main. В методе Main нам будет предложено ввести число для вычисления квадрата числа.

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

    Когда асинхронная задача завешила свое выполнение (в случае выше - подсчитала факториал числа), продолжает работу асинхронный метод FactorialAsync, который вызвал асинхронную задачу.

Функция факториала, возможно, представляет не самый показательный пример, так как в реальности в данном случае нет смысла делать ее асинхронной. Но рассмотрим другой пример - чтение-запись файла:

Using System; using System.Threading; using System.Threading.Tasks; using System.IO; namespace HelloApp { class Program { static async void ReadWriteAsync() { string s = "Hello world! One step at a time"; // hello.txt - файл, который будет записываться и считываться using (StreamWriter writer = new StreamWriter("hello.txt", false)) { await writer.WriteLineAsync(s); // асинхронная запись в файл } using (StreamReader reader = new StreamReader("hello.txt")) { string result = await reader.ReadToEndAsync(); // асинхронное чтение из файла Console.WriteLine(result); } } static void Main(string args) { ReadWriteAsync(); Console.WriteLine("Некоторая работа"); Console.Read(); } } }

Асинхронный метод ReadWriteAsync() выполнение запись в файл некоторой строки и затем считывает записанный файл. Подобные операции могут занимать продолжительное время, особенно при больших объемах данных, поэтому такие операции лучше делать асинхронными.

Фреймворк.NET уже имеет встроенную поддержку таких операций. Например, в классе StreamWriter определен метод WriteLineAsync() . По сути он уже представляет асинхронную операцию и принимает в качестве параметра некоторую строку, которую надо записать в файл. Поскольку этот метод представляет асинхронную операцию, то вызов этого метода мы можем оформить в выражение await :

Await writer.WriteLineAsync(s); // асинхронная запись в файл

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

Во фреймворке.NET Core определено много подобных методов. Как правило, они связаны с работой с файлами, отправкой сетевых запросов или запросов к базе данных. Их легко узнать по суффиксу Async . То есть если метод имеет подобный суффикс в названии, то с большей степенью вроятности его можно использовать в выражении await.

Static void Main(string args) { ReadWriteAsync(); Console.WriteLine("Некоторая работа"); Console.Read(); }

И опять же когда выполнение в методе ReadWriteAsync доходит до первого выражения await, управление возвращается в метод Main, и мы можем продолжать с ним работу. Запись в файл и считывания файла будут производиться параллельно и не будут блокировать работу метода Main.

Определение асинхронной операции

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

StreamWriter writer = new StreamWriter("hello.txt", false); await writer.WriteLineAsync("Hello"); // асинхронная запись в файл

Либо мы сами можем определить асинхронную операцию, используя метод Task.Run() :

Static void Factorial() { int result = 1; for (int i = 1; i <= 6; i++) { result *= i; } Thread.Sleep(8000); Console.WriteLine($"Факториал равен {result}"); } // определение асинхронного метода static async void FactorialAsync() { await Task.Run(()=>Factorial()); // вызов асинхронной операции }

Можно определить асинхронную операцию с помощью лямбда-выражения:

Static async void FactorialAsync() { await Task.Run(() => { int result = 1; for (int i = 1; i <= 6; i++) { result *= i; } Thread.Sleep(8000); Console.WriteLine($"Факториал равен {result}"); }); }

Передача параметров в асинхронную операцию

Выше вычислялся факториал 6, но, допустим, мы хотим вычислять факториалы разных чисел:

Using System; using System.Threading; using System.Threading.Tasks; namespace HelloApp { class Program { static void Factorial(int n) { int result = 1; for (int i = 1; i <= n; i++) { result *= i; } Thread.Sleep(5000); Console.WriteLine($"Факториал равен {result}"); } // определение асинхронного метода static async void FactorialAsync(int n) { await Task.Run(()=>Factorial(n)); } static void Main(string args) { FactorialAsync(5); FactorialAsync(6); Console.WriteLine("Некоторая работа"); Console.Read(); } } }

Получение результата из асинхронной операции

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

Using System; using System.Threading; using System.Threading.Tasks; namespace HelloApp { class Program { static int Factorial(int n) { int result = 1; for (int i = 1; i <= n; i++) { result *= i; } return result; } // определение асинхронного метода static async void FactorialAsync(int n) { int x = await Task.Run(()=>Factorial(n)); Console.WriteLine($"Факториал равен {x}"); } static void Main(string args) { FactorialAsync(5); FactorialAsync(6); Console.Read(); } } }

Метод Factorial возвращает значение типа int, это значение мы можем получить, просто присвоив результат асинхронной операции переменной данного типа: int x = await Task.Run(()=>Factorial(n));

mob_info