Подтвердить что ты не робот

Async/await, пользовательский сборщик и сборщик мусора

Я имею дело с ситуацией, когда управляемый объект преждевременно завершается в середине метода async.

Это проект домашней автоматизации хобби (Windows 8.1,.NET 4.5.1), где я предоставляю обратный вызов С# неуправляемой сторонней DLL. Обратный вызов активируется при определенном событии датчика.

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

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

Press Enter to exit...
Awaiter()
tick: 0
tick: 1
~Awaiter()
tick: 2
tick: 3
tick: 4

Обратите внимание на то, как мой awaiter завершается после второго тика. Это неожиданно.

Код (консольное приложение):

using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    class Program
    {
        static async Task TestAsync()
        {
            var awaiter = new Awaiter();
            //var hold = GCHandle.Alloc(awaiter);

            WaitOrTimerCallbackProc callback = (a, b) =>
                awaiter.Continue();

            IntPtr timerHandle;
            if (!CreateTimerQueueTimer(out timerHandle, 
                    IntPtr.Zero, 
                    callback, 
                    IntPtr.Zero, 500, 500, 0))
                throw new System.ComponentModel.Win32Exception(
                    Marshal.GetLastWin32Error());

            var i = 0;
            while (true)
            {
                await awaiter;
                Console.WriteLine("tick: " + i++);
            }
        }

        static void Main(string[] args)
        {
            Console.WriteLine("Press Enter to exit...");
            var task = TestAsync();
            Thread.Sleep(1000);
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
            Console.ReadLine();
        }

        // custom awaiter
        public class Awaiter : 
            System.Runtime.CompilerServices.INotifyCompletion
        {
            Action _continuation;

            public Awaiter()
            {
                Console.WriteLine("Awaiter()");
            }

            ~Awaiter()
            {
                Console.WriteLine("~Awaiter()");
            }

            // resume after await, called upon external event
            public void Continue()
            {
                var continuation = Interlocked.Exchange(ref _continuation, null);
                if (continuation != null)
                    continuation();
            }

            // custom Awaiter methods
            public Awaiter GetAwaiter()
            {
                return this;
            }

            public bool IsCompleted
            {
                get { return false; }
            }

            public void GetResult()
            {
            }

            // INotifyCompletion
            public void OnCompleted(Action continuation)
            {
                Volatile.Write(ref _continuation, continuation);
            }
        }

        // p/invoke
        delegate void WaitOrTimerCallbackProc(IntPtr lpParameter, bool TimerOrWaitFired);

        [DllImport("kernel32.dll")]
        static extern bool CreateTimerQueueTimer(out IntPtr phNewTimer,
           IntPtr TimerQueue, WaitOrTimerCallbackProc Callback, IntPtr Parameter,
           uint DueTime, uint Period, uint Flags);
    }
}

Мне удалось подавить коллекцию awaiter с помощью этой строки:

var hold = GCHandle.Alloc(awaiter);

Однако я не совсем понимаю, почему мне нужно создать такую ​​сильную ссылку. awaiter ссылается на бесконечный цикл. AFAICT, он не выходит за пределы области действия, пока задача, возвращаемая TestAsync, не будет завершена (отменена/не выполнена). И сама задача ссылается внутри Main навсегда.

В конце концов я уменьшил TestAsync до этого:

static async Task TestAsync()
{
    var awaiter = new Awaiter();
    //var hold = GCHandle.Alloc(awaiter);

    var i = 0;
    while (true)
    {
        await awaiter;
        Console.WriteLine("tick: " + i++);
    }
}

Коллекция все еще имеет место. Я подозреваю, что весь объект автомата, созданный компилятором, собирается. Может кто-нибудь объяснить, почему это происходит?

Теперь, со следующей незначительной модификацией, awaiter больше не получает сбор мусора:

static async Task TestAsync()
{
    var awaiter = new Awaiter();
    //var hold = GCHandle.Alloc(awaiter);

    var i = 0;
    while (true)
    {
        //await awaiter;
        await Task.Delay(500);
        Console.WriteLine("tick: " + i++);
    }
}

Обновлено, эта скрипка показывает, как объект awaiter получает сбор мусора без кода p/invoke. Я думаю, причина может заключаться в том, что нет внешних ссылок на awaiter вне исходного состояния созданного объекта конечного автомата. Мне нужно изучить код, сгенерированный компилятором.


Обновлен, здесь код, сгенерированный компилятором (для эта скрипка, VS2012). По-видимому, Task, возвращаемый stateMachine.t__builder.Task, не содержит ссылки на (или, скорее, копию) самого конечного автомата (stateMachine). Я что-то пропустил?

    private static Task TestAsync()
    {
      Program.TestAsyncd__0 stateMachine;
      stateMachine.t__builder = AsyncTaskMethodBuilder.Create();
      stateMachine.1__state = -1;
      stateMachine.t__builder.Start<Program.TestAsyncd__0>(ref stateMachine);
      return stateMachine.t__builder.Task;
    }

    [CompilerGenerated]
    [StructLayout(LayoutKind.Auto)]
    private struct TestAsyncd__0 : IAsyncStateMachine
    {
      public int 1__state;
      public AsyncTaskMethodBuilder t__builder;
      public Program.Awaiter awaiter5__1;
      public int i5__2;
      private object u__awaiter3;
      private object t__stack;

      void IAsyncStateMachine.MoveNext()
      {
        try
        {
          bool flag = true;
          Program.Awaiter awaiter;
          switch (this.1__state)
          {
            case -3:
              goto label_7;
            case 0:
              awaiter = (Program.Awaiter) this.u__awaiter3;
              this.u__awaiter3 = (object) null;
              this.1__state = -1;
              break;
            default:
              this.awaiter5__1 = new Program.Awaiter();
              this.i5__2 = 0;
              goto label_5;
          }
label_4:
          awaiter.GetResult();
          Console.WriteLine("tick: " + (object) this.i5__2++);
label_5:
          awaiter = this.awaiter5__1.GetAwaiter();
          if (!awaiter.IsCompleted)
          {
            this.1__state = 0;
            this.u__awaiter3 = (object) awaiter;
            this.t__builder.AwaitOnCompleted<Program.Awaiter, Program.TestAsyncd__0>(ref awaiter, ref this);
            flag = false;
            return;
          }
          else
            goto label_4;
        }
        catch (Exception ex)
        {
          this.1__state = -2;
          this.t__builder.SetException(ex);
          return;
        }
label_7:
        this.1__state = -2;
        this.t__builder.SetResult();
      }

      [DebuggerHidden]
      void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
      {
        this.t__builder.SetStateMachine(param0);
      }
    }
4b9b3361

Ответ 1

Я удалил все материалы p/invoke и заново создал упрощенную версию логики конечного автомата, созданного компилятором. Он демонстрирует такое же поведение: awaiter получает сбор гарабеков после первого вызова метода конечного автомата MoveNext.

Недавно Microsoft сделала отличную работу по предоставлению веб-интерфейса своим .NET справочным источникам, что было очень полезно. Изучив реализацию AsyncTaskMethodBuilder и, самое главное, AsyncMethodBuilderCore.GetCompletionAction, теперь я считаю, что поведение GC, которое я вижу, имеет смысл. Я попытаюсь объяснить это ниже.

Код:

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;

namespace ConsoleApplication
{
    public class Program
    {
        // Original version with async/await

        /*
        static async Task TestAsync()
        {
            Console.WriteLine("Enter TestAsync");
            var awaiter = new Awaiter();
            //var hold = GCHandle.Alloc(awaiter);

            var i = 0;
            while (true)
            {
                await awaiter;
                Console.WriteLine("tick: " + i++);
            }
            Console.WriteLine("Exit TestAsync");
        }
        */

        // Manually coded state machine version

        struct StateMachine: IAsyncStateMachine
        {
            public int _state;
            public Awaiter _awaiter;
            public AsyncTaskMethodBuilder _builder;

            public void MoveNext()
            {
                Console.WriteLine("StateMachine.MoveNext, state: " + this._state);
                switch (this._state)
                {
                    case -1:
                        {
                            this._awaiter = new Awaiter();
                            goto case 0;
                        };
                    case 0:
                        {
                            this._state = 0;
                            var awaiter = this._awaiter;
                            this._builder.AwaitOnCompleted(ref awaiter, ref this);
                            return;
                        };

                    default:
                        throw new InvalidOperationException();
                }
            }

            public void SetStateMachine(IAsyncStateMachine stateMachine)
            {
                Console.WriteLine("StateMachine.SetStateMachine, state: " + this._state);
                this._builder.SetStateMachine(stateMachine);
                // s_strongRef = stateMachine;
            }

            static object s_strongRef = null;
        }

        static Task TestAsync()
        {
            StateMachine stateMachine = new StateMachine();
            stateMachine._state = -1;

            stateMachine._builder = AsyncTaskMethodBuilder.Create();
            stateMachine._builder.Start(ref stateMachine);

            return stateMachine._builder.Task;
        }

        public static void Main(string[] args)
        {
            var task = TestAsync();
            Thread.Sleep(1000);
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
            Console.WriteLine("Press Enter to exit...");
            Console.ReadLine();
        }

        // custom awaiter
        public class Awaiter :
            System.Runtime.CompilerServices.INotifyCompletion
        {
            Action _continuation;

            public Awaiter()
            {
                Console.WriteLine("Awaiter()");
            }

            ~Awaiter()
            {
                Console.WriteLine("~Awaiter()");
            }

            // resume after await, called upon external event
            public void Continue()
            {
                var continuation = Interlocked.Exchange(ref _continuation, null);
                if (continuation != null)
                    continuation();
            }

            // custom Awaiter methods
            public Awaiter GetAwaiter()
            {
                return this;
            }

            public bool IsCompleted
            {
                get { return false; }
            }

            public void GetResult()
            {
            }

            // INotifyCompletion
            public void OnCompleted(Action continuation)
            {
                Console.WriteLine("Awaiter.OnCompleted");
                Volatile.Write(ref _continuation, continuation);
            }
        }
    }
}

Созданный компилятором конечный автомат является изменчивой структурой, передаваемой ref. По-видимому, это оптимизация, чтобы избежать дополнительных распределений.

Основная часть этого происходит внутри AsyncMethodBuilderCore.GetCompletionAction, где текущая структура состояния машины получает бокс, а ссылка на коробочную копию сохраняется путем обратного вызова продолжения, переданного в INotifyCompletion.OnCompleted.

Это единственная ссылка на конечный автомат, который имеет шанс выдержать GC и выжить после await. Объект Task, возвращаемый TestAsync, выполняет не, ссылаясь на него, только обратный вызов продолжения await. Я считаю, что это сделано специально, чтобы сохранить эффективное поведение GC.

Обратите внимание на прокомментированную строку:

// s_strongRef = stateMachine;

Если я прокомментирую это, коробочная копия конечного автомата не получит GC'ed, а awaiter останется в живых как часть этого. Конечно, это не решение, но оно иллюстрирует проблему.

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

Например, в случае YieldAwaitable (возвращается Task.Yield), внешняя ссылка на обратный вызов продолжения сохраняется ThreadPool планировщик задач, в результате вызова ThreadPool.QueueUserWorkItem. В случае Task.GetAwaiter это объект косвенно ссылается на объект задачи.

В моем случае "хранителем" обратного вызова продолжения является сам awaiter.

Таким образом, до тех пор, пока нет внешних ссылок на обратный вызов продолжения, который CLR знает (вне объекта конечного автомата), пользовательский awaiter должен предпринять шаги для сохранения объекта обратного вызова. Это, в свою очередь, будет поддерживать всю государственную машину. В этом случае необходимы следующие шаги:

  • Вызовите GCHandle.Alloc в обратном вызове INotifyCompletion.OnCompleted.
  • Вызов GCHandle.Free, когда событие async действительно произошло, перед вызовом обратного вызова продолжения.
  • Внесите IDispose, чтобы вызвать GCHandle.Free, если событие никогда не происходило.

Учитывая, что ниже приведена версия исходного кода обратного вызова таймера, который работает правильно. Обратите внимание: нет необходимости сильно удерживать делегата обратного вызова таймера (WaitOrTimerCallbackProc callback). Он поддерживается как часть конечного автомата. Обновлено: как указано в @svick, этот оператор может быть специфичен для текущей реализации конечного автомата (С# 5.0). Я добавил GC.KeepAlive(callback), чтобы устранить любую зависимость от этого поведения, если он изменится в будущих версиях компилятора.

using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    class Program
    {
        // Test task
        static async Task TestAsync(CancellationToken token)
        {
            using (var awaiter = new Awaiter())
            {
                WaitOrTimerCallbackProc callback = (a, b) =>
                    awaiter.Continue();
                try
                {
                    IntPtr timerHandle;
                    if (!CreateTimerQueueTimer(out timerHandle,
                            IntPtr.Zero,
                            callback,
                            IntPtr.Zero, 500, 500, 0))
                        throw new System.ComponentModel.Win32Exception(
                            Marshal.GetLastWin32Error());
                    try
                    {
                        var i = 0;
                        while (true)
                        {
                            token.ThrowIfCancellationRequested();
                            await awaiter;
                            Console.WriteLine("tick: " + i++);
                        }
                    }
                    finally
                    {
                        DeleteTimerQueueTimer(IntPtr.Zero, timerHandle, IntPtr.Zero);
                    }
                }
                finally
                {
                    // reference the callback at the end
                    // to avoid a chance for it to be GC'ed
                    GC.KeepAlive(callback);
                }
            }
        }

        // Entry point
        static void Main(string[] args)
        {
            // cancel in 3s
            var testTask = TestAsync(new CancellationTokenSource(10 * 1000).Token);

            Thread.Sleep(1000);
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true);

            Thread.Sleep(2000);
            Console.WriteLine("Press Enter to GC...");
            Console.ReadLine();

            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
            Console.WriteLine("Press Enter to exit...");
            Console.ReadLine();
        }

        // Custom awaiter
        public class Awaiter :
            System.Runtime.CompilerServices.INotifyCompletion,
            IDisposable
        {
            Action _continuation;
            GCHandle _hold = new GCHandle();

            public Awaiter()
            {
                Console.WriteLine("Awaiter()");
            }

            ~Awaiter()
            {
                Console.WriteLine("~Awaiter()");
            }

            void ReleaseHold()
            {
                if (_hold.IsAllocated)
                    _hold.Free();
            }

            // resume after await, called upon external event
            public void Continue()
            {
                Action continuation;

                // it OK to use lock (this)
                // the C# compiler would never do this,
                // because it slated to work with struct awaiters
                lock (this)
                {
                    continuation = _continuation;
                    _continuation = null;
                    ReleaseHold();
                }

                if (continuation != null)
                    continuation();
            }

            // custom Awaiter methods
            public Awaiter GetAwaiter()
            {
                return this;
            }

            public bool IsCompleted
            {
                get { return false; }
            }

            public void GetResult()
            {
            }

            // INotifyCompletion
            public void OnCompleted(Action continuation)
            {
                lock (this)
                {
                    ReleaseHold();
                    _continuation = continuation;
                    _hold = GCHandle.Alloc(_continuation);
                }
            }

            // IDispose
            public void Dispose()
            {
                lock (this)
                {
                    _continuation = null;
                    ReleaseHold();
                }
            }
        }

        // p/invoke
        delegate void WaitOrTimerCallbackProc(IntPtr lpParameter, bool TimerOrWaitFired);

        [DllImport("kernel32.dll")]
        static extern bool CreateTimerQueueTimer(out IntPtr phNewTimer,
            IntPtr TimerQueue, WaitOrTimerCallbackProc Callback, IntPtr Parameter,
            uint DueTime, uint Period, uint Flags);

        [DllImport("kernel32.dll")]
        static extern bool DeleteTimerQueueTimer(IntPtr TimerQueue, IntPtr Timer,
            IntPtr CompletionEvent);
    }
}