Wielowątkowość w JavaScript

tech • 1683 słowa • 8 minut czytania

JavaScript nie posiada wielowątkowości, we wszystkich przeglądarkach (z wyjątkiem Chrome), kod JS wykonywany jest w jednym wątku. Niejeden webdeveloepr “naciął się” na zamrożenie przeglądarki (lub ostrzeżenie w Firefoksie) w czasie wykonywania intensywnego kodu zajmującego zasoby. W takich wypadkach JS blokuje przeglądarkę, podobnie aktualizacje interfejsu użytkownika i zawartość strony, do czasu zakończenia wykonywania bieżącej operacji, co można w prosty sposób doświadczyć, poprzez prostą konstrukcję nieskończonej pętli (symulującej ciężkie obliczenia):

while (true)

Taką “ciężką” operacją może być przeliczenie dużej ilości danych, lub chociażby długotrwałe operacje na drzewie DOM (np. przetworzenie 100k elementów). Mała ciekawostka - jak wspomniałem blokowane jest aktualizowanie UI, zatem, gdybyśmy sobie dodali jakiś “progress bar” do monitorowania postępu prac w takiej pętli, aka:

progressBar.value = x;

To niestety taka konstrukcja nie zadziałałaby poprawnie - zawartość odświeżyłaby się po dopiero po wykonaniu pętli.

Jako, ze webdeveloperka zajmuję się rzadko, problem jedno-wątkowości i blokowania UI przeglądarki mnie prawie wcale nie dotyczył. Do czasu… W poprzedniej firmie, pracując nad silnikiem JS przetwarzającym i wizualizującym (dużo) dane na stronie, ów problem dał o sobie znać. Zatem zacząłem szukać jakiegoś rozwiązania…

Jedyne co możemy w takiej sytuacji zrobić to zasymulować wielowątkowość, al’a Green Threads. Pozwoli to na bezproblemowe przetwarzanie wielu “ciężkich” obliczeń w tym samym czasie oraz nieblokowanie UI i aktualizacji DOM/zawartości strony. Istnieje kilka ścieżek, którymi można podążyć do rozwiązania: setTimeout + callback, generatory. Wszystkie opierają się na podobnej idei.

Idea!

Dla uproszczenia rozpatrywać będziemy przypadek dla jednego wątku. Idea jest prosta - asynchroniczne wykonywanie kodu w kawałkach z małymi przerwami dla przeglądarki na “złapanie oddechu” - co jakiś czas wstrzymujemy wykonywanie bieżącego kontekstu wątku i zwracamy przetwarzanie na pewien okres do silnika/przeglądarki, aby mogła zaktualizować UI i inne elementy. Dokonujemy tego za pomocą odpowiedniej konstrukcji. W poniższych przykładach takową konstrukcją będzie suspend, która, usypia aktualny wątek, zwracając przetwarzanie do kontekstu silnika, po czym po określonym czasie wraca i wznawia przetwarzanie bieżącego zadania.

Dla jednej długiej funkcji, intensywnie przetwarzającej dane, wystarczy znaleźć odpowiednie punkty i rozdzielić na mniejsze bloki konstrukcją wstrzymującą wykonywanie. W zadaniach przetwarzających dane iteracyjnie, należy wstrzymywać wykonywanie co określoną ilość iteracji, czyli przetwarzać dane w odpowiednich paczkach.

W pseudokodzie mogłoby to wyglądać następująco:

function do_task1() {

	// do something 1

	suspend

	// do something 2

	suspend

	// do something 3

}

function do_task2() {

	for (i=0; i<100; i++) {

		// do something

		if (!(i%10)) suspend
	}
}

Tyle teorii, pora na praktykę…

setTimeout

Konstrukcje oparte na setTimeout są znanymi sposobami na asynchroniczne wywołanie funkcji po określonym czasie. Próbując implementować omówioną wyżej idee i przedstawione przykłady, za pomocą wspomnianej funkcji musimy pamiętać, że będzie to wymagało zdefiniowania wielu dodatkowych funkcji implementujących poszczególne bloki funkcjonalne - callbacków dla setTimeout, będących kontynuacja przetwarzanego zadania.

Pierwszy przykład, bardzo łatwo osiągnąć za pomocą wywoływania “drabinkowego”:

const sleep = 30;

function do_task1() {

	// do something 1

	setTimeout(function() {

		// do something 2

		setTimeout(function() {

			// do something 3

		}, sleep);

	}, sleep);

}

Najprostsze rozwiązanie, ale za to niezbyt eleganckie, im więcej bloków tym bardziej zagmatwany i mniej czytelny kod, a co za tym idzie - koszmarny rozwój i pielęgnacja. Lepiej byłoby wydzielić poszczególne bloki do podzadań i za pomocą prostego procesora odpowiednio wywoływać:

function process(tasks, sleep) {
	sleep = sleep || 30;
	var i = 0;

	var call = function() {
		if (tasks.length > i) {
			tasks[i++]();
			setTimeout(arguments.callee, sleep);
		}
	}

	call();
}

function do_task1() {

	var task1 = function() { console.log('task 1'); }
	var task2 = function() { console.log('task 2'); }
	var task3 = function() { console.log('task 3'); }

	process( [task1, task2, task3] );

}

W przypadku iteracyjnego przetwarzania, najprościej jest wydziedziczyć pojedynczą iterację do osobnej funkcji:

function process(task, iter, steep, sleep) {
	sleep = sleep || 30;
	var i = 0;

	var call = function() {
		var j = i + steep;
		if (j > iter)
			j = iter;

		for (; i<j; i++)
			task(i);

		if (j < iter)
			setTimeout(arguments.callee, sleep);
	}

	call();
}

function do_task2() {

	var task = function(i) { console.log('iter ' + i); }

	process(task, 100, 10, 30);

}

Powyższy procesor ma ograniczenie tylko dla pętli for, ale można iść dalej i przygotować bardziej generyczną wersję, która zamiast ilości iteracji, będzie przyjmować metodę określającą warunek zakończenia pętli:

function process(task, cond, steep, sleep) {
	sleep = sleep || 30;

	var call = function() {

		for (var i=0; cond() && i<steep; i++)
			task();

		if (cond())
			setTimeout(arguments.callee, sleep);
	}

	call();
}

function do_task2() {

	var i = 0;

	var task = function() { console.log('iter ' + i++); }

	process(task, function() { return i < 100 }, 10, 30);

}

Należy jednak wsiać pod uwagę fakt, ze wywoływanie funkcji też coś kosztuje. W powyższej implementacji dla każdej iteracji należy sprawdzać wartość zwracaną przez funkcję warunku kontynuacji cond. O ile dla prostych i krótkich pętli nie ma to większego znaczenia, tak dla bardzo dużej ilości iteracji przetwarzania krótkich danych, narzut ten będzie już widoczny. Dlatego, gdy stanie się to problemem, warto dostosować nieco kod do danej sytuacji…

W powyższych implementacjach bazujących na setTimeout można byłoby również wykorzystać funkcje setInterval i clearInterval, niestety wiązałoby się to z modyfikacjami procesorów.

Generatory

Do zrozumienia przedstawionych dalej rozwiązań i zagadnień, polecam zaznajomić się z ideą działania i używania generatorów. Generatory są nową konstrukcją dostępną w JavaScript 1.7 (obecnie dostepne tylko w Firefoksie 2.0+). Pozwalają one na zawieszenie wykonywania funkcji, oddając kontrolę wykonywania wyżej, a następnie wznowienie dalszego jej działania w późniejszym czasie. W porównaniu do zwykłych funkcji, które trzymają kontrolę wykonywania do czasu zakończenia działania (wyjścia z funkcji), możemy w większym stopniu wpływać na kontrolę wykonywania operacji w naszej funkcji-generatorze.

Tworzenie generatora z dowolnej funkcji następuje przez dodanie do jej ciała operatora yield:

function count(n) {
	for (var i=0; i<n; i++)
		yield i;
}

A jego uruchomienie i obsługa za pomocą wbudowanych metod obiektu generatora:

var g = count(10);

try {

	while (true) {
		console.log( g.next() );
	}

} catch (e if e instanceof StopIteration) {
	g.close();
}

Wywołanie takiej funkcji-generatora, nie spowoduje wykonania ciała funkcji - zostanie zwrócony iterator, za pomocą którego będzie można wznawiać działanie (iterować) generator, który w każdej iteracji przerywa swoje działanie zwracając wartość. Zwracana przez yield wartość jest odbierana przez kod obsługi generatora, kontekst w którym wywołano metodę next(). Działanie generatora jest tak długo wznawiane (iterowane), dopóki nie zakończy on swojego działania - rzuci wyjątek StopIteration. Można również przesłać dane do iteratora (metoda send()) oraz rzucać wyjątkami (metoda throw()), jako wynik działania operatora yield.

Jak można zauważyć, generatory oferują nam to co jest nam potrzebne w symulacji środowiska wielowątkowego. Kontrolujący je proces, może “wywoływać” je kilkakrotnie, uruchamiając poszczególny blok lub segment przetwarzania kodu. Spróbujmy wykorzystać generatory do zaimplementowania funkcji realizujących przedstawioną ideę.

Funkcja do_task1 staje się generatorem, kontrolowanym przez procesor, wznawiający asynchronicznie jej działanie po upływie określonego czasu, za pomocą setTimeout:

function process(gen, sleep) {
	sleep = sleep || 30;

	var g = gen();

	var call = function() {
		if (g.next())
			setTimeout(arguments.callee, sleep);
		else
			g.close()
	}

	call();

}

function do_task1() {

	var gen = function() {

		console.log('task1');

		yield true;

		console.log('task2');

		yield true;

		console.log('task3');

		yield false;
	}

	process(gen);
}

Ilość iteracji generatora oparto na wartości przekazywanej przez yield. Generator jest tak długo wznawiany dopóki nie zwróci wartości false. Można nieco zmodyfikować kod procesora i zwracać przez yield czas zwłoki między kolejnym wznowieniem lub w ogóle pozbyć się przekazywania jakichkolwiek wartości i wznawiać działanie do zaistnienia sytuacji wyjątkowej StopIteration:

function process(gen, sleep) {
	sleep = sleep || 30;

	var g = gen();

	var call = function() {
		try {
			while (true) {
				g.next();
				setTimeout(arguments.callee, sleep);
			}
		} catch (e if e instanceof StopIteration) {
			g.close();
		}
	}

	call();

}

Przetwarzanie iteracyjne funkcji do_task2, jest jakby naturalnym przykładem użycia generatorów:

function do_task2() {

	var gen = function() {

		for (var i=0; i<100; i++) {

			console.log('iter ' + i);

			if (!(i%10)) yield;
		}

	}

	process(gen);
}

Kod procesora - funkcja process - jest taki sam jak dla funkcji do_task1, przedstawionej wyżej, operującej na zakresie za pomocą wyjątka StopIteration.

Podsumowując temat generatorów, można jasno powiedzieć, że w zaprezentowanych tutaj rozwiązaniach można traktować generatory jako idealne rozwiązanie w symulacji środowiska wielowątkowego. Choć wymaga to przeistoczenia funkcji w generatory, modyfikacja taka jest bardzo prosta (dodanie operatora yield), daje to niezwykle możliwości, o czym przekonamy się idąc dalej.

Mała, ale dość istotna uwaga, należy pamiętać o dodaniu odpowiedniego type-mime wraz z wersją JS do tagów script, aby móc skorzystać z nowych elementów dostępnych w JS 1.7:

<script type="application/javascript;version=1.7"/>

Web Workers

Wraz z nowościami w HTML5 pojawiły się web-workery, pozwalające na uruchamianie skryptów w tle niezależnie od głównego wątku UI i skryptów interfejsu użytkownika. Nowe zabawki wydają się bardzo ciekawym i potężnym narzędziem, które rozwiązuje nasze problemy z blokowaniem i zamrażaniem UI oraz multiwątkowością w JS. Mechanizm ten posiada pewne ograniczenia (kwestie bezpieczeństwa) brak bezpośredniego dostępu do drzewa DOM i UI. Niedogodność ta nie stanowi żadnego wielkiego problemu, ponieważ jasno zdefiniowany jest interfejs umożliwiający komunikację pomiędzy poszczególnym i wątkami za pomocą wiadomości (postMessage i onMessage).

Niestety nie będziemy tutaj skupiać się na tym mechanizmie i szerzej go przedstawiać. Więcej informacji można znaleźć w specyfikacji, na mozillowym MDNie i HTML5Rocks.

Konkluzje

Wykorzystanie przedstawionych tutaj technik asynchronicznego i współbieżnego przetwarzania wiąże ze sobą szczególny styl programowania. W przypadku użycia generatorów jest on jak najmniej inwazyjny i nie wymagający wielu modyfikacji oraz zmian w istniejącym kodzie. Wystarczy tylko znaleźć odpowiednie miejsca dla operatora yield. O tyle użycie asynchronicznych wywołań poprzez setTimeout, wymaga trochę większej uwagi i zaangażowania, ale jak przedstawiono w niniejszej notatce, jest możliwe zrobienie tego prosto i łatwo, a tym samym nie zaśmiecając czytelności kodu.

Niemniej wykorzystanie natywnych elementów języka, w tych nowych konstrukcji pozwala w efektywny sposób rozwiązać większość problemów, na jakie mogą trafić webdeveloperzy (takie mam przekonanie). A będzie jeszcze lepiej, gdy ES6 aka Harmony wejdzie w życie…

Oprócz przytoczonych w treści notatki odnośników, zachęcam również do odwiedzenia kilku innych stron, gdzie poruszono inne podobne tematy, na jakie trafiłem podczas pisania tego wpisu:

  • [Why coroutines won't work on the web](http://calculist.org/blog/2011/12/14/why-coroutines-wont-work-on-the-web/)
  • [Workflow.js: Async Workflows in JavaScript With Iterators & Generators](http://matt.bridges.name/archive/54)
  • [Task.js: Beautiful Concurrency for JavaScript](http://taskjs.org/)

Jako małe uzupełnienie :)

A już w następnej notce, będącej kontynuacją bieżącej, przedstawiona zostanie moja implementacja biblioteki thread.js, bazującej na przedstawionych tutaj pomysłach i eksperymentach, umożliwiająca w bardzo prosty sposób uruchamiać niezależnie kilka “wątków”, nie blokując przy tym kontekstu przeglądarki ;)

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/