Kiedy pozbyć się CancellationTokenSource?

163

Klasa CancellationTokenSourcejest jednorazowa. Szybkie spojrzenie w Reflector dowodzi użycia KernelEvent(bardzo prawdopodobne) niezarządzanego zasobu. Ponieważ CancellationTokenSourcenie ma finalizatora, jeśli go nie wyrzucimy, GC tego nie zrobi.

Z drugiej strony, jeśli spojrzysz na przykłady wymienione w artykule MSDN Anulowanie w zarządzanych wątkach , tylko jeden fragment kodu usuwa token.

Jaki jest właściwy sposób pozbycia się go w kodzie?

  1. Nie możesz opakować kodu rozpoczynającego równoległe zadanie, usingjeśli nie czekasz na to. Anulowanie ma sens tylko wtedy, gdy nie czekasz.
  2. Oczywiście możesz dodać ContinueWithzadanie, Disposedzwoniąc, ale czy to właściwy sposób?
  3. A co z anulowanymi zapytaniami PLINQ, które nie są synchronizowane z powrotem, ale po prostu coś robią na końcu? Powiedzmy .ForAll(x => Console.Write(x))?
  4. Czy to jest wielokrotnego użytku? Czy ten sam token może być używany do kilku wywołań, a następnie usuwać go razem z komponentem hosta, powiedzmy, kontrolką interfejsu użytkownika?

Ponieważ nie ma czegoś takiego jak Resetmetoda czyszczenia IsCancelRequestedi Tokenpola, przypuszczam, że nie można jej użyć ponownie, więc za każdym razem, gdy zaczynasz zadanie (lub zapytanie PLINQ), powinieneś utworzyć nowe. Czy to prawda? Jeśli tak, moje pytanie brzmi: jaka jest poprawna i zalecana strategia postępowania Disposew tych wielu CancellationTokenSourceprzypadkach?

George Mamaladze
źródło

Odpowiedzi:

82

A skoro już mowa o tym, czy naprawdę trzeba dzwonić do Dispose CancellationTokenSource... Miałem wyciek pamięci w moim projekcie i okazało się, że CancellationTokenSourceto jest problem.

Mój projekt ma usługę, która nieustannie odczytuje bazę danych i uruchamia różne zadania, a ja przekazywałem moim pracownikom połączone tokeny anulowania, więc nawet po zakończeniu przetwarzania danych tokeny anulowania nie zostały usunięte, co spowodowało wyciek pamięci.

MSDN Cancellation in Managed Threads stwierdza wyraźnie:

Zauważ, że Disposepo zakończeniu pracy musisz wywołać połączone źródło tokenu. Aby uzyskać pełniejszy przykład, zobacz How to: Listen for Multiple Cancellation Requests .

Użyłem ContinueWithw mojej realizacji.

Gruzilkin
źródło
14
To ważne pominięcie w obecnie akceptowanej odpowiedzi Bryana Crosby'ego - jeśli utworzysz połączony CTS, ryzykujesz wyciek pamięci. Scenariusz jest bardzo podobny do programów obsługi zdarzeń, które nigdy nie są wyrejestrowane.
Søren Boisen
5
Miałem wyciek z powodu tego samego problemu. Korzystając z profilera, mogłem zobaczyć rejestracje wywołań zwrotnych przechowujące odniesienia do połączonych instancji CTS. Badanie kodu implementacji CTS Dispose w tym miejscu było bardzo wnikliwe i podkreśla porównanie @ SørenBoisen z przeciekami rejestracji programu obsługi zdarzeń.
BitMask777
Powyższe komentarze odzwierciedlają stan dyskusji, w której inna odpowiedź udzielona przez @Bryan Crosby została zaakceptowana.
George Mamaladze
Dokumentacja w 2020 roku wyraźnie mówi: Important: The CancellationTokenSource class implements the IDisposable interface. You should be sure to call the CancellationTokenSource.Dispose method when you have finished using the cancellation token source to free any unmanaged resources it holds.- docs.microsoft.com/en-us/dotnet/standard/threading/…
Endrju
44

Uważam, że żadna z obecnych odpowiedzi nie była zadowalająca. Po zbadaniu informacji znalazłem odpowiedź od Stephena Toub ( odniesienie ):

To zależy. W .NET 4 CTS.Dispose służył dwóm głównym celom. Jeśli uzyskano dostęp do WaitHandle CancellationToken (w ten sposób leniwie przydzielając go), Dispose pozbędzie się tego dojścia. Ponadto, jeśli CTS został utworzony za pomocą metody CreateLinkedTokenSource, Dispose odłączy CTS od tokenów, z którymi był połączony. W .NET 4.5, Dispose ma dodatkowy cel, czyli jeśli CTS używa Timera pod osłonami (np. Wywołano CancelAfter), Timer zostanie usunięty.

Używanie CancellationToken.WaitHandle jest bardzo rzadkie, więc czyszczenie po tym zwykle nie jest dobrym powodem do użycia metody Dispose. Jeśli jednak tworzysz swój CTS za pomocą CreateLinkedTokenSource lub jeśli używasz funkcji timera CTS, użycie Dispose może mieć większy wpływ.

Uważam, że odważna część jest ważną częścią. Używa określenia „bardziej wpływowy”, co pozostawia go nieco niejasnym. Interpretuję to jako oznaczenie, że dzwonienie Disposew takich sytuacjach powinno być wykonywane, w przeciwnym razie używanie Disposenie jest potrzebne.

Jesse Good
źródło
10
Bardziej wpływowy oznacza, że ​​dziecko CTS jest dodawane do rodzica. Jeśli nie wyrzucisz dziecka, dojdzie do wycieku, jeśli rodzic jest długowieczny. Dlatego ważne jest, aby pozbyć się powiązanych.
Grigorij
26

Zajrzałem do ILSpy, CancellationTokenSourceale mogę znaleźć tylko, m_KernelEventktóra jest faktycznie a ManualResetEvent, która jest klasą opakowującą dla WaitHandleobiektu. Powinno to być odpowiednio obsługiwane przez GC.

Bryan Crosby
źródło
7
Mam takie samo przeczucie, że GC wyczyści to wszystko. Spróbuję to zweryfikować. Dlaczego firma Microsoft zaimplementowała usuwanie w tym przypadku? Aby pozbyć się wywołań zwrotnych zdarzeń i uniknąć propagacji do GC drugiej generacji. W tym przypadku wywołanie Dispose jest opcjonalne - wywołaj je, jeśli możesz, jeśli nie tylko zignoruj. Myślę, że nie jest to najlepszy sposób.
George Mamaladze
4
Zbadałem ten problem. CancellationTokenSource pobiera elementy bezużyteczne. Możesz pomóc w przygotowaniu się do tego w GEN 1 KG. Zaakceptowany.
George Mamaladze
1
Zrobiłem to samo dochodzenie niezależnie i doszedłem do tego samego wniosku: pozbądź się, jeśli możesz, ale nie przejmuj się próbą zrobienia tego w rzadkich, ale nie niespotykanych przypadkach, w których wysłałeś CancellationToken do boondocks i nie chcą czekać, aż napiszą pocztówkę z informacją, że skończyli. Będzie się to zdarzać od czasu do czasu ze względu na naturę, do czego służy CancellationToken, i obiecuję, że jest naprawdę OK.
Joe Amenta
6
Mój powyższy komentarz nie dotyczy połączonych źródeł tokenów; Nie mogłem udowodnić, że pozostawienie tych niedysponowanych jest w porządku, a mądrość w tym wątku i MSDN sugeruje, że tak nie jest.
Joe Amenta
23

Zawsze powinieneś się pozbyć CancellationTokenSource.

Sposób pozbycia się tego zależy dokładnie od scenariusza. Proponujesz kilka różnych scenariuszy.

  1. usingdziała tylko wtedy, gdy wykonujesz CancellationTokenSourcerównoległą pracę, na którą czekasz. Jeśli to twój senario, to świetnie, to najłatwiejsza metoda.

  2. Podczas korzystania z zadań użyj ContinueWithwskazanego zadania do usunięcia CancellationTokenSource.

  3. Plinq można użyć, usingponieważ uruchamiasz go równolegle, ale czekasz na zakończenie wszystkich równolegle działających procesów roboczych .

  4. W przypadku interfejsu użytkownika można utworzyć nową CancellationTokenSourcedla każdej operacji, którą można anulować, która nie jest powiązana z żadnym wyzwalaczem anulowania. Zachowaj List<IDisposable>i dodaj każde źródło do listy, usuwając je wszystkie po usunięciu komponentu.

  5. W przypadku wątków utwórz nowy wątek, który łączy wszystkie wątki robocze i zamyka pojedyncze źródło po zakończeniu wszystkich wątków roboczych. Zobacz CancellationTokenSource, kiedy usunąć?

Zawsze jest sposób. IDisposablewystąpienia należy zawsze usuwać. Próbki często nie są takie, ponieważ są albo szybkimi próbkami pokazującymi podstawowe użycie, albo ponieważ dodanie wszystkich aspektów prezentowanej klasy byłoby zbyt skomplikowane dla próbki. Próbka jest po prostu próbką, niekoniecznie (lub nawet zwykle) kodem jakości produkcji. Nie wszystkie próbki można skopiować do kodu produkcyjnego w takiej postaci, w jakiej są.

Samuel Neff
źródło
w punkcie 2, czy jest jakiś powód, dla którego nie można było użyć awaitw zadaniu i usunąć CancellationTokenSource w kodzie, który pojawia się po oczekiwaniu?
stijn
14
Istnieją zastrzeżenia. Jeśli CTS zostanie anulowany podczas awaitoperacji, możesz wznowić z powodu OperationCanceledException. Możesz wtedy zadzwonić Dispose(). Ale jeśli istnieją operacje nadal działające i używające odpowiednich CancellationToken, ten token nadal zgłasza się CanBeCanceledjako, truemimo że źródło zostało usunięte. Jeśli spróbują zarejestrować oddzwonienie w sprawie anulowania, BOOM! , ObjectDisposedException. Wystarczy zadzwonić Dispose()po pomyślnym zakończeniu operacji. To staje się naprawdę trudne, gdy naprawdę musisz coś anulować.
Mike Strobel
8
Głos w dół z powodów podanych przez Mike'a Strobela - wymuszenie na regule, aby zawsze wywoływała Dispose, może doprowadzić cię do kłopotliwych sytuacji podczas pracy z CTS i Task ze względu na ich asynchroniczny charakter. Zamiast tego reguła powinna brzmieć: zawsze usuwaj połączone źródła tokenów.
Søren Boisen
1
Twój link prowadzi do usuniętej odpowiedzi.
Trisped
19

Ta odpowiedź wciąż pojawia się w wyszukiwaniach Google i uważam, że głosowana odpowiedź nie zawiera pełnej historii. Po patrząc na kod źródłowy dla CancellationTokenSource(CTS) i CancellationToken(CT) Wierzę, że dla większości przypadków użycia następujących sekwencji kodu jest w porządku:

if (cancelTokenSource != null)
{
    cancelTokenSource.Cancel();
    cancelTokenSource.Dispose();
    cancelTokenSource = null;
}

m_kernelHandlePole wewnętrzne wspomniano powyżej jest przedmiotem synchronizacja kopii na WaitHandlenieruchomość w obu klasach CTS i CT. Jest tworzony tylko wtedy, gdy masz dostęp do tej właściwości. Tak więc, jeśli nie używasz WaitHandlestarej szkoły synchronizacji wątków w Taskusuwaniu wywołań, nie przyniesie to żadnego efektu.

Oczywiście, jeśli użyciem należy zrobić, co jest sugerowane przez innych odpowiedzi powyżej opóźnienia powołania Disposeaż wszystkie WaitHandleoperacje za pomocą uchwytu są kompletne, ponieważ, jak to jest opisane w dokumentacji API Windows dla WaitHandle , wyniki są niezdefiniowane.

jlyonsmith
źródło
7
Artykuł MSDN Cancellation in Managed Threads stwierdza: „Detektory monitorują wartość IsCancellationRequestedwłaściwości tokenu przez odpytywanie, wywołanie zwrotne lub dojście do oczekiwania”. Innymi słowy: to nie Ty (tj. Osoba wysyłająca żądanie asynchroniczne) możesz użyć uchwytu oczekiwania, może to być słuchacz (tj. Osoba odpowiadająca na żądanie). Oznacza to, że jako osoba odpowiedzialna za usuwanie nie masz żadnej kontroli nad tym, czy uchwyt oczekiwania jest używany, czy nie.
herzbube
Według MSDN zarejestrowane wywołania zwrotne, które mają wyjątek, spowodują zgłoszenie .Cancel. Twój kod nie wywoła .Dispose (), jeśli tak się stanie. Callback powinien uważać, aby tego nie zrobić, ale może się to zdarzyć.
Joseph Lennox
11

Minęło dużo czasu, odkąd o to zapytałem i otrzymałem wiele pomocnych odpowiedzi, ale natknąłem się na interesujący problem związany z tym i pomyślałem, że opublikuję go tutaj jako kolejną odpowiedź:

Powinieneś dzwonić CancellationTokenSource.Dispose()tylko wtedy, gdy jesteś pewien, że nikt nie będzie próbował zdobyć Tokenwłasności CTS . W przeciwnym razie nie powinieneś tego nazywać, ponieważ jest to wyścig. Na przykład zobacz tutaj:

https://github.com/aspnet/AspNetKatana/issues/108

W rozwiązaniu tego problemu kod, który poprzednio robił, cts.Cancel(); cts.Dispose();został zmieniony tak, aby zrobić, cts.Cancel();ponieważ każdy, kto ma pecha, aby spróbować uzyskać token anulowania, aby obserwować jego stan anulowania po Dispose wywołaniu, niestety będzie musiał również obsłużyć ObjectDisposedException- oprócz OperationCanceledExceptionże planowali.

Inną kluczową obserwacją związaną z tą poprawką jest Tratcher: „Utylizacja jest wymagana tylko w przypadku tokenów, które nie zostaną anulowane, ponieważ anulowanie powoduje takie samo czyszczenie”. tzn. po prostu robienie Cancel()zamiast wyrzucania jest naprawdę wystarczająco dobre!

Tim Lovell-Smith
źródło
1

Stworzyłem klasę bezpieczną dla wątków, która wiąże a CancellationTokenSourcez a Taski gwarantuje, że CancellationTokenSourcezostanie usunięta, gdy skojarzona z nim zostanie Taskzakończona. Używa blokad, aby zapewnić, że CancellationTokenSourcenie zostaną anulowane podczas lub po ich usunięciu . Dzieje się tak w celu zachowania zgodności z dokumentacją , która stwierdza:

Tej Disposemetody należy używać tylko wtedy, gdy wszystkie inne operacje na CancellationTokenSourceobiekcie zostały zakończone.

A także :

DisposeMetoda pozostawia CancellationTokenSourcew stanie nie do użytku.

Oto klasa:

public class CancelableExecution
{
    private readonly bool _allowConcurrency;
    private Operation _activeOperation;

    private class Operation : IDisposable
    {
        private readonly object _locker = new object();
        private readonly CancellationTokenSource _cts;
        private readonly TaskCompletionSource<bool> _completionSource;
        private bool _disposed;

        public Task Completion => _completionSource.Task; // Never fails

        public Operation(CancellationTokenSource cts)
        {
            _cts = cts;
            _completionSource = new TaskCompletionSource<bool>(
                TaskCreationOptions.RunContinuationsAsynchronously);
        }
        public void Cancel()
        {
            lock (_locker) if (!_disposed) _cts.Cancel();
        }
        void IDisposable.Dispose() // Is called only once
        {
            try
            {
                lock (_locker) { _cts.Dispose(); _disposed = true; }
            }
            finally { _completionSource.SetResult(true); }
        }
    }

    public CancelableExecution(bool allowConcurrency)
    {
        _allowConcurrency = allowConcurrency;
    }
    public CancelableExecution() : this(false) { }

    public bool IsRunning =>
        Interlocked.CompareExchange(ref _activeOperation, null, null) != null;

    public async Task<TResult> RunAsync<TResult>(
        Func<CancellationToken, Task<TResult>> taskFactory,
        CancellationToken extraToken = default)
    {
        var cts = CancellationTokenSource.CreateLinkedTokenSource(extraToken, default);
        using (var operation = new Operation(cts))
        {
            // Set this as the active operation
            var oldOperation = Interlocked.Exchange(ref _activeOperation, operation);
            try
            {
                if (oldOperation != null && !_allowConcurrency)
                {
                    oldOperation.Cancel();
                    await oldOperation.Completion; // Continue on captured context
                }
                var task = taskFactory(cts.Token); // Run in the initial context
                return await task.ConfigureAwait(false);
            }
            finally
            {
                // If this is still the active operation, set it back to null
                Interlocked.CompareExchange(ref _activeOperation, null, operation);
            }
        }
    }

    public Task RunAsync(Func<CancellationToken, Task> taskFactory,
        CancellationToken extraToken = default)
    {
        return RunAsync<object>(async ct =>
        {
            await taskFactory(ct).ConfigureAwait(false);
            return null;
        }, extraToken);
    }

    public Task CancelAsync()
    {
        var operation = Interlocked.CompareExchange(ref _activeOperation, null, null);
        if (operation == null) return Task.CompletedTask;
        operation.Cancel();
        return operation.Completion;
    }

    public bool Cancel() => CancelAsync() != Task.CompletedTask;
}

Podstawowe metody tej CancelableExecutionklasy to RunAsynci Cancel. Domyślnie operacje współbieżne nie są dozwolone, co oznacza, że ​​wywołanie RunAsyncpo raz drugi spowoduje ciche anulowanie i oczekiwanie na zakończenie poprzedniej operacji (jeśli nadal jest uruchomiona) przed rozpoczęciem nowej operacji.

Ta klasa może być używana w dowolnych aplikacjach. Jego głównym zastosowaniem jest jednak w aplikacjach UI, wewnątrz formularzy z przyciskami do uruchamiania i anulowania operacji asynchronicznej lub z listą, która anuluje i ponownie uruchamia operację za każdym razem, gdy zostanie zmieniony jej wybrany element. Oto przykład pierwszego przypadku:

private readonly CancelableExecution _cancelableExecution = new CancelableExecution();

private async void btnExecute_Click(object sender, EventArgs e)
{
    string result;
    try
    {
        Cursor = Cursors.WaitCursor;
        btnExecute.Enabled = false;
        btnCancel.Enabled = true;
        result = await _cancelableExecution.RunAsync(async ct =>
        {
            await Task.Delay(3000, ct); // Simulate some cancelable I/O operation
            return "Hello!";
        });
    }
    catch (OperationCanceledException)
    {
        return;
    }
    finally
    {
        btnExecute.Enabled = true;
        btnCancel.Enabled = false;
        Cursor = Cursors.Default;
    }
    this.Text += result;
}

private void btnCancel_Click(object sender, EventArgs e)
{
    _cancelableExecution.Cancel();
}

RunAsyncSposób przyjmuje dodatkową CancellationTokenjako argumentu, który jest połączony z utworzony wewnętrznie CancellationTokenSource. Dostarczenie tego opcjonalnego tokena może być przydatne w scenariuszach zaawansowanych.

Theodor Zoulias
źródło