Dlaczego nie mogę użyć operatora „Oczekiwanie” w treści instrukcji blokady?

348

Słowo kluczowe Oczekiwanie w C # (.NET Async CTP) jest niedozwolone w instrukcji lock.

Z MSDN :

Wyrażenia oczekującego nie można użyć w funkcji synchronicznej, w wyrażeniu zapytania, w instrukcji catch lub wreszcie instrukcji obsługi wyjątku, w bloku instrukcji lock lub w niebezpiecznym kontekście.

Zakładam, że z jakiegoś powodu zespół kompilatora jest trudny lub niemożliwy do wdrożenia.

Próbowałem obejść instrukcję using:

class Async
{
    public static async Task<IDisposable> Lock(object obj)
    {
        while (!Monitor.TryEnter(obj))
            await TaskEx.Yield();

        return new ExitDisposable(obj);
    }

    private class ExitDisposable : IDisposable
    {
        private readonly object obj;
        public ExitDisposable(object obj) { this.obj = obj; }
        public void Dispose() { Monitor.Exit(this.obj); }
    }
}

// example usage
using (await Async.Lock(padlock))
{
    await SomethingAsync();
}

Nie działa to jednak zgodnie z oczekiwaniami. Wywołanie Monitor.Exit w ExitDisposable.Dispose wydaje się blokować na czas nieokreślony (przez większość czasu), powodując zakleszczenia, gdy inne wątki próbują uzyskać blokadę. Podejrzewam, że niewiarygodność mojej pracy, a powody, dla których oświadczenia oczekujące są niedozwolone, są w jakiś sposób powiązane.

Czy ktoś wie, dlaczego czekanie nie jest dozwolone w treści instrukcji blokady?

Kevin
źródło
27
Wyobrażam sobie, że znalazłeś powód, dla którego nie jest to dozwolone.
asawyer 30.09.11
Zaczynam już nadrabiać zaległości i uczyć się trochę więcej o programowaniu asynchronicznym. Po wielu impasach w aplikacjach wpf, ten artykuł był świetną ochroną w praktykach programowania asynchronicznego. msdn.microsoft.com/en-us/magazine/…
C. Tewalt
Blokada ma na celu zapobieganie dostępowi asynchronicznemu, gdy dostęp asynchroniczny złamałby kod, ergo, jeśli używasz asynchronizacji wewnątrz zamka, unieważniłeś zamek .. więc jeśli musisz poczekać na coś wewnątrz zamka, nie używasz go poprawnie
MikeT

Odpowiedzi:

366

Zakładam, że z jakiegoś powodu zespół kompilatora jest trudny lub niemożliwy do wdrożenia.

Nie, wcale nie jest to trudne ani niemożliwe do wdrożenia - fakt, że sam go wdrożyłeś, świadczy o tym. Jest to raczej bardzo zły pomysł, dlatego nie pozwalamy na to, aby uchronić cię przed popełnieniem tego błędu.

Wywołanie Monitor.Exit w ExitDisposable.Dispose wydaje się blokować w nieskończoność (przez większość czasu), powodując zakleszczenia, gdy inne wątki próbują uzyskać blokadę. Podejrzewam, że niewiarygodność mojej pracy, a powody, dla których oświadczenia oczekujące są niedozwolone, są w jakiś sposób powiązane.

Zgadza się, odkryłeś, dlaczego wprowadziliśmy to w nielegalność. Oczekiwanie wewnątrz zamka to przepis na wytwarzanie zakleszczeń.

Jestem pewien, że rozumiesz, dlaczego: dowolny kod jest uruchamiany między czasem, w którym oczekiwanie zwraca kontrolę nad programem wywołującym, a metoda wznawia działanie . Tym dowolnym kodem może być usuwanie blokad, które powodują odwrócenie kolejności blokowania, a tym samym zakleszczenia.

Co gorsza, kod może zostać wznowiony w innym wątku (w zaawansowanych scenariuszach; zwykle odbiera się ponownie w wątku, który czekał, ale niekoniecznie), w którym to przypadku odblokowanie odblokowuje zamek w innym wątku niż wątek, który wziął z zamka. Czy to dobry pomysł? Nie.

Zauważam, że jest to również „najgorsza praktyka” robić yield returnwnętrze z locktego samego powodu. Jest to zgodne z prawem, ale szkoda, że ​​nie uczyniliśmy tego nielegalnym. Nie popełnimy tego samego błędu co do „czekania”.

Eric Lippert
źródło
189
Jak radzisz sobie ze scenariuszem, w którym musisz zwrócić pozycję pamięci podręcznej, a jeśli pozycja nie istnieje, musisz obliczyć zawartość asynchronicznie, a następnie dodać + zwrócić pozycję, upewniając się, że nikt inny nie zadzwoni w międzyczasie?
Softlion
9
Zdaję sobie sprawę, że spóźniłem się na przyjęcie tutaj, ale zdziwiłem się, widząc, że ustawiłeś impasy jako główny powód, dla którego to zły pomysł. Doszedłem do wniosku w moim własnym przekonaniu, że ponowne włączenie blokady / monitora będzie większą częścią problemu. Oznacza to, że ustawiasz w kolejce dwa zadania do puli wątków, które lock (), które w świecie synchronicznym byłyby wykonywane na osobnych wątkach. Ale teraz z oczekiwaniem (jeśli to dozwolone), możesz mieć dwa zadania wykonujące się w bloku blokady, ponieważ wątek został ponownie użyty. Następuje wesołość. Czy coś źle zrozumiałem?
Gareth Wilson,
4
@GarethWilson: Mówiłem o impasach, ponieważ zadane pytanie dotyczyło impasów . Masz rację, że dziwne problemy związane z ponownym wejściem są możliwe i wydają się prawdopodobne.
Eric Lippert,
11
@Eric Lippert. Biorąc pod uwagę, że SemaphoreSlim.WaitAsyncklasa została dodana do środowiska .NET długo po opublikowaniu tej odpowiedzi, myślę, że możemy bezpiecznie założyć, że jest to teraz możliwe. Niezależnie od tego, twoje komentarze na temat trudności w implementacji takiego konstruktu są nadal w pełni aktualne.
Contango,
7
„dowolny kod jest uruchamiany między czasem, w którym oczekiwanie zwraca kontrolę nad programem wywołującym, a metoda wznawia” - z pewnością dotyczy to każdego kodu, nawet przy braku asynchronizacji / oczekiwania w kontekście wielowątkowym: inne wątki mogą wykonywać dowolny kod w dowolnym czas i powiedział dowolny kod, jak mówisz „może zdejmować zamki, które powodują odwrócenie kolejności zamków, a tym samym zakleszczenia”. Dlaczego więc ma to szczególne znaczenie w przypadku asynchronizacji / czekania? Rozumiem, że druga kwestia „kod może zostać wznowiony w innym wątku” ma szczególne znaczenie dla asynchronizacji / oczekiwania.
Bacar
291

Użyj SemaphoreSlim.WaitAsyncmetody

 await mySemaphoreSlim.WaitAsync();
 try {
     await Stuff();
 } finally {
     mySemaphoreSlim.Release();
 }
użytkownik1639030
źródło
10
Ponieważ ta metoda została niedawno wprowadzona do środowiska .NET, myślę, że możemy założyć, że koncepcja blokowania w świecie asynchronizacji / oczekiwania jest teraz dobrze sprawdzona.
Contango,
5
Aby uzyskać więcej informacji, wyszukaj tekst „SemaphoreSlim” w tym artykule: Async / Await - Best Practices in Asynchronous Programming
BobbyA
1
@JamesKo, jeśli wszystkie te zadania czekają na wynik Stuff, nie widzę żadnego sposobu na obejście tego ...
Ohad Schneider
7
Czy nie należy go inicjować tak mySemaphoreSlim = new SemaphoreSlim(1, 1), aby działał jak lock(...)?
Siergiej
3
Dodano rozszerzoną wersję tej odpowiedzi: stackoverflow.com/a/50139704/1844247
Sergey
67

Zasadniczo byłoby to niewłaściwe.

Można to zrealizować na dwa sposoby :

  • Trzymaj zamek, zwalniając go tylko na końcu bloku .
    To naprawdę zły pomysł, ponieważ nie wiesz, ile czasu zajmie operacja asynchroniczna. Blokady należy trzymać tylko przez minimalny czas. Jest to również potencjalnie niemożliwe, ponieważ wątek posiada blokadę, a nie metodę - a nawet nie można wykonać reszty metody asynchronicznej w tym samym wątku (w zależności od harmonogramu zadań).

  • Zwolnij blokadę w oczekiwaniu i ponownie ją uzyskaj, gdy oczekiwanie powróci.
    To narusza zasadę najmniejszego zdziwienia IMO, w której metoda asynchroniczna powinna zachowywać się tak dokładnie, jak to możliwe, jak równoważny kod synchroniczny - chyba że użyjesz Monitor.Waitw bloku blokady, spodziewaj się posiadać zamek na czas trwania bloku.

Zasadniczo istnieją tutaj dwa konkurujące wymagania - nie powinieneś próbować robić pierwszego tutaj, a jeśli chcesz zastosować drugie podejście, możesz uczynić kod znacznie wyraźniejszym poprzez oddzielenie dwóch bloków blokujących oddzielonych wyrażeniem oczekującym:

// Now it's clear where the locks will be acquired and released
lock (foo)
{
}
var result = await something;
lock (foo)
{
}

Tak więc zakazując ci czekania w samym bloku blokady, język zmusza cię do myślenia o tym, co naprawdę chcesz zrobić, i czyni wybór bardziej przejrzystym w pisanym kodzie.

Jon Skeet
źródło
5
Biorąc pod uwagę, że SemaphoreSlim.WaitAsyncklasa została dodana do środowiska .NET długo po opublikowaniu tej odpowiedzi, myślę, że możemy bezpiecznie założyć, że jest to teraz możliwe. Niezależnie od tego, twoje komentarze na temat trudności w implementacji takiego konstruktu są nadal w pełni aktualne.
Contango,
7
@Contango: Dobrze, że nie jest całkiem to samo. W szczególności semafor nie jest powiązany z konkretnym wątkiem. Osiąga podobne cele do zablokowania, ale istnieją znaczne różnice.
Jon Skeet
@JonSkeet Wiem, że to bardzo stary wątek i tak dalej, ale nie jestem pewien, w jaki sposób wywołanie czegoś () jest chronione przy użyciu tych blokad w drugi sposób? gdy wątek wykonuje coś (), może się w to również zaangażować każdy inny wątek! Czy coś mi umyka ?
@Joseph: W tym momencie nie jest chroniony. To drugie podejście, które wyjaśnia, że ​​nabywasz / zwalniasz, a następnie ponownie nabywasz / zwalniasz, być może w innym wątku. Ponieważ pierwsze podejście jest złym pomysłem, zgodnie z odpowiedzią Erica.
Jon Skeet
41

To tylko rozszerzenie tej odpowiedzi .

using System;
using System.Threading;
using System.Threading.Tasks;

public class SemaphoreLocker
{
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

    public async Task LockAsync(Func<Task> worker)
    {
        await _semaphore.WaitAsync();
        try
        {
            await worker();
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

Stosowanie:

public class Test
{
    private static readonly SemaphoreLocker _locker = new SemaphoreLocker();

    public async Task DoTest()
    {
        await _locker.LockAsync(async () =>
        {
            // [asyn] calls can be used within this block 
            // to handle a resource by one thread. 
        });
    }
}
Siergiej
źródło
1
Blokada semafora poza tryblokiem może być niebezpieczna - jeśli zdarzy się wyjątek między, WaitAsynca trysemafor nigdy nie zostanie zwolniony (zakleszczenie). Z drugiej strony przeniesienie WaitAsyncwywołania do trybloku wprowadzi inny problem, gdy semafor może zostać zwolniony bez uzyskiwania blokady. Zobacz pokrewny wątek, w którym wyjaśniono ten problem: stackoverflow.com/a/61806749/7889645
AndreyCh
16

Odnosi się to do http://blogs.msdn.com/b/pfxteam/archive/2012/02/12/10266988.aspx , http://winrtstoragehelper.codeplex.com/ , sklepu z aplikacjami Windows 8 i .net 4.5

Oto mój punkt widzenia na to:

Funkcja języka asynchronicznego / oczekującego sprawia, że ​​wiele rzeczy jest dość łatwych, ale wprowadza również scenariusz, z którym rzadko się zdarzało, zanim było tak łatwe w użyciu wywołania asynchroniczne: ponowne wejście.

Jest to szczególnie prawdziwe w przypadku procedur obsługi zdarzeń, ponieważ w przypadku wielu zdarzeń nie masz pojęcia, co się dzieje po powrocie z procedury obsługi zdarzeń. Jedną z rzeczy, które mogą się zdarzyć, jest to, że metoda asynchroniczna, na którą czekasz w pierwszej procedurze obsługi zdarzeń, jest wywoływana z innej procedury obsługi zdarzeń, która wciąż znajduje się w tym samym wątku.

Oto prawdziwy scenariusz, z którym spotkałem się w aplikacji Windows 8 App Store: Moja aplikacja ma dwie ramki: wchodzenie i wychodzenie z ramki Chcę załadować / zabezpieczyć niektóre dane do pliku / pamięci. Zdarzenia OnNavigatedTo / From służą do zapisywania i ładowania. Zapisywanie i ładowanie odbywa się za pomocą funkcji narzędzia asynchronicznego (np. Http://winrtstoragehelper.codeplex.com/ ). Podczas nawigacji z ramki 1 do ramki 2 lub w innym kierunku, ładowanie asynchroniczne i operacje bezpieczne są wywoływane i oczekiwane. Procedury obsługi zdarzeń stają się asynchroniczne zwracając void => nie można się doczekać.

Jednak pierwsza operacja otwierania pliku (powiedzmy: wewnątrz funkcji składowania) narzędzia jest również asynchroniczna, więc pierwsze oczekiwanie zwraca kontrolę do frameworka, który później wywołuje inne narzędzie (ładowanie) za pośrednictwem drugiej procedury obsługi zdarzeń. Ładunek próbuje teraz otworzyć ten sam plik, a jeśli plik jest już otwarty dla operacji składowania, kończy się niepowodzeniem z wyjątkiem ACCESSDENIED.

Minimalnym rozwiązaniem dla mnie jest zabezpieczenie dostępu do pliku za pomocą przy użyciu i AsyncLock.

private static readonly AsyncLock m_lock = new AsyncLock();
...

using (await m_lock.LockAsync())
{
    file = await folder.GetFileAsync(fileName);
    IRandomAccessStream readStream = await file.OpenAsync(FileAccessMode.Read);
    using (Stream inStream = Task.Run(() => readStream.AsStreamForRead()).Result)
    {
        return (T)serializer.Deserialize(inStream);
    }
}

Należy pamiętać, że jego blokada zasadniczo blokuje wszystkie operacje na plikach dla narzędzia za pomocą tylko jednej blokady, co jest niepotrzebnie silne, ale działa dobrze w moim scenariuszu.

Oto mój projekt testowy: aplikacja Windows 8 App Store z kilkoma testowymi wywołaniami oryginalnej wersji z http://winrtstoragehelper.codeplex.com/ i mojej zmodyfikowanej wersji, która używa AsyncLock z Stephen Toub http: //blogs.msdn. com / b / pfxteam / archive / 2012/02/12 / 10266988.aspx .

Czy mogę również zasugerować ten link: http://www.hanselman.com/blog/ComparingTwoTechniquesInNETAsynchronousCoordinationPrimitives.aspx

hans
źródło
7

Stephen Taub zaimplementował rozwiązanie tego pytania, patrz Budowanie prymityw koordynacji asynchronicznej, część 7: AsyncReaderWriterLock .

Stephen Taub jest wysoko ceniony w branży, więc wszystko, co pisze, może być solidne.

Nie będę reprodukować kodu, który opublikował na swoim blogu, ale pokażę ci, jak go użyć:

/// <summary>
///     Demo class for reader/writer lock that supports async/await.
///     For source, see Stephen Taub's brilliant article, "Building Async Coordination
///     Primitives, Part 7: AsyncReaderWriterLock".
/// </summary>
public class AsyncReaderWriterLockDemo
{
    private readonly IAsyncReaderWriterLock _lock = new AsyncReaderWriterLock(); 

    public async void DemoCode()
    {           
        using(var releaser = await _lock.ReaderLockAsync()) 
        { 
            // Insert reads here.
            // Multiple readers can access the lock simultaneously.
        }

        using (var releaser = await _lock.WriterLockAsync())
        {
            // Insert writes here.
            // If a writer is in progress, then readers are blocked.
        }
    }
}

Jeśli chcesz metodę, która jest upieczona w środowisku .NET, użyj SemaphoreSlim.WaitAsynczamiast tego. Nie dostaniesz blokady czytnika / zapisu, ale otrzymasz wypróbowaną i przetestowaną implementację.

Contango
źródło
Jestem ciekawy, czy istnieją jakieś zastrzeżenia dotyczące używania tego kodu. Jeśli ktoś może zademonstrować jakiekolwiek problemy z tym kodem, chciałbym wiedzieć. Jednak prawda jest taka, że ​​koncepcja asynchronizacji / oczekiwania na blokowanie jest zdecydowanie dobrze sprawdzona, podobnie jak SemaphoreSlim.WaitAsyncw środowisku .NET. Wszystko, co robi ten kod, to dodanie koncepcji blokady czytnika / pisarza.
Contango,
3

Hmm, wygląda brzydko, wydaje się działać.

static class Async
{
    public static Task<IDisposable> Lock(object obj)
    {
        return TaskEx.Run(() =>
            {
                var resetEvent = ResetEventFor(obj);

                resetEvent.WaitOne();
                resetEvent.Reset();

                return new ExitDisposable(obj) as IDisposable;
            });
    }

    private static readonly IDictionary<object, WeakReference> ResetEventMap =
        new Dictionary<object, WeakReference>();

    private static ManualResetEvent ResetEventFor(object @lock)
    {
        if (!ResetEventMap.ContainsKey(@lock) ||
            !ResetEventMap[@lock].IsAlive)
        {
            ResetEventMap[@lock] =
                new WeakReference(new ManualResetEvent(true));
        }

        return ResetEventMap[@lock].Target as ManualResetEvent;
    }

    private static void CleanUp()
    {
        ResetEventMap.Where(kv => !kv.Value.IsAlive)
                     .ToList()
                     .ForEach(kv => ResetEventMap.Remove(kv));
    }

    private class ExitDisposable : IDisposable
    {
        private readonly object _lock;

        public ExitDisposable(object @lock)
        {
            _lock = @lock;
        }

        public void Dispose()
        {
            ResetEventFor(_lock).Set();
        }

        ~ExitDisposable()
        {
            CleanUp();
        }
    }
}
Anton Pogonets
źródło
0

Próbowałem użyć Monitora (kod poniżej), który wydaje się działać, ale ma GOTCHA ... gdy masz wiele wątków, to da ... System.Threading.SynchronizationLockException Metoda synchronizacji obiektu została wywołana z niezsynchronizowanego bloku kodu.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace MyNamespace
{
    public class ThreadsafeFooModifier : 
    {
        private readonly object _lockObject;

        public async Task<FooResponse> ModifyFooAsync()
        {
            FooResponse result;
            Monitor.Enter(_lockObject);
            try
            {
                result = await SomeFunctionToModifyFooAsync();
            }
            finally
            {
                Monitor.Exit(_lockObject);
            }
            return result;
        }
    }
}

Wcześniej po prostu to robiłem, ale było to w kontrolerze ASP.NET, co spowodowało impas.

public async Task<FooResponse> ModifyFooAsync() { lock(lockObject) { return SomeFunctionToModifyFooAsync.Result; } }

Andrzej Pasztet
źródło