Systemy operacyjne ZSOP. Wykład 5

Michał Goliński

2017-12-17

Wstęp

Pojęcia – przypomnienie

Ostatnio poznaliśmy:

  • kompilator gcc
  • funkcje systemowe do obsługi plików
  • obsługę błędów
  • funkcje biblioteczne do obsługi plików (C/C++)
  • przestrzeń adresowa procesu
  • mapowanie plików do pamięci

Pytania?

Plan na dziś

  • polecenia powłoki dotyczące procesów
  • sygnały
  • funkcje systemowe dotyczące procesów
  • proces a wątek
  • biblioteka pthread
  • synchronizacja procesów
  • klasyczne problemy współbieżności

Wprowadzenie

Proces

Przypomnijmy: proces to działający program wraz ze wszystkimi przydzielonymi mu przez system operacyjny zasobami, takimi jak:

  • pamięć operacyjna
  • dostęp do procesora
  • otwarte pliki
  • otwarte połączenia sieciowe

W szczególności ten samo program może działać w ramach kilku różnych procesów. Przez system operacyjny będą one traktowane jako osobne byty.

Identyfikator procesu (PID)

Każdy działający proces ma przydzielony na czas działania unikalny numer – identyfikator procesu. Dzięki temu można się jednoznacznie odwoływać do konkretnego działającego procesu.

Identyfikator procesu rodzica (PPID)

Każdy proces w systemach uniksowych ma dokładnie jednego rodzica – proces odpowiedzialny za jego utworzenie. Dzięki temu procesy układają się w strukturę drzewiastą. Jeżeli rodzic procesu przestanie istnieć (skończy działanie), to tak osierocony proces zostaje adoptowany przez odpowiedzialny za to proces systemowy (historycznie init).

init – PID 1

Specjalny status ma proces o numerze 1 – jest to pierwszy uruchamiany przez jądro proces, którego zadaniem jest uruchamianie innych procesów użytkownika. W szczególności init odpowiada za prawidłowy rozruch usług systemowych przy starcie systemu i ekranu logowania. Każdy proces użytkownika jest w którymś pokoleniu potomkiem procesu init. Historycznie było używanych kilka konkretnych implementacji tego procesu, dzisiaj pod Linuksem najczęściej używany jest systemd.

kthreadd

Analogicznie, w przestrzeni jądra działa proces kthreadd odpowiedzialny za tworzenie tzw. wątków jądra – usług systemowych potrzebnych jądru systemu do normalnej pracy. Proces ten istnieje, choć nie jest tworzony przez init.

Informacje o działających procesach

Polecenie ps

Polecenie ps wyświetla informacje o aktualnie działających procesach. Pozwala na filtrowanie listy procesów na różne sposoby oraz na wyświetlanie różnych informacji. Może przyjmować przełączniki w trzech formatach: krótkim, długim oraz BSD. Aby wyświetlić wiele różnych informacji o wszystkich procesach wykonujemy

$ ps axu
$ ps -eF

Polecenie pstree

Polecenie pstree wyświetla listę działających procesów w postaci drzewa (podobnie jak fstree wyświetla pliki).

Sygnały

Co to jest?

Sygnał to asynchroniczna bardzo krótka informacja (liczba), którą można wysłać do działającego procesu. W chwili nadejścia sygnału proces zostanie przerwany i rozpocznie się tzw. procedura obsługi sygnału. Jeżeli programista nie zadbał o napisanie procedury obsługi dla przychodzącego sygnału, zostanie wywołana standardowa procedura obsługi danego sygnału.

Najważniejsze sygnały

  • SIGINT (2) – sygnał przerwania, wysyłany np. przy naciśnięciu Ctrl+C, normalnie kończy natychmiast program, można obsłużyć samemu
  • SIGKILL (9) – sygnał bezwarunkowego zabicia procesu, nie można obsłużyć samemu
  • SIGTSTP – sygnał zatrzymania procesu, wysyłany np. przy naciśnięciu Ctrl+Z, można obsłużyć samemu
  • SIGCONT – kontynuuj zatrzymany proces

Wysyłanie sygnałów – kill

Polecenie kill wysyła zadany sygnał do procesu o podanym identyfikatorze. Domyślnie wysyłany jest sygnał SIGTERM, który kończy proces, ale który można obsłużyć. Jednak można wysłać dowolny sygnał, niekoniecznie zabijający podany proces.

$ kill -9 12345

Wysyłanie sygnałów – killall

Polecenie killall wysyła sygnał do procesów o podanej nazwie programu. Domyślnie wysyłany jest sygnał SIGTERM, który kończy proces, ale który można obsłużyć. Jednak można wysłać dowolny sygnał, niekoniecznie zabijający podany proces.

$ killall -9 opera

Obsługa sygnałów

Aby móc wykonać własny kod w momencie nadejścia sygnału, musimy zarejestrować własną procedurę obsługi sygnału. Jest to po prostu funkcja, która będzie uruchomiona w momencie nadejścia sygnału. Sterowanie wraca do normalnego kodu gdy ta procedura się zakończy. Do rejestracji procedury służy funkcja signal lub funkcja sigaction:

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);

Przykład

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void sighandler(int signum) {
fflush(stdout);
printf("received signal %d\n", signum);
}
int main(void) {
signal(SIGINT, sighandler);
for(;;) {
sleep(3600);
}
return 0;
}

Funkcje wielowejściowe

Rozpoczęcie obsługi sygnału A powoduje, że obsługa kolejnego sygnału A będzie musiała poczekać. Jednak obsługa sygnału A może zostać przerwana przez nadejście sygnału B!

Procedurą obsługi sygnałów A i B może być ta sama funkcja. Musi być ona tak napisana, aby w każdym momencie jej wykonanie mogło być zatrzymane i rozpoczęte od nowa bez żadnych problemów. O takich funkcjach w językach programowania mówimy, że są wielowejściowe (reentrant).

Funkcje systemowe dotyczące procesów

Funkcje getpid i getppid

#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);

Funkcje te zwracają odpowiednio identyfikator bieżącego procesu i identyfikator procesu macierzystego.

Przykład

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdint.h>
int main() {
pid_t pid = getpid();
pid_t ppid = getppid();
printf("Bieżący proces ma numer %jd\n", (intmax_t) pid);
printf("Proces macierzysty ma numer %jd\n", (intmax_t) ppid);
return 0;
}

Tworzenie nowych procesów

Czasem w trakcie działania procesu chcielibyśmy stworzyć nowy proces. Celem utworzenia takowego mogłoby być:

  • wywołanie innego programu, który coś za nas zrobi (np. wget)
  • podzielenie pracy między kilka procesorów
  • uproszczenie logiki aplikacji (np. proces obsługujący pojedynczego klienta na serwerze)

Funkcja fork

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

Funkcja fork ma przedziwne działanie. Jej wywołanie powoduje skopiowanie wywołującego procesu i utworzenie zależności rodzic-potomek między tak powstałymi procesami. Funkcja zwraca dwie wartości:

  • w procesie potomnym zwraca 0
  • w procesie macierzystym zwraca numer procesu potomnego

Funkcja fork cd.

Utworzony proces potomny dziedziczy większość właściwości procesu macierzystego, ale następujące po wywołaniu funkcji fork zmiany w jednym procesie nie są widoczne w drugim.

Przykład 1

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
int main() {
printf("Przed wywołaniem fork()\n");
fork();
printf("Po wywołaniu fork()\n");
return 0;
}

Przykład 2

Różne wartości zwracane w procesie macierzystym i potomnym:

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdint.h>
int main() {
printf("Proces macierzysty ma numer %jd\n", (intmax_t) getpid());
pid_t child_id;
if(child_id = fork()) {
printf("W procesie macierzystym, proces potomny ma numer %jd\n",
(intmax_t) child_id);
} else {
printf("W procesie potomnym, mój numer to %jd, mój rodzic ma numer %jd\n",
(intmax_t) getpid(), (intmax_t) getppid());
}
return 0;
}

Przykład 3

Różne wartości tych samych zmiennych:

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int i = 0;
pid_t child_id;
if(child_id = fork()) {
i = 1;
printf("W procesie macierzystym i = %d\n", i);
} else {
i = 2;
printf("W procesie potomnym, i = %d\n", i);
}
return 0;
}

Kopiowanie procesu

Wydaje się, że funkcja fork musi wykonać dużo pracy. W rzeczywistości jest to stosunkowo szybka funkcja. WIększośc obiektów jest współdzielona między procesem macierzystym i potomnym, a prawdziwe kopiowanie dokonuje się dopiero gdy rzeczywiście zachodzi taka potrzeba. Mechanizm ten nazywa się copy-on-write.

Funkcja clone

Funkcja fork jest dzisiaj implementowana przez wywołanie ogólniejszej funkcji systemowej clone. Nie będziemy się jej bliżej przyglądać, powiedzmy tylko, że clone pozwala określić jak bardzo rozdzielone mają być proces macierzysty i proces potomny. W szczególności możemy np. ustalić, że pamięć jest współdzielona (a nie kopiowana).

Podmiana procesu – funkcje exec*

Zadaniem funkcji z rodziny exec jest podmiana procesu na inny proces. Znowu wydaje się to bardzo dziwne, ale wraz z funkcją fork pozwoli nam bardzo łatwo uruchamiać zewnętrzne programy:

#include <unistd.h>
int execl(const char *path, const char *arg, ...
/* (char *) NULL */);

Przykład

#include <unistd.h>
int main() {
execl("/usr/bin/ls", "ls", "-lh", (char *) NULL);
return 0;
}

Czekanie na proces potomny – wait

Z reguły samo uruchomienie procesu potomnego (np. z funkcją exec) nie jest wystarczające. Proces macierzysty często musi wiedzieć, kiedy proces potomny skończył pracę. Służą do tego funkcje wait:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);

Czekanie na proces potomny – wait cd.

Pierwsza funkcja czeka na zakończenie jakiegokolwiek procesu potomnego, w drugiej funkcji można wskazać na który proces potomny czekamy. Zwracana liczba koduje powód zakończenia procesu i kod powrotu.

Przykład

#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t child;
if (child = fork()) {
int wstatus;
wait(&wstatus);
printf("Mój adres IP to: ");
fflush(stdout);
execl("/usr/bin/cat", "cat", "/tmp/internet", (char *) NULL);
return 127;
} else {
execl("/usr/bin/wget", "wget", "http://ipinfo.io/ip", "-O", "/tmp/internet", "-q", (char *) NULL);
return 126;
}
}

Implementacja powłoki

Powyższe trzy funkcje mogłyby nam posłużyć do implementacji bardzo prostej powłoki, która w pętli:

  • odczyta polecenie z klawiatury
  • wykona fork, tworząc proces potomny – kopię powłoki
  • podmieni proces potomny na proces z wpisanego polecenia
  • poczeka na zakończenie procesu potomnego

Oczywiście rzeczywiste powłoki robią dużo więcej, ale to jest tylko kwestia rozwinięcia powyższego schematu.

Wątki

Co to jest

Do tej pory nasze procesy były jednowątkowe, to znaczy każdy proces w dowolnym momencie wykonywał dokładnie jeden kawałek kodu. Współczesne systemy operacyjne pozwalają pisać programy wielowątkowe, czyli takie, które jednocześnie wykonują wiele fragmentów kodu naraz (być może na różnych procesorach) dla wykonania zadanej pracy.

Wątek a proces

Koncepcyjnie: procesy są bardziej oddzielone – wymieniają się informacjami jedynie za pomocą osobno przesyłanych komunikatów. Wątki raczej współdzielą (przynajmniej częściowo) pamięć operacyjną i tym samym mogą wymienić się informacjami po prostu zmieniając wartości wspólnych komórek pamięci.

Wątek a proces cd.

Różnica między osobnymi procesami a wątkami jest płynna – w końcu dwa procesy też mogą razem współpracować nad jednym zagadnieniem i wymieniać dane poprzez pamięć (funkcja mmap) a każdy wątek i tak w większości przypadków potrzebuje obszaru prywatnej pamięci (np. stosu).

Implementacja

W niektórych systemach operacyjnych wątek i proces to bardzo różne byty – wątek jest czymś mniej niż proces i niekoniecznie musi być mu osobno przydzielany procesor. Za to tworzenie procesu może być wielokrotnie dłuższe niż tworzenie wątku.

Podobnie działają wątki w niektórych językach wyższego poziomu. Dla przykładu w Pythonie moduł threading produkuje wątki, które nigdy nie będą się wykonywać równolegle (choć będą się przeplatać).

Implementacja cd.

W Linuksie (od strony systemy operacyjnego) nie ma różnicy między wątkiem a procesem. Dla Linuksa wszystko jest zadaniem. Wątki to po prostu zadania, które zostały zgrupowane. Identyfikator tej grupy jest w istocie zwracany użytkownikowi jako PID, więc kilka zadań w tej samej grupie wygląda jak jeden proces wielowątkowy.

Pod Windows sytuacja jest bardziej skomplikowana – tworzenie wątku jest kilkaset razy szybsze niż tworzenie osobnego procesu.

Tworzenie wątków

Do tworzenia wątków pod Linuksem służy funkcja systemowa clone z odpowiednimi parametrami. W praktyce jednak wywoływanie tej funkcji systemowej jest zbyt skomplikowane i nieprzenośne między systemami, więc używa się bibliotek. Na systemach uniksowych podstawową biblioteką jest pthread – POSIX Threads.

Przykład z pthread

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
void *thread(void *p) {
pthread_t pth = pthread_self();
printf("%ld\n", pth);
sleep(3);
return NULL;
}
int main() {
for(int i = 0; i < 100; ++i) {
pthread_t pth;
pthread_create(&pth, NULL, thread, NULL);
}
pthread_exit(NULL);
return 0;
}

Kompilujemy z przełącznikiem -pthread.

Przykład z pthread 2

#include <stdio.h>
#include <pthread.h>
#include <math.h>
#include <stdlib.h>
typedef struct thread_params {
unsigned int start;
unsigned int end;
unsigned long *suma;
pthread_mutex_t *mutex;
} thread_params;
void *run(void *arg) {
thread_params *params = arg;
unsigned long suma = 0;
for (unsigned int i = params->start; i < params->end; ++i) {
suma += i;
}
pthread_mutex_lock(params->mutex);
*(params->suma) += suma;
pthread_mutex_unlock(params->mutex);
return NULL;
}
int main() {
unsigned int n, k;
scanf("%u%u", &n, &k);
unsigned long suma = 0;
pthread_t *threads = malloc(k*sizeof(pthread_t));
thread_params *params = malloc(k*sizeof(thread_params));
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
unsigned int start = 0;
unsigned int step = ceil((n+1.0)/k);
for(unsigned int i = 0; i < k; ++i) {
params[i].mutex = &mutex;
params[i].suma = &suma;
params[i].start = start;
params[i].end = (start + step <= n+1) ? (start+step) : (n+1);
pthread_create(&threads[i], NULL, run, &params[i]);
start += step;
}
for(unsigned int i = 0; i < k; ++i) {
pthread_join(threads[i], NULL);
}
free(threads);
free(params);
printf("%lu", suma);
return 0;
}

Kompilujemy z przełącznikami -pthread i -lm.

OpenMP

Tworzenie wątków ze strony systemu operacyjnego ma być operacją szybką i bezproblemową. Ale nie zawsze jest takie bezproblemowe dla programisty. OpenMP jest wbudowaną w kompilator techniką deklaratywnego tworzenia kodu równoległego. Najprostszym przykładem jest pętla for, którą zamiast wykonywać miliard razy w jednym wątku moglibyśmy chcieć wykonać po 250 milionów w czterech wątkach. Albo jeszcze lepiej podzielić na tyle wątków ile mamy procesorów w komputerze na którym program jest uruchamiany.

OpenMP – przykład

#include <stdio.h>
#include <sys/time.h>
unsigned long sum(unsigned int n) {
unsigned long sum = 0;
for(unsigned int i = 1; i <= n; ++i) {
sum += i;
}
return sum;
}
unsigned long parallel_sum(unsigned int n) {
unsigned long sum = 0;
#pragma omp parallel for reduction(+:sum)
for(unsigned int i = 1; i <= n; ++i) {
sum += i;
}
return sum;
}
int main() {
struct timeval start, end;
for(;;) {
unsigned int n;
scanf("%u", &n);
gettimeofday(&start, NULL);
unsigned long s = sum(n);
gettimeofday(&end, NULL);
printf("Obliczenia zajęły %d milisekund, wynik to %lu.\n", (end.tv_sec-start.tv_sec)*1000 + (end.tv_usec-start.tv_usec)/1000, s);
gettimeofday(&start, NULL);
s = parallel_sum(n);
gettimeofday(&end, NULL);
printf("Obliczenia równoległe zajęły %d milisekund, wynik to %lu.\n", (end.tv_sec-start.tv_sec)*1000 + (end.tv_usec-start.tv_usec)/1000, s);
}
return 0;
}

Kompilujemy z przełącznikiem -fopenmp.

Funkcje wieloużywalne

Program wielowątkowy może w różnych wątkach wywoływać te same funkcje. Powinny być one napisane w taki sposób, aby nie powodowało to błędów. O takich funkcja mówimy, że są wieloużywalne (thread safe).

Wieloużywalność a wielowejściowość

Poznane przedtem pojęcie wielowejściowości jest dość bliskie wieloużywalności, ale są to pojęcia niezależne.

Różnica jest taka, że wielowejściowa funkcja może być wykonywana przez ten sam wątek bez jej uprzedniego zakończenia. Funkcja wieloużywalna może być wywołana z kilku wątków, ale w ramach wątku kolejne wywołania tylko po zakończeniu poprzedniego wywołania.

Problemy programowania współbieżnego

Pouczający przykład

Wyobraźmy sobie bardzo uproszczony bank w amerykańskim sennym, bezpiecznym miasteczku. Konta to po prostu małe przegródki na ścianie z gotówką. Wykonujący przelewy pracownicy po prostu przenoszą gotówkę między przegródkami – wszystko działa sprawnie, zakładając że nikt się nie pomyli.

Pouczający przykład cd.

Nasz bank chce iść z duchem czasu i inwestuje w system komputerowy. Przegródki zamieniamy na tablicę sejf w pamięci komputera, pracownicy wywołują funkcję PRZELEW nie ruszając się ze swoich biurek:

PRZELEW(konto1 --> konto2, kwota) {
  stan = sejf[konto1];
  if(stan >= kwota) {
    sejf[konto1] = stan - kwota;
    sejf[konto2] += kwota;
  } else {
    BrakŚrodków;
  }
}

Pouczający przykład cd.

Wydaje się, że logika jest tak sama jak przedtem, ale gdy będziemy wykonywać równolegle dwa przelewy z tego samego konta, to oba mogą się powieść, mimo, że teoretycznie wypłacamy więcej pieniędzy niż było na koncie! Nasz kod powoduje wyścig (race condition) między wątkami. Wątki ścigają się o dostęp do komórek pamięci. W praktyce podobne (i znacznie bardziej subtelne) błędy mogą być pozostawać długo ukryte, bo normalnie wszystko działa bez zarzutu.

Pouczający przykład cd.

Mogłoby się wydawać, że skrócenie procedury PRZELEW poprawi sprawę, najwyżej wprowadzi saldo ujemne.

PRZELEW(konto1 --> konto2, kwota) {
    sejf[konto1] -= kwota;
    sejf[konto2] += kwota;
}

Proszę jednak pamiętać, że to nie jest kod wykonywany przez komputer – procesor wykonuje kod maszynowy, a tam pierwsza linijka naszego kodu nie wykonuje się naraz – mamy osobno pobranie wartości, zmniejszenie i zapisanie. Nadal może dochodzić do wyścigu.

Sekcja krytyczna

Wiele problemów w programowaniu współbieżnym można sprowadzić do problemu tzw. sekcji krytycznej. Sekcja krytyczna to część wątku, która odpowiada za operacje na współdzielonych danych. Chcemy aby w sekcji krytycznej mógł znajdować się najwyżej jeden wątek naraz. Kiedy jeden wątek wykonuje sekcję krytyczną, inny wątek na wejście do swojej sekcji krytycznej musi poczekać.

Problem sekcji krytycznej

Dobre rozwiązanie problemu sekcji krytycznej spełnia postulaty:

  • działa – pod żadnym pozorem dwa wątki nie wejdą jednocześnie do swoich sekcji krytycznych
  • postęp – jeśli żaden wątek nie wykonuje sekcji krytycznej, to któryś z oczekujących na wejście wątków zostanie wpuszczony
  • ograniczone czekanie – istnieje ograniczenia na liczbę wejść innych wątków do sekcji krytycznej, kiedy jeden z wątków ciągle oczekuje

Kolejność operacji w pamięci

Dzisiejsze procesory przechowują te same operacje w wielu miejscach. W szczególności każdy rdzeń procesora ma pamięć podręczną, która zawiera jakieś informacje z pamięci operacyjnej. Informacje te na różnych rdzeniach mogą być czasami niezsynchronizowane. Prowadzi to do sytuacji, w której różne wątki mogą widzieć operacje na zmiennych w różnym porządku

Mechanizmy synchronizacji wątków

Operacje atomowe

Podstawą rzeczywistych rozwiązań problemu sekcji krytycznej są tzw operacje atomowe. Są to specjalne instrukcje, dla których procesor daje gwarancje, że wszystkie wątki widzą sytuację tak jakby całą operacja jeszcze nie została wykonana, albo już została wykonana w całości. Wiele (zwłaszcza nowszych) języków programowania udostępnia specjalne atomowe typy danych. Operacji atomowych należy używać oszczędnie – są one znacznie wolniejsze niż ich nieatomowi (zwyczajni) odpowiednicy.

Blokady

Blokady (ang. lock, mutex) to podstawowe narzędzia synchronizacji wątków na większości platform. Implementowane najczęściej są z użyciem instrukcji atomowych. Blokada jest specjalnym obiektem (zmienną) współdzielonym pomiędzy wątkami. Wątek może spróbować zdobyć blokadę. Jeżeli operacja się nie powiedzie (bo blokada jest zajęta), to wątek czeka na zwolnienie blokady i po jej zwolnieniu natychmiast ją przejmuje.

Blokady cd.

Najczęściej w programie jest wiele blokad przypisanych różnym współdzielonym zasobom, wątek przed użyciem odpowiedniego zasobu zdobywa odpowiednią blokadę. Gdy zakończy używać zasobu, zwalnia blokadę.

Semafory

Semafory to narzędzia synchronizacji nieco wyższego poziomu. Sercem semafora jest liczba całkowita. Dostępne są tylko dwie operacje: podniesienie i opuszczenie. Podniesienie zwiększa wartość zmiennej o 1. Opuszczenie zmniejsza wartość o 1 jeśli jest dodatnia lub czeka na dodatnią wartość i potem zmniejsza ją o 1.

Semafory cd.

Operacje podnoszenia i opuszczania nie muszą być wykonywana przez ten sam wątek. Sterując początkową wartością semafora możemy różnie go wykorzystywać. Semafory o początkowej wartości 1 można stosować jako blokady.

Bariery

Bariery to specjalne instrukcje procesora, które zapobiegają problemom z porządkiem operacji w pamięci. Sprzęt dba o to, że wszystkie procesory/rdzenie zobaczą wszystkie operacje wykonane przed barierą przed wszystkimi operacjami po barierze. Bariry mogą dotyczyć odczytu, zapisu albo obu tych rodzajów operacji.

Klasyczne problemy współbieżności

Producenci i konsumenci

Dysponujemy ograniczonym buforem. Część wątków produkuje pewne obiekty i umieszcza je w buforze, część wątków konsumuje obiekty z bufora opróżniając go. Chcemy tak zsynchronizować wątki, aby producent mógł umieścić obiekt w buforze gdy jest tam miejsce, a konsument mógł skonsumować obiekt znajdujący się w buforze.

Producenci i konsumenci cd.

Rozwiązanie najczęściej opiera się na dwóch semaforach – jeden przechowuje liczbę zajętych, drugi wolnych miejsc w buforze. W tym rozwiązaniu producenci produkują obiekty, a konsumenci „produkują” wolne miejsce.

Czytelnicy i pisarze

Mamy zbiór danych i wiele wątków. Większość tylko odczytuje zbiór (czytelnicy) – dowolnie wiele z nich może robić to naraz. Niektóre wątki jednak zmieniają zbiór (pisarze). Mogą to robić tylko w pojedynkę. W czasie dostępu przez pisarza ani inni pisarze ani czytelnicy nie mogą mieć dostępu do zbioru. Są różne wariacje tego problemu: kto ma pierwszeństwo – kolejny czytelnik czy czekający pisarz itp.

Ucztujący filozofowie

Przy okrągłym stole siedzi pięciu filozofów, na środku stołu stoi miska ryży i między każdymi dwoma filozofami leży pałeczka. Filozofowie najczęściej myślą, ale czasem chcą zjeść – wtedy koniecznie potrzebują dwie pałeczki. Po jedzeniu odkładają pałeczki na stół jak przedtem. Należy opracować system, w którym żaden z filozofów nie padnie z głodu.

Ucztujący filozofowie cd.

W tym przykładzie bardzo bardzo łatwo wyobrazić sobie sytuację, w której każdy z filozofów nagle sięga po pałeczkę (np. z prawej strony). Żaden nie będzie mógł jednak zdobyć drugiej pałeczki. Wszyscy czekają. Tak sytuację nazywamy w programowaniu współbieżnym zakleszczeniem (ang. deadlock).

Ucztujący filozofowie cd.

Najprostsze rozwiązanie każe numerować pałeczki i najpierw brać tę z niższym numerem. Istnieją bardziej skomplikowane rozwiązania, np. dodające do każdej pałeczki informację czy jest brudna czy czysta.