Исходный пост What is .NET, and why should you choose it? предоставляет обзор платформы на высоком-уровне, перечисляя различные компоненты и решения на уровне дизайна, и предваряя последующие посты в глубину обозначенных тем. Этот пост .Net блога является продолжением того исходного поста, глубоко погружающим в историю, приведшую к созданию конструкций async/await и стоящие за этим дизайнерские решения и детали реализации async/await в C# и .NET.

Поддержка конструкций async/await появилась более десяти лет назад. За это время изменился способ написания масштабируемого кода для .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 особенно полезно, когда вы хотите заглянуть поглубже, например, когда вы занимаетесь отладкой кода, который не работает или для того, чтобы улучшить производительность кода, который уже и так работает. В этом посте мы подробно разберем как await работает на уровнях языка, компилятора, и библиотек, чтобы вы могли максимально осмысленно использовать богатые возможности, которые предоставляют основанные на нем конструкции.

<РЕД:* кстати, понятие "поток исполнения" тесно связано с концепцией потоков (threads). Одну из визуализаций-интерпретаций этого понятия можно найти в моей статье: Многопоточность (Multithreading) для практического программирования, это вполне согласуется с желанием автора исходной статьи подтолкнуть читателя к пониманию внутренней реализации и фундаментальных основ для реализации асинхронных вызовов и всего что с ними связано.>

Но прежде нам надо вернуться назад к тому, как подобные задачи решались в отсутствии async/await, чтобы понять каким искусством был асинхронный код в их отсутствии. Честно говоря, я бы не сказал, что это было красивое искусство.

В начале…

Еще в .NET Framework 1.0 существовал шаблон модели асинхронного программирования (APM), иначе известный как шаблон APM, или как шаблон Begin/End, или как шаблон IAsyncResult. На высоком уровне шаблон прост. Для асинхронной операции DoStuff:

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

Добавляется два соответствующих метода как часть реализации шаблона APM,

class Handler
{
    public int DoStuff(string arg);

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

BeginDoStuff будет принимать все те же параметры, что и doStuff, но, кроме того, он также будет принимать делегат AsyncCallback и не очень понятный объект состояния, один или оба из этих объектов могут быть равны null. Метод Begin отвечал за инициирование асинхронной операции, и если ему предоставлялся callback (часто называемый “продолжением” для начальной операции), он также отвечал за то, чтобы callback был вызван после завершения асинхронной операции. Метод Begin также создает и возвращает экземпляр типа, который реализует IAsyncResult, используя необязательный параметр state  метода Begin для заполнения свойства AsyncState этого IAsyncResult:

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, когда callback был в конечном итоге вызван. Когда вызывающий объект будет готов к использованию результатов операции (и если ему нужно их использовать), он передаст этот экземпляр 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
}

Для любого, кто имел дело с API-интерфейсами на основе callback-ов на любом языке, это должно выглядеть знакомым.

Однако с этого момента все только усложнилось. Например, возникает проблема “погружения в стек”.

<РЕД: “погружение в стек” - вольный перевод нового термина введенного автором поста, надеюсь кто-нибудь сможет придумать более благозвучный перевод этого термина, но возможно его применение будет ограничено этой статьей>

Погружение в стек — это когда код многократно выполняет вложенные вызовы, которые проникают все глубже и глубже в стек, вплоть до точки, где потенциально может произойти переполнение стека. Методу Begin разрешено вызывать callback синхронно, если операция завершается синхронно, что означает, что вызов Begin сам по себе может напрямую вызывать callback. Но “асинхронные” операции, которые завершаются синхронно, на самом деле очень распространены; они вызываются как “асинхронные” операции, но они завершаются синхронно, потому что вместо того, чтобы быть гарантированно асинхронными, для них асинхронное выполнение всего лишь разрешено. <РЕД: похоже на проблему с оптимизацией некоторых библиотечных асинхронных вызовов в компиляторе.> Например, рассмотрим асинхронное чтение из сокета. Если вам нужен лишь небольшой объем данных для каждой отдельной операции, такой как чтение некоторых данных по заголовкам из ответа, вы можете создать буфер, чтобы избежать накладных расходов, связанных с большим количеством системных вызовов. Вместо того, чтобы выполнять множество коротких операций чтения только того объема данных, который вам нужен немедленно, вы выполняете большое чтение в буфер, а затем берете данные из этого буфера до тех пор, пока они не будут исчерпаны; это позволяет вам сократить количество дорогостоящих системных вызовов, необходимых для фактического взаимодействия с сокетом. Такой буфер может существовать за любой используемой вами асинхронной абстракцией, так что первая выполняемая вами “асинхронная” операция (заполнение буфера) завершается асинхронно, но затем все последующие операции, до тех пор, пока этот большой буфер не будет исчерпан, эти последующие короткие операции будут завершаться синхронно. Они будут короткие, поскольку не нужно будет выполнять никаких операций ввода-вывода, вместо этого просто извлекая данные из буфера, и поэтому будут завершаться синхронно. Когда метод Begin выполняет одну из этих операций и обнаруживает, что она завершается синхронно, он может затем синхронно вызвать callback. Это означает, что у вас есть один фрейм стека, который вызвал метод Begin, другой фрейм стека для самого метода Begin, а теперь еще один фрейм стека для callback-а. Теперь, что произойдет, если этот callback развернется и вызовы начнутся снова? Если эта операция завершается синхронно и ее callback вызывается синхронно, вы еще глубже погружаетесь в стек еще на несколько фреймов. И так далее, и так далее, пока, в конце концов, у вас не закончится стек (пока не случится переполнение стека).

Это реальная ситуация, которую легко воспроизвести. Попробуйте эту программу на .NET Core:

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, чтобы использовать их “асинхронно” по одному за раз (это ужасно неэффективно и делается только для демонстрации проблемы).

 <РЕД: вопрос: а можно ли воспроизвести проблему с более-менее эффективным кодом? Если нет, то получается, что это проблема исходной неэффективности кода, своего рода рекурсия неэффективности. Соответственно, тут есть поле для критики того откуда берется проблема и как она формулируется>

Callback, переданный в BeginRead, завершает чтение вызовом EndRead, а затем, если он успешно считывает нужный байт (в этом случае поток данных еще не завершен), он выдает другой BeginRead посредством рекурсивного вызова локальной функции ReadAgain. Однако в .NET Core операции с сокетами выполняются намного быстрее, чем в .NET Framework, и завершатся синхронно, если ОС сможет выполнить операцию синхронно (следует отметить, что в самом ядре есть буфер, используемый для выполнения операций приема сокетов). Таким образом, этот стек переполняется:

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

  1. Запретить (видимо на уровне компиляции) синхронный вызов AsyncCallback. Если он всегда вызывается асинхронно, даже если операция завершается синхронно, то риск погружения в стек исчезает. Но то же самое касается и производительности, потому что операции, которые выполняются синхронно (или настолько быстро, что их практически невозможно различать), очень распространены, и принудительное включение callback-а в очередь после каждой из них ощутимо увеличивает накладные расходы.

  1. Можно использовать механизм, который позволяет вызывающей стороне, а не callback-у, выполнять работу продолжения, если операция завершается синхронно. Таким образом, вы избегаете создания дополнительного фрейма стека для метода, и продолжаете выполнять последующую работу, не углубляясь в стек.

Шаблон APM выбирает опцию (2). Для этого интерфейс IAsyncResult предоставляет два связанных, но разных элемента: IsCompleted и CompletedSynchronously. IsCompleted сообщает вам, завершена ли операция: вы можете проверить это несколько раз, и в конечном итоге оно перейдет из false в true, а затем останется там. Напротив, CompletedSynchronously никогда не изменяется (или, если это произойдет, это неприятная ошибка, ожидающая своего появления); этот метод используется чтобы выбрать между вызывающим объектом метода Begin и AsyncCallback, то есть чтобы выбрать который из них отвечает за выполнение работы продолжения. Если CompletedSynchronously равно false, то операция завершается асинхронно, и любая дальнейшая работа в ответ на завершение операции должна быть оставлена на усмотрение callback-а; в конце концов, если работа не завершилась синхронно, вызывающий Begin на самом деле не может справиться с этим, потому что известно, что операция еще не выполнена (и если бы вызывающий абонент просто вызвал End, он был бы заблокирован до завершения операции). Однако, если значение CompletedSynchronously равно true, то если callback должен был обрабатывать работу продолжения, то он рискует создать проблему погружения в стек, поскольку он будет выполнять эту работу продолжения глубже в стеке, чем там, где она началась. Таким образом, любые реализации, для которых можно ожидать проблему погружения в стек, должны проверять CompletedSynchronously и заставлять вызывающий метод Begin выполнять работу продолжения, если это true, что означает, что callback тогда не должен выполнять работу продолжения. Именно поэтому CompletedSynchronously никогда не должен изменяться: вызывающий объект и callback должны видеть одно и то же значение, чтобы гарантировать, что работа продолжения выполняется один и только один раз, несмотря на возможность возникновения race conditions (гонки, или лотереи как было определено в моей статье Многопоточность (Multithreading) для практического программирования).

В случае нашего предыдущего 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);
    }
}

Все просто: мы многократно считываем данные из одного потока, а затем записываем полученные данные в другой, считываем из одного потока и записываем в другой и так далее, пока у нас больше не останется данных для чтения. Теперь, как бы мы реализовали это асинхронно, используя шаблон APM? Что-то вроде этого <РЕД: прежде чем разбираться с этим кодом обязательно дочитайте текст до конца!>:

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;
            }
        }
    }
}

Yowsers (непереводимый местный фольклор). И, даже со всей этой белибердой, это все равно не самая лучшая реализация. Например, реализация IAsyncResult блокируется на каждой операции, а не выполняется каким-то более-менее свободным от блокировок способом, где это возможно. Исключения сохраняются в необработанном виде, а не как ExceptionDispatchInfo, что позволило бы увеличить его стек вызовов при распространении, в каждой отдельной операции происходит множество выделений памяти (например, делегат выделяется для каждого вызова BeginWrite) и так далее. Теперь представьте, что вам приходится делать все это для каждого метода, который вы собираетесь написать. Каждый раз, когда вы хотели написать повторно используемый метод, который вызывал бы другую асинхронную операцию, вам нужно было бы выполнять всю эту работу. И если бы вы хотели написать повторно используемые комбинаторы, которые могли бы эффективно работать с несколькими дискретными результатами IASYNC (подумайте о Task.WhenAll), это другой уровень сложности; каждая операция, реализующая и предоставляющая свои собственные API, специфичные для этой операции, означала бы, что не было бы промежуточного языка для преодоления разрыва между спецификой этих уникальных API операций (хотя некоторые разработчики писали библиотеки, которые пытались немного облегчить нагрузку, как правило, с помощью другого уровня callback-ов, который позволял API предоставлять соответствующий AsyncCallback методу Begin).

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

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

Продолжение следует...

Надеюсь на вашу поддержку.

Комментарии (9)


  1. MonkAlex
    18.12.2023 04:06

    Итак, зачем же лезть в это, если уже 10 лет у нас есть async-await?

    Я что-то суть статьи утерял, зачем воду лить на неактуальные паттерны?


    1. nronnie
      18.12.2023 04:06

      Исторический экскурс чтобы лучше понимать как в итоге появился TAP. Полная статья очень объемная и хорошая, т.ч. вполне стоит опубликовать тут её перевод целиком несколькими частями.


      1. MonkAlex
        18.12.2023 04:06

        И это важно, чтобы что?

        Я не знаю, может это моя деформация, но мне неинтересен исторический экскурс с не используемым нынче кодом. Просто задумайтесь - статья названа "Как на самом деле Async/Await работают в C#", но т.к. это часть 1 - в ней ничего нет по названию.

        ПС: есть отличные книги типа Async in C# 5.0, в которых я что-то не помню истории (она там есть, но встроена в повествование). А async-await при этом описан вполне доступно и понятно.

        ПСС: а если глянуть на https://www.microsoft.com/en-us/download/details.aspx?id=19957 то там ровно обратный подход - сначала расскажем про сам крутой TAP, а потом как его прикрутить к APM (если кому нужно).

        ПССС: а есть же ещё EAP. И на него будет статья, такая же как эта?

        ПСССС: а, таки да, в оригинале дальше EAP. Потом ещё будет разбор enumerable для объяснения стейт-машины, а ещё ValueTask, а ещё AsyncLocal и куча прочей магии. Т.е. всё то, на что нужна целая книга по факту - эти вещи непросты для новичков и не сильно интересны тем кто уже знает. Интересно, что ребята из дотнета зачем-то это всё написали в 2023 отдельной статьей.


        1. rukhi7 Автор
          18.12.2023 04:06

          эти вещи непросты для новичков и не сильно интересны тем кто уже знает.

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


          1. MonkAlex
            18.12.2023 04:06

            Удачи в переводе.

            Я как читатель одобряю такое.

            А вот как программист на c# я честно скажу - мне такие статьи почти никогда не помогали, не хватало хороших примеров использования. Поэтому в целом по теме async-await предпочитаю (и рекомендую) книги.


            1. nronnie
              18.12.2023 04:06

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


  1. HomoLuden
    18.12.2023 04:06

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

    Жду следующей серии Дикой Розы. Следуй продолжение вскорости...


  1. balandinve
    18.12.2023 04:06

    Уже же был перевод

    https://habr.com/ru/articles/727850/


    1. rukhi7 Автор
      18.12.2023 04:06

      Интересно! Спасибо! Я подозревал что он должен быть! Но поиск по "Async/Await" по Хабру его не выдает, соответственно я его не видел. По Async отдельно тоже искал, но тоже не нашел. И вообще, похоже, мало кто может найти тот первый перевод, то есть эта моя статья может служить ссылкой, хотя бы.

      Хорошо! Я думаю ничего страшного, если мой перевод тоже пока повисит, мне тоже будет интересно сравнить свою вторую версию с той первой.