Як async/await дійсно працює в C#. Частина 1

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Вітаю, мене звати Юрій Рожков. Займаюсь автоматизацією тестування, розробкою тестових фреймворків, по можливості закриваю задачі девелоперів і налаштовую інфраструктуру. Так сталось, що крайні 7 років мій техстек роботи — це .NET/C# і все, з ним пов’язане. Нещодавно зіткнувся з цікавою статтею про async/await, про що й буде далі мова. Стаття є перекладом, оригінал знаходиться тут.

Нещодавно .NET Blog опублікували статтю Що таке .NET, і чому вам варто його обрати? (‘What is .NET, and why should you choose it?’). В ній надається поверхневий огляд платформи, різних компонентів і рішень дизайну, з подальшим поглибленим вивченням тем. Ця стаття є першою з циклу, з детальним оглядом історії, прийнятих рішень та імплементації async/await в C# та .NET.

Підтримка async/await існує вже понад 10 років. На сьогодні це трансформувалось в те, як пишеться масштабований код на .NET, і доволі життєво стало користуватись функціоналом без розуміння, що відбувається «під капотом». Почнемо з синхронного методу («синхронний» тому, що при його виклику нічого не можливо зробити, поки вся операція не завершиться і контроль не перейде до основної програми):

// Synchronously copy all data from source to destination.
public void CopyStreamToStream(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }
}

Додаємо пару фраз, міняємо назви методів, і ви зупинитесь на асинхронному методі («асинхронний» тому, що контроль досить швидко повертається до основної програми, ймовірно до того, як робота метода буде завершена):

// Asynchronously copy all data from source to destination.
public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

Майже ідентичний синтаксис, ті ж самі конструкції, але тепер неблокуюча поведінка, зі значно різною моделлю виконання, і з величезною зміною у роботі C# компілятора та бібліотек ядра.

Хоча достатньо поширене використання чогось без знання роботи «під капотом», твердо вірю, що розуміння того, як це щось дійсно працює, допоможе зробити речі краще. Для async/await зокрема, розуміння механізмів дійсно корисне, коли ви дебажите проблемні місця або намагаєтесь покращити перформанс. Для цього нам треба повернутись в часі назад, до появи async/await.

На початку...

В .NET Framework 1.0 був патерн асинхронної програмної моделі (APM), інакше відомий як Begin/End pattern, або IAsyncResult патерн. Поверхнево, патерн простий. Для синхронної операції DoStuff:

class Handler
{
    public int DoStuff(string arg);
}

буде 2 відповідних метода BeginDoStuff, EndDoStuff:

class Handler
{
    public int DoStuff(string arg);

    public IAsyncResult BeginDoStuff(string arg, AsyncCallback? callback, object? state);
    public int EndDoStuff(IAsyncResult asyncResult);
}

BeginDoStuff приймає ті ж параметри що і DoStuff + делегат AsyncCallback та об’єкт стану object, які можуть бути null. Begin метод відповідає за початок асинхронної дії, при наявному callback параметру (продовження основної операції) також відповідає за його виклик після закінчення асинхронної операції. Begin метод також будує інстанс інтерфейсу IAsyncResult з опціональним полем стану AsyncState:

namespace System
{
    public interface IAsyncResult
    {
        object? AsyncState { get; }
        WaitHandle AsyncWaitHandle { get; }
        bool IsCompleted { get; }
        bool CompletedSynchronously { get; }
    }

    public delegate void AsyncCallback(IAsyncResult ar);
}

IAsyncResult інстанс буде повернутий з Begin методу і переданий в AsyncCallback, коли його буде врешті викликано. При готовності обробити результати операції програма передає інстанс IAsyncResult в End метод, котрий відповідає за перевірку завершення операції (синхронно очікуючи), і наприкінці повертає будь-який результат, в т.ч. будь-які помилки/ ексепшени. Таким чином, замість написання синхронного коду:

try
{
    int i = handler.DoStuff(arg); 
    Use(i);
}
catch (Exception e)
{
    ... // handle exceptions from DoStuff and Use
}

Begin/End методи можуть бути використані для тієї ж операції асинхронно:

try
{
    handler.BeginDoStuff(arg, iar =>
    {
        try
        {
            Handler handler = (Handler)iar.AsyncState!;
            int i = handler.EndDoStuff(iar);
            Use(i);
        }
        catch (Exception e2)
        {
            ... // handle exceptions from EndDoStuff and Use
        }
    }, handler);
}
catch (Exception e)
{
    ... // handle exceptions thrown from the synchronous call to BeginDoStuff
}

Для всіх, хто мав справу з callback-based API, це має бути знайомим.

Далі все стає складніше. Наприклад, проблема «занурення у стек» (stack dives). Це коли код повторно робить виклики, що ведуть далі і далі по стеку до моменту потенційного переповнення стека (stack overflow). Begin викликає callback синхронно, тільки якщо операція завершена синхронно, виклик Begin може сам по собі викликати callback.

Асинхронні операції що завершуються синхронно, доволі поширені; для них асинхронне завершення скоріше дозволено, ніж гарантовано. Уявіть асинхронне читання операції з мережі, наприклад з сокета. Якщо вам потрібна невелика кількість даних, таких як читання заголовків (header data) з респонсу, ви можете додати буфер, щоб уникнути частих системних викликів.

Замість читання невеликих об’ємів необхідних даних, виконується читання великого об’єму в буфер і читання з нього, допоки він не буде вичерпаний; це дозволяє скоротити кількість важких викликів по сокету. Буфер може бути за будь-якою асинхронною абстракцією, так перша «асинхронна» операція (заповнення буфера) завершується асинхронно, але потім всі наступні операції, поки буфер не буде вичерпаний, не потрібні, і можуть бути завершені синхронно. Коли Begin метод виконує одну з таких операцій, і вона виявляється завершеною синхронно, він може викликати колбек синхронно.

Це значить, що є 1 стек фрейм (stack frame) з назвою метода Begin, інший стек фрейм для самого метода, і ще один — для колбеку. Що трапиться, якщо колбек викликає Begin знову? Якщо операція завершується синхронно і її колбек був викликаний синхронно, маємо на декілька стек-фреймів більше. І так, допоки не стикнемось з переповненням.

Це доволі легко репродьюсити. Спробуйте наступне:

using System.Net;
using System.Net.Sockets;

using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
listener.Listen();

using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
client.Connect(listener.LocalEndPoint!);

using Socket server = listener.Accept();
_ = server.SendAsync(new byte[100_000]);

var mres = new ManualResetEventSlim();
byte[] buffer = new byte[1];

var stream = new NetworkStream(client);

void ReadAgain()
{
    stream.BeginRead(buffer, 0, 1, iar =>
    {
        if (stream.EndRead(iar) != 0)
        {
            ReadAgain(); // uh oh!
        }
        else
        {
            mres.Set();
        }
    }, null);
};
ReadAgain();
mres.Wait();

Тут я засетапив прості клієнт і сервер для сокета, підключені між собою. Сервер відправляє 100,000 байтів клієнту, котрі обробляються «асинхронно» в методах BeginRead/EndRead по одному (що вкрай неефективно). В методі BeginRead колбек закінчує роботу викликом метода EndRead, якщо байт даних прочитаний (що не кінець стріму), він викликає BeginRead ще раз рекурсивно в локальній функції ReadAgain.

Хоча в .NET Core операції з сокетами відпрацьовують набагато швидше, ніж це було в .NET Framework, і вони будуть завершені синхронно, якщо це підтримується ядром ОС, отримаємо переповнення стеку:

Є 2 шляхи виправити це:

  1. Не дозволяти викликати AsyncCallback синхронно. Якщо він асинхронний, не зважаючи на синхронну операцію, ризик переповнення стеку зникає, але так само просідає перформанс, додавання колбеку в чергу накладає свої витрати.
  2. Задіяти механізм, при якому основна операція замість колбеку виконує роботу по завершенню синхронно. В такому випадку отримаємо ще один фрейм у стеку.

APM слідує пункту 2. IAsyncResult інтерфейс прокидає IsCompleted та CompletedSynchronously. IsCompleted вказує, чи завершена операція; можна перевіряти його декілька разів, врешті значення перейде з false на true. На відмінність від нього, CompletedSynchronously ніколи не змінює стан (або це дуже брудний баг); він використовується для комунікації між основним методом Begin та AsyncCallback, котрий відповідає за роботу по завершенню.

Якщо CompletedSynchronously = false, тоді операція завершується асинхронно і вся робота після цього має бути залишена для колбеку; після всього, якщо робота не завершена синхронно, виклик методу Begin не може обробити його, тому що невідомо, чи завершена операція (і якщо був викликаний метод End, він блокує поки операція не завершена).

Якщо CompletedSynchronously = true, і якщо колбек обробляє роботу після завершення, то існує ризик занурення у стек. Таким чином, будь-яка імплементація у зв’язку з проблемою «stack dives» має перевіряти CompletedSynchronously і виклик методу Begin має виконувати роботу після завершення, тільки якщо воно = true, тобто колбек не має виконувати роботу після завершення. Тому CompletedSynchronously ніколи не має змінюватись: основний виклик і колбек мають бачити одне й теж саме значення для впевненості, що робота по завершенню виконана один і тільки один раз, незважаючи на race conditions.

Наш попередній приклад DoStuff стає:

try
{
    IAsyncResult ar = handler.BeginDoStuff(arg, iar =>
    {
        if (!iar.CompletedSynchronously)
        {
            try
            {
                Handler handler = (Handler)iar.AsyncState!;
                int i = handler.EndDoStuff(iar);
                Use(i);
            }
            catch (Exception e2)
            {
                ... // handle exceptions from EndDoStuff and Use
            }
        }
    }, handler);
    if (ar.CompletedSynchronously)
    {
        int i = handler.EndDoStuff(ar);
        Use(i);
    }
}
catch (Exception e)
{
    ... // handle exceptions that emerge synchronously from BeginDoStuff and possibly EndDoStuff/Use
}

Без коментарів. Поки що ми тільки розглянули використання патерну, не імплементацію... Хоча більшості девелоперів не потрібно перейматись реалізацією таких операцій як Socket.BeginReceive/EndReceive, багатьом з них необхідно думати про композицію таких операцій (виконання декількох асинхронних операцій), що означає не тільки використання інших Begin/End методів, але й їхня власна імплементація. Ви могли помітити відсутність контролю виконання в попередньому прикладі DoStuff. Додайте до цього пару простих операцій, як цикли, і це стає зоною страждань, або спробою автора блогу довести свою точку зору.

Давайте для повної картини реалізуємо весь приклад. На початку, я показав метод CopyStreamToStream, котрий копіює всі дані з одного стріма в інший (а-ля Stream.CopyTo, але заради експерименту уявимо, що його нема):

public void CopyStreamToStream(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }
}

По кроках: ми поступово читаємо з одного стріма і пишемо результат в інший, читаємо-пишемо, і так далі, поки більше не маємо даних для читання. Як нам реалізувати це асинхронно через АРМ паттерн? Щось подібне:

public IAsyncResult BeginCopyStreamToStream(
    Stream source, Stream destination,
    AsyncCallback callback, object state)
{
    var ar = new MyAsyncResult(state);
    var buffer = new byte[0x1000];

    Action<IAsyncResult?> readWriteLoop = null!;
    readWriteLoop = iar =>
    {
        try
        {
            for (bool isRead = iar == null; ; isRead = !isRead)
            {
                if (isRead)
                {
                    iar = source.BeginRead(buffer, 0, buffer.Length, static readResult =>
                    {
                        if (!readResult.CompletedSynchronously)
                        {
                            ((Action<IAsyncResult?>)readResult.AsyncState!)(readResult);
                        }
                    }, readWriteLoop);

                    if (!iar.CompletedSynchronously)
                    {
                        return;
                    }
                }
                else
                {
                    int numRead = source.EndRead(iar!);
                    if (numRead == 0)
                    {
                        ar.Complete(null);
                        callback?.Invoke(ar);
                        return;
                    }

                    iar = destination.BeginWrite(buffer, 0, numRead, writeResult =>
                    {
                        if (!writeResult.CompletedSynchronously)
                        {
                            try
                            {
                                destination.EndWrite(writeResult);
                                readWriteLoop(null);
                            }
                            catch (Exception e2)
                            {
                                ar.Complete(e);
                                callback?.Invoke(ar);
                            }
                        }
                    }, null);

                    if (!iar.CompletedSynchronously)
                    {
                        return;
                    }

                    destination.EndWrite(iar);
                }
            }
        }
        catch (Exception e)
        {
            ar.Complete(e);
            callback?.Invoke(ar);
        }
    };

    readWriteLoop(null);

    return ar;
}

public void EndCopyStreamToStream(IAsyncResult asyncResult)
{
    if (asyncResult is not MyAsyncResult ar)
    {
        throw new ArgumentException(null, nameof(asyncResult));
    }

    ar.Wait();
}

private sealed class MyAsyncResult : IAsyncResult
{
    private bool _completed;
    private int _completedSynchronously;
    private ManualResetEvent? _event;
    private Exception? _error;

    public MyAsyncResult(object? state) => AsyncState = state;

    public object? AsyncState { get; }

    public void Complete(Exception? error)
    {
        lock (this)
        {
            _completed = true;
            _error = error;
            _event?.Set();
        }
    }

    public void Wait()
    {
        WaitHandle? h = null;
        lock (this)
        {
            if (_completed)
            {
                if (_error is not null)
                {
                    throw _error;
                }
                return;
            }

            h = _event ??= new ManualResetEvent(false);
        }

        h.WaitOne();
        if (_error is not null)
        {
            throw _error;
        }
    }

    public WaitHandle AsyncWaitHandle
    {
        get
        {
            lock (this)
            {
                return _event ??= new ManualResetEvent(_completed);
            }
        }
    }

    public bool CompletedSynchronously
    {
        get
        {
            lock (this)
            {
                if (_completedSynchronously == 0)
                {
                    _completedSynchronously = _completed ? 1 : -1;
                }

                return _completedSynchronously == 1;
            }
        }
    }

    public bool IsCompleted
    {
        get
        {
            lock (this)
            {
                return _completed;
            }
        }
    }
}

Трясця! І навіть ця абракадабра — не найкраща реалізація. Наприклад, імплементація IAsyncResult блокує кожну операцію, замість того, щоб працювати без блокувань де це можливо, Exception передається в чистому вигляді замість ExceptionDispatchInfo, котрий дозволяє накопичувати стек викликів, багато розподілу пам’яті в кожній окремій операції (напр. виділений делегат на кожен виклик BeginWrite) тощо. Уявіть все це для кожного реюзабельного метода з використанням асинхронних операцій. А також комбінації з декількома дискретними IAsyncResults (згадаємо Task.WhenAll). Кожна операція реалізує своє специфічне АПІ, що означає відсутність спільної мови між ними (хоча деякі розробники намагались полегшити цей тягар через окремий шар колбеків).

Вся ця складність означає, що дехто намагався це робити, і скоріш за все стикався з лютими багами. По правді кажучи, це не критика АРМ патерна, а скоріше асинхронності на колбеках в цілому.

Ми так звикли до міці і простоти в сучасних мовах програмування що контролює потік виконання, що підходи на колбеках зазвичай стикаються з такими складними конструкціями. Жодна інша популярна мова не мала кращої альтернативи.

Нам було потрібне інше рішення, з урахуванням, що було зроблено правильно і неправильно. Цікаво, що АРМ патера це тільки патерн; ані runtime, ані бібліотеки ядра або компілятор не надають підтримки в його використанні або реалізації.

Патерн на асинхронних подіях (Event-Based Asynchronous Pattern)

.NET Framework 2.0 надав пару АПІ з інших підходом до обробки асинхронних операцій, один з яких призначений для роботи в контексті клієнтських застосунків. Це Event-based Asynchronous Pattern, або EAP, з методом для початку асинхронної операції, і івентом для очікування її завершення. Таким чином, наш попередній приклад з DoStuff стає:

class Handler
{
    public int DoStuff(string arg);

    public void DoStuffAsync(string arg, object? userToken);
    public event DoStuffEventHandler? DoStuffCompleted;
}

public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);

public class DoStuffEventArgs : AsyncCompletedEventArgs
{
    public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) :
        base(error, canceled, usertoken) => Result = result;

    public int Result { get; }
}

Робота по завершенню реєструється в івенті DoStuffCompleted, потім викликається метод DoStuffAsync; він стартує операцію, і до її завершення DoStuffCompleted буде асинхронно викликатись з основного метода. Потім хендлер може запустити роботу по завершенню, напр. валідацію userToken, при цьому дозволяючи запуск декількох хендлерів одночасно.

Цей паттерн спрощує одні і суттєво ускладнює інші кейси (і попередній приклад з АРМ CopyStreamToStream). Він не отримав широкого розповсюдження, з’явився в одному релізі .NET Framework і залишив після себе АПІ, наприклад, Ping.SendAsync/Ping.PingCompleted:

public class Ping : Component
{
    public void SendAsync(string hostNameOrAddress, object? userToken);
    public event PingCompletedEventHandler? PingCompleted;
    ...
}

Однак він залишив одну помітну річ, якою ми користуємось дотепер: SynchronizationContext. Він був представлений в .NET Framework 2.0 як абстракція для загального планувальника. Зокрема, найбільш вживаний метод SynchronizationContext-у — метод Post, котрий додає в чергу будь-що, представлене планувальником. Наприклад, базова імплементація SynchronizationContext — ThreadPool, базова імплементація SynchronizationContext.Post — ThreadPool.QueueUserWorkItem, котрий викликає з ThreadPool колбек зі статусом одного з потоків пула. З усім тим, основа основ SynchronizationContext-у — скоріше в підтримці планувань у необхідному вигляді для різних застосунків.

Згадайте UI-фреймворк Windows Forms. Як і в більшості фреймворків на Windows, контроли асоційовані з відповідним потоком, і цей потік запускає повідомлення до відповідної роботи з цими контролами: тільки цей потік має керувати цими контролами, і будь-який інший потік має відправити повідомлення в купу UI потоків для обробки. У Windows Forms це легко робиться завдяки Control.BeginInvoke, котрий передає в чергу відповідний делегат з аргументами які асоціюють потік з цим контролом. Наприклад:

private void button1_Click(object sender, EventArgs e)
{
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        button1.BeginInvoke(() =>
        {
            button1.Text = message;
        });
    });
}

Робота методу ComputeMessage() виконується в ThreadPool (задля збереження UI активним під час виконання потоку), і коли робота завершена, передається делегат назад в потік, пов’язаний з button1 для оновлення лейбла кнопки. Досить легко. WPF має щось аналогічне, з його типом Dispatcher:

private void button1_Click(object sender, RoutedEventArgs e)
{
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        button1.Dispatcher.InvokeAsync(() =>
        {
            button1.Content = message;
        });
    });
}

І .NET MAUI має щось схоже. Далі, збережемо цю логіку в хелпер метод.

// Call ComputeMessage and then invoke the update action to update controls.
internal static void ComputeMessageAndInvokeUpdate(Action<string> update) { ... }

І використаємо його:

private void button1_Click(object sender, EventArgs e)
{
    ComputeMessageAndInvokeUpdate(message => button1.Text = message);
}

Постає питання: як використати ComputeMessageAndInvokeUpdate у всіх інших застосунках? Хардкодити його для кожного UI-фреймворку? Тут і з’являється SynchronizationContext. Додаємо метод:

internal static void ComputeMessageAndInvokeUpdate(Action<string> update)
{
    SynchronizationContext? sc = SynchronizationContext.Current;
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        if (sc is not null)
        {
            sc.Post(_ => update(message), null);
        }
        else
        {
            update(message);
        }
    });
}

Він використовує SynchronizationContext, щоб вказати, як повернути планувальник до необхідного середовища для взаємодії з UI. Кожна модель реалізує свій тип, похідний від SynchronizationContext, наприклад, Windows Forms має такий тип:

public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable
{
    public override void Post(SendOrPostCallback d, object? state) =>
        _controlToSendTo?.BeginInvoke(d, new object?[] { state });
    ...
}

WPF :

public sealed class DispatcherSynchronizationContext : SynchronizationContext
{
    public override void Post(SendOrPostCallback d, Object state) =>
        _dispatcher.BeginInvoke(_priority, d, state);
    ...
}

ASP.NET раніше мав аналогічне і не займався запуском потоку, а скоріше забезпечував, щоб декілька потоків одночасно не мали доступу до HttpContext при серіалізації конкретного реквеста:

internal sealed class AspNetSynchronizationContext : AspNetSynchronizationContextBase
{ 
    public override void Post(SendOrPostCallback callback, Object state) =>
        _state.Helper.QueueAsynchronous(() => callback(state));
    ...
}

Цим не обмежується. Наприклад, популярний юніт-тест фреймворк xunit також реалізує декілька своїх SynchronizationContext. Завдяки цьому, ви можете запускати тести паралельно, але обмежити кількість одночасно запущених тестів:

public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable
{
    public override void Post(SendOrPostCallback d, object? state)
    {
        var context = ExecutionContext.Capture();
        workQueue.Enqueue((d, state, context));
        workReady.Set();
    }
}

У даному кейсі Post метод тільки додає роботу у свою внутрішню чергу, котра запускається в своєму окремому потоці, де метод контролює, як багато одночасних потоків запускати.

Як це пов’язано з ЕАР патерном? Обидва (ЕАР та SynchronizationContext) були представлені одночасно, і ЕАР вказував, що завершення івентів має бути додано в чергу незалежно від того, який був SynchronizationContext, коли була розпочата асинхронна операція. Щоб трохи спростити це, в System.ComponentModel були введені допоміжні типи, зокрема AsyncOperation та AsyncOperationManager.

Перший був кортежем (тьюплом), обгорнутий в об’єкт стану і опрацьований в SynchronizationContext, другий — проста фабрика для створення і опрацювання інстансу AsyncOperation. Імплементації ЕАР використовують це, наприклад, Ping.SendAsync викликає AsyncOperationManager.CreateOperation для перехоплення методу SynchronizationContext, і коли операція завершена, з методу AsyncOperation.PostOperationCompleted буде викликаний SynchronizationContext.Post.

SynchronizationContext також надає кілька цінних дрібниць. Зокрема, він видає методи OperationStarted та OperationCompleted. Базова реалізація цих віртуальних методів порожня, але похідні можуть перезаписувати її. Імплементації ЕАР будуть також викликати ці перезаписані методи OperationStarted/OperationCompleted на початку і наприкінці кожної дії, щоб інформувати будь-який наявний SynchronizationContext і дозволити йому відстежувати роботу. Це релевантно для ЕАР патерну, тому що методи, що ініціюють асинхронні операції, повертають void. Ми ще повернемось до цього.

Отже, нам було необхідно щось краще за АРМ, і наступний за ним ЕАР представив деякі нові речі, але насправді не вирішив головних проблем. Нам досі необхідно щось краще.

👍ПодобаєтьсяСподобалось10
До обраногоВ обраному11
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

В статті жодного разу не згадується клас Thread. А це база

Воно, звісно, база. Але усі асинхронні підходи придумали як раз для того щоб позбавити пересічного розробника від необхідності спілкуватися із Thread. Тобто, стаття сенс має і бажано десь у наступних частинах прочитати щось на кшталт «а тепер подивимось як це все працює всередині». І там вже про Thread, continuation, state machine та таке інше. Щоб було розуміння що все це має нюанси і не безкоштовне.

Але усі асинхронні підходи придумали як раз для того щоб позбавити пересічного розробника від необхідності спілкуватися із Thread.

загалом це не зовсім так тут склалися 2 вагомі фактори № 1 технічний бо thread виявився занадто важкий ну власне так і задумано просто cpu core сам собою став занадто важкий загалом питання складне і цікаве і прямо переходить у № 2 використання thread ефективно як за масштабуванням так і за ефективністю виявилося складно для пересічного

я прямо сьогодні бачу це на «пересічному» multi threaded коді густо увішаному mutexes без усякого чіткого розуміння що воно за таке тобто грубо кажучи (але точно передаючи) гора коду може бути «обмежена» 1 lock на початку гори коду і 1 unlock у самому самому кінці

розуміння того що такий «підхід» робить код фактично single threaded у людей нема ну а власне хіба тепер він не може виконуватися multi threaded ? може тож які претензії

ЗЫ: я одного разу прямо сказав того разу навіть прямо як окремий оплачений експерт «такий підхід прямо протирічить парадигми робити локи якомога най коротшими ось мєтодічка» то мене на тому мітінгу не зрозумів ні хто ані «інженери» ані «манагери» тобто «не зрозумів» там складне слово питання «зрозумів» у цьому контексті окремо складне і цікаве ))

І там вже про Thread, continuation, state machine та таке інше. Щоб було розуміння що все це має нюанси і не безкоштовне.

здебільшого питання у суто формальних підходах 21 hour training style людей «навчили» («натренували») використовувати thread + mutex і це все більше ні чого вони не вміють і більше того навіть трохи не цікавляться

так само і з async await людям дали простий тупий патерн який саме за своїм дизайном бо так задумано дозволяє просте тупе використання а то більше того для «міксування» там уже треба буде докласти певних сузиль для «пересічного» далеко не тривіальних я знаю з досвіду що далеко не тривіальних для «пересічного» тож там просто увесь код від початку до кінця async і всьо

далі реалізація віднесена «під капотом» і реалізується вже геть іншими людьми селяві ні чого особистого просто бізнес

В нас на весь автопілот два м’ютекси, глибоко в кишках. Бо це зло.

Плюсую, обсолютна більшість дупля не ріже як правильно юзать локи. З реактивщиною та сама срака.
Люди роблять Моно.бла().бла().бла() тільки для того щоб одразу зробить .блок() і чекати результат.
Особисто викорчовував таку срань з одного проекту.

«а тепер подивимось як це все працює всередині». І там вже про Thread

А что там про Thread?

Була якось задача роки 3-4 тому... шукали на що переписати — в головних варіантах були Java і C#.

Дотнет: неможливо зафіксувати, щоб колбек асінхронного завершення операції в сокеті викликався в потрібній нитці, а не де рантайму захочеться.
Ще шукали як зробити пули ниток так щоб обмежити розмір кожного, скільки він може створити для класу задач — і щоб для нитки можна було задати, який пул прийматиме async-продовження запущеного в ній. Знайшли настільки непрямі і нестійкі методи, що користуватись їми було неможливо.

Може, для UI задач воно ще якось працює, а для серверних — ні. На Java — без проблем, напряму.

Визнали цих наркоманів з MS невиліковними.

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

Ось тут незрозуміло — є ж SynchronizationContext, можна кастомний написати

Ще шукали як зробити пули ниток так щоб обмежити розмір кожного, скільки він може створити для класу задач — і щоб для нитки можна було задати, який пул прийматиме async-продовження запущеного в ній.

Кастомний TaskScheduler

В Java я зустрічав проблеми через відсутність простої для девелопера асинхронності. В результаті трапляються випадки, коли декількасот воркер тредів чекають відповіді від бази та інших ресурсів, а хелсчек чекає в черзі, і потім сервіс вбивають через те, що хелсчек декілька раів за пару секунд не проходить. Або займаєшся оптимізацією ресурсів і раптово дізнаєшся, що сотні тредів виділено під інстанс HTTP клієнта, хоча такому ж клієнту з .NET додаткові треди не потрібні. Всюди є свої нюанси...

Щоб використовувати свій SynchronizationContext, потрібно щоб код не робив .ConfigureAwait(false), шо не завжди можливо. З іншого боку, є підозра, що автори не тими інструментами намагались вирішити свої завдання

Та не сприймай ти той коментар серйозно. Там головне не проблема з тредами, а якось знайти привід обгадити C# та порекламувати Java.

а якось знайти привід обгадити C# та порекламувати Java.

Ваш /dev/telepathy зламаний з самого початку.

)))) Тебе ще у проекті не було коли був мій початок.

Дотнет: неможливо зафіксувати, щоб колбек асінхронного завершення операції в сокеті викликався в потрібній нитці, а не де рантайму захочеться.
Ще шукали як зробити пули ниток так щоб обмежити розмір кожного, скільки він може створити для класу задач — і щоб для нитки можна було задати, який пул прийматиме async-продовження запущеного в ній. Знайшли настільки непрямі і нестійкі методи, що користуватись їми було неможливо

Я не эксперт в сокетах, но можно вкратце описать зачем это все нужно было?
По первому вопросу. Я же так понимаю задача стояла выполнять коллбек не в оригинальном треде из которого вызов произошел а именно в отдельно выделенном для этого треде? Другими словами выполняются 10 асинхронных задач (какие-то в разных тредах, какие-то в одном и том же), но при этом все их коллбеки должны были запускаться в одном конкретно треде выделенном для этого? Не совсем понятно зачем это может понадобится
По второму вопросу как уже подсказали можно было кастомный TaskScheduler использовать. Я давно уже с этим не работал но помню на десктопном проекте у нас была задача сделать так чтобы в интегрейшен тестах не создавались никакие треды (несмотря на то что в коде кроме асинхронности были еще и cpu bound операции) а все выполнялось всегда последовательно в одном треде. И это решалось с помощью кастомного TaskScheduler и помнится мне там было не больше 10-20 строчек кода

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

Я же так понимаю задача стояла выполнять коллбек не в оригинальном треде из которого вызов произошел а именно в отдельно выделенном для этого треде?

Наоборот, в оригинальном. Один тред и на нём свой движок событий. Рядом, возможно, такой же, но другой.
Ибо синхронизировать иначе задолбёшься.

По второму вопросу как уже подсказали можно было кастомный TaskScheduler использовать.

Вот с asyncʼами он не дружил, они всё равно расползались. Может, с 2019 там что-то починили...

Я думаю ви не розібрались в предметі, бо з такими сценаріями проблем ніколи не було.

Багато англицизмів, важко читати

Підписатись на коментарі