Wielowątkowość w Bashu

tech • 1154 słowa • 6 minut czytania

Jakiś czas temu pracowałem nad kilkoma agentami/serwisami w bashu - prostymi (choć może nie tak bardzo) skryptami shellowymi, przetwarzającymi sukcesywnie dane oraz wykonywujące pewne określone operacje w miarę napływania do nich zadań. Chcąc usprawnić i przyspieszyć processing, eksperymentowałem i szukałem bardzo prostego sposobu na zrównoleglenie kilku procesów, dokonywujących podobne zadania…

Bash sam w sobie nie posiada wielowątkowości, ale za to ma inne narzędzia i metody, które mogą nam w jakiś sposób pomoc i ułatwić tworzenie wielowątkowych oraz wieloprocesorowych skryptów lub aplikacji. Cała idea opiera się na zadaniach uruchomianych w “tle” oraz sterowaniu nimi za pomocą wbudowanych funkcji basha kontrolujących zadania, takich jak: jobs, wait, kill

Zadania w tle

Najprostsze uruchomienie wielu równoległych funkcji sprowadza się do uruchomienia ich w tle. W bashu do tego celu służy operator sterujący & (ampersand). Prosty przykład poniżej:

function func {

	for (( i = 0; i < $2; i++ )) ; do
		echo "$1: $i"
		sleep $3
	done
}

func "foo" 10 0.4 &
func "bar"  5 0.8 &

wait

Funkcja func zostaje uruchomiona w tle w oddzielnych podpowłokach - procesach potomnych, a powłoka macierzysta (proces główny) oczekuje na ich zakończenie.

Należy pamiętać, że każda podpowłoka dziedziczy środowisko macierzyste, nie mamy wpływu na wartości zmiennych rodzica - posiada ich własne kopie. Podobnie się dzieje w przypadku uruchamiania innych poleceń w subshellach lub potokach (każde polecenie w potoku uruchamiane jest jako odrębny proces).

Podobnie kłopotliwe może się okazać wysyłanie sygnałów do procesów potomnych, standardowo bash nie przekazuje dalej otrzymanych sygnałów. Sami musimy zadbać o sprzątanie, aby nie pozostawić procesów zombie.

Sprzątnie za pomocą trapa

Jednym z rozwiązań jest wykorzystanie pułapek (trap) w skrypcie. Kod zawarty w pułapce wykonywany jest w odpowiedzi na odebranie sygnału przez proces, czyli można traktować je jako handlery obsługi sygnałów. A zatem, dodajemy obsługę sygnału EXIT lub INT i TERM, gdzie umieszczamy procedury “sprzątające”.

trap 'kill $PID1 $PID2' INT TERM

Sygnały propagowane są do procesów potomnych w celu zakończenia ich działania.

Zamiast “zbierania” i pamiętania poszczególnych pidów uruchamianych zadań, można na bieżąco pobrać identyfikatory aktywnych zadań, korzystając z polecenia jobs:

trap 'kill $(jobs -r)' EXIT

Lub wysyłać sygnały do wszystkich procesów należących do danej grupy (w tym przypadku rodzica):

trap 'kill 0' EXIT

Innym rozwiązaniem jest wykorzystanie polecenia ps do determinacji identyfikatorów procesów potomnych i rekurencyjne propagowanie sygnałów w dół. Kod takiego rozwiązania można znaleźć tutaj: Kill all child processes from shell script.

Warto wspomnieć o sposobach i możliwościach pobierania identyfikatorów procesów w bashu w określonych warunkach, służą do tego różne zmienne specjalne. Zmienna $BASHPID przechowuje zawsze identyfikator procesu aktualnej powłoki. Pid procesu macierzystego zawsze dostępny jest w zmiennej $$, jest to identyfikator skryptu, procesu macierzystego najwyższego stopnia (on-top), niezależnie od poziomu zagnieżdżenia procesów, $$ wskazuje zawrze na najwyższego rodzica w całym drzewie. Bardzo często zdarza się wśród początkujących, nierozróżnianie tych dwóch zmiennych lub niewiedza i stosowanie $$ w miejsce $BASHPID, co prowadzi do dziwnych zachowań i błedów. Pod zmienna $! znajduje się pid ostatniego procesu uruchomionego w tle.

Ciekawą zmienną jest $BASH_SUBSHELL, określa ona poziom zagnieżdżenia danej podpowłoki.

Nie zabijaj! Zakończ!

Czasami nie chcemy od razu ubijać procesów potomnych, w których przetwarzanym jakieś dane lub wykonujemy skomplikowane operacje. Głowna przyczyną może być fakt, że nie chcemy, aby aktualna przetwarzana operacja pozostawiła system, dane lub wyniki w stanie nieustalonym. Lepiej jest dokończyć bieżąca operację lub obliczenia, a następnie wstrzymać się z dalszego działania i zakończyć przetwarzanie procesu.

Jeśli przetwarzanie odbywa się w pętli, bardzo łatwo można to dokonać manipulując wartością warunku zakończenia takiej pętli:

function func {
	local task=$1

	local process=1
	trap '
		echo "$task: Stopping task..."
		process=0
	' INT TERM

	echo "$task: Task started ($BASHPID)"

	while [ $process -eq 1 ] ; do
		echo $task
		sleep 0.5
	done

	echo "$task: Task stopped"
}

Sygnały “zakończenia” propagowane są do procesów potomnych i następuje oczekiwanie na zakończenie działania wszystkich aktywnych zadań:

PID1=
PID2=

trap '
	echo "Stopping process..."
	echo "Waiting for end processing current task..."
	kill $PID1 $PID2
	wait
' INT TERM


echo "Start process..."

func "foo" &
PID1="$!"

func "bar" &
PID2="$!"

wait
echo "Stop process..."

Zastanawiać może polecenie wait zawarte w obsłudze sygnału. Po uruchomieniu zadań, skrypt oczekuje na ich zakończenie w funkcji wait. Oczekiwanie to zostaje przerwane przez sygnały, a zatem, aby po obsłudze przychodzącego sygnału nie wyjść ze skryptu (po wyświetleniu komunikatu), należy ponownie “zaczekać” na aktywne zadania, które zostały poinformowane o “prośbie” zakończenia działania skryptu i właśnie kończą przetwarzanie aktualnych obliczeń (iteracji).

Prosty WorkerPool

Potrafiąc w łatwy sposób zrównoleglać działanie w bashu, możemy stworzyć prostego WorkePolla. Oczywiście nie będzie to prawdziwy zarządca puli procesorów, ale jego działanie będzie bardzo zbliżone do typowej architektury tego typu.

Mając do przetwarzania dużą ilość podobnych do siebie zadań, możemy pokusić się o wykonywanie naraz określonej ilości zadań. Zainspirowane przez Multithreaded Bash.

Zdefiniujmy sobie listę zadań do wykonania oraz funkcje je przetwarzającą:

tasks=('foo' 'bar' 'alfa' 'beta' 'gamma' 'omega')

function process {
	echo "$1 start"
	sleep $(($RANDOM % 10))
	echo "$1 done"
}

a całość uruchamiamy w prostej pętli:

for n in ${tasks[*]} ; do
	process $n &
	while [ $(jobs | wc -l) -ge 4 ] ; do
		sleep 0.1;
		jobs &> /dev/null
	done
done
wait

Przed każdym uruchomieniem przetwarzania nowego (kolejnego) zadania, sprawdzamy czy są wolne jakiekolwiek “workery”, jeśli nie to czekamy, aż jakieś zadanie zostanie zakończone, aby w jego miejsce uruchomić kolejne. W ten prosty sposób możemy przetwarzać równolegle dużą ilość zadań, bez obaw o “zajęcie” wszelkich dostępnych zasobów - jednocześnie mogą być przetwarzane tylko 4 zadania.

Każde zakończone zadanie w bashu zostawia wpis informujący o tym fakcie w jobs, do czasu wyświetlenia tej informacji, zatem przekierowanie jego wyjścia do nulla chyba jest w takim przypadku jasne.

Kod ten można napisać bardziej generycznie - wyekstrahować do osobnej funkcji, przez co jego użycie będzie prostsze i nie wymagające zaśmiecania oraz pogarszania czytelności kodu jakiejkolwiek pętli, w której chcielibyśmy wykorzystać równoległe przetwarzanie.

Funkcja taka będzie blokować (czekać) dalsze przetwarzanie poleceń, w sytuacji, gdy osiągnięto limit aktualnie uruchomionych (aktywnych) zadań:

function job_wait {
	local limit=${1:-2}

	while [ $(jobs | wc -l) -ge $limit ] ; do
		sleep 0.1;
		jobs &> /dev/null
	done
}

A jej użycie może wyglądać tak:

for txt in *.txt ; do 
	process $txt & 
	job_wait 2 
done 
wait

Zakończenie

Przedstawione metody równoległego i wielowątkowego przetwarzania zadań w bashu spełniają swoje zadanie. Są to najprostsze sposoby oparte na wbudowanych elementach shella.

Mimo to istnieje jeszcze kilka innych, również ciekawych metod. W artykule Parallel batch processing in the shell przedstawiono podobne rozwiązanie z wykorzystaniem jobs, a także, niecodzienne wykorzystanie sygnałów do kontroli i zarządzania zadaniami. Natomiast Pavel Shved na swoim blogu, przedstawia inne metody osiągnięcia wielowątkowości, korzystając z takich narzędzi jak xargs, flocks, subshell… Znaleźć je można w notatce Easy parallelization with Bash in Linux (cześć 1 i cześć 2).

Jeśli myślimy o prawdziwym równoległym przetwarzaniu w bashu powinniśmy zaznajomić się z takim narzędziem jak parallel lub prll. Niestety, większość takich narzędzi posiada pewna wadę - nie wspierają operowania na funkcjach, równolegle działanie bazuje na pojedynczych skryptach, a czasami taka możliwość jest dla nas niezbędna.

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/