Radek Chalupa   konzultace a školení programování, vývoj software na zakázku

Použití vláken v C na Linuxu

13.11.2022

Jaké jsou 2 hlavní důvody pro použití vláken v programu?

Pokud máme v běhu programu nějaké místo kde program, resp. nějaká funkce (říká se jim blokující) čeká na nějaká vstupní data (např. naslouchá na socketu) nebo čeká na vstup od uživatele (klávesnice, myš apod.), a toto čekání může trvat i několik sekund nebo i minut či hodin, běh programu v tomto místě "zamrzne" a nemůže provádět žádnou jinou požadovanou činnost. Pak je potřeba abychom spustili další posloupnost provádění instrukcí pararelně, nikoliv sériově. A právě toto realizujeme dalším jedním či více vlákny (threads).

Pokud máme nějaké větší množství dat ke zpracování, můžeme je rozdělit a zpracovávat pararelně, asi jako ve slovní úloze z 1. stupně základní školy: "Když jeden dělník kope příkop 8 hodin, za jak dlouho stejný příkop vykopou 4 dělníci (za předpokladu že neskončí u pauzírovaného mariáše:-)). Toto samozřejmě platí za předpokladu že máme vícejádrový procesor (což je na dnešním PC prakticky samozřejmé) a v ideálním případě počet současně běžících vláken je menší nebo roven počtu jader procesoru.

Každé vlákno musí mít určenu funkci, jejíž kód proběhne a jakmile je funkce ukončena buď provedením jejího posledního příkazu (resp. instrukce) nebo příkazem return, vlákno je ukončeno. Je to tedy obdobné jako vstupní funkce procesu, kterou je z pohledu programu napsaném v C známá funkce main.

Samozřejmě některé knihovny/frameworky jako GTK, Qt apod. implementují (a částečně možná zjednodušují) práci s vlákny v programu, zde si ukážeme jak pracovat s vlákny pomocí nástrojů které nabízí implementace standardní knihovny C na Linuxu.

Ve zdrojovém kódu musíme vložit hlavičkový soubor threads.h a při sestavení linkovat s parametrem -pthreads.

Nejprve musíme napsat funkci která bude představovat výše zmíněnou vstupní funkci vlákna. Pokud budeme mít více vláken, tato funkce může být společná pro všechny vlákna programu. Každé vlákno dostane na stacku vlastní sadu lokálních proměnných a také vlastní hodnotu parametru, jak uvidíme dále. Funkce má následující tvar:

static void* thread_func(void* arg)
{
	return NULL;
}

Nyní už si ve funkci main můžeme vytvořit např. 2 vlákna následovně:

int main(int argc, char** argv)
{
	pthread_t threads[2];
	int iret;
	iret = pthread_create(&threads[0], NULL, &thread_func, (void*)0);
	if (0 != iret)
	{
		perror("thread create");
		return errno;
	}
	iret = pthread_create(&threads[1], NULL, &thread_func, (void*)1);
	if (0 != iret)
	{
		perror("thread create");
		return errno;
	}
	(void)getchar();
	return EXIT_SUCCESS;
}

Po vytvoření vlánka funkcí pthread_create pokračuje v tomto místě běh programu dále a vytvořené vlákno si "žije svým vlastním životem", dokud neskončí provádění jeho vstupní funkce. Proto také jsem dal do kódu volání getchar, kde se běh hlavní funkce zastaví až do vstupu nějakého znaku.

Nyní si do funkce vlákna dáme nějaký kód na kterém uvidíme běh vláken a také předání parametru. Celý program bude vypadat následovně:

#define _DEFAULT_SOURCE

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
#include <errno.h>
#include <unistd.h>

static void* thread_func(void* arg)
{
	for (size_t i = 0; i < 10; i++)
	{
		printf("%d: %d\n", (int)((size_t)arg), (int)i);
		usleep(0 == arg ? 400000 : 600000);
	}
	printf("thread %d: hotovo\n", (int)((size_t)arg));
	return NULL;
}

int main(int argc, char** argv)
{
	pthread_t threads[2];
	int iret;
	iret = pthread_create(&threads[0], NULL, &thread_func, (void*)0);
	if (0 != iret)
	{
		perror("thread create");
		return errno;
	}
	iret = pthread_create(&threads[1], NULL, &thread_func, (void*)1);
	if (0 != iret)
	{
		perror("thread create");
		return errno;
	}
	(void)getchar();
	return EXIT_SUCCESS;
}

Program sestavíme pomocí gcc příkazem:

$ gcc -Wall -std=c99 -pthread main.c

Po spuštění vytvořeného souboru a.out dostaneme tento výstup:

0: 0
1: 0
0: 1
1: 1
0: 2
1: 2
0: 3
0: 4
1: 3
0: 5
1: 4
0: 6
0: 7
1: 5
0: 8
1: 6
0: 9
thread 0: hotovo
1: 7
1: 8
1: 9
thread 1: hotovo

Jak je vidět, obě vlákna běží pararelně a první vlákno skončí rychleji, protože funkce sleep má nastaven kratší interval. Na nastavení intervalu je vidět využití parametru vlákna. Hodnotu kterou zadáme jako poslední parametr funkce pthread_create, dostane předanou parametrem vstupní funkce vlákna, a v případě společné funkce pro více vláken tak můžeme v této funkci rozlišit, o které vlákno se jedná. Vzhledem k tomu že tento parametr je obecný pointr, můžeme si v případě potřeby předat naprosto libovolná data co do typu i velikosti. Může to být tedy pointr na vlastní datovou strukturu obsahující v podstatě neomezenou velikost dat.

Jak jsme si ukázali, funkce pthread_create je neblokující, tj. je ukončena bezprostředně po vytvoření vlákna nezávisle na tom jako dlouho vytvořené vlákno pokračuje. Může ale nastat situace kdy potřebujeme počkat na ukončení vlákna a navíc třeba i zjistit návratovou hodnotu jeho vstupní funkce, kterou může vlákno například informovat o úspěchu/neúspěchu své práce. K tomu slouží funkce pthread_join, která je blokující a čeká na ukončení vlákna zadaného jako 1. parametr. Použití si ukážeme úpravou funkce main a vrácením volání funkce time ve funkci vlákna:

static void* thread_func(void* arg)
{
	for (size_t i = 0; i < 10; i++)
	{
		printf("%d: %d\n", (int)((size_t)arg), (int)i);
		usleep(0 == arg ? 400000 : 600000);
	}
	printf("thread %d: hotovo\n", (int)((size_t)arg));
	return (void*)time(NULL);
}

int main(int argc, char** argv)
{
	pthread_t threads[2];
	int iret;
	iret = pthread_create(&threads[0], NULL, &thread_func, (void*)0);
	if (0 != iret)
	{
		perror("thread create");
		return errno;
	}
	iret = pthread_create(&threads[1], NULL, &thread_func, (void*)1);
	if (0 != iret)
	{
		perror("thread create");
		return errno;
	}
	void* pret;
	if (0 != pthread_join(threads[1], &pret))
		return errno;
	printf("vlakno 1 ukonceno s hodnotou %lu\n", (unsigned long)pret);
	if (0 != pthread_join(threads[0], &pret))
		return errno;
	printf("vlakno 0 ukonceno s hodnotou %lu\n", (unsigned long)pret);
	return EXIT_SUCCESS;
}

Výsledkem bude následující výstup:

0: 0
1: 0
0: 1
1: 1
0: 2
1: 2
0: 3
0: 4
1: 3
0: 5
1: 4
0: 6
0: 7
1: 5
0: 8
1: 6
0: 9
thread 0: hotovo
1: 7
1: 8
1: 9
thread 1: hotovo
vlakno 1 ukonceno s hodnotou 1668341652
vlakno 0 ukonceno s hodnotou 1668341650

Záměrně jsem dal nejprve čekání na vlákno které skončí později, takže je vidět že návratová hodnota je "uložena uvnitř", takže i když funkce pthread_join je volána později, dostaneme hodnotu získanou funkcí time v okamžiku ukončování vlákna, tedy cca o 2 sekundy nižší.

V některém dalším pokračování si ukažeme více o tom jak "ovládat" vlákna zvenčí, jak je můžeme ukončit nebo přerušit běh a následně pokračovat.