Асинхронностью в программировании называется возможность приоритетного выполнения определенных задач без ожидания сигнала об их завершении и вызванной его отсутствие блокировки других работающих программ. Этот способ разработки программных продуктов особенно часто используется на C#, так как именно этот язык содержит большое количество инструментов для разработки асинхронных приложений. Попробуем разобраться, что означает этот сложный для восприятия термин – асинхронность, а также каких преимуществ позволяет добиться его грамотное применение с использованием возможностей C#.
Стандартный механизм (другое часто применяемое название – синхронный) функционирования процессора предусматривает одновременное выполнение только одной задачи. Именно поэтому даже некоторые сравнительно простые действия, например, сложение значений из двух ячеек памяти, необходимо совершить сразу четыре операции:
Описанные операции называются атомарными или неделимыми. Более сложные действия включают сразу несколько атомарных, иногда – очень много. Даже представленное выше сложение содержит четыре тика. Умножение предусматривает еще больше операций. А арифметические действия с цифрами после запятой даже сложно представить визуально.
Важно отметить, что мощности современных процессоров очень велики. Они измеряются в ГГц, в каждом из которых 1 млрд. атомарных операций или гиков. Стандартное значение производительности обычного процессора – 2-3 ГГц. Именно такие огромные цифры маскируют неспособность компьютера выполнять сразу несколько операций, которую обычный пользователь попросту не замечает.
Любая программа представляет собой множество процессов. Их линейное выполнение очень неудобно, так как приведет к ситуации, когда до завершения одной программы все остальные будут блокированы. А у каждого пользователя ПК одновременно открыто несколько вкладок или совершается большое количество различных действий.
Чтобы обеспечивалась видимость одновременной работы всех запущенных программ, используется принцип многопоточности. Когда процессор последовательно выполняет часть каждой из них с учетом заданных пользователем приоритетов. Наличие нескольких потоков обмена данными позволяет создать иллюзию, что ПК выполняет все программы параллельно, которая дополняется серьезной мощностью процессора.
Лучший пример асинхронности – это операционная система, которая прямо сейчас обрабатывает на заднем фоне десятки и сотни потоков, пока вы читаете эту статью. В далекие времена, когда на рынке доминировал MS-DOS, операционные системы были синхронными – то есть ОС выполняла одно действие за раз, а все остальные действия ставились на паузу. Когда конкурирующие Билл Гейтс и Стив Джобс решили сделать компьютеры «домашними», перед обоими встала одна и та же проблема – их текущие операционные системы представляли собой черное окно консоли со строчкой для ввода, и домохозяйки вряд ли когда-либо разобрались бы, что с этой консолью делать. Решением стали окошки – элементы графического интерфейса, на которых можно было наглядно предоставлять информацию. Но у окошек была одна фундаментальная проблема: на отрисовку изменений в окошке требуются усилия процессора, и пока процессор занят, ничего другого делать нельзя. Решением и стала асинхронность, которую ввели на уровне операционной системы. Как это все выглядит:
Это решает множество проблем: теперь ОС сама заботится о безопасности, может убить зависший процесс, не дает сторонним программам съедать все ресурсы, может распределять задачи по разным процессорам (многопоточность), может отслеживать дедлоки. Deadlock – это одна из самых сложных проблем асинхронности (и многопоточности), о проблемах мы поговорим ниже. Но всю эту информацию мы дали для того, чтобы вы поняли одну важную вещь: асинхронность внутри приложения (= процесса) работает так же, как асинхронность внутри ОС. Только разные потоки исполнения (то, что можно «закинуть» на процессор для обработки) в ОС называются процессами, а в приложении – потоками.
При реализации асинхронности вы создаете несколько потоков исполнения внутри вашей программы, и операционная система сама решает, каким потокам дать приоритетный доступ к процессору.
Держите это в голове, когда будете читать про проблемы асинхронности.
Асинхронность представляет собой концепцию программирования, при которой сигналы о результате исполнения той или иной операции направляется в формате нестандартного, то есть асинхронного вызова. Это позволяет запускать длительные операции без необходимости ждать их завершения с одновременной блокировкой дальнейшей работы всей программы. Особенно часто асинхронность используется для программ с графическим интерфейсом, для которых характерно выполнение сложных инструкций.
Кроме того, асинхронные программы повышают стабильность, отзывчивость и скорость работы приложений с такими характеристиками:
Крайне актуальными являются сегодня серверные платформы, способные работать асинхронно. Главным их преимуществом становится возможность отличить рабочий процесс приложения, которое обрабатывает запрос пользователя. Результатом становится приоритетное предоставление данных именно в этом направлении. Примером подобной платформы выступает Node.js.
Их, в общем-то, две: утечка ресурсов и deadlock. Утечка ресурсов случается тогда, когда вы создаете поток, который резервирует себе ресурсы (память, например), а затем просто висит, ничего не делая.
В целом современные языки программирования имеют встроенные механизмы для предотвращения утечек ресурсов, но программист все еще может обойти эту защиту. Например, код создает новый поток, в котором что-то вычисляется. Если в течение некоторого времени ответ не приходит – код снова создает такой же поток. При этом внутри потока есть какая-то зависимость от внешнего кода или ресурса – нужно дождаться ответа от сервера, например. Если интернет пропадет, то код будет снова и снова создавать одинаковые потоки, каждый из которых будет просить себе кусок оперативной памяти. Проблему сможет решить только операционная система – в какой-то момент она заметит, что приложение требует больше ресурсов, чем есть у компьютера, и ОС решит проблему своим излюбленным способом: просто убьет подозрительное приложение.
Вторая проблема – это deadlock. Суть проблемы очень проста: у нас есть поток А и поток Б, поток А ожидает ответа от потока Б, поток Б ожидает ответа от потока А. Как вариант – поток А заблокировал ресурс, нужный потоку Б; поток Б заблокировал ресурс, нужный потоку А. Оба потока будут бесконечно ждать либо ответа друг от друга, либо ресурса.
В C# встроен ряд инструментов, которые предотвращают мертвые замки, поэтому разработчики на C# встречаются с дедлоками намного реже, чем, например, разработчики на C++. Но «встрять» все еще можно, особенно – если вы только начинаете работать с асинхронностью с C#. Детальное рассмотрение таких кейсов выходит за рамки этой статьи, но есть материал на Хабре, который раскладывает тему по полочкам (предупреждаем, статья – довольно сложная для понимания).
Всего есть 3 варианта реализации асинхронности: async/await, coroutine и callback.
Основная реализация, которую стоит использовать. Ключевое слово async в описании метода указывает на то, что этот метод – асинхронный, и его надо запускать в отдельном потоке. Ключевое слово await указывает на то, что нужно дождаться, пока асинхронный метод закончит свою работу, и только после этого продолжать исполнение.
using System;
using System.Threading.Tasks;
public class Program
{
public static async Task Main(string[] args)
{
LongProcess();
ShortProcess();
Console.ReadKey();
}
public static async void LongProcess()
{
Console.WriteLine("LongProcess Started");
await Task.Delay(4000);
Console.WriteLine("LongProcess Completed");
}
static void ShortProcess()
{
Console.WriteLine("ShortProcess Started");
Console.WriteLine("ShortProcess Completed");
}
}
В примере мы имеем два метода, которые запускаются из main. Поскольку LongProcess() – асинхронный, main продолжает исполняться после того, как LongProcess() был запущен. Внутри длинного процесса используется
await Task.Delay(4000);
– задержка в 4 секунды, реализованная в стандартном классе Task. На выходе получим следующее:
LongProcess Started
ShortProcess Started
ShortProcess Completed
LongProcess Completed
А что делать, если метод возвращает значение? Асинхронные методы могут возвращать либо void, либо Task, с типом или без. Соответственно, в качестве возвращаемого значения нам нужно указать Task<тип>, после чего уже возвращать само значение. При необходимости мы можем «настроить» порядок возврата значений из разных потоков через await:
using System;
using System.Threading.Tasks;
public class Program
{
static async Task Main(string[] args)
{
Task<int> result1 = LongProcess1();
Task<int> result2 = LongProcess2();
//do something here
Console.WriteLine("After two long processes.");
int val = await result1; // wait untile get the return value
DisplayResult(val);
val = await result2; // wait untile get the return value
DisplayResult(val);
Console.ReadKey();
}
static async Task<int> LongProcess1()
{
Console.WriteLine("LongProcess 1 Started");
await Task.Delay(4000); // hold execution for 4 seconds
Console.WriteLine("LongProcess 1 Completed");
return 10;
}
static async Task<int> LongProcess2()
{
Console.WriteLine("LongProcess 2 Started");
await Task.Delay(4000);
Console.WriteLine("LongProcess 2 Completed");
return 20;
}
static void DisplayResult(int val)
{
Console.WriteLine(val);
}
}
На выходе получаем:
LongProcess 1 Started
LongProcess 2 Started
After two long processes.
LongProcess 2 Completed
LongProcess 1 Completed
10
20
Корутины – это функции, которые запускаются в отдельных потоках и возвращают значения каждый раз, когда к ним обращаются. Если внутри корутины написать счетчик, начинающийся с нуля – при первом обращении она вернет 0, при втором – 1, при третьем – 2 и так далее. Понятие «корутины» стало популярным после расцвета Unity, где корутины часто используются – до Unity они обычно назывались нумераторами. Аналоги корутин есть во многих языках – в Python их, например, называют итераторами.
Реализация корутины на примере Unity:
IEnumerator Fade()
{
for (float ft = 1f; ft >= 0; ft -= 0.1f)
{
Color c = renderer.material.color;
c.a = ft;
renderer.material.color = c;
yield return null;
}
}
void Update()
{
if (Input.GetKeyDown("f"))
{
StartCoroutine("Fade");
}
}
void Update() вызывается каждый фрейм, и каждый фрейм идет проверка: если нажата «f», то запускается корутина, которая что-то делает и возвращает null. Внутри Fade() есть цикл for, и с каждым вызовом корутины этот цикл прогоняет одну свою итерацию, поскольку после исполнения yield корутина становится на паузу до следующего вызова.
Это – устаревший метод использования асинхронности, который сейчас применяется значительно реже. Колбэк – это функция, которую мы запускаем в отдельном потоке с намерением получить ответ «когда-нибудь». Самая полезная реализация – через IAsyncResult:
strm.Read(buffer, 0, buffer.Length);
IAsyncResult result = strm.BeginRead(buffer, 0, buffer.Length, null, null);
// Что-то делаем
int numBytes = strm.EndRead(result);
Здесь мы запускаем чтение из потока данных в result с помощью callback-функции, которая стартует в отдельном потоке. Далее мы что-то делаем в методе, когда закончили делать то, что нам нужно – вызываем EndRead, забираем результат и идем дальше по потоку выполнения.
При желании мы можете создать массив коллбэков и делать что-то, пока все вызванные в отдельных потоках функции не вернут значения. Но это чревато дэдлоками, поэтому делать так нужно с оглядкой на отказоустойчивость асинхронного кода.
Для еще большей наглядности имеет смысл привести образец асинхронного приложения. Он делится на два потока: один обновляет полосу загрузки, второй – непосредственно осуществляет эту загрузку. Ниже приводится программный код с комментариями предназначения основных его частей.
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Text;
namespace Async
{
class Program
{
static int full = 100;
static int completed = 0;
static int state = 0;
static char[] cursors = new char[] { '-', '/', '|', '\\' };
static void Main(string[] args)
{
LoadAsync(); //Старт загрузки
UpdateLoading(); //Старт обновления полосы загрузки
Console.ReadKey();
Console.WriteLine();
}
static void UpdateLoading() //Этот метод каждые 100 миллисекунд будет стирать старую полосу загрузки, а потом выводить новую
{
while(completed <= full)
{
Console.Clear();
state++;
if(state == 4)
{
state = 0;
}
string loadingBar = GetLoadingString();
Console.WriteLine(loadingBar + " " + cursors[state]);
Thread.Sleep(100);
}
}
static string GetLoadingString() //Метод, который создаёт текстовую полосу загрузки
{
StringBuilder loadingBar = new StringBuilder("[");
for(int i = 0; i <= full; i++)
{
if(i < completed)
{
loadingBar.Append("#");
}
else
{
loadingBar.Append(".");
}
}
loadingBar.Append($"] {completed} %");
return loadingBar.ToString();
}
static async void LoadAsync() //Асинхронный метод, который создаёт поток для выполнения метода Load()
{
await Task.Run(()=>Load()); //Метод ожидает выполнения метода Load()
}
static void Load() //Метод загрузки, который каждые 500 миллисекунд прибавляет к значению completed единицу
{
for(int i = 0; i <= full; i++)
{
completed++;
Thread.Sleep(500);
}
}
}
}
Запуск программы позволяет добиться более частого обновления курсора загрузки перед указанием значения процентов выполнения операции. Точно в 5 раз, что наглядно показывает действие асинхронности.
Важно понимать, что выше приводится только беглый обзор таких понятий как асинхронность, параллелизм и многопоточность. Каждый из этих инструментов при грамотном использовании станет мощным и очень полезным подспорьем в работе программиста. Но их неправильное применение часто оборачивается серьезными сбоями в работе программного обеспечения. А потому пользоваться ими нужно с предельной осторожностью.
Под асинхронным способом программирования понимается задействование нестандартного по обычным меркам метода работы процессора, предусматривающего запуск длительных задач без ожидания их окончания и блокировки всех остальных программ.
При грамотном применении асинхронные приложения работают стабильнее и производительнее, что особенно важно, если речь идет о необходимости решения серьезных вычислительных задач или быстрого получения ответа на запрос пользователя.
Основной областью использования асинхронности выступают требовательные приложения с большим объемом обмена данными или множества решаемых задач. В том числе – в программах с графическим типом интерфейса.
Наиболее часто для разработки асинхронных программ используется язык C#, так как его возможности наилучшим образом позволяют реализовать преимущества этого способа программирования.