Każdy z nas w swojej karierze programisty stanął przed zadaniem napisania programu który wykonuje się równolegle. Aby tak się stało musimy zaprzęgnąć do wykonywania naszych zadań wątki. Te konstrukty to jednostki wykonujące kod, które są zarządzane przez planistę systemowego. Gdy w systemie jest uruchomionych setki czy tysiące wątków, a procesor posiada np. 2 rdzenie fizyczne, to aby umieścić je w czasie, należy wykonywać je „po trochu”.

Planista w zależności od priorytetu i aktualnych potrzeb systemu przełącza wątki, tak że użytkownik ma wrażenie że system działa w czasie rzeczywistym. Ważnym pojęciem jest context switching (przełączanie pomiędzy wątkami), gdy wątek aktualnie nie może się wykonywać (np. z powodu oczekiwania na dane, operacje I/O) to planista zmienia aktualny wątek. Cały stan wątku musi zostać zapisany do pamięci. Dokładnie kopia rejestrów, stos oraz zmienne lokalne dla wątku. Struktura zapisanych rejestrów jest dla dla GNU Linux w katalogu /usr/include/sys/user.h jako struktura user_regs_struct, natomiast dla Windowsa całą strukturę kontekstu można zobaczyć w pliku WinNT.h pod nazwą CONTEXT_structure. Gdy wątek przez planistę zostanie wznowiony to stan musi zostać odczytany oraz przywrócony, co czyni tą operację nieco obciążającą.

Wątki współdzielą ze sobą przestrzeń adresową procesu (wszelkiego rodzaju pamięć) oraz mogą odwoływać się do tych samych zmiennych. Jeżeli dwa wątki zażądają dostępu do tego samego miejsca w pamięci (np. jakiejś zmiennej) i operacja na nim nie będzie atomowa (niepodzielna w sensie instrukcji procesora czy kodu wyższego poziomu) to jeden z wątków może uzyskać starą wartość (np. zmiennej) kiedy ta była już zmieniona przez drugi wątek. Możemy tą sytuacje zobrazować przykładem. W naszym programie jest zmienna money, która jest globalna. Program posiada 2 wątki które inkrementują tę wartość w tym samym czasie.

#include <pthread.h>
#include <stdio.h>

const int cyclesPerThread = 100000;
const int numThreads = 2;

int incrementMoney ()
{	
	static int money = 0;
	money++;
	return money;
}
void * test (void * /*arg*/)
{
	static int acc = 0;
	acc++;
	while (acc != numThreads); // prepare all threads to start in the same time, spinlock
	for (int i = 0 ; i < cyclesPerThread;i++)
	{
		incrementMoney();
	}	
	return 0;
}

int main ()
{
	pthread_t threads [numThreads];
	for (int i = 0; i < numThreads; i++)
	{	
		pthread_create(&threads[i], NULL, test, NULL);
	}
	for (int i = 0 ; i < numThreads; i++)
	{
		pthread_join(threads[i],NULL);
	}
	printf ("Balance : %u \n",incrementMoney());
	printf ("Should be %u \n",numThreads * cyclesPerThread+1);
	return 0;
}

Funkcja incrementMoney została wygenerowana przez kompilator w taki sposób:

Jak widzimy instrukcja „money++;” rozdzieliła się na pobranie aktualnej wartości z pamięci do rejestru, inkrementacje wartości pobranej do rejestru, oraz zapisaniu wyniku do pamięci. Przeanalizujmy przykład gdy 2 wątki są na różnym etapie wykonywania funkcji incrementMoney i jakie konsekwencje to za sobą niesie.

W tym przypadku finalną wartością jest 1 zamiast 2. Jak widzimy tak prosta operacja jak inkrementacja zmiennej nie jest operacją atomową. Składają się na nią w tym przypadku 3 instrukcje procesora. Wracając do naszego przykładowego programu z początku sprawdźmy jego wynik.

Za każdym uruchomieniem wynik różni się, co nie zależy zupełnie od kodu programu. Gdy nasz program uruchomimy na systemie z procesorem jednordzeniowym na Linuxie

Nie doświadczyliśmy problemu złego wyniku ze względu na wykonywanie wszystkich wątków sekwencyjnie (brak współbieżności).

Aby nie doświadczać takich niespodzianek również dla procesorów wielordzeniowych musimy zrozumieć czym jest „thread safety” oraz „race condition” (piszę je po angielsku, bo pewnie nie spotkacie się z ich polskimi odpowiednikami). Dany kod jest thread safe jeżeli wszystkie operacje w nim zawarte gwarantują że dostęp do pamięci współdzielonej nie koliduje pomiędzy wątkami które żądają do niej dostępu. Race condition możemy przetłumaczyć na polski jako sytuacja wyścigu, np. dostępu do danego zasobu. Program który był opisywany wcześniej jest przykładem właśnie tej sytuacji. Dla bardziej wnikliwych odsyłam do artykułu na wikipedii race condition

Brak synchronizacji może powodować niebezpieczne dla bezpieczeństwa aplikacji błędy. Przedstawmy przykład instalatora dwuetapowego:

  1. stwórz katalog tmp
  2. zmień uprawnienia katalogu tmp tak aby tylko instalator miał do niego dostęp
  3. wypakowuj pliki dla drugiego etapu instalacji
  4. drugi etap instalacji

Jeżeli złośliwy użytkownik „wstrzeli się” w czas pomiędzy stworzeniem katalogu a jego zmianą uprawnień, to może w nim zapisać wcześniej spreparowany plik. Pod warunkiem że instalator źle obsługuje sytuacje istnienia pliku w tymczasowym katalogu złośliwy użytkownik może przejąć kontrole nad instalacją oraz zmienić kod programu. Co ciekawe ten błąd może wystąpić również na komputerze wyposażonym w procesor jednordzeniowy ze względu na fakt że planista systemowy mógł wywłaszczyć wątek instalatora oraz przekazać wykonanie do kodu który podstawia plik w tamtej lokalizacji.

Jak zabezpieczyć aplikacje przed tego typu sytuacjami ?

Można przyjąć dwa podejścia: unikanie współdzielenia danych pomiędzy wątkami. stosowanie operacji które są atomowe, bądź stosowanie wzajemnego wykluczania (mutex - mutual exclusion) dostępu do współdzielonych zmiennych przez wątki.

W przypadku programu który omawialiśmy wcześniej przyjmijmy że zmienna money musi być globalna i wszystkie wątki muszą do niej mieć dostęp. Aby poprawić działanie naszego programu użyjemy mutex’a. Mutex służy do chronienia danych dzielonych miedzy wątkami przed równoczesnymi modyfikacjami fragmentów pamięci. Można o nim myśleć (w uproszczeniu) jako o zmiennej globalnej bool która jest synchronizowana przez system operacyjny, a jej wartość oznacza czy obiekt który chcemy synchronizować jest w użyciu. W tym przykładzie użyję specyfikacji pthreads (POSIX threads), jednak równie dobrze można skorzystać z szablonu std::mutex z C++ 11. Mutex może być zajęty (tylko wątek który go zajął może wykonywać kod dopóki go nie zwolni) bądź wolny czyli gotowy do zajęcia przez jakikolwiek inny wątek. Zmieńmy nasz już istniejący kod:

pthread_mutex_t moneyMutex = PTHREAD_MUTEX_INITIALIZER;

int incrementMoney ()
{	
	static int money = 0;
	pthread_mutex_lock(&moneyMutex);
	money++;
	pthread_mutex_unlock(&moneyMutex);
	return money;
}

Jak widać mutex musi być globalnie widoczny dla wszystkich wątków które się do niego odwołują, Obszar kodu pomiędzy zajęciem a zwolnieniem mutexu nazywamy sekcją krytyczną. Sprawdźmy poprawność wyniku naszego programu

Za każdym razem wynik jest poprawny oraz kod możemy nazwać thread safe.