You are currently viewing Kopiowanie jest OK, ale rób to asynchronicznie

Kopiowanie jest OK, ale rób to asynchronicznie

Dziś o kopiowaniu. Tyle, że nie o kopiowaniu czyjegoś kodu, czego nie zalecam robić bez zrozumienia, bo można sobie/klientowi/pracodawcy zrobić krzywdę. Będzie o kopiowaniu plików. Kopiowanie plików jak wiadomo nie jest operacją, przez którą mielibyśmy nie spać. NET zapewnia zgrabne metody w statycznych klasach File czy Directory. Dodajemy sobie przestrzeń System.IO i już możemy kopiować do woli.

File.Copy("stąd", "tam")

Jeśli chcemy być bardziej profi, a na naszym pliku wykonujemy więcej niż jedną operację, na przykład chcemy do jakiegoś pliku dopisywać nową linijkę upamiętniającą nowego SMSa, którego Romek napisał do Ali, to możemy utworzyć obiekt new FileInfo("smsy-romka.txt") i używać go do tego celu. Będzie się to odbywało nieco szybciej i fajniej (czyt. profesjonalnie).

Schody zaczną się, kiedy zamiast tekstowych SMSów będziemy chcieli skopiować zdjęcia ważące po kilka MB jedno, które Romek zrobił na spacerze, a miał Romek dużą kartę pamięci i trochę czasu. Wtedy może to potrwać dłużej. Całkiem nam się humor popsuje, jeśli pliki trzeba będzie przesyłać przez sieć, nie w obrębie jednej maszyny. Wtedy najwolniejszym ogniwem nie będzie nasz dysk, tylko wydajność sieci, a z tą bywa różnie.

Takim kopiowaniem, jak pokazałem powyżej zablokujesz wątek i aplikacja stanie się „trudna w kontakcie” lub inaczej mówiąc „nie responsywna”. Sprytnie możesz przekazać taki przydługi proces do wykonania w nowym wątku, a wątek główny może w tym czasie wykonywać inne zadania, jakie dla niego wymyślimy.

var copyTask = Task.Run(() => File.Copy("stąd", "tam"));

Na zakończenie tego Tasku w gdzieś trzeba poczekać wydając komendę: await copyTask. W trakcie oczekiwania wątek główny zostanie zwolniony do puli wątków aplikacji. I jeśli jest to aplikacja webowa lub okienkowa, to natychmiast docenimy zalety naszego posunięcia, bo aplikacja przestanie „lagować”. W wypasionej wersji można nawet przekazać CancellationToken, aby skończyć z takim Taskiem, gdyby oczekiwanie znudziło usera i postanowił zamknąć aplikację.

Jest jednak jeden problem. Po tak partacko zakończonym zadaniu system plików zostawi nam w docelowej lokalizacji plik z przypadkową zawartością. Na dodatek zaalokuje miejsce na dysku. Romek nawet nie będzie wiedział, że stracił swoje zdjęcie!

Aby temat ogarnąć tak, żeby wstydu nie było, wypadało by zrobić dwie rzeczy:

  1. Zadanie kopiowania wykonać asynchronicznie, ale tak, żeby ewentualny shut down aplikcji dał nam czas na zwolnienie zasobów i posprzątanie, zanim ostatecznie aplikacja zostanie zamknięta.
  2. Posprzątanie pozostałości po przerwanym zadaniu.

Do pierwszego punktu użyjemy klasy FileStream, która zawiera asynchroniczną metodę CopyToAsync. Nie będziemy oryginalni i użyjemy tej metody. Czyli kopiujemy tak:

async Task CopyFileAsync(string sourcePath, string destinationPath)
{
    var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
    var destinationStream = new FileStream(destinationPath, FileMode.CreateNew, FileAccess.Write);
    await sourceStream.CopyToAsync(destinationStream);
}

Kod jest uproszczony, aby był przejrzysty. Pełny działający kod można znaleźć na Githubie. Link znajduje się pod artykułem.

Jest prawie super, ale musimy rozwiązać temat sprzątania po ewentualnym przerwaniu kopiowania. Poprawiona wersja wygląda tak:

async Task CopyFileAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken)
{
    try
    {
        var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
        var destinationStream = new FileStream(destinationPath, FileMode.CreateNew, FileAccess.Write);
        await sourceStream.CopyToAsync(destinationStream, cancellationToken);
    }
    catch (OperationCanceledException)
    {
        var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(3));
        await Delete(destinationPath, cts.Token);
    }
    catch (Exception)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cts.CancelAfter(TimeSpan.FromSeconds(10));
        await Delete(destinationPath, cts.Token);
        throw;
    }
}

Po pierwsze, asynchroniczna metoda CopyToAsync przyjmuje CancellationToken, więc korzystamy z tego natychmiast i przekazujemy do niej token (linia 7).

Po drugie, w linii 9 przechwytujemy OperationCanceledException i czas jaki mamy na zamkmięcie procesu wykorzystujemy na skasowanie pliku, który został utworzony w docelowej lokalizacji, ale na pewno będzie uszkodzony, bo przecież kopiowanie zostało przerwane. W tym celu tworzymy nowy token, który daje metodzie Delete() trzy długie sekundy z pięciu jakie defaultowo dostaje aplikacja na zamknięcie (linia 12).

Po trzecie, w przypadku innego błędu także kasujemy plik, który najprawdopodobniej będzie uszkodzony, ale dodatkowo rzucamy wyjątek do kodu wywołującego kopiowanie. Niech go sobie obsłuży (linia 20).

W ten sposób kopiowanie nawet dużych plików może odbywać się bez utraty responsywności aplikacji, a ewentualne błędy nie powodują śmietnika w systemie plików. Kod, który jest na Githubie dodatkowo pilnuje cyklu życia egzemplarzy FileStream. Ma też zaimplementowaną flagę overvrite i ma dodatkową metodę MoveFileAsync.

W każdym kodzie znajdzie się coś do poprawienia. Nie inaczej jest w tym przypadku. Plik po zapisaniu w nowej lokalizacji powinien być zweryfikowany na okoliczność zgodności z oryginałem. Można to zaimplementować przez obliczenie skrótów oryginału i kopii i porównaniu ich wartości. Jeśli nie są zgodne, to coś poszło nie tak i należy taki przypadek obsłużyć.

Pełen kod na Githubie

Dodaj komentarz