Diversity w polskim IT
Jimmy M Andersson
Jimmy M AnderssonSoftware Developer @ NIRA Dynamics AB

Projektowanie aplikacji wielowątkowych w Swift

Sprawdź, jak używać wielowątkowości w Swift, aby upewnić się, że nie blokujesz interfejsu użytkownika.
18.12.20194 min
Projektowanie aplikacji wielowątkowych w Swift

Jako programista iOS w branży motoryzacyjnej, spędzam dużo czasu pracując z danymi w czasie rzeczywistym. Potrzeba wydajnego przetwarzania ciągle napływających strumieni danych jest dziś ważna w wielu aplikacjach. Aby upewnić się, że nie blokujesz interfejsu użytkownika, najprawdopodobniej musisz użyć wielowątkowości.

Praca z informacjami przesyłanymi strumieniowo w czasie rzeczywistym jest naprawdę przyjemna, ponieważ stale otrzymujesz nowe dane, które możesz wykorzystać do aktualizacji swoich wizualizacji. Jest to też jednak najtrudniejsza i najbardziej frustrująca rzecz, jaką możesz robić, ponieważ urządzenie iOS ma pewne ograniczenia sprzętowe. Na szczęście Apple udostępniło wielowątkowość dzięki niezwykle łatwemu w użyciu interfejsowi o nazwie GCD (Grand Central Dispatch). Być zdarzało Ci się widywać kod, który wyglądał mniej więcej tak:

DispatchQueue.main.async {
  // Place a work item in the GCD main queue and then
  // move on to the next statement in your code.
}


Główna kolejka to miejsce, w którym uruchamiana jest większość kodu programów, jeśli nie umieścisz go wprost w innej kolejce. Jest to kolejka szeregowa, co oznacza, że wybiera pierwszy element na liście, wykonuje kod, czeka na zakończenie i zwalnia element. Następnie wybiera kolejny element z listy itd.

Wielowątkowość i współbieżność

Główna kolejka nie jest jednak jedyną kolejką, jaką udostępnia GCD. Istnieje wiele predefiniowanych kolejek, które mają różne priorytety. Istnieją również sposoby tworzenia własnych, specjalistycznych kolejek:

let myConcurrentQueue = DispatchQueue(label: "ConcurrentQueue",
                                      qos: .background,
                                      attributes: .concurrent,
                                      autoreleaseFrequency: .workItem,
                                      target: nil)


Zauważ, że utworzona kolejka ma atrybut .concurrent, co oznacza, że ta konkretna kolejka nie będzie czekać na zakończenie jednej operacji przed wykonaniem następnej. Po prostu umieści pierwszy element w wątku i uruchomi go, a następnie przejdzie do kolejnego elementu, niezależnie od tego, czy pierwszy już skończył, czy nie.

Kwestie techniczne

Załóżmy, że pracujesz ze strumieniem danych, w którym częstotliwość próbkowania wynosi ~20Hz. Oznacza to, że będziesz mieć około 50 milisekund na przeanalizowanie i zinterpretowanie danych, dodanie ich do struktury danych i wyświetlenie wyników. Jeśli Twoje urządzenie iOS spróbuje to zrobić w głównym wątku, zostanie bardzo mało czasu na sprawdzenie, czy użytkownik próbuje wejść w interakcję z aplikacją i aplikacja przestanie odpowiadać. Tutaj przechodzimy do wielowątkowości.

Załóżmy, że używamy bardzo prostej struktury danych do przechowywania otrzymanych próbek danych - tablicy liczb całkowitych. Możemy stworzyć kolejkę i użyć jej w następujący sposób:

// Here's our data queue from before, but with a
// higher priority Quality of Service flag
let myDataQueue = DispatchQueue(label: "DataQueue",
                                qos: .userInitiated,
                                attributes: .concurrent,
                                autoreleaseFrequency: .workItem,
                                target: nil)

// Our data structure, probably initialized in 
// a data manager somewhere
var dataArray = [Int]()

// When we receive our data, we call our
// parsing/storing/updating code like this
myDataQueue.async {
  let parsedData = parseData(data)
  dataArray.append(parsedData)
  DispatchQueue.main.async {
    updateViews()
  }
}

Czy to działa?

Wygląda to dobrze, prawda? Teraz przetwarzamy wszystkie dane w wątku w tle, a główny wątek służy wyłącznie do aktualizacji naszych wizualizacji. Mimo wszystko, to musi paść. Dlaczego? Odpowiedź jest techniczna, ale spójrz na to w inny sposób.

Ponieważ nasza kolejka jest współbieżna, będzie wyrzucać elementy do przetworzenia do wątków, które będą wykonywane równolegle. Używamy również tablicy do przechowywania danych. Tablica Swift jest strukturą, co oznacza, że jest to typ wartościowy. Kiedy spróbujesz dołączyć wartość do tablicy takiej jak ta, będziesz musiał:

  1. Zaalokować nową tablicę i skopiować wartości ze starej tablicy
  2. Dołączyć nowe dane
  3. Napisać nową referencję z powrotem do zmiennej
  4. System będzie kontynuował zwalnianie pamięci używanej przez starą tablicę


Zastanów się, co by się stało, gdyby dwa wątki otrzymały tę samą tablicę, skopiowały do siebie własne dane, a następnie zapisały nowe odwołanie do naszej zmiennej, albo jedno przed drugim, albo oba jednocześnie. Pierwszy przypadek da nam niepoprawne dane, ponieważ danych z wątku, który napisał pierwszy, zabraknie w tablicy zapisanej przez wątek, który zapisuje jako ostatni. Drugi przypadek spowoduje awarię naszej aplikacji, ponieważ dwa wątki nie mogą jednocześnie uzyskać dostępu do zapisu do przydzielonej pamięci.

Mając to na uwadze, moglibyśmy zastosować całkiem sprytną konstrukcję, która pochodzi z klasy DispatchQueue, a nazywana jest flagą. Teraz możemy edytować nasz kod w następujący sposób:

let myDataQueue = DispatchQueue(label: "DataQueue",
                                qos: .userInitiated,
                                attributes: .concurrent,
                                autoreleaseFrequency: .workItem,
                                target: nil)

var dataArray = [Int]()

// The .barrier flag tells the queue that this particular
// work item will need to be executed without any other 
// work item running in parallel
myDataQueue.async(flags: .barrier) {
  let parsedData = parseData(data)
  dataArray.append(parsedData)
  DispatchQueue.main.async {
    // This method will most likely need to access our data
    // structure at some point, and it will need to do so
    // in a specific manner. Check the implementation below.
    updateViews()
  }
}

func updateViews() {
  let dataForViews = return myDataQueue.sync { return dataArray }
  // Do any updates using the dataForViews variable,
  // since that will remain intact even if the data
  // array is changed during our update.
}


Może to wyglądać skomplikowanie, ale już wyjaśniam, co tu zrobiłem.

Używając flagi .barrier, ilekroć dodamy element, który zmieni naszą strukturę danych poprzez zapis do niej, informujemy naszą kolejkę, że ten konkretny element będzie musiał zostać wykonany samodzielnie. Oznacza to, że kolejka będzie musiała poczekać na zakończenie wszystkich uruchomionych wątków, a następnie uruchomić ten element i poczekać na zakończenie, a następnie będzie mogła ponownie uruchomić wykonywanie kodu równolegle.

Gdy główny wątek potrzebuje dostępu do danych, aby zaktualizować nasze widoki, musi przejść przez kolejkę danych za pomocą połączenia synchronicznego. Jeśli tak się nie stanie, istnieje ryzyko, że jeden z naszych wątków spowoduje uszkodzenie danych, które odczytuje w dowolnym momencie.

Podsumowanie

Mam nadzieję, że udało Ci się zdobyć trochę nowej wiedzy. Pomocne może być ponowne przeczytanie tego za kilka dni, dając sobie trochę czasu na przemyślenie tych operacji.


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>