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ąć, ż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:

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

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

      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.

      Pełen kod na Githubie

      Dodaj komentarz