awk ist ein programmierbarer Universalfilter.

awk-Programme schauen aus wie c-Skripte

awk 'BEGIN {printf("Hello, World!\n")} {} END {}' input.txt
> Hello, World!
  • Max. 3 Komponenten, eine am Anfang, eine main, eine am Ende
  • -f - Skriptdatei angeben

Die Main Komponente wird für jede Zeile ausgeführt

awk '{printf("Zeile");}' input.txt
> Zeile
> Zeile
> Zeile
> ...
> Zeile

Komponente nur für Zeilen mit int ausgeben (regex)

awk '/int/ {printf("Hello, World!\n")}' code.c

Variablen: Hier wird die Anzahl an int-Statements gezählt

awk 'BEGIN {u=0;} /int/ {u++;} END {printf("%d", u);}' code.c

Komponenten: $0 ist die erste Komponente, die ganze Zeile

awk '{ printf("%s\n", $0) }' input.txt
# Äquivalent:
awk '{ print }' input.txt

Field-Separator

awk 'BEGIN { FS=";" u=0; } /int/ {u++;} END {printf("%d", u);}' input.txt

Record-Separator trennt standardmäßig Zeilen Das Programm zählts Statements

awk 'BEGIN { FR=";" u=0; } /int/ {u++;} END {printf("%d", u);}' input.txt

Vorteile gegenüber sed

  • Arithmetische Funktionen
  • Variablen und Kotrollfluss
  • Zugriff auf einzelne Felder innerhalb von Zeilen
  • Formatierte Ausgaben
  • Stringfunktionen
  • C Programmlogik

seq - Erzeugt die Ausgabe einer Schleife

Beispiele

seq 1 5      # 1 2 3 4 5
seq 5 -1 1   # 5 4 3 2 1
 
# For-Schleife
for n in `seq 1 5`; do echo $n; done
  • Befehle werden durch ein Semikolon ; getrennt
  • Wird jeder Befehl in eine eigene Zeile geschrieben, kann das Semikolon auch weggelassen werden

Allgemeines

Arrays erstellen

array=(elem1 elem2 elem3)

Auf Array-Elemente zugreifen

echo ${array[0]}

Größe von Arrays bestimmen

groesse = ${#array[@]}

Beispiele

array=(1 2 3 4 5)
echo $array        # 1
echo ${array[1]}   # 2
echo ${array[*]}   # 1 2 3 4 5
 
# Über Array iterieren
for u in ${ar[*]}; do echo "v:" $u; done
 
 
ARRAY=('Debian Linux' 'RedHat Linux' 'Ubuntu Linux')
# Größe
ELEMENTS=${#ARRAY[@]}
# Ausgabe
for (( i=0;i<$ELEMENTS;i++)); do
	echo ${ARRAY[${i}]}
done
# Entfernt das kleinste passende Stück
${var%regex} # rechts
${var#regex} # links
 
# Entfernt das größte passende Stück
${var%%regex} # rechts
${var##regex} # links
 
# Ändert das erste Zeichen
${var^} # uppercase
${var,} # lowercase
 
# Ändert alle Zeichen
${var^^} # uppercase
${var,,} # lowercase
  • Variablen können mit = zugewiesen werden
  • Mit $ kann die Variable ausgelesen werden
  • Zum Escapen wird \ verwendet
a=5
b=10
echo $a
echo \$b

Ausgabe:

5
$b

Umgebungsvariablen

  • Dollar und irgend ein Zeichen
echo $0
echo $u war glaub ich user
echo $7 ist der return wert vom letzten programm
 
z.B. Nützlich bei test
test 2 -lt 6
echo $7       <--- gibt zurück ob 3 kleiner
                   als 7 ist (in diesem Fall 0)

Scope

  • Variablen sind in der Regel global
  • Lokale Variablen sind nur innerhalb von Subroutinen erlaubt und müssen mit dem Schlüsselwort local gekennzeichnet werden

Argumentübergabe

  • Parameter können über die Kommandozeile an das Bash-Skript übergeben werden
  • Im Skript können diese mit $1, $2, … abgerufen werden
  • Die Anzahl der Argumente ist in $# gespeichert
  • Alle Argumente nacheinander können mit $@ ausgegeben werden

Beispiel

#!/bin/bash
 
echo `expr 7 + 2`
# Äquivalent:
echo $(expr 7 + 2)
 
 
a=5
label=sum
echo $label:$a+3=$(expr $a + 3)    # sum:5+3=8
echo \$a=$a   # $a=5

read - Liest eine Zeicheneingabe vom Nutzer ein

Verhalten

  • Mit read können Strings in Variablen gelesen werden
  • Die variable $REPLY speichert die Antwort standardmäßig
  • Mit -a können die Werte in ein Array gelesen werden

Beispiele

read word1 word2
echo "Here is your input: \"$word1\" \"$word2\""
 
read
echo "You said $REPLY"
 
read -a array
echo "The words are ${array[0]} and ${array[1]}"
# Einlesen in die Umgebungsvariable
read
echo "You said " $REPLY
 
# Einlesen in angegebene Variablen
read word1 word2
echo "Here is your input: \"$word1\" \"$word2\""
 
# Einlesen in ein Array
read -a colors
echo "The first two colors were: ${colors[0]} and ${colors[1]}"

echo - Gibt Text auf die Standardausgabe aus

Verhalten

Mit Backticks ` lassen sich Ausgaben zu Eingaben anderer Kommandos machen

#!/bin/bash
 
# Die backticks führen zur Auswertung
# des uname Kommodos (Ausgabe: GNU/Linux)
echo `uname -o`
# Ohne backticks erhalten wir nur ein
# Echo, d.h. die Ausgabe ist: uname -o
echo uname -o
  • > leitet die Standardausgabe in eine Datei
  • >> wie >, nur wird die Ausgabe angehangen und nicht überschrieben
  • | leitet die Ausgabe eines Kommandos in die Eingabe eines anderen
  • < Statt von der Standardeingabe (Tastatur) wird aus einer Datei eingelesen

Piping

Interprozesskommunikation

#!/bin/bash
  • Shebang #!
  • Gefolgt vom Pfad zum Interpreter

Beispiel

#!/usr/bin/python3
print('Hello World!')
# Kommentar
  • Hashtag # und Bash ignoriert alles was dahinter steht
  • Außer in ANführungszeichen

Build-Prozesse

Wir wollen diese Funktion als Bibliothek verwenden:

// square.c
double square(double input) {
	return input * input;
}
  1. Objektfiles erzeugen
gcc -c square.c
  1. Archivieren
ar r libstuff.a square.o
  1. Indizieren
ranlib libstuff.a
  1. Im Programm verwenden
#include "stuff.h"
 
int main(int argc, char **argv) {
	printf("%f", square(3.64));
	return 0;
}

Übersetzen:

gcc libtest.c -o libtest -L. -lstuff
  • Quelle: libtest.c
  • Output: -o libtest
  • -L - Library
  • . - Bibliotheken stehen im aktuellen Verzeichnis
  • -l - Schalter für lib
  • stuff - So heißt die Bibliothek (sie heißt eigentlich libstuff.a aber das lib am Anfang und die Endung .a sind Standard)
count=6
 
count=$(expr $count-1)
# ist äquivalent zu:
let count=count-1

Make ist ein Tool zum Automatisieren von Build-Prozessen.

Funktionsweise

Syntax

make [options] [targets]
  • -n - Nur Ausgabe, keine Ausführung
  • -f - Falls das Makefile anders heißt

Makefile

Make braucht ein Konfigurationsfile, das sog. Makefile. Hier ein Beispiel:

CXX = g++
CXXFLAGS = -g -O
LDFLAGS =
 
all: hello
 
clean:
	rm -f hello *.o
 
hello:	hello.o
	$(CXX) $(CXXFLAGS) -o $@ $^ $(LDFLAGS)
 
.PHONY: all clean
  • targets - Ein oder mehrere target-Bezeichner, durch Leerzeichen getrennt
    • Make wird immer nur die commands ausführen, um targets zu bauen, die out-of-date sind
    • Die meisten targets gehören zu einem File mit dem gleichen Namen, d.h. das bauen des targets erzeugt dieses
    • Eine Ausnahme sind phony-targets, die kein File erzeugen
      • Um ein target ausdrücklich als phony-target zu vereinbaren, wird es zum prerequisite des speziellen targets .PHONY erklärt
      • Beim Aufruf eines phony-target werden immer die zugehörigen commands ausgeführt
  • prerequisites - Files, die nötig sind um das target zu bauen
    • Make checkt ob ein bereits gebautes file wiederverwendet werden kann
    • Ist ein gebautes file neuer als target, muss neu gebaut werden
  • commands - Abfolge der einzelnen Aktionen, die nötig sind, um das target aus den prerequisites zu bauen (=recipe)
  • Kommentare - Das #-Zeichen markiert den Rest der Zeile als Kommentar
  • Variablen - Können per Zuweisung vereinbart werden
    • Einige Variablen sind vordefiniert:
      • CXX - C++ compiler command
      • CXXFLAGS - C++ compiler options
      • LDFLAGS - Linker options
    • Um eine Variable aufzulösen wird das $-Zeichen , gefolgt vom Variablenbezeichner in runden Klammern verwendet
    • Weitere Symbole können innerhalb von rules verwendet werden:
      • $@ - target
      • $< - Name of first prerequisite
      • $^ - Names of all of prerequisites separated by spaces

Makefile per configure-Skript automatisch generieren

Problem: Ablageorte, Dateibezeichner, Versionsnummern und verwendete Werkzeuge unterscheiden sich auf den Plattformen. Ein Makefile mit festgelegten Optionen ist nicht portabel

Lösung: Das Makefile wird von einem configure-Skript aus dem Template Makefile.in erzeugt.

Das automake Programm generiert das Template Makefile.in aus der Datei makefile.am

Auch das configure-Skript wird automatisch konfiguriert

  • Im configure.ac kann man einstellen, wie übersetzt werden soll
  • Im Makefile.am werden nur die Optionen vereinbart, die plattformübergreifend gültig sind

Alternativen sind z.B. meson

Definition und Kategorisierung

  • Kerne moderner Betriebssysteme sind zwischen wenigen 100 KByte bis einige MByte groß

  • Prozessverwaltung und -kommunikation normal im Kern

  • Mikroarchitektur

    • Was in den Kern hineingehört ist nicht direkt festgelegt
    • Normal nicht anhand der Größe in bytes festgelegt, sondern am Funktionsumfang
  • Mikrokern-Architektur - Nur essenzielle BS-Funktionen sind enthalten

  • Makrokern-Architektur - z.B. auch das Dateisystem im Kern

  • Keine Übereinstimmung, was in einen Kern gehört

  • Keine Trennung von Anwendung und BS
  • Geeignet für statische BS, bei großen BS eher fehleranfällig
  • Bsp: MS-DOS
  • Trennung Anwendung - BS
  • Keine Trennung von Kernkomponenten
  • Schichtartig angeordnet
  • Kern umfasst nur Prozessmanagement, z.B. Scheduling und Dispatching
  • Extern: Treiber, Dateisysteme, …

Vorteile Mikrokernarchitektur

  • Modulare Struktur
  • Dienste nicht im BS, dadurch
  • Sicherheit und Stabilität
  • Flexibilität und Erweiterbarkeit
  • Portierbarkeit
  • Bessere Testmöglichkeiten

Nachteile Mikrokernarchitektur

  • i.d.R. schlechte Performance

Neue Anwendungen und neue Hardware haben stetig neue Anforderungen an ein Betriebssystem

  • Anpassung der Maschinenwelt an die Benutzerbedürfnisse
  • Regelung des Zugriffs auf Ressourcen
  • Verwaltung von Daten und Programmen
  • Effiziente Ausnutzung der Betriebsmittel
  • Unterstützung bei Fehlern und Ausfällen
  • Sicherheitsvorkehrungen

Was ist ein Betriebssystem?

Die Programme eines digitalen Rechensystems, die zusammen mit den Eigenschaften der Rechenanlage die Grundlage der möglichen Betriebsarten des digitalen Rechensystems bilden und insbesondere die Ausführung von Programmen steuern und überwachen.

  • Steuert und verwaltet Computer-Ressourcen, auch Betriebsmittel
  • Gibt oft die Kontrolle ab und ist auf den Prozessor angewiesen, die Kontrolle zurück zu bekommen

Ein Rechensystem besteht aus Hardware, Systemprogrammen und Anwendungsprogrammen.

  • Sichtbar für den Nutzer: Bedienung und Kontrolle von Anwendungen und Hardware
  • Unsichtbar: Verwaltung und Betrieb der Hardware

Virtuelle Maschine (Top-Down-Sicht)

  • Abstrahiert die Hardware

Ressourcenmanager (Bottom-Up-Sicht)

  • Verwaltet Prozessoren, Speicher, Platten
  • Koordiniert Zugriff von Anwendungen auf Ressourcen
    • Zeitliche Verwaltung: Nacheinander
    • Räumliche Verwaltung: Zeitgleich auf unterschiedliche Ressourcen

50er Jahre

Zuse; Neumann; Apps und OS in einem; Stapel-Verarbeitung mit Lochkarten; OS: EIn/Ausgabe, Umwandlung von Zahlen und Zeichen

60er Jahre

CPUs schneller als E/A-Rate; Echte Parallelität, Multiprogramming, Prozess als virtueller Prozessor; Speicher wird virtualisiert; Mehrnutzerbetrieb durch Timesharing

70er Jahre

Einsatz von C; Prozess wird zur Schutzumgebung mit eigenem Adressraum und Rehten

80er Jahre

Arbeitsrechner (z.B. MS-DOS); Ethernet, vernetzte Systeme -> Komplexe Prozesse; Adressraum und Prozess werden wieder getrennt, lightweight-Prozesse, Threads; GUIs; Parallelität; Betriebssystem über Rechnergrenzen hinaus, verteiltes System

90er Jahre

Parallelverarbeitung; Multimedia; Embedded Systems; Emulation

Aktuell

Cluster, Grid, Cloud-Computing; IoT; Sicherheit; Robustheit (Web); Energiemanagement; User Interfaces

Wir unterscheiden:

  • Prozessbereich, in dem die eigentlichen Funktionen vom OS erbracht werden
  • Kern(bereich), der für Prozesse die erforderliche Infrastruktur zur Verfügung stellt

Definition: Signalverarbeitung (en. trap)

Das Betriebssystem kann die Ausführung laufender Prozesse beim Eintreten einer Ausnahmesituation (en. Exception) abbrechen und die Ursache der Unterbrechung durch ein sogenanntes Signal bekannt machen

  • Wenn der Prozess nur unterbrochen werden soll, muss ein benutzerdefinierter trap handler installiert werden
  • System-Exceptions sollten für die Verarbeitung technischer und nicht logischer Fehler verwendet werden
  • Asynchron: Nach Request IRQ eines Gerätes
  • Synchron: Ausgelöst durch Traps/Exceptions, z.B. SIGSEGV, und Signale, z.B. SIGINT

Peripherie kann unabhängig von der CPU auf Speicher zugreifen: Direct Memory Access - DMA

Signale und Interrupts

Nützliche Befehle:

  • help <command> - Anleitung zu einem gdb-command anzeigen
  • quit - Debugger beenden
  • p[rint] <expr> - Den Wert von <expr> ausgeben
  • list - Programmausschnitt (dort, wo das Programm gerade steht) ausgeben
  • break function | line - Breakpoint für Funktion (bzw. Zeile) festsetzen
  • watch <expr>: Watchpoint für <expr> festsetzen
  • c[ontinue] - Ausführung fortsetzen
  • next - Nächste Zeile ausführen (Funktionsaufrufe überspringen)
  • step - Nächste Zeile ausführen (in aufgerufene Funktionen einspringen)

Backtrace

Erstellbar mit bt

Logs untersuchen

Fehler in Anwendungen führen zu core dump files, die sich im gdb öffnen lassen und ein Speicherabbild des abgestürzten Prozesses enthalten

Fehler im System führen zu crash dump files, die mit dem crash-Tool untersucht werden können

Valgrind ist ein Tool zum Erstellen von Speicherprofilen

valgrind --leak-check=yes ./prg

Ablaufprofile lasssen sich mit

valgrind --tool=helgrind

erstellen

Debugging

shmget - alloziert Shared Memory

Beschreibung

Benötigt <sys/ipc.h> und <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);

Parameter

  • key - Segment key oder IPC_PRIVATE (d.h. prozessinternes Segment)
  • size - Größe des neuen Segments in Byte
  • shmflg - Zugriffsberechtigungen und initiale Segmenteigenschaften, z. B. IPC_CREAT | 0666 für ein neues Segment mit Lese- & Schreibberechtigung

Rückgabewerte

SEGMENT ID oder −1 im Fehlerfall (die Ursache lässt sich dann in errno auslesen)

shmat - Bindet Prozesse an Shared Memory Segmente

Beschreibung

Benötigt <sys/types.h> und <sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);

Parameter

  • shmid - Segment ID
  • shmaddr - NULL (oder die gewünschte Zieladresse)
  • shmflg - 0 für Lesen & Schreiben, oder z. B. SHM_RDONLY oder SHM_RND

Rückgabewerte

segAddr oder −1 im Fehlerfall (Details in errno)

shmdt - trennt Prozesse von Shared Memory

Beschreibung

int shmdt(const void *shmaddr);

Achtung

Das Segment wird vom Prozess getrennt, der Speicher bleibt aber noch erhalten. Um den Speicher freizugeben, muss shmctl aufgerufen werden.

Shared memory ist eine einfache Möglichkeit zur Kommunikation unter Prozessen. Dabei wird ein Speichersegment dem logischen Adressraum mehrerer Prozesse hinzugefügt.

Der gemeinsame Speicher hat keinen internen Mechanismus zur Synchronisation, der Nutzer muss ein eigenes Sperrverfahren, z.B. mit Spin Locks implementieren.

Das Betriebssystem ordnet jedem Prozess einen sogenannten Adressraum zu:

  • Physikalische Adressen werden von der MMU verarbeitet.
  • Logische Adressen - auch Virtuelle Adressen, davon können beliebig viele von der CPU gebildet werden. Für logische Adressen wendet die MMU ein dynamisches Mapping an.

Mehrere Relationen sind möglich:

  • Ein Prozess besitzt genau einen Adressraum
  • Mehrere Prozesse teilen sich einen Adressraum (Shared Memory)

System Calls erfordern

  • Wechsel in den Kernel-Mode
  • Spezifikation einer OS-Routine über ihre Nummer
  • Parameterübergabe auf Stack und Registern

Meistens sind die Spezifikationen durch die API verborgen

Mit der strace-Routine lassen sich alle Systemaufrufe anzeigen:

strace ls

Obiges Beispiel zeigt alle Systemaufrufe die ls gemacht hat.

Die meisten Prozessoren haben 2 Modi:

  • Benutzermodus - für “normale” Programme
  • “Privilegierter” Modus - auch System- oder Kernel-Modus

Bestimmte Befehle werden nur im priviligierten Modus ausgeführt, z.B.:

  • Lesen/Schreiben bestimmter Register
  • Speicherverwaltung

Grund: Schutz des OS vor Benutzern

Nicht alle Betriebssysteme verwenden mehrere Modi, z.B. Embedded Systems wegen Effizienz

Allgemein gilt: Alles im Kernmodus gehört zum Betriebssystem aber nicht umgekehrt

Berechtigung erteilen

Skripte haben standardmäßig nicht die Berechtigung, ausgeführt zu werden. Mit

chmod +x <skript>

kann man die Berechtigung ändern.

Pfad auflösen

  • Man kann nicht einfach wenn man im selben Verzeichnis wie das auszuführende Programm ist nicht einfach den Programmnamen (z.B. script.sh) eingeben
  • Dies dient der Sicherheit, ansonsten könnten Standard-Befehle (wie z.B. cd oder ls) überschrieben werden
  • Stattdessen muss man den Pfad des Programms komplett auflösen, z.B. mit ./script.sh

tar - Erstellt ein Tape-Archiv

Beschreibung

tar [OPTIONEN] Datei(en)
  • x - Extract
  • c - Ein neues Archiv erzeugen
  • v - Verbal, outputs on console
  • z - Mit gzip komprimieren
  • t - Table
  • f - Archiv in Datei schreiben. Achtung: Dieses Flag muss als letztes kommen

Beispiele

# Erzeugen
tar -czvf meinarchiv.tar.gz meinverzeichnis
# Inhalt auflisten
tar -tzvf meinarchiv.tar.gz
# Extrahieren
tar -xzvf meinarchiv.tar.gz
# Die Argumente der Kommandozeile
echo $1 $2 $3
 
# Alle Argumente
echo $@
# Lassen sich in ein Array speichern
args=("$@")
 
# Anzahl der übergebenen Argumente
echo $#

test - Testet die Eingaben auf Konditionen

Beschreibung

test [var1] [flags] [var2]

Parameter

Strings

  • -n nicht leer?
  • -z leer?
  • = gleich?

Zahlen

  • -eq gleich?
  • -ne nicht gleich?
  • -lt, -gt, … kleiner, größer, …

Weitere

  • ! nicht?
  • -d Verzeichnis existiert?
  • Nicht auswendig lernen, nicht Prüfungsrelevant

Wichtig/Umdenken: Rükgabe von 0, falls es wahr ist (0 - Kein Fehler)

Beispiele

Anwendung:

if $(test $1 -gt $2); then
	echo $1 is greater than $2
 
# Die eckigen Klammern rufen auch test auf
elif [ $1 -lt $2 ]; then  # Äquivalent: elif $(test $1 -lt $2);
	echo $1 is smaller than $2
else
	echo $1 and $2 are equal
fi

Beispiele zu Flags:

  • -n foo - foo ist nicht leer
  • -z foo - foo ist leer
  • =, -eq, -lt, -gt, -le, -ge, -ne
  • ! foo - foo ist falsch
  • -e foo - die Datei foo existiert
  • -d foo - das Verzeichnis foo existiert
  • -r foo - die Datei foo hat Leseberechtigung
# Binde die bashtrap-Funktion an das SIGINT (Strg+C)
trap bashtrap INT
 
function bashtrap {
	echo "Interrupt!"
}
  • Mit tab wird der getippte Befehl automatisch verfollständigt
  • Mit der Cursor-Tasten (z.B. Pfeil nach oben) können die letzten Befehle hervorgeholt werden
  • Strg+R - Suche aktivieren, Suchtext eingeben und dann per Pfeiltasten hoch und runter

Rückgabewert findet sich in $?

Beispiel

v=5
w=v
echo $v    # auslesen
v="oth"
echo $v # oth
 
 
echo $0    # $0 gibt den Namen der shell aus, hier: bash
test 2 -lt 6  # ist 2 kleiner als 6?
 
 
# Programme ausführen
# Angenommen, wir haben ein Programm test im Verzeichnis.
test   # führt das Programm nicht aus, sondern den test-befehl
# So ruft man das Programm auf:
./test
 
# Den Return-Wert findet man dann in folgender Umgebungsvariable:
echo $?  # gibt den return-wert des letzten programms aus
  • In spitzen Klammern <stdlib.h> dann steht die Bibliothek im Standard-Bibliothekenverzeichnis, aufrufbar mit echo $INCLUDE, wahrscheinlich in /usr/include/
  • In Anführungszeichen dann sucht der Compiler im aktuellen Verzeichnis
cc -c factorial.c
 
# -c heißt compile, heißt ein object file, ein binary kommt raus aber kein executable.
# Da steht kein main drin, das kann für den Linker verwendet werden
 
 
# Einbinden anderer o-files
cc test.c factorial.o square.o -o testStuff
 
# Macht eine library aus den o-files
ar r libstuff.a factorial.o square.o
 
# Dann noch indizieren um es als Bibliothek verwenden zu können
ranlib libstuff.a

Bash-Programmierung

Allgemeines

  • [[Bash Trennung von Befehlen]]
  • [[Bash Variablen]]
  • [[Bash Arrays]]
  • [[Bash String-Manipulation]]

Programmstruktur

  • [[Bash Schleifen]]
  • [[Bash Bedingungen]]
  • [[Bash Subroutinen]]

Befehle

  • [[read]]
  • [[echo]]

Spezielles

  • [[Bash Umleitungen]]

In C erlauben ...-Parameter die Übergabe beliebiger Daten. Die interne Verarbeitung funktioniert mit va_start, va_arg und va_list aus stdarg.h.

Regel: mind. ein Parameter muss bekannt sein

Beispiele

Beispiel 1

#include <stdarg.h>
 
int add(int zahlen, ...) {
 
	va_list zeiger;
	int zahl;
	va_start(zeiger, zahlen);
 
	do {
		zahl = va_arg(zeiger,int);
		zahlen += zahl;
	} while(zahl != 0);
 
	va_end(zeiger);
 
	return zahlen;
}

Aufruf z.B. mit:

printf("%d\n",add(11,12,13,0));

Beispiel 2

#include <stdio.h>
#include <stdarg.h>
 
void printNumbers(int count, ...) {
    va_list args; // Deklarieren Sie eine Variable vom Typ va_list.
    va_start(args, count); // Initialisieren Sie die Variable mit va_start.
 
    for (int i = 0; i < count; i++) {
        int num = va_arg(args, int); // Holen Sie den nächsten Parameter aus der Liste.
        printf("%d ", num);
    }
 
    va_end(args); // Beenden Sie die Verwendung der va_list.
    printf("\n");
}
 
int main() {
    printNumbers(3, 1, 2, 3); // Beispielaufruf mit drei Parametern.
    return 0;
}
# For-Schleife
for((i=0; i<10; i++)); do
    echo $i
done
for i in $(seq 1 10); do
	echo $i
done

Programmstruktur

Beispiele

function subr {
    echo "Subroutine aufgerufen"
}
 
subr
VAR="global variable"
function subroutine {
	local VAR="local variable"
	echo $VAR
}
echo $VAR   # global variable
subr        # local variable

Siehe: test

if $(test $1 -gt $2); then
    echo $1 is greater than $2
elif [ $1 -lt $2 ]; then # Kurzschreibweise
    echo $1 is smaller than $2
else # elif [ $1 -eq $2 ]; then
    echo $1 and $2 are equal
fi

Ein Prozess ist ein eigenständiges Programm, das im Speicher läuft. Er hat einen eigenständigen Adressraum.

Prozesse können selbst wieder Kindprozesse (mit unabhängigem Adressraum) starten.

Ein I/O-bound-Prozess benötigt die meiste Zeit für Eingabe/Ausgabe-Operationen

Ein CPU-Bound-Prozess ist ein Prozess, der wenige, sehr lange CPU Bursts hat.

Moderne Betriebssysteme stellen Anwendungen Ressourcen durch untersützende Dienste (Dämonen, eng. Daemons) zur Verfügung.

graph TB
    A[Anwendung] -->|leicht, einfach| B["Dienste: 
    Umgang mit den Ressourcen
    (Betriebsmittelabstraktion)"]
    B -->|schwierig, umständlich| C1[Betriebsmittel]

Beispiele

  • initd - Elternprozess aller anderen Prozesse (PID 1)
  • dhcpd - Zur dynamischen TCP/IP Konfiguration
  • crond - Job Scheduler
  • sshd - Secure Shell Server
  • sendmail - SMTP Daemon

Daemon erstellen

if (fork() != 0) exit(0);
  • fork dupliziert den Prozess und gibt die Prozess-ID zurück
  • Der duplizierte Prozess hat immer die ID 0
  • Unterhalb dieser Zeile macht nur noch der Kindprozess weiter
  • Weil der Elternprozess sich danach sofort beendet hat, wird der Kindprozess vom initd “adoptiert”
  • Das SIGHUP-Signal kommt nicht durch und dadurch bleibt der Prozess auch am laufen, wenn sich der Elternprozess schließt

Daemon unsterblich machen

  • Bei vielen Daemons ist es erwünscht, dass sie niemals nicht laufen und immer wenn sie beendet werden wieder neu starten
for (;;) { // Bis zum nächsten Stromausfall
	procid = fork(); 
	
	if (procid > 0) { 
		// Elternprozess:
		// Wartet auf Terminierung
		wait(&zustand);
		// Neuer Kindprozess übernimmt
	}
	else {
		// Kindprozess läuft
		for (;;) { 
			// Hier kommen wir nur per SIGKILL raus
			// Um den Daemon zu beenden, muss der
			// Elternprozess beendet werden
		}	
	}
}
  • Der Prozess dupliziert sich
  • Der Elternprozess wartet bis das Kind beendet wurde
  • Sobald das passiert ist, wird die Schleife wiederholt und ein neues Kind wird erstellt

exec - Führt eine Datei aus

Varianten

-ep
lexeclexecleexeclp
vexecvexecveexecvp
  • l - Kommandozeilenargumente werden individuell übergeben
  • v - Kommandozeilenargumente werden als Array übergeben
  • e - Umgebungsvariablen werden (als Array von Zeigern) übergeben
  • p - Die PATH Variable wird für die Suche des Executables verwendet

Beschreibung

In unistd.h enthalten

execl(const char* pathname, const char *arg, ...)

fork - Klont das Aktuelle Programm in einen Kindprozess, welcher ab dann selbstständig weiterläuft

Beschreibung

fork(): int

Mit fork() lässt sich ein Kindprozess erzeugen, der per Kopie aus dem Elternprozess generiert wird, d.h. Variableninhalte sind zunächst gleich, Änderungen aber nur lokal wirksam -> exec dient zur Ausführung eines neuen Programms durch den Kindprozess.

Rückgabewerte

fork() gibt die Prozess-ID (meist ein int) des erstellten Prozesses zurück.

Beispiel

childPID = fork( );
 
if (childPID > 0) {
	/* Der Elternprozess ist hier aktiv */
} else if (childPID == 0) {
	/* Der Kindprozess ist hier aktiv */
} else {
	/* Ein Fehler ist aufgetreten: errno auswerten .. */
}

dup2 - dupliziert einen Dateideskriptor.

Beschreibung

int dup2(int oldfd, int newfd);

Der neue Dateideskriptor newfd wird angepasst, sodass er den selben offenen Dateideskriptor oldfd referenziert.

Beispiel

Dieses Beispiel ist eine Implementierung für das Kommando ls -l | wc -l (Zählen von Dateien im geöffneten Verzeichnis) in C.

Wir leiten hier die Standardausgabe von ls -l um in die Eingabe von wc -l.

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
 
int main(int argc, char* argv[]) {
 
	pid_t pid;
	int fd[2];
	
	pipe(fd); 
	pid = fork();
	
	if(pid==0) {
		close(fd[1]);
		
		// fd[0] ist das offene Pipe-Ende
		// 0 ist die numerische Kennung von stdin
		dup2(fd[0], 0);
		
		// Aufruf `wc -l`
		execlp("wc", "wc", "-l", (char *)NULL); 
		exit(0); 
	}
	else {
		close(fd[0]); 
		
		// 1 ist die numerische Kennung von stdout
		dup2(fd[1], 1);
		
		// Aufruf `ls -l`
		execlp("ls", "ls", "-l", (char*)NULL); 
	}
	
	close(fd[0]); 
	close(fd[1]); 
	
	return 0; 
}
  • Mit fork lässt sich ein Kindprozess erzeugen, der per Kopie aus dem Elternprozess generiert wird, d. h. Variableninhalte sind zunächst gleich, Änderungen aber nur lokal wirksam -> exec dient zur Ausführung eines neuen Programms durch den Kindprozess
  • Mit wait kann der Elternprozess auf die Terminierung eines beliebigen Kindprozesses warten
  • Nach dem der Kindprozess (z. B. per exit) terminiert, ist die nebenläufige Aktivität beendet

Siehe auch: fork, exec, pipe, write, close

#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
 
int main() {
    char buffer[50];
 
    pid = fork();
 
	// Gerichtete Kanäle
    int fromChild[2], toChild[2];
    
    // Pipes haben 2 Elemente:
    // 0 -> Lesen
    // 1 -> Schreibem
    pipe(fromChild); pipe(toChild);
    
    
    if(pid == -1) { 
	    // pid = -1 -> Fehler beim Erstellen
        return fprintf(stderr, "Error: %s\n", stderror(errno));
    }
    else if(pid == 0) { // pid = 0 -> Childprozess
        // Unnötige Kanäle schließen
        close(fromChild[0]); close(toChild[1]);
 
		// Sende Nachricht an Parent (insgesamt 17 Bytes)
        write(fromChild[1], "Ich bin das Kind", 17);
    }
    else { 
	    // pid > 0 -> Parentprozess
        close(fromChild[1]); close(toChild[0]);
		
		// Lese Nachriht vom Kind in den Buffer
		// Wir wissen hier dass wir 17 bytes empfagen werden
		read(fromChild[0], buffer, 17);
		
		// Nachricht ausgeben
        printf("%s\n", buffer);
    }
    return 0;
}

Prozesserzeugung

Fork-Bomben sind einfache Denial of Service-Attacken, die sich selbst immer wieder duplizieren, bis der Host abstürzt.

Beispiele

Achtung

Der folgende Code kann einen Computer sehr heiß laufen lassen oder abstützen lassen!

Umsetzung einer Fork-Bombe in C:

#include <uinstd.h>
 
int main (int argc, char **argv) {
	for (;;) fork(); // BOOM!
	return 0;
}

Kurzschreibweise in der Shell:

:():|:&;:

Abhilfe

Als root-user kann man folgende Datei anpassen:

/etc/security/limits.conf

Ein Zombie-Prozess ist ein Prozess, der nicht beendet werden kann. Der Zombie ist bereits “tot”, steht aber noch in der Prozesstabelle.

Entstehung

  • Wenn ein Kindprozess terminiert, sollte der Elternprozess, nach dem Aufruf der Funktion wait fortfahren (resume)
  • Wird wait nicht aufgerufen, verbleibt der Kindprozess als Zombie in der Prozesstabelle

Probleme

Zombies benötigen kaum Ressourcen, können sich aber negativ auf Scheduling auswirken.

  • In einem Betriebssystem werden die Elemente von Prozessen gebildet, d. h. ein Betriebssystem ist eine Menge interagierender Prozesse
  • Da Prozesse nicht in der Hardware vorgesehen sind, muss es etwas geben, das Prozesse und ihre Interaktion ermöglicht und unterstützt
  • Dieser Bereich heißt Kern (kernel) des Betriebssystems. Er stellt die grundlegende Infrastruktur für Prozesse bereit

Jeder Prozess hat unter anderem einen privaten Stack, Heap, Code und Daten.

Das Betriebssystem sichert (nebenläufigen) Prozessen Konsistenz bzgl. ihrer internen Zustände und der aller genutzten Betriebsmittel (Register, PCB) zu.

Registerinhalte müssen beim Hin- und Herschalten (Kontextwechsel) zwischen Prozessen zwischengespeichert werden.

Der Prozesskontrollblock (kurz PCB) enthält u.a. den Programmzähler, Registerinhalte, Scheduling-Informationen, Memory-Management Informationen, Account-Daten und den Zustand eines Prozesses.

  • Im allgemeinen Fall hat man mehr Prozesse als Prozessoren, was zu einem Wettbewerb zwischen Prozessen führt
  • Damit alle Prozesse auf einem Prozessor vorankommen, schaltet der Prozessor zwischen den Prozessen um, siehe Context Switch
  • Zur Vereinfachung wird das Umschalten “versteckt”: jeder Prozess erhält einen virtuellen Prozessor
  • Diese idalisierte Sicht erlaubt es, von einem Starten, Anhalten, Warten oder Beenden eines Prozesses zu sprechen, obwohl der ausführende Prozessor tatsächlich weder wartet noch anhält

Ein virtueller Prozessor ist eine Abstraktion des Betriebssystems um mehrere Prozesse scheinbar gleichzeitig auszuführen.

Die Auswahl, auf welchen Prozess als nächstes umgeschalten wird, geschieht erst zum Zeitpunkt des Umschaltens, also dynamisch. Folgende Kriterien sind möglich:

  • Nummer des Prozesses
  • Ankunftsreihenfolge
  • Priorität
    • Konstant
    • Veränderlich

In Unix wird jedem Prozess ein nice-Level zugeordnet, welches aussagt, wie “egoistisch” der Prozess in Hinsicht auf Rechenzeit gegenüber anderen ist. Der nice-Level hat einen Wert zwischen -20 und 19.

Rechenbeispiel

Die CPU-Nutzung von Prozessen mit Priorität ist proportional zu . Wird nun der nice-Level für einen Prozess mit nice +15 <PID> erhöht, so erhält dieser (im Vergleich zu Prozessen mit normaler Priorität ) die CPU nur der Zeit zugeteilt:

renice - Ändert das nice-Level von einem Prozess.

Beschreibung

nice [level] [pid]
  • level - Das neue nice-Level.
  • pid - Die ID des Prozesses.

Beispiele

renice 15 9124

Scheduling beschreibt die Festlegung der Prozessreihenfolge, welcher Prozess als nächstes auf dem Prozessor rechnen darf.

Preemtives und nicht preemtives Scheduling

Der Scheduler muss bei Zustandsübergängen von

  1. running zu waiting
  2. running zu ready
  3. waiting zu ready
  4. running zu terminated

einen Folgeprozess selektieren. Die Übergänge 1. und 4. werden von den Prozessen ausgelöst und sind nicht Präemptiv. Alle anderen, möglichen Zustandsübergänge heißen Präemptiv.

Kriterien

Folgende Kriterien sind für die Auswahl eines Scheduling-Verfahrens ausschlaggebend:

  • CPU Utilization - Das Ziel ist maximale Auslastung
  • throughput - Die Anzahl der Prozesse, die innerhalb eines Zeitschritts fertig werden
  • turnaround time - Die Zeit, bis ein bestimmter Prozess zur Ausführung kommt
  • waiting time - Die Zeit, die ein Prozess auf der ready-Queue verbringt
  • response time - Die Zeit, die nachdem eine Anfrage abgesetzt wurde vergeht, bis die erste Antwort kommt

Wenn ein Scheduler einem Prozess nie Rechenzeit zuteilt, heißt das Starvation. Beispielsweise führt ein LIFO-Scheduling zu Starvation.

Lösungen

  • Vor der Sperrung einer Ressource geprüft werden, ob andere Prozesse bereits lange genug warten (Timeout) und ihnen dann Vorrang geben
  • Ein Zählsemaphor kann eingesetzt werden um immer nur weniger Prozesse zuzulassen als es kritische Ressourcen gibt

Beim Shortest Job First-Scheduling (kurz: SFJ-Scheduling) werden zuerst die Prozesse abgearbeitet, die die kürzesten Bursts haben.

Beispiel

gantt
    dateFormat  X
    axisFormat  %s

    P2 :a1, 0, 3
    P3 :a2, 3, 6
    P1 :a3, 6, 30

Waiting Time für , , , im Durchschnitt

Bestimmung der Länge des nächsten CPU Bursts

Die Länge lässt sich nur schwer schätzen. Unter Berücksichtigung der Länge des letzten Bursts lässt sich die Exponentielle Glättung anwenden:

  1. = tatsächliche Länge des -ten CPU Bursts
  2. = vorhergesagte Länge des nächsten CPU Bursts
  3. Wir definieren:

Ein first come, first serve Scheduler startet die Prozesse in der Reihenfolge, in der sie eingetroffen sind. Meist ist die durchschnittliche waiting time lang: Convoy-Effekt.

Beispiel

gantt
    dateFormat  X
    axisFormat  %s
    
    P1 :a1, 0, 24
    P2 :a2, 24, 27
    P3 :a3, 27, 30

Hier ergibt sich im Durschnitt eine waiting time von 17, also massiv schlechter.

Beim Round Robin-Scheduling erhält jeder Prozess gleich viel Rechenzeit. Die Rechenzeit ist das sogenannte Time Quantum , nach maximal dieser Zeit wird ein neuer Prozess gewählt. Das Time Quantum ist entscheidend für die Scheduling Kriterien.

Beispiel

Hier ein Beispiel mit Time Quantum :

gantt
    dateFormat  X
    axisFormat  %s

    P1 :a1, 0, 4
    P2 :a2, 4, 7
    P3 :a3, 7, 10
    P1 :a1, 10, 14
    P1 :a1, 14, 18
    P1 :a1, 18, 22
    P1 :a1, 22, 26
    P1 :a1, 26, 30

Wenn beim Scheduling das Betriebssystem eingreifen muss, nennt man das präemptiv.

Contention beschreibt den Konflikt von Prozessen über Zugriff auf eine Ressource.

pthread_create - Erzeugt einen neuen Thread und startet diesen

Beschreibung

int pthread_create(
	pthread_t *thread,
	const pthread_attr_t *attr,
    void *(*start_routine) (void *),
    void *arg
);

Beispiel

Transclude of threads-beispiel#^beispiel

Eine Compute Farm ist ein Datenparalleles Programmiermodell das wie folgt funktioniert:

  • Master - Wartet in Endlosschleife auf requests und erzeugt dynamisch Threads zu deren Verarbeitung
  • Worker - Verarbeitet Task durch Abarbeitung des vom Master spezifizierten Code(s)

Prinzip: Abhängigkeiten innerhalb der Worker minimieren

Beispiel

int main(int argc, char **argv) {
	for (;;) {
		get(&request); // blocking
		switch (request) {
			case X: pthread_create(... taskX); break;
			case Y: pthread_create(... taskY); break;
		}
	}
}
 
void *taskX(void *data) {
	// ...
}

Eine Workcrew ist ein Datenparalleles Programmiermodell, das wie folgt funktioniert:

  • Master - Generiert Workerthreads und wartet danach bis diese terminieren
  • Worker - Verwenden alle eine gemeinsame, statische Datenquelle

  • Gut geeignet für Verfahren mit unveränderlicher Eingabe, z.B. (Datenbank-)Suchmaschine

Beispiel

int main(int argc, char **argv) {
	pthread_create(... thread1 ... taskX);
	pthread_create(... thread2 ... taskY);
	// ...
 
	suspend( ) // wait for all workers to finish
	}
}
void *taskX(void *data) { ... } // process taskX
void *taskY(void *data) { ... } // process taskY

Programmiermodelle

Eine Pipeline ist ein Taskparalleles Programmiermodell, bei dem eine große Menge unabhängiger Eingabedaten durch eine Abfolge von stages verarbeitet wird.

  • Dieses Modell eignet sich für Schachtelung einer Compute Farm bzw. Workcrew innerhalb von Stages: Multiplexing
  • Der maximale Durchsatz ist in jedem Fall auf die Dauer der aufwendigsten stage begrenzt

Beispiel

int main(int argc, char **argv) {
	pthread_create(... stage1);
	pthread_create(... stage2); /* ... */
}
 
void *stage1(void *data) {
	for ( ; ; ) { 
		getItem( ); // blocking
		... // process stage 1
		forwardItem( ); // to next stage
	} /* or output */ 
}
 
/* ... */ 
 
void *stageN(void *data) { ... }

pthread_join - Blockiert den auszuführenden Thread (caller) bis zur Terminierung eines anderen Threads

Beschreibung

int pthread_join(pthread_t thread, void **retval);
  • thread - Der Thread, auf den gewartet werden soll

Beispiel

Transclude of threads-beispiel#^beispiel

Um den wechselseitigen Zugriff auf Ressourcen (z.B. Datenbankzeilen) zu Synchronisieren verwendet man Mutual Exclusion (Mutex).

Vorgehen

Zur Threadkoordination (ohne Polling) kann

Beispiel

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
 
int arrived = 0;
pthread_mutex_t taxi;
pthread_cond_t station;
 
void *taxiDriver(void *p) {
	int i;
 
	for (i = 0; i < 3; ++i) {
		// Sperrt das Taxi, damit während dem kritischen Zustand
		// (Taxi fahren) niemand was anderes mit dem Taxi macht
		pthread_mutex_lock(&taxi);
		printf("Mein Taxi fährt\n");
 
		sleep(1);
 
		// Kritisch! Nach jedem Ausdruck kann der Scheduler
		// den Thread unterbrechen. Wenn taxiGuest genau zwischen
		// der Abfrage `i > 1` und dem schreiben von `arrived`
		// den Inhalt von `arrived` ausliest, kommt es vor, dass
		// er denkt er wäre nicht angekommen obwohl die Bedingung
		// wahr war. Deswegen Taxi sperren!
		if (i > 1)
			arrived = 1;
 
 
		pthread_cond_signal(&station);
		// Nach dem sperren nicht vergessen dass man die Ressource
		// auch wieder freigeben muss
		pthread_mutex_unlock(&taxi);
	}
 
}
 
void *taxiGuest(void *p) {
	printf("Der Fahrgast entspannt sich...\n");
 
	sleep(1);
	pthread_mutex_lock(&taxi);
 
	while (arrived == 0)
		pthread_cond_wait(&station, &taxi);
 
	printf("Der Fahrgast öffnet die Tür\n");
	sleep(1);
 
	printf("Der Fahrgast steigt aus\n");
	pthread_mutex_unlock(&taxi);
}
 
int main(int argc, char **argv) {
	pthread_t driver, guest;
 
	pthread_mutex_init(&taxi, NULL);
	pthread_cond_init(&station, NULL);
 
	pthread_create(&driver, NULL, taxiDriver, NULL);
	pthread_create(&guest, NULL, taxiGuest, NULL);
 
	pthread_join(guest, NULL);
	pthread_join(driver, NULL);
 
	pthread_mutex_destroy(&taxi);
	pthread_cond_destroy(&station);
 
	return 0;
}

Threads, Threadsynchronisation

Ein Remote Procedure Call (RPC) ist eine Technik, die Funktionen auf anderen Computern völlig transparent ausführt.

Ablauf

  • Der Stub hat die Funktionssignatur wie die eigentliche Funktion. Er vereint die Parameter zu einer Nachricht und überführt sie in eine flache, Pointer-freie Räpresentation (Marshalling)
  • Der Skeleton (auch Server-Stub) empfängt die Nachricht, zerlegt sie und konvertiert die Parameter in einer auf der Server-Seite lesbare Darstellung
    • Unter Umständen beinhaltet das Änderungen an der Byte-Folge und Wortlänge
    • Die benutzerdefinierte Serverfunktion wird aufgerufen und das Ergebnis zurückgeschickt
  • Der Client empfängt das Ergebnis und und verarbeitet es nach dem Unmarshalling weiter

Das Philosophenproblem (Dijkstra, 1968) ist ein klassisches Gedankenexperiment zur Nebenläufigkeit

Das Problem

  • 5 Philosophen speisen mit 5 Gabeln Spaghetti, wobei jeder zum Essen 2 Gabeln braucht.
  • Jeder Philosoph kommt zu einem zufälligen Zeitpunkt an seinen Platz, nimmt zwei Gabeln und isst eine zufällig lange Zeit.

Philosophenproblem|300

Das Szenario veranschaulicht die Phänomene:

  1. Deadlock - z.B. wenn alle hintereinander die rechte Gabel nehmen
  2. Starvation - z.B. wenn einer (oder mehr) nie Zugriff bekommen weil andere immer wieder eine Ressource erlangen
  3. Busy Waiting - z.B. wenn einer (oder mehr) unaufhörlich um Besteck bitten

Ein Deadlock (deutsch Verklemmung) ist die dauerhafte Blockierung von (mindestens 2) Prozessen, die die gemeinsame Ressource nutzen.

Ein Deadlock tritt auf, wenn mindestens zwei Prozesse sich in einem wechselseitigen Wartezustand befinden und dieser Zustand auch durch die Freigabe aller Ressourcen anderer Prozesse nicht aufgegeben werden kann. Einen Deadlock mit nur 2 beteiligten Prozessen nennt man auch Deadly Embrace (tödliche Umarmung).

Deadly Embrace|200

Bedingungen

Folgende vier Bedingungen sind notwendig und hinreichend für Deadlocks (Coffman, Elphick & Shoshani, 1971). Das heißt, treffen alle Bedingungen zu, kommt es zwangsläufig zum Deadlock. Trifft mindestens eine Bedingung nicht zu, wird der Deadlock vermieden.

  1. Serielle Benutzung von Ressourcen (mutual exclusion), z.B. synchronized-Methoden in Java
  2. Inkrementelles Ressourcen-Erlangen - Während des Wartens auf eine Ressource gibt ein Prozess bereits erlangte Ressourcen nicht frei
  3. Keine Preemption - Einem Prozess kann eine bereits erlangte Ressource nicht genommen werden
  4. Wartezyklus - Eine Prozesskette existiert, innerhalb der jeder Prozess die Ressource hält, auf die sein Vorgänger wartet

Vermeidung

  • Detection & Recovery - Terminieren einzelner Prozesse in der Abhängigkeitskette durch Präemptiv
  • Vorbeugen - Programme so designen, dass es nicht zum Deadlock kommen kann

Starvation durch Priority Inversion kann durch ungünstige Priorisierung auftreten:

  • Ein höher-priorisierter Prozess (Task H) kann einen niedriger priorisierten Prozess (Task L) daran hindern eine Ressource freizugeben
  • Wird diese von einem Prozess mit noch höherer Priorität (Task A) benötigt, so muss dieser auf beide warten und unter Umständen aushungern.

Eine Sequenz von Prozessen ist sicher im Bezug auf Deadlocks, wenn für jeden Prozess gilt: Die von angeforderten Ressourcen können aus dem Pool freier Ressourcen sofort zugewiesen werden oder sie sind unter den von (mit ) gehaltenen Ressourcen verfügbar.

Einfach gesagt

Eine Sequenz von Prozessen ist deadlock-sicher, wenn jeder Prozess entweder die benötigten Ressourcen direkt aus den noch freien Ressourcen bekommen kann oder diese Ressourcen von vorherigen Prozessen in der Sequenz freigegeben werden. Es gibt also immer einen Weg, alle Prozesse nacheinander ohne Blockierung ablaufen zu lassen.

Ein Resource Allocation Graph (kurz RAG) ist ein Gerichteter Graph, der die Beziehungen von Prozessen und Ressourcen in Verbindung stellt. Er hilft beim Finden von Deadlocks.

Die Knoten können in 2 Teilmengen unterteilt werden:

  • , die Menge aller Prozesse eines Systems
  • , die Menge aller Ressourcen eines Systems

Zusätzlich definieren wir:

  • Anforderungskante - gerichtete Kante wenn ein Prozess die Ressource haben möchte
  • Zuweisungskante - gerichtete Kante wenn ein Prozess Zugriff auf die Ressource hat

Beispiele

fordert Instanz von an:

Graph|300

hat eine Instanz von :

Graph|300

Grundlegende Fakten

Es gilt:

  • Wenn der Graph keinen Zyklus hat, kann es nicht zu einem Deadlock kommen.
  • Wenn der Graph einen Zyklus enthält, kommt es darauf an:
    • Falls nur eine Instanz je Ressourcen-Typ existiert, kommt es garantiert zu einem Deadlock
    • Falls mehrere Instanzen je Ressourcen-Typ existieren, besteht die Möglichkeit eines Deadlocks

Der Bankieralgorithmus (Dijkstra, 1965) wird zur Vermeidung von Verklemmungen genutzt.

Funktionsweise

Wir erweitern den Resource Allocation Graph wie folgt:

  • Claim-Kante : Prozess könnte Ressource anfordern (gestrichelte Linie)
  • Claim-Kante wird zur Anforderungskante, wenn ein Prozess die Ressource anfordert
  • Anforderungskante wird zur Zuweisungskante, wenn die Ressource dem Prozess zugeteilt wird
  • Zuweisungskante wird zur Claim-Kante, wenn die Ressource wieder freigegeben wird

Beispiel:

graph LR
    P1((P1)) -.-> R2[R2]
    P2((P2)) -.-> R2
    R1 --> P1
    P2 --> R1

Voraussetzungen

Um den Bankieralgorithmus anzuwenden, müsen folgende Voraussetzungen erfüllt sein:

  • Es gibt mehrere Instanzen jedes Ressourcentyps
  • Die Maximale Anzahl benötigter Instanzen eines Ressourcentyps eines Prozesses muss bekannt sein
  • Der Prozess muss das System in einem sicheren Zustand belassen, ansonsten muss er warten

Datenstrukturen

: Anzahl der Prozesse, : Anzahl der Ressourcentypen

  • Available - Vektor der Länge
    gibt Anzahl der verfügbaren Instanzen pro Ressourcentyp an
  • Max - Matrix
    definiert den maximalen Bedarf an Ressourcen für jeden Prozess
  • Allocation - Matrix
    definiert die derzeit reservierten Ressourcen für jeden Prozess
  • Need - Matrix
    gibt die noch benötigten Ressourcen für jeden Prozess an

Es gilt:

Beispiel

Gegeben seien folgende Allocation und Max Matrizen, sowie die Available Matrix von den Prozessen bis auf die Ressourcen , und :

Dadurch lässt sich die Need-Matrix berechnen:

Jetzt muss überprüft werden, ob der Need von einem Prozess dem Available-Vektor ist.

  • Für trifft das nicht zu: Dementsprechend wird dieser Prozess zunächst übersprungen.
  • Für trifft das zu: Dementsprechend fügen wir den Prozess an die Safe-Sequenz an: Der Available-Vektor wird mit der entsprechenden Zeile aus der Allocation-Matrix erhöht:
  • Bei trifft die Bedingung wieder nicht zu:
  • Für trifft die Bedingung wieder zu: Der Prozess wird an die Safe-Sequenz angehängt: Der Available-Vektor wird wieder aktualisiert:
  • So geht das weiter:
  • Ist man am Ende angekommen aber hat Prozesse übrig, die noch nicht in der Safe Sequenz sind, fängt man wieder von Vorne an:

Daraus folgt die Reihenfolge , die die Sicherheitsbedingungen befriedigt.

Beim dynamischen Laden werden Subroutinen erst in den Speicher geladen, wenn sie aufgerufen werden. Dadurch ergibt sich eine Bessere Speichernutzung: Nicht verwendeter Code wird überhaupt nicht im Speicher platziert. Dies zahlt sich vor allem dann aus, wenn selten genutzte Routinen einen großen Code-Umfang haben.

Beim dynamischen Linken fügt der Linker anstatt von Subroutinen-Code nur einen “Stub” in das Programm ein. Dieser Stub hat (wie beim RPC) nur die Aufgabe, die Subroutine aufzurufen. Das Betriebssystem prüft, ob die Subroutine bereits geladen wurde und lädt sie ggf. nach. Dies ist vor allem dann sinnvoll, wenn ganze Bibliotheken von Subroutines benötigt werden, die dynamisch geladen werden sollen. Solche Bibliotheken nennt man Shared Libraries (.so in Linux, .dll in Windows).

Bei absolutem Code wird die Speicherposition bereits zur Compile Time festgelegt.

Das Zuordnen von Code an Speicheradressen kann zu drei unterschiedlichen Zeitpunkten geschehen:

graph TB
    A((source<br>program)) --> B
    subgraph Compile Time
        B[compiler or<br>assembler]
    end
    C((object<br>module))
    D((other<br>object<br>modules))
    subgraph Load Time
        E[linkage<br>editor]
        E --> F((load<br>module))
        G((system<br>library)) --> H[loader]
    end
    B --> C
    C --> E
    D --> E
    F --> H
    I((dynamically<br>loaded<br>system<br>library)) -- dynamic linking --> J
    H ---> J
    subgraph Execution/Run Time
        J[in-memory<br>binary<br>memory<br>image]
    end

Wenn Code erst während der Load Time an Speicheradressen zugeordnet wird, heißt er relocatable code.

Beim Swapping wird ein inaktiver Prozess vorübergehend vollständig (inkl. PCB) auf einen dazu vorhergesehenen Bereich eines Sekundärspeichers, den sogenannten Backing Store, ausgelagert. Bei der Auslagerung werden zunächst niedrig priorisierte Prozesse übertragen, beim Wiederherstellen werden höher priorisierte Prozesse bevorzugt.

Damit der Scheduler möglichst selten auf den Backing Store zugreifen muss, ist es sinnvoll, Prozesse in der ready queue im Speicher zu halten und nur deren Daten auf den Sekundärspeicher auszulagern.

Speichermanagement

Das Base Register enthält die niedrigste physikalische Adresse des Nutzerbereiches.

Positionsunabhängiger Code (mit gcc Option -fpic generierbar) ermöglicht das Zuordnen von Code an Speicheradressen zur Execution Time und damit die größte Flexibilität: Prozesse können im Speicher verschoben werden, einzelne Bestandteile (Unterprogramme) nach Bedarfs hinzuladen und diese untereinander teilen.

Diese Flexibilität hat (gegenüber Compile & Ladezeitoptimierung) Performance-Nachteile und erfordert Hardware-Unterstützung (mehrere Base- und Limit Register).

In positionsunabhängigem Code sind logische und physische Adressen grundsätzlich verschieden.

Das Betriebssystem ordnet jedem Prozess einen sogenannten Adressraum zu:

  • Physikalische Adressen werden von der MMU verarbeitet.
  • Logische Adressen - auch Virtuelle Adressen, davon können beliebig viele von der CPU gebildet werden. Für logische Adressen wendet die MMU ein dynamisches Mapping an.

Mehrere Relationen sind möglich:

  • Ein Prozess besitzt genau einen Adressraum
  • Mehrere Prozesse teilen sich einen Adressraum (Shared Memory)

Bei Contiguous Allocation werden einem Prozess zusammenhängende Speicherblöcke zugewiesen. Dadurch wird Fragmentierung vermieden.

Das Base Register enthält die niedrigste physikalische Adresse des Nutzerbereiches.

Im allgemeinen lässt sich der Hauptspeicher eines Rechnersystems in zwei Bereiche unterteilen:

  • Systembereich - Enthält u. a. die Liste der System Interrupts und nutzt typischerweise niedrige Adressen
  • Nutzerbereich - Hier lagern die Nutzerprozesse, denen das Betriebssystem typischerweise höhere Adressen (oberhalb des Systembereichs) zuteilt

Die limit register enthalten die logische, für jeden Prozess spezifische Obergrenze, ab der dessen Adressraum endet (und der Adressraum eines anderen Prozesses beginnen kann).

Die Memory Management Unit (MMU) ist ein Hardware-Gerät, das logische auf physikalische Adressen abbildet. Im MMU Schema wird der Inhalt des Relocation Registers zu jeder Adresse addiert, bevor auf diese zugegriffen wird.

Beispiel: Intel Pentium

Logische Adresse -> Segmentation Unit -> Lineare Adresse -> Paging Unit -> Physikalische Adresse

Bei der Externen Fragmentierung ist genügend Speicher frei, um eine Anfrage zu bedienen, dieser ist aber nicht zusammenhängend.

Bei der Internen Fragmentierung wird mehr Speicher bereitgestellt als angefragt, der überschüssige Anteil bleibt unbenutzt.

Compaction ist eine Technik, die Betriebssysteme zur Verringerung der externen Fragmentierung anwenden. Dabei wird der Speicher “durchsortiert”, bis alle freien Stücke einen Block bilden. Da der Speicher Daten und Code enthält, funktioniert Compaction nur dort, wo Relocatable Code für Dynamic Binding & Positionsunabhängiger Code verwendet wird.

E/A-Problem

Speicher lässt sich während E/A-Jobs nicht verschieben. Lösungen:

  • E/A-Jobs werden während der Aktivität “abgeriegelt”, d.h. das Betriebssystem kann sie erste verschieben, wenn sie fertig sind
  • Das Betriebssystem stellt spezielle E/A-Puffer bereit, die dann für alle E/A-Aktivitäten benutzt werden

Beim Laden eines neuen Prozesses muss das Betriebssystem eine geeignete Lücke auswählen um den neuen Prozess dort zu platzieren.

Dazu benötigt das Betriebssystem Listen der…

  • Belegten Blöcke
  • Speicherlücken

Für die Frage, wie man eine Anfrage nach einem neuen Block der Größe am Besten bedient, gibt es mehrere Strategien:

  • First-fit - Belegung der ersten Speicherlücke, die groß genug ist
  • Best-fit - Belegung der kleinsten Speicherlücke, in die der Block passt
    • Sofern die Liste der Speicherlücken nicht sortiert vorliegt, erfordert das eine lineare Suche
    • Lässt nur kleine Speicherlücken zurück
  • Worst-Fit - Belegung der größten Speicherlücke (wieder per linearer Suche)
    • Lässt große Speicherlücken zurück

First-fit und best-fit sind hinsichtlich der Performance und Speicherorganisation besser als worst-fit

Hierarchisches Paging ist eine Variante des Paging, bei der der logische Adressraum auf mehrere Page Tables verteilt wird.

Beispiel Zweistufiges Page Table

Zweistufiges Page Table|400

Angenommen, eine Adresse wären 32 Bit. Diese würde sich dann zusammensetzen aus

  • Einer 22-Bit langen Seitennummer . Da die Page Table zweistufig ist, setzt sich die Seitennummer wiederum zusammen aus
    • Der 12-Bit langen Seitennummer
    • Dem 10-Bit-langen Seitenoffset
  • Einem 10-Bit langen Seiten-Offset
| Seitennummer  | Seiten-Offset |
| p1    | p2    | d             |
| 12    | 10    | 10            |

Invertierte Page Tables sind eine Variante des Paging. Dabei gibt es nicht für jeden Prozess eine eigene Liste sondern eine große für alle. In den Einträgen stehen die Nummern der zugehörigen Pages, sowie die PIDs der Prozesse, denen die Seiten gehören. Dies Verringert den Speicherbedarf, allerdings wird der Suchaufwand erhöht, weshalb oft ein Hashed Page Table vorgeschalten wird.

Inverted Page Table

Hashed Page Tables sind eine Variante des Paging mit Hilfe von Hashing. Diese kommen oft bei großen Adressräumen (> 32 Bit) zum Einsatz.

Die virtuelle Seitennummer (logische) wird dabei in eine Page Table gehashed. In dieser Page Table befinden sich i.A. Listen von Elementen für Seiten, die auf die gleiche Position gehashed wurden.

Zum Auflösen einer virtuellen Seitenadresse, muss diese mit allen Elementen der zugehörigen Liste verglichen werden. Sobald ein Treffer für eine Seite gefunden wurde, wird der zugehörige Frame aus dem Speicher geladen.

Paging beschreibt eine Methode zur Verringerung von externer Fragmentierung.

Funktionsweise

Dabei wird der physikalische Speicher in Blöcke fixer Größe, sogenannte Frames aufgeteilt. Der logische Speicher wird in gleichgroße Pages aufgeteilt. Jede Page wird auf einen Frame abgebildet.

Framegrößen

Die Größe von Frames ist typischerweise eine Zweierpotenz zwischen und Bytes.

Fragmentierung

Zwar verringert Paging externe Fragmentierung, doch kommt es wegen der fixen Pagegröße zu interner Fragmentierung.

Das Betriebssystem “führt Buch” über alle freien Frames. Meist ist das eine Liste, wo z.B. steht “Zur Page abc gehört Frame 45 xyz”. Dabei hilft ein page table.

Benötigt ein Programm Speicher, so muss das Betriebssystem genug Frames finden.

Adressschema

Die von der CPU generierten Adressen beinhalten…

  • Seitennummer (p) - Dient als Index für die page table und liefert die Basisadresse
  • Seiten-Offset (d) - Dient zur Auffindung des Speicherinhalts und wird auf die Basisadresse addiert

Prozessspezifischer Zugriffsschutz

An jeden Frame wird ein Flag, das sogenannte valid-invalid Bit angefügt. Diese Bits stehen hinter den Einträgen der Page Tables der Prozesse

  • valid (v) - Zeigt an, dass eine Seite zum logischen Adressraum des Prozesses gehört, dem der Page Table zugeordnet ist
  • invalid (i) - Zeigt an, dass eine Seite nicht zum logischen Adressraum gehört und der Prozess, dem die Page Table mit diesem Eintrag zugeordnet ist, folglich nicht auf die Seite zugreifen darf

Paging Hardware

Segmentierung beschreibt ein Memory-Management Schema, das der User-Sicht auf die Daten entspricht. Ein Programm besteht hier aus einer Ansammlung von Segmenten, wobei jedes Segment einer logischen Einheit entspricht.

Logische Adressen sind hier Zweiertupel: <segment-nummer, offset>

Begriffe

  • Segment Table - Enthält Mehrkomponenten-Adressen, jeder Eintrag beinhaltet:
    • base - Physikalische Startadresse im RAM
    • limit - Segmentlänge
  • Segment-table base register (STBR) - zeigt auf die Segment Table
  • Segment-table length register (STLR) - gibt die Anzahl der Segmente eines Programms an

Eine Segmentnummer ist legal wenn sie kleiner als der STLR ist.

  • Das Betriebssystem speichert zusätzlich zu jedem Eintrag der Segmenttabelle
    • Ein Validierungsbit
    • Read / Write / Execute Rechte
  • Da Segmente in der Länge variieren, muss Speicher dynamisch reserviert werden

Segmentierung in der Hardware

Virtueller Speicher ist im Memory Management ein Überbegriff für die Arbeitsweise der Abbildung von logischen Speicher auf physikalischen Speicher.

Vorteile

  • Programme müssen nur teilweise im Speicher liegen
  • Logische Adressen können sich auf Bereiche außerhalb des physikalischen Speichers beziehen
  • Prozesse können Teile des Adressraums gemeinsam benutzen
  • Auf diese Weise lassen sich Prozesse sehr zügig erzeugen

Implementierungsoptionen

Speicherlücken entstehen bei der Prozessminimierung und sind beliebig über den Speicher verteilt.

Demand Paging ist eine Variante des Paging, bei der Pages nur in den RAM geladen werden, wenn sie auch benutzt werden. Dadurch ergeben sich einige Vorteile:

  • Verringerter E/A-Aufwand
  • Reduzierte Speicherbelegung
  • Höhere Responsivität
  • Mehr Benutzer

Für das Demand Paging wird eine zusätzliche Spalte in den Page Tables benötigt: Das Valid-Invalid Bit. Steht es auf v (valid), ist die Page in den Speicher geladen, steht es auf i (invalid), ist die Seite nicht im Speicher und eine trap (der sog. Page Fault) wird verarbeitet. Dafür ist eine zusätzliche Tabelle notwendig, in der steht, ob die Page zum Prozess gehört.

  • Page gehört nicht zum Prozess
    • Prozess abbrechen
  • Seite gehört zum Prozess:
    1. Freien Frame suchen
    2. Seiteninhalt laden - swap
    3. Seitentabelle(n) aktualisieren
    4. Valid-Invalid Bit auf v setzen
    5. Instruktion, die zum Page Fault geführt hat, erneut ausführen

Effektive Zugriffszeit

Sei die Page Fault Rate (0=Keine Page Faults, 1=Alles Page Faults), dann ist die effektive Zugriffszeit EAT

Copy On Write ist eine Methode des Demand Paging, bei der nach einem fork der Eltern- und Kindprozess zunächst die selben Pages verwenden. Erst, wenn einer der Prozesse eine Shared Page modifiziert, wird sie kopiert. Die Prozesserzeugung läuft dadurch schneller.

Page Replacement ist eine Methode des Demand Paging, bei der nicht genutzte Pages, sogenannte victims, aus dem Hauptspeicher ausgelagert werden um Platz für neue Frames, die replacements, zu schaffen.

Verfahren

  1. Die benötigte Page muss auf dem Sekundärspeicher lokalisiert werden
  2. Suche einen freien Frame. Wenn es keinen freien Frame gibt, verwende einen replacement Algorithmus, um einen victim zu selektieren
  3. Der Inhalt des victim-Frames wird gelöscht, die benötigte Page vom Sekundärspeicher eingelesen und diesem Frame zugeordnet, d.h. die Page Tables werden aktualisiert
  4. Der Prozess, der den Zugriff getätigt hat wird neu gestartet

Das FIFO-Verfahren ist ein Verfahren des Page Replacement, bei dem die Pages, die als erstes in den Speicher geladen werden, auch als erstes als victims gekennzeichnet werden.

Belady’s Anomaly sagt aus, dass mehr Frames nicht unbedingt immer zu weniger Page faults führen, sondern ggf. sogar zu mehr.

Der Least Recently Used (LRU) Algorithmus ist eine Methode des Page Replacement, bei der das victim der Prozess ist, der am längsten nicht mehr genutzt wurde.

Implementierung

Es gibt mehrere Möglichkeiten, den LRU zu implementieren:

Logische Uhr

Jede Seite bekommt einen Zeitstempel der den letzten Zugriffszeitpunkt speichert. Wenn eine Seite ersetzt werden soll, wird die mit dem kleinsten Zeitstempel gewählt.

Hitliste

Ein spezieller Stack, auf dem die Seitennummern in einer doppelt verketteten Liste gespeichert sind. Wenn eine Seite referenziert wird, wird sie an der obersten Position des Stacks positioniert. Vorteile:

  • Der Stack ist viel kleiner als die Page Table, das spart Speicherplatz
  • Die älteste (bekannte) Seite liegt immer an der untersten Position, der Zeitaufwand der Suche entfällt also

Reference Bit

  • Jede Seite bekommt ein Bit, das zunächst auf 0 gesetzt wird
  • Wenn die Seite referenziert wird, ändert sich der Wert zu 1

Wir wissen nicht in welcher Abfolge die Seiten referenziert wurden, aber wir haben ein neues Ersetzungsverfahren: Wir ersetzen Pages Reihum, d.h. eine nach der anderen. Wenn aber die Page das Reference Bit 1 hat:

  • Setzen wir ihr Reference Bit auf 0
  • Belassen wir die Page im Speicher
  • Fahren wir mit der nächsten Page entsprechend fort

Zählverfahren

Für jede Seite gibt es einen Zähler, in dem steht, wie oft die Seite referenziert wurde. Ersetzt wird:

  • Beim LFU Algorithmus die Seite mit dem kleinsten Zählwert
  • Beim MFU Algorithmus die Seite mit dem größten Zählwert

Ein Argument für MFU wäre, dass die Seiten mit dem kleinsten Zählwert möglicherweise gerade erst geladen wurden und die Verwendung noch bevorsteht.

Im Demand Paging bedeutet Global Replacement, dass ein Prozess bei einem Page Fault anderen Prozessen Frames wegnehmen kann.

Im Demand Paging bedeutet Local Replacement, dass ein Prozess bei einem Page Fault nur Frames aus der eigenen Page Table ersetzen kann, also eigene Frames.

Das Working Set Model ist eine Methode zur Vorbeugung von Thrashing. Es basiert auf der Annahme von Lokalität, also das ein Prozess eine gewisse Menge an Pages zusammen benutzt und sich diese Menge während der Prozessausführung verändert.

Dabei wird ein sogenanntes Working Set Window verwendet. Dieses ist eine Liste aller Ressourcen der letzten Schritte, die der Prozess getätigt hat.

Das Working Set ist dann die Menge aller Ressourcen im Working Set Window zu diesem Zeitpunkt.

Beispiel

Angenommen . Für einen Prozess könnte die Page Reference Table so aussehen:

Eingezeichnet ist bereits ein Zeitpunkt und das Working Set Window. Das Working Set sind dann alle Ressourcen aus diesem Window:

Anpassung von

Das Working Set beinhaltet jetzt also alle Ressourcen auf die der Prozess im im vergangenen Zeitfenster zugegriffen hat.

  • Ist zu klein, wird es kaum Lokalitäten erfassen.
  • Ist zu groß, werden oft mehrere Lokalitäten überdeckt.
  • Ist = LOC, überdeckt das Window das gesamte Programm.

Gegenmaßnahmen

Sei die absolute Anzahl der Seiten die innerhalb des letzten referenziert wurden und

die Anzahl aller referenzierten Ressourcen (total demand frames). Ferner sei die Anzahl der verfügbaren Frames. Dann gilt:

Um Thrashing also zu verhindern, werden Prozesse suspendiert, sobald eintritt. Ist , können Prozesse wieder forthfahren.

Approximierung

  • Mit Intval Timer und Reference Bits

Beispiel:

  • Der Timer unterbricht z.B. alle 5000 Zeitschritte
  • Jeder Eintrag der Page Table bekommt 2 zusätzliche Bits
  • Bei jeder Timer Unterbrechung werden die Bitinhalte der ersten Spalte in die zweite kopiert und auf 0 gesetzt
  • Wenn eines der Bits = 1 -> Page im Working Set
  • Ungenau

Heuristische Anpassung

  • Ist die Page Fault Rate niedrig, wird die Page Table verkleinert
  • Ist die Page Fault Rate hoch, wird die Page Table vergrößert

In-Memory Dateien ermöglichen es, E/A-Vorgäge in Dateien durch einfache (und damit schnellere) RAM-Zugriffe zu ersetzen, indem sogenannte RAM Disks Speicherblöcken zugeordnet werden.

Moderne Betriebssysteme führen auch ein implizites Memory-Mapping für Dateien durch: Beim Lesezugriff werden Blöcke in den Speicher geladen, nachfolgende Zugriffe erfolgen (nach Möglichkeit) dort.

Die Systemfunktionen read und write abstrahieren über diese Optimierung.

Das Betriebssystem kann fortlaufend adressierte Blöcke (contiguous memory) auf beliebig viele Seiten (non-contiguous memory) abbilden.

Seitenflattern (auch Thrashing) ist ein Phänomen bei der virtuellen Speicherverwaltung, bei dem ein Prozess aufgrund mangelnden verfügabren Speichers mehr Zeit mit dem Swapping von Pages beschäftigt ist als mit den eigentlichen Berechnungen.

Demand Paging führt zu Beginn eines neuen Prozesses zu vielen Page Faults. Ein Trick moderner Betriebssysteme besteht darin, einige Seiten vorab bereit zu stellen.

Probleme

Wenn die Seiten nicht verwendet werden, werden Einträge der Seitentabelle und - falls Virtueller Speicher verwendet wird - auch Zeit verschwendet. Deshalb stellt sich die Frage, ob die Einsparungen durch die verhinderten Page-Faults größer oder kleiner sind als die Kosten der unnötig vorbereiteten Seiten.

Weil das nachschlagen der Frames hinter Pages oft passiert und langsam ist, werden oft genutzte Pages in einen Assoziativen Cache zwischengespeichert. Dieser Speicher heißt in diesem Zusammenhang auch Translation Look Aside Buffer (TLB). Wird das zugehörige Frame einer Page in diesem Speicher zwischengespeichert, haben wir glück und können schnell den Speicherbereich aufrufen. Falls nicht, haben wir einen TLB miss und müssen wie gewohnt im langsamen Page Table nachschauen.

Schätzung der effektiven Zugriffszeit

Angenommen, wir haben eine Cache-Trefferquote , ein assoziativer Lookup dauert Zeiteinheiten und ein Memory Cycle benötigt eine Mikrosekunde. Dann ist die effektive Zugriffszeit (EAT)

Reichweite des TLB

Die Kapazität des assoziativen Speichers ist durch den TLB begrenzt:

Idealerweise passt das Working Set eines Prozesses in den assoziativen Speicher, andernfalls lassen sich Page Faults folgendermaßen verhindern:

  • Page Size vergrößern (führt zu einer Zunahme an interner Fragmentierung)
  • Unterschiedliche Page Sizes zulassen (Prozess-speziefisches Paging, z.B. angepasst für Kernel-Prozesse)