Podstawy programowania asynchronicznego: Async, Await, Futures i Streams
Wiele operacji, o które prosimy komputer, może zająć trochę czasu. Dobrze by było, gdybyśmy mogli robić coś innego, czekając na zakończenie tych długotrwałych procesów. Nowoczesne komputery oferują dwie techniki pracy nad więcej niż jedną operacją jednocześnie: równoległość i współbieżność. Logika naszych programów jest jednak pisana w sposób głównie liniowy. Chcielibyśmy móc określać operacje, które program powinien wykonać, oraz punkty, w których funkcja mogłaby się zatrzymać, a jakaś inna część programu mogłaby działać zamiast niej, bez konieczności wcześniejszego precyzowania dokładnej kolejności i sposobu, w jaki każdy fragment kodu powinien działać. Programowanie asynchroniczne to abstrakcja, która pozwala nam wyrażać kod w terminach potencjalnych punktów pauzy i ostatecznych wyników, która zajmuje się szczegółami koordynacji za nas.
Ten rozdział opiera się na użyciu wątków do równoległości i współbieżności z
Rozdziału 16, wprowadzając alternatywne podejście do pisania kodu: futures i
streams Rust oraz składnię async i await, które pozwalają nam wyrazić, jak
operacje mogą być asynchroniczne, oraz crate’y zewnętrzne, które
implementują środowiska uruchomieniowe asynchroniczne: kod, który zarządza i
koordynuje wykonywanie operacji asynchronicznych.
Rozważmy przykład. Powiedzmy, że eksportujesz wideo, które stworzyłeś z rodzinnej uroczystości – operacja, która może trwać od kilku minut do kilku godzin. Eksport wideo wykorzysta tyle mocy CPU i GPU, ile tylko może. Gdybyś miał tylko jeden rdzeń CPU, a system operacyjny nie wstrzymywałby tego eksportu do momentu jego zakończenia – to znaczy, gdyby wykonywał eksport synchronicznie – nie mógłbyś robić niczego innego na swoim komputerze, podczas gdy to zadanie byłoby uruchomione. Byłoby to dość frustrujące doświadczenie. Na szczęście system operacyjny twojego komputera może, i robi to, niewidocznie przerywać eksport wystarczająco często, abyś mógł jednocześnie wykonywać inną pracę.
Teraz powiedzmy, że pobierasz wideo udostępnione przez kogoś innego, co również może trochę potrwać, ale nie zajmuje tyle czasu procesora. W tym przypadku procesor musi czekać na dane z sieci. Chociaż możesz zacząć odczytywać dane, gdy zaczną napływać, może minąć trochę czasu, zanim wszystkie się pojawią. Nawet gdy wszystkie dane są już dostępne, jeśli wideo jest dość duże, załadowanie całości może zająć co najmniej sekundę lub dwie. Może to nie brzmieć jak wiele, ale to bardzo długo dla nowoczesnego procesora, który potrafi wykonywać miliardy operacji na sekundę. Ponownie, system operacyjny niewidocznie przerwie Twój program, aby umożliwić procesorowi wykonywanie innej pracy, podczas gdy czeka na zakończenie wywołania sieciowego.
Eksport wideo jest przykładem operacji CPU-bound (ograniczonej przez CPU) lub compute-bound (ograniczonej przez obliczenia). Jest ograniczony przez potencjalną szybkość przetwarzania danych przez komputer w obrębie CPU lub GPU oraz przez to, ile z tej szybkości może poświęcić na operację. Pobieranie wideo jest przykładem operacji I/O-bound (ograniczonej przez wejście/wyjście), ponieważ jest ograniczone szybkością wejścia i wyjścia komputera; może działać tylko tak szybko, jak dane mogą być przesyłane przez sieć.
W obu tych przykładach niewidoczne przerwania systemu operacyjnego zapewniają pewną formę współbieżności. Współbieżność ta ma jednak miejsce tylko na poziomie całego programu: system operacyjny przerywa jeden program, aby inne programy mogły wykonywać swoją pracę. W wielu przypadkach, ponieważ rozumiemy nasze programy na znacznie bardziej szczegółowym poziomie niż system operacyjny, możemy dostrzec możliwości współbieżności, których system operacyjny nie jest w stanie zobaczyć.
Na przykład, jeśli budujemy narzędzie do zarządzania pobieraniem plików, powinniśmy móc napisać nasz program tak, aby rozpoczęcie jednego pobierania nie blokowało interfejsu użytkownika, a użytkownicy mogli rozpocząć wiele pobierań jednocześnie. Wiele API systemów operacyjnych do interakcji z siecią jest jednak blokujących; to znaczy, blokują one postęp programu, dopóki przetwarzane dane nie będą całkowicie gotowe.
Uwaga: Tak działa większość wywołań funkcji, jeśli się nad tym zastanowisz. Jednak termin blokujący jest zazwyczaj zarezerwowany dla wywołań funkcji, które współdziałają z plikami, siecią lub innymi zasobami na komputerze, ponieważ to właśnie w tych przypadkach indywidualny program skorzystałby na tym, aby operacja była nie-blokująca.
Moglibyśmy uniknąć blokowania naszego głównego wątku, tworząc dedykowany wątek do pobierania każdego pliku. Jednak narzut zasobów systemowych wykorzystywanych przez te wątki w końcu stałby się problemem. Byłoby lepiej, gdyby wywołanie w ogóle nie blokowało, a zamiast tego moglibyśmy zdefiniować wiele zadań, które chcielibyśmy, aby nasz program wykonał, i pozwolić środowisku uruchomieniowemu wybrać najlepszą kolejność i sposób ich wykonania.
Właśnie to zapewnia nam abstrakcja async (skrót od asynchronous) w Rust. W tym rozdziale dowiesz się wszystkiego o async, omawiając następujące tematy:
- Jak używać składni
asynciawaitRust oraz wykonywać funkcje asynchroniczne w środowisku uruchomieniowym - Jak używać modelu async do rozwiązywania niektórych z tych samych problemów, które rozważaliśmy w Rozdziale 16
- Jak wielowątkowość i async zapewniają uzupełniające się rozwiązania, które można łączyć w wielu przypadkach
Zanim jednak zobaczymy, jak async działa w praktyce, musimy zrobić krótki objazd, aby omówić różnice między równoległością a współbieżnością.
Równoległość i współbieżność
Do tej pory traktowaliśmy równoległość i współbieżność jako w większości wymienne. Teraz musimy je precyzyjniej rozróżnić, ponieważ różnice pojawią się, gdy zaczniemy pracować.
Rozważmy różne sposoby, w jakie zespół mógłby podzielić pracę nad projektem o programowaniu. Można by przydzielić jednemu członkowi wiele zadań, każdemu członkowi jedno zadanie, lub zastosować połączenie obu podejść.
Kiedy jednostka pracuje nad kilkoma różnymi zadaniami, zanim którekolwiek z nich zostanie ukończone, jest to współbieżność. Jeden ze sposobów implementacji współbieżności jest podobny do posiadania dwóch różnych projektów pobranych na komputerze, a kiedy się znudzisz lub utkniesz na jednym projekcie, przełączasz się na drugi. Jesteś tylko jedną osobą, więc nie możesz poczynić postępów w obu zadaniach dokładnie w tym samym czasie, ale możesz wielozadaniowo, robiąc postępy w jednym zadaniu naraz, przełączając się między nimi (patrz Rysunek 17-1).
Kiedy zespół dzieli grupę zadań, tak że każdy członek bierze jedno zadanie i pracuje nad nim samodzielnie, jest to równoległość. Każda osoba w zespole może robić postępy dokładnie w tym samym czasie (patrz Rysunek 17-2).
W obu tych przepływach pracy, możesz musieć koordynować działania między różnymi zadaniami. Może myślałeś, że zadanie przydzielone jednej osobie było całkowicie niezależne od pracy innych, ale w rzeczywistości wymaga ono od innej osoby w zespole najpierw zakończenia swojego zadania. Część pracy mogła być wykonana równolegle, ale część z nich była w rzeczywistości sekwencyjna: mogła dziać się tylko w serii, jedno zadanie po drugim, jak na Rysunku 17-3.
Podobnie, możesz zdać sobie sprawę, że jedno z twoich zadań zależy od innego z twoich zadań. Wtedy twoja współbieżna praca również stała się szeregowa.
Równoległość i współbieżność mogą się również ze sobą krzyżować. Jeśli dowiesz się, że kolega utknął, dopóki nie skończysz jednego ze swoich zadań, prawdopodobnie skupisz wszystkie swoje wysiłki na tym zadaniu, aby „odblokować” swojego kolegę. Ty i twój współpracownik nie jesteście już w stanie pracować równolegle, a także nie jesteście już w stanie pracować współbieżnie nad własnymi zadaniami.
Ta sama podstawowa dynamika wchodzi w grę w oprogramowaniu i sprzęcie. Na maszynie z pojedynczym rdzeniem CPU, CPU może wykonywać tylko jedną operację na raz, ale nadal może działać współbieżnie. Używając narzędzi takich jak wątki, procesy i async, komputer może wstrzymać jedną aktywność i przełączyć się na inne, zanim ostatecznie powróci do tej pierwszej aktywności. Na maszynie z wieloma rdzeniami CPU, może również wykonywać pracę równolegle. Jeden rdzeń może wykonywać jedno zadanie, podczas gdy inny rdzeń wykonuje zupełnie niepowiązane zadanie, a te operacje faktycznie dzieją się w tym samym czasie.
Uruchamianie kodu async w Rust zazwyczaj odbywa się współbieżnie. W zależności od sprzętu, systemu operacyjnego i używanego środowiska asynchronicznego (więcej o środowiskach asynchronicznych wkrótce), ta współbieżność może również wykorzystywać równoległość pod maską.
Teraz zagłębmy się w to, jak faktycznie działa programowanie asynchroniczne w Rust.