Überblick

[https://heiprotec.de/die-betriebssysteme-im-vergleich]

Mitarbeiter

Prof. Dr. Stefan Bosse, Praktische Informatik (Vorlesung und Übung)

Verteilte Künstliche Intelligenz & Sensornetzwerke

Betriebssysteme & Virtualisierung

Technische Systeme

WiMi Philipp Seifer (Übung)

Software Sprachen

HiWi (Übung)

Aufbau der Veranstaltung

Vorlesungen

Mittwoch 8-10 Uhr (Hauptstrang, Grundlagen)

On-line, Video (Tutorials, besondere Themen, wie z.B. C und Betriebssystemsprogrammierung)

Übungen

Freitags 8-10 Uhr

Übungsformat

Notebooks: Alle Übungen sind digital und werden im Web Browser bearbeitet (lokal, ggfs. off-line). Die Inhalte einer Übungseinheit werden als Datei im JSON Format gespeichert und hochgeladen.

Interaktive Übungen mit Fragen, Hinweisen, und Codesnippets.

Leistungskriterien

Übungen

Bearbeitung der Übungseinheiten in Einzelarbeit (in Gruppe)

Abgabe eines Übungszettels in digitaler Form als Gruppe

Klausur

Inhalte

Taxonomie der Betriebssysteme, Grundlagen

Das Speichermodell und die Programmiersprache C

Eine Einführung in die Programmiersprache C

Prozesse

Virtualisierung

Scheduling

Echtzeitbetriebssysteme und Eingebettete Systeme

Speicherverwaltung, Virtueller Speicher

Dateisysteme, Ein- und Ausgabe

Synchronisation, Networking

Einführung und Überblick

Was sind Betriebssysteme?

Was sind Virtuelle Maschinen?

Wie sind Betriebssysteme aufgebaut, wie programmiert man sie, wie analysiert man Fehler?

Betriebsysteme

Was ist ein Betriebssystem?

Das Betriebssystem soll den Anwendungsprogrammierer bzw. den Anwender von Details der Rechnerhardware entlasten. Modern strukturierte Betriebssysteme kapseln den Zugriff auf die Betriebsmittel. Das Betriebssystem stellt somit eine virtuelle Maschine über der Hardware bereit.

Das Betriebssystem ist ein Programm mit Besonderheiten die in diesem Kurs untersucht werden. Auch praktisch!

Die geräteunabhängige Ein-/Ausgabe war eine der wichtigsten Errungenschaften bei der erstmaligen Einführung von Betriebssystemen.

Früher war es notwendig, dass Applikationen die Eigenheiten der angeschlossenen Peripheriegeräte im Detail kennen mussten.

Abbildung 2-1. Ein-/Ausgabe ohne und mit Betriebssystem

Lernziele

Sie erklären den Zweck und die Rolle eines modernen Betriebssystems.

Sie erkennen wie Rechnerressourcen durch Applikationen genutzt werden, wenn sie das Betriebssystem verwaltet.

Sie beschreiben die Funktionen eines aktuellen Betriebssystems in Bezug auf Benutzbarkeit, Effizienz und Entwicklungsfähigkeit.

Sie können die Vorteile abstrakter Schichten und ihrer Schnittstellen in hierarchisch gestalteten Architekturen erklären.

Sie analysieren die Kompromisse beim Entwurf eines Betriebssystems.

Sie erläutern die Architektureigenschaften monolithischer, geschichteter, modularer und Mikrokernsysteme.

Sie stellen netzwerkfähige, Client/Server- und verteilte Betriebssysteme einander gegenüber und vergleichen diese.

Sie erlernen die C Programmiersprache - Die Basis fast aller betriebssysteme.

Literatur

Grundkurs Betriebssysteme&br;Peter Mandel&br;Springer Verlag

Betriebssysteme&br;Eduard Glatz&br;dpunkt Verlag

Betriebessysteme, kompakt&br;Christian Baun&br;Springer Verlag

Operating Systems - Design And Implementation&br;Andrew S. Tanenbaum&br;Prentice Hall

Modern Operating Systems&br;Andrew S. Tanenbaum&br;Prentice Hall

Distributed Operating Systems&br;Andrew S. Tanenbaum&br;Prentice Hall, 1996

Taxonomie der Betriebssysteme

Klassen:

IO Loop: Kein Betriebssystem, eine einzige Endlosschleife und endless pain

Mikrokernel und Dienstprogramme

Monolithischer Kernel (eingebettet)

Monolithischer Kernel und Dienstprogramme

Verteilte Betriebssysteme (inkl. Cloud)

Echtzeitfähige Betriebssysteme

Generische versa applikationsspezifische (domänenspezifische) BS

Betriebssysteme und Rechnerarchitektur

Betriebssysteme sind eng verknüpft mit Rechnerarchitekturen!

Eingebettetes System

%% HTML

%% UNWRAP p Server

%% HTML

%% UNWRAP p Notebook

Programmierung von Betriebssystemen

Randbedingungen:

Rechnerarchitektur

Speicherarchitektur und Speichermodell

Nutzerinteraktion

Vernetzung und Netzwerkmodell

Aufgaben

Zeitmodell (Echtzeit)

Wir werden ein minimales Betriebssystem (basekernel) in C programmieren. C?

Die Programmierung, der Test und die Fehlersuche in Betriebssystemen ist wenig komfortabel und methodisch getrieben wie die Informatik es im klassischen Softwareentwurf lehrt und ggfs. praktiziert!

Welcher Debugger (Diagnosewerkzeug zur Fehlersuche) wird am häufigsten in der Praxis bei der Fehlersuche eingesetzt ?

Fehlersuche in Betriebssystemen ist wie Detektivarbeit (Sherlock Holmes, Hercule Poirot)!

Basekernel

https://github.com/dthain/basekernel

Prof. Douglas Thain (University of Notre Dame) et al.

Minimaler Betriebssystemkernel (monolithisch)

Betriebsprogramme (Shell Interpreter, inklusive minimaler grafischer Benutzeroberfläche)

Statistik der Programmiersprachen:

C | Assembler | Sonstiges
96.3% | 3.0% | 0.7%
Kernel: ≈13000 Zeilen | Kernel: ≈710 Zeilen | -
Library: ≈7200 Zeilen | - | -
User: ≈1500 Zeilen | - | -

Basekernel ist ein einfacher Betriebssystemkern für Forschung, Lehre und Spaß.

Der Basiskernel ist kein vollständiges Betriebssystem, aber er ist ein Ausgangspunkt für diejenigen, die neuen Betriebssystemcode studieren und entwickeln möchten.

Basekernel kann in einer Intel PC-kompatiblen virtuellen Maschine im geschützten 32-Bit-Modus gestartet werden, mit Unterstützung für VESA-Framebuffer-Grafiken, ATA-Festplatten, optische ATAPI-Geräte, Prozessverwaltung, Speicherschutz, einfache Grafiken und grundlegendes Dateisystem.

Es können einfach neue Programme auf Benutzerebene geschrieben und das System erweitert werden.

Basekernel: Das erste Experiment geht schief

Das Betriebssystem wird automatisch mittels des make Programms erzeugt (Dauer: 2 Sekunden!):

Der Kernel

Die Betriebsprogramme

Alles zusammen in eine ISO Datei gepackt.

Der erste Versuch das Betriebssystem (baserkernel.iso) in der vm86 zu starten führt zu der Ausgabe (und 100% CPU Last):

Basekernel: Das erste Experiment geht schief. Was war passiert?

Ein Betriebssystem braucht selber ein kleines minimales Betriebssystem um (von einem Speichermedium) geladen und gestartet zu werden.

Hier wird der Betriebssystemkernel von einem Bootloader geladen

Der Bootloader wird selber wieder von einem Programm geladen

Hier wird der Bootloader vom im Rechner eingebauten Basic Input Output System (BIOS) geladen

Wir sehen an der Terminalausgabe dass der Bootloader geladen wurde, dieser das Kernelprogramm geladen hat, und es schließlich gestartet hat.

Dann war Schluss...

Okay, in den Programmcode des Bootloaders schauen und her mit dem printf Debugger ...

Programmiersprache des Bootloaders: Maschinensprache (Assembler)! OMG

Printf? Gibt es nicht.

Es gibt nur die Möglichkeit mit einer Reihe von Maschinenbefehlen des Mikroprozessors via BIOS Funktionen Textzeichen auf den Bildschirm zu setzen!

Basekernel: Das erste Experiment geht schief. Was tun?

bios_putstring:				# routine to print an entire string
    mov  (%si) , %al
    cmp  $0    , %al
    jz   bios_putstring_done
    call bios_putchar
    inc  %si
    jmp  bios_putstring
bios_putstring_done:
    ret
bios_putchar:				# routine to print a single char
	push %ax
    push %bx
    mov  $14  ,  %ah
    mov  $1   ,  %bl
    int  $0x10
    pop  %bx
    pop  %ax
    ret
bootmsg:
    .asciz "\r\nbootblock: booting kernel...\r\n"
****
    mov $(bootmsg),%si		# print boot message
    call bios_putstring

Kode 2-1. Ausschnitt Minimalimplementierung "printf" in Assembler (`bootblock.S`)

Wir fügen Checkpoint Ausgaben in den Bootloader (bootblock.S) ein um zu sehen wie weit wir kommen und später im Anfang des Betriebssystemkerns (kernelcore.S)

checkpoint_msg_a:
    .asciz "Checkpoint BL.1\r\n"
checkpoint_msg_b:
    .asciz "Checkpoint BL.2\r\n"
checkpoint_msg_c:
    .asciz "Checkpoint BL.3\r\n"

loadsector:
    mov  $(checkpoint_msg_a),%si		# Checkpoint A
    call bios_putstring

    mov  $1, %al			# load 1 sector
    mov  $0x02, %ah		# load command
    int  $0x13			# execute load

    mov  $(checkpoint_msg_b),%si		# Checkpoint B
    call bios_putstring

    mov  $'.', %al		# display a dot
    call bios_putchar		# for each sector loaded

    mov  (sectors_left),%ax	# how many sectors left?
    cmp  $0xffff, %ax		# has it been initialized?
    jne  gotsectors		# yes - use the value
    mov  %es:(KERNEL_SIZE_OFFSET),%eax	# no - get size of kernel
    shr  $9, %eax		# convert into blocks
    inc  %eax            # add one for good measure
nextsector:
    inc  %cl			# advance by one sector
    mov  (disk_sectors),%al	# what is the maximum sector?
    cmp  %al, %cl		# is this the last sector?
    jle  loadsector		# no - load the next sector
    mov  $1,%cl			# yes - go to sector zero..
gotsectors:
    dec  %ax			# remove one block
    mov  %ax,(sectors_left)	# store the value
    cmp  $0, %ax			# are we done?
    je   loaddone		# yes - jump to bottom
loaddone:
    mov $(checkpoint_msg_c),%si		# Checkpoint C
    call bios_putstring

Kode 2-2. Checkpoint Ausgaben in `bootblock.S` Bootloader

.code16
.text
.global _start
_start:

.org KERNEL_SIZE_OFFSET
.global kernel_size
kernel_size:
	.long	_end-_start

bios_putstring:				# routine to print an entire string
***
bios_putchar:				# routine to print a single char
***
checkpoint_msg_a:
    .asciz "Checkpoint KS.1\r\n"

realstart:
    mov $(checkpoint_msg_a),%si		# Checkpoint A
    call bios_putstring

***

Kode 2-3. Checkpoint Ausgaben in `kernelcore.S` Kernelstart

Kernel wieder kompilieren, ISO bauen

Kernel in VM starten

Ergebnis: Checkpoints BL.1, BL.2, BL.3 werden erreicht, KS.1 nicht!

Der Kernel wird überhaupt nicht gestartet (oder?)! Was ist falsch hier? Wir sind im Nirvana.

Was passiert im Bootloader?

Er lädt den Kernel blockweise, ein Block sind hier 512 bytes, das Kernelimage (basekernel.img) ist aber rund 1 MB groß, d.h. mindestens 250 Blöcke müssten von dem virtuellen CDROM Laufwerk geladen werden.

Tatsächlich wird nur ein Block geladen!

Wieso? Woher weiß der Bootloader wie viele Blöcke er laden soll? Das steht am Anfang vom Kernelimage:

.org KERNEL_SIZE_OFFSET
.global kernel_size
kernel_size:
	.long _end-_start

Ist vielleicht die Angabe im Kernelimage falsch? Schauen wir mit einem Hex-Editor hinein:

00000000   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000010   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000020   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000030   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000040   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000050   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000060   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000070   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000080   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000090   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
000000A0   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
000000B0   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
000000C0   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
000000D0   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
000000E0   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
000000F0   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000100   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000110   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000120   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ................
00000130   00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00  ...............

Kode 2-4. Hex Editor Dump vom Beginn des Kernelimages. Lauter Nullen!?

Nur Nullen im Image - bis - halt stopp, da war doch etwas mit dem Kernelimage und dessen seltsame virtuelle Größe!

Wir schauen uns nun die Symboltabelle, d.h. die Adresstabelle des Kernels an ...

>> objdump -t kernel.elf
kernel.elf:     file format elf32-i386

SYMBOL TABLE:
080480f4 l    d  .note.gnu.property     00000000 .note.gnu.property
00010000 l    d  .text                  00000000 .text
0001e000 l    d  .rodata                00000000 .rodata
0001f1f0 l    d  .eh_frame              00000000 .eh_frame
00024000 l    d  .data                  00000000 .data
00024b80 l    d  .bss                   00000000 .bss
00000000 l    d  .comment               00000000 .comment

Kode 2-5. Auszug von `objdump -t kernel.elf` (Symbole und ihre Adressen)

Das vom Linker automatisch eingefügte und nicht benötigte Symbol .note.gnu.property wurde mit in das Image übernommen, hatte aber unabänderlich eine völlig andere Adresse (0x8000000) als die Kernelsymbole (0x10000). Das führte zu einem großen "Speicherloch" im Image und einem völlig falschen Aufbau mit Nullen gefüllt!

Lösung 1: objcopy -R .note.gnu.property

Lösung 2: Das Kleingedruckte lesen und den Hinweis beachten vorher einen Crosscompiler zu bauen und zu benutzen (der diesen Mist nicht macht).

Programmiersprache C

Warum C?

Wir haben an der beispielhaften Fehlersuche am häufigsten Speicher und Speicheradressen gesehen.

Die Programmiersprache bildet das lineare Speichermodell direkt ab (fast alles sind Speicheradressen)

Aber trotzdem sind C Programme (Quelltext) nicht auf einen Rechner oder eine Rechnerarchitektur beschränkt

Portabilität der Programme!

Besser schreib- und lesbar als Assembler (nicht portabel)

C ist eine Hochsprache mit statischer Typisierung, aber expliziten Speichermanagement (durch den Programmierer) und direkter Sichtbarkeit des Speichers und Speicheradressenoperationen (Pointer)

Aber trotzdem ist maschinennahe Programmierung (also die Programmierung der Maschine) möglich, gerade auch Gerätetreiber und Ein-und Ausgabe

                              ┌―――――――――――――┐ 80000
      ┌――――――――――――――――――――――▶│┌―――――┐      │ 79010
      │                       ││ var │      │ &var=79010
    ┌―┴┐                      │└―――――┘      │
┌――▶│25│ 30  35               │             │
│   └――┘ ▲   ▲                │  Heap       │
│        │   │                ├――――――――┬――――┤ 50000
│        │   └―――――――――┐      │ ▲      │    │
│        └――――――――――┐  │      │ │      ▼    │
│                   │  │      ├―┴―――――――――――┤ 40000
└―― int var=25;     │  │      │  Stack      │
    int *ptr=&var;  │  │      ├―――――――――――――┤ 30000
        *ptr=30;  ――┘  │      │  Data Init. │
    int **pptr=&ptr;   │      ├―――――――――――――┤ 20000
        **pptr=35;  ―――┘      │  Data Noinit│
                              ├―――――――――――――┤ 10000
                              │  Text       │
                              └―――――――――――――┘ 0

Kode 2-6. Speicherlayout in C / Pointervariablen

C History

UNIX History

Programmiersprache C: C2JS

Statisch kompiliert, nicht inkrementell (wie JavaScript, Python)

Aber mit dem C2JS Transpiler ...

               ┌――――――――┐   ┌―――――――――――┐
               │        │   │           │
┌――――――――┐     │ C      │   │ C-Analyzer│   ┌―――――――――┐
│C Source├――――▶│ Parser ├――▶│ JS-       ├――▶│ JS Code │
└――――――――┘     │        │   │ Generator │   └――――┬――――┘
               │        │   │           │        │
               └――――――――┘   └―――――――――――┘        ▼
                                    ┌―――┐  ┌―――――――――――┐
                            Heap    │DS │  │┌―――――――――┐│
                            Stack   │   ├――┼│ JS Exec ││
                            malloc  │   │  │└―――――――――┘│
              │             free    │   │  └―――――――――――┘ │
              │                     │   │   Sandbox Env. │
              │   JavaScript        └―――┘                │
              └――――――――――――――――――――――――――――――――――――――――――┘

Abbildung 2-2. C2JS Architektur

Programmiersprache C: Live Programming mit C2JS

/ide

size

──────────────────────────────────────────────────────────────────────────────────────
#include "clib.h"
int x=1;
int main () {
  printf("Hello World %d\n",x);
}
──────────────────────────────────────────────────────────────────────────────────────
Object.assign(CS,{"-1":"free","-2":"malloc","-3":"memcpy","-4":"printf","-5":"main"});
Object.assign(CSI,{"free":-1,"malloc":-2,"memcpy":-3,"printf":-4,"main":-5});
var x=x||__heap_allocate(1,4,"int");
DS.writeInt32(1,x);
function main() {
  var __sp=__stack_pointer();
  printf("Hello World %d\n",DS.readInt32(x));
  __stack_pointer(__sp);
};
main(argc,argv)
──────────────────────────────────────────────────────────────────────────────────────

Beispiel 2-1. (Oben) C Code (Unten) Transpillierte JS Code

Betriebssysteme - Eine Einführung

Alle Einheiten die für einen Rechnerbetrieb nötig sind, sind Ressourcen oder Betriebsmittel

Ein Betriebssystem ist die Gesamtheit der Programmteile, die die Benutzung von Betriebsmitteln steuern und verwalten.

Das Betriebssystem ist die Software (Programmteile), die für den Betrieb eines Rechners anwendungsunabhängig (unterstützend) notwendig ist.

Formale Definition Betriebssystem

Betriebssysteme sind also Programme und Software. Betriebssystementwicklung ist Softwareentwurf.

Im vorherigen Praxisbeispiel wurden schon einige Unterschiede zum klassischen Softwarenentwurf deutlich:

Zugriff auf Ein- und Ausgabegeräte (Betriebsmittel)

Direkte Maschinenprogrammierung

Speichermodell und Rechnerarchitektur

| Zentrale Ressourcen | Periphere Ressourcen
Aktive Ressourcen | Prozessor(en) | Kommunikationseinheiten:&br;1. Endgeräte (Tastaturen, Drucker, Anzeigen, Zeigegeräte etc.)&br;2. Netzwerk (entfernt, lokal) etc.
Passive Ressourcen | Hauptspeicher | Speichereinheiten:&br;1. Festkörper SDD&br;2. Platten HDD&br;3. Bänder Tape&br;4. CD-ROM/DVD etc.

Ressourcenklassen

Es gibt nicht das Betriebssystem schlechthin, sondern nur eine den Forderungen der Anwenderprogramme entsprechende Unterstützung, die von der benutzerdefinierten Konfiguration abhängig ist und sich im Laufe der Zeit stark gewandelt hat. Gehörte früher nur die Prozessor-, Speicher- und Ein-/Ausgabeverwaltung zum Betriebssystem, so werden heute auch eine grafische Benutzeroberfläche mit verschiedenen Schriftarten und -größen (Fonts) sowie Netzwerkfunktionen verlangt.

Betriebssysteme - Das Schichtenmodell (1)

[978-3-96088-839-0]

Abbildung 2-3. Das Nutzerschichtenmodell von Betriebssystemen

Betriebssysteme - Programme

Abbildung 2-4. Softwaregliederung

Die eigentlichen Steuerprogramme sind für folgende Funktionen zuständig:

Steuerung aller Computerfunktionen und Koordination der verschiedenen zu aktivierenden Programme.

Steuerung der Ein-/Ausgabeoperationen für die Anwendungsprogramme.

Überwachung und Registrierung der auf dem Computersystem ablaufenden Aktivitäten.

Ermittlung und Korrektur von Systemfehlern.

Auffallend bei dieser Definition ist die Einbeziehung von Übersetzern (Compiler, Binder), Testhilfen und Dienstprogrammen.

Für klassische Betriebssysteme (z.B. Unix und GNU-Tools) trifft dies vollumfänglich zu, während moderne Betriebssysteme oft die Bereitstellung von Übersetzungstools irgendwelchen Drittherstellern überlassen bzw. diese als separate Applikation ausliefern (z.B. Windows und Visual Studio).

Einordnung im Computersystem

Abbildung 2-5. In einem Rechner stellt das Betriebssystem eine Softwareschicht dar, die zwischen den Benutzerapplikationen einerseits und der Rechnerhardware andererseits liegt. [bsdp]

Programme zur Softwareentwicklung, wie Editoren und Compiler, können dazugehören.

Häufig wird nur der Betriebssystemkern als Betriebssystem bezeichnet, während der Begriff Systemprogramme für das Gesamtpaket inklusive der Programmentwicklungswerkzeuge benutzt wird.

Abstraktion von Hardwareelementen

Prozessor

Arbeitsspeicher (main memory)

Massenspeicher (mass storage), z.B. Festplatten, CD-ROM, DVD

Benutzerschnittstelle (user interface)

Kommunikations- und andere Peripheriegeräte (LAN, WLAN usw.)

Die Betriebssystemtheorie beruht damit auf den Prinzipien der Computertechnik. Computertechnik befasst sich mit:

Rechner-Grundmodellen (Von-Neumann-, Harvard-Architektur)

Funktionsweise des Prozessors (Instruktionssatz, Registeraufbau)

Speichern und ihren Realisierungen (Primär- und Sekundärspeicher)

Peripheriegeräten (Tastatur, Bildschirm, Schnittstellenbausteine usw.)

Betriebssystemarten

Wir unterscheiden klassisch:

Stapelverarbeitung (batch processing)

Typisches Merkmal ist, dass Programme angestoßen werden, aber ansonsten keine nennenswerte Benutzerinteraktion stattfindet. Die auszuführenden Befehle sind stattdessen in einer Stapeldatei abgelegt, deren Inhalt fortlaufend interpretiert wird. Klassische Großrechnerbetriebssysteme werden auf diese Art und Weise genutzt, z.B. zur Ausführung von Buchhaltungsprogrammen über Nacht.

Time-Sharing-Betrieb

Die zur Verfügung stehende Rechenleistung wird in Form von Zeitscheiben (time slices, time shares) auf die einzelnen Benutzer aufgeteilt mit dem Ziel, dass jeder Benutzer scheinbar den Rechner für sich alleine zur Verfügung hat. Historisch gesehen sind Time-Sharing-Systeme die Nachfolger bzw. Ergänzung der Batch-Systeme mit der Neuerung, dass sie Benutzer interaktiv arbeiten lassen (Dialogbetrieb).

Echtzeitbetrieb

Die Rechenleistung wird auf mehrere Benutzer oder zumindest Prozesse aufgeteilt, wobei zeitliche Randbedingungen beachtet werden. Oft sind Echtzeitsysteme reaktive Systeme, indem sie auf gewisse Signale aus der Umgebung (Interrupts, Meldungen) möglichst rasch reagieren.

Weitere Unterteilung (modern) von Betriebssystemen nach unterstützter Rechnerstruktur:

Einprozessorsysteme

Multiprozessorsysteme

Verteiltes System

Betriebssystemmodell

Es gibt zwei Arten von Betriebssystemmodellen:

Blackbox

Whitebox

Abbildung 2-6. Black- und Whitebox-Betrachtung (Beispiel: Unix) [bsdp]

Solange es lediglich um die Systemprogrammierung geht, ist eine Blackbox-Betrachtung des Betriebssystems ausreichend.

Nach außen ist damit nur die Programmierschnittstelle sichtbar, jedoch nicht das Systeminnere

Dies entspricht einem klassischen Ideal des Software Engineering, das aussagt, dass die Schnittstelle das Maß aller Dinge ist und die Implementierung dahinter beliebig austauschbar sein soll.

Aber: Hier ist interessant, unter die »Motorraumhaube« eines Betriebssystems zu gucken (Whitebox).

Entwurf von Betriebssystemen

Anforderungen

Fehlerfreiheit des Codes: z.B. durch minimale Komplexität des Quellcodes

Einfache Operationen (auf API und für alle Schnittstellen)

Erweiterbarkeit (extensibility)

Skalierbarkeit (scalability)

Orthogonalität: Operationen wirken gleich auf verschiedenartigen Objekten

Robuste Betriebsumgebung (»crash-proof«, »reliable«)

Einhaltung der Sicherheitsziele (mehrere Anforderungsstufen denkbar)

Portabilität (Unterstützung verschiedenartiger Plattformen)

Echtzeitfähigkeit (z.B. für Multimedia-Anwendungen)

Effizienz (schnelle Diensterbringung, minimaler Ressourcenbedarf)

Weiterentwickelbarkeit: Trennung von Strategie (policy) und Mechanismus (mechanism)

Betriebsmodus

Betriebssystemeprogramme sind wie Benutzeprogramme Programme aber mit verschiedenen Rechten und Rollen.

Diese Rechte und Rollen benötigen Hardwareunterstützung

Mikrokontroller sind einfache Mikroprozessoren (eingebettete Systeme) und bieten kaum oder gar keine Unterstützung für ein Betriebssystem. Sie bündeln Prozessor, Speicher, und Peripherie 8auf einem Chip)

Beispiele sind ESP32, ARM Cortex M0 STM32, Atmel ATMega (aktuell).

Einfache Universalmikroprozessoren bieten nicht viel mehr, nur dass hier keine Peripheriegeräte integriert sind.

Beispiels sind Motorolla 6800, 68000, Intel 8086 (ca. 1990)

Moderne leistungsfähige Universalmikroprozessoren bieten verscheiden Mechanismen um Betriebssysteme zu unterstützen.

Beispiele sind ARM v6/7/8, Intel 80386/Pentium usw., Sun Microsystems UltraSPARC usw.

MMU (Memory Management Unit) und Mechanis-men für einen privilegierten Betriebsmodus für die Systemsoftware (Privilegiensystem).

Betriebsmodus und Schutz

Bei Universalmikroprozessoren werden durch das Privilegiensystem kritische Operationen und Zugriffe geschützt, damit ein Programmierfehler in einem Anwendungsprogramm nicht das ganze Computersystem beeinträchtigt.

Insbesondere bei Multitasking-Anwendungen und Multiuser-Betrieb (mehrere gleichzeitige Benutzer) ist ein solcher Schutz erforderlich!

So wird in den meisten Betriebssystemen der Zugriff auf Hardwareteile mittels dieser Schutzfunktionen dem normalen Anwender (bzw. Benutzerapplikationen) verwehrt.

Dazu dienen unterschiedliche CPU-Betriebsarten

Minimales Zweiebenen Modussystem:

Kernmodus (kernel mode, supervisor mode) mit »allen Rechten« für Betriebssystemcode

Benutzermodus (user mode) mit »eingeschränkten Rechten« für Applikationscode

| Benutzermodus | Kernmodus
Ausführbare Maschinenbefehle | Begrenzte Auswahl | Alle
Hardwarezugriff | Nein bzw. nur mithilfe des Betriebssystems | Ja, Vollzugriff
Zugriff auf Systemcode bzw. Daten | Keiner bzw. nur lesend | Exklusiv

Vergleich zwischen Benutzer- und Kernmodus

Im Idealfall werden Schutzmechanismen auch für die Abschottung verschiedener Systemteile untereinander eingesetzt. Die Grenze des Sinnvollen ist allerdings darin zu sehen, dass eine teilweise lahmgelegte Systemsoftware aus Sicht des Anwenders oft nicht besser ist als ein Totalabsturz.

Monolithische Systeme

Die Struktur dieser Systeme besteht darin, dass sie keine oder nur eine unklare Struktur haben.

Abbildung 2-7. Beispiel für eine monolithische Betriebssystemstruktur [bsdp]

Meist handelt es sich um evolutionär gewachsene Betriebssysteme, bei denen es anfänglich unwichtig war, einzelne Teilfunktionen klar mit Schnittstellen voneinander abzugrenzen.

Schichtsysteme

Bei dieser Strukturierungsform sind die Betriebssystemfunktionen in viele Teilfunktionen gegliedert, die hierarchisch auf mehrere Schichten verteilt sind.

Abbildung 2-8. Beispiel einer geschichteten Struktur [bsdp]

Die Funktionen einer höheren Schicht bauen strikt nur auf Funktionen einer tieferen Schicht auf.

Die erste Schicht könnte eine Hardware-Abstraktionsschicht sein.

Die Festlegung der Schichtenstruktur folgt keiner Standardstruktur.

Mikrokernelsysteme

Nur die allerzentralsten Funktionen sind in einem (kleinen) Kernteil zusammengefasst, alle übrigen Funktionen sind als Serverdienste separat realisiert. Nachrichtenbasierte Kommunikation!

Abbildung 2-9. Mikrokern nach dem Client/Server-Prinzip [bsdp]

Serverdienste können z.B. Dateidienste, Verzeichnisdienste sein

Der Mikrokern enthält lediglich die vier Basisdienste:

Nachrichtenübermittlung (message passing)

Speicherverwaltung (virtual memory)

Prozessorverwaltung (scheduling)

Gerätetreiber (device drivers).

Da ein Großteil des Betriebssystemcodes auf die Benutzerebene (Benutzermodus) verschoben wird, sind überschaubare zentrale Teile des Systems durch den Kernmodus gegen fehlerhafte Manipulationen geschützt.

Ebenso ist es nur dem eigentlichen Kern erlaubt, auf die Hardware zuzugreifen.

Beansprucht ein Benutzerprozess einen Systemdienst, so wird die Anforderung als Nachricht durch den Mikrokern an den zuständigen Serverprozess weitergeleitet.

Entsprechend transportiert der Mikrokern auch die Antwort an den anfordernden Prozess zurück

Multiprozessorsysteme

Eine weitere Abstraktion und Schnittstellen sollten Betriebssysteme bei Multiprozessorrechnern anbieten.

Das Verarbeiten von Prozessen auf mehreren Prozessoren ist im Detail nicht so einfach und birgt eine Reihe von Problemen, die typische für Multiprozessverareitung sind.

Multiprozessorsysteme haben durch die Einführung von Multicore-CPUs eine große Verbreitung erfahren. Dabei teilen sich typischerweise alle Rechenkerne die Peripherie und den Hauptspeicher, weswegen man sie Shared-Memory-Multiprozessoren nennt.

Drei Betriebssystemtypen sind für diese Rechner denkbar:

Monosystem: Jede CPU hat ihr eigenes Betriebssystem: Der Speicher wird in Partitionen (pro CPU/Betriebssystem) aufgeteilt.

Asymmetrische Multiprozessoren (asymmetric multiprocessing, AMP): Das Betriebssystem läuft nur auf einer einzigen CPU (=Master), die Anwendungsausführung nutzt alle restlichen Prozessoren (=Slaves).

Symmetrische Multiprozessoren (symmetric multiprocessing, SMP): Nur eine einzige Kopie des Betriebssystems liegt im Speicher. Diese ist von jeder CPU ausführbar.

Monosystem

Am einfachsten ist die Lösung, dass jede CPU ihr eigenes Betriebssystem hat und der Speicher partitionsweise den einzelnen CPUs zugeteilt wird, womit die einzelnen Prozessoren unabhängig voneinander arbeiten.

Der Code des Betriebssystems ist nur einmal im Speicher abgelegt, da unveränderlich.

Infolge des fehlenden Lastausgleichs und der fixen Speicheraufteilung skaliert diese Lösung schlecht und hat deswegen keine nennenswerte Verbreitung.

Abbildung 2-10. Jede CPU hat ein eigenes Betriebssystem. [bsdp]

Asymmetrische Multiprozessoren

Die Variante der asymmetrischen Multiprozessoren weist alle Betriebssystemaktivität einer bestimmten CPU zu, die damit der Chef (Master) wird.

Die Anwenderprozesse laufen auf den restlichen CPUs, die man Slaves nennt .

Vorteilhaft ist die Möglichkeit der flexiblen Zuteilung ablaufwilliger Prozesse an die einzelnen Slaves. Der wesentliche Nachteil ist aber der Flaschenhals, der durch den Master entsteht, da alle Systemaufrufe nur von dieser CPU bearbeitet werden.

Es entstehen infolge des Flaschenhalses (Master) bereits bei einem System mit 8 Prozessoren signifikante Wartezeiten.

Es handelt sich also um eine Lösung, die nur für eine kleine Prozessoranzahl sinnvoll ist.

Die Skalierung ist also mäßig, die Auslastung typisch (N-1)/N% (gewünscht ist 100%. bei N=4 dann nur 75%)

Abbildung 2-11. Asymmetrisches (Master/Slave-)Multiprozessorsystem [bsdp]

Symmetrische Multiprozessoren

Bei symmetrischen Multiprozessoren ist das Betriebssystem genau einmal im Speicher, und zwar sowohl der Code als auch die Daten

Systemaufrufe können von allen CPUs ausgeführt werden. Da die Daten des Betriebssystems für alle gemeinsam zugreifbar sind, entfällt damit der Flaschenhals der Master-CPU.

Allerdings stellt sich damit auch das Problem des koordinierten Zugriffs auf die Systemdaten, um Dateninkonsistenzen zu vermeiden (kritische Bereiche).

Der einfachste Weg wäre der, dass zu jedem Zeitpunkt nur eine einzige CPU einen Systemaufruf ausführen darf (Spinlock).

Damit wäre das Flaschenhalsproblem aber in einer neuen Form wieder vorhanden und die Leistung limitiert.

In der Praxis genügt es aber, wenn die einzelnen Systemtabellen separat abgesichert werden.

Systemaufrufe auf unterschiedlichen Tabellen können dann parallel stattfinden.

Da viele Systemtabellen in verschiedener Beziehung voneinander abhängen, ist die Realisierung eines derartigen Betriebssystems sehr anspruchsvoll (z.B. Deadlock-Problematik).

Abbildung 2-12. Symmetrisches Multiprozessor-(SMP-)System [bsdp]

Verteilte Betriebssysteme

Parallels System

Starke Kopplung der Prozessoren, geteilter Speicher

Verteiltes System

Lose Kopplung der Prozessoren, privater Speicher

Verteilte Betriebssysteme nutzen eine Menge von über ein Netzwerk verbundenen Rechnern zur Lösung größerer Aufgaben und um eine gemeinsame Rechenplattform in größerem Rahmen zu realisieren.

Idealerweise wird das Betriebssystem so realisiert, dass Ortstransparenz herrscht.

Dies bedeutet, dass der Benutzer nur ein einziges System sieht (Single System Image, SSI), egal an welchem der teilnehmenden Rechner er sich momentan angemeldet hat.

Solche Betriebssysteme realisieren die rechnerübergreifende Kommunikation systemintern und unterstützen Mechanismen zum Lastausgleich zwischen den einzelnen Rechnern wie auch Ausfallredundanz.

Clustersysteme hingegen unterstützen die Ortstransparenz nur partiell oder gar nicht, womit sie partiell Fähigkeiten verteilter Betriebssysteme besitzen.

Abbildung 2-13. Von parallelen zu verteilten Betriebssystemen und Rechnern

Netzwerk Betriebssysteme

Nicht ganz ein verteiltes System

Es gibt lokalisierte und isolierte Betriebssysteme

Eine Software Applikation oder Mittelebenensoftware verbindet die Rechner

[https://www.slideserve.com/loc/distributed-operating-systems] %% HTML

Abbildung 2-14. NOS: Nur die Applikation ist verteilt

Abbildung 2-15. DOS: Betriebssystem und Applikation sind verteilt

Das WWW als Betriebssystem?

Durch die Einführung von "Middleware" Software können heterogene Betriebssystemlandschaften vernetzt werden und quasi verteilte Applikationen ermöglichen.

Abbildung 2-16. WWW als verteilte Anwendung durch HTTP und Web Browser (+ JavaScript + REST APIs und Web Server) [https://www.slideserve.com/loc/distributed-operating-systems]

Taxonomie der Betriebssysteme

Abbildung 2-17. Unterteilung nach Betriebsarten ergibt eine Terminologie [bspm]

Single- und Multitasking

Die Begriffe Singletasking und Multitasking sind eng verwandt mit den Begriffen Einprogramm- und Mehrprogrammbetrieb.

Singletasking

Im Singletasking-Betrieb ist nur ein Programm aktiv, das sämtliche Betriebsmittel des Systems nutzen kann. Die alten PC-Betriebssysteme wie MS-DOS unterstützen z.B. nur Singletasking.

Multitasking

Im Multitasking-Betrieb können mehrere Programme nebenläufig ausgeführt werden. Die erforderlichen Betriebsmittel werden nach verschiedenen Strategien (Prioritäten, Zeitscheibenverfahren) zuge-teilt. Die Zuordnung des Prozessors nach Zeitintervallen an die nebenläufigen Programme wird als Timesharing bezeichnet.

Terminal-, Teilhaber- versus Teilnehmerbetrieb

Im Hinblick auf die Programmnutzungsart unterscheidet man Teilhaber- und Teilnehmerbetrieb, wobei der Teilhaberbetrieb in der Regel eine Systemsoftware mit der Bezeichnung Transaktionsmonitor benötigt. Die Unterscheidung ist auf den ersten Blick nicht ganz einleuchtend und soll daher erläutert werden.

Teilnehmerbetrieb

Teilhaberbetrieb

Transaktionen und Transaktionsmonitore

Transaktion

Unter einer Transaktion wird hierbei ein atomar auszuführender Service verstanden, der entweder ganz oder gar nicht ausgeführt wird. Werden innerhalb der Ausführung einer Transaktion mehrere Operationen etwa auf einer Datenbank erforderlich, so darf dies nur in einem Stück erfolgen.

Teilnehmerbetrieb

Im Teilnehmerbetrieb erhält jeder Anwender seinen eigenen Benutzerprozess sowie weitere Betriebsmittel vom Betriebssystem zugeteilt.

Der Benutzer meldet sich über einen Login-Dialog beim System an und bekommt die Betriebsmittel dediziert zugeordnet.

Dies ist auch heute noch die übliche Vorgehensweise in modernen Betriebssystemen. Unter Unix und Windows arbeitet man üblicher- weise nach dem Login-Vorgang im Teilnehmerbetrieb.

Teilhaberbetrieb

Im Teilhaberbetrieb werden Prozesse und Betriebsmittel über einen Transaktionsmonitor zugeteilt.

Dies ist möglich, da die Benutzer aus Sicht des Betriebssystems die meiste Zeit „denken“, bevor eine Eingabe in das System erfolgt.

Die Betriebsart Teilhaberbetrieb ist ideal für dialogorientierte Programme mit vielen parallel arbeitenden Anwendern, die meistens kurze und schnelle Transaktionen ausführen, wie dies etwa bei einem Buchungssystem der Fall ist.

Terminalbetrieb

Abbildung 2-18. Typischer Terminalservereinsatz [bspm]

Ein Terminalserver verwaltet Terminals bzw. Client-Arbeitsplätze.

Sinn und Zweck von Terminalserver-Systemen ist es, die Administration verteilter Komponenten zu vereinfachen und besser kontrollieren zu können.

Eine zentrale Serverlandschaft, die aus großen Serverfarmen bestehen kann, bedient „dumme“ Clientrechner (sog. Thin Clients), wobei die Anwendungsprogramme vollständig in den Servern ablaufen und die Clientrechner nur noch für Zwecke der Präsentation eingesetzt werden.

Die Idee hinter Terminaldiensten ist die Zentralisierung von Betriebsmitteln, um die beteiligten Systeme leichter administrieren zu können.

Betriebssystemarchitektur: Unix

Abbildung 2-19. Interne Struktur des Unix (Kern des System V Release 3) [bsdp]

Betriebssystemarchitektur: MS Windows

Abbildung 2-20. Interne Struktur von Windows 10 [bsdp]

C Programmierung (Teil 1)

Die Grundlagen, um aus einem Quellcode mit einfachsten Mitteln eine ausführbare Datei zu generieren

Literatur

Jürgen Wolf, René Krooß

Grundkurs C

3., aktualisierte und überarbeitete Auflage 2020

C Compiler

Es gibt verschiedene Compiler. Folgende Auswahl sind bekannte und weniger bekannte C Compiler:

GCC aus GNU GCC

Der C-Compiler GCC aus der GNU Compiler Collection (kurz: GCC) wird auf vielen Plattformen verwendet und ist sehr beliebt, um neue Funktionen auszuprobieren. Unter Linux ist er für gewöhnlich die erste Wahl. Unter Windows steht die Portierung MinGW (Minimal GNU for Windows) zur Verfügung. Allerdings ist MinGW besonders unter Windows 10 nicht einfach zu installieren.

Clang der LLVM

Clang aus dem LLVM-Projekt ist der C-Standard-Compiler für die macOS-Entwicklung schlechthin, mit einer sehr guten Unterstützung für C11 und C18. Aber auch für Linux können Sie diesen Compiler kostenlos nachinstallieren.

Pelles C

Diese schlanke Entwicklungsumgebung für Microsoft Windows verwendet eine modifizierte und erweiterte Version des LCC-Compilers mit Unterstützung für C11 und C18 und dürfte somit die erste Wahl für die reine Programmierung in C unter Windows sein.

Microsoft Visual Studio

Diese Entwicklungsumgebung ist der Standard schlechthin für Windows-Anwendungen. Mittlerweile können Sie sogar Apps für macOS oder Smartphones erstellen. Visual Studio eignet sich nur bedingt für diesen Kurs (oder gar nicht), weil es für die einfachen Beispiele schlicht »überdimensioniert« ist und darüber hinaus vierstellige Beträge kostet. Eine Installation benötigt bis zu 10 GB Festplattenspeicher.

C Workflow und Toolchain

Der Workflow (und die Toolchain) ist i.A. nicht monolithisch, sondern besteht aus einer Vielzahl von einzelnen Schritten mit eigenen Programmen.

Abbildung 3-1. Typischer C Workflow: Präprozessor CPP → C Compiler CC → Assembler ASM → Linker LD

C Workflow

Präprozessor

Der Präprozessor ist eine Krücke um den C Programmcode anpassbar und konfigurierbar zu machen.

Die Präprozessorsprache ist unabhängig von C und bietet Makrodefinition und Substitution sowie konditionale Codeblöcke

Makros

#define X 1
#define PRINT2(a,b) \
        printf("%d:%d",a,b)
#define LINUX 1
int x=X;

Konditionale 1

#ifdef LINUX
 XXX
#endif

#ifdef LINUX
 XXX
#else
 YYY
#endif

Konditionale 2

#if (X > 0)
  #define XSIGN 1
#else
  #define XSIGN 0
#endif

#if (X > 0)
  unsigned int x;
#else
  signed int x;
#endif

Der C-JS Transpiler

In diesem Kurs wird C mittels eines C-JavaScript Transpilers geübt un erprobt.

Der C-JS Transpiler besteht aus folgenden Komponenten:

Ein gebündelter C Lexer und Parser (handwritten, Token und RegEx-basiert). Ausgabe ist ein Abstrakter Syntaxgraph.

Ein C-JS Generator mit Typprüfer. Ausgabe ist audführbarer JS Code.

Eine Ausführungsfunktion für den transpilierten Code.

Der generierte JS Code ist lesbar und kann eingesehen werden um ein tieferes Verständnis eines C Programms zu erhalten. Alles dreht sich um ein zentrales Speichermodell und Zugriff darauf.

Abbildung 3-2. Aufbau des C-JS Transpiler mit optionalen inkrementellen Kompilieren

Variablen und das Speichermodell

Die C Programmiersprache implementiert ein prozedurales und imperatives Programmiermodell mit zustandsbehafteten Variablen und zustandsänderndern Operationen inklusive Funktionen.

Daten werden durch Variablen verwaltet. Eine Variable ist gekennzeichnet durch:

Wert ε

Name name

Speicheradresse (Beginn) σ

Datengröße (Länge) δ

Eine Variable bezeichnet durch name befindet sich im Speicher Σ, formal ausgedrückt:

image-placeholder
(3-1)

Eine Vielzahl von Variablen können hintereinander oder beliebig verteilt im Speicher Σ platziert werden.

Es gibt keine Regel wie Variablen im Speicher angeordnet werden

Die Platzierung kann zur Kompilierungszeit oder zur Laufzeit erfolgen

Statische Speicherbelegung versa dynamischer

┌―――――――┐      ┌―――――――┐
│ x     │      │       │
├―――――――┤      ├―――――――┤
│ y     │      │ z     │
├―――――――┤      ├―――――――┤
│ z     │      │       │   int x,y,z;
├―――――――┤      ├―――――――┤
│       │      │ x     │
│       │      ├―――――――┤
│       │      │       │
│       │      ├―――――――┤
│       │      │ y     │
└―――――――┘      └―――――――┘

Kode 3-1. Zwei verschiedene Speicherallokationen für die Variablen x,y,z: Linear fortlaufend versa verstreut

Speichermodell C-JS Transpiler

DS

Das Datensegment welches den Heap (globale Variablen) und den Stack (lokale Variablen) beinhaltet.

CS

Anders als in C wo Funktionen Maschinenkode sind und im Speicher ebenfalls gespeichert werden, werden hier C Funktionen als normale JS Funktionen implementiert. Um Funktionszeiger (kommt noch) zu ermöglichen ist das Codesegment CS nur eine Indextabelle mit "virtuellen" Adressen von Funktionen, also eine Lookup Tabelle.

CSI

Reverse lookup Tabelle

Abbildung 3-3. Speichermodell des CJS Transpilers, DS: Datensegment, CS: Code Funktion Index Tabelle, CSI: Inverse CS Tabelle

Definition versa Deklaration

Definition

Einführung eines Bezeichners (Variable, Funktion) mit Speicherallokation und Festlegung des Datentyps oder der Typsignatur

Deklaration

Bekanntgabe eines Bezeichners (Variable, Funktion) ohne Speicherallokation und Festlegung des Datentyps oder der Typsignatur

Typdefinition

Einführung und Bekanntgabe eines benutzerdefinierten Datentyps (Structure, Enumeration)

1: int x;                          extern int x;
2: int foo(int x) { return x };    int foo(int x);
3:                                 extern int foo(int x);

Kode 3-2. (Links) Definition (Rechts) Deklaration

Symbole in C

Ein Symbol ist durch einen Namen (lexikalische Folge aus Textzeichen) und einer operationalen Semantik gekennzeichnet.

Es gibt folgende Symbolklassen:

Bezeichner (benutzerdefiniert)

Reservierte Schlüsselwörter

Literale

Begrenzer

Bezeichner in C

Bezeichner sind Namen für Objekte in einem Programm, die vom Programmierer festgelegt werden können, etwa Variablen, Funktionen usw. Für die Namen für gültige Bezeichner gelten folgende Regeln:

Namen können aus Buchstaben aus dem Basis-Ausführungszeichensatz, Ziffern und Unterstrichen bestehen.

Das erste Zeichen darf keine Zahl sein. Ein Bezeichner darf also nur mit einem Buchstaben oder einem Unterstrich beginnen. Zeichen wie @ oder der (Back-)Slash sind bei manchen Compilern nicht erlaubt.

Es wird zwischen Groß- und Kleinbuchstaben unterschieden (engl.: case sensitive). Somit sind fun, Fun und FUN drei verschiedene Bezeichner.

Als Bezeichner darf kein reserviertes Schlüsselwort von C verwendet werden.

1: int osMinorVersion₂=1;
2: int main(int x) { .. };
3: struct node { .. };

Reservierte Bezeichner

Auf Bezeichner, die mit zwei sequenziellen Unterstrichen oder einem Unterstrich, gefolgt von einem Großbuchstaben beginnen, sollte verzichtet werden, weil sie für C-Implementierungen reserviert sind.

Bezeichner wie __asdf sind für gewöhnlich für Compiler-Zwecke, Bezeichner wie _Asdf für Betriebssystem- und Bibliotheken gedacht.

Bei gcc kann die Liste von vordefinierten Bezeichnern (mit Werten) mittels echo | gcc -dM -E - ausgegeben werden:

__UINT8_MAX__   0xff
__unix          1
__INT_WIDTH__   32

Reservierte Schlüsselwörter

Reservierte Schlüsselwörter dürfen nicht als Bezeichner verwendet werden (auch nicht in Feld- und Attributenamen),

auto     break    case    char    const  continue   default  do       double
else     enum     extern  float   for    goto       if       int      long
register return   short   signed  sizeof unsigned   void     volatile  while

Datentypen

Wir unterscheiden folgende Datentypen in C:

Nicht nutzerdefinierbare Kerndatentypen (zunächst Skalarwerte):

Ganzzahlige Werte (long,int,short)

Gleitkommawerte (double,float)

Bytes und Textzeichen (char)

Referenzen auf Variablen und Funktionen, Pointer (*)

Benutzerdefinierte Aggregate

Datenstrukturen/Records (typedef, struct)

Arrays von Datenstrukturen oder Kerndatentypen (t[],* t)

Datentypmodifizierer

Vorzeichen (signed,unsigned)

Länge (long,short)

Boolescher Datentyp

In C gibt es keinen Booleschen Datentyp. Aber dieser kann einfach mit einer Ganzzahl implementiert werden. Dazu verwendet man eine Enumeration.

Eine Enumeration definiert eine Wertemenge die i.A. einen Bezeichner mit einem Ganzzahlwert verknüpft.

Enumerationen sind i.A. autonummeriert.

enum bool_vals {false,true};
typedef enum bool_vals bool;
bool b1=false;

Hier ist enum die eigentliche Enumeration, mittels typedef wird aus der Wertemenge bool ein neuer Datentyp.

Enumerationen

Eine Enumeration definiert eine Wertemenge die i.A. einen Bezeichner mit einem Ganzzahlwert verknüpft.

Der "echte" Datentyp einer Ennumeration ist i.A. Integer (int)

enum <ennumname> {
  C1,
  C2=<expressio>,
  ..
};

Definition 3-1. Eine Eumeration führt konstante benannte Werte ein

#define C1 0
#define C2 <expression>
...

Kode 3-3. Alternative mit Präprozessor Makrodefinitionen

Variabledefinition

<datentypmod> <datentyp> <name>;
<datentypmod> <datentyp> <name> = <initval>;

Begrenzer

Um einzelne Symbole voneinander zu trennen, werden sogenannte Begrenzer benötigt.

Begrenzer | Bedeutung
Semikolon (;) | Dient als Abschluss einer Anweisung. Jeder Ausdruck, der mit einem Semikolon endet, wird als Anweisung behandelt. Der Compiler weiß dann, dass hier das Ende der Anweisung ist, und fährt nach der Abarbeitung dieser Anweisung mit der nächsten fort.
Komma (,) | Mit dem Komma trennen Sie für gewöhnlich gleichartige Elemente, z. B. können Sie mehrere Variablen für Ganzzahlen so definieren: int minSp, maxSp, startSp;
geschweifte Klammern ({}) | Zwischen den geschweiften Klammern wird ein Anweisungsblock zusammengefasst. In diesem befinden sich alle Anweisungen (abgeschlossen mit einem Semikolon), die ausgeführt werden sollen.
Gleichheitszeichen (=) | Das Gleichheitszeichen = steht in C für eine Zuweisung, z. B. in int maxSpieler = 500;

Einfache Begrenzer in C

Speicherbedarf

Eine Variable belegt immer einen Teil des Datenspeichers, auch bei Zeigervariablen (kommen noch).

Speicherbedarf mit sizeof ermitteln

Wenn die Größe eines Typs oder einer Variable benötigt wird, wird der sizeof-Operator verwendet.

Dieser gibt in der Regel die Größe des Operanden in Bytes zurück und wird beispielsweise bei der dynamischen Speicherreservierung verwendet, oder wenn Sie Programme schreiben, die auf andere Plattformen portierbar sind.

Wie viel Speicherplatz ein Variablentyp letztlich benötigt, ist implementierungsabhängig.

int x;
int szx=sizeof(x);
int szint=sizeof(int);

Wertebereiche von Datentypen

Alle Datentypen können anders als in der Mathematik nur eine diskrete und endliche Menge von Werten darstellen (kodieren).

[num2017]

%% FIGURE [] Wertebereiche von Ganzzahl- und Gleitkommazahlen in C. Die Bytereihenfolge ist prozessor- und betriebssystemabhängig!

Konstanten

Benötigen Sie einen unveränderbaren Wert, können Sie eine Konstante verwenden.

Der Sinn und Zweck einer solchen Konstanten ist es, dass der Wert zur Laufzeit des Programms nicht mehr verändert werden kann.

Das Gegenstück zu einer Konstanten ist eine Variable.

Eine Konstante definiert man, indem vor den eigentlichen Datentyp das Schlüsselwort const gesetzt wird (Typqualifikator als Modifizierer, als Alternative zur Makrowertdefinition für den Präprozessor)

#define N2 100
const int N1 = 100;
int x1 = N1;
int x2 = N2;

Vorteil: "read only" Semantik, der Wert von konstanten "Variablen" kann zur Kompilierungszeit substituiert werden und benötigt keinen (Heap/Stack) Speicher.

Lebensdauer und Sichtbarkeit von Variablen

Die Lebensdauer einer Variablen gilt immer bis zum Ende des Anweisungsblocks (Bezeichnerkontext, Scope), in dem sie definiert wurde. Dazu ein einfaches Beispiel:

int x=1,y=2;
if (x>0) {
  int x=2;
  printf("x=%d y=%d\n",x,y);
}
printf("x=%d y=%d\n",x,y);

Die Variable x gibt es tatsächlich zweimal.

Am Ende vom Blockkontext ist nur der äußere Kontext sichtbar, d.h. hier die ersten x und y Variablen, im tieferen Kontext wird x überlagert (überschattet), aber y kann aus dem äußeren Kontext verwendet werden.

Lokaler Blockscope

Referenz- versa Wertsemantik

Wir unterscheiden:

Wertsemantik ⇒ Eine Variable liefert immer ihren Wert, aber nicht ihre Kontainereferenz

x=100
function foo(u) {
  u=200
}
foo(x)
// x==100!

Referenzsemantik ⇒ Eine Variable wird über ihre Referenz ihres Kontainers (oder Wertes), also letztlich einer Speicheradresse benutzt.

x=100
function foo(reference u) {
  u=200
}
foo(x)
// x==200!

Die Unterscheidung beider Semantiken kann verwirrend sein. Insbesondere in C. Hier unterscheiden wir Wert- und Zeigervariablen.

Das pardoxe in C ist aber: Auch Zeigervariablen (werden noch eingeführt) unterliegen der Wertsemantik. In C gibt es nur Wertsemantik!. Das ist i.A. typisch für statisch typisierte Programmiersprachen.

Dynamisch typisierte Programmiersprachen können teils wert- teil referenzbasiert sein.

Welche Programmiersprachen unterstützen echte Referenzsemantik bei Funktionsparametern?

Kontrollanweisungen

Bedingte Verzweigung

if (ε) {
  ..
}
if (ε) {
 ..
} else {
 ..
}

Mehrfachauswahl

switch (ε) {
  case c1:
    ..
    break;
  case c2:
    ..
    break;
  case c3:
    ..
    break;
  default :
    ..
}

Schleifen

for(i=ε;i<ε;i=ε(i)) {
  ..
}
while (ε) {
  ..
}
do {
  ..
} while (ε)

Schleifen

Man unterscheidet:

Zählschleife for(start;condition;update) mit drei integrierten Anweisungen: Einer Initialisierung (einer oder mehrere Zählvariablen), einer Schleifenbedingung die vor jedem Durchlauf getestet wird (i.A. der Zählvariable), und einer Update Anweisung die nach jedem Schleifendurchlauf ausgeführt wird (i.A. Veränderung der Zählvariable).

Abweisende Schleife while(condition) die abhängig von der Schleifenbedingung niemals, einmal oder mehrmals den Schleifenkörper ausführt (Test vor Schleifendurchlauf).

Eine nicht abweisende Schleife do {} while(condfition) die mindestens einmal den Schleifenkörper ausführt (Test nach jedem Schleifendurchlauf).

Beispiele von Kontrollanweisungen

Goto Hell

Goto ist eine reine Sprunganweisung zu einem symbolischen Label an anderer Stelle im Programm (unbedingter Sprung).

Goto ist ein Relikt aus alten (Assembler) Zeiten und reduziert die Lesbarkeit und erhöht die Fehlerwahrscheinlichekit von Programmen.

Goto kann vorwärts oder rückwärts im Programmfluß verzweigen.

Goto sollte nur sparsam (wenn überhaupt) verwendet werden und nut vorwärts verzweigende.

Modernes (vorwärts) Goto: Exceptions!

Aber: Goto kann die Lesbarkeit und Nachvollziehbarkeit von Programmen auch erhöhen wenn es keine Exceptions gibt, wie das in C der Fall ist.

Goto Heaven

Die Bearbeitung von Ein-Ausgabe Aufgaben, d.h. die Kommunikation mit Peripheriegeräten, ist i.A. mehrschrittig.

Bei den einzelnen Schritten kann es zu Fehlern kommen (z.B. Timeout).

Nach jeder Teiloperation wird der Status abgefragt, und wenn ein Fehler aufgetreten ist dann wird zum Ausgang der Hauptoperation und einer Fehlerbehandlung verzweigt.

Goto nur als Ersatz für Exceptions benutzen.

Eine Ausnahme: Virtuelle Machinen und deren Instruktionsinterpreter. Hier kann die Verwendung von Goto vorwärts und rückwärts sinnvoll sein.

#define EOK 0
#define EIO -1
int IOOP(...) {
  int status;
  status=dev_open(...);
  if (status!=0) goto error;  // raise Error
  status=dev_read(...);
  if (status!=0) goto error;  // raise Error
  status=dev_write(...);
  if (status!=0) goto error;  // raise Error
  status=dev_close(...);
  if (status!=0) goto error;  // raise Error
  return EOK;
error:
  dev_close(...);  // handle Error locally
  return EIO;  // raise EIO
}

Algorithmus 3-1. Teilschrittige Ausführung einer EA Operation mit Fehlerbehandlung

Goto Earth

Der C2JS Transpiler kann kein Goto unterstützen da 1. JavaScript (zu Recht) kein Goto unterstützt und 2. C Anweisungsblöcke als JS Anweisungsblöocke implementiert werden (und dann wieder 1.).

Um C Goto zu unterstützen bräuchte man eine VM Loop die adressierte (Bytecode) Instruktionen verarbeitet - haben wir hier aber nicht.

Eine Krücke wäre nur die o.g. Struktur in eine try-catch-raise Struktur umzusetzen.

Analysiere den Kernel vom basekernel BS auf die Verwendung von goto (Üungsaufgabe)

Funktionen

Eine Funktion besteht aus einem Namen, einer (optionalen) Liste von Funktionsparametern, dem Rückgabetyp, und dem Funktionsrumpf aus Anweisungen.

Der Funktionsrumpf ist ein Blocklevel Scope und kann neue lokale Variablen einführen, aber keine weitere Funktionen oder Typdefinitionen.

==== Definition ====
<rettype> <name>(<partype> <parname>,...) { ... };
==== (Vorwärts)Deklaration ====
extern <rettype> <name>(<partype> <parname>,...);
==== Aufruf ====
<name>(<argexpr>,<argexpr>,..)

Definition 3-2. Funktionsdefinition in C

Beispiele von Funktionen

C Programmierung (Teil 2)

Datenstrukturen

Aggregationen

Zeiger und Speichereferenzen

Datenstrukturen

C ist eine prozedurale und speicherorientierte Programmiersprache, d.h. Daten und Funktionen werden getrennt formuliert.

Wie einzelne Variablen definiert und benutzt werden wurde bereits eingeführt

Mit Rekords werden einzelne Variablen zu benannten Datenstrukturen zusammengefasst

Eine Definition einer Datenstruktur ist eine Typedefinition, und es können Variablen von diesem Typ erstellt werden.

struct <sbezeichner> {
  <datentyp> <ebezeichner>;
  <datentyp> <ebezeichner>;
  ..
};
typedef struct <sbezeichner> <tbezeichner>;
struct <sbezeichner> ds1;
<tbezeichner> ds2;

Definition 4-1. Datenstrukturen in C. Die Deklaration eines Strukturtyps fängt immer mit dem Schlüsselwort struct an und enthält eine Liste von Elementen in geschweiften Klammern.

sbzeichner: Name der Datenstrukur (Datentyp ist noch struct <sbezeichner>

ebezeichner: Name eines Datenstrukturfelds (Element)

tbezeichner: Name eines Datentyps (hier als Ersatz für struct <sbezeichner>)

Die Größe des Datenebereichs im Speciher für eine Datenstruktur ist die Summe alle Datenbereiche der einzelnen Strukturelemente

struct {        ┌――――――――┐  addr
  char a; ――――▶ │        │
                ├――――――――┤  addr+1
  int b;  ――――▶ │        │
                │        │
                │        │
                │        │
                ├――――――――┤  addr+5
  char c; ――――▶ │        │
                ├――――――――┤  addr+6
  float d;――――▶ │        │
                │        │
                │        │
                │        │
                │        │
                │        │
}               └――――――――┘

Definition 4-2. Lineares Speichermodell einer Datenstruktur

Folgende Operationen sind mit Strukturvariablen erlaubt:

Zuweisen einer Struktur an eine andere Struktur mit demselben Typ (aber nur als Speicherobjekt und Speicherkopie) mit Ausnahme von Zeigern und Funktionsargumenten

Übergabe von Strukturen an eine Funktion und Verwenden von Strukturen als Rückgabeparameter (Strukturen werden immer als Referenz, d.h. die Startadresse, übergeben)

Ermitteln der Adresse einer Struktur oder eines Elements mit dem Adressoperator &

Ermitteln der Größe einer Struktur mit dem sizeof-Operator und Speicher mit dem Alignof-Operator anordnen (seit C11)

Selektieren einzelner Komponenten über den Punktoperator s.e,sowohl LHS als auch RHS.

Datenstrukturen als ganzes sind immer als Pointer zu betrachten.

Werden Datenstrukturen als Argument and Funktionen übergeben so geschieht dass immer als Speicherreferenz uns es wird keine Wertkopie erstellt!

D\.h. aber nicht das Variablen von Datenstrukturen immer als Pointer angesehen werden, wie nachfolgendes Beispiel zeigt.

struct s {
  int x;
  int y;
};
struct s ds1,ds2;
// falsch: memcpy(ds2,ds1,sizeof(ds1));
// richtig mit Umwandlung in einen Pointer, als
memcpy((void*)&ds2,(void *)&ds1,sizeof(ds1));
// richtig!
int foo(struct s ds) {
  return ds.x;
}
.. foo(ds1) ..

Beispiel 4-1. Verwendung von Datenstrukturen: 1. mit expliziter, 2. impliziter Pointer Wandlung.

Zeiger

Eines der wichtigsten Themen in C: Zeiger(variablen).

Zeiger werden auch Pointer genannt und stellen einen Verweis auf eine bestimmte Speicheradresse dar, in der z.B. ein Wert steht.

Ist die Zeigerarithmetik erst einmal verstanden, sind auch fortgeschrittene Themen keine so große Hürde mehr.

Es folgt ein kleiner Überblick über die wichtigsten Dinge, die man mit Zeigern realisieren kann:

Speicherbereiche können dynamisch zur Laufzeit reserviert, verwaltet und wieder gelöscht werden.

Mit Zeigern können Sie Datenobjekte per Referenz an Funktionen übergeben.

Mit Zeigern lassen sich Funktionen als Argumente an andere Funktionen übergeben.

Komplexe verkettete Datenstrukturen wie Listen und Bäume lassen sich ohne Zeiger gar nicht realisieren.

Es lässt sich ein typenloser Zeiger (void *) definieren, mit dem Datenobjekte beliebigen Typs verarbeitet werden können, auch Funktionen!

Achtung: Zeiger sind bei der Definition uninitialisiert! Gefahr fehlerhafter Speicherzugriffe!

                              ┌―――――――――――――┐ 80000
      ┌―――――――――――――――――┐     │             │
      │                 │     ├―――――――――――――┤ 79010
    ┌―┴┐                └――――▶│ var (25)    │ &var=79010
┌――▶│25│ 30  35               ├―――――――――――――┤
│   └――┘ ▲   ▲                │             │
│        │   │                ├―――――――――――――┤ 50000
│        │   └―――――――――┐      │ ptr (79010) │ &ptr=50000
│        └――――――――――┐  │      ├―――――――――――――┤
│                   │  │      │             │ 40000
└―― int var=25;     │  │      │             │
    int *ptr=&var;  │  │      ├―――――――――――――┤ 30000
        *ptr=30;  ――┘  │      │ pptr (50000)│
    int **pptr=&ptr;   │      ├―――――――――――――┤ 20000
        **pptr=35;  ―――┘      │             │
                              │             │ 10000
                              │             │
                              └―――――――――――――┘ 0

Abbildung 4-1. Der Zusammenhang zwischen Wert- und Zeigervariablen. Das Widersprüchliche ist: Zeiger sind auch Wertvariablen, und obwohl sie mit einen ausgwiesenen Datentyp erzeugt werden sind sie doch immer vom gleichen Datentyp (z.B. long oder int)

Zeiger stellen lediglich die Adresse und den Typ eines Speicherobjekts dar. Die Definition eines solchen Zeigers sieht wie folgt aus:

<Datentyp> *<pbezeichner>;
*<pbezeichner> = ε(*<pbezeichner>; // Ausdrücke mit Pointern
<pbezeichner> = &<vbezeichner>;

Definition 4-3. Definition von Zeigervariablen von einem bestimmten Zieldatentyp, Indirektionsoperationen, und Adressierung.

Am Stern \* zwischen Datentyp und Bezeichner kann man den Zeiger erkennen. Der Name ist der Bezeichner und wird als Zeiger auf einem Typ Datentyp deklariert.

Zeiger enthalten Adressen auf Speicherobjekte. Man braucht einen Dereferenzierungs- oder Indirektionsoperator * in Ausdrücken um an den Wert des referenzietren Speicherobjekts zu gelangen bzw. den Wertcontainer. Dei Adresses einer Variable (auch von Zeigern selbst!) erhält man durch den Adressoperator &.

Zeiger als Funktionsparameter

Mit Zeigern kann man Referenzsemantik in Funktionsaufrufen simulieren:

void foo(int x, int *y) {
  *y=x*2;
}
int main() {
  int x,y;
  x=2;
  foo(x,&y);
  printf("%d %d\n",x,y);
}

Beispiel 4-2. Referenzsemantik für Funktionsargumente mit Pointern

Sowohl Wertvariablen als auch Zeigervariablen können über Zeiger (d.h. Adressenwerte) an Funktionen übergeben werden und dort verändert werden können

Abbildung 4-2. Beispiel von Zeigern als Funktionsargumente [www4.cs.fau.de]

Zeiger als Rückgabewert

Man kann auch Speicheradressen aus Funktionen zurückgeben (also Werte von Zeigern).

Hier muss auf das Speichersegment geachtet werden aud dem die Speicheradresse stammt. Nur Heap Adressen dürfen aus einer Funktion "hochgereicht" werden, niemals Stack Adressen.

Dynamische Speicherallokation

Will man zur Laufzeit neue Speicherobjekte (vor allem Strukturen und Arrays) erzeugen, muss man dazu einen Speicherbereich auf dem Heap reserviren.

Dazu wird die <addr>=malloc(<numbytes>) Funktion verwendet.

Der Speicherbereich enthält noch keine gültigen Daten, sondern kann alle mögliche (Byte)Werte enthalten. Einige malloc Implementierungen (Betriebssystem!) füllen den Speicherbereich mit Nullen. Kostest Laufzeit, aber verhindert Datenlecks und "Spionage", wenn der Speicherbereich zuvor von einem anderen Prozess benutzt wurde.

Anders als Speicherobjekte die auf dem Stack reserviert wurden benötigt der Heap explizite Freigabe von nicht mehr benötigten Speicherbereichen mittels der free(<addr>) Funktion.

Allokation und Freigabe müssen immer paarweise verwendet werden (wobei bei Beendigung eines Programms der gesamte belegte Speicher aber freigegeben wird).

Der NULL-Zeiger

Den Indirektionsoperator darf man natürlich nur verwenden, wenn der Zeiger eine gültige Adresse enthält.

Wurde dem Zeiger keine gültige Adresse zugewiesen und wird der Indirektionsoperator trotzdem verwendet, wird das Programm vermutlich aufgrund einer Speicherzugriffsverletzung (segmentation fault) abstürzen oder im schlimmsten Fall sogar weiterlaufen und unvorhergesehene Dinge auslösen.

In der Praxis kann man viele Fehler vermeiden, wenn ein (vorerst) nicht verwendeten Zeiger zunächst mit NULL (0) belegt, also gleich bei der Definition initialisieren und einen Zeiger vor jeder Verwendung überprüfen.

NULL ist ein Zeiger, der keine gültige Adresse verwendet, sondern meist einfach auf die Adresse 0 zeigt.

Globale Zeiger oder Zeiger, die mit static gekennzeichnet wurden, werden automatisch mit dem NULL-Zeiger initialisiert.

Lokale Zeiger in einem Anweisungsblock enthalten dagegen ein beliebiges und somit undefiniertes Bitmuster.

Das Problem mit nicht oder falsch initialisierten Zeigern ist ein ungültiger Speicherzugriff.

In vielen Betriebssystemen wird im virtuellen Speicher die Adressse 0 (oder der erste Block, z.B. 0-511) nicht belegt. Ein Speicherzugriff auf die 0-Adresse wird als Fehler erkannt.

Aber eine andere ungültige Adresse (ungleich 0) wird nicht als fehlerhaft erkannt und kann zu "Spätfolgen" bei der Benutzung führen (Überschreibung von anderen Variablen usw.).

Der Funktionszeiger

Obwohl Funktionen in C keine Werte erster Ordnung sind (also nicht an Funktionen als Argumente übergeben werden können), kann man sich wieder mit Zeigern auf Funktionen behelfen.

Die Definition und Verwendung von Funktionszeigern ist in ihrer Schreibweise etwas verwirrend.

int foo(int x) { return x*2 };
int (*fooPtr)(int);
int main() {
  fooPtr=&foo;
  printf("%d\n",foo(2));
  printf("%d\n",(*fooPtr)(2));
}

Beispiel 4-3. Funktionszeiger

Zeiger können alsi auf Funktionen verweisen. Sie verweisen dann auf die Anfangsadresse des Codes in der Funktion. Es sind hierbei zusätzliche Klammern nötig. Ein solcher Zeiger kann wie folgt erstellt werden:

<Rückgabetyp> (*funktionsZeiger)(<formale Parameter>);

Ohne die Klammerungen von (\*funktionsZeiger) hätte man lediglich den Prototyp einer Funktion und keine Definition eines Zeigers erstellt.

Der Name der Funktion wird dann implizit in einen Zeiger auf diese Funktion umgewandelt.

Aufrufen kann man die Funktion über Zeiger mit:

( *funktionsZeiger ) (parameter);

womit der Funktionszeiger explizit dereferenziert wird.

Im C2JS Transpiler werden Funktionen aber als JS Funktionen implementiert. "Speicherzeiger" auf Funktionen sind daher nicht möglich. Stattdessen werden negative Adressen als ein Funktionsindex in einer (globalen) Funktionstabelle verwendet um Funktionen referenzierbar zu machen!

CS={"-1":"free","-2":"malloc","-3":"memcpy","-4":"printf",
    "-5":"foo","-6":"main"}
CSI={"free":-1,"malloc":-2,"memcpy":-3,"printf":-4,
     "foo":-5,"main":-6}
DS.writeInt32(CSI["foo"],fooPtr);
printf("%d\n",eval(CS[DS.readInt32(fooPtr)])(2));

Beispiel 4-4. JS Code vom fooPtr C Code unter Verwendung von Funktionstabellen

Arrays

Arrays dienen dazu, mehrere Variablen gleichen Typs zu speichern. Bei Arrays werden hierzu die einzelnen Elemente als eine Folge von Werten eines bestimmten Typs im Speicher linear abgelegt.

Wir müssen unterscheiden:

Statische Definition und Allokation von Arrays ⇒ Aggregatvariablen

Dynamische Allokation ⇒ Zeigervariablens + malloc

Statische Definition

<Datentyp> <Arrayname> [ <Anzahl_der_Elemente N> ];
<Datentyp> <Arrayname> [ <N> ][ <M> ] ..;

Definition 4-4. Definition einer Aggregatvariable mit Speicherallokation. Der gesamte belegte Speicherbeich ist *N* *sizeof*(*elementtype*), sowie mehrdimensionale Arrays.

Speicherlayout

Auch mehrdimensionale Arrays werden im Speicher linear abgelegt. Es gibt eine Indexreihenfolge, typischerweise:

Führender Index sind die Spalten (also hier der zweite Index), nachfolgend der Zeilenindex (hier der erste Index).

RGB Bildmatrizen weichen ab, hier ist der Farbindex führender Index, gefolgt von Spalten und dann Zeilen.

Abbildung 4-3. Speicherlayout bei zweidimensionalen Matrizen

<Datentyp> <Arrayname> [ <Anzahl_der_Matrizen Z> ]
                       [ <Anzahl_der_Zeilen Y> ]
                       [ <Anzahl_der_Spalten X> ];

Abbildung 4-4. Bei dreidimensionalen Arrays ist der Tiefenindex führend, dann folgen Zeilen- und Spaltenindex.

Arrayzugriff

Arrayelemente (von Aggregatvariablen und Zigervariablen) werden inidiziert.

Um einzelnen Array-Elementen einen Wert zu übergeben oder Werte daraus zu lesen, wird der Indizierungsoperator [] (auch Subskript-Operator genannt) verwendet, bei mehreren Dimensionen je ein Indizierungsoperator pro Dimension.

Abbildung 4-5. Arrayzugriffe über den Index werden nicht geprüft. Ein zu großer Index führt beim Lesen zu fehlerhaften Werten, beim Schreiben zu einer Kollision mit einer anderen Variable, und ggfs. zu einem Speicherschutzfehler (z.B. Lesen/Schreiben über Segmentgrenzen hinaus).

Initialisierung mit einer Initialisierungsliste

Ein Array (vor allem kleine Arrays) kann bereits bei der Definition mit einer Initialisierungsliste initialisiert werden. Hierbei wird bei der Definition eine Liste von Werten in geschweiften Klammern, getrennt durch Kommata, angegeben.

<Datentyp> <Arrayname> [ <Anzahl_der_Elemente N> ] =
  { <Wert El1>, <Wert El2> , .. , <Wert ElN> };

Definition 4-5. Initialisierung von Arrays. Die Angabe der Anzahl der Elemente kann auch weggelassen werden, ergibt sich aus der Werteliste. Die Anzahl der Elemente in der Werteliste darf aber kleiner als ein explizit angegebenes *N* sein.

Dynamische Arrayvariablen

Anstelle einer Aggregatvariable type name[N] kann auch eine Zeigervariable type *name definiert werden.

Hier muss dann explizit Speicher reserviert werden.

<Datentyp> *<Arrayname>;
..
  <Arrayname>=(<Datentyp> *)malloc(N*sizeof(<Datentyp>));
..

Definition 4-6. Definition einer Zeigervariable für Arrays mit Speicherallokation. Ein Array wird daraus erst durch die Speicherbelegung.

Lineare Arrays können über Zeiger nur eindimensional indiziert werden da die Dimensionsinformation fehlt, anders als bei statisch definierten Arrays.

Adressrechnung

Eindimensionale Arrays [i] mit Definition <eltype> x[I]:

image-placeholder
(4-1)

Zweidimensionale Arrays [i][j] mit Definition <eltype> x[I][J]:

image-placeholder
(4-2)

Multidimensionale Arrays [i1][i2].. mit Definition <eltype> x[I1][I2].. und Dimension d:

image-placeholder
(4-3)

Zeigerarrays

Wir haben festgestellt dass mehrdimensionale Arrays nicht dynamisch linear erzeugt werden können bzw. man selber explizit die Indexberechnung gemäß vorheriger Gleichungen (ohne Datentypmultiplikation) durchführen.

Eine Möglichkeit multidimensionale Arrays zu erstellen und zu nutzen sind Zeigerarrays, d.h. die Partitionierung einer lineare Tabelle in einzelnen verkettete Tabellen.

Bei Zeigerarrays kann man dann wieder multidimensionale Indizierung durchführen!

Es werden als Zeigertabellen verwendet um weitere Zeigertabellen oder die Wertarrays zu referenzieren.

int **xp;

int main() {
  xp=(int **)malloc(10*sizeof(int *));
  for(int i=0;i<10;i++) {
   xp[i]=(int *)malloc(10*sizeof(int));
  }
  for(int i=0;i<10;i++)  {
    for(int j=0;j<10;j++) {
      xp[i][j]=i+j;
    }
  }
}

Beispiel 4-5. Zeigerarrays von Arrays, so z.B. auch `main(char **argv)`

Zeigerarithmetik

Jetzt kann es verwirrend werden. Zeigerarihmetik ist einfach eine arithmetische Operation wie Addition die auf die Speicheradressen angewendet wird. Aber durchaus anders als erwartet.

Numerische Addition von Werten (1:=1):

int x=1;
x=x+1;
x++;
print(x) // x==3;

Numerische Addition von Zeigern immer in Datentypgrößeneinheiten (Basistyp des Zeigers, d.h. 1:=sizeof(typ)):

int *x=1
x=x+1;
x++;
print(x) // x==9 !!!!;

int *xp;
..
  int *xpp;
  xp=(int *)malloc(100*sizeof(int));
  xpp=xp;
  printf("%d==%d\n",xpp,xp);
  for(int i=0;i<100;i++) {
   *xpp=i;
   xpp++;
  }
  printf("%d!=%d\n",xpp,xp);
  xpp=xp;
..

Beispiel 4-6. Arrayzugriff über dynamische Zeiger

Zeichenketten

Zeichenketten (Strings) sind in C utf8 oder ASCII kodierte char Arrays.

Da keine Länge gespeichert wird muss eine Zeichenkette immer mit einem NULL Zeichen (Ganzzahlwert 0) abgeschlossen werden.

Ansonsten übliche Arrays

char message[]="Hello World";
char *m;
int main() {
  m=(char *)malloc(100);
  memcpy(m,message,strlen(message)+1)
  pritnf("%s %s\n",message,m);
}

Beispiel 4-7. Zeichenketten in C

Zusammenfassung

Aggregate sind:

Mehrsortige Datenstrukturen (Records, Structures)

Einsortige Datenstrukturen (Arrays)

Structures bestehen aus Feldelementen die über Namen referenziert werden (Namenentliche Indizierung)

Arrays bestehen aus Feldelementen die über einen numerischen Index (Beginn imm 0) refrenziert werden (Numerische Indizierung)

Es gibt ein- und mehrdimensionale Arrays, die auch bei der Definition mit Werten initialisiert werden könne

Man unterscheidet statisch und dynamisch definierte Arrays

Es gibt ein 1:1 Speichermodell

Zeiger sind Wertvariablen die aber Speicheradressen enthalten

Zeiger können auf alle anderen Variablen sowie Funktionen zeigen

Zeigerarithmetik findet in Vielfachen der Datengröße des Basistyps statt

Dateien und Dateisysteme

Die Datei und ihr Speicherabbild, Struktur und die operationale Schnittstelle

Das Dateisystem als Abstraktionsmodell von Masenspeicherns und als Organisationsstruktur

Abbildung von logischen Dateisystemstrukturen auf physische Datenträger

Dateisysteme erlauben die persistente, d.h. dauerhafte Ablage von Anwender und Systemdaten sowie ausführbaren Programmen. Wir betrachten zuerst die logische Organisation abgelegter Daten und die für den Zugriff benötigte Dateisystemschnittstelle.

Dem folgen einführende Überlegungen zu Dateisystemimplementierungen und Details zu den Dateisystemen UFS, FAT, NTFS und ZFS. Dies wird ergänzt durch Betrachtungen zu Netzwerkdateisystemen und ausgewählten Dateisystemtechnologien, wie protokollierende Dateisysteme, Schattenkopien und Disk Scheduling.

Das Speichermodell eines Rechners

Wir haben in einem Rechner i.A. verschiedene Schichten von Speichern die hierarchisch aufgebaut sind:

Prozessorregister

Cache

Primärspeicher

Hauptspeicher ⇒ Kurzzeitspeicher

Sekundärspeicher

Festplatten, CDROM, USB Flash EEPROM usw ⇒ Langzeitspeicher

Die Datei

Abbildung 5-1. Der Speicher eines Rechners ist unterteilt in Primärspeicher und Sekundärspeicher. In der Speicherhierarchie steht der Sekundärspeicher weiter vom Prozessor als Primärspeicher.

Eine Datei entspricht zunächst einem Datenkontainer mit einem linearen Speichermodell (wie ein Array).

Die Datei ist in Speicherzellen unterteilt, i.A. ein Byte

Die Speicherzellen werden indiziert, i.A. hat das erste Byte den Index Null, und allgemein I={0,1,..,L-1}, mit L als Länge der Datei in Bytes.

Die Bedeutung des Speicherinhalts und deren Struktur ist nicht nägher beschrieben.

Im Gegensatz zum RAM Speichermodell - Random Acesss ! - ist ein Zugriff in wahlloser Reihenfolge nicht "gewünscht" und i.A. ineffizient, und wird daher auch nicht direkt unetrstützt, d.h. es wird ein sequenzielles geordnetes oeprationales Zugriffsmodell bereitgestellt.

Das sequenzille Zugriffsmodell wird über einen Dateizeiger realisiert.

Dieser wird aber nicht direkt verändert, sondern impliziz beim Lesen oder Schreiben einer Datei.

Der Zeiger kann aber neu gesetzt werden (typischerweise auf Anfang oder Ende eine Datei)

Es sind folgende Operation auf Dateien möglich:

Lesen (read, ganz, teilweise, oder zellenweise) bis zum Ende (EOF, gegebn durch Länge).

Jedes Lesen setzt den Dateizeiger weiter.

Schreiben (write), i.A. inkrementell, d.h. das Ende einer Datei (Länge) kann verschoben (vergrößert) werden.

Jedes Schreiben setzt den Dateizeiger weiter.

Neupositionierung des Zeigers (seek)

Diesen Basissatz kann man erweitern zu einer operationellen Schnittstelle (Programmier API):

Create und Delete

Erzeugen einer leeren Datei.

Löschen einer vorhandenen Datei.

Open und Close

Öffnen einer vorhandene Datei. Ein Dateiindex wird angelegt und positioniert. +Schließen der Datei. Der entsprechende Dateiindex wird gelöscht.

Write und Read

Schreiben in eine Datei ab einem Dateiindex.

Lesen aus einer Datei ab einem Dateiindex.

Seek

Positionieren des Dateiindexes.

Abbildung 5-2. Sequenzieller Zugriff (SAM) auf Dateien mit einem linearen Speichermodell. Die lineare Folge von Werten kann auch stukturiert sein, wie z.B. bei Programmdateien die einzlnen Segmente - aber betrifft die Operationen nicht! Struktur komtm später. Zum Vergleich rechts das RAM Modell mit Arrays.

Dateipufferung

Teile einer Datei oder die ganze Datei werden vom SAM in das RAM Modell durch Leseoperationen abgebildet

Der Zugriff über Puffer ermöglich randomisiertes Lesen (und Schreiben) eines temporären Speicherabbilds der Datei (oder eines Teils davon)

Abbildung 5-3. Puffern von Dateiinhalten über Arrays [https://www.virtualuniversity.ch/telekom/algoda/18.html]

Die High-Level-Funktionen verwenden einen programminternen Dateipuffer. Die Funktionen greifen zum Lesen und Schreiben immer nur auf den Dateipuffer zu. Der Dateipuffer ermöglicht einen effektiver Datentransfer zwischen Primärspeicher und Sekundärspeicher.

Der Dateipuffer birgt jedoch das Risiko eines möglichen Datenverlustes in sich. Wenn eine Datei für die Ausgabe geöffnet wurde, werden Daten erst beschrieben, wenn der Puffer voll ist. Ist das Programm durch einen Fehler abgebrochen, gehen die Daten im Puffer verloren.

Die Funktion fflush schreibt sofort alle Daten des Ausgabestroms aus dem Dateipuffer in den Sekundärspeicher. Es wird empfohlen, nach der Ausgabe sehr wichtiger Daten die Funktion fflush aufzurufen. Das Schließen einer Datei bewirkt das gleiche.

Datenströme

Jetzt befinden wir uns auf der operationalen und Betriebssystemebene. Die Dateimodell erlaubt sequenziellen Zugriff, daher kann man das Konzept des Kanals und Datenstroms einführen.

Ein- und Ausgabe wird als Datenstrom (Byte-Strom, stream) behandelt. Streams sind immer unidirektional.

Beim Öffnen einer Datei wird ein Kanal (logischer Kanal, Zugriffskanal, Verbindung ) vom Prozess zur vorhandenen logischen Datei angelegt und eine Übertragungsrichtung des Kanals festgelegt:

unidirektionaler Kanal für Ausgabe,

unidirektionaler Kanal für Eingabe,

bidirektionaler Kanal für abwechselnde Eingabe und Ausgabe.

Bei der Ausgabe wird eine Folge von Bytes durch den Kanal transportiert und in die Datei geschrieben.

Bei der Eingabe kann die gespeicherte Reihenfolge durch den Kanal exakt wieder abgerufen werden.

Abbildung 5-4. Öffnen einer Datei und Lesen über einen sequenziellen Datenstrom [https://hssm.hqedv.de/BSys08Dateisystem]

Dateizugriffsschnittstellen

Eine Menge von Dateioperationen einer logischen Datei kann als eine Dateizugriffsschnittstelle bezeichnet werden. Die unterschiedlichen Systeme stellen konkrete Dateizugriffsschnittstellen zur Verfügung.

Zwei Abstraktionsniveaus sind bekannt:

Low-Level-Dateizugriff mittels der Operationen von Betriebssystemen. Diese Operationen sind hardwareunabhängig, gehören aber nicht zu dem ANSI C-Standard.

High-Level-Dateizugriff mittels der Operationen von Standardbibliotheken. Diese Operationen sind unabhängig von Betriebssystemen bzw. portabel.

Abbildung 5-5. Low- und High-level Dateizugriffsschnittstellen [https://hssm.hqedv.de/BSys08Dateisystem]

High-Level-Dateizugriff

Es gibt zwei Arten von Zugriffsoperationen unter Unix / (C Standardbibliothek, POSIX):

Über einen Dateikanalindex (numerisch, Handle), Funktionen open read write close

Über einen Dateizeiger und Dateidiskriptor (Datenstruktur), Funktione fopen fread fwrite fclose

In beiden Fällen ist der Ablauf immer gleich:

OPEN(name, attributes)
REPEAT data=READ | WRITE(data)
CLOSE

C-Standardbibliothek Unix-Dateisystem  Windows-Dateisystem  Beschreibung
================================================================================
fopen                open/creat        CreateFile           Neue Datei erzeugen
remove               unlink            DeleteFile           Datei löschen
fopen                open              CreateFile           Vorhandene Datei öffnen
fclose               close             CloseHandle          Datei schließen
fread/fwrite         read/write        ReadFile /WriteFile  Lesen von Datei und Schreiben in Datei
fseek                lseek             SetFilePointer       Positionieren des Dateizeigers
fflush               fsync             FlushFileBuffers     Sofortiges Schreiben des Puffers
feof                 -                 -                    Prüft, ob Dateiende erreicht
FILE *               int               ?                    Datei Handle

Definition 5-1. Dateioperationen

Beispiel 1

#include <fcntl.h>
#include <sys/stat.h>
int buffer[1024];                                // Zwischenpuffer
int nread;                                       // Anzahl gelesener Byte
int srcfd, dstfd;                                // Dateideskriptoren
int main (int argc, char * argv[])
{
  srcfd = open ("srcFile", O_RDONLY);           // Datei srcFile öffnen
  dstfd = creat ("dstFile", S_IRWXU);           // Datei dstFile erzeugen
  do {                                          // Dateien umkopieren
   nread = read (srcfd, buffer, sizeof(buffer));
   if (nread > 0) {
     write (dstfd, buffer, nread);
   }
  } while (nread > 0);                          // Lese bis End-Of-File
  close (srcfd);
  close (dstfd);
  exit (0);
}

Beispiel 5-1. Elementares Beispiel: Kopieren einer Datei

Beispiel 2

Abbildung 5-6. Ein Filehandle ist ein numerische Index in einer Handletabelle, die dann die Zugrife auf Datein verwaltet.

Abbildung 5-7.

Abbildung 5-8. Dateien können konkurrierend merhfach geöffnet werden (acuh vom gleichen Prozess)

Abbildung 5-9. Die C Bibliothek ist eine betriebssystemübergreifende Schnittstelle, nicht nur für den Dateizugriff [bsdp]

EA Multiplexing

Nimmt man an adas mehrere Prozesse auf Dateien (quasi zeitgleich oder überlappend) zugreifen und das der Zugriff auf den Datenträger immer eine bestimmte Zeit τ in Anspruch nimmt, dann könnte man viele anstehende Operationen in einander verschachteln &rARr; EA Multiplexing

Auch innerhalb eines Prozesses kann es mehrere ausstehende Dateioperationen (oder allgemein EA) geben. Wir unterscheiden dann synchronen und asynchronen Zugriff.

Abbildung 5-10. EA Multiplexing bei asynchronen Zugriff [bsdp]

Das Dateisystem (logische Schicht)

Dateisysteme dienen der Verwaltung von Daten auf einem Sekundärspeicher.

Dies beinhaltet Festplatten- und Halbleiterlaufwerke sowie die Wechselmedien CD-ROM und DVD.

Die Verwaltung großer Datenbestände in Dateisystemen stellt erhöhte Anforderungen an die organisierte Ablage auf dem Medium.

Wie haben Datenstrukturen und Algorithmen:

Dazu gehören effiziente Such-, Lese- und Schreibvorgänge.

Die Dateiverwaltung stellt Möglichkeiten zur Verfügung, um Daten möglichst allgemein auf einem Datenträger abzulegen.

Die Dateisystemschnittstelle bietet dazu Funktionen an, die von den implementationsabhängigen Details abstrahieren.

Ziel: Aus Anwendungssicht können alle Medien gleichartig behandelt werden mit gleicher Funktionsschnittstelle.

Die aus Anwendungssicht verwaltete Einheit in einem Dateisystem ist die logische Datei (logical file).

Sie stellt eine benannte Ablage für Anwenderdaten unterschiedlicher Art dar.

Mit einer Auswahl von Dateisystemfunktionen können Dateien angelegt, verwaltet, beschrieben/gelesen und wieder gelöscht werden.

Zusätzlich kann die Ablage auf einem Medium strukturiert werden, sodass auch bei sehr vielen Dateien die Übersicht gewahrt bleibt. Die übliche Lösung dieses Problems nutzt eine Verzeichnishierarchie, wenn auch alternative Ablageorganisationen denkbar wären.

Eine logische Datei stellt eine geordnete Sammlung von Datensätzen (records) dar.

Ein Datensatz ist die kleinste adressierbare Informationseinheit innerhalb einer Datei.

Typischerweise entspricht ein Datensatz auf der Ebene der Dateisystemschnittstelle einem Bytewert beliebigen Inhalts, analog zur Organisation des Hauptspeichers.

Legt man Dateien auf einem Massenspeicher ab, so ist es üblich, eine Reihe damit verbundener Informationen ebenfalls dort zu speichern.

Dateiname, Ablageort, Zugriffsrechte und Ablagedatum

Abbildung 5-11. Logische Datei: Datenablage mit Hilfsinformationen (Blackbox-Modell)

Taxonomie der Dateisysteme

Eine Datei (file) ist ein logisches Betriebsmittel, welches eine endliche Menge zusammengehöriger Daten beinhaltet. Dateien existieren, um Daten zu speichern und später wieder abzurufen.

Ein Dateisystem (file system, Dateiverwaltung) ist ein Teil des Betriebssystems, der der Verwaltung von Daten auf einem Sekundärspeicher dient.

Alle Dateisysteme zielen auf Geräteunabhängigkeit ab, d. h ein Programm muss sich nicht darum kümmern, ob eine Datei sich auf Platte, Magnetband oder einem anderen Massenspeicher befindet.

Ein Dateideskriptor ist eine Tabelle mit Dateiattributen. Dateideskriptoren werden vom Dateisystem auf dem Datenträger abgelegt.

Dateidiskriptoren

[https://hssm.hqedv.de/BSys08Dateisystem]

Dateiattribute sind dateispezifische Informationen für der Verwaltung von Dateien. Einige Dateiattribute sind;

Dateiname

Dateityp (Text, binäre Daten, Programmdatei)

Ablageort (Verzeichnis, Ordner, URL usw.)

Datum der Erstellung, Veränderung, letzer Zugriff usw.

Zugriffsrechte

Als Metadaten bezeichnet man allgemein Daten, die Informationen über andere Daten enthalten.

Dateitypen

Jedes Betriebssystem muss zwei Varianten von Dateitypen unterscheiden:

ausführbare Programme, die in Form von Dateien (executable files) gespeichert werden. Bei der Auswahl wird dieses Programm gestartet, z.B.: „prog.exe“.

nicht ausführbare Dateien (Daten, Dokumente). Diese Dateien werden vom Betriebssystem nicht interpretiert, sondern Anwendungsprogrammen zugeordnet, die die Dateien interpretieren können.

Das Dateisystem, die Datenstrukturen und Algorithmen unterscheiden diese Dateitypen nicht. Sie unterscheiden u.U. andere Dateiarten: Swap Datei, Protokolldatei (des Dateisystems), I-Node Tabellendatei, usw..

Es müssen aber Verkettungen, Verknüpfungspunkte mit anderen Dateisystemen und Gerätedateien von echten Datein mit Daten unterschieden werden.

Nach der Art des Inhaltes werden unterschieden:

Binärdateien enthalten beliebige Bytewerte (auch nicht-alphabetische Zeichen) und können in der Regel nur durch entsprechende Anwendungen gelesen werden. Dazu gehören auch ausführbare Dateien. Beispiele: a.xls, a.exe, a.obj.

Textdateien (ASCII-Dateien) enthalten nur reinen Text und einfache Steuerzeichen, z.B. Zeilenumbruch. Viele Steuerdateien sind Textdateien. Beispiele: a.htm , a.txt, a.bpr.

Das Dateiformat kann auf drei verschiedene Arten automatisch ermittelt werden:

Erweiterung eines Dateinamens (.exe,.png,.html), ist aber unsicher und nicht eindeutig

Die Ermittlung durch Dateiinhalte. Dabei wird auf bekannte Muster untersucht, z.B.: UNIX identifiziert den Dateityp durch die so genannten „magischen Zahlen“.

Die Ermittlung durch Metadaten ist die zuverlässigste Methode, weil das Dateiformat zusammen mit der Datei abgelegt ist.

kernel.elf, shell.exe
=====================
00000000   7F 45 4C 46  01 01 01 00  00 00 00 00  00 00 00 00  .ELF............
00000010   02 00 03 00  01 00 00 00  00 00 01 00  34 00 00 00  ............4...

basekernel.png
=====================
00000000   89 50 4E 47  0D 0A 1A 0A  00 00 00 0D  49 48 44 52  .PNG........IHDR
00000010   00 00 03 F5  00 00 00 EE  08 06 00 00  00 78 42 27  .............xB'

Beispiel 5-2. Beispiele von Dateitypen und dme "Header"

Die logische Datei

Eine logische Datei wird vom Dateisystem wie ein langer char-Vektor behandelt:

Eine logische Datei ist ein Behälter für eine Reihenfolge von Bytes.

Der Dateiinhalt ist beliebig, da er vom Dateisystem nicht interpretiert wird.

Die Dateilänge ist nicht im Voraus abzusehen (zumindest beim Schreiben).

Zu der Spezifikation einer logischen Datei gehört ein Dateiindex (Schreib-/Lese-Zeiger, Dateizeiger, Byte-Position, Seek-Zeiger, Zeichenposition, aktuelle Position, Dateiposition), der von Dateioperationen (read, write) benutzt wird.

Verzeichnishierarchie

Dateisysteme sind heute meist hierarchisch organisiert, indem in einem Wurzelverzeichnis (root directory) sich sowohl Dateien (files) als auch Unterverzeichnisse (sub directories) anlegen lassen.

Innerhalb von Unterverzeichnissen, manchmal auch als Ordner (folder) bezeichnet, können weitere Dateien und Unterverzeichnisse vorhanden sein.

Ein Verzeichnis, als Oberbegriff sowohl für das Wurzelverzeichnis als auch für Unterverzeichnisse verstanden, stellt im Grunde genommen nichts anderes dar als eine Liste von Dateinamen.

Diese Dateinamen können normale Dateien (regular files) oder Spezialdateien (special files) bezeichnen. Verzeichnislisten gelten sowohl unter Windows als auch unter Unix als Spezialdateien der Art Verzeichnisdatei (directory file).

Verzeichnisse (Ordner) sind also Tabellen die Namen mit anderen Dateien oder Verzeichnissen verknüpfen!

Abbildung 5-12. Verzeichnishierarchie unter Unix

Die logische Organisation eines Dateisystems ist somit ein Verzeichnisbaum (top-down)

Da es aber auch Verkettungen (Verlinkung, smybolisch) von Dateien und vor allem Verzeichnis geben kann, können auch Zyklen enthalten sein.

Die logische Organisation eines Dateisystems ist somit ein zyklischer Verzeichnisgraph

Verzeichnis

Ein Verzeichnis kann Einträge von verschiedenen Dateiarten enthalten:

normale Dateien (regular files),

wiederum Verzeichnisse (directory file),

Verknüpfungen (links)

weitere Arten von Spezialdateien, z.B. Gerätedatei (device files).

Verzeichnisfunktionen

Genauso wie Dateien können Verzeichnisse gelesen und verändert werden!

UNIX,POSIX     Windows          Beschreibung
=========================================
mkdir          CreateDirectory  Verzeichnis erzeugen
rmdir          RemoveDirectory  Verzeichnis löschen
opendir        -                Verzeichnis öffnen
closedir       FindClose        Verzeichnis schliessen
readdir        FindFirstFile    Ersten Eintrag suchen
readdir        FindNextFile     Nächsten Eintrag suchen
getcwd         GetCurrentDirectory  Arbeitsverzeichnis abfragen
chdir          SetCurrentDirectory  Arbeitsverzeichnis ändern
link, symlink  CreateHardLink CreateSymbolicLink  Dateiverknüpfung anlegen

Verzeichnisfunktionen (C Standardbibliothek)

Pfade

Verzeichnisse sind Tabellen die auf andere Dateien oder Verzeichnisse verweisen. Verzeichnisse sind selbst Dateien. Einzelne Verzeichnisse können nicht direkt abgerufen werden sondern erfordern eine "Suche".

Verzeichnisbäume werden durch Suchpfade als Aneinanderkettung der Namen entlang eines Pfades dargestellt (hyperlogische Struktur durch Pfade)

Das Trennzeichen ist festgelegt:

UNIX: /

MS Windows: \\

Es gibt immer ein Wurzelverzeichnis:

UNIX : / (alle Laufwerke werden von dort aus erreicht)

MS Windows: X:\\, wobei X ein Laufwerksbezeichner ist

Relativer Pfadname: Dateien können relativ zu dem Arbeitsverzeichnis adressiert werden.

Dafür wird zusätzliche Symbolik verwendet:

Der Name ".“ verweist auf das Arbeitsverzeichnis.

Der Name "..“ verweist auf das Elternverzeichnis.

Vorteil: Pfadnamen werden kürzer, Nachteil: relative Pfade müssen vor dem Zugriff immer in absolute aufgelöst werden!

Das Dateisystem (physische Schicht)

Bisher hatten wir nur eine vereinfachte logische Sicht auf Dateien und Dateisysteme:

Die Datei war ein linearer Datenbereich auf den über Zeiger und Puffer zugegriffen wurde

Das Dateisystem war ein Verzeichnisbaum (oder zyklischer Graph).

Jetzt kommt die "Implementierung" dieser abstrakten Konzepte auf physische Datenträger.

Realisierung von Dateisystemen

Abbildung 5-13. Schichtenmodell eins Dateisystems: Von logisch bis physische und Gerätetreiber

Blockspeicher als Grundlage

Bisher war die elementare Strukturgröße der Datei ein Byte. Jetzt führen wir größere Einheiten für vereinfachte und effizientere Verarbeitung ein: Blöcke

Abbildung 5-14. Blockspeichermodell als Realisierung eins Dateisystems

Ein Datenträger stellt im Blockspeichermodell somit einen Behälter mit n eindeutig adressierbaren Blöcken dar, die entsprechend ihrer Nummerierung auch auf dem Datenträger benachbart angelegt sind.

Eine Datei ist logisch immer zusammenhängend, im Blockspeichermodell wie beim Hauptspeicher und VMM nicht mehr notwendigerweise.

Abbildung 5-15. Zusammenhängende und verteilte Belegung

Die zusammenhängende Belegung führt zu den gleichen Problemen, wie wir sie bereits bei der dynamischen Speicherverwaltung kennengelernt haben. Im Vordergrund steht dabei die unter Umständen erhebliche externe Fragmentierung. Dem steht dafür eine im sequenziellen Zugriff auf die Festplatte hohe Geschwindigkeit gegenüber, da keine oder nur minimale mechanische Kopfbewegungen nötig sind.

Physisch vers Logisch

Abbildung 5-16. Beispiel einer Dateispeicherung: Links physische Sicht, Rechts logische Sicht

Wie verwaltet man nun "verstreute" Dateien und Verzeichnisse auf Blockebene?

Verwaltungsformen

Abbildung 5-17. Typische Datenstrukturen für die Verwaltung von Blockgruppen die zu einer Datei gehören [bsdp]

Belegungsformen

Vor- und Nachteile zusammenhängender bzw. verteilter Belegung

Methoden (Formen) der Dateiallokation:

Kontinuierliche Allokation.

Allokation durch verkettete Liste.

Allokation durch eine Belegungstabelle.

Allokation durch I-Nodes (wird noch eingeführt).

Datenträgeraufteilung

Man unterscheidet grundlegend zwei Bereiche (die aber nicht immer getrennt werden können), die eine Aufteilung des Datenträgers bedeuten:

Verwaltungsbereiche

Datenbereiche

Abbildung 5-18. Datenträgeraufteilung des UFS. Index Nodes und die ersten Blöcke gehören zur Verwaltung (physisch), Blocks gehören zur logischen Dateisystemschicht

Der Datenträger wird in vier Bezirke aufgeteilt:

Der Boot-Block wird für das Laden des Betriebssystems benötigt.

Im Super-Block sind Basisinformationen über den Datenträger abgelegt, wie Größe, Name des Dateisystems und des Datenträgers, Zeiteinträge des letzten Zugriffs und der Datensicherung. Daneben enthält er die Anzahl aller Index Nodes und den Beginn der Liste freier Blöcke.

Die Index Nodes, abgekürzt auch Inodes genannt, dienen der Verwaltung einzelner Dateien. Man kann sie als Verwaltungsblöcke bezeichnen. Sie sind im dritten Bereich als Liste enthalten (i-list).

Die Datenblöcke (Blocks) enthalten sowohl Verzeichnisdaten als auch die eigentlichen Nutzdaten. Mit Nutzdaten sind diejenigen Inhalte gemeint, die Applikationsprogramme persistent (d.h. dauerhaft) ablegen. Im Unterschied dazu werden alle Daten, die der Verwaltung der gespeicherten Nutzdaten dienen, als Metadaten bezeichnet.

Index Nodes

Internal oder Index Nodes (I-Nodes) sind Referenzen und in einfache Inode Tabellen zusammengefasst.

Zu jeder Datei wird im Verzeichniseintrag die Inode-Nummer und der Dateiname abgelegt.

Die Inode-Nummer ist ein Index in eine Liste der verfügbaren Verwaltungsblöcke (i-list).

%% FIGURE [] Struktur und Vernetzung eines Inodes. Die Tabelle links enthält zwei Spalten: Inode Nummer und Name

Index Node Tabellen und Verkettung

Abbildung 5-19. Zusammenhang zwischen Verzeichniseintrag, Verzeichnisblock und Datenblöcke

Abbildung 5-20. Beispiel zur Dateilokalisierung im klassischen Unix-Dateisystem

Hierarchische Tabellenverkettung

Eine I-Nodes Tabelle reicht nicht aus um große Dateien mit allen Blöcken zu erfassen.

Daher werden die Tabellen selber wieder verkettet

Abbildung 5-21. Prinzipbeispiel für große Datei mit Verkettung von I-Nodes Tabellen [https://hssm.hqedv.de/BSys08Dateisystem]

Storage Pools und Logging

Was passiert wenn während der Veränderung des Dateisystems ein Fehler auftritt? Z.B. Stromausfall oder Softwarefehler oder Datenträgerfehler?

Nur ein Protokollierung der Operationen auf Dateisystemen ermöglich eine Rekosntruktion und Wiederherstellung der Konsitenz der veretilten Datenstrukuren (I-Nodes Tabellen usw.)

Was machen wir wenn wir mehr als einen Datenträger für ein Dateisystem verwenden wollen?

Storage Pools können helfen

Abbildung 5-22. Unter ZFS (Zettabyte File System) lassen sich Laufwerke zu einem Storage Pool (Zpool) zusammenfassen, aus dem sich mehrere ZFS-Dateisysteminstanzen und Swap-Laufwerke erzeugen lassen.

Zusammenfassung

Datein sind abstrakt und durch ein lienares Zellenmodell beschrieben.

Bei der physische Realisierung einer Datei (Speicherung auf Datenträger) wird diese in Blöcke unterteilt die eindeutig adressierbar sind und über den Datenträger verteilt sein können.

Dateisysteme haben verschiedene Schichten: Logisch ⇒ Verzeichnisgraph, Physisch ⇒ Speicherung auf Datenträger z.B. via I-Nodes Tabellen

Verzeichnisse sind Datenstrukturen und Tabellen und auch Dateien und verknüpfen Daten mit Namen (Dateien haben eigentlich keine Namen!) und bauen die Verzeichnishierarchie auf.

Das Basekernel OS

Kernel

Anwenderprogrammme

Standardbibliothek

Kernel

Der Kernel des Basesystem OS hat eine klassische monolithische Kernelarchitektur. Wesentliche Bestandteile der innersten ersten Schicht und Basisdienste sind:

Kernel Startup (aka. "main" Funktion)

Assembler kernelcore.S

C main.c

Speichermanagement

page.c (VMM)

pagetable.c (VMM)

kmalloc.c

Prozessmanagement

process.c (Programmausführung)

elf.c (Programmformat)

Systemaufruf Schnittstelle (für Anwendungsprogramme)

syscall_handler.c

Ereignismanagement

event.c

interrupt.c

.up.compact[

Weitere Bestandteile der zweiten Schicht und Basisdienste sind:

.shadowbox.size09[

Gerätemanagement

device.c (Gerätetreiberschnittstelle)

pipe.c (Queues zwischen Prozessen)

Dateisystem

diskfs.c

fs.c (allgemeine Dateisystemschnittstelle)

cdromfs.c

(Block) Cache

bcache.c

Ein- und Ausgabe (eher minimale Standardblibliothek für den Kernel)

console.c

`graphics.c'

printf.c

string.c ] ]

.up.compact[

Weitere Bestandteile der nullten Schicht sind hardwarenahe Komponenten:

.shadowbox.size09[

Gerätetreiber

console.c

graphics.c

pic.c (Interruptcontroller)

ata.c (Parallele Geräteschnittstelle für Blockgeräte, HDD, CDROM)

clock.c (Timer)

keyboard.c (Tastatur)

serical.c (Serielle UART Schnittstelle)

rtc.c (Real Time Clock ⇒ Systemuhr)

Datenstrukturen

list.c

hash_set.c ] ]

.centered.width60[

] .figure[Schichtenaufbau des monolithischen Basekernel]

Standardbibliothek

Die Standardbibliothek implementiert häufig vorkommende Funktionen für:

Ein- und Ausgabe

Dateioperationen

Speichermanagement (oder eine Schnittstelle dazu)

Systemaufrufe

Sie wird von allen Nutzerprogrammen (user space) verwendet. Funktionen für Netzwerkzugriff (Sockets) und vieles mehr kann noch enthalten sein.

.up.shadowbox.size09[

Systemaufrufschnittstelle

C syscalls.s

Assembler syscall.S

Speichermanagement (USer space)

malloc.c

Ein- und Ausgabe

stdio.c

Sonstiges

user_start-c (Start eines Benutzerprogramms)

errno.c (Fehlerbehandlung) ]

.up.shadowbox.size09[

Zeichenketten

string.c

Allgemeine Funktionen

stdlib.c (Programmausführung, Zeit, Systeminformation usw.)

Konsole und Grafiksystem (Fenster)

nwindow.c ]

Benutzerpogramme

Im wesentlichen gibt es nur eine Shell. Die Aufgabe einer Shell ist:

Interaktive eingegebene Befehle von einer Kommandozeile unmittelbar ausführen ⇒ Nutzerbetrieb

Shellskripte (kleine Programme als Sammlung von Shellbefehlen) ausführen ⇒ Stapelbetrieb

Dabei sollen zwei primäre Schnittstellen zum Betriebssystem auf programmatische und interaktive Weise zur Verfügung gestellt werden (Operationalität):

.box[

Dateisystem, Textdatein

Prozesse und Programme

Ein-und Ausgabe (Terminal) ]

Shell

Die meisten gängigen Unix-Shells bieten die folgenden Funktionen:

.reference[Wikipedia] .box[

Starten von Kommandos

Dateinamen-Wildcards (globs) als Kommandoargumente

Bedingungen (if, case) und Schleifen (while, for)

interne Kommandos (cd, read)

interne Variablen ($HOME)

Manipulation der Umgebungsvariablen für die neuen Prozesse

Ein-/Ausgabeumlenkung

Starten mehrerer Prozesse, Verkettung über Pipes

Starten von Prozessen im Hintergrund ]

Moderne Shells können darüber hinaus:

.box[

Vervollständigung von Kommandos, Dateinamen und Variablen (completion system)

Editieren der Kommandozeile (command line editing)

Wiederholung und Editieren früherer Kommandos (command history)

Stoppen und erneutes Starten von Prozessen (job control)

Verschieben von Prozessen aus dem Vordergrund in den Hintergrund und umgekehrt (job control)

Eingebautes Kommando zur Durchführung von Berechnungen ($((2+2)))

Eingebautes Kommando zum Testen von Dateieigenschaften (test) ]

Skripte

Shellskripte sind Text-Dateien und stellen kleine Programme dar.

Sie werden vom Anwender geschrieben und beim Aufruf von der Shell gelesen und ausgeführt.

Muss man z. B. immer wieder in seinem Arbeitsalltag fünf Befehle nacheinander in die Shell eintippen, kann man das vereinfachen, indem man diese Befehle in einem Skript sammelt und dann nur noch das ganze Skript aufruft.

Die Shell liest das Skript aus und führt die Befehle entsprechend aus. Zu beachten ist, dass man alle Befehle, die man „händisch“ in die Shell eingibt, auch über ein Skript ausführen kann und umgekehrt.

Dienstprogramme

.shadowbox[

Shell

shell.c (sehr einfacher Shell Intepreter, kein Skripting)

Prozess Manager

Nur Prozessinformation durch procstat.c

Steuerung von Prozessausführung (stoppen) gibt es hier nicht

Systeminformationen

systats.c

Dateisystemedienste

livestat.c (Information über Gerätetreiber/Gerät und Cache)

copy.c (Datei kopieren)

Fenstermanager

manager.c (Grafiksystem, Verwaltung von Fenstern) ]

Der Erzeugungsprozess

Es müssen folgende Komponenten von Quellcode (C/Assembler) in binäre Programmdaten übersetzt werden:

Der Kernel (kernel) und Bootloader (bootblock)

Die Standardbibliothek (baselib.a)

Hier nur als Archiv (a), nicht als geteilte (shared) Bibliothek (dll/so) baselib.a

Alle Programme werden statisch (d.h. vollständig enthaltend) kompiliert und erzeugt.

Die Nutzerprogramme

Für den gesamtn Übersetzungsprozess wird ein GCC Kompiler verwendet. Dieser enthält neben dem Präprozessor cpp, den eigentlichen Kompiler cc noch einen Assembler as und einen Linker ld. Weitere Hilfsprogramme vor allem für das ELF Format werden benutzt (objdump, objcopy, ar).

Es muss GCC mit 32 Bit x86 ELF (Executable Link Format) verwendet werden. Andere Prrogrammformate und Prozessorarchitekturen führen zu einem nicht funktionieren Basekernl OS (sowohl Kernel als auch Nutzerprogramme).

Der gesamte Übersetzungsprozess wird mit dem make Programm gesteuert.

make (englisch für machen, erstellen) ist ein Build-Management-Tool, das Kommandos in Abhängigkeit von Bedingungen ausführt. Es wird hauptsächlich bei der Softwareentwicklung als Programmierwerkzeug eingesetzt.

make liest eine teils prozedurale und teils deklarative Anweisungsdatei (Makefile), in dem die Abhängigkeiten des Übersetzungsprozesses von Programmen formalisiert erfasst sind.

Diese Formalisierung beschreibt, welche Quelltextdateien auf welche Weise durch den Compiler oder durch andere Programme zu welchen Objektdateien bzw. Ergebnissen verarbeitet werden, bzw. welche Objektdateien vom Linker zu Programmbibliotheken oder ausführbaren Programmen verbunden werden.

Alle Schritte erfolgen unter Beachtung der im Makefile erfassten Abhängigkeiten.[2]

Wenn das Makefile vom make-Programm abgearbeitet wird, wird eine Umwandlung etwa einer Quelldatei in eine Objektdatei nur dann vorgenommen, wenn die Quelldatei neuer als die bereits vorliegende Version der Ergebnisdatei ist, bzw. wenn keine Ergebnisdatei vorhanden ist.

Bei der Entwicklung großer Programmpakete mit vielen Quelldateien und vielen ausführbaren Programmen entfällt so die Notwendigkeit, bei einer Reihe kleiner Veränderungen alle Kompilierungen erneut durchzuführen.

Makefile (main)

include Makefile.config

LIBRARY_SOURCES=$(wildcard library/*.c)
LIBRARY_HEADERS=$(wildcard library/*.h)
USER_SOURCES=$(wildcard user/*.c)
USER_PROGRAMS=$(USER_SOURCES:c=exe)
KERNEL_SOURCES=$(wildcard kernel/*.[chS])
WORDS=/usr/share/dict/words

.PHONY: build-kernel build-library build-userspace build-cdrom-image

all: build-cdrom-image

build-kernel: kernel/basekernel.img

build-library: library/baselib.a

build-userspace: $(USER_PROGRAMS)

build-cdrom-image: basekernel.iso

kernel/basekernel.img: $(KERNEL_SOURCES) $(LIBRARY_HEADERS)
	cd kernel && make

library/baselib.a: $(LIBRARY_SOURCES) $(LIBRARY_HEADERS)
	cd library && make

$(USER_PROGRAMS): $(USER_SOURCES) library/baselib.a $(LIBRARY_HEADERS)
	cd user && make

image: kernel/basekernel.img $(USER_PROGRAMS)
	rm -rf image
	mkdir image image/boot image/bin image/data
	cp kernel/basekernel.img image/boot
	cp $(USER_PROGRAMS) image/bin
	if [ -f ${WORDS} ]; then head -2000 ${WORDS} > image/data/words; fi

basekernel.iso: image
	${ISOGEN} -input-charset utf-8 -iso-level 2 -J -R -o $@ -b boot/basekernel.img image

disk.img:
	qemu-img create disk.img 10M

run: basekernel.iso disk.img
	qemu-system-i386 -cdrom basekernel.iso -hda disk.img

debug: basekernel.iso disk.img
	qemu-system-i386 -cdrom basekernel.iso -hda disk.img -s -S &

clean:
	rm -rf basekernel.iso image disk.img
	cd kernel && make clean
	cd library && make clean
	cd user && make clean

Makefile (config)

KERNEL_CCFLAGS=-Wall -c -ffreestanding -fno-pie -g -std=gnu99

# These settings select the native compiler,
# which is likely to work on native linux-x86.
#
CC=gcc -m32
LD=ld -melf_i386
#LD=ld -arch i386
AR=ar
OBJCOPY=objcopy -R .note.gnu.property
ISOGEN=genisoimage

# If you are compiling from another platform,
# then use the script build-cross-compiler.sh
# add cross/bin to your path, and uncomment these lines:
#CC=i686-elf-gcc
#LD=i686-elf-ld
#AR=i686-elf-ar
#OBJCOPY=i686-elf-objcopy

# If building on OSX, then install mkisofs via ports or brew
# and uncomment this:
#ISOGEN=mkisofs

Makefile (kernel)

include ../Makefile.config

KERNEL_OBJECTS=kernelcore.o main.o console.o page.o keyboard.o mouse.o event_queue.o clock.o interrupt.o kmalloc.o pic.o ata.o cdromfs.o string.o bitmap.o graphics.o font.o syscall_handler.o process.o mutex.o list.o pagetable.o rtc.o kshell.o fs.o hash_set.o diskfs.o serial.o elf.o device.o kobject.o pipe.o bcache.o printf.o is_valid.o window.o

basekernel.img: bootblock kernel
	cat bootblock kernel /dev/zero | head -c 1474560 > basekernel.img

kernel: kernel.elf
	${OBJCOPY} -O binary $< $@

bootblock: bootblock.elf
	${OBJCOPY} -O binary $< $@

kernel.elf: ${KERNEL_OBJECTS}
	${LD} ${KERNEL_LDFLAGS} -Ttext 0x10000 ${KERNEL_OBJECTS} -o $@

bootblock.elf: bootblock.o
	${LD} ${KERNEL_LDFLAGS} -Ttext 0 $< -o $@

%.o: %.c
	${CC} ${KERNEL_CCFLAGS} -I ../include $< -o $@

%.o: %.S
	${CC} ${KERNEL_CCFLAGS} -I ../include $< -o $@

clean:
	rm -rf basekernel.img *.o *.elf kernel bootblock bootblock.o

make it all

cd kernel && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/kernel'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bootblock.S -o bootblock.o
ld -melf_i386  -Ttext 0 bootblock.o -o bootblock.elf
objcopy -R .note.gnu.property -O binary bootblock.elf bootblock
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kernelcore.S -o kernelcore.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include main.c -o main.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include console.c -o console.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include page.c -o page.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include keyboard.c -o keyboard.o
In file included from keyboard.c:42:
keymap.de.pc.c:5:7: warning: multi-character character constant [-Wmultichar]
 {'3', '§', '3', 0},
       ^~~~
keymap.de.pc.c:5:7: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49831’ to ‘-89’ [-Woverflow]
keymap.de.pc.c:13:2: warning: multi-character character constant [-Wmultichar]
 {'ß', '?', 'ß','\\'},
  ^~~~
keymap.de.pc.c:13:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50079’ to ‘-97’ [-Woverflow]
keymap.de.pc.c:13:13: warning: multi-character character constant [-Wmultichar]
 {'ß', '?', 'ß','\\'},
             ^~~~
keymap.de.pc.c:13:13: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50079’ to ‘-97’ [-Woverflow]
keymap.de.pc.c:14:2: warning: multi-character character constant [-Wmultichar]
 {'´', '`', '´',0},
  ^~~~
keymap.de.pc.c:14:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49844’ to ‘-76’ [-Woverflow]
keymap.de.pc.c:14:13: warning: multi-character character constant [-Wmultichar]
 {'´', '`', '´',0},
             ^~~~
keymap.de.pc.c:14:13: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49844’ to ‘-76’ [-Woverflow]
keymap.de.pc.c:27:2: warning: multi-character character constant [-Wmultichar]
 {'ü', 'Ü', 0, 0},
  ^~~~
keymap.de.pc.c:27:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50108’ to ‘-68’ [-Woverflow]
keymap.de.pc.c:27:8: warning: multi-character character constant [-Wmultichar]
 {'ü', 'Ü', 0, 0},
        ^~~~
keymap.de.pc.c:27:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50076’ to ‘-100’ [-Woverflow]
keymap.de.pc.c:40:2: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
  ^~~~
keymap.de.pc.c:40:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50102’ to ‘-74’ [-Woverflow]
keymap.de.pc.c:40:8: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
        ^~~~
keymap.de.pc.c:40:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50070’ to ‘-106’ [-Woverflow]
keymap.de.pc.c:40:14: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
              ^~~~
keymap.de.pc.c:40:14: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50102’ to ‘-74’ [-Woverflow]
keymap.de.pc.c:41:2: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
  ^~~~
keymap.de.pc.c:41:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50084’ to ‘-92’ [-Woverflow]
keymap.de.pc.c:41:8: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
        ^~~~
keymap.de.pc.c:41:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50052’ to ‘-124’ [-Woverflow]
keymap.de.pc.c:41:14: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
              ^~~~
keymap.de.pc.c:41:14: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50084’ to ‘-92’ [-Woverflow]
keymap.de.pc.c:42:7: warning: multi-character character constant [-Wmultichar]
 {'^', '°', '^', 0},
       ^~~~
keymap.de.pc.c:42:7: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49840’ to ‘-80’ [-Woverflow]
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include mouse.c -o mouse.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include event_queue.c -o event_queue.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include clock.c -o clock.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include interrupt.c -o interrupt.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kmalloc.c -o kmalloc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pic.c -o pic.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include ata.c -o ata.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include cdromfs.c -o cdromfs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include string.c -o string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bitmap.c -o bitmap.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include graphics.c -o graphics.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include font.c -o font.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include syscall_handler.c -o syscall_handler.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include process.c -o process.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include mutex.c -o mutex.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include list.c -o list.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pagetable.c -o pagetable.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include rtc.c -o rtc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kshell.c -o kshell.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include fs.c -o fs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include hash_set.c -o hash_set.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include diskfs.c -o diskfs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include serial.c -o serial.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include elf.c -o elf.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include device.c -o device.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kobject.c -o kobject.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pipe.c -o pipe.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bcache.c -o bcache.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include printf.c -o printf.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include is_valid.c -o is_valid.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include window.c -o window.o
ld -melf_i386  -Ttext 0x10000 kernelcore.o main.o console.o page.o keyboard.o mouse.o event_queue.o clock.o interrupt.o kmalloc.o pic.o ata.o cdromfs.o string.o bitmap.o graphics.o font.o syscall_handler.o process.o mutex.o list.o pagetable.o rtc.o kshell.o fs.o hash_set.o diskfs.o serial.o elf.o device.o kobject.o pipe.o bcache.o printf.o is_valid.o window.o -o kernel.elf
objcopy -R .note.gnu.property -O binary kernel.elf kernel
cat bootblock kernel /dev/zero | head -c 1474560 > basekernel.img
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/kernel'
cd library && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/library'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include user-start.c -o user-start.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include errno.c -o errno.o
gcc -m32    -c -o syscall.o syscall.S
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include syscalls.c -o syscalls.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include string.c -o string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include stdio.c -o stdio.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include stdlib.c -o stdlib.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include malloc.c -o malloc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kernel_object_string.c -o kernel_object_string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include nwindow.c -o nwindow.o
ar rv baselib.a errno.o syscall.o syscalls.o string.o stdio.o stdlib.o malloc.o kernel_object_string.o nwindow.o
ar: creating baselib.a
a - errno.o
a - syscall.o
a - syscalls.o
a - string.o
a - stdio.o
a - stdlib.o
a - malloc.o
a - kernel_object_string.o
a - nwindow.o
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/library'
cd user && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/user'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include ball.c -o ball.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o ball.o ../library/baselib.a -o ball.exe
objcopy -R .note.gnu.property -R .comment --strip-debug ball.exe
objcopy: ball.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include clock.c -o clock.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o clock.o ../library/baselib.a -o clock.exe
objcopy -R .note.gnu.property -R .comment --strip-debug clock.exe
objcopy: clock.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include copy.c -o copy.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o copy.o ../library/baselib.a -o copy.exe
objcopy -R .note.gnu.property -R .comment --strip-debug copy.exe
objcopy: copy.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include livestat.c -o livestat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o livestat.o ../library/baselib.a -o livestat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug livestat.exe
objcopy: livestat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include manager.c -o manager.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o manager.o ../library/baselib.a -o manager.exe
objcopy -R .note.gnu.property -R .comment --strip-debug manager.exe
objcopy: manager.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include fractal.c -o fractal.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o fractal.o ../library/baselib.a -o fractal.exe
objcopy -R .note.gnu.property -R .comment --strip-debug fractal.exe
objcopy: fractal.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include procstat.c -o procstat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o procstat.o ../library/baselib.a -o procstat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug procstat.exe
objcopy: procstat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include saver.c -o saver.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o saver.o ../library/baselib.a -o saver.exe
objcopy -R .note.gnu.property -R .comment --strip-debug saver.exe
objcopy: saver.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include shell.c -o shell.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o shell.o ../library/baselib.a -o shell.exe
objcopy -R .note.gnu.property -R .comment --strip-debug shell.exe
objcopy: shell.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include snake.c -o snake.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o snake.o ../library/baselib.a -o snake.exe
objcopy -R .note.gnu.property -R .comment --strip-debug snake.exe
objcopy: snake.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include sysstat.c -o sysstat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o sysstat.o ../library/baselib.a -o sysstat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug sysstat.exe
objcopy: sysstat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
rm manager.o ball.o copy.o shell.o saver.o fractal.o snake.o livestat.o procstat.o clock.o sysstat.o
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/user'
rm -rf image
mkdir image image/boot image/bin image/data
cp kernel/basekernel.img image/boot
cp user/manager.exe user/fractal.exe user/sysstat.exe user/shell.exe user/saver.exe user/clock.exe user/copy.exe user/livestat.exe user/ball.exe user/procstat.exe user/snake.exe image/bin
if [ -f /usr/share/dict/words ]; then head -2000 /usr/share/dict/words > image/data/words; fi
genisoimage -input-charset utf-8 -iso-level 2 -J -R -o basekernel.iso -b boot/basekernel.img image
Size of boot image is 2880 sectors -> Emulating a 1440 kB floppy
Total translation table size: 2048
Total rockridge attributes bytes: 1946
Total directory bytes: 6854
Path table size(bytes): 46
Max brk space used 1a000
1159 extents written (2 MB)

TODO

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Prozesse und Prozessverwaltung

Einführung des Prozessmodells

Datenstrukturen von Prozessen

Multiprozesssysteme und Scheduling (Prozessumschaltung)

Terminologie

Programm

Ausführungsvorschrift für eine Maschine, i.A. eine lineare Liste von Instruktionen.

Prozessor

Physische (oder virtuelle) Ausführungseinheit einer Maschine (Rechner) um Instruktionen auszuführen. Maschineninstruktionen sind Daten- und Kontrollanweisungen, d.h. Berechnung und Kontrollflusssteuerung.

Prozess

Ein Orogramm in Ausführung, entweder im Vordergrund durch einen Prozessor, oder im Hintergrund durch einen Speicherzustand (inkl. Auslagerung auf einen Datenträger).

.reference[bsdp] .centered.width80[

] .figure[Unterscheidung Programm zu Prozess]

Allgemein gilt: kommt ein Programm zur Ausführung, dann wird ein neuer Prozess gestartet. Der Prozess belegt Hauptspeicherplatz, Prozessorregister, belegt Rechenzeit und nutzt die Peripherie. Ein bestimmtes Programm kann bei Bedarf auch mehrfach parallel ablaufen. Es liegen dann mehrere Prozesse vor, aber immer noch nur ein Programm.

Threads

Threads sind parallel ablaufende Aktivitäten innerhalb einer gemeinsamen Prozessumgebung. Der Code einzelner Threads ist Teil des bereits geladenen Programms. Jeder Thread gehört damit zu einem bestimmten Prozess.

.reference[bsdp] .centered.width80[

] .figure[Varianten der Software-Parallelität]

Nebenläufigkeit

Nebenläufigkeit (concurrency) bedeutet, dass die Software mehrere Programmanweisungen gleichzeitig ausführen kann (abstrakte Parallelität). Parallelität im strikten Sinne bezieht sich hingegen auf die tatsächlich gleichzeitige Ausführung (konkrete Parallelität). Eine weniger strikte Definition der Parallelität schlie0t die Nebenläufigkeit ein, da dies Praxisgebrauch darstellt.

Task

Primär ist diese Bezeichnung bei eingebetteten Systemen (embedded systems) üblich und bezeichnet dort parallel ausgeführte Programmteile. Einige Betriebssysteme benutzen den Begriff Task hingegen als einen Oberbegriff, der Prozesse und Threads umfasst.

Job und Session

Darunter sind voneinander unabhängige und gegenseitig geschützte Anwendungen auf einer Rechneranlage zu verstehen. Eine derartige Anwendung besteht dabei aus einer Gruppe von Prozessen.

Echtzeitbetrieb

Echtzeitbetrieb ist ein Betrieb eines Rechensystems, bei dem Programme zur Verarbeitung anfallender Daten ständig betriebsbereit sind derart, dass die Verarbeitungsergebnisse innerhalb einer vorgegebenen Zeitspanne verfügbar sind. Die Daten können je nach Anwendungsfall nach einer zufälligen zeitlichen Verteilung oder zu vorbestimmten Zeitpunkten auftreten (DIN 44 300).

Man unterscheidet harte und weiche Echtzeitsysteme. Das Überschreiten von Zeitgrenzen (von Prozessen) wird im Softbetrieb toleriert, im Hartbetrieb wird diese als Systemfehler gewertet mit Anhalten der Rechneranlage.

Prozess

Ein Prozess i hat einen Zustand Σi. Ein Prozess in Ausführung ändert seinen Zustand.

Der Prozesszustand setzt sich aus zwei Teilzuständen zusammen:

.box[

Kontrollzustand γ, gegeben durch den Befehlszähler;

Datenzustand σ, gegeben durch alle Prozessvariablen. ]

Der Prozess ist ein Grundbaustein der Parallelverarbeitung mit einem Betriebssystem.

Das Prozessmodell ist daher ein zentrales Konzept moderner Betriebssysteme. Ergänzt wird es durch das Thread-Modell, das für die Parallelisierung innerhalb von Applikationen die optimalere Lösung darstellt.

Ein Prozess kann sich in drei wesentlichen Zuständen befinden:

.box[

Ready: Rechenbereit, aber nicht in Ausführung durch einen Prozessor;

Run: In Ausführung durch einen Prozessor; wesentlich Berechnung;

Wait: Wartend (auf Ereignisse). ]

Das Warten bedeutet i.A. Ein- und Ausgabe, Kommunikation oder die Verzögerung

Es gibt zwei Arten um auf Ereignisse zu warten:

.box[

Durch wiederholte Abfrage (Polling);

Durch Prozessblockierung (Suspendierung) und Wiederaufnahme (Resume) beim Eintreten des Ereignisses (Interrupt). ]

Ereignisse

Man kann verschiedene Klassen von Ereignissen unterscheiden, die von Prozessen berarbeitet werden:

.box[

Daten oder Ereignisse von Kommunikationsgeräten (WIFI/WLAN, Bluetooth, USB, Scanner, Drucker "kein Papier");

Zeitablauf (Verzögerung, Timer);

Synchronisation zwischen Prozessen, z.B. Steuerung einer Warteschlange, Bereistellen von Daten zur Abholung usw.;

Wettbewerbsauflösung durch Sequenzialisierung, d.h., Nutzung geteilter Ressourcen. ]

Warten auf Ereignisse bedingt das Anhalten des Kontrollflusses eine Prozesses (Prozessblockierung), wenn auch beim Pollingbetrieb nur eine wiedeholende Schleife den Programmfortschritt blockiert.

Prozesszustände

.col1.width70[ .centered.width85[

] ] .col2.width30[.reference[bsbrause].figure[(a) Allgemein Prozesszustände und Übergänge (b) Prozesszustände und Übergänge bei UNIX (c) Prozesszustände in Windows NT]]

Die Zustände „bereit“ und „blockiert“ enthalten eine oder mehrere Warteschlangen (Listen), in die die Prozesse mit diesem Zustand eingetragen werden.

Der bereit-Zustand ist besonders ausgezeichnet: Alle Prozesse, die Ereignisse erhalten und so entblockt werden, werden zunächst in die bereit-Liste (ready-queue) verschoben und erhalten dann in der Reihenfolge den Prozessor zur Ausführung.

Prozessmodell

Das Prozessmodell ist ein zentrales Konzept der meisten Betriebssysteme.

Kerneigenschaft dabei ist, dass jeder Prozess virtuell den ganzen Rechner für sich alleine zur Verfügung hat, d.h., er kann den Adressraum frei belegen (abgesehen von reservierten Systembereichen) und kann die Prozessorregister nach Belieben benutzen.

Das Prozessmodell realisiert durch die nebenläufige Ausführung mehrerer Prozesse den Mehrprogrammbetrieb.

In einem Einprozessorsystem wird eine Aufteilung der Rechenzeit auf mehrere ablaufwillige Prozesse durchgeführt.

Es findet dabei ein Zeitmultiplex des Prozessors statt.

Dies ergibt für den Benutzer die Illusion der echt parallelen Programmausführung, obwohl zu jedem Zeitpunkt nur immer gerade ein einziges Programm von der CPU abgearbeitet wird.

Auf Multiprozessorsystemen ist eine mehrheitlich gleichzeitige Ausführung von Programmen möglich.

Prozessverwaltung

.shadowbox[

Die Programme und damit die Prozesse existieren nicht ewig, sondern werden irgendwann erzeugt, in eine Wartschlange eingereiht und auch beendet.

Dabei verwalten die Prozesse aus Sicherheitsgründen sich nicht selbst, sondern das Einordnen in die Warteschlangen wird von einer besonderen Instanz des Betriebssystems, dem Scheduler, nach einer Strategie geplant.

Bei einigen Betriebssystemen gibt es darüber hinaus eine eigene Instanz, den Dispatcher, der das eigentliche Überführen von einem Zustand in den nächsten bewirkt. ]

Im Unterschied zu dem Maschinencode werden die Zustandsdaten der Hardware (CPU, FPU, MMU), mit denen der Prozess arbeitet, als Prozesskontext bezeichnet.

Der Teil der Daten, der bei einem blockierten Prozess den letzten Zustand der CPU enthält und damit wie ein Abbild der CPU ist, kann als virtueller Prozessor angesehen werden und muss bei einer Umschaltung zu einem anderen Prozess bzw. Kontext (context switch) neu geladen werden.

Die verschiedenen Betriebssysteme differieren in der Zahl der Ereignisse, auf die gewartet werden kann, und der Anzahl und Typen von Warteschlangen, in denen gewartet werden kann. Sie unterscheiden sich auch darin, welche Strategien sie für das Erzeugen und Terminieren von Prozessen sowie die Zuteilung und Einordnung in Wartelisten vorsehen.

Prozessumschaltung

Synonym für Scheduling oder Prozessmultiplexing

Multiplexing: Die wechselseitige Nutzung einer geteilten Ressource, z.B. eines Funkkanals, Datenbusses, Prozessors, oder des Speichers. Beim Prozessor handelt es sich um Zeitmultiplexing.

.centered.width80[

] .figure[Kanalmultiplexing (grün) im Vergleich zum Prozessmultiplexing (rot). Im Grunde werden Daten geschaltet.]

.centered.width80[

] .figure[Multiprozessorsysteme können als multiple Kommunikationskanäle verstanden werden.]

Die Prozessumschaltung bewirkt letztlich eine wechselseitige Verarbeitung von Datenströmen.

Prozessgruppen

Prozesse können einzeln gestartet oder in Gruppen zugeordnet werden.

In Unix gibt es die Eltern-Kind Prozessgruppe, unter Windows nicht.

Ein Kindprozess wird i.A. als Kopie des Elternprozesses erzeugt (Forking)

.centered.width60[

] .figure[Prozessverdoppelung bei fork(). PID: Process Identifier Number (numerischer ganzzahliger Wert der einen Prozess im System eindeutig identifiziert)]

Prozessumschaltung

.centered.width80[

] .figure5-5

Prozessverwaltung

.reference[bsdp] .centered.width80[

] .figure[Mehrprogrammbetrieb mit vier Prozessen (Beispiel)]

Prozessumschaltung

Prozesswechsel benötigt eien Verwaltungstabelle für Prozesse mit einem Process Control Block (PCB), kann noch durch erweiterte Tabellen überdeckt sein.

Ablauf für einen Prozesswechse:

Der laufende Prozess wird gestoppt (»eingefroren«): Damit übernimmt der Prozesswechselcode des Betriebssystems die Kontrolle.

Kontextsicherung des aktuellen Prozesses: Die aktuellen CPU-Registerinhalte werden aus der CPU in den PCB1 kopiert.

Auswahl nächster Prozess: Entsprechend einer Prozessorzuteilungsstrategie wird der nächste auszuführende Prozess bestimmt (sog. CPU-Scheduling).

Kontextwiederherstellung neuer Prozess: Die gesicherten CPU-Registerinhalte werden aus dem PCB2 in die CPU kopiert.

Neuer Prozess wird gestartet (»aufgetaut«): die Kontrolle wird neuem Prozess übergeben, der am letzten Unterbrechungspunkt fortfährt.

.reference[bsdp] .centered.width80[

] .figure[Prozessumschaltung und Rolle des PCB in fünf Schritten (vereinfachtes Beispiel)]

Vordergrund/Hintergrund System

Das einfachste Betriebssystem ist keines (im Sinne eines eigenständigen Programms). vor allem in Eingebetteten Systemen (Einplatinenrechner, Mikrokontroller) kommt das Einprogrammsystem zum Einsatz.

Es gibt nur ein Programm welches monolithisch verschiedene Aufgaben ausführt:

Berechnung

Ein- und Ausgabe

Kommunikation

Jetzt gibt es aber ein Problem: Wie soll ein Programm ohne Prozesse diese verschiedenen Aufgaben nebenläufig (quasi parallel) ausführen?

Das Warten auf Ereignisse müsste "parallel" im Polling geschehen

Wenn in Berechnung dann können keine Ereignisse überwacht und verarbeitet werden.

Besser: Hardwareunterstützung durch den Prozessor mit Interruptbetrieb

.compact[

Durch den Interruptbetrieb kann das Programm rechnen und beim Eintreten einen Ereignisses (Daten verfügbar, Ressource verfügbar, Fehler usw.) verzweigt der Prozessor in ein Unterpogramm welches das Ereignis bearbeitet.]

Kurios: Das Hauptprogramm ist ein Hintergrundprozess, das Interrupt Unterprogramm (Interrupthandler) ist ein Vordergrundprozess mit Kontextwechsel!

Grund: Interrupt Unterprogramme werden präemptiv aufgerufen, d.h. es wird das Hauptprogramm "sofort" unterbrochen und in das Unterpogramm verzweigt.

Der Interrupthandler hat die höhere Priorität und ist daher (wenn auch selten aktiv) der Vordergrundprozess.

Aber: Präemption bedeutet Gefahr der Dateninkonsistenz. Beim (partiellen) Kontextwechsel werden nur einige Register gesichert, einige werden vom Hauptprogramm weiter verwendet und dürfen nicht modifiziert werden.

Daher sollte in den Interrupt Unterprogrammen nur das Ereignis (beim Gerät) bestätigt werden und das Hauptprogramm über Speichervariablen über das Ereignis informiert werden. Den Rest muss das Hauptprogramm bearbeiten.

Main IO Loop

Wenn wir ein Einprogrammsystem betrachten dann muss es ungeachtet von Interruptbetrieb eine Hauptschleife geben die Berechnung und Ereignisverarbeitung "fair" aufteilt.

Ansonsten müsste das Hauptprogramm ständig nach Ereignissen fragen (Verteiltes Polling)

void setup() {
  Startcode
}
void loop () {
  if (Ereignis) handle(Ereignis)
  if (Berechnung) process(Berechnung)
}
setup();
while (1) loop();

.code[Zwei Funktionen (Arduino Style!): setup wird einmal zu Beginn, und loop "endlos" immer wieder aufgerufen.]

Damit die Hauptschleife durchläuft muss die eigentliche Berechnung gestückelt sein. I.a. folgt die Berechnung auf Ereignisse.

int timeout=0;
struct list { int type; struct list *next };
void add(struct list* l, int ev) { add event ev to list l };
void loop () {
  int now=milliseconds();
  struct list events, eventp=events;
  if (Serial1.available) add(events,EV1);
  if (Serial2.available) add(events,EV2);
  if (timeout!=0 && timeout<now) { timeout=0; add(events,EV3); }
  ...
  while (eventp) {
    switch (eventp->type) {
      case EV1: procev1(); domore(); break;
      case EV2: procev2(); break;
      ...
    }
    eventp=eventp->next;
  }
}

Event Handler (Ein- und Ausgabe usw.) haben nur kurze Laufzeit, können aber häufig aufgereufen werden. Die Laufzeit kann durch Monitoring abgeschätzt werden (Min., Mittelwert, Max.).

Berechnungsfunktionen können beliebig lange laufen, die Laufzeit ist i.A. nicht über Monitoring abschätzbar.

Ein großes Problem (nicht nur in Echtzteitbetriebssystemen): Eine lange Laufzeit in einer Berechnung kann die Ereignsiverarbeitung derart einschränken dass es zu Verlusten von Ereignissen und Daten kommen kann. Es können sogar Hardware Geräte in einen blockierten/unbrauchbaren Zustand übergehen!

Und noch schlimmer: Die meisten Programmiersprachen erlauben verschachtelte Funktionen. Eine lineare Liste von Instruktionen können unterbrochen werden, eine tief aufgerufene Funktion nicht ohne weiteres.

Es gibt zwei Lösungen:

Präemptives Zeitscheibenverfahren sofern es möglich ist aus Funktionen heraus zu springen und später zurück zukehren ("Long Jumps");

Transformation einer ganzen Berechung (funktionale/prozedural) in eine lineare Liste von abstrakten Instruktionen die von einer Virtuellen Maschine sukzessive verarbeitet werden kann.

Eine einfache VM

Jedes zusammenhängende (atomare) Syntaxelement einer fiktiven Programmiersprache, sagen wir Basic, wird einen Sequenz von Tokens (Symbolen) transformiert.

a=1             LHS VAR a 1 EOL
b=2             LHS VAR b 2 EOL
x=3             LHS VAR x 3 EOL
for i = 1 to 10 FOR VAR i 1 10 EOL
  x=x+i+a+b     LHS VAR ADD VAR x ADD VAR a ADD VAR b
next i          NEXT i
print x         PRINT VAR X

.example[Transformation eines Programms in eine lineare Folge von Symbolen]

Die einzelnen Tokens können nun linear von der VM abgearbeitet werden und jederzeit unterbrochen werden: Mikrostepping.

Die Tokens können direkt in einem Speicherarray abgelegt (kompiliert) werden

// VM state
int pc; int sp; uint16_t mem[SIZE]; // .....
// VM Execution Loop
int vmloop(steps) {
  for (int step=0;step<steps;step++) {
    int next=mem[pc++];
    switch (next) {
      case LHS:    xassign(); break;
      case VAR:    xvar();    break;
      case PRINT:  xprint();  break;
      case DELAY:  xdelay();  return -1;
      case END:    xend();    return 0;
      ...
    }
    if (interrupt) return 1;
  }
  return 1;
}

.example[VM Execution Loop]

// Interrupt Handler
int interrupt=0,
    pendingtokens=0,
    runnable=0;
void handleIRQn() { interrupt++; }
// IO Loop
void loop () {
  if (interrupt) handle interrupts;
  interrupt=0;
  if (events) handle events;
  if (pendingtokens && runnable) {
    status=vmloop(STEPSMAX);
    if (status!=0) pendingtokens=1;
    else           pendingtokens=0;
    if (status>0)  runnable=1;
    else           runnable=0;
  }
  if (newcode) {
    tokenize(newcode);
    pendingtokens=1;
    runnable=1;
  }
  ...
}
while (1) loop();

.example[Einbindung einer VM Execution Loop in eine IO Loop]

Prozesserzeugung und Terminierung

Prozessstart

In sehr einfachen Systemen, z.B. in vielen eingebetteten Systemen, existieren alle Prozesse von Anfang an und laufen ewig, d.h., solange das System läuft. Auf Universalrechnern (general-purpose computers) ist es hingegen notwendig, Prozesse bei Bedarf zu erzeugen und gelegentlich wieder zu terminieren. Auslösende Ereignisse für die Prozess- bzw. Thread-Erzeugung können dabei sein:

Systemstart (Initialisierung)

Systemdienstaufruf zur Prozesserzeugung durch irgendeinen laufenden Prozess

Benutzeranforderung zum Starten eines neuen Prozesses (Applikationsstart)

Auslösung eines Stapelauftrags (batch job)

Es gibt drei grundlegende Möglichkeiten für den Start eines neuen Prozesses:

Prozessverkettung (chaining): Der laufende Prozess startet einen neuen Prozess und terminiert sich damit selbst. Der Code des neuen Prozesses ist unabhängig (z.B. in separater ausführbarer Datei vorliegend).

Prozessvergabelung (forking): Der laufende Prozess startet einen neuen Prozess, läuft selbst aber weiter. Der Code beider Prozesse ist gemeinsam.

Prozesserzeugung (creation): Der laufende Prozess startet einen unabhängigen neuen Prozess. Der Code beider Prozesse ist unabhängig (z.B. in zwei separaten ausführbaren Dateien vorliegend).

.centered.width60[

] .figure[Prozessstartformen: (a) Prozessverkettung, Sequenz (b) Prozessvergabelung, Forking (c) Unabhängige Prozesserzeugung, Create ]

Prozessbeendigung

Neben der Erzeugung interessiert auch die Terminierung von Prozessen. Übliche Formen sind:

Normale Beendigung (freiwillig)

Vorzeitige Beendigung bei einem durch den Prozess selbst erkannten Fehler (freiwillig)

Vorzeitige Beendigung bei einem katastrophalen Fehler, erkannt durch das System (unfreiwillig)

Terminierung durch einen anderen Prozess (unfreiwillig)

.centered.width50[

] .figure[Prozessvereinigungsformen: Prozesse können auf andere warten.]

Prozessverwaltung: API

.centered.width80[

] .figure[(a) Systemdienstaufrufe für Prozess-/Thread-Start (b) Weitergabe von Daten an neue Prozesse/Threads (c) Systemdienstaufrufe für die Prozess-/Thread-Vereinigung]

Prozessorzuteilungsstrategien

Für die Ausführung paralleler Prozesse wäre idealerweise pro Prozess ein eigener Prozessor nötig. Da dies technisch nicht sinnvoll ist und nicht skalierbar ist (oder sehr aufwendig ist), hat man Verfahren entwickelt, die es erlauben, mehrere Prozesse quasi gleichzeitig auf dem gleichen Prozessor im Zeitmultiplexverfahren auszuführen.

Aber wie sollen die Prozesse der Ressource Prozessor zugeteilt werden, und wie lange sollen oder dürfen sie den Prozessor belegen? Reicht kooperatives Scheduling (Prozesse geben freiwillig die Ressource zurück, yielding), oder muss das Betriebssystem dieses erzwingen?

Die Prozesszuteilung erfolgt nach einer Strategie und einem Algorithmus.

Die Prozesszuteilungsstrategie gilt gleichermaßen für voll Prozesse und dür Subprozesse (Threads).

.reference[bsdp] .centered.width80[

] .figure[Vergleich echte 1:1 Prallelitäz versa "simulierte" N:M Parellelität durch Zeitmultiplexing]

Welchen Nachteil hat die 1:1 Zeuteilung (von der nicht vorhandenen Skalierbarkeit abgesehn)?

Ein wichtiger Faktor ist die Auslastung einer Rechneranlage, die wie bei einer Heizungsanlage 100% betragen sollte (d.h. durchgehender Rechenbetrieb).

Bei einer festen 1:1 Zuteilung würden Prozessoren beim Warten von Prozessen auf Ereignisse untätig sein - keine Auslastung von 100%!

Wie können nun N Prozesse auf M Prozessoren verteilt werden?

Priorität & Präemption

Die Auswahl kann von der Wichtigkeit von Prozessen beeinflusst werden, d.h. die Priorität, eine Ganzzahl.

Je größer die Priorität, desto eher könnte der Prozess ausgewählt werden oder

ein Prozess niedrigere Priorität könnte beim Bereitsein eines Prozesse höherer Priorität unterbrochen werden, d.h. es findet Präemption statt.

Pritoritäten haben Einfluss auf das Zustandsmodell von Prozessen

.centered.width60[

] .figure[Grundmodell mit drei Zuständen]

.centered.width70[

] .figure[Erweitertes Modell der Prozesszustände vier Zuständen und Priorität]

Warteschlangen

Für die Prozesszuteilung und dem Prozessscheduling gibt es mehrere Warteschlangen, d.h. im i.A. eine Warteschalnge pro Prozesszustand:

Bereit

Laufend

Blockiert

...

.centered.width80[

] .figure[Konzeptionelle Prozessverwaltung (Prozessorganisation)]

.compact[

Ein Prozess ist entweder der CPU zugeteilt oder in einer der Ressourcen-Warteschlangen

Es findet ein Wechsel der Prozesse zwischen den Warteschlangen je nach Prozesszustand statt.

Die Prozessorzuteilungsstrategie (CPU scheduling) legt fest, welcher Prozess als nächster die CPU zugeteilt erhält.

Der ausgewählte Prozess wird aus der Bereitliste entfernt und der CPU zugeordnet.

Die Reihenfolge der Prozesse in der Bereitliste wird hierbei strategieabhängig unterschiedlich berücksichtigt.

.box[

Gibt ein laufender Prozess freiwillig die CPU ab, so wechselt er in die Bereitliste an die vorderste Position.

Wird einem Prozess die CPU entzogen, weil er sein Zeitquantum erschöpft hat (Präemption), dann wechselt er in die Bereitliste an die hinterste Position seiner Priorität.

Wenn der laufende Prozess auf eine Ressource/ein Ereignis zu warten beginnt, so wechselt er in die zugehörige Warteschlange. ] ]

Welcher Prozess aus einer Ressourcen-Warteschlange die frei werdende Ressource erhält, ist eine Frage der E/A-Zuteilungsstrategie (I/O scheduling).

Diese kann ressourcenabhängig unterschiedlich sein.

Wie sollen nun Prozesse aus der Bereitwarteschlange der CPU zugeteilt werden? Was sind die Ziele?

Prozesszuteilung

Man unterscheidet zunächst:

Process based scheduling: Findet die Prozessorzuteilung nur zu ganzen Prozessen statt, so kennt der Systemkern keine Threads (single-threaded process). Dies bedingt ein Multithreading vollständig im Benutzermodus (user level threads).

Thread based scheduling: Findet die Prozessorzuteilung zu einzelnen Threads statt, so sind unterschiedliche Varianten des Multithreading möglich. Sowohl ein Multithreading auf der Benutzerebene als auch die direkte Nutzung von KL-Threads (kernel level threads) sowie verschiedene Zwischenformen sind denkbar.

Es gibt wesentlich zwei Arten von Prozessausführung (auch abwechselnd):

Betrachtet man typische Prozessabläufe, so kann man zwei wichtige Grundmuster (pattern) unterscheiden (siehe auch Abb. 4–31):

CPU-lastig (CPU-bound): Die Aktivität nutzt viel Rechenzeit und wartet im Vergleich dazu selten auf die Ein-/Ausgabe.

E/A-lastig (I/O-bound): Die Aktivität rechnet wenig, wartet hauptsächlich auf die Ein-/Ausgabe (Input/Output), d.h. nutzt Peripherie.

Generell können Wartezeiten auf die Ein-/Ausgabe für die Ausführung parallel laufender Berechnungen genutzt werden. Besonders lohnend ist dies für »E/A-lastige« Aktivitäten. Grundprinzip der asynchronen E/A, z.B. in virtuellen Maschinen.

E/A-lastig versa CPU-lastig hat große Auswirkung auf das Scheduling!

.centered.width100[

] .figure[Prozessablaufmuster (E/A- bzw. CPU-lastig)]

Bei einer reinen Berechnung spielen Verzögerung i.A. ein nicht so große Rolle als wie bei der Verarbeitung von E/A Ereignissen!

Optimierungsziele

Für eine optimale Zuteilung der CPU zu rechenwilligen Prozessen wurde eine Vielzahl an Strategien entwickelt. Diese Vielfalt kommt daher, dass je nach verfolgten Optimierungszielen unterschiedliche Lösungen optimal sind. Aus Sicht des Anwenders können folgende Ziele wichtig sein:

Durchlaufzeit (turnaround time): Gesamtzeit von Prozessstart bis Prozessbeendigung

Antwortzeit (response time): Zeit zwischen einer Eingabe und der Reaktion des Systems darauf

Endtermin (deadline): Zeitpunkt, zu dem eine vorgegebene Aktion erfolgt sein muss

Aus Sicht eines optimalen Ressourceneinsatzes können weitere Ziele interessant sein, wie Vorhersagbarkeit, Durchsatz (Anzahl erledigter Aufträge pro Zeiteinheit) und Prozessorauslastung.

Verdrängende und nicht verdrängende Zuteilungsstrategien

Eine Zuteilungsstrategie wählt aus der Menge aller Prozesse, die sich im »Bereit«-Zustand befinden, einen bestimmten Prozess aus. Zuteilungsstrategien unterscheiden sich durch das maßgebende Auswahlkriterium.

Wichtig ist dabei, zu verstehen, dass ein zum Auswahlzeitpunkt im Zustand »Laufend« befindlicher Prozess an dieser Auswahl ebenfalls teilnimmt.

Damit kann der Auswahlentscheid auch lauten, dass kein Prozesswechsel stattfindet, sondern dem gleichen Prozess weiter die CPU zugeteilt wird.

Eine weitere spezielle Situation liegt vor, wenn der laufende Prozess in den Zustand »Wartend« gewechselt hat.

In diesem Fall ist das Ziel der Auswahl auf jeden Fall die Zuteilung der CPU zu einem neuen Prozess.

Eine solche Auswahl eines Prozesses, der als nächster laufen soll, wird als Neuzuteilung bezeichnet (rescheduling). Eine Neuzuteilung kann auch die Auswahl des bereits laufenden Prozesses bedeuten.

Zuteilungszeitpunkte

.shadowbox[

Ein Prozess wechselt vom Zustand »Laufend« in den Zustand »Wartend« (dadurch wird die CPU frei und kann neu zugeteilt werden).

Ein Prozess wechselt vom Zustand »Wartend« in den Zustand »Bereit« (dadurch könnte eine Prioritätsregel verlangen, dass dieser Prozess Vorrang vor dem laufenden Prozess erhält).

Ein Prozess wechselt vom Zustand »Laufend« in den Zustand »Bereit« (dies könnte nach Ablauf einer bestimmten Zeit von einer Zeitquotenregel erzwungen werden).

Ein Prozess wechselt von einem der Zustände »Laufend«/»Bereit«/»Wartend« in den Zustand »Inaktiv« (dies wäre eine freiwillige oder erzwungene Prozessbeendigung).

Ein Prozess wechselt vom Zustand »Inaktiv« in den Zustand »Bereit« (dies trifft für einen neu gestarteten Prozess zu, der durch eine Prioritätsregel den Vorrang vor einem laufenden Prozess erhalten könnte). ]

Eine Zuteilungsstrategie gilt als nicht verdrängend (nonpreemptive), wenn die Neuzuteilung nur zum 1. und 4. Zeitpunkt stattfindet. Eine nicht verdrängende Neuzuteilung hat die Eigenschaft, dass sie kooperatives Verhalten der laufenden Prozesse erwartet. Beansprucht ein laufender Prozess für sehr lange Zeit fortlaufend immer die CPU, so haben ablaufbereite Prozesse keine Möglichkeit, dies zu ändern. Dies gilt nicht für verdrängende (preemptive) Zuteilungsstrategien, da bei ihnen eine Neuzuteilung zu allen fünf oben aufgeführten Zeitpunkten stattfindet.

FIFO Strategie

Die FIFO-Strategie (First In First Out) ist auch unter dem Namen FCFS (First Come First Served) bekannt. Die Reihenfolge der Prozessausführung richtet sich streng nach der Reihenfolge, in der die Prozesse ablaufbereit wurden (d.h. Wechsel in den Zustand »Bereit«).

Es handelt sich um eine nicht verdrängende Strategie, sodass neu ablaufbereite Prozesse keine Neuzuteilung zur Folge haben.

Man bezeichnet dies auch als kooperative Zuteilung, da eine Neuzuteilung erst möglich wird, wenn der laufende Prozess die CPU freiwillig freigibt.

Diese einfache Strategie erlaubt einen minimalen Verwaltungsaufwand. Damit eine Neuzuteilung des Prozessors stattfinden kann, muss der laufende Prozess entweder zu warten beginnen oder seine Ausführung terminieren.

Alle Prozesse besitzen die gleiche Priorität. Dieses Verfahren funktioniert nur so lange, bis ein Prozess die CPU nicht mehr abgibt.

Die Reaktionszeiten hängen zudem direkt von den durch die einzelnen Prozesse bedingten Laufzeiten ab (sog. Konvoieffekt).

.centered.width100[

] .figure[Beispielszenario mit FIFO-Strategie]

.up.compact.smaller[

Ein Prozess A startet zum Zeitpunkt 0 und läuft für 40 Zeiteinheiten. Dann wartet er auf eine Ein-/Ausgabe, die nach 50 Zeiteinheiten abgeschlossen ist. Dadurch wird er wieder ablaufbereit und belegt seine restlichen 30 Zeiteinheiten.

Die Wartezeit (hier 50) für den Prozess A startet von dem Zeitpunkt an, wenn der Prozess blockiert, weil ein Prozess eine Ein-/Ausgabe anfordert und dann für die Dauer der Ein-/Ausgabe warten muss. Die Verzögerung (hier 40) startet ab dem Eintreten des Eireignisses.

Prozess B startet nach 20 Zeiteinheiten und will dann die CPU für 90 Zeiteinheiten belegen.

Prozess C startet nach 30 Zeiteinheiten und benötigt ab dann die CPU für 30 Zeiteinheiten. ]

SJF Strategie

Bei der nicht verdrängenden Strategie SJF (Shortest Job First), die auch unter dem Namen SPN (Shortest Process Next) fungiert, wird bei einer Neuzuteilung derjenige Prozess ausgewählt, der den kleinsten erwarteten Rechenzeitbedarf hat.

Diese Strategie lässt sich naturgemäß nur anwenden, wenn Angaben über den Rechenzeitbedarf zum Entscheidungszeitpunkt dem System zugänglich sind.

Der Benutzer kann Angaben zur Laufzeit machen

Aaus der Vergangenheit kann eine Vorhersage gemacht werden (durch Messung). Besonders bei E/A-lastigen Prozessen kann das System repräsentative Daten über den vergangenen Rechenzeitbedarf sammeln. Diese Daten ermöglichen dann eine Hochrechnung, deren Qualität letztlich durch die Vorhersagbarkeit des zukünftigen Prozessverhaltens bestimmt wird.

.centered.width100[

] .figure[Beispielszenario mit SJF-Strategie]

E/A Reaktionszeiten können auch hier schlecht sein (lange Verzögerung)

SRT Strategie

Die Strategie SRT (Shortest Remaining Time) stellt eine Abwandlung des SJF-Verfahrens dar. Bei einer Neuzuteilung wird derjenige Prozess ausgewählt, der den kleinsten verbleibenden Rechenzeitbedarf hat.

Da es sich um eine verdrängende Strategie handelt, kann eine Neuzuteilung einem laufenden Prozess die CPU entziehen, wenn ein anderer Prozess weniger Rechenzeit benötigt. - Im Übrigen gelten die gleichen Einschränkungen wie bei der SJF-Strategie.

.up.centered.width85[

] .up.figure[Beispielszenario mit SRT-Strategie]

.up.compact.smaller[

Es fällt auf, dass zu den Zeitpunkten 20 und 30 jeweils eine Neuzuteilung stattfindet. Da aber Prozess A in beiden Fällen den geringsten verbleibenden Rechenzeitbedarf hat, wird er weder durch B noch durch C verdrängt.

Erst zum Zeitpunkt 40 findet ein Prozesswechsel statt, da A zu warten beginnt. Zu diesem Zeitpunkt erhält Prozess C den Vorrang, da er weniger Rechenzeit benötigt als B.

Nach dem Prozessende von C wird die CPU frei und kann daher dem einzigen dann ablaufbereiten Prozess B zugeteilt werden.

Zum Zeitpunkt 90 wird A wieder ablaufbereit, da die Ein-/Ausgabe abgeschlossen ist.

Da Prozess A einen geringeren Restzeitbedarf aufweist als B, erfolgt eine Verdrängung. Prozess B kann erst nach dem Prozessende von A seine restliche Rechenzeit beanspruchen. ]

RR Strategie

Bei der RR-Strategie (Round Robin, im Kreis herum) erhält jeder Prozess ein Quantum an Rechenzeit zugeteilt, für das er ununterbrochen die CPU belegen kann.

.compact.up[

Periodisch (z.B. alle 10 ms, "tick") wird der Zeitquantumszähler des laufenden Prozesses dekrementiert.

Fällt damit sein Zeitquantum auf null, so wird eine Neuzuteilung veranlasst.

Enthält die Liste der ablaufbereiten Prozesse mindestens einen Eintrag, so wird der laufende Prozess verdrängt. Er wechselt damit in den Zustand »Bereit« und wird zuhinterst in der Liste ablaufbereiter Prozesse eingetragen.

Danach wird der vorderste Prozess der Liste entnommen, sein Zeitquantumszähler auf ein volles Zeitquantum gesetzt und ihm die CPU zugeteilt.

Ist die Liste der ablaufbereiten Prozesse leer, wird der Zeitquantumszähler des laufenden Prozesses wieder auf ein volles Zeitquantum gesetzt und belegt die CPU weiter.

Neu gestartete Prozesse werden anfänglich in den »Bereit«-Zustand versetzt und zuhinterst in die Liste der ablaufbereiten Prozesse eingereiht.

Diese Strategie versucht die Rechenzeit möglichst gleichmäßig auf alle ablaufbereiten Prozesse zu verteilen, indem sich diese zyklisch abwechseln. ]

Da jedoch das Dekrementieren des Zeitquantumszählers nur in einem festen Zeitraster erfolgt, werden Prozesse benachteiligt, die erst kurz vor dieser periodischen Prüfung die CPU zugeteilt bekamen.

Ihr Zeitquantum ist damit um maximal eine Zeitscheibe verkürzt.

Diesem Effekt kann begegnet werden, indem ein volles Zeitquantum mehrere Zeitscheiben umfasst (z.B. 10 ticks).

Die Implementierung des RR-Verfahrens kann unterschiedlich aufwendig sein.

Eine einfache Implementierung wird einen festen Wert für das volle Zeitquantum nutzen. Wird ein volles Zeitquantum nur eine einzige Zeitscheibe groß gewählt, so stellt dies ebenfalls eine Vereinfachung dar.

Andererseits lässt sich das Verfahren verfeinern, indem prozessabhängig oder lastabhängig der Wert für ein volles Zeitquantum variiert wird.

Eine weitere Variante teilt Vordergrundprozessen ein größeres Zeitquantum zu und erlaubt ihnen so, möglichst gut auf Benutzereingaben zu reagieren.

Das RR-Verfahren geht davon aus, dass alle Prozesse zu jedem Zeitpunkt gleich wichtig sind, womit sich nur einfachere Systeme bauen lassen.

Vorteilhaft ist der vergleichsweise bescheidene Verwaltungsaufwand.

Ein weiterer Vorteil ist, dass einzelne Prozesse mit Busy Waits (E/A Polling, Schleifen) das System nicht lahmlegen können.

Neuzuteilungen finden immer dann statt, wenn der laufende Prozess die CPU freigibt (zu warten beginnt oder terminiert) oder wenn sein Zeitquantum erschöpft ist.

Dieses Verfahren baut auf dem FIFO-Prinzip (kooperative Zuteilung) auf und erweitert es um die Zeitquantenumschaltung.

.up.centered.width85[

] .up.figure[Beispielszenario mit RR-Strategie: Zeitscheibengröße 40. Erreicht ein Prozess laufend das Zeitscheibenende, so wird ihm die CPU entzogen und dem nächsten bereiten Prozess zugeteilt.]

.up.compact.smaller[

Die Zeitscheibengröße wird exemplarisch auf 40 Zeiteinheiten festgelegt und das volle Zeitquantum ist eine Zeitscheibe. Implementierung durch periodischen Uhreninterrupt.

Am Anfang ist Prozess A lauffähig und erhält die CPU zugeteilt. Nach 20 bzw. 30 Zeiteinheiten werden B und C ablaufbereit. Dies ändert jedoch nichts, da Verdrängung nur bei Ablauf eines Zeitquantums möglich ist.

Zum Zeitpunkt 40 beginnt Prozess A zu warten, wodurch die CPU frei wird. Da Prozess B vor Prozess C ablaufbereit wurde, kann jetzt B laufen (erhält ein volles Zeitquantum von eins).

Zum Zeitpunkt 80 ist das Zeitquantum von B erschöpft, womit C die CPU zugeteilt bekommt.

Kurz darauf zum Zeitpunkt 90 wird A wieder ablaufbereit nach abgeschlossener Ein-/Ausgabe. Prozess A wird hinter B in der Bereitliste eingereiht. ]

.up.centered.width85[

] .up.figure[Beispielszenario mit erweiterter RR-Strategie: Zeitscheibengröße 20, wobei jeder Prozess jeweils über ein Zeitquantum von 2 verfügt. Erreicht ein Prozess laufend zweimal das Zeitscheibenende, so wird ihm die CPU entzogen und dem nächsten bereiten Prozess zugeteilt.]

Kleinere Zeitscheibengrößen bei höherer Anzahl von Zeitscheiben kann zu einer schlechteren E/A Reaktionszeit führen. Die Wartezeit von bereiten Prozessen kann sich verbesssern. Das hängt aber vom aktuellen Zustand der Prozesse und der Wartelisten ab!.

ML Strategie

.compact[ Die ML Strategie (Multi-Level Priority) basiert auf Prioritäten, die den einzelnen Prozessen zugeordnet sind. Diese Prioritätszuordnung obliegt dem Anwender, der damit die Wichtigkeit der einzelnen Prozesse bestimmt.

Die Priorität wird meistens mit Zahlenwerten umschrieben, z.B. 0..100. Ob eine hohe Zahl einer hohen Priorität entspricht oder umgekehrt, ist eine Implementationsfrage.

Typischerweise ist diese Strategie verdrängend realisiert (Präemtpion), sodass stets derjenige ablaufbereite Prozess die CPU besitzt, der die höchste Priorität hat.

Sind mehrere Prozesse auf dieser Prioritätsstufe ablaufbereit, so gilt sekundär das FIFO-Prinzip.

Nachteilig bei dieser Strategie ist, dass die CPU von höherpriorisierten Prozessen monopolisiert werden kann. Dies ist dann der Fall, wenn diese zu oft ablaufbereit sind, sodass niederpriore Prozesse nie die CPU erhalten. Man sagt dann, dass diese niederprioren Prozesse verhungern (starvation). ]

.compact[

Gründe für das Auftreten dieser Situation können falsch ausgelegte Prozesse, unerwartete Anwendungssituationen mit erhöhtem Rechenzeitbedarf oder eine zu leistungsschwache CPU sein.

Die ML Strategie wird gerne für Echtzeitsysteme genutzt, da sie bei nur einem einzigen Prozess auf der höchsten Prioritätsstufe diesem stets sofort die CPU zuteilt, wenn er ablaufen will.

Eine Neuzuteilung des Prozessors findet nicht nur statt, wenn der aktuelle Prozess die CPU freigibt (zu warten beginnt oder terminiert), sondern auch dann, wenn ein anderer Prozess ablaufbereit wird.

In der Praxis wird diese Strategie oft mit der RR-Strategie kombiniert, d.h., existieren ablaufbereite Prozesse mit gleicher Priorität wie der laufende Prozess, so wechseln sie zyklisch ab (round robin).

Die Priorität kann auch dynamisch sein, d.h. sie ändert sich mit der Laufzeit von Prozessen. So werden Prozesse die häufig die Zeitscheiben ausschöüfen degradiert, welche die häufig auf E/A warten und nur kurz rehchen befördert.]

.centered.width90[

] .figure[Beispielszenario mit ML Strategie. Das Diagramm wurde um eine Prioritätsrangierung erweitert in der Art, dass A die höchste, B eine mittlere und C die tiefste Priorität zugeordnet bekommt.]

Da Prozess A als einziger die höchste Priorität besitzt, erhält er stets sofort die CPU zugeteilt, wenn er ablaufen will. So verdrängt er beispielsweise zum Zeitpunkt 90 den Prozess B, wenn er die Ein-/Ausgabe abgeschlossen hat. Prozess B kann immer dann laufen, wenn A die CPU nicht besetzt. Prozess C schließlich muss warten, bis die CPU für ihn als Letzten frei wird.

MLF Strategie

Die MLF Strategie (Multi-Level Feedback) begründet ihre Scheduling-Entscheide auf der aufgelaufenen Rechenzeit der ablaufbereiten Prozesse. Dazu verwendet sie eine feste Anzahl von Prioritätsstufen, z.B. 1..10.

Anfänglich ist einem Prozess die höchste Priorität zugeteilt.

Die Auswahl des nächsten Prozesses bei einer Neuzuteilung berücksichtigt primär die Priorität und sekundär das FIFO-Prinzip, wenn mehrere wählbare Prozesse der gleichen Priorität vorliegen.

Jeder Prioritätsstufe ist zudem ein festes Zeitquantum zugeordnet.

Hat ein Prozess auf einer Prioritätsstufe das geltende Zeitquantum ausgeschöpft, so wird seine Priorität um eins erniedrigt und eine Neuzuteilung ausgelöst.

.centered.width40[

] .figure[Warteschlagenorganisation für die MLF-Strategie (Beispiel). Prio: Priorität, Q: Zeitquantum. T: Zeitscheibengröße]

.compact[

Damit werden Prozesse mit kleinem Rechenzeitbedarf bevorzugt, da lang laufende Prozesse schrittweise in ihrer Priorität heruntergestuft werden.]

.centered.width90[

] .figure[Beispielszenario mit MLF-Strategie]

Vorteilhafte Eigenschaften besitzt diese Strategie für interaktive Systeme, da einerseits keine Vorkenntnisse über erwartete Rechenzeiten nötig sind und andererseits E/A-lastige Prozesse bevorzugt werden. Dies sind typischerweise auch Prozesse, die einen intensiven Benutzerdialog besitzen und mit dieser Strategie kurze Antwortzeiten realisieren können. Länger laufende Hintergrundprozesse nutzen so die verbleibende Rechenzeit, ohne dass sie die Reaktionszeit der Vordergrundprozesse negativ beeinflussen.

RM Strategie

Das RM-Verfahren (Rate Monotonic) bietet sich für periodisch ablaufende Prozesse an, wie sie oft in Echtzeitsystemen anzutreffen sind. Dabei wird von einer festen Periodenlänge ausgegangen. Für periodische Aktivitäten gilt typischerweise als spätester Endtermin der Anfang der nächstfolgenden Periode.

Besitzt ein Prozess die Periodenlänge T, so wird er alle T Zeiteinheiten starten und muss seine Berechnung vor dem Start der nächsten Zeiteinheit (also nach T Zeiteinheiten) abschließen.

Es handelt sich um ein verdrängendes Zuteilungsverfahren, bei dem derjenige ablaufbereite Prozess gewählt wird, dessen Periodenlänge am kürzesten ist.

.centered.width100[

] .figure[Beispielszenario mit RM Strategie (periodische Prozessabläufe) ohne Verdrängung/Präemption. Gelbe Bereiche: CPU nicht genutzt.]

Prozess A läuft periodisch alle 60 Zeiteinheiten und er braucht für jeden Ablauf 40 Einheiten an Rechenzeit. Prozess B hat eine Periode von 40 und einen Rechenzeitbedarf von 10 Zeiteinheiten pro Aktivierung.

EDF Strategie

Bei der EDF-Strategie (Earliest Deadline First) wird derjenige Prozess ausgewählt, dessen Restzeit bis zum Ablauf seines Endtermins am kleinsten ist.

Wie bei dem RM-Verfahren gehen wir von periodisch ablaufenden Prozessen aus, die mit festen Intervallen arbeiten. Der Endtermin einer anstehenden Bearbeitung ist stets der Anfang der nächstfolgenden Periode.

Es handelt sich um ein verdrängendes Verfahren, das sich gut für Echtzeitsysteme eignet.

Wie bei dem RM-Verfahren gehen wir von periodisch ablaufenden Prozessen aus, die mit festen Intervallen arbeiten. Der Endtermin einer anstehenden Bearbeitung ist stets der Anfang der nächstfolgenden Periode. Es handelt sich um ein verdrängendes Verfahren, das sich gut für Echtzeitsysteme eignet.

.compact[ .centered.width100[

] .centered.width100[

] .figure[(Oben) Beispielszenario für RM Strategie mit Unterbechung/Verdrängung von Prozess A durch Prozess B (Unten) EDF-Strategie mit Verdrängung]

Bei EDF ist die Laufzeitunterbrechung von länger laufenden Prozesse etwas günmstiger.

Eigentlich verpasst Prozess A hier seine Deadline da er mit B überlappt! ]

Vergleich der Strategien

Um verschiedene Strategien miteinander zu vergleichen, sind folgende Größen zur Beurteilung hilfreich:

.compact.smaller.up[

Wartezeit (wait time): Summe aller Zeiträume, in denen der Prozess am Warten war (auf irgendeine Ressource, das heißt auch auf die CPU).

Bedienzeit (service time): Summe aller Zeiträume, in denen der Prozess am Rechnen war (d.h. CPU belegt hat, also inklusive aktiver Warteschleifen).

Durchlaufzeit (turnaround time): Gesamter Zeitbedarf (verstrichene Zeit), bis ein Prozess vollständig abgearbeitet ist (Zeitpunkt Prozessbeendigung minus Zeitpunkt Prozessstart). Es gilt: Durchlaufzeit = Wartezeit plus Bedienzeit.

Antwortzeit (response time): Zeit zwischen dem Auftreten eines Ereignisses bis zur ersten Reaktion des Prozesses darauf. Entweder wird die Situation bei einem Einzelereignis bestimmt oder ein Mittelwert über eine Vielzahl an Ereignissen ermittelt.

Durchsatz (Throughput): Anzahl vollständig abgearbeiteter Prozesse pro Zeiteinheit. Zur Bestimmung wird ein Betrachtungszeitraum gewählt.

CPU-Auslastung (cpu usage): Zeiträume, während der die CPU irgendeinen Prozess ausgeführt hat, im Verhältnis zum gesamten Beobachtungszeitraum. Typischerweise erfolgt eine prozentuale Angabe. ]

.reference[bsdp] .centered.width100[

] .table[Vergleich ausgewählter Scheduling-Strategien. Teils am Beispiel eines Prozesses, teils für alle Prozesse.]

Zusammenfassung

Wichtigste Konzepte für Multiprozessausführung (Multiprogramming) sind

Prozessblockierung

Prozessunterbrechung (Präemption)

Prioritäten

Es wird verdrängende (präemptive) und nichtverdrängende Prozessumschaltung unterschieden

Wichtigstes Beurteilungskriterien:

Antwortzeit auf Ereignisse (Verzögerung)

Faire Prozessauswahl, Zeitscheiben

Auslastung des Prozessors (optimal 100%)

Overhead durch Prozesswechsel!

Möglichkeiten Prozesse messbar zu machen

Man unterscheidet beim Scheduling zwei wesentliche Prozessklassen:

Rechenlastig

Ereignislastig (E/A)

Die Prozessauswahl hängt ab von:

Prozessklasse (s.o)

Statistiken der laufende Prozesse

Prioritäten

Aktuell laufender Prozess!

. Das einfachste Multiprozessystem ist das Vordergund-Hintergrund System

Hauptprogramm ist Hintergrundprozessn (niedrigste Priorität)

Interrupthandler werden präemotiv aufgerufen und sind zum Vordergundprozess zusammengefasst (höchste Priorität).

Prozesse besitzen einen Daten- und Kontrollzuständ (Programmzähler), aber viel wichtiger werden sie für die Prozessverwaltung auf Metazustände abgebildet und in Wartelisten verarbeitet:

Laufend

Bereit

Wartend/Blockiert

...

Prozesswechsel bedeutet:

Overhead!

Umladung PCB und MMU Tabellen

Verwaltungsstrukutren, Wartelisten

Kontextwechsel! Das Betriebssystem ist selber ein Prozess.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Rechner- und Prozessorgrundlagen

Wie funktioniert ein Rechner?

.left[]

Wie werden Programme von einem Rechner verarbeitet?

Grundmodell eines Rechners

Programm

Eine Sequenz von Maschinenbefehlen (= Prozessorinstruktionen) wird zusammen mit ihrer Datenhaltung als Programm bezeichnet

Programmausführung

Entsprechend die Ausführung durch den Prozessor als Programmausführung.

Die meisten heute gebauten Computersysteme beruhen auf der Aufbaustruktur des Von-Neumann-Rechners, die John von Neumann 1946 aufgestellt hat. Seltener kommt die alternative Struktur des Harvard-Rechners zum Zug, benannt nach der Struktur des Mark-I-Rechners an der Harvard University (1939-44).

Von-Neumann Rechner

Der Von-Neumann-Rechner besteht aus vier Funktionseinheiten:

.reference[bsdp] .centered.width80[

] .figure[Funktionsblöcke des Von-Neumann-Rechners]

.smaller.box[ Leitwerk (Control Unit, CU) : Programme werden maschinenintern als Zahlen, auch Maschinenbefehle genannt, gespeichert. Die Maschinenbefehle legen die vom Prozessor auszuführenden Operationen fest. Das Leitwerk holt die Maschinenbefehle nacheinander aus dem Speicher, interpretiert sie und setzt sie in die zugehörigen Steueralgorithmen um. Das Leitwerk übernimmt damit als Befehlsprozessor die Steuerung der Instruktionsausführung. Unter Steueralgorithmen verstehen wir mögliche Prozessoroperationen, wie z.B. eine Addition, logische Oder-Verknüpfung oder Daten kopieren.

Rechenwerk (Processing Unit, PU)

Eingabedaten können neben den Maschinenbefehlen Bestandteile eines Programms sein (sie liegen dann im Speicher vor) oder werden während des Programmablaufs über die Funktionseinheit Ein-/Ausgabe von der Peripherie hereingeholt. Das Rechenwerk holt die Daten aus dem Speicher bzw. von der Eingabe, transformiert diese Daten mittels unterschiedlicher Steueralgorithmen und legt sie im Speicher ab bzw. übergibt sie der Ausgabe. Als eigentlicher Datenprozessor realisiert es die logischen und arithmetischen Operationen.

Speicher (Memory)

Er enthält die Maschinenbefehle und die zu verarbeitenden Daten. Eine Folge von logisch zusammengehörenden Maschinenbefehlen bezeichnen wir als Programm. Sowohl Befehle als auch Daten befinden sich in einem gemeinsamen Adressraum. Der Speicher dient somit der kombinierten Ablage von Programmen und Daten.

]

.smaller.box[ Ein-/Ausgabe (Input/Output, I/O) : Sie verbindet die Peripheriegeräte (z.B. Tastatur, Monitor, Drucker) mit dem Rechenwerk, stellt also eine oder mehrere Schnittstellen zur Umwelt dar. Mittels passender Maschinenbefehle werden über die Ein-/Ausgabe Daten von der Peripherie entgegengenommen oder dieser übergeben. Ursprünglich wurden das Eingabewerk und das Ausgabewerk als zwei getrennte Funktionsblöcke betrachtet. Heute werden sie zur Ein-/Ausgabe zusammengefasst.

Bussystem

Die Schaltzentrale um alle vier Werke miteinander zu verbinden. Gleichzeitig der Flaschenhals im gesamten Rechner (neben dem Speicher als zentrale Ressource für Programm und Daten)

]

.reference[bsdp] .centered.width60[

] .figure[Betriebssystem und Von-Neumann-Rechner]

Prozessoraufbau

Jede CPU-Familie besitzt einen spezifischen Instruktionssatz und Registeraufbau, mit dem sie sich von anderen Prozessorfamilien unterscheidet.

Deswegen muss ein Programm stets für die richtige CPU übersetzt sein, damit die Maschinenbefehle korrekt interpretiert werden.

Innerhalb einer sogenannten CPU-Familie kann jedoch der gleiche Code benutzt werden.

Ein Prozessor besteht grob gesehen aus den Teilen Leitwerk, Rechenwerk, Register, Adress- und Bussteuerung

.reference[bsdp] .centered.width60[

] .figure[Schematischer Prozessoraufbau]

Register

Register sind Speicherzellen, aber im Mikroprozessor integriert, und teils mit spezieller Bedeutung, wie z.B.:

PC: Program Counter (Code Adresszeiger)

SP: Stack Pointer (Daten Adresszeiger)

Die Registersätze verschiedener CPu Familien können sich erheblich unterscheiden

.smaller[

Man unterscheidet grob drei Klassen CPU Architekturen, mit weitreichenden Folgen für Betriebssysteme und Programme (Erstellung):

Complex Instrucion Set Computer (CISC): Wenige Register, Operationen sind hauptspeicherzentriert, variables Instruktionsformat und Anzahl von Operanden ⇒ Intel x86 usw.

Reduced Instruction Set Computer (RISC): Viele Register, Operationen sind registerzentriert, teils fixes Instruktionsformat, wenige Operanden ⇒ ARM Cortex, Sun UltraSparc usw.

Stack Processors: Wenige Register, Operationen sind stackzentriert, 0-Operand Instruktionen ⇒ Green Array F18A (Dual stack Forth architecture), Virtual Machines! ]

.centered.width60[

] .figure[Beispiele von CPU-Registern]

Grundlagen der Programmausführung

.centered.width60[

] .figure[Rechnereinteilung nach Adressanzahl: gezählt wird die Anzahl Operanden eines dyadischen Operators (z.B. einer Addition)]

Grundlagen des Adressraums

Der Zugriff auf die Befehle eines Programms erfolgt ebenso wie auf alle im Speicher abgelegten Operanden mittels Speicheradressen. Die Organisation des Adressraums stellt damit eine wichtige Eigenschaft und allenfalls auch Limitierung einer bestimmten Rechnerplattform sowie des Betriebssystems dar.

Ein Adressraum Σ ist gekennzeichnet durch:

.box[

Gesamtgröße in Bits (oder Bytes)

Anzahl N=|Σ| von Speicherzellen

Anzahl der möglichen Adressen (u.U. ≠ Bytes!)

Anzahl der Bits pro Speicherzelle W, d.h. die Wortbreite ]

Adressen sind numerische (ganzzahlige) Werte und referenzieren Speicherzellen

I\.A. wird die erste Adresse mit 0, die letzte als N-1 bezeichnet.

Achtung: Die Adresse kann sich entweder auf die Speicherzelle mit der Wortbreite W oder die Elementarzelle mit der Breite B Bits (i.A. 1 Byte) beziehen!

.reference[bsdp] .centered.width40[

] .figure[Grundprinzip des Adressraums, hier Byteadressierung]

Adressraumtypen

.up.compact[

Der (Haupt-) Speicher M ist zunächst passiv und enthält Daten und Programmcode

Aber neben dem Speicher müssen auch Ein- und Ausgabegeräte P "adressiert" (selektiert) und mit denen kommuniziert werden...

Es gibt grob zwei Adressraumtypen bezüglich Speicher und Peripheriegeräten, die auch für das Betriebssystem sehr relevant sind:

Gemeinsamer Adressraum Σ=⟨M,P⟩

Getrennte Adressräume ΣM=⟨M⟩, ΣP=⟨P⟩ ]

.up.centered.width50[

] .figure[Klassischer und erweiterter Von-Neumann-Rechner]

Speicher und Speicherverwaltung

Wie ist das Speichermodell eines Programs und Prozesses organisiert?

.left[]

Wie ist das Speichermodell eines Betriebssystems organisiert?

.left[]

Wie findet Speicherverwaltung statt?

Bytereihenfolge (byte ordering)

Ein ganz einfacher Prozessor könnte nur mit Datenwerten von 8 Bit Größe arbeiten, d.h., er würde über eine Speicheradresse stets nur ein einzelnes Byte lesen oder schreiben. Heutige Universalprozessoren unterstützen jedoch verschiedene Operandengrößen, die auch mehrere Byte umfassen können.

.centered.width80[

] .figure[Varianten der Bytereihenfolge bei Mehrbyte-Datenwerten (Beispiel für 4-Byte-Wert)]

Ausrichtungsregeln im Adressraum

.compact[

Die Ausrichtungsregeln (alignment rules) legen fest, auf welchen Adressen Variablen und Instruktionen liegen müssen. Sie sind maßgebend für die Programmübersetzung (Compiler, Assembler, Binder). Ihr Zweck liegt in der Erreichung optimaler Ausführungsgeschwindigkeiten auf dem benutzten Rechnersystem. Da sie von der Hardware abhängen, können sie entsprechend der benutzten Rechnerplattform variieren. Ausrichtungsregeln können sowohl für Code als auch Daten existieren.]

.up.reference[bsdp] .up.centered.width70[

] .up.figure[Fehlausrichtung im Adressraum und ihre Folgen (Beispiel für einen 32-Bit-Datenbus und einen Zugriff auf einen Datenwert von 32 Bit Größe)]

Bedeutung für Instruktionscode: Abhängig von der Prozessorhardware gelten andere Ausrichtungsregeln, z.B.:

Instruktionen müssen immer auf geradzahligen Adressen liegen.

Bedeutung für Daten: Die Datenbusbreite bestimmt die Ausrichtungsregeln für eine optimale Zugriffsgeschwindigkeit.

Z\.B. ist der Datenbus 32 Bit breit. Damit ist eine optimale Ausrichtung für 4-Byte-Werte eine sogenannte Langwortgrenze, d.h. eine ohne Rest durch vier teilbare Adresse.

Im vorherigen Beispiel ist die Notwendigkeit von zwei Zugriffszyklen auf dem Datenbus gezeigt, wenn die Ausrichtungsregel nicht eingehalten wird (misalignment).

Beim C-Strukturdatentyp (struct) bzw. dem C++-Klassendatentyp (class) gelten die Ausrichtungsregeln jeweils separat für die einzelnen Teilkomponenten. Neben dem Einfluss auf die Zugriffsgeschwindigkeit spielt dies wiederum sowohl für die Kompatibilität beim Datenaustausch wie auch für den effektiv belegten Platz im Adressraum eine Rolle.

.centered.width80[

] .figure[Einfluss der Ausrichtung auf die resultierende Adressraumplatzierung (Beispiel)]

Adressraumbelegung durch Programme

Die Belegung des Adressraums durch ein Programm wird einerseits durch die Übersetzungswerkzeuge, andererseits durch das Betriebssystem festgelegt.

Im Grundsatz können etwa fünf verschiedene Bereiche unterschieden werden:

Code und Konstanten: Die zugehörigen Speicherinhalte werden aus der ausführbaren Datei in den Hauptspeicher geladen. Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Initialisierte Daten: Ein passender Bereich des Adressraums wird reserviert und anschließend werden die Initialwerte der Variablen aus der ausführbaren Datei dorthin geladen. Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Nicht initialisierte Daten: Ein passender Speicherbereich wird reserviert und eventuell gelöscht (d.h. mit 0 geladen). Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Heap und Stack: Es wird ein gemeinsamer Bereich im Adressraum derart reserviert, dass die zwei Bereiche möglichst weit voneinander entfernt sind. Im Betrieb können sowohl Heap als auch Stack wachsen und schrumpfen.

Das Wachstum ist aber nur ungefährlich, solange sich die Heap- und Stack-Inhalte nicht überschneiden. Eine derartige Überschneidungssituation würde zu einem Fehler führen. Die Kunst liegt darin, diese Bereiche ausreichend groß zu wählen und eine mögliche Fehlsituation erkennen zu können. Ein Betriebssystem und/oder ein Laufzeitsystem einer Programmiersprache können hier allenfalls Hilfe anbieten, um Fehlsituationen zu erkennen.

Weitere Lösung des Kollisonsproblem: Getrennte Segmente für Heap und Stackspeicher eines Programms.

.centered.width60[

] .figure[Alternative Layouts des Adressraums eines Programms (oder Prozesses als Programm in Ausführung)]

Adressraumnutzung durch C-Programme

Für die Programmierung ist es hilfreich, die Platzierungsregeln für Variablen in der Programmiersprache C zu kennen. Nachfolgend sind sie kurz aufgeführt:

Global (d.h. außerhalb einer Funktion) deklarierte Variablen

Ohne Initialisierung werden sie im Bereich nicht initialisierte Daten platziert. Mit einer Initialisierung jedoch im Bereich initialisierte Daten. Diese unterschiedliche Behandlung wird deshalb gemacht, weil die Anfangswerte für initialisierte Daten sich aus der ausführbaren Datei laden lassen. Das Betriebssystem kann so diese zwei Fälle klar voneinander unterscheiden.

Innerhalb einer Funktion deklarierte Variablen

Ohne Angabe der Speicherklasse (implizit gilt auto) oder mit Angabe von auto: Es erfolgt eine Platzierung auf dem Stapel. Mit Angabe der Speicherklasse static: Platzierung wie global deklarierte Variablen.

Konstanten (const)

Platzierung im Bereich Code, Konstanten. Sowohl Code als auch Konstanten sind funktional gesehen nur lesbare Speicherinhalte. Da sie separat ausgezeichnet werden, kann das Betriebssystem die so belegten Speicherbereiche mit einem Schreibschutz belegen.

Dynamisch allozierte Daten

Platzierung auf der Halde (Heap). Reservation mittels der Bibliotheksfunktionen malloc() oder calloc(), Freigabe mittels der Bibliotheksfunktion free(). In C++ leisten die Operatoren new und delete Vergleichbares.

Aufrufparameter von Funktionen

Die Parameter stehen auf dem Stapel (Stack) bereit oder werden über allgemeine Prozessorregister übergeben (compilerabhängig konfigurierbar). Zu beachten ist, dass bei der Übergabe von Zeigern die Daten selbst anderswo liegen, d.h. nicht auf dem Stapel!

C2JS

+

.col1[

int x=0;
int foo(int x) {
  int t=x;
  return t+1;
}
int main() {
  int y;
  int *yp;
  y=foo(x);
  yp=(int*)malloc(sizeof(int));
  *yp=y;
}

.example[Beispiel eines C Programms] ] .col2[ .centered.width100[

] .figure[Variablenplatzierung im Adressraum (entsprechend Programmbeispiel)] ]

Heap versa Stack Speicher

.box[

Beide nehmen Datenvariablen auf (nur in speziellen Fällen Programmcode, z.B. bei Virtuellen Maschinen);

Der Heap Speicher braucht eine explizite Speicherverwaltung

Verwaltung von belegten und freien Bereich im Heap

Verwendung von Tabellen- oder Listenstrukturen

Speicherallokation und Speicherrückgabe erfolgt durch das Programm (oder Betriebssystem) explizit;

Der Stack Speicher braucht keine explizite Speicherverwaltung, er verwaltet sich automatisch über einen einzigen Zeiger (Stack Pointer SP)

Die Allokation ist lediglich eine Verschiebung des SP

Der Heap kann fragmentieren (Verlöcherung), der Stack nicht. ]

Funktionsweise des Stapels und Stapelzeigers (SP)

Wenn Funktionen (Unterprogramme) aufgerufen werden wird:

Ein Funktionsrahmen angelegt, der u.A. die Rücksprungadresse enthält (return);

Werden alle lokalen Variablen

Die dynamischen Daten eines Unterprogrammaufrufs werden auf dem Stapel als Teil des sogenannten Aufrufrahmens bzw. Aktivierungsrahmens (stack frame) gespeichert. Sie stehen dort nur für die Dauer der Unterprogrammausführung zur Verfügung.

Der bei Unterprogrammaufrufen benutzte Aktivierungsrahmen betrifft direkt die Programmierschnittstelle des Betriebssystems und die Prozess-/Thread-Verwaltung!

Der Stapelspeicher (stack) besteht genau genommen aus einem dafür reservierten Teil des Adressraums und dem Prozessorsteuerregister Stapelzeiger (Stack Pointer, SP).

Der Stapelzeiger enthält jederzeit die Adresse des aktuellen oberen Endes des Stapels (Top Of Stack, TOS).

Im Stapel selbst befinden sich die Aufrufrahmen (stack frames) aller nicht beendeten Unterprogrammaufrufe sowie eventuell andere zwischengespeicherte Werte. Entsprechend sieht man oft den Begriff des call stack, d.h. des Aufrufstapels.

Der Stapel wird im deutschen Sprachraum teilweise als Kellerspeicher bzw. Keller bezeichnet.

.centered.width60[

] .figure[Prinzip des Stapelspeichers]

.compact[

Der Stapel stellt die Grundinfrastruktur für die Unterprogrammtechniken dar.

Pro Unterprogrammaufruf wird ein Aktivierungsrahmen (stack frame), d.h. ein Datenbereich auf dem Stapel, angelegt.

Bei Beendigung des Unterprogramms wird der Datenbereich wieder freigegeben, was auch als Abräumen bezeichnet wird. Ein Aktivierungsrahmen enthält eines oder mehrere der folgenden Elemente:

Rücksprungadresse des Unterprogramms (d.h. Adresse, an der im aufrufenden Programm nach Unterprogrammende fortzufahren ist)

Unterprogramm-Aufrufparameter (Funktionsargumente)

Unterprogramm-Rückgabewerte

Lokale Variablen (in C sog. automatic variables)

Nach Bedarf weitere zwischengespeicherte Werte (computersprachabhängig) ]

Unterprogrammaufruf

Jeder Universalprozessor stellt für den Aufruf von Unterprogrammen und die Rückkehr in das Oberprogramm spezifische Maschinenbefehle zur Verfügung, die diesen Vorgang automatisieren helfen.

Wir bezeichnen nachfolgend diese zwei Maschineninstruktionen mit den Namen JSR (jump to subroutine) und RET (return from subroutine).

Abhängig vom benutzten Prozessor können diese Befehle etwas abweichend bezeichnet sein, arbeiten aber stets nach dem hier vorgestellten Verfahren.

Eine wichtige Grundeigenschaft des Unterprogrammaufrufs besteht darin, dass egal woher ein Unterprogramm aufgerufen wird, stets an die dem Aufruf direkt nachfolgende Programmstelle zurückgekehrt wird, wenn das Unterprogramm endet.

Dies wird technisch mithilfe des Stapels realisiert, indem die Rücksprungadresse für die Dauer der Unterprogrammausführung auf dem Stapel zwischengespeichert wird.

.smaller.box[Für den Unterprogrammaufruf dient der JSR-Befehl, der die Rücksprungadresse auf dem Stapel ablegt und den Programmzähler mit der Startadresse des Unterprogramms lädt. Die Rückkehr in das Oberprogramm wird durch den RET-Befehl veranlasst, indem er das oberste Element vom Stapel entfernt und als neuen Wert in den Programmzähler, womit der Programmablauf an der Rücksprungstelle fortfährt.]

.col1[ .centered.width100[

] .figure[Verwendung von Unterprogrammen (Beispiel)] ] .smaller.col2[

.invert[Aufruf]

Maschinenbefehl JSR

sichert Rücksprungadresse auf Stapel

lädt PC mit UP-Adresse

.invert[Rücksprung]

Maschinenbefehl RET

holt gesicherte Rücksprungadresse vom Stapel und lädt sie in den Programmzähler PC (damit: Rückkehr zu OP)

OP: Oberprogramm&br; UP: Unterprogramm ]

Durch die Verwendung des Stack Speichers und der LIFO Semantik wird auch Funktionsrekursion und geschachtelte Azfrufe unterstützt!

.reference[bsdp] .col1[.centered.width100[

]] .col2[.centered.width100[

]] .left[] .figure[Geschachtelter Unterprogrammaufruf (Beispiel)]

Paging und Segmentierung

Bisher gab es nur einen gesamten Speicherbereich mit der Unterteilung in die Bereiche Programm, statische Daten, dynamische Daten und temporäre Daten.

In einem Betriebssystem werden (außer bei Mikrokontrollerrechnern) mehrere Prozesse, d.h. Programme in Ausführung, in einem Multiplexbetrieb quasi parallel betrieben.

Jetzt muss jeder Prozess eigene Speichersegmente besitzen

Es bedarf jetzt einer hierarchischen Speicherverwaltung: 1. Segmente / alle Prozesse, 2. Speicherbereiche in Segmenten eines Prozesses.

Aber halt: schauen wir uns von einer Programmdatei die Symboltabelle an so finden wir z.B.:

objdump ball.exe  -h

ball.exe:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00006c68  80000000  80000000  00001000  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       0000000b  80007000  80007000  00008000  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .eh_frame     00001038  8000700c  8000700c  0000800c  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .data         00000004  8000a000  8000a000  0000a000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  4 .bss          00001238  8000a020  8000a020  0000a004  2**5
                  ALLOC

.example[Die Segmente eines Programms bevor es in den Speicher geladen wird. VMA: Virtual Mmeory Address, LMA: Linear Memory Address. Stack und Heap gibt es hier noch nicht! .text: Programmcode, .rodata: Konstante Daten, .data: statische globale und initialisierte Daten, .bss: statische globale nicht initialisierte Daten]

.reference[bsdp] .centered.width100[

] .figure[Gruppierung verschiedener Programmteile]

Würde man alle Programme nach dieser Anweisung in den Speicher laden würden sich alle Programme im Speicher überdecken.

Man könnte alle Programmadressen umrechnen (Offset), aber das müsste man nicht nur für die Segmente machen, sondern für alle Maschineninstruktionen die mit absoluten Adressen arbeiten. Keine gute Idee.

Man könnte Virtuelle Speicherräume und Virtuellen Speicher einführen. Gute Idee, aber...

Es nützt uns nichts wenn wir (oder das Betriebssystem) die physikalischen Speicheradressen (P Raum) auf virtuelle Adressen (für jeden Prozess, der V Raum) abbilden. Die Hardware muss das tun, und das geschieht durch die Memory Management Unit (MMU).

Die MMU arbeitet mit Tabellen die Adressen von Segmenten vom V in den P Raum transformieren. Damit die Tabellen um Umfang nicht zu groß werden werden Speichersegmente nur in Seiten (Pages) verwaltet, und Page Units (Größe z.B. 4096 Bytes).

Dynamische Segmentierung kann auch direkt vom Mikroprozessor unterstützt werden, so dass kein virtueller Speicherraum benötigt wird.

Dazu werden für einen Speicherzugriff Segmentregister verwendet, die dann den oberen Teil einer Speicheradresse zusammen mit der von einer Maschineninstruktion gelieferten Adresse zusammengerechnet werden:

                            ┌――――――――――┐ 0
   ┌――――――┐                 │          │
   │  SS  ├――――――――――――┐    │          │
   └――――――┘            │    │          │
                       └―――▶│          │5000
   ┌――――――┐                 │          │
┌――┤  DS  ├――――――――――――――――▶│          │10000
│  └――――――┘                 │          │10100
│                       ┌――▶│          │10102
│  ┌――――――┐             │   │          │
│  │  CS  ├―――――――――――――┼――▶│          │20000
│  └――――――┘             │   │          │
│                       │   │          │
│                       │   │          │
└―▶ add $100,$102 ――――――┘   │          │
                            │          │
                            └――――――――――┘ N-1

.example[Die Programmadresse ergibt sich aus PC+CS, die Datenadressen aus DS+$addr, und die Stackadressen aus SS+SP]

Verwaltung von Prozessadressräumen

.compact[

Dynamische Segmentierung kann erfolgen durch:

MMU (Tabellen)

Prozessor (Register)

Egal welche Methode, bei jedem Prozesswechsel müssen die Tabellen oder Register umgeladen werden.

Nachdem ein Programm übersetzt wurde, steht es dem Betriebssystem in Form einer ausführbaren Datei zur Verfügung.

Beim Laden eines Programms müssen Bereiche im Adressraum zum Beispiel für Code, Daten, Heap und Stack reserviert werden.

Ein laufendes Programm kann zudem Threads erzeugen, gemeinsame Speicherbereiche mit anderen Prozessen einrichten und weitere Aktionen ausführen, die wiederum Bereiche im Prozessadressraum betreffen.

Das Betriebssystem muss über alle diese Belegungen des Adressraums Buch führen. Nur so ist es in der Lage, neue Reservierungen ohne Kollisionen mit bestehenden Belegungen durchzuführen.]

.compact[

Prozessadressräume sind also i.A. virtuell, so dass jeder Prozess aus seiner Sicht immer die gleiche "Startadresse" hat.

Vorteile: Einfache Kompilierung und Mehrfachausführung einer Programmdatei ist möglich

Nachteile: Hardwareunterstützung (MMU), Verwaltungsaufwand, Prozesswechsel

Segmente sind Speicherregionen die vom Betriebssystem verwaltet werden müssen (Partitionierung des physikalischen Speichers).

Auch hier muss zwischen Anwendungsprozessen und dem Betriebssystemprozess unterschieden werden. Der virtuelle Adressraum ist auch zu partitionieren: ]
.up.centered.width60[

] .up.figure[Beispiele von Adressraumaufteilungen]

Für jeden Prozess benötigt das Betriebssystem eine Buchhaltung der im Adressraum belegten und freien Bereiche. Da bei vielen Prozessen der Adressraum nur schwach belegt ist, wird typischerweise nur die Belegung festgehalten.

Dies ist die Voraussetzung dafür, dass sich während des Prozessablaufs zusätzliche Bereiche kollisionsfrei reservieren lassen.

Steht eine passende Hardwareunterstützung (MMU) zur Verfügung, so können zudem Fehlzugriffe auf unbelegte Bereiche festgestellt werden.

.centered.width40[

] .figure[Funktionsweise einer MMU+CPU Architektur]

Dynamische Speicherreservierung von Regionen oder Erweiterungen

Anlässe für nachträgliche Reservierungen können sein:

Thread-Erzeugung: Es wird ein Stack-Bereich bereitgestellt (evtl. sogar zwei Stack-Bereiche, getrennt für Benutzer- und Kernmodus).

Einrichten gemeinsamen Speichers (shared memory): Es wird ein Adressbereich bereitgestellt, der den Datenaustausch zwischen mehreren Prozessen ermöglicht.

Laden einer Bibliotheksdatei: Ihr Inhalt wird geladen und in einem Bereich des Adressraums sichtbar gemacht.

Gemeinsame Bibliothek (shared library) nutzen: Von mehreren Prozessen gemeinsam genutzte Bibliotheken werden einmal geladen und für alle beteiligten Prozesse sichtbar gemacht.

Speicherbasierte Datei einrichten (memory mapped file): Der Dateiinhalt oder Teile davon werden über einen Bereich im Adressraum sicht- und änderbar gemacht.

Regionen

Im Adressraum belegte Bereiche werden Regionen genannt. Sie repräsentieren lückenlos zusammenhängende Adressbereiche mit unterscheidbaren Eigenschaften. Wichtige Attribute einer Region sind:

Startadresse: Dies ist die Adresse, mit der die Region beginnt (niedrigster Adresswert)

Größe: Die Anzahl zugehöriger Adresswerte (als Anzahl Byte)

Schutzattribute: Les-, schreib- oder ausführbar bzw. Kombinationen davon (die Durchsetzung benötigt spezielle Hardwareunterstützung)

Zugehöriger Hintergrundspeicher: Dateiname und Position innerhalb der Datei (kann undefiniert sein; benötigt zudem Softwareunterstützung)

Für das Suchen von Lücken im Adressraum sind nur die Attribute 1 und 2 von Belang. Wird ein virtuelles Speichersystem realisiert, so erlaubt das Attribut 3 die Erkennung von Fehlzugriffen und das Attribut 4 die Zuordnung zu einer Datei. Eine Region kann einem Hintergrundspeicher zur Auslagerung zugeordnet sein

Für die Verwaltung von Regionen werden i.A. verkettete Listen verwendet. Ausnahme bei hart echtzeitfähigen Systemen wo statische Tabellen verwendet werden (Hashtabellen).

.reference[bsdp] .centered.width90[

] .figure[Regionen pro Prozess]

.reference[bsdp] .centered.width90[

] .figure[Prozessadressraumbeschreibung unter Linux (Beispiel)]

... wenn wir schon virtuelle Speicherräume haben:

Swapping

Die Idee liegt darin, ganze Prozesse oder Speicherbereiche auszulagern, wenn sie im Moment keine Rechenzeit benötigen. Für diesen Zweck wird auf der Festplatte ein spezieller Bereich (Swapping Area) reserviert.

In seiner Grundform basiert das Swapping auf der Multiprogrammierung mit festen Partitionen, die um das Ein- und Auslagern von Prozessen erweitert wird.

Das Swapping wird vor allem dann benutzt, wenn mehrere Benutzer auf einem Rechner arbeiten (z.B. ältere Unix-Systeme).

Bei sehr vielen gleichzeitigen Benutzern kann es dann passieren, dass das Betriebssystem nur noch mit Swappen beschäftigt ist. In dieser Situation könnte es hilfreich sein, wenn das Betriebssystem den Grad der Multiprogrammierung reduziert bzw. beschränkt, was jedoch in Dialogsystemen unakzeptabel ist.

In einem Stapelverarbeitungssystem wäre hingegen das längere Anhalten eines Prozesses kein Problem, womit für die übrigen Aufträge ausreichend Speicher verfügbar wäre.

.centered.width60[

] .figure[Swapping Prinzip]

Bei der Partitionierung von Speicher legt man Regionen vorab fest die dann an Prozesse vergeben werden. Mit Swapping findet dann Austausch zur Laufzeit statt.

.centered.width80[

] .figure[Dynamische Partitionierung beim Swapping (Beispielsabfolge)]

Idee gut, aber zeitintensiv, gerade wenn ein Prozess nur kurz rechnet ...

.centered.width80[

] .figure[Beispiele für Zeitbedarfe beim Swapping-Verfahren]

Virtueller Speicher

Zusammengefasst sind dies die wesentlichsten Eigenschaften:

.compact[

Jeder Prozess kann den Adressraum fast beliebig belegen, d.h. teilt ihn höchstens mit dem Betriebssystem selbst.

Jeder Prozess ist gegen Fehlzugriffe aller anderen Prozesse geschützt (Schreibund Leseschutz!), auch das Betriebssystem ist geschützt.

Alle Prozesse sind von der vorhandenen Hauptspeichergröße weitgehend unabhängig (mehr Adressraum als real vorhanden). ] .centered.width70[

] .up.figure[Virtualisierung des Speichers (Beispiel für 32-Bit-Adressierung)]

Flexibilität ...

.centered.width80[

] .figure[Flexible Platzzuteilung dank Adressumsetzung. ]

Segmentierte versa seitenweise Adressierung, nicht ganz so flexibel aber einfacher und kompakter umzusetzen ...

.centered.width80[

] .figure[Segmentbasierte Adressumsetzung]

Seitenweise versa segmentierte Adressierung, nicht ganz so einfach aber viel flexibler, gerade bei Reallozierung und Bereichserweiterung ...

.centered.width80[

] .figure[Die Umsetzung einer virtuellen Adresse in eine physikalische Adresse mit doppelter Adressberechnung und Seitentabellen]

Man beachte, dass der tatsächlich vorhandene Hauptspeicher zwar ebenfalls in Seiten (Speicherpartitionen) eingeteilt sein kann, die effektive Lage und Größe der virtuellen Seite durch den Adressierungsmechanismus aber unabhängig davon ist. Die dadurch definierte, physikalische Speicherportion wird deshalb auch als Seitenrahmen (page frame ) bezeichnet.

Die MMU

Die Memory Management Unit ist eine Hardwarekomponente und verwaltet Abbildungen von physikalischen Speicher auf virtuelle Adressräume.

Dazu werden Umsetzungstabellen verwendet, die sog. Seitentabellen (Paging).

Der Speicher wird seitenweise aufgeteilt (z.B. 4kB Größe)

.centered.width60[

] .figure[Adressumsetzung mittels MMU und TLB (Beispiel)]

Zusammenfassung

Jedes Programm besteht aus verschiedenen Speicherbereichen: Heap, Stack, Datenbereiche, Code

C bildet das Prozesspeichermodell sehr konkret durch globale, lokale, und temporäre Variablen sowie Funktionen ab, und ermöglicht den Speicherzugriff über Zeiger ("Specihermodell")

Die einzelnen Programmbereiche können auf verschiedene Regionen im Speicher abgebildet werden.

Dynamische Speicherverwaltung benötigt Datenstrukturen die belegte und freie Regionen verwalten, der Prozess kann ebenfalls in seinen Regionen eine solche Speicherverwaltung haben (Verwaltungshierarchie)

Das Problem von Adresskollision kann durch Virtuelle Adressraume gelöst werden, die über eine MMU oder einen VMM auf physikalische Adressen abgebildet werden.

Was noch fehlt: Wie wird Speicher effizient verwaltet? Also die Algorithmik . coming soon.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - AG Praktische Informatik

]

Speicherverwaltung - Algorithmen

Eines der wichtigsten Betriebsmittel ist der Hauptspeicher . Die Verwaltung der Speicherressourcen ist deshalb auch ein kritisches und lohnendes Thema, das über die Leistungsfähigkeit eines Rechnersystems entscheidet. Dabei unterscheiden wir drei Bereiche, in denen unterschiedliche Strategien zur Speicherverwaltung angewendet werden:

Anwenderprogramme

Hauptspeicher

Massenspeicher

Anwenderprogramme

Der Speicherraum eines Prozesses

Die Hauptaufgabe besteht hierbei darin, interne Speicherbereiche des Prozesses (beispielsweise den Heap) mit dem Wissen um die speziellen Speicheranforderungen des Programms optimal zu verwalten.

Dies erfolgt durch spezielle Programmteile, z. B. den Speichermanager, oder durch sog. garbage collection Programme in Virtuellen Maschinen.

Hauptspeicher

Hier besteht das Problem in der optimalen Aufteilung des gesamten knappen Hauptspeicherplatzes auf die einzelnen Prozesse.

Die Aufgabe wird in der Regel durch spezielle Hardwareeinheiten unterstützt (MMU).

Besonders in Multiprozessorsystemen ist dies wichtig, um Konflikte zu vermeiden, wenn mehrere Prozessoren auf dieselben Speicherbereiche zugreifen wollen.

In diesem Fall helfen Techniken wie Non-Uniform Memory Access (NUMA) weiter, die zwischen einem Zugriff des Prozessors auf lokalen und auf globalen Speicher unterscheiden.

Massenspeicher

Abgesehen von der Dateiverwaltung, die noch besprochen wird, gibt es auch spezielle Dateien (z. B. bei Datenbanken), bei denen der Platz innerhalb der Datei optimal eingeteilt und verwaltet werden muss.

Ein Beispiel dafür ist die Swap-Datei, auf die Prozesse verlagert werden, die keinen Platz mehr im Hauptspeicher haben.

Direkte Speicherbelegung

In den frühen Tagen der Computernutzung war es üblich, für jeden Job den gesamten Computer mit seinem Speicher zu reservieren.

Der Betriebssystemkern bestand dabei oft aus einer Sammlung von Ein- und Ausgabeprozeduren, die als Bibliotheksprozeduren zusätzlich an den Job angehängt waren.

Musste man einen Job zurückstellen/wechseln, um erst einen anderen durchzuführen, so wurden alle Daten des dazugehörenden Prozesses auf den Massenspeicher verlagert und dafür die neuen Prozessdaten vom Massenspeicher in den Hauptspeicher befördert. (Swapping)

Viele Nachteile (Overhead, "Reibung"). Besser die Daten mehrerer Prozesse gleichzeitig im Speicher halten, sobald genügend Speicher vorhanden war.

Allerdings brachte dies weitere Probleme mit sich: Wie kann man den Speicher so aufteilen, dass möglichst viele Prozesse Platz haben? Diese Frage stellt sich prinzipiell nicht nur für den Hauptspeicher , sondern gilt natürlich auch für den Auslagerungsplatz auf der Festplatte, den swap space.

Zuordnung durch feste Tabellen

Die einfachste Möglichkeit besteht darin, ein verkleinertes Abbild des Speichers zu erstellen: die Speicherbelegungstabelle . Jede Einheit einer solchen Tabelle (beispielsweise ein Bit) ist einer größeren Einheit (z. B. einem 32-Bit-Wort) zugeordnet. Ist das Wort belegt, so ist das Bit Eins, sonst Null.

.reference[BSG] .centered.width60[

] .figure[Eine direkte Speicherbelegung und ihre Belegungstabelle]

Eine solche Belegungstabelle benötigt also bei 32 MiB Speicher selbst 1 MiB.

Soll ein freier Platz belegt werden, so muss dazu die gesamte Tabelle auf eine passende Anzahl von aufeinanderfolgenden Nullen durchsucht werden.

Komplexitätsklasse O(N) bei N Bytes

Besser: Paging (Einteilung des Speichers in Blöcke), reduziert die Tabellengröße

Zuordnung durch verzeigerte Listen

Der belegte bzw. freie Platz ist meist wesentlich länger als die Beschreibung davon.

Die Belegung des gesamten Hauptspeichers mit belegten und freien Plätzen kann dünn besetzt sein.

Aus diesem Grund lohnt es sich, anstelle einer festen Tabelle eine Liste von Einträgen über die Speicherbelegung zu machen, die in der Reihenfolge der Speicheradressen über Zeiger miteinander verbunden sind.

.shadowbox[

Ein Eintrag der Liste besteht aus drei Teilen:

der Startadresse des Speicherteils,

der Länge und

dem Index (Zeiger) des nächsten Eintrags. ]

.reference[BSG] .centered.width60[

] .figure[Eine Speicherbelegung und die verzeigerte Belegungsliste]

Eine solche Belegungsliste lässt sich auch in zwei Listen zerteilen:

eine für die belegten Bereiche, deren Einträge im Fall von Prozessen mit dem Prozesskontrollblock PCB verzeigert sind, und

eine nur für die unbelegten Bereiche.

Ordnen wir diese Liste noch zusätzlich nach der Größe der freien Bereiche, so muss normalerweise nie die ganze Liste durchsucht werden.

Allerdings wird auch dadurch das Verschmelzen angrenzender, freier Bereiche erschwert. Eine doppelte Verzeigerung ermöglicht das Durchsuchen der Liste in beiden Richtungen.

Beispiel: Heap Liste

typedef unsigned int memoryptr;
typedef int size;
struct heap_entry {
  memoryptr  adress;
  memorysize size;
  struct heap_entry next;
};
struct heap_entry *allocated;
struct heap_entry *free;

.definition[Eine listenbasierte Speicherverwaltung ist beispielsweise für das Speichermanagement eines Heaps sinnvoll. Es wird meistens in Blockgrößen gerechnet (z.B. 4kB), nicht in Byteeinheiten]

Es ist einfacher und schneller, nur eine einzige Liste zu führen, die Liste der freien Plätze.

Allerdings ist es sicherer, auch eine Liste der belegten Plätze zu führen, um bei der Platzrückgabe die Angaben über die Lage und die Länge des zurückgegebenen Platzes überprüfen zu können und so Fehlprogrammierungen des Benutzerprogramms aufzudecken.

Besonders in der Debugging-Phase ist dies durchaus sinnvoll; eine solche Überprüfung kann dann in späteren Phasen bei der Laufzeitoptimierung abgeschaltet werden.

Zuordnung durch Baumstrukturen

Listen haben eine Such- und Bearbeitungslaufzeit der Klasse O(N) bei N Elementen, Bäume hingegen (im Mittel) von log(N)N.

Daher bieten sich Bereichsbäume an um belegte und freie Speicherbereiche zu verwalten.

Jedoch bedürfen wie in der Natur Bäume einer Gartenpflege, Listen i.A. nicht.

Ein Baum ist nur dann effizient wenn er ausbalanziert ist.

Wenn er balanziert ist, dann ist die Suche schneller als bei Listen

Aber beim Speichermanagement müssen häufig Änderungen vorgenommen werden, was zum Ungleichgewicht führen kann, was dann wieder korrigiert werden muss.

Extremfall eines Baums: Die Liste!

Belegungsstrategien

Unabhängig von dem Mechanismus der Speicherbelegungslisten gibt es verschiedene Strategien, um aus der Menge der unbelegten Speicherbereiche den geeignetsten auszusuchen.

Ziel der Strategien ist es, die Anzahl der freien Bereiche möglichst klein zu halten und ihre Größe möglichst groß.

Die wichtigsten Strategien sind:

FirstFit

Das erste, ausreichend große Speicherstück, das frei ist, wird entsprechend belegt. Dies bringt meist ein Reststück mit sich, das unbelegt übrig bleibt.

NextFit

Die FirstFit-Strategie führt dazu, dass in den ersten freien Bereichen nur Reststücke (Verschnitt) übrigbleiben, die immer wieder durchsucht werden. Um dies zu vermeiden, geht man wie FirstFit vor, setzt aber beim nächsten Mal die Suche an der Stelle fort, an der man aufgehört hat.

BestFit

Die gesamte Liste bzw. Tabelle wird durchsucht, bis man ein geeignetes Stück findet, das gerade ausreicht, um den zu belegenden Bereich aufzunehmen.

WorstFit

Sucht das größte vorhandene freie Speicherstück, so dass das Reststück möglichst groß bleibt.

QuickFit

Für jede Sorte von Belegungen wird eine extra Liste unterhalten. Dies gestattet es, schneller passende Freistellen zu finden. Werden beispielsweise in einem Nachrichtensystem regelmäßig Nachrichten der Länge 1 KB verschickt, so ist es sinnvoll, eine extra Liste für 1-KB-Belegungen zu führen und alle Anfragen damit schnell und ohne Verschnitt zufriedenzustellen.

.reference[bsdp]

free={addr:0,                 last=free                       function WorstFit(size) {
      size:N,                 function NextFit(size) {          block=free; biggest=null
      next:null}                if (last && last.size>=size)    while(block) {
                                  block=last                      if (block.size>=size) {
function FirstFit(size) {       else                                if (!biggest ||
  block=free                      block=free                            biggest.size<block.size)
  while(block.size<size) {      while(block.size<size) {              biggest=block;
   block=block.next              block=block.next                 }
  }                             }                                 block=block.next
  if (!block)                   if (!block)                     }
    return null;                 return null;                   if (!biggest) return null;
  addr=block.addr               addr=block.addr                 addr=biggest.addr
  block.size-=size              block.size-=size                biggest.size-=size
  block.addr+=size              block.addr+=size                biggest.addr+=size
  return addr                   last=block                      return addr
}                               return addr                   }
                              }

.algorithm[FirstFit versa NextFit versa WorstFit]

Buddy-Systeme

Den Gedanken von QuickFit kann man nun dahin erweitern, dass man für jede gängige Belegungsgröße, am besten Speicher in den Größen von Zweierpotenzen, eine eigene Liste vorsieht und nur Speicherstücke einer solchen festen Größe vergibt.

Alle Anforderungen müssen also auf die nächste Zweierpotenz aufgerundet werden. Ein Speicherplatz der Länge 280 Bytes = 256 + 16 + 8 = 2⁸ + 2⁴ + 2³ Bytes muss also auf 2⁹ = 512 Bytes aufgerundet werden.

Ist kein freies Speicherstück der Größe 2k vorhanden, so muss ein freies Speicherstück der Größe 2k+1 in zwei Stücke von je 2k Byte aufgeteilt werden. Beide Stücke, die Partner (Buddy), sind genau gekennzeichnet: Ihre Anfangsadressen sind identisch bis auf das k-te Bit in ihrer Adresse, das invertiert ist. Beispiel: … XYZ0000 … und … XYZ1000 … sind die Anfangsadressen von Partnern.

Dies lässt sich ausnutzen, um sehr schnell (in einem Schritt!) prüfen zu können, ob ein freigewordenes Speicherstück einen freien Partner in der Belegungstabelle hat, mit dem es zu einem (doppelt so großen) Stück verschmolzen werden kann, ohne freie Reststücke zu hinterlassen.

Beispiel Buddy-Speicherzuweisung

Angenommen, wir benötigen Speicherstücke der Größen 80 KB, 8 KB, 9 KB, 3 KB und 50 KB in der genannten Reihenfolge von einem Speicherstück von 256 KB.

Dann lässt sich die Zuordnung ganz gut mit Hilfe eine Binärbaumes visualisieren, bei dem jedes Speicherstück durch seine beiden Buddy notiert ist.

Da es sich jedes Stück in zwei Unterstücke aufspalten lässt, bilden alle Speicherstücke zusammen einen Binärbaum.

.reference[bsdp] .centered.width60[

] .figure[Visualisierung einer Speicherzuteilung im Buddy-System]

Man sieht, dass jedes Speicherstück, das benutzt wird, meist noch ein unbenutztes Reststück, den Verschnitt, enthält.

Beide Vorgänge, sowohl das Suchen eines passenden freien Stücks (bzw. das dafür nötigen Auseinanderbrechen eines größeren) als auch das Zusammenfügen zu größeren Einheiten lässt sich rekursiv über mehrere Partnerebenen (mehrere Zweierpotenzen) durchführen.

Bewertung der Strategien

.shadowbox[Der Vergleich der verschiedenen Strategien führt zu einer differenzierten Bewertung. So stellt sich heraus, dass FirstFit von der Platzausnutzung etwas besser als NextFit und WorstFit ist und überraschenderweise auch besser als BestFit, das dazu neigt, nur sehr kleine, unbelegbare Reste übrig zu lassen. Eine Anordnung der Listenelemente nach Größe der Bereiche erniedrigt dabei die Laufzeit von BestFit und WorstFit.]

Wissen wir mehr über die Verteilung der Speicheranforderungen nach Zeit oder Beleggröße, so können mit QuickFit und anderen, speziellen Algorithmen bessere Resultate erzielt werden!

Die Leistungsfähigkeit des Buddy-Systems lässt sich mit folgender Rechnung kurz abschätzen: Nehmen wir an, für den Hauptspeicher der Größe 2n werden alle 2n möglichen Stückgrößen S daraus mit gleicher Wahrscheinlichkeit verlangt (was sicher nicht vorkommt, aber mangels plausiblerer Annahmen vorausgesetzt sei). Dann ist die mittlere Speicheranforderung 〈S(n)〉 mit

Profiling zur Laufzeit kann helfen den richtigen Algorithmus auszuwählen.

.reference[file:///home/sbosse/skripte/bs1k/doc/BetriebssystemeGrundlagen/html/49321_4_De_3_Chapter.xhtml#Sec3]

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Zeitmanagement

Timer

In einem Betriebssystem müssen häufig zeitliche Abläufe synchronisiert werden.

Zeitauflösung für Ereignisse könnte Mikro- oder Millisekunden sein

Eine solch hohe Auflösung ist aber meistens nicht erforderlich

Beispiel: Ein Mikroprozessr arbeitet mit einem Kerntakt von 72MHZ. Dann beträgt die minimale Ausführungszeit einer Maschineninstruktion ca. 20 ns, typischerweise nebötigt ein Funktionaufruf einer kleinen Funktion ca. 50 Instruktionen, was 1 μs entspricht.

Daher wird in den meisten Betriebssystemen eine virtuelle Zeiteinheit genutzt, die Ticks.

Ein Tick entspricht Zeitintervallen typischerweise im Breich 10-100 ms

Prozessscheduling wird bei Zeitscheibenverfahren in Ticks behandelt, d.h. eine Zeitscheibe häufig ein Tick

Programmable Interval Timer

Für die "Erzeugung" von Ticks werden Intervalltimer verwendet, also binäre Zähler. Diese stellen die Systemuhr des betriebssystem zur Verfügung. Nicht zu verwechseln mit der Real Time Clock (RTC), die eine nicht flüchtige Echtzeit in Sekunden usw. ausgibt.

.reference[Wikipedia]

Der Programmable Interval Timer (PIT) war ursprünglich ein spezieller Baustein (Chip) im IBM-PC, der für die zeitliche Steuerung von Prozessen sorgte. Die am häufigsten verwendeten Timer-Chips waren die Typen 8253 und 8254 von Intel mit drei 16-Bit breiten Zählern.

.reference[A\. Sahu, Dept. of Comp. Sc. & Eng. IIT Guwahati] .centered.width60[

] .figure[Die Architektur eines PITs (8254)]

Die Ausgänge der drei Zähler können/sind mit dem Interruptcontroller verbunden

Der PIT wird, unabhängig von der Rechnergeschwindigkeit, stets im Takt von (idealerweise) 1,19318181818… MHz betrieben; das ist 1/3 der Farbträger-Frequenz des NTSC-Fernsehsystems!

In den ersten PCs von IBM wurde diese merkwürdig anmutende Frequenz gewählt, da für die Einbindung einer CGA-Grafikkarte, die auch Fernsehgeräte als Monitor verwenden konnte, ohnehin ein Quarz mit einem Vielfachen des Farbträgers vorhanden sein musste.

Aus diesem Quarz konnte die PIT-Frequenz dann leicht mittels Frequenzteilung gewonnen werden; diese Vorgehensweise war günstiger als der Einsatz weiterer Quarze, da mit nur einem Quarz in der Schaltung keine störenden Schwebungen das Bildsignal beeinträchtigen können, die dann mit weiterem Schaltungsaufwand herausgefiltert werden müssten.

Auch der CPU-Takt der ersten PCs von 4,772727 MHz ergab sich aus einer solchen Erwägung, er betrug 4/3 der Farbträgerfrequenz.

Der PIT hat verschiedene Betriebsarten:

Standard Interrupt Modus: Interrupt bei Erreichen eines einstellbaren Zählwerts (Über- oder Unterlauf)

Periodischer Modus: Erzeugung eines Rechtecksignals

Periodischer Modus: Erzeugung von Pulssignalen

Software Modus: Softwaresteuerung des Zählers

Im Standard-Modus zählt der Timer vom Startwert (maximal 65535) auf 0 hinunter und fängt beim folgenden Überlauf wieder von vorne an.

Bei jedem solchen Überlauf sendet er ein Signal, den Timer-Interrupt (IRQ0), an den Prozessor.

Wenn man die Taktfrequenz durch die Takte bis zum erneuten Überlauf dividiert, erhält man die Frequenz, mit der der IRQ0 erzeugt wird.

Unter MS-DOS und älteren Windows-Versionen bis einschließlich Windows 98 und Windows ME wurde der Maximalwert 65535 in den Timer geladen, man erhält somit 1193181,8181… Hz / 65536 = ca. 18,2065 Hz.

Es kommt auf diese Art ein Intervall von ca. 55 ms zwischen den Aufrufen des IRQ 0 zustande.

Modernere Betriebssysteme (Linux, Windows NT und Abkömmlinge) verwenden kürzere Intervalle zwischen 1 und 10 ms.

Mehr Info gibt es hier:

https://wiki.osdev.org/Programmable_Interval_Timer

Ticks,Frequenzen, Zeiten und Timer

Folgende Parameter sind die Zutaten des Zeitmanagements im Betreibssystem:

image-placeholder
(15-1)

D.h. der Timer muss immer mit TIMER_COUNT intialisiert werden um die Vorgabe TICKS_PER_SECOND zu erreichen.

Im Basekernel werden die Ticks als Clicks bezeichnet.

Die kleinste von Programmen (und dem Kernel selber) messbare Zeiteinheit ist 1/TICKS_PER_SECOND, also z.B. 20 ms

Benötigt man eine genauere Zeitmessung so müssen entweder andere Timer oder der aktuelle Timercount des PIT benutzt werden.

Bekannt ist mindestens die Anzahl der Ticks (Clicks), dann kann man den aktuellen Zählerwert des PIT auslesen und kann die Zeit zwischen zwei Ticks intgerpolieren.

Bei ca. 1 MHZ Timerfrequenz ist die kleinste Auflösung 1 μs.

Aufgabe: Implementiere im Basekernel eine neue Funktion clock_micros die die Systemzeit in Mikrosekunden zurück gibt.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Virtuelle Machinen

Abstraktion von Rechnern, Betriebssystemen und Software

Virtuelle Maschinen und Virtualisierung sind zentraler Bestandteil im Betrieb moderner Rechneranlagen und Bestandteil von Betriebssystemen

Die Virtualisierung von Rechnerressourcen bringt in verschiedener Hinsicht neue Nutzen. Sie kann auf verschiedenen Stufen erfolgen und ist bereits teilweise in den Konzepten des Prozessmodells und des virtuellen Speichers realisiert.

Anwendungsbereiche

Eine Reihe von Virtualisierungsansätzen im Umfeld des Betriebssystems:

.shadowbox[

Virtuelle Prozessoren: Simulation einer CPU-Hardware durch Software

Virtuelle Prozessumgebungen: Nutzung virtueller Adressräume für Prozesse (Raummultiplex) zusammen mit CPU-Scheduling (Zeitmultiplex)

Virtuelles Betriebssystem: Ausführung unveränderter (Binär-)Programme eines Betriebssystems A unter einem Betriebssystem B

Virtueller Desktop: entfernter Zugriff auf einen grafischen Desktop über das Netzwerk

Virtuelle Ressourcen: Sekundärspeicher- und Netzwerkvirtualisierung

Sandboxing: virtuelle Prozesslaufzeitumgebungen inklusive Betriebssystem (Abstrakter Prozessor)

Virtuelle Computer: Virtualisierung der gesamten Computerhardware mithilfe eines Virtual Machine Monitor (Hypervisor) ]

Taxonomie der Virtuellen Maschinen

Es können zwei wesentliche Klassen unterschieden werden:

Prozess Virtuelle Maschinen

Virtuelle Prozessoren oder Virtuelle Anwendungsmaschinen die eine Anwendung innerhalb oder außerhalb eines Betriebssystems ausführen und einen einzelnen Prozess unterstützen. Java Virtual Machines, auf denen in Java kompilierte Programme ausgeführt werden, sind Beispiele für Prozess-VMs. Es wird ein Prozessor simuliert.

System Virtuelle Maschinen

System-VMs ermöglichen die gemeinsame Nutzung der zugrunde liegenden physischen Maschinenressourcen durch verschiedene Virtual Machines, die jeweils ihr eigenes Betriebssystem ausführen. Es wird kein Prozessor simuliert, aber je nach Typ emuliert.

Simulation

Abtrakte Maschine ohne Kompatibilität zu bestehender Hardware

Emulation

Konkerete Maschinen mit bestmöglicherKompatibilität zu bestehender Hardware

.reference[VM,Smith,2005] .centered.width60[

] .figure[Übersicht und Klassendiagramm der VM Architekturen und Methodem]

Virtualisierung

.reference[VM,Smith,2005] .centered.width50[

] .figure[Virtualisierung. Formal ist Virtualisierung die Konstruktion eines Isomorphismus zwischen einem Gastsystem und einem Host; e' ◦ V(Si) = V ◦ e(Si). Dabei ist e eine Funktion im Gastsystem, die den Zustand Si in Sj überführt. Die Virtualisierung V "übersetzt" den Zustandsübergang in das Hostsystem. Im Host gibt es eine entsprechende Abfolge von Operationen e', die eine äquivalente Änderung des Hostzustands vornimmt (Änderungen S'i zu S'j). ]

.reference[VM,Smith,2005] .centered.width50[

] .figure[Implementierung virtueller Festplatten. Virtualisierung bietet eine andere Schnittstelle und / oder Ressourcen auf derselben Abstraktionsebene. Das Dateisystem ist Virtualisierung!]

Sandboxing (virtuelles Laufzeitsystem)

Die Laufzeitumgebung einer Applikation wird vom restlichen System abgeschirmt, d.h. virtualisiert (Container-based Operating Systems, COS).

Diese Möglichkeit wird von einigen Betriebssystemen unterstützt, zum Beispiel von Oracle Solaris mit den Containers, vom FreeBSD-Unix mit den Jails und von Linux mit dem Vserver.

Dasselbe Konzept wird aber beispielsweise auch in Webbrowsern im Kleinen genutzt, um einzelne Webseiten oder Plug-ins zu isolieren.

Beim Sandboxing sieht jeder Prozess nur sich selbst und das Betriebssystem, wodurch eine wechselseitige Beeinflussung von Applikationen untereinander verhindert wird.

Dazu gehört, dass jedem Anwenderprozess nur ein begrenzter Anteil an der Nutzung gemeinsamer Ressourcen gewährt wird.

Jede Sandbox ist ein separater User Space, der sich mit anderen Sandboxen lediglich den geschützten Kernel Space teilt.

Die im Unix-Bereich klassische Lösung der chroot-Umgebung kann nicht dieselbe Isolation gewähren wie das Sandboxing-Konzept, da die damit realisierbare Isolierung unvollständig ist.

Virtual Hosting

Anwendung findet das Sandboxing bei Anbietern von Virtual Hosting, zur erhöhten Isolierung kritischer Applikationen bei sich wechselseitig nicht trauenden Systembenutzern, zur feineren Kontrolle der Ressourcenzuteilung und zur Ermöglichung einfacher Migrierungen von Applikationen.

Im Vergleich zu Lösungen mit einem Virtual Machine Monitor ist der Zusatzaufwand gering, da stets dieselbe Systemprogrammierschnittstelle genutzt wird und spezielle Hardware unnötig ist. Allerdings wird nicht dieselbe Flexibilität erreicht, da nur Applikationen für dieselbe Betriebssystemplattform lauffähig sind.

Virtual Private Server

Ein Schritt weiter: Ein ganzer Rechner wird aufgeteilt und wird mehreren Betriebssystemen und Betreibern (Kunden) zur Verfügung gestellt.

Beispiel: https://edu-9.de ist Virtual Private Server Hosting!

Auf einem virtuellen privaten Server wird eine eigene Kopie eines Betriebssystems (OS) ausgeführt, mit möglicherweise Zugriff auf die Superuser-Ebene dieses Betriebssysteminstanz, sodass sie nahezu jede Software installieren können, die auf diesem Betriebssystem ausgeführt wird.

Für viele Zwecke entspricht es funktional einem dedizierten physischen Server und kann softwaredefiniert einfacher erstellt und konfiguriert werden (z.B. webbasierte Installationd eines Betriebssystems).

Container-Systeme

Für den betrieblichen Einsatz ist eine möglichst einfache Bereitstellung von Anwendungen wichtig, die voneinander isoliert ablaufen und wenig Systemabhängigkeiten zeigen (Bibliotheken!).

.shadowbox[

Dies ermöglichen Container-Systeme, wie z.B. Docker oder rkt, die beide auf Linux aufsetzen.

Dazu werden Anwendungen, wie z.B. ein Webserver oder ein Datenbanksystem, mithilfe entsprechender Werkzeuge als Standardpakete bereitgestellt.

Werden diese Pakete, als Abbilder (Images) bezeichnet, auf einer webbasierten Plattform allgemein nutzbar bereitgestellt, so benötigt eine Installation nur noch wenige Arbeitsschritte.

Jedes Abbild, als einzelne Datei verfügbar, enthält nicht nur die Anwendung selbst, sondern auch alle Drittsoftware, die bei traditioneller Bereitstellung zusätzlich zu installieren wäre.

Für Docker-Images ist dies mit dem Docker Hub realisiert, der als Onlinedienst sowohl eine öffentliche wie auch eine private Bereitstellung erlaubt. ]

System-VM

Emulation von Prozessoren unter Verwendung physischer Prozessoren;

Emulation von Hardwaregeräten unter Verwendung von Betriebssystemmitteln;

Virtualisierung von Speicher unter Verwendung von Betriebssystemmitteln.

.centered.width60[

] .figure[Eine virtuelle Systemmaschine. Virtualisierungssoftware übersetzt die von einer Hardwareplattform verwendete ISA in eine andere und bildet eine virtuelle Systemmaschine, die in der Lage ist, eine Systemsoftwareumgebung auszuführen, die u.U. auch für eine andere Hardware entwickelt wurde.]

.centered.width100[

] .figure[Beispiele für Anwendungen virtueller Maschinen. (a) Emulieren eines Befehlssatzes mit einem anderen; (b) Replizieren einer virtuellen Maschine, so dass mehrere Betriebssysteme gleichzeitig unterstützt werden können; (c) Komponieren von Software für virtuelle Maschinen zu einem komplexeren, flexibleren System.]

Virtual Machine Monitor bzw. Hypervisor

Neben der Speichervirtualisierung ist heute die Bereitstellung virtueller Rechnerumgebungen durch einen Virtual Machine Monitor (VMM) bzw. Hypervisor die wichtigste Virtualisierungsform.

Der VMM erlaubt die Erstellung mehrerer virtueller Umgebungen (hier dann VM genannt) in denen sich unterschiedliche Betriebssysteme installieren lassen.

.centered.width60[

] .figure[VMM-Typen: Ohne (bare metal) und mit zwischengeschalteten Betriebssystem]

.shadowbox.smaller[Ein VMM kann auf zwei Arten realisiert werden, die als Typ-I- und Typ-II-VMM bezeichnet werden.

Beim Typ I setzt der VMM direkt auf der Hardware auf und virtualisiert diese (bare-metal). Der VMM stellt ein eigenes Betriebssystem dar, dessen Aufgabe in der Ressourcenverwaltung und der Bereitstellung von VMs liegt. Die Ressourcenverwaltung umfasst die Aufteilung der Rechenzeit (scheduling) und die Zuteilung (allocation) von Speicher und Peripherie auf die einzelnen VMs. Beispiel eines solchen Produkts ist VMware ESX, das aber nur auf Hardware ablaufen kann, für die es eigene Treiber besitzt.

Ein Typ-II-VMM läuft als eine von mehreren Applikationen (hosted) auf einem Gastgeber-Betriebssystem (host operating system). Damit er jedoch die nötigen Rechte auf der Hardware erhält, benutzt er einen speziellen VMM-Treiber, der unter dem Gastgeber-Betriebssystem installiert ist und dadurch dem Hypervisor Kernmodus-Privilegien eröffnet. Jedes weitere Betriebssystem, das unter dem VMM läuft, wird als Gastbetriebssystem (guest operating system) bezeichnet. Der VMM kann auf Mechanismen des unterliegenden Gastgeber-Betriebssystems für die Ressourcenverwaltung zurückgreifen. Typ-II-Produkte sind VMware Workstation, Virtual-Box und Xen.

Eine zwischen Typ I und Typ II anzusiedelnde Variante sind Hypervisors, die in das Betriebssystem von Haus aus bereits integriert sind. Realisiert wird dies in Linux durch die KVM (Kernel-based Virtual Machine) und unter Windows mit Hyper-V. Diese Zwischenform lässt sich so einsetzen, dass keine Applikationen auf dem Betriebssystem selbst laufen, sondern nur der Hypervisor genutzt wird. In diesem Szenario sind keine Leistungsnachteile im Vergleich zu reinen Typ-I-VMM zu erwarten. ]

Anforderungen

Ein VMM muss nach Popek/Goldberg (1974) drei Kerneigenschaften unterstützen:

.shadowbox[

Ausführungsumgebung (fidelity, equivalence): Programme laufen auf einem virtualisierten Rechner identisch ab wie auf einem realen Rechner, mit Ausnahme der Geschwindigkeit.

Effizienz (performance, efficiency): Die große Mehrheit aller Maschinenbefehle, die innerhalb einer VM abgearbeitet werden, müssen direkt durch den realen Prozessor ausgeführt werden ohne die Intervention durch den VMM.

Ressourcenverwaltung (safety, resource control): Die Ressourcen werden vollständig durch den VMM verwaltet und den einzelnen VMs zugeteilt bzw. wieder entzogen. ]

Funktionsweise eines VMM

In der Anfangszeit der VMM in der PC-Welt fehlte eine passende Hardwareunterstützung, sodass sich der VMM und das in der VM ablaufende Betriebssystem den Kernmodus teilten.

Ohne Unterstützung durch Hardware

Ein sicherer VM-Monitor setzt eine voll virtualisierbare CPU voraus. Das heißt, dass alle Instruktionen, die auf die Hardware zugreifen oder indirekt die Hardware beeinflussen können, nur privilegiert ausgeführt werden. Dazu muss die CPU zwischen einem privilegierten und nicht privilegierten Betriebsmodus unterscheiden. Typischerweise werden diese als Kernmodus (kernel/supervisor mode) und Benutzermodus (user mode) bezeichnet. Prozessorintern wird der privilegierte Modus durch ein gesetztes KM-Bit repräsentiert (KM für Kernel Mode). Dieses Bit kann im Benutzermodus natürlich nicht direkt gesetzt werden. Erfolgt jedoch ein Hardware- oder Software-Interrupt, so wird es automatisch durch die CPU selbst gesetzt.

Funktionsweise eines VMM

.centered.width60[

] .figure[Zustandsmodell für Privilegiensystem der CPU]

Funktionsweise eines VMM

Was passiert nun, wenn ein Programm im Benutzermodus eine privilegierte Instruktion ausführen will? Die CPU wird in diesem Fall den Befehl nicht ausführen, sondern einen Software-Interrupt (trap) auslösen. Wird ein VMM eingesetzt, so läuft nur der VMM im privilegierten Modus. Aller restlicher Code, einschließlich irgendwelcher Betriebssysteme, läuft im Benutzermodus. Die Unterscheidung eines Betriebssystems zwischen Benutzer- und Kernmodus wird nämlich durch den VMM ebenfalls virtualisiert.

.centered.width60[

] .figure[Sichere Virtualisierung mittels Privilegiensystem: KM\*: virtualisiertes Privilegien-Bit (pro VM)]

Funktionsweise eines VMM

Mit Unterstützung durch Hardware

Moderne CPUs enthalten eine Zusatzhardware für eine einfachere Realisierung des VMM, der dann als Hardware-assisted VMM bezeichnet wird. Dies trifft für viele Prozessortypen zu, auch für die x86- und x86-64-Prozessoren. Ergänzend zu den Privilegienstufen, die das Betriebssystem nutzt, steht eine nochmals höher privilegierte Hypervisor-Stufe zur Verfügung, in die bei der Ausführung kritischer Instruktionen verzweigt wird. Bei x86-Prozessoren wird diese als Root-Betriebsmodus bezeichnet.

Einsatz von VMM für ...

.centered.width80[

] .figure[VMM für die Serverzusammenführung (Konsolidierung) auf einer Hardware]

Einsatz von VMM für ...

.centered.width80[

] .figure[VMM-Einsatzszenario Applikationsmigration]

Einsatz von VMM für ...

.centered.width80[

] .figure[VMM-Einsatzszenario Softwaredistribution (Beispiel) mit Replikation]

Prozess-VM

Beispiele für Hochsprachen Prozess-VMs:

Java Virtual Machine (Bytecode, Stack- und Registerprozessor)

Lua (Lua, LuaJit, Bytecode)

JavaScript (Google V8 - Bytecode und nativer Maschinenkode, Spidermonkey - Bytecode, WebAssembly)

Continuum (JavaScript -> JavaScipt)

Kompiler werden eine Programmiersprache in ein i.A. lineares Maschinenprogramm übersetzen. Dabei wird die Komplexität reduziert.

Für die zuvor beschriebenen Prozess-VMs ist die plattformübergreifende Portabilität eindeutig ein sehr wichtiges Ziel. Vollständige plattformübergreifende Portabilität lässt sich leichter erreichen, wenn die Prozess-VM in ein gesamtes Software-Framework integriert wird.

.centered.width60[

] .figure[Hochsprachenumgebungen. (a) Ein herkömmliches System, in dem plattformabhängiger Objektcode erzeugt, verteilt und ausfgeführt wird → starke Betriebssystem- und Rechnerabhängigkeit; (b) eine HLL-VM-Umgebung, in der portabler Zwischencode von einer plattformabhängigen virtuellen Maschine "ausgeführt" wird → schwache Betriebssystem- und Rechnerabhängigkeit.]

.reference[VM,Smith,2005] .centered.width80[

] .figure[Eine virtuelle Prozessmaschine ist Virtualisierungssoftware die einen Satz von Anweisungen auf Betriebssystem- und Benutzerebene übersetzt, und eine Plattform auf eine andere abbildet - quasi ein Kompiler mit laufzeitumgebung (runtime).]

Klassen der Hochsprachen Prozess-VMs:

Interpreter ("eat and think", Programmkode wird sofort ausgeführt)

Basic

Bash Shell

interpret(method) {
  while( code remains in sequence ) {
    read the next code from the sequence;
    if (the code needs more data){
      read more data from the sequence;
    }
    perform actions specified by the code;
  }
}

.algorithm[Der Interpreter]

Die Komplexität verbirgt sich im Schritt “Aktionen ausführen, die durch den Code definiert sind."

Wenn der Code beispielsweise eine neue Instanz einer Klasse erstellen soll, ruft der Interpreter den Garbage Collector (Speichermanager der VM) auf, um einen Speicherabschnitt zuzuweisen, den Speicherinhalt auf Null zu setzen, den Objektheader zu initialisieren (z.B. einen vtable-Zeiger der Klasse zu installieren) und dann gibt er den Objektzeiger zurück.

Wenn der Code eine virtuelle Methode aufrufen soll, muss der Interpreter die Methodenadresse herausfinden, einen Stapelrahmen vorbereiten, die Argumente auf den Stapel laden, die Methode aufrufen, was rekursives interpretieren bedeuten kann, und das Ergebnis zurückgeben.

Beim Aufruf einer Zielmethode/Funktion muss u.U. der Methodencode geladen und analysiert werden, wenn er sich noch nicht im Speicher befindet oder noch nicht initialisiert wurde.

Verzweigungen im Programmfluss (Sprünge) müssen ggfs. im Programmkode gesucht werden.

Besser: Bytecode oder Super Code Maschinen (abstrakte Software ISA)

int x; x=0; x=x+1; printf("%d",x);
if (x<0) { x=-x; } else { x=1; };
------------------------------------------------
ALLOCATE(4) VARIABLE(x) NUMBER(0) WRITE
VARIABLE(x) VARIABLE(x) READ NUMBER(1) ADD WRITE
STRING("%d") VARIABLE(x) READ CALL(printf)
VARIABLE(x) NUMBER(0) LT BRANCHZ(5)
VARIABLE(x) VARIABLE(x) READ NEG WRITE BRANCH(3)
VARIABLE(x) NUMBER(1) WRITE

.example[Beispiel Überstzung C Programm in lineare Instruktionssequenz für abstrakte Stackmaschine]

Abstrakte Maschine

.col1.width50[ Register Maschine

Die meisten Instruktionen werden mit einem Registersatz und direkten Speicherzugriff ausgeführt

Einen Stack gibt es meist nur für temporäre Varibalen

Das Instruktionsformat kann keinen, einen, zwei oder mehr Operanden enthalten (Konstante, Register, Speicheradresse) ] .col2.width50[ Stack Maschine

Die meisten Instruktionen werden über einen oder mehreren Stapelspeichern und einem Akkumulator ausgeführt

Eventuell kleiner Registersatz für temporäre Ergebnisse

Das Instruktionsformat enthält i.A. keine Operanden, diese befinden sich auf dem Stack, und auch Ergebnisse von Berechnungen werden wieder auf dem Stack abgelegt,. ]

Prozess-VM Interpreter Loop

Die Ausführung von abstrakten Instruktionen findet vollständig in Software statt, was langsam ist.

Es gibt i.A. einen Schleife die aus einem Programmcodespeicher Instruktionen liest, diese kodieren und schließelich ausführen muss.

CS[];
ip=0;
while(!terminated) {
  code=CS[ip]
  instr=decode(cmd)
  status=execute(instr)
  ip=next(instr,status)
}

.algorithm[Die Main Loop]

Instruktionsformat

Neben "binären" Bytecode (man spricht von seriellisierten komopakten Code) kann man auch nicht kodierte Instruktionsformate wählen die keine Dekodierung benötigen.

struct command {
  opcode   : opcodes[OPNAME],
  operands : operand [],
};
enum opcodes {
  OP1 = 1,
  OP2 = 2,
  ...
}
union operand {
  number_t constant;
  address_t address;
  address_t register;
}

.definition17-7-1

Instruktionsdekoder

Es muss eine spezifische opCode Funktion ausgeführt werden (Mikrooperationen). Wir gehen davon aus dass der opCode bereits dekodiert ist.

Wie bildet man einen (numerischen oder symbolischen) opCode auf eine Funktion effizient ab?

Eine lange if-then-else-if Kaskadenstruktur die den opCode mit Anweisungen verbindet und diese direkt ausführt ⇒ nicht konstante Laufzeit!

Eine switch-case Mehrfachauswahl. Zunächst kein große Verbesserung zu 1. da auf Maxhinenebene ein switch-case in eine Vielzahl von Vergleichsanweisungen mit bedingten und unbedingten Sprüngen umgesetzt wird (wie if-then-else-if)

Irgeneine Form eine Lookup Tabelle deren Index der opCode ist und der Tabelleneintgrag entweder eine Funktions- und interne Codesprungadresse ausgibt.

Hier müssen die opCodes (bestenfalls) aufeinander folgend nummeriert werden.

Hier gibt es konstante Dekodierungslaufzeit!

Prozess-VM Interpreter Loop (Option 2)

while() {
  op=CS[ip]
  if (op==OP1) { status=doop1(arguments); }
  else if (op==OP1) { status=doop1(arguments); }
  else if (op==OP2) { status=doop2(arguments); }
  ...
  else if (op==OPN) { status=doopN(arguments); }
  ip=next(ip,op,status);
}

.algorithm[Nicht effiziente Implementierung einer Instruktionsschleife in C mit linearer Ausführungszeit ohnen Möglichkeit der Optimierung durch Compiler - Option 1]

while() {
  op=CS[ip]
  switch (op.code) {
    case OP1: status=doop1(arguments); break;
    case OP2: status=doop2(arguments); break;
    ...
    case OPN: status=doop3(arguments); brak;
  }
  ip=next(ip,op,status);
}

.algorithm[Effiziente Implementierung einer Instruktionsschleife in C mit linearer Ausführungszeit (Mulitplexer), aber mit Möglichkeit für den Compiler mittels Tabellen in konstanter Laufzeit zu realisieren - Option 2]

Prozess-VM Interpreter Loop (Option 3)

enum opCode {
  OP1,
  OP2,
  ...
  MAXOP
};
void op1(..); ...
OPTABLE[MAXOP]={op1,op2,..}
...
while() {
  next=OPTABLE[op.code]
  next()
}

.algorithm[Effiziente Implementierung einer Instruktionsschleife in C mit konstanter (?) Ausführungszeit (LUT) - Option 3]

JIT Übersetzung

.compact[

Die Ausführung von Maschineninstruktionen in Software führt zu geringer Verarbeitungsgeschwindigkeit. Typisch werden 100 native Maschineninstruktionen für die Ausführung einer abstrakte Instruktion benötigt.

Die JIT-Kompilierung kompiliert einen Teil des Anwendungscodes zur Laufzeit in binären (nativen) Maschinencode und ermöglicht es der VM dann, den generierten Code direkt auszuführen, anstatt den ursprünglichen Teil des Anwendungscodes zu interpretieren.

Es ist, als würde man den gesamten Anwendungscode als eine einzige Superanweisung behandeln.

Man unterscheidet:

Übersetzung von Programmkode

Übersetzung von Daten(strukturen)

Die erste Frage an JIT ist, wie der zu kompilierende Anwendungscode ausgewählt wird?]

Eine Methode oder Funktion wird aufgrund ihrer genau definierten semantischen Grenze als Kompilierungseinheit betrachtet.

Bei Daten wird es schwieriger die semantische Grenze zu finden!

.centered.width45[

] .figure[Googles V8 Engine als dominanter Vertreter einer JIT HLL Prozess-VM. Bei numerischen Berechnungen erreicht V8 nahezu native Code Performanz (z.B. Matrixmultiplikation)]

.reference[DOI:10.1145/3067695.3082521] .centered.width80[

] .figure[Java-Quellcode wird zuerst zu Bytecode kompiliert und anschließend als nativer Code interpretiert oder ausgeführt. Umfangreiche Optimierungen sind für die JIT-Kompilierungsphase reserviert. JAVA JVM JIT erzeugt aber schlechteren Code (langsamer) wie V8 JIT und die Konzepte konzentrien sich auf Programmcode (und nicht Daten). Dafür werden eine Vielzahl von Prozessoren und Rechneranlagen unterstützt - V8 ist nur für ia32/ia64 optimiert!]

JIT benötigt Datentypen und Speicherinformation von Variablen, Funktionen und Funktionsargumenten!

Dynamisch typisierte Sprachen (JavaScript, Lua) können diese Informationen nicht liefern, sondern Typsignaturen müssen zur Laufzeit vom JIT Compiler abgeleitet werden. Zeitaufwändige Operation!

Und noch viel schlimmer: Datentypen von Variablen, Funktionsargumenten und Rückgabewerten könen sich zur Laufzeit ändern!

Es muss für jede Typsigantur einer Funktion (oder Datenstruktur/Objekt) eine eigene native Übersetzung angelegt werden. Teuer im Speicher!

Statisch typisierte Sptachen wie Java haben dieses Problem nicht, jedoch ist fraglich woher die Typinformationen kommen. Im Bytecode sind nur sehr begrenzte Informationen verfügbar.

Ein abschließendes Beispiel:

Der gute alte Basic Interpreter

Aufteilung eines Programms in Zeilen (ursprünglich mit vorangestellter Zeilennummer)

Es gibt Sprungbefehle wie goto <line>

Basic wird Zeile für zeile entweder interpretiert oder kompiliert

Bei einem Vorwärtssprung ist die Programmcodeadresse einer noch folgenden Zeile noch nicht bekannt, erst bei der Ausführung wenn das Programm vollständig ist.

Wenn aber die Zeile des Ziels ermittelt wurde kann diese durch eine Codeadresse ersetzt werden: JIT

10: a=1
15: goto 20
20: b=a
------------------------------------------------------------------------------------------------
0              2           4      5         7              9    10         12
LINENUMBER(10) VARIABLE(a) ASSIGN NUMBER(1) LINENUMBER(15) GOTO LINENUMBER(20) LINENUMBER(20) ...
LINENUMBER(10) VARIABLE(a) ASSIGN NUMBER(1) LINENUMBER(15) GOTO ADDRESS(12)    LINENUMBER(20) ...

Vertiefende Literatur

[1] J. E. Smith and R. Nair, Virtual Machines Versatile Platforms for Systems and Processes. Elsevier, 2005.

[2] X.-F. Li, Advanced Design and Implementation of Virtual Machines. CRC Press, 2017.

[3] Reinhard Wilhelm , H. Seidl, Übersetzerbau VirtuelleMaschinen. Springer, 2007.

[4] lain D. Craig, Virtual Machines. Springer London, 2006.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Kommunikation und Synchronisaztion von Prozessen

Lernziele

Sie beurteilen die Eignung nachrichten- und speicherbasierter Verfahren zur Kommunikation zwischen parallelen Abläufen.

Sie analysieren Anwendungsprobleme von Unix-Pipes.

Sie unterscheiden zwei Implementierungsarten von Monitoren.

Sie erläutern das Grundprinzip eines Rendezvous paralleler Prozesse.

Sie entwickeln eigene Programme, die Berkeley-Sockets bzw. RPC zur rechnerübergreifenden Datenverarbeitung nutzen.

Taxonomie

Wie können Prozesse kommunizieren? Kommunikation ist örtlich, temporal und zustandsbasiert mit Daten.

IPC: Interprocess Communication

Ein Prozess wartet auf die Terminierung eines anderen (Eltern-Kind Gruppe);

Ein Prozess wartet auf Daten von einem anderen; Beispiel: Anforderung von Daten eines Web Servers über HTTP (remote) oder ein Prozess waretet auf Daten einer Datenbank; → Kooperation

Prozesse wollen quasi gleichzeitig (oder zeitlich sehr nahe beiandner) einen gemeinsamen Punkt im Programmfluss erreichen; Barriere

Prozesse greifen auf geteilte Ressourcen qusi parallel zu und der Zugriff muss sequenzialisiert werden; Wettbewerb

Prozesse warten auf zeitliche Ereignisse (ist es der gleiche Timer und mehrere Prozesse warten darauf dann ist das IPC)

.shadowbox.size09[

Synchrone versa asynchrone Kommunikation (mit Signalen oder Nachrichten)

Synchron bedeutet dass auf eine Antwort oder Bestätigung gewartet wird (mit Prozessblockierung und Prozesswechsel)

Asynchron bedeutet dass der Prozess nicht wartet und ggf. später durch eine Rückruffunktion (ähnlich Interruptbetrieb) eine Antwort erhält

Wir unterscheiden Signale (Events) und Nachrichten mit Daten; beide können zu einer Synchronisation von Prozessen führen;

Synchronisation kann Daten enthalten - aber kein Muss.

Es gibt eine Vielzahl von IPC Objketen. Auch eine Datei kann dazu gehören (Schreib/Lese Sperren)

Geteilte Speicherbereiche

Dateien, Pipes (Warteschlangen über das Dateisystem)

Semaphore

Nachrichten

Synchronisation führt i.A. zur Prozessblockierung und Prozesswechsel bzw. Warten. ]

Prozesse und Threads können lokal auf dem gleichen Rechner oder mithilfe der Netzwerksoftware des Betriebssystems in einer verteilten Umgebung kommunizieren.

.reference[bsdp] .centered.width70[

] .figure[Überblick über Synchronisation und Kommunikation]

Es wird unterschieden zwischen den zwei Gruppen »Synchronisation« und »Kommunikation«.

Bei der Kommunikation wird weiter unterteilt in »speicherbasierte Verfahren« und »nachrichtenbasierte Verfahren«.

Die Kommunikation unterscheidet sich von der Synchronisation, indem explizit Daten zwischen den Prozessen ausgetauscht werden und nicht nur eine Koordination im zeitlichen Ablauf oder Ressourcenzugriff realisiert wird.

.centered.width60[

] .figure[IPC-Verfahren aus Sicht der Programmierung]

.box[

Mit der Eigenschaft »systemgebunden« ist gemeint, dass das Verfahren als Funktionsaufruf der Betriebssystem-Programmierschnittstelle zur Verfügung steht.

Mit der Eigenschaft »sprachgebunden« wird der Fall umschrieben, bei dem ein Funktionsaufruf des Laufzeitsystems der Programmiersprache bzw. der Sprachbibliothek zur Verfügung steht. ]

Jedes der Programmierkonzepte weist bezüglich einer bestimmten Problemklasse seine Vorzüge auf.

Wesentlich ist jedoch, dass alle Konzepte funktional äquivalent sind, d.h., jedes Konzept kann in seiner Wirkungsweise durch jedes andere nachgebildet werden.

Nachrichtenbasierte Verfahren

Unter nachrichtenbasierten Verfahren fassen wir alle Mechanismen zusammen, die einen Datenaustausch zwischen Prozessen und Threads mithilfe von Systemfunktionen bewerkstelligen.

Allgemeine Aspekte

Datenabgrenzung bei der Kommunikation

Die Datenabgrenzung bei der Kommunikation hängt von der Form der ausgetauschten Daten ab. Diese ist bestimmend für mögliche Lösungen der Datenabgrenzung.

Datenaustausch mittels Nachrichten (message passing)

Datenaustausch mittels Datenströmen (streaming)

Datenaustausch mittels Paketen (packeting)

Beim Datenaustausch mittels Nachrichten haben wir eine abgegrenzte Datenmenge bei der Kommunikation in Form der Meldung (message).

Die Meldungsgröße kann systemabhängig fest oder variabel sein.

Kommen hingegen für den Datenaustausch Datenströme zur Anwendung, so ist die Nachrichtengrenze für den Sender und den Empfänger unsichtbar.

Die übertragbare Datenmenge ist theoretisch unbeschränkt.

Typischerweise werden die Dateisystemfunktionen read() und write() für eine stromartige Datenübertragung genutzt.

E/A ist auch Kommunikation, aber zunächst nur zwischen Prozessen und Geräten.

Beim Datenaustausch mittels Paketen kommen feste, oft standardisierte Datenformate zum Einsatz.

Sie sind im Rahmen von Kommunikationsprotokollen definiert (z.B. IP-Paketformat).

Für die Applikationsprogrammierung sind die Pakete nicht sichtbar (transparent).

Beim Übertragen der Pakete kann eine Fragmentierung (= Aufteilung in Teilpakete) und eine Defragmentierung (= Zusammensetzen aus Teilpaketen) stattfinden.

Dies wird versteckt (hidden) durch die Netzwerksoftware realisiert.

Der Datenaustausch mittels Paketen ist ein Thema der Computernetze und wird daher hier nicht weiter berücksichtigt. Die zwei verbleibenden Arten des Datenaustauschs wollen wir jedoch anhand von Realisierungsformen kennenlernen.

Synchronität bei der Kommunikation

Unter einer synchronen Kommunikation versteht man den Fall, dass der Sender warten muss, bis der Empfänger zur Entgegennahme der Daten bereit ist (Rendezvous). Bei der asynchronen Kommunikation läuft der Sender hingegen weiter, auch wenn der Empfänger gerade nicht für den Datenempfang bereit ist.

Bei der synchronen Kommunikation muss entweder der Sender (A1) oder der Empfänger (A2) warten, wenn nicht gerade das Senden und Empfangen zeitlich zusammenfallen.

Bei der asynchronen Kommunikation (B) kann hingegen das Senden sofort erfolgen, auch wenn der Empfänger nicht gerade für den Empfang bereit ist. Es ist also ein Puffer für die Zwischenlagerung von Daten, ein sogenannter Nachrichtenpuffer, notwendig.

.centered.width90[

] .figure19-2-3

Die asynchrone Kommunikation entkoppelt den Ablauf des Senders und des Empfängers und hilft, Geschwindigkeitsunterschiede in der Datenverarbeitung auszugleichen.

Genau genommen läuft bei der asynchronen Kommunikation der Sender erst dann weiter, wenn die Nachricht vollständig in einen Zwischenspeicher kopiert wurde.

Dies ist in der Abbildung durch die schräg verlaufenden Pfeile angedeutet und gilt auch für den Nachrichtenempfang.

send    (destination, &message);
receive (source,      &message);

.code[Programmierschnittstelle für die synchrone und asynchrone Nachrichtenkommunkation. Bei einer synchronen Art können beide Funktionen den Prozess blockieren. Ein Nachrichtenpuffer message trägt eine Identifikation (Textname, Deskriptor, Handle o.Ä.).]

Synchrone versa Asynchone Kommunikation

.reference[bsdp] .centered.width100[

] .table[Vergleich von synchroner und asynchroner Kommunikation]

Bei der synchronen Kommunikation übergibt der Sender die Nachricht direkt an den Empfänger. Typischerweise wird der Datenaustausch in dieser Situation in der Art eines Rendezvous erledigt. Nur wenn sowohl Sender als auch Empfänger bereit sind, kann die Datenübertragung stattfinden.

Synchrone versa Asynchone Ein- und Ausgabe

Auch bei der Grerätekommunikation kann A. vs S. unterschieden werden.

Bei der S. Kommunikation muss beim Lesen und Schreiben der Prozess warten bis die Operation ausgeführt wurde (und beim Lesen die Daten verfügbar sind).

Bei der A. Kommunikation kann der Prozess eine E/A Operation starten aber mit der Programmausführung fortsetzen. Der Prozess kann auch mehrere E/A Operationen starten.

Nur dann ist die Frage wie er mitbekommt wann E/A Operationen ausgeführt wurden (also auch Daten verfügbar sind).

Viele Programmiersprachen sind vom Paradigma her strikt sequenziell. Es ist nicht möglich mehrer Operationen parallel auszuführen.

A\. E/A ist daher in Virtuellen Maschinen eine gängige Möglichkeit auf der Programmierebene das strikt sequenzielle Paradigma aufrecht zu erhalten und unter der Motorhaube E/A Operationen mit Threads parallel auszuführen.

Mittels Callback Funktionen können dann erfüllte Operationen ihr Ergebnis wieder an das Programm übermitteln.

function callback(status,data) {
  if (!status)   print("Error:",status)
  else if (data) processData(data)
}
send('edu-9.de','mymessage1',callback) // Time 1
send('ag-0.de','mymessage2',callback)  // Time 2
receive('edu-9.de',callback)           // Time 3
receive('ag-0.de',callback)            // Time 4
print('I am finished with my computation. Waiting for I/O completion')
// Time 10: callback(...)
// Time 12: callback(...)

.definition[Asynchone E/A mit Callback Funktionen am Beispiel von JavaScript]

Das Problem: Die Callbackhölle, Verschachtelung, und schwierige Fehlerbehandlung.

Z\.B. wenn die zweite Send Operation auf Daten von der ersten Receive Operation warten muss ist eine Verlegung der Send Operation in den Callback Handler notwendig!

Vergleich von Kommunikationsverfahren

.reference[bsdp] .centered.width100[

] .table[Vergleich von Kommunikationsverfahren: Speicherbasiert versa nachrichtenbasiert.]

Ein Datenaustausch mittels Speicher kann mithilfe von Systemfunktionen erfolgen oder in Selbstverwaltung, nachdem ein gemeinsamer Speicherbereich eingerichtet wurde.

Bei der Selbstverwaltung muss in der Regel mittels Semaphoren die Koordinierung der beteiligten Threads bzw. Prozesse sichergestellt werden.

Somit stellt der Einsatz gemeinsamen Speichers meist eine Mischform dar, die sich für den schnellen Transfer großer Datenmengen besonders anbietet.

Geteilter Speicher

Threads besitzen ein inherentes geteiltes Specihermodell und können somit unmittelbar ohne weitere System- und Hilfsfunktionen über getilte Speichervariablen kommunizieren, anders als isolierte Prozesse.

Prozesse können sich über das Betriebssystem einen Speicherbereich teilen.

Dieser ist aber nur ein Bytepuffer ohne Datentyp.

Ein sinnvoller Datentyp kann mit C durch Typecasting erstellt werden.

void* create_shared_memory(size_t size) {
  // Our memory buffer will be readable and writable:
  int protection = PROT_READ | PROT_WRITE;
  // The buffer will be shared (meaning other processes can access it), but
  // anonymous (meaning third-party processes cannot obtain an address for it),
  // so only this process and its children will be able to use it:
  int visibility = MAP_SHARED | MAP_ANONYMOUS;
  // The remaining parameters to `mmap()` are not important for this use case,
  // but the manpage for `mmap` explains their purpose.
  return mmap(NULL, size, protection, visibility, -1, 0);
}
...
void* shmem = create_shared_memory(16);
int * counter = (int*)&shmem[0];
*counter=0;
int pid = fork();
if (pid == 0) { // child process
  *counter++;
} else {        // parent process
  *counter--;
}

.example[SHM für zwei Prozesse, hier als "Vererbung" durch das Forking vpn Kindprozessen]

Welchen Wert hat die Zählervariable am Ende? Gibt es Gebote? Wir nehmen einen Mehrkernrechner an.

.left[]

Keine Ahnung. Die möglichen Ergebniswerte von counter können {-1,0,1} sein. Das Ergebnis ist nicht deterministisch.

Und das ist das Problem bei SHM Kommunikation: Es gibt keine Programmflusssteuerung! Diese muss dann zusätzlich z.B. mit Semaphoren erfolgen.

.reference[Andrew Forney] .centered.width60[

] .figure[Vergleich von Kommunikationsverfahren: Nachrichtenbasiert versa speicherbasiert.]

Verbindungsorientierung

Die Verbindungsorientierung spielt eine Rolle beim asynchronen Datenaustausch, wenn wiederholt Datenübertragungen mit gleichen Kommunikationspartnern anstehen.

Unabhängig davon, ob man streng abgegrenzte Nachrichten oder einfach Datenströme austauschen will, können zwei Verbindungsformen unterschieden werden.

.shadowbox.smaller[

Arbeitet man verbindungsorientiert (connection-oriented), so muss zuerst ein logischer Verbindungskanal eingerichtet werden. Dazu muss die Identität des Empfängers bekannt sein.

Ist die Verbindung aufgebaut, so werden Daten an den Verbindungskanal übergeben, der eine Kennung trägt.

Die Angabe der Empfängeridentität ist dann nicht mehr nötig.

Sind alle Daten ausgetauscht, was typischerweise nach einer Vielzahl an einzelnen Datentransfers zutrifft, wird die Verbindung explizit wieder abgebaut.

Dies kann über eine »disconnect«-Funktion oder ein Schließen des Verbindungskanals erfolgen.

Je nach Form des Verbindungskanals sind nur zwei Kommunikationspartner erlaubt, oder es sind auch m:n-Konstellationen möglich. ]

Der logische Verbindungskanal kann zu jedem Zeitpunkt nur einen Datentransfer in eine Richtung (unidirektional) oder gleichzeitig in beide Richtungen erlauben (bidirektional).

Der unidirektionale Datenaustausch (mit alternierender Richtung) wird als Halbduplex-, der bidirektionale Datenaustausch als (Voll-) Duplexbetrieb bezeichnet.

Ist generell nur eine Kommunikationsrichtung möglich, so handelt es sich um einen Simplex-Betrieb.

.centered.width90[

] .figure[Halb- und Vollduplex-Betrieb]

Ein verbindungsloser (connectionless, datagram-oriented) Datenaustausch benutzt für jeden einzelnen Datentransfer die Identität des Empfängers als Adresse.

Es können sofort Daten übertragen werden, ohne dass zuerst ein Verbindungskanal angelegt werden muss.

.compact[

Diese Kommunikationsform empfiehlt sich, wenn nur einzelne Datentransfers, aber möglicherweise an viele verschiedene Empfänger, nötig sind.]

.reference[bsdp] .centered.width100[

] .table[Vergleich von verbindungsorientierter und verbindungsloser Kommunikation.]

Empfängeradressierung

Neben der Frage wie zwei Prozesse miteinander kommunizieren können und sich "finden" kann man adressierte Kommunikation in verschiedene Klassen einteilen.

Diese Klassen bestimmen die Empfänger von Nachrichten (lösen aber nicht das Problem der eindeutige Adressierung der Prozesse an sich)

.centered.width100[

] .figure[Empfängeradressierung bei der Kommunikation]

Bei einem unicast (1:1) wird genau ein bestimmter Empfänger angesprochen. Bei einem multicast (1:m) wird eine definierte Gruppe an Empfängern (m) adressiert. Der anycast übermittelt Daten an irgendeinen Empfänger, mindestens einen einzigen (1:1..n). Der broadcast schließlich geht an alle Empfänger (n), die sich finden lassen (1:n). Welche Empfängeradressierung eine Lösung unterstützt, ist implementationsabhängig. Meistens ist es aber nur der unicast.

Prioritäten

Erfolgen bei einem Datenaustausch alle Übertragungen mit der gleichen Priorität, so werden sie bei einer Zwischenpufferung in der FIFO-Reihenfolge abgelegt.

Können unterschiedliche Prioritäten benutzt werden, so ist es möglich, neue Daten vor alten Daten in den Nachrichtenpuffer einzutragen. Bei Datenströmen sind Prioritäten meistens nicht sinnvoll, daher ist diese Möglichkeit in der Regel dem Meldungsaustausch vorbehalten.

Im einfachsten Fall sind zwei Prioritäten definiert, nämlich »normal« und »dringend«. Anspruchsvollere Lösungen können feinere Prioritätsabstufungen anbieten.

Anwendungsszenario Client/Server

Der gepufferte Nachrichtenaustausch eignet sich zur Realisierung eines Client/Server-Betriebs.

.shadowbox[

Client: Prozess oder Thread, der eine Dienstleistung beansprucht (Auftraggeber).

Server: Prozess oder Thread, der eine Dienstleistung erbringt (Auftragnehmer). Beispiele: Dateiverzeichnis liefern, Faxmeldung übermitteln.

Funktionsweise: Ein Client beansprucht vom Server eine Dienstleistung und sendet dem Server deshalb eine Meldung. Wenn der Server die notwendigen Arbeiten erledigt hat, liefert er mittels einer Meldung die Antwort an den entsprechenden Client zurück.

Absenderkennung: Da Meldungen von sich aus in der Regel keine Absenderangaben enthalten, muss die Absenderadresse bzw. die Bezeichnung der Nachrichtenwarteschlange für die Rückmeldung in der Meldung selbst enthalten sein. ]

.centered.width90[

] .figure[Client/Server-Prinzip mittels Meldungen]

Pipe als Verbindungskanal zweier Prozesse

Eine namenlose Pipe für die Kommunikation zweier Prozesse anzulegen erfordert insgesamt drei Prozesse und die folgenden Schritte:

.shadowbox[

Der Elternprozess erzeugt die Pipe durch Aufruf von pipe(). Er erhält für beide Enden der Pipe die vom Betriebssystem zugeteilten Deskriptoren.

Der Elternprozess erzeugt nun zwei Kindprozesse (oder er selbst ist ein Kommunikationspartner).

Diese erhalten automatisch Kopien der Pipe File Deskriptoren.

Jeder Prozess schließt mittels close() die Enden der Pipe, die er nicht braucht.

Der Elternprozess schließt beide Enden, Kindprozess 1 das »obere« Ende und Kindprozess 2 das »untere« Ende.

Nun kann Kindprozess 1 mittels write() Daten über die Pipe an Kindprozess 2 senden, der diese mittels read() entgegennimmt. ]

int main()
{
  int fds[2];                             // Für Pipe-Dateideskriptoren
  char *text = "Hallo da!\n";             // Zu transferierender Text
  char buffer [5];                        // Lesepuffer
  int count, status;                      // Leseanzahl bzw. Endstatus
  pipe(fds);                              // Unnamed pipe erzeugen
  if (fork() == 0) {                      // Kindprozess 1:
     dup2(fds[1], 1);                     // stdout auf Pipe-Eingang legen
     close(fds[0]);                       // Pipe-Ausgang schließen
     write(1,text,strlen(text)+1);        // Text in Pipe schreiben
  } else if (fork() == 0) {               // Kindprozess 2:
     dup2(fds[0], 0);                     // Pipe-Ausgang auf stdin legen
     close(fds[1]);                       // Pipe-Eingang schließen
     while ((count = read(0, buffer, 4)) != 0) { // Wiederholt auslesen
        buffer[count]= 0;                 // »string terminating zero«
        printf("%s", buffer);             // Laufend ausgeben
     }
  } else {                                // Elternprozess:
     close(fds[0]);                       // Pipe-Ausgang schließen
     close(fds[1]);                       // Pipe-Eingang schließe
     wait(&status);                       // Warte auf erstes Kind
     wait(&status);                       // Warte auf zweites Kind
  }
  exit(0);
}

.example[Ein einfaches Zwei-Prozess-Kommunikationssystem über Pipers]

Benannte Pipes

Hier werden die Pipes als "virtuelle" Dateien über ds Dateisystem von Prozessen mit üblichen Read und Write Operationen geteilt.

.centered.width90[

] .figure[Client/Server-Konstellation mit benannten Unix-Pipes]

Message Queues

Die Unix-Pipes sind gut geeignet für die Übertragung von Daten, wenn eine klare Abgrenzung der einzelnen gesendeten Daten voneinander nicht notwendig ist (Bytestrom). Stehen gut definierte Meldungen im Vordergrund, so bieten sich die Message Queues an.

.box.smaller[

Sie übertragen Meldungen unterschiedlicher Länge.

Die einzelnen Meldungen sind so gegeneinander abgegrenzt, dass beim Lesen der Message Queue jeweils genau eine Meldung entnommen wird.

Der Inhalt und die Länge einer einzelnen Meldung sind frei wählbar, wobei die Meldungsgröße nicht den für die Message Queue festgelegten Maximalwert überschreiten darf (attr.mq\_msgsize).

Beim Auslesen einer Meldung wird dem Empfänger die genaue Länge der Meldung mitgeteilt.

Die in der Message Queue gepufferten Meldungen sind entsprechend ihrer Meldungspriorität sortiert, wobei Meldungen der gleichen Priorität eine FIFO-Reihenfolge einnehmen.

Die Meldungspriorität wird durch den Sender zusammen mit der Meldung der Message Queue übergeben. ]

.reference[bsdp] .centered.width80[

] .figure[Aufbau einer Message Queue]

Eine Meldung besteht immer aus den zwei Teilen msg_typ und msg_txt, die den Meldungstyp und den Meldungsinhalt repräsentieren. Wie in einer eigenen Applikation ein Meldungstyp deklariert werden kann, zeigt nachfolgendes Beispiel:

struct meine_nachricht {
  long msg_typ;
  char msg_txt[100];
}

Mutualer Ausschluss und Semaphor

Bisher wurde nachrichtenbasierte Kommunikation eingeführt. Nebem dem Nachrichtenaustausch (Kooperation) ist aber auch die Konfliktlösung bei Wettbewerb wichtig, also eine sequenzielle Synchronisation.

Vorteilhafter als eine Lösung mit Selbstverwaltung ist der Einsatz von sogenannten Semaphoren. Es handelt sich dabei um Betriebssystem- und Synchronisationsobjekte für die Absicherung und Synchronisation von Prozessen bzw. Datenkonsistenz. Der Semaphor und ihre Operationen P und V wurden 1962 von E. Dijkstra erstmals systematisch entwickelt und dargestellt

Wir unterscheiden wieder zwei Arten der Prozessinteraktion:

Kooperation, d.h. Produzenten-Konsumenten Anwendungen

Wettbewerb, d.h. Konflikte bei der Nutzung von geteilten Ressource.

Der Semaphor kann zur Synchronisation für beide Arten verwenden werden.

.reference[bsdp] .centered.width100[

] .figure[Semaphor mit einer Identität (Instanzenbezeichnung), den Operationen P/V und den Attributen Zähler (für Marken) und Warteschlange (für Prozesse)]

Der Semaphor implementiert einen geschützten Zähler (Marken, Tokens) mit einer Prozesswarteschlange:

Invariante: Der Zähler darf nie negativ werden

Prozesse werden blockiert die diese Invariante verletzen würden

Es gibt zwei Operationen: V(+) oder Up und P(-) oder Down. Beide Operationen verändern den Zähler um den Wert 1.

.centered.width60[

] .centered.width60[

] .figure[(Oben) Definition der P-Operation: Der aufrufende Prozess erhält entweder sofort eine Marke oder muss warten, bis eine verfügbar wird (dazu wird er an die Warteschlange des Semaphors angefügt). (Unten) Definition der V-Operationen: Der aufrufende Prozess übergibt eine Marke entweder dem Semaphor oder einem am Semaphor wartenden Prozess, der damit wieder weiterlaufen kann.]

Semaphortypen

Es existieren zwei Grundtypen von Semaphoren:

Binärer Semaphor (binary semaphore, mutex): Es sind genau 0 oder 1 Marke erlaubt ⇒ Lösung des mutualen Ausschlussproblems (entweder oder), Mutex Lock

Zählsemaphor (counting semaphore, general semaphore): Es sind beliebig viele Marken erlaubt, dies stellt die universellere Form dar.

Unteilbarkeit von P/V

Die Unteilbarkeit der P- und V-Operationen muss durch ihre Implementierung sichergestellt werden. Mögliche Lösungen dafür sind:

Ausschalten der Prozessorneuzuteilung für die Operationsdauer

Vorübergehend höhere Priorität als alle anderen Prozesse

Ausschalten aller Interrupts (= Gewaltlösung) während der P- und V-Operationen; ist aber nur für Einprozessorsysteme ausreichend

Verwendung atomarer Prozessorinstruktionen (v.a. der TAS-Befehl, siehe S. 201)

Aktives oder passives Warten

Eine zweite Implementierungsfrage betrifft die Art des Wartens an der Semaphor-Warteschlange.

In der Regel heißt dies, dass der Prozess für die Dauer des Wartens blockiert wird, d.h. die CPU freigibt und in den Zustand »Wartend« wechselt (passives Warten). Dies erlaubt anderen Prozessen die Wartezeit zu nutzen.

Unter speziellen Bedingungen kann jedoch ein aktives Warten die bessere Lösung sein:

Sehr kurze Wartezeit: Der Zeitbedarf für ein Blockieren kann größer sein als für ein aktives Warten. Das Blockieren und spätere Aufwecken des Prozesses beinhaltet zwei Prozessumschaltungen, für die eine zwar kleine, aber doch gegebene Zeitdauer nötig ist.

Multiprozessorsysteme: Da eine echte Parallelität möglich ist, wird in verschiedenen Situationen die Wartezeit sehr klein sein. Dies macht ebenfalls ein Blockieren unattraktiv.

Für diese speziellen Situationen bieten manche Betriebssysteme einen speziellen Semaphortyp an, der als Spinlock bezeichnet wird (ein passender deutscher Begriff fehlt).

In einer ersten Variante wartet ein Spinlock beliebig lange in einer aktiven Warteschlange.

Dies ist unattraktiv, wenn nicht bekannt ist, wie lange das Warten dauert.

Eine zweite Variante wartet in einer aktiven Warteschleife, kennt dafür aber ein Zeitlimit.

Wird dieses überschritten, so wird ein passives Warten, d.h. ein Blockieren des Prozesses, durchgeführt.

Wartereihenfolge

Eine dritte Implementierungsfrage betrifft die Einreihung in der Semaphor-Warteschlange.

Erfolgt dies nach dem FIFO-Prinzip, so ist sichergestellt, dass jeder Prozess in endlicher Zeit drankommt, sofern die einer P-Operation folgende V-Operation stets in endlicher Zeit ausgeführt wird.

Wird für die Einreihung die Prozesspriorität benutzt, so besteht die Gefahr, dass niederpriore Prozesse nie drankommen (verhungern), wenn hochpriore Prozesse genügend oft die P-Operation aufrufen.

Anwendung der Semaphoren

Eine hervorragende Bedeutung fällt Semaphoren bei der Absicherung kritischer Bereiche zu, indem sie den wechselseitigen Ausschluss sicherstellen.

Da ein kritischer Bereich jederzeit von maximal einem Prozess betreten werden darf, kommt ein binärer Semaphor zum Einsatz.

Betritt ein Prozess einen kritischen Bereich, so sperrt er diesen mithilfe des Semaphors ab.

Ein so eingesetzter Semaphor wird manchmal als Sperre (lock), die P- und V-Operationen als Sperren (lock) und Freigeben (release) bezeichnet.

Kritischer Bereich bedeutet der Schutz von Daten und Wahrung der Konsistenz von Daten. Nicht der Programmcode muss geschützt werden, sonder die Daten die er verarbeitet.

Konkret beinhaltet die Absicherung eines kritischen Bereichs die folgenden Elemente:

Den Semaphorzähler mit 1 initialisieren (eine Berechtigungsmarke bereitstellen, d.h. kritischer Bereich als frei markieren)

Die P-Operation vor dem Eintritt in den kritischen Bereich ausführen (= reservieren bzw. Berechtigungsmarke beziehen)

Die V-Operation beim Austritt aus dem kritischen Bereich ausführen (= freigeben bzw. Berechtigungsmarke an Semaphor zurückgeben)

Das Betriebssystem blockiert den Prozess bei der P-Operation, wenn die Berechtigungsmarke bereits vergeben ist

Das Betriebssystem weckt einen wartenden Prozess bei der nächsten V-Operation, die auf diesem Semaphor ausgeführt wird (da Berechtigungsmarke nun verfügbar ist)

.centered.width100[

] .figure[Absicherung kritischer Bereiche mittels Semaphor (Beispiel)]

Eine weiter Anwendung ist Kooperation ind Produzenten-Konsumenten Systemen mit einem Datenpuffer (Kapazität N Datensätze).

Eine Menge von Prozessen P schreibt Daten in den Puffer (atomar)

Ein oder mehrere Prozesse C lesen dei Daten aus dem Puffer (atomar) für weitere Verarbeitung

Es gibt zwei Situationen die mit Semaphoren abgesichert werden können:

Der Puffer ist leer ⇒ Semaphor Empty(0)

Der Puffer ist voll ⇒ Semaphor Full(N)

Die Operationen (Write, Read) verändern dann die Semaphoren und können blockieren (; ist ein Zeitschritt):

Read() ⇒ P(Empty) ; V(Full)

Write() ⇒ P(Full) ; V(Empty)

.centered.width80[

] .figure[Synchronisierte Datenwarteschlange mit Semaphoren für Produzenten-Konsumenten Systeme]

Und es geht schief: Das Problem der fünf dinierenden Philosophen ist ein Beispiel für Deadlocks (Verklemmung) in verteilten und parallelen (asynchronen) Systemen mit Kommuniaktion!

.col1[

] .up.col2[ - 5 Philosophen an einem Tisch - 5 Gabeln, jeweils eine zwischen zwei Philosophen - Ein Philosoph braucht zwei Gabeln zum Essen! - Eine Gabel wird durch eine Semaphore repräsentiert (atomare Ressource, Startwert des Zählers ist 1) ]

Implementierung mit Kanälen

.compact[

Bisher haben wir nur Kommunikationskanäle wie Sockets oder Pipes kennen gelernt. Mit denen kann unter bestimmten Bedingungen ein Semaphor implementiert werden.

Wir nehmen an wir haben ein Prozesssystem aus N+1 Prozessen.

Ein Prozess soll den Semaphor implementiert und überwachen.

Die anderen N Prozess wollen sich über den Semaophor mittels Nachrichtenaustausch synchronisieren. .size09[

Dazu wird wie in der Übung ein Request und eine Acknowledge Kanale (dort Pipe) verwendet.

je nachdem wie es mit der Atomarität von Nachrichten auf dem Kanel aussieht können Kanäle von Prozessen geteilt werden oder nicht

Also zwei Prozesse P1 und P2 schreiben eine zweistellige Nachricht 1+ und 2- in den Requestkanal. Dann muss am anderen Ende entweder 1+2- oder 2-1+ gelesen werden.

Beim Acknowledge Kanal (da N Leser) muss je einer pro Prozess existieren (es sei denn der Kanal ist wieder atomar in seinen Nachrichten und arbeitet im 1:N Multicasting, d.h. alle Leser bekommen alle Nachrichten). ]]

.smaller[Protokoll: <ID>+ für V und <ID>- für P Operation und ID als Prozessnummer 1,2,3,..,N]

channel req,ack1,ack2,... ;   waiters = [] ; counter = INIT
while (!terminate) {
  received=read(req)
  messages=split(received,2) // 1+2- => [1+,2-]
  for(message of messages) {
    switch (message[1]) {
      case '+':
        if (length(waiters)) {
          next=head(waiters); waiters=tail(waiters)
          write(ack[next],'!')
        } else {
          counter++;
        }
        write(ack[Number(message[0]),'!') ; break
      case '-':
        if (counter>0) {
          counter--;
          write(ack[Number(message[0]),'!')
        } else {
          push(waiters,Number(message[0]))
        } ; break
    }
  }
}

.algorithm[Der Semaphor Master Prozess]

function P() {
  write(req,String(myid)+'-')
  reply=read(ack[myid])
}
function V() {
  write(req,String(myid)+'+')
  reply=read(ack[myid])
}

.algorithm[Der Semaphor Klienten Prozess]

Zusammenfassung

.box[

Neben impliziter Synchronisation wie z.B. beim Lesen aus Dateien bedarf es in Multiprozesssystemen explizite Synchronisation

Ein beispiel ist der Semaphor der:

mutualen Ausschluss (Wettbewerb)

Kooperation ermöglicht.

Pipes sind i.A. inhärent synchron (geht aber auch asynchron)

Ein Semaphroe kann mit Kanälen und einem zentralen Masterprozess realisiert werden

Prozessblockierung (also Prozesswechsel) ist wesentliches Mermal von Synchronsiation

Es kann auch schief gehen wenn Prozesse mit mehreren Synchronisationsobjekten wie Semaphoren umgehen (Deadlock) ]

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Zusammenfassung

Weitere Bestandteile der zweiten Schicht und Basisdienste sind:

.shadowbox.size09[

Gerätemanagement

device.c (Gerätetreiberschnittstelle)

pipe.c (Queues zwischen Prozessen)

Dateisystem

diskfs.c

fs.c (allgemeine Dateisystemschnittstelle)

cdromfs.c

(Block) Cache

bcache.c

Ein- und Ausgabe (eher minimale Standardblibliothek für den Kernel)

console.c

`graphics.c'

printf.c

string.c ] ]

Kernel

.up.compact[

Weitere Bestandteile der nullten Schicht sind hardwarenahe Komponenten:

.shadowbox.size09[

Gerätetreiber

console.c

graphics.c

pic.c (Interruptcontroller)

ata.c (Parallele Geräteschnittstelle für Blockgeräte, HDD, CDROM)

clock.c (Timer)

keyboard.c (Tastatur)

serical.c (Serielle UART Schnittstelle)

rtc.c (Real Time Clock ⇒ Systemuhr)

Datenstrukturen

list.c

hash_set.c ] ]

.centered.width60[

] .figure[Schichtenaufbau des monolithischen Basekernel]

Standardbibliothek

Die Standardbibliothek implementiert häufig vorkommende Funktionen für:

Ein- und Ausgabe

Dateioperationen

Speichermanagement (oder eine Schnittstelle dazu)

Systemaufrufe

Sie wird von allen Nutzerprogrammen (user space) verwendet. Funktionen für Netzwerkzugriff (Sockets) und vieles mehr kann noch enthalten sein.

.up.shadowbox.size09[

Systemaufrufschnittstelle

C syscalls.s

Assembler syscall.S

Speichermanagement (USer space)

malloc.c

Ein- und Ausgabe

stdio.c

Sonstiges

user_start-c (Start eines Benutzerprogramms)

errno.c (Fehlerbehandlung) ]

.up.shadowbox.size09[

Zeichenketten

string.c

Allgemeine Funktionen

stdlib.c (Programmausführung, Zeit, Systeminformation usw.)

Konsole und Grafiksystem (Fenster)

nwindow.c ]

Benutzerpogramme

Im wesentlichen gibt es nur eine Shell. Die Aufgabe einer Shell ist:

Interaktive eingegebene Befehle von einer Kommandozeile unmittelbar ausführen ⇒ Nutzerbetrieb

Shellskripte (kleine Programme als Sammlung von Shellbefehlen) ausführen ⇒ Stapelbetrieb

Dabei sollen zwei primäre Schnittstellen zum Betriebssystem auf programmatische und interaktive Weise zur Verfügung gestellt werden (Operationalität):

.box[

Dateisystem, Textdatein

Prozesse und Programme

Ein-und Ausgabe (Terminal) ]

Shell

Die meisten gängigen Unix-Shells bieten die folgenden Funktionen:

.reference[Wikipedia] .box[

Starten von Kommandos

Dateinamen-Wildcards (globs) als Kommandoargumente

Bedingungen (if, case) und Schleifen (while, for)

interne Kommandos (cd, read)

interne Variablen ($HOME)

Manipulation der Umgebungsvariablen für die neuen Prozesse

Ein-/Ausgabeumlenkung

Starten mehrerer Prozesse, Verkettung über Pipes

Starten von Prozessen im Hintergrund ]

Moderne Shells können darüber hinaus:

.box[

Vervollständigung von Kommandos, Dateinamen und Variablen (completion system)

Editieren der Kommandozeile (command line editing)

Wiederholung und Editieren früherer Kommandos (command history)

Stoppen und erneutes Starten von Prozessen (job control)

Verschieben von Prozessen aus dem Vordergrund in den Hintergrund und umgekehrt (job control)

Eingebautes Kommando zur Durchführung von Berechnungen ($((2+2)))

Eingebautes Kommando zum Testen von Dateieigenschaften (test) ]

Skripte

Shellskripte sind Text-Dateien und stellen kleine Programme dar.

Sie werden vom Anwender geschrieben und beim Aufruf von der Shell gelesen und ausgeführt.

Muss man z. B. immer wieder in seinem Arbeitsalltag fünf Befehle nacheinander in die Shell eintippen, kann man das vereinfachen, indem man diese Befehle in einem Skript sammelt und dann nur noch das ganze Skript aufruft.

Die Shell liest das Skript aus und führt die Befehle entsprechend aus. Zu beachten ist, dass man alle Befehle, die man „händisch“ in die Shell eingibt, auch über ein Skript ausführen kann und umgekehrt.

Dienstprogramme

.shadowbox[

Shell

shell.c (sehr einfacher Shell Intepreter, kein Skripting)

Prozess Manager

Nur Prozessinformation durch procstat.c

Steuerung von Prozessausführung (stoppen) gibt es hier nicht

Systeminformationen

systats.c

Dateisystemedienste

livestat.c (Information über Gerätetreiber/Gerät und Cache)

copy.c (Datei kopieren)

Fenstermanager

manager.c (Grafiksystem, Verwaltung von Fenstern) ]

Der Erzeugungsprozess

Es müssen folgende Komponenten von Quellcode (C/Assembler) in binäre Programmdaten übersetzt werden:

Der Kernel (kernel) und Bootloader (bootblock)

Die Standardbibliothek (baselib.a)

Hier nur als Archiv (a), nicht als geteilte (shared) Bibliothek (dll/so) baselib.a

Alle Programme werden statisch (d.h. vollständig enthaltend) kompiliert und erzeugt.

Die Nutzerprogramme

Für den gesamtn Übersetzungsprozess wird ein GCC Kompiler verwendet. Dieser enthält neben dem Präprozessor cpp, den eigentlichen Kompiler cc noch einen Assembler as und einen Linker ld. Weitere Hilfsprogramme vor allem für das ELF Format werden benutzt (objdump, objcopy, ar).

Es muss GCC mit 32 Bit x86 ELF (Executable Link Format) verwendet werden. Andere Prrogrammformate und Prozessorarchitekturen führen zu einem nicht funktionieren Basekernl OS (sowohl Kernel als auch Nutzerprogramme).

Der gesamte Übersetzungsprozess wird mit dem make Programm gesteuert.

make (englisch für machen, erstellen) ist ein Build-Management-Tool, das Kommandos in Abhängigkeit von Bedingungen ausführt. Es wird hauptsächlich bei der Softwareentwicklung als Programmierwerkzeug eingesetzt.

make liest eine teils prozedurale und teils deklarative Anweisungsdatei (Makefile), in dem die Abhängigkeiten des Übersetzungsprozesses von Programmen formalisiert erfasst sind.

Diese Formalisierung beschreibt, welche Quelltextdateien auf welche Weise durch den Compiler oder durch andere Programme zu welchen Objektdateien bzw. Ergebnissen verarbeitet werden, bzw. welche Objektdateien vom Linker zu Programmbibliotheken oder ausführbaren Programmen verbunden werden.

Alle Schritte erfolgen unter Beachtung der im Makefile erfassten Abhängigkeiten.[2]

Wenn das Makefile vom make-Programm abgearbeitet wird, wird eine Umwandlung etwa einer Quelldatei in eine Objektdatei nur dann vorgenommen, wenn die Quelldatei neuer als die bereits vorliegende Version der Ergebnisdatei ist, bzw. wenn keine Ergebnisdatei vorhanden ist.

Bei der Entwicklung großer Programmpakete mit vielen Quelldateien und vielen ausführbaren Programmen entfällt so die Notwendigkeit, bei einer Reihe kleiner Veränderungen alle Kompilierungen erneut durchzuführen.

Makefile (main)

include Makefile.config

LIBRARY_SOURCES=$(wildcard library/*.c)
LIBRARY_HEADERS=$(wildcard library/*.h)
USER_SOURCES=$(wildcard user/*.c)
USER_PROGRAMS=$(USER_SOURCES:c=exe)
KERNEL_SOURCES=$(wildcard kernel/*.[chS])
WORDS=/usr/share/dict/words

.PHONY: build-kernel build-library build-userspace build-cdrom-image

all: build-cdrom-image

build-kernel: kernel/basekernel.img

build-library: library/baselib.a

build-userspace: $(USER_PROGRAMS)

build-cdrom-image: basekernel.iso

kernel/basekernel.img: $(KERNEL_SOURCES) $(LIBRARY_HEADERS)
	cd kernel && make

library/baselib.a: $(LIBRARY_SOURCES) $(LIBRARY_HEADERS)
	cd library && make

$(USER_PROGRAMS): $(USER_SOURCES) library/baselib.a $(LIBRARY_HEADERS)
	cd user && make

image: kernel/basekernel.img $(USER_PROGRAMS)
	rm -rf image
	mkdir image image/boot image/bin image/data
	cp kernel/basekernel.img image/boot
	cp $(USER_PROGRAMS) image/bin
	if [ -f ${WORDS} ]; then head -2000 ${WORDS} > image/data/words; fi

basekernel.iso: image
	${ISOGEN} -input-charset utf-8 -iso-level 2 -J -R -o $@ -b boot/basekernel.img image

disk.img:
	qemu-img create disk.img 10M

run: basekernel.iso disk.img
	qemu-system-i386 -cdrom basekernel.iso -hda disk.img

debug: basekernel.iso disk.img
	qemu-system-i386 -cdrom basekernel.iso -hda disk.img -s -S &

clean:
	rm -rf basekernel.iso image disk.img
	cd kernel && make clean
	cd library && make clean
	cd user && make clean

Makefile (config)

KERNEL_CCFLAGS=-Wall -c -ffreestanding -fno-pie -g -std=gnu99

# These settings select the native compiler,
# which is likely to work on native linux-x86.
#
CC=gcc -m32
LD=ld -melf_i386
#LD=ld -arch i386
AR=ar
OBJCOPY=objcopy -R .note.gnu.property
ISOGEN=genisoimage

# If you are compiling from another platform,
# then use the script build-cross-compiler.sh
# add cross/bin to your path, and uncomment these lines:
#CC=i686-elf-gcc
#LD=i686-elf-ld
#AR=i686-elf-ar
#OBJCOPY=i686-elf-objcopy

# If building on OSX, then install mkisofs via ports or brew
# and uncomment this:
#ISOGEN=mkisofs

Makefile (kernel)

include ../Makefile.config

KERNEL_OBJECTS=kernelcore.o main.o console.o page.o keyboard.o mouse.o event_queue.o clock.o interrupt.o kmalloc.o pic.o ata.o cdromfs.o string.o bitmap.o graphics.o font.o syscall_handler.o process.o mutex.o list.o pagetable.o rtc.o kshell.o fs.o hash_set.o diskfs.o serial.o elf.o device.o kobject.o pipe.o bcache.o printf.o is_valid.o window.o

basekernel.img: bootblock kernel
	cat bootblock kernel /dev/zero | head -c 1474560 > basekernel.img

kernel: kernel.elf
	${OBJCOPY} -O binary $< $@

bootblock: bootblock.elf
	${OBJCOPY} -O binary $< $@

kernel.elf: ${KERNEL_OBJECTS}
	${LD} ${KERNEL_LDFLAGS} -Ttext 0x10000 ${KERNEL_OBJECTS} -o $@

bootblock.elf: bootblock.o
	${LD} ${KERNEL_LDFLAGS} -Ttext 0 $< -o $@

%.o: %.c
	${CC} ${KERNEL_CCFLAGS} -I ../include $< -o $@

%.o: %.S
	${CC} ${KERNEL_CCFLAGS} -I ../include $< -o $@

clean:
	rm -rf basekernel.img *.o *.elf kernel bootblock bootblock.o

make it all

cd kernel && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/kernel'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bootblock.S -o bootblock.o
ld -melf_i386  -Ttext 0 bootblock.o -o bootblock.elf
objcopy -R .note.gnu.property -O binary bootblock.elf bootblock
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kernelcore.S -o kernelcore.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include main.c -o main.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include console.c -o console.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include page.c -o page.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include keyboard.c -o keyboard.o
In file included from keyboard.c:42:
keymap.de.pc.c:5:7: warning: multi-character character constant [-Wmultichar]
 {'3', '§', '3', 0},
       ^~~~
keymap.de.pc.c:5:7: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49831’ to ‘-89’ [-Woverflow]
keymap.de.pc.c:13:2: warning: multi-character character constant [-Wmultichar]
 {'ß', '?', 'ß','\\'},
  ^~~~
keymap.de.pc.c:13:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50079’ to ‘-97’ [-Woverflow]
keymap.de.pc.c:13:13: warning: multi-character character constant [-Wmultichar]
 {'ß', '?', 'ß','\\'},
             ^~~~
keymap.de.pc.c:13:13: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50079’ to ‘-97’ [-Woverflow]
keymap.de.pc.c:14:2: warning: multi-character character constant [-Wmultichar]
 {'´', '`', '´',0},
  ^~~~
keymap.de.pc.c:14:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49844’ to ‘-76’ [-Woverflow]
keymap.de.pc.c:14:13: warning: multi-character character constant [-Wmultichar]
 {'´', '`', '´',0},
             ^~~~
keymap.de.pc.c:14:13: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49844’ to ‘-76’ [-Woverflow]
keymap.de.pc.c:27:2: warning: multi-character character constant [-Wmultichar]
 {'ü', 'Ü', 0, 0},
  ^~~~
keymap.de.pc.c:27:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50108’ to ‘-68’ [-Woverflow]
keymap.de.pc.c:27:8: warning: multi-character character constant [-Wmultichar]
 {'ü', 'Ü', 0, 0},
        ^~~~
keymap.de.pc.c:27:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50076’ to ‘-100’ [-Woverflow]
keymap.de.pc.c:40:2: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
  ^~~~
keymap.de.pc.c:40:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50102’ to ‘-74’ [-Woverflow]
keymap.de.pc.c:40:8: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
        ^~~~
keymap.de.pc.c:40:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50070’ to ‘-106’ [-Woverflow]
keymap.de.pc.c:40:14: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
              ^~~~
keymap.de.pc.c:40:14: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50102’ to ‘-74’ [-Woverflow]
keymap.de.pc.c:41:2: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
  ^~~~
keymap.de.pc.c:41:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50084’ to ‘-92’ [-Woverflow]
keymap.de.pc.c:41:8: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
        ^~~~
keymap.de.pc.c:41:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50052’ to ‘-124’ [-Woverflow]
keymap.de.pc.c:41:14: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
              ^~~~
keymap.de.pc.c:41:14: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50084’ to ‘-92’ [-Woverflow]
keymap.de.pc.c:42:7: warning: multi-character character constant [-Wmultichar]
 {'^', '°', '^', 0},
       ^~~~
keymap.de.pc.c:42:7: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49840’ to ‘-80’ [-Woverflow]
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include mouse.c -o mouse.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include event_queue.c -o event_queue.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include clock.c -o clock.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include interrupt.c -o interrupt.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kmalloc.c -o kmalloc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pic.c -o pic.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include ata.c -o ata.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include cdromfs.c -o cdromfs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include string.c -o string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bitmap.c -o bitmap.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include graphics.c -o graphics.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include font.c -o font.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include syscall_handler.c -o syscall_handler.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include process.c -o process.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include mutex.c -o mutex.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include list.c -o list.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pagetable.c -o pagetable.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include rtc.c -o rtc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kshell.c -o kshell.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include fs.c -o fs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include hash_set.c -o hash_set.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include diskfs.c -o diskfs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include serial.c -o serial.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include elf.c -o elf.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include device.c -o device.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kobject.c -o kobject.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pipe.c -o pipe.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bcache.c -o bcache.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include printf.c -o printf.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include is_valid.c -o is_valid.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include window.c -o window.o
ld -melf_i386  -Ttext 0x10000 kernelcore.o main.o console.o page.o keyboard.o mouse.o event_queue.o clock.o interrupt.o kmalloc.o pic.o ata.o cdromfs.o string.o bitmap.o graphics.o font.o syscall_handler.o process.o mutex.o list.o pagetable.o rtc.o kshell.o fs.o hash_set.o diskfs.o serial.o elf.o device.o kobject.o pipe.o bcache.o printf.o is_valid.o window.o -o kernel.elf
objcopy -R .note.gnu.property -O binary kernel.elf kernel
cat bootblock kernel /dev/zero | head -c 1474560 > basekernel.img
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/kernel'
cd library && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/library'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include user-start.c -o user-start.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include errno.c -o errno.o
gcc -m32    -c -o syscall.o syscall.S
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include syscalls.c -o syscalls.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include string.c -o string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include stdio.c -o stdio.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include stdlib.c -o stdlib.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include malloc.c -o malloc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kernel_object_string.c -o kernel_object_string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include nwindow.c -o nwindow.o
ar rv baselib.a errno.o syscall.o syscalls.o string.o stdio.o stdlib.o malloc.o kernel_object_string.o nwindow.o
ar: creating baselib.a
a - errno.o
a - syscall.o
a - syscalls.o
a - string.o
a - stdio.o
a - stdlib.o
a - malloc.o
a - kernel_object_string.o
a - nwindow.o
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/library'
cd user && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/user'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include ball.c -o ball.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o ball.o ../library/baselib.a -o ball.exe
objcopy -R .note.gnu.property -R .comment --strip-debug ball.exe
objcopy: ball.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include clock.c -o clock.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o clock.o ../library/baselib.a -o clock.exe
objcopy -R .note.gnu.property -R .comment --strip-debug clock.exe
objcopy: clock.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include copy.c -o copy.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o copy.o ../library/baselib.a -o copy.exe
objcopy -R .note.gnu.property -R .comment --strip-debug copy.exe
objcopy: copy.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include livestat.c -o livestat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o livestat.o ../library/baselib.a -o livestat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug livestat.exe
objcopy: livestat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include manager.c -o manager.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o manager.o ../library/baselib.a -o manager.exe
objcopy -R .note.gnu.property -R .comment --strip-debug manager.exe
objcopy: manager.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include fractal.c -o fractal.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o fractal.o ../library/baselib.a -o fractal.exe
objcopy -R .note.gnu.property -R .comment --strip-debug fractal.exe
objcopy: fractal.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include procstat.c -o procstat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o procstat.o ../library/baselib.a -o procstat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug procstat.exe
objcopy: procstat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include saver.c -o saver.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o saver.o ../library/baselib.a -o saver.exe
objcopy -R .note.gnu.property -R .comment --strip-debug saver.exe
objcopy: saver.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include shell.c -o shell.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o shell.o ../library/baselib.a -o shell.exe
objcopy -R .note.gnu.property -R .comment --strip-debug shell.exe
objcopy: shell.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include snake.c -o snake.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o snake.o ../library/baselib.a -o snake.exe
objcopy -R .note.gnu.property -R .comment --strip-debug snake.exe
objcopy: snake.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include sysstat.c -o sysstat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o sysstat.o ../library/baselib.a -o sysstat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug sysstat.exe
objcopy: sysstat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
rm manager.o ball.o copy.o shell.o saver.o fractal.o snake.o livestat.o procstat.o clock.o sysstat.o
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/user'
rm -rf image
mkdir image image/boot image/bin image/data
cp kernel/basekernel.img image/boot
cp user/manager.exe user/fractal.exe user/sysstat.exe user/shell.exe user/saver.exe user/clock.exe user/copy.exe user/livestat.exe user/ball.exe user/procstat.exe user/snake.exe image/bin
if [ -f /usr/share/dict/words ]; then head -2000 /usr/share/dict/words > image/data/words; fi
genisoimage -input-charset utf-8 -iso-level 2 -J -R -o basekernel.iso -b boot/basekernel.img image
Size of boot image is 2880 sectors -> Emulating a 1440 kB floppy
Total translation table size: 2048
Total rockridge attributes bytes: 1946
Total directory bytes: 6854
Path table size(bytes): 46
Max brk space used 1a000
1159 extents written (2 MB)

TODO

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Prozesse und Prozessverwaltung

Einführung des Prozessmodells

Datenstrukturen von Prozessen

Multiprozesssysteme und Scheduling (Prozessumschaltung)

Terminologie

Programm

Ausführungsvorschrift für eine Maschine, i.A. eine lineare Liste von Instruktionen.

Prozessor

Physische (oder virtuelle) Ausführungseinheit einer Maschine (Rechner) um Instruktionen auszuführen. Maschineninstruktionen sind Daten- und Kontrollanweisungen, d.h. Berechnung und Kontrollflusssteuerung.

Prozess

Ein Orogramm in Ausführung, entweder im Vordergrund durch einen Prozessor, oder im Hintergrund durch einen Speicherzustand (inkl. Auslagerung auf einen Datenträger).

.reference[bsdp] .centered.width80[

] .figure[Unterscheidung Programm zu Prozess]

Allgemein gilt: kommt ein Programm zur Ausführung, dann wird ein neuer Prozess gestartet. Der Prozess belegt Hauptspeicherplatz, Prozessorregister, belegt Rechenzeit und nutzt die Peripherie. Ein bestimmtes Programm kann bei Bedarf auch mehrfach parallel ablaufen. Es liegen dann mehrere Prozesse vor, aber immer noch nur ein Programm.

Threads

Threads sind parallel ablaufende Aktivitäten innerhalb einer gemeinsamen Prozessumgebung. Der Code einzelner Threads ist Teil des bereits geladenen Programms. Jeder Thread gehört damit zu einem bestimmten Prozess.

.reference[bsdp] .centered.width80[

] .figure[Varianten der Software-Parallelität]

Nebenläufigkeit

Nebenläufigkeit (concurrency) bedeutet, dass die Software mehrere Programmanweisungen gleichzeitig ausführen kann (abstrakte Parallelität). Parallelität im strikten Sinne bezieht sich hingegen auf die tatsächlich gleichzeitige Ausführung (konkrete Parallelität). Eine weniger strikte Definition der Parallelität schlie0t die Nebenläufigkeit ein, da dies Praxisgebrauch darstellt.

Task

Primär ist diese Bezeichnung bei eingebetteten Systemen (embedded systems) üblich und bezeichnet dort parallel ausgeführte Programmteile. Einige Betriebssysteme benutzen den Begriff Task hingegen als einen Oberbegriff, der Prozesse und Threads umfasst.

Job und Session

Darunter sind voneinander unabhängige und gegenseitig geschützte Anwendungen auf einer Rechneranlage zu verstehen. Eine derartige Anwendung besteht dabei aus einer Gruppe von Prozessen.

Echtzeitbetrieb

Echtzeitbetrieb ist ein Betrieb eines Rechensystems, bei dem Programme zur Verarbeitung anfallender Daten ständig betriebsbereit sind derart, dass die Verarbeitungsergebnisse innerhalb einer vorgegebenen Zeitspanne verfügbar sind. Die Daten können je nach Anwendungsfall nach einer zufälligen zeitlichen Verteilung oder zu vorbestimmten Zeitpunkten auftreten (DIN 44 300).

Man unterscheidet harte und weiche Echtzeitsysteme. Das Überschreiten von Zeitgrenzen (von Prozessen) wird im Softbetrieb toleriert, im Hartbetrieb wird diese als Systemfehler gewertet mit Anhalten der Rechneranlage.

Prozess

Ein Prozess i hat einen Zustand Σi. Ein Prozess in Ausführung ändert seinen Zustand.

Der Prozesszustand setzt sich aus zwei Teilzuständen zusammen:

.box[

Kontrollzustand γ, gegeben durch den Befehlszähler;

Datenzustand σ, gegeben durch alle Prozessvariablen. ]

Der Prozess ist ein Grundbaustein der Parallelverarbeitung mit einem Betriebssystem.

Das Prozessmodell ist daher ein zentrales Konzept moderner Betriebssysteme. Ergänzt wird es durch das Thread-Modell, das für die Parallelisierung innerhalb von Applikationen die optimalere Lösung darstellt.

Ein Prozess kann sich in drei wesentlichen Zuständen befinden:

.box[

Ready: Rechenbereit, aber nicht in Ausführung durch einen Prozessor;

Run: In Ausführung durch einen Prozessor; wesentlich Berechnung;

Wait: Wartend (auf Ereignisse). ]

Das Warten bedeutet i.A. Ein- und Ausgabe, Kommunikation oder die Verzögerung

Es gibt zwei Arten um auf Ereignisse zu warten:

.box[

Durch wiederholte Abfrage (Polling);

Durch Prozessblockierung (Suspendierung) und Wiederaufnahme (Resume) beim Eintreten des Ereignisses (Interrupt). ]

Ereignisse

Man kann verschiedene Klassen von Ereignissen unterscheiden, die von Prozessen berarbeitet werden:

.box[

Daten oder Ereignisse von Kommunikationsgeräten (WIFI/WLAN, Bluetooth, USB, Scanner, Drucker "kein Papier");

Zeitablauf (Verzögerung, Timer);

Synchronisation zwischen Prozessen, z.B. Steuerung einer Warteschlange, Bereistellen von Daten zur Abholung usw.;

Wettbewerbsauflösung durch Sequenzialisierung, d.h., Nutzung geteilter Ressourcen. ]

Warten auf Ereignisse bedingt das Anhalten des Kontrollflusses eine Prozesses (Prozessblockierung), wenn auch beim Pollingbetrieb nur eine wiedeholende Schleife den Programmfortschritt blockiert.

Prozesszustände

.col1.width70[ .centered.width85[

] ] .col2.width30[.reference[bsbrause].figure[(a) Allgemein Prozesszustände und Übergänge (b) Prozesszustände und Übergänge bei UNIX (c) Prozesszustände in Windows NT]]

Die Zustände „bereit“ und „blockiert“ enthalten eine oder mehrere Warteschlangen (Listen), in die die Prozesse mit diesem Zustand eingetragen werden.

Der bereit-Zustand ist besonders ausgezeichnet: Alle Prozesse, die Ereignisse erhalten und so entblockt werden, werden zunächst in die bereit-Liste (ready-queue) verschoben und erhalten dann in der Reihenfolge den Prozessor zur Ausführung.

Prozessmodell

Das Prozessmodell ist ein zentrales Konzept der meisten Betriebssysteme.

Kerneigenschaft dabei ist, dass jeder Prozess virtuell den ganzen Rechner für sich alleine zur Verfügung hat, d.h., er kann den Adressraum frei belegen (abgesehen von reservierten Systembereichen) und kann die Prozessorregister nach Belieben benutzen.

Das Prozessmodell realisiert durch die nebenläufige Ausführung mehrerer Prozesse den Mehrprogrammbetrieb.

In einem Einprozessorsystem wird eine Aufteilung der Rechenzeit auf mehrere ablaufwillige Prozesse durchgeführt.

Es findet dabei ein Zeitmultiplex des Prozessors statt.

Dies ergibt für den Benutzer die Illusion der echt parallelen Programmausführung, obwohl zu jedem Zeitpunkt nur immer gerade ein einziges Programm von der CPU abgearbeitet wird.

Auf Multiprozessorsystemen ist eine mehrheitlich gleichzeitige Ausführung von Programmen möglich.

Prozessverwaltung

.shadowbox[

Die Programme und damit die Prozesse existieren nicht ewig, sondern werden irgendwann erzeugt, in eine Wartschlange eingereiht und auch beendet.

Dabei verwalten die Prozesse aus Sicherheitsgründen sich nicht selbst, sondern das Einordnen in die Warteschlangen wird von einer besonderen Instanz des Betriebssystems, dem Scheduler, nach einer Strategie geplant.

Bei einigen Betriebssystemen gibt es darüber hinaus eine eigene Instanz, den Dispatcher, der das eigentliche Überführen von einem Zustand in den nächsten bewirkt. ]

Im Unterschied zu dem Maschinencode werden die Zustandsdaten der Hardware (CPU, FPU, MMU), mit denen der Prozess arbeitet, als Prozesskontext bezeichnet.

Der Teil der Daten, der bei einem blockierten Prozess den letzten Zustand der CPU enthält und damit wie ein Abbild der CPU ist, kann als virtueller Prozessor angesehen werden und muss bei einer Umschaltung zu einem anderen Prozess bzw. Kontext (context switch) neu geladen werden.

Die verschiedenen Betriebssysteme differieren in der Zahl der Ereignisse, auf die gewartet werden kann, und der Anzahl und Typen von Warteschlangen, in denen gewartet werden kann. Sie unterscheiden sich auch darin, welche Strategien sie für das Erzeugen und Terminieren von Prozessen sowie die Zuteilung und Einordnung in Wartelisten vorsehen.

Prozessumschaltung

Synonym für Scheduling oder Prozessmultiplexing

Multiplexing: Die wechselseitige Nutzung einer geteilten Ressource, z.B. eines Funkkanals, Datenbusses, Prozessors, oder des Speichers. Beim Prozessor handelt es sich um Zeitmultiplexing.

.centered.width80[

] .figure[Kanalmultiplexing (grün) im Vergleich zum Prozessmultiplexing (rot). Im Grunde werden Daten geschaltet.]

.centered.width80[

] .figure[Multiprozessorsysteme können als multiple Kommunikationskanäle verstanden werden.]

Die Prozessumschaltung bewirkt letztlich eine wechselseitige Verarbeitung von Datenströmen.

Prozessgruppen

Prozesse können einzeln gestartet oder in Gruppen zugeordnet werden.

In Unix gibt es die Eltern-Kind Prozessgruppe, unter Windows nicht.

Ein Kindprozess wird i.A. als Kopie des Elternprozesses erzeugt (Forking)

.centered.width60[

] .figure[Prozessverdoppelung bei fork(). PID: Process Identifier Number (numerischer ganzzahliger Wert der einen Prozess im System eindeutig identifiziert)]

Prozessumschaltung

.centered.width80[

] .figure5-5

Prozessverwaltung

.reference[bsdp] .centered.width80[

] .figure[Mehrprogrammbetrieb mit vier Prozessen (Beispiel)]

Prozessumschaltung

Prozesswechsel benötigt eien Verwaltungstabelle für Prozesse mit einem Process Control Block (PCB), kann noch durch erweiterte Tabellen überdeckt sein.

Ablauf für einen Prozesswechse:

Der laufende Prozess wird gestoppt (»eingefroren«): Damit übernimmt der Prozesswechselcode des Betriebssystems die Kontrolle.

Kontextsicherung des aktuellen Prozesses: Die aktuellen CPU-Registerinhalte werden aus der CPU in den PCB1 kopiert.

Auswahl nächster Prozess: Entsprechend einer Prozessorzuteilungsstrategie wird der nächste auszuführende Prozess bestimmt (sog. CPU-Scheduling).

Kontextwiederherstellung neuer Prozess: Die gesicherten CPU-Registerinhalte werden aus dem PCB2 in die CPU kopiert.

Neuer Prozess wird gestartet (»aufgetaut«): die Kontrolle wird neuem Prozess übergeben, der am letzten Unterbrechungspunkt fortfährt.

.reference[bsdp] .centered.width80[

] .figure[Prozessumschaltung und Rolle des PCB in fünf Schritten (vereinfachtes Beispiel)]

Vordergrund/Hintergrund System

Das einfachste Betriebssystem ist keines (im Sinne eines eigenständigen Programms). vor allem in Eingebetteten Systemen (Einplatinenrechner, Mikrokontroller) kommt das Einprogrammsystem zum Einsatz.

Es gibt nur ein Programm welches monolithisch verschiedene Aufgaben ausführt:

Berechnung

Ein- und Ausgabe

Kommunikation

Jetzt gibt es aber ein Problem: Wie soll ein Programm ohne Prozesse diese verschiedenen Aufgaben nebenläufig (quasi parallel) ausführen?

Das Warten auf Ereignisse müsste "parallel" im Polling geschehen

Wenn in Berechnung dann können keine Ereignisse überwacht und verarbeitet werden.

Besser: Hardwareunterstützung durch den Prozessor mit Interruptbetrieb

.compact[

Durch den Interruptbetrieb kann das Programm rechnen und beim Eintreten einen Ereignisses (Daten verfügbar, Ressource verfügbar, Fehler usw.) verzweigt der Prozessor in ein Unterpogramm welches das Ereignis bearbeitet.]

Kurios: Das Hauptprogramm ist ein Hintergrundprozess, das Interrupt Unterprogramm (Interrupthandler) ist ein Vordergrundprozess mit Kontextwechsel!

Grund: Interrupt Unterprogramme werden präemptiv aufgerufen, d.h. es wird das Hauptprogramm "sofort" unterbrochen und in das Unterpogramm verzweigt.

Der Interrupthandler hat die höhere Priorität und ist daher (wenn auch selten aktiv) der Vordergrundprozess.

Aber: Präemption bedeutet Gefahr der Dateninkonsistenz. Beim (partiellen) Kontextwechsel werden nur einige Register gesichert, einige werden vom Hauptprogramm weiter verwendet und dürfen nicht modifiziert werden.

Daher sollte in den Interrupt Unterprogrammen nur das Ereignis (beim Gerät) bestätigt werden und das Hauptprogramm über Speichervariablen über das Ereignis informiert werden. Den Rest muss das Hauptprogramm bearbeiten.

Main IO Loop

Wenn wir ein Einprogrammsystem betrachten dann muss es ungeachtet von Interruptbetrieb eine Hauptschleife geben die Berechnung und Ereignisverarbeitung "fair" aufteilt.

Ansonsten müsste das Hauptprogramm ständig nach Ereignissen fragen (Verteiltes Polling)

void setup() {
  Startcode
}
void loop () {
  if (Ereignis) handle(Ereignis)
  if (Berechnung) process(Berechnung)
}
setup();
while (1) loop();

.code[Zwei Funktionen (Arduino Style!): setup wird einmal zu Beginn, und loop "endlos" immer wieder aufgerufen.]

Damit die Hauptschleife durchläuft muss die eigentliche Berechnung gestückelt sein. I.a. folgt die Berechnung auf Ereignisse.

int timeout=0;
struct list { int type; struct list *next };
void add(struct list* l, int ev) { add event ev to list l };
void loop () {
  int now=milliseconds();
  struct list events, eventp=events;
  if (Serial1.available) add(events,EV1);
  if (Serial2.available) add(events,EV2);
  if (timeout!=0 && timeout<now) { timeout=0; add(events,EV3); }
  ...
  while (eventp) {
    switch (eventp->type) {
      case EV1: procev1(); domore(); break;
      case EV2: procev2(); break;
      ...
    }
    eventp=eventp->next;
  }
}

Event Handler (Ein- und Ausgabe usw.) haben nur kurze Laufzeit, können aber häufig aufgereufen werden. Die Laufzeit kann durch Monitoring abgeschätzt werden (Min., Mittelwert, Max.).

Berechnungsfunktionen können beliebig lange laufen, die Laufzeit ist i.A. nicht über Monitoring abschätzbar.

Ein großes Problem (nicht nur in Echtzteitbetriebssystemen): Eine lange Laufzeit in einer Berechnung kann die Ereignsiverarbeitung derart einschränken dass es zu Verlusten von Ereignissen und Daten kommen kann. Es können sogar Hardware Geräte in einen blockierten/unbrauchbaren Zustand übergehen!

Und noch schlimmer: Die meisten Programmiersprachen erlauben verschachtelte Funktionen. Eine lineare Liste von Instruktionen können unterbrochen werden, eine tief aufgerufene Funktion nicht ohne weiteres.

Es gibt zwei Lösungen:

Präemptives Zeitscheibenverfahren sofern es möglich ist aus Funktionen heraus zu springen und später zurück zukehren ("Long Jumps");

Transformation einer ganzen Berechung (funktionale/prozedural) in eine lineare Liste von abstrakten Instruktionen die von einer Virtuellen Maschine sukzessive verarbeitet werden kann.

Eine einfache VM

Jedes zusammenhängende (atomare) Syntaxelement einer fiktiven Programmiersprache, sagen wir Basic, wird einen Sequenz von Tokens (Symbolen) transformiert.

a=1             LHS VAR a 1 EOL
b=2             LHS VAR b 2 EOL
x=3             LHS VAR x 3 EOL
for i = 1 to 10 FOR VAR i 1 10 EOL
  x=x+i+a+b     LHS VAR ADD VAR x ADD VAR a ADD VAR b
next i          NEXT i
print x         PRINT VAR X

.example[Transformation eines Programms in eine lineare Folge von Symbolen]

Die einzelnen Tokens können nun linear von der VM abgearbeitet werden und jederzeit unterbrochen werden: Mikrostepping.

Die Tokens können direkt in einem Speicherarray abgelegt (kompiliert) werden

// VM state
int pc; int sp; uint16_t mem[SIZE]; // .....
// VM Execution Loop
int vmloop(steps) {
  for (int step=0;step<steps;step++) {
    int next=mem[pc++];
    switch (next) {
      case LHS:    xassign(); break;
      case VAR:    xvar();    break;
      case PRINT:  xprint();  break;
      case DELAY:  xdelay();  return -1;
      case END:    xend();    return 0;
      ...
    }
    if (interrupt) return 1;
  }
  return 1;
}

.example[VM Execution Loop]

// Interrupt Handler
int interrupt=0,
    pendingtokens=0,
    runnable=0;
void handleIRQn() { interrupt++; }
// IO Loop
void loop () {
  if (interrupt) handle interrupts;
  interrupt=0;
  if (events) handle events;
  if (pendingtokens && runnable) {
    status=vmloop(STEPSMAX);
    if (status!=0) pendingtokens=1;
    else           pendingtokens=0;
    if (status>0)  runnable=1;
    else           runnable=0;
  }
  if (newcode) {
    tokenize(newcode);
    pendingtokens=1;
    runnable=1;
  }
  ...
}
while (1) loop();

.example[Einbindung einer VM Execution Loop in eine IO Loop]

Prozesserzeugung und Terminierung

Prozessstart

In sehr einfachen Systemen, z.B. in vielen eingebetteten Systemen, existieren alle Prozesse von Anfang an und laufen ewig, d.h., solange das System läuft. Auf Universalrechnern (general-purpose computers) ist es hingegen notwendig, Prozesse bei Bedarf zu erzeugen und gelegentlich wieder zu terminieren. Auslösende Ereignisse für die Prozess- bzw. Thread-Erzeugung können dabei sein:

Systemstart (Initialisierung)

Systemdienstaufruf zur Prozesserzeugung durch irgendeinen laufenden Prozess

Benutzeranforderung zum Starten eines neuen Prozesses (Applikationsstart)

Auslösung eines Stapelauftrags (batch job)

Es gibt drei grundlegende Möglichkeiten für den Start eines neuen Prozesses:

Prozessverkettung (chaining): Der laufende Prozess startet einen neuen Prozess und terminiert sich damit selbst. Der Code des neuen Prozesses ist unabhängig (z.B. in separater ausführbarer Datei vorliegend).

Prozessvergabelung (forking): Der laufende Prozess startet einen neuen Prozess, läuft selbst aber weiter. Der Code beider Prozesse ist gemeinsam.

Prozesserzeugung (creation): Der laufende Prozess startet einen unabhängigen neuen Prozess. Der Code beider Prozesse ist unabhängig (z.B. in zwei separaten ausführbaren Dateien vorliegend).

.centered.width60[

] .figure[Prozessstartformen: (a) Prozessverkettung, Sequenz (b) Prozessvergabelung, Forking (c) Unabhängige Prozesserzeugung, Create ]

Prozessbeendigung

Neben der Erzeugung interessiert auch die Terminierung von Prozessen. Übliche Formen sind:

Normale Beendigung (freiwillig)

Vorzeitige Beendigung bei einem durch den Prozess selbst erkannten Fehler (freiwillig)

Vorzeitige Beendigung bei einem katastrophalen Fehler, erkannt durch das System (unfreiwillig)

Terminierung durch einen anderen Prozess (unfreiwillig)

.centered.width50[

] .figure[Prozessvereinigungsformen: Prozesse können auf andere warten.]

Prozessverwaltung: API

.centered.width80[

] .figure[(a) Systemdienstaufrufe für Prozess-/Thread-Start (b) Weitergabe von Daten an neue Prozesse/Threads (c) Systemdienstaufrufe für die Prozess-/Thread-Vereinigung]

Prozessorzuteilungsstrategien

Für die Ausführung paralleler Prozesse wäre idealerweise pro Prozess ein eigener Prozessor nötig. Da dies technisch nicht sinnvoll ist und nicht skalierbar ist (oder sehr aufwendig ist), hat man Verfahren entwickelt, die es erlauben, mehrere Prozesse quasi gleichzeitig auf dem gleichen Prozessor im Zeitmultiplexverfahren auszuführen.

Aber wie sollen die Prozesse der Ressource Prozessor zugeteilt werden, und wie lange sollen oder dürfen sie den Prozessor belegen? Reicht kooperatives Scheduling (Prozesse geben freiwillig die Ressource zurück, yielding), oder muss das Betriebssystem dieses erzwingen?

Die Prozesszuteilung erfolgt nach einer Strategie und einem Algorithmus.

Die Prozesszuteilungsstrategie gilt gleichermaßen für voll Prozesse und dür Subprozesse (Threads).

.reference[bsdp] .centered.width80[

] .figure[Vergleich echte 1:1 Prallelitäz versa "simulierte" N:M Parellelität durch Zeitmultiplexing]

Welchen Nachteil hat die 1:1 Zeuteilung (von der nicht vorhandenen Skalierbarkeit abgesehn)?

Ein wichtiger Faktor ist die Auslastung einer Rechneranlage, die wie bei einer Heizungsanlage 100% betragen sollte (d.h. durchgehender Rechenbetrieb).

Bei einer festen 1:1 Zuteilung würden Prozessoren beim Warten von Prozessen auf Ereignisse untätig sein - keine Auslastung von 100%!

Wie können nun N Prozesse auf M Prozessoren verteilt werden?

Priorität & Präemption

Die Auswahl kann von der Wichtigkeit von Prozessen beeinflusst werden, d.h. die Priorität, eine Ganzzahl.

Je größer die Priorität, desto eher könnte der Prozess ausgewählt werden oder

ein Prozess niedrigere Priorität könnte beim Bereitsein eines Prozesse höherer Priorität unterbrochen werden, d.h. es findet Präemption statt.

Pritoritäten haben Einfluss auf das Zustandsmodell von Prozessen

.centered.width60[

] .figure[Grundmodell mit drei Zuständen]

.centered.width70[

] .figure[Erweitertes Modell der Prozesszustände vier Zuständen und Priorität]

Warteschlangen

Für die Prozesszuteilung und dem Prozessscheduling gibt es mehrere Warteschlangen, d.h. im i.A. eine Warteschalnge pro Prozesszustand:

Bereit

Laufend

Blockiert

...

.centered.width80[

] .figure[Konzeptionelle Prozessverwaltung (Prozessorganisation)]

.compact[

Ein Prozess ist entweder der CPU zugeteilt oder in einer der Ressourcen-Warteschlangen

Es findet ein Wechsel der Prozesse zwischen den Warteschlangen je nach Prozesszustand statt.

Die Prozessorzuteilungsstrategie (CPU scheduling) legt fest, welcher Prozess als nächster die CPU zugeteilt erhält.

Der ausgewählte Prozess wird aus der Bereitliste entfernt und der CPU zugeordnet.

Die Reihenfolge der Prozesse in der Bereitliste wird hierbei strategieabhängig unterschiedlich berücksichtigt.

.box[

Gibt ein laufender Prozess freiwillig die CPU ab, so wechselt er in die Bereitliste an die vorderste Position.

Wird einem Prozess die CPU entzogen, weil er sein Zeitquantum erschöpft hat (Präemption), dann wechselt er in die Bereitliste an die hinterste Position seiner Priorität.

Wenn der laufende Prozess auf eine Ressource/ein Ereignis zu warten beginnt, so wechselt er in die zugehörige Warteschlange. ] ]

Welcher Prozess aus einer Ressourcen-Warteschlange die frei werdende Ressource erhält, ist eine Frage der E/A-Zuteilungsstrategie (I/O scheduling).

Diese kann ressourcenabhängig unterschiedlich sein.

Wie sollen nun Prozesse aus der Bereitwarteschlange der CPU zugeteilt werden? Was sind die Ziele?

Prozesszuteilung

Man unterscheidet zunächst:

Process based scheduling: Findet die Prozessorzuteilung nur zu ganzen Prozessen statt, so kennt der Systemkern keine Threads (single-threaded process). Dies bedingt ein Multithreading vollständig im Benutzermodus (user level threads).

Thread based scheduling: Findet die Prozessorzuteilung zu einzelnen Threads statt, so sind unterschiedliche Varianten des Multithreading möglich. Sowohl ein Multithreading auf der Benutzerebene als auch die direkte Nutzung von KL-Threads (kernel level threads) sowie verschiedene Zwischenformen sind denkbar.

Es gibt wesentlich zwei Arten von Prozessausführung (auch abwechselnd):

Betrachtet man typische Prozessabläufe, so kann man zwei wichtige Grundmuster (pattern) unterscheiden (siehe auch Abb. 4–31):

CPU-lastig (CPU-bound): Die Aktivität nutzt viel Rechenzeit und wartet im Vergleich dazu selten auf die Ein-/Ausgabe.

E/A-lastig (I/O-bound): Die Aktivität rechnet wenig, wartet hauptsächlich auf die Ein-/Ausgabe (Input/Output), d.h. nutzt Peripherie.

Generell können Wartezeiten auf die Ein-/Ausgabe für die Ausführung parallel laufender Berechnungen genutzt werden. Besonders lohnend ist dies für »E/A-lastige« Aktivitäten. Grundprinzip der asynchronen E/A, z.B. in virtuellen Maschinen.

E/A-lastig versa CPU-lastig hat große Auswirkung auf das Scheduling!

.centered.width100[

] .figure[Prozessablaufmuster (E/A- bzw. CPU-lastig)]

Bei einer reinen Berechnung spielen Verzögerung i.A. ein nicht so große Rolle als wie bei der Verarbeitung von E/A Ereignissen!

Optimierungsziele

Für eine optimale Zuteilung der CPU zu rechenwilligen Prozessen wurde eine Vielzahl an Strategien entwickelt. Diese Vielfalt kommt daher, dass je nach verfolgten Optimierungszielen unterschiedliche Lösungen optimal sind. Aus Sicht des Anwenders können folgende Ziele wichtig sein:

Durchlaufzeit (turnaround time): Gesamtzeit von Prozessstart bis Prozessbeendigung

Antwortzeit (response time): Zeit zwischen einer Eingabe und der Reaktion des Systems darauf

Endtermin (deadline): Zeitpunkt, zu dem eine vorgegebene Aktion erfolgt sein muss

Aus Sicht eines optimalen Ressourceneinsatzes können weitere Ziele interessant sein, wie Vorhersagbarkeit, Durchsatz (Anzahl erledigter Aufträge pro Zeiteinheit) und Prozessorauslastung.

Verdrängende und nicht verdrängende Zuteilungsstrategien

Eine Zuteilungsstrategie wählt aus der Menge aller Prozesse, die sich im »Bereit«-Zustand befinden, einen bestimmten Prozess aus. Zuteilungsstrategien unterscheiden sich durch das maßgebende Auswahlkriterium.

Wichtig ist dabei, zu verstehen, dass ein zum Auswahlzeitpunkt im Zustand »Laufend« befindlicher Prozess an dieser Auswahl ebenfalls teilnimmt.

Damit kann der Auswahlentscheid auch lauten, dass kein Prozesswechsel stattfindet, sondern dem gleichen Prozess weiter die CPU zugeteilt wird.

Eine weitere spezielle Situation liegt vor, wenn der laufende Prozess in den Zustand »Wartend« gewechselt hat.

In diesem Fall ist das Ziel der Auswahl auf jeden Fall die Zuteilung der CPU zu einem neuen Prozess.

Eine solche Auswahl eines Prozesses, der als nächster laufen soll, wird als Neuzuteilung bezeichnet (rescheduling). Eine Neuzuteilung kann auch die Auswahl des bereits laufenden Prozesses bedeuten.

Zuteilungszeitpunkte

.shadowbox[

Ein Prozess wechselt vom Zustand »Laufend« in den Zustand »Wartend« (dadurch wird die CPU frei und kann neu zugeteilt werden).

Ein Prozess wechselt vom Zustand »Wartend« in den Zustand »Bereit« (dadurch könnte eine Prioritätsregel verlangen, dass dieser Prozess Vorrang vor dem laufenden Prozess erhält).

Ein Prozess wechselt vom Zustand »Laufend« in den Zustand »Bereit« (dies könnte nach Ablauf einer bestimmten Zeit von einer Zeitquotenregel erzwungen werden).

Ein Prozess wechselt von einem der Zustände »Laufend«/»Bereit«/»Wartend« in den Zustand »Inaktiv« (dies wäre eine freiwillige oder erzwungene Prozessbeendigung).

Ein Prozess wechselt vom Zustand »Inaktiv« in den Zustand »Bereit« (dies trifft für einen neu gestarteten Prozess zu, der durch eine Prioritätsregel den Vorrang vor einem laufenden Prozess erhalten könnte). ]

Eine Zuteilungsstrategie gilt als nicht verdrängend (nonpreemptive), wenn die Neuzuteilung nur zum 1. und 4. Zeitpunkt stattfindet. Eine nicht verdrängende Neuzuteilung hat die Eigenschaft, dass sie kooperatives Verhalten der laufenden Prozesse erwartet. Beansprucht ein laufender Prozess für sehr lange Zeit fortlaufend immer die CPU, so haben ablaufbereite Prozesse keine Möglichkeit, dies zu ändern. Dies gilt nicht für verdrängende (preemptive) Zuteilungsstrategien, da bei ihnen eine Neuzuteilung zu allen fünf oben aufgeführten Zeitpunkten stattfindet.

FIFO Strategie

Die FIFO-Strategie (First In First Out) ist auch unter dem Namen FCFS (First Come First Served) bekannt. Die Reihenfolge der Prozessausführung richtet sich streng nach der Reihenfolge, in der die Prozesse ablaufbereit wurden (d.h. Wechsel in den Zustand »Bereit«).

Es handelt sich um eine nicht verdrängende Strategie, sodass neu ablaufbereite Prozesse keine Neuzuteilung zur Folge haben.

Man bezeichnet dies auch als kooperative Zuteilung, da eine Neuzuteilung erst möglich wird, wenn der laufende Prozess die CPU freiwillig freigibt.

Diese einfache Strategie erlaubt einen minimalen Verwaltungsaufwand. Damit eine Neuzuteilung des Prozessors stattfinden kann, muss der laufende Prozess entweder zu warten beginnen oder seine Ausführung terminieren.

Alle Prozesse besitzen die gleiche Priorität. Dieses Verfahren funktioniert nur so lange, bis ein Prozess die CPU nicht mehr abgibt.

Die Reaktionszeiten hängen zudem direkt von den durch die einzelnen Prozesse bedingten Laufzeiten ab (sog. Konvoieffekt).

.centered.width100[

] .figure[Beispielszenario mit FIFO-Strategie]

.up.compact.smaller[

Ein Prozess A startet zum Zeitpunkt 0 und läuft für 40 Zeiteinheiten. Dann wartet er auf eine Ein-/Ausgabe, die nach 50 Zeiteinheiten abgeschlossen ist. Dadurch wird er wieder ablaufbereit und belegt seine restlichen 30 Zeiteinheiten.

Die Wartezeit (hier 50) für den Prozess A startet von dem Zeitpunkt an, wenn der Prozess blockiert, weil ein Prozess eine Ein-/Ausgabe anfordert und dann für die Dauer der Ein-/Ausgabe warten muss. Die Verzögerung (hier 40) startet ab dem Eintreten des Eireignisses.

Prozess B startet nach 20 Zeiteinheiten und will dann die CPU für 90 Zeiteinheiten belegen.

Prozess C startet nach 30 Zeiteinheiten und benötigt ab dann die CPU für 30 Zeiteinheiten. ]

SJF Strategie

Bei der nicht verdrängenden Strategie SJF (Shortest Job First), die auch unter dem Namen SPN (Shortest Process Next) fungiert, wird bei einer Neuzuteilung derjenige Prozess ausgewählt, der den kleinsten erwarteten Rechenzeitbedarf hat.

Diese Strategie lässt sich naturgemäß nur anwenden, wenn Angaben über den Rechenzeitbedarf zum Entscheidungszeitpunkt dem System zugänglich sind.

Der Benutzer kann Angaben zur Laufzeit machen

Aaus der Vergangenheit kann eine Vorhersage gemacht werden (durch Messung). Besonders bei E/A-lastigen Prozessen kann das System repräsentative Daten über den vergangenen Rechenzeitbedarf sammeln. Diese Daten ermöglichen dann eine Hochrechnung, deren Qualität letztlich durch die Vorhersagbarkeit des zukünftigen Prozessverhaltens bestimmt wird.

.centered.width100[

] .figure[Beispielszenario mit SJF-Strategie]

E/A Reaktionszeiten können auch hier schlecht sein (lange Verzögerung)

SRT Strategie

Die Strategie SRT (Shortest Remaining Time) stellt eine Abwandlung des SJF-Verfahrens dar. Bei einer Neuzuteilung wird derjenige Prozess ausgewählt, der den kleinsten verbleibenden Rechenzeitbedarf hat.

Da es sich um eine verdrängende Strategie handelt, kann eine Neuzuteilung einem laufenden Prozess die CPU entziehen, wenn ein anderer Prozess weniger Rechenzeit benötigt. - Im Übrigen gelten die gleichen Einschränkungen wie bei der SJF-Strategie.

.up.centered.width85[

] .up.figure[Beispielszenario mit SRT-Strategie]

.up.compact.smaller[

Es fällt auf, dass zu den Zeitpunkten 20 und 30 jeweils eine Neuzuteilung stattfindet. Da aber Prozess A in beiden Fällen den geringsten verbleibenden Rechenzeitbedarf hat, wird er weder durch B noch durch C verdrängt.

Erst zum Zeitpunkt 40 findet ein Prozesswechsel statt, da A zu warten beginnt. Zu diesem Zeitpunkt erhält Prozess C den Vorrang, da er weniger Rechenzeit benötigt als B.

Nach dem Prozessende von C wird die CPU frei und kann daher dem einzigen dann ablaufbereiten Prozess B zugeteilt werden.

Zum Zeitpunkt 90 wird A wieder ablaufbereit, da die Ein-/Ausgabe abgeschlossen ist.

Da Prozess A einen geringeren Restzeitbedarf aufweist als B, erfolgt eine Verdrängung. Prozess B kann erst nach dem Prozessende von A seine restliche Rechenzeit beanspruchen. ]

RR Strategie

Bei der RR-Strategie (Round Robin, im Kreis herum) erhält jeder Prozess ein Quantum an Rechenzeit zugeteilt, für das er ununterbrochen die CPU belegen kann.

.compact.up[

Periodisch (z.B. alle 10 ms, "tick") wird der Zeitquantumszähler des laufenden Prozesses dekrementiert.

Fällt damit sein Zeitquantum auf null, so wird eine Neuzuteilung veranlasst.

Enthält die Liste der ablaufbereiten Prozesse mindestens einen Eintrag, so wird der laufende Prozess verdrängt. Er wechselt damit in den Zustand »Bereit« und wird zuhinterst in der Liste ablaufbereiter Prozesse eingetragen.

Danach wird der vorderste Prozess der Liste entnommen, sein Zeitquantumszähler auf ein volles Zeitquantum gesetzt und ihm die CPU zugeteilt.

Ist die Liste der ablaufbereiten Prozesse leer, wird der Zeitquantumszähler des laufenden Prozesses wieder auf ein volles Zeitquantum gesetzt und belegt die CPU weiter.

Neu gestartete Prozesse werden anfänglich in den »Bereit«-Zustand versetzt und zuhinterst in die Liste der ablaufbereiten Prozesse eingereiht.

Diese Strategie versucht die Rechenzeit möglichst gleichmäßig auf alle ablaufbereiten Prozesse zu verteilen, indem sich diese zyklisch abwechseln. ]

Da jedoch das Dekrementieren des Zeitquantumszählers nur in einem festen Zeitraster erfolgt, werden Prozesse benachteiligt, die erst kurz vor dieser periodischen Prüfung die CPU zugeteilt bekamen.

Ihr Zeitquantum ist damit um maximal eine Zeitscheibe verkürzt.

Diesem Effekt kann begegnet werden, indem ein volles Zeitquantum mehrere Zeitscheiben umfasst (z.B. 10 ticks).

Die Implementierung des RR-Verfahrens kann unterschiedlich aufwendig sein.

Eine einfache Implementierung wird einen festen Wert für das volle Zeitquantum nutzen. Wird ein volles Zeitquantum nur eine einzige Zeitscheibe groß gewählt, so stellt dies ebenfalls eine Vereinfachung dar.

Andererseits lässt sich das Verfahren verfeinern, indem prozessabhängig oder lastabhängig der Wert für ein volles Zeitquantum variiert wird.

Eine weitere Variante teilt Vordergrundprozessen ein größeres Zeitquantum zu und erlaubt ihnen so, möglichst gut auf Benutzereingaben zu reagieren.

Das RR-Verfahren geht davon aus, dass alle Prozesse zu jedem Zeitpunkt gleich wichtig sind, womit sich nur einfachere Systeme bauen lassen.

Vorteilhaft ist der vergleichsweise bescheidene Verwaltungsaufwand.

Ein weiterer Vorteil ist, dass einzelne Prozesse mit Busy Waits (E/A Polling, Schleifen) das System nicht lahmlegen können.

Neuzuteilungen finden immer dann statt, wenn der laufende Prozess die CPU freigibt (zu warten beginnt oder terminiert) oder wenn sein Zeitquantum erschöpft ist.

Dieses Verfahren baut auf dem FIFO-Prinzip (kooperative Zuteilung) auf und erweitert es um die Zeitquantenumschaltung.

.up.centered.width85[

] .up.figure[Beispielszenario mit RR-Strategie: Zeitscheibengröße 40. Erreicht ein Prozess laufend das Zeitscheibenende, so wird ihm die CPU entzogen und dem nächsten bereiten Prozess zugeteilt.]

.up.compact.smaller[

Die Zeitscheibengröße wird exemplarisch auf 40 Zeiteinheiten festgelegt und das volle Zeitquantum ist eine Zeitscheibe. Implementierung durch periodischen Uhreninterrupt.

Am Anfang ist Prozess A lauffähig und erhält die CPU zugeteilt. Nach 20 bzw. 30 Zeiteinheiten werden B und C ablaufbereit. Dies ändert jedoch nichts, da Verdrängung nur bei Ablauf eines Zeitquantums möglich ist.

Zum Zeitpunkt 40 beginnt Prozess A zu warten, wodurch die CPU frei wird. Da Prozess B vor Prozess C ablaufbereit wurde, kann jetzt B laufen (erhält ein volles Zeitquantum von eins).

Zum Zeitpunkt 80 ist das Zeitquantum von B erschöpft, womit C die CPU zugeteilt bekommt.

Kurz darauf zum Zeitpunkt 90 wird A wieder ablaufbereit nach abgeschlossener Ein-/Ausgabe. Prozess A wird hinter B in der Bereitliste eingereiht. ]

.up.centered.width85[

] .up.figure[Beispielszenario mit erweiterter RR-Strategie: Zeitscheibengröße 20, wobei jeder Prozess jeweils über ein Zeitquantum von 2 verfügt. Erreicht ein Prozess laufend zweimal das Zeitscheibenende, so wird ihm die CPU entzogen und dem nächsten bereiten Prozess zugeteilt.]

Kleinere Zeitscheibengrößen bei höherer Anzahl von Zeitscheiben kann zu einer schlechteren E/A Reaktionszeit führen. Die Wartezeit von bereiten Prozessen kann sich verbesssern. Das hängt aber vom aktuellen Zustand der Prozesse und der Wartelisten ab!.

ML Strategie

.compact[ Die ML Strategie (Multi-Level Priority) basiert auf Prioritäten, die den einzelnen Prozessen zugeordnet sind. Diese Prioritätszuordnung obliegt dem Anwender, der damit die Wichtigkeit der einzelnen Prozesse bestimmt.

Die Priorität wird meistens mit Zahlenwerten umschrieben, z.B. 0..100. Ob eine hohe Zahl einer hohen Priorität entspricht oder umgekehrt, ist eine Implementationsfrage.

Typischerweise ist diese Strategie verdrängend realisiert (Präemtpion), sodass stets derjenige ablaufbereite Prozess die CPU besitzt, der die höchste Priorität hat.

Sind mehrere Prozesse auf dieser Prioritätsstufe ablaufbereit, so gilt sekundär das FIFO-Prinzip.

Nachteilig bei dieser Strategie ist, dass die CPU von höherpriorisierten Prozessen monopolisiert werden kann. Dies ist dann der Fall, wenn diese zu oft ablaufbereit sind, sodass niederpriore Prozesse nie die CPU erhalten. Man sagt dann, dass diese niederprioren Prozesse verhungern (starvation). ]

.compact[

Gründe für das Auftreten dieser Situation können falsch ausgelegte Prozesse, unerwartete Anwendungssituationen mit erhöhtem Rechenzeitbedarf oder eine zu leistungsschwache CPU sein.

Die ML Strategie wird gerne für Echtzeitsysteme genutzt, da sie bei nur einem einzigen Prozess auf der höchsten Prioritätsstufe diesem stets sofort die CPU zuteilt, wenn er ablaufen will.

Eine Neuzuteilung des Prozessors findet nicht nur statt, wenn der aktuelle Prozess die CPU freigibt (zu warten beginnt oder terminiert), sondern auch dann, wenn ein anderer Prozess ablaufbereit wird.

In der Praxis wird diese Strategie oft mit der RR-Strategie kombiniert, d.h., existieren ablaufbereite Prozesse mit gleicher Priorität wie der laufende Prozess, so wechseln sie zyklisch ab (round robin).

Die Priorität kann auch dynamisch sein, d.h. sie ändert sich mit der Laufzeit von Prozessen. So werden Prozesse die häufig die Zeitscheiben ausschöüfen degradiert, welche die häufig auf E/A warten und nur kurz rehchen befördert.]

.centered.width90[

] .figure[Beispielszenario mit ML Strategie. Das Diagramm wurde um eine Prioritätsrangierung erweitert in der Art, dass A die höchste, B eine mittlere und C die tiefste Priorität zugeordnet bekommt.]

Da Prozess A als einziger die höchste Priorität besitzt, erhält er stets sofort die CPU zugeteilt, wenn er ablaufen will. So verdrängt er beispielsweise zum Zeitpunkt 90 den Prozess B, wenn er die Ein-/Ausgabe abgeschlossen hat. Prozess B kann immer dann laufen, wenn A die CPU nicht besetzt. Prozess C schließlich muss warten, bis die CPU für ihn als Letzten frei wird.

MLF Strategie

Die MLF Strategie (Multi-Level Feedback) begründet ihre Scheduling-Entscheide auf der aufgelaufenen Rechenzeit der ablaufbereiten Prozesse. Dazu verwendet sie eine feste Anzahl von Prioritätsstufen, z.B. 1..10.

Anfänglich ist einem Prozess die höchste Priorität zugeteilt.

Die Auswahl des nächsten Prozesses bei einer Neuzuteilung berücksichtigt primär die Priorität und sekundär das FIFO-Prinzip, wenn mehrere wählbare Prozesse der gleichen Priorität vorliegen.

Jeder Prioritätsstufe ist zudem ein festes Zeitquantum zugeordnet.

Hat ein Prozess auf einer Prioritätsstufe das geltende Zeitquantum ausgeschöpft, so wird seine Priorität um eins erniedrigt und eine Neuzuteilung ausgelöst.

.centered.width40[

] .figure[Warteschlagenorganisation für die MLF-Strategie (Beispiel). Prio: Priorität, Q: Zeitquantum. T: Zeitscheibengröße]

.compact[

Damit werden Prozesse mit kleinem Rechenzeitbedarf bevorzugt, da lang laufende Prozesse schrittweise in ihrer Priorität heruntergestuft werden.]

.centered.width90[

] .figure[Beispielszenario mit MLF-Strategie]

Vorteilhafte Eigenschaften besitzt diese Strategie für interaktive Systeme, da einerseits keine Vorkenntnisse über erwartete Rechenzeiten nötig sind und andererseits E/A-lastige Prozesse bevorzugt werden. Dies sind typischerweise auch Prozesse, die einen intensiven Benutzerdialog besitzen und mit dieser Strategie kurze Antwortzeiten realisieren können. Länger laufende Hintergrundprozesse nutzen so die verbleibende Rechenzeit, ohne dass sie die Reaktionszeit der Vordergrundprozesse negativ beeinflussen.

RM Strategie

Das RM-Verfahren (Rate Monotonic) bietet sich für periodisch ablaufende Prozesse an, wie sie oft in Echtzeitsystemen anzutreffen sind. Dabei wird von einer festen Periodenlänge ausgegangen. Für periodische Aktivitäten gilt typischerweise als spätester Endtermin der Anfang der nächstfolgenden Periode.

Besitzt ein Prozess die Periodenlänge T, so wird er alle T Zeiteinheiten starten und muss seine Berechnung vor dem Start der nächsten Zeiteinheit (also nach T Zeiteinheiten) abschließen.

Es handelt sich um ein verdrängendes Zuteilungsverfahren, bei dem derjenige ablaufbereite Prozess gewählt wird, dessen Periodenlänge am kürzesten ist.

.centered.width100[

] .figure[Beispielszenario mit RM Strategie (periodische Prozessabläufe) ohne Verdrängung/Präemption. Gelbe Bereiche: CPU nicht genutzt.]

Prozess A läuft periodisch alle 60 Zeiteinheiten und er braucht für jeden Ablauf 40 Einheiten an Rechenzeit. Prozess B hat eine Periode von 40 und einen Rechenzeitbedarf von 10 Zeiteinheiten pro Aktivierung.

EDF Strategie

Bei der EDF-Strategie (Earliest Deadline First) wird derjenige Prozess ausgewählt, dessen Restzeit bis zum Ablauf seines Endtermins am kleinsten ist.

Wie bei dem RM-Verfahren gehen wir von periodisch ablaufenden Prozessen aus, die mit festen Intervallen arbeiten. Der Endtermin einer anstehenden Bearbeitung ist stets der Anfang der nächstfolgenden Periode.

Es handelt sich um ein verdrängendes Verfahren, das sich gut für Echtzeitsysteme eignet.

Wie bei dem RM-Verfahren gehen wir von periodisch ablaufenden Prozessen aus, die mit festen Intervallen arbeiten. Der Endtermin einer anstehenden Bearbeitung ist stets der Anfang der nächstfolgenden Periode. Es handelt sich um ein verdrängendes Verfahren, das sich gut für Echtzeitsysteme eignet.

.compact[ .centered.width100[

] .centered.width100[

] .figure[(Oben) Beispielszenario für RM Strategie mit Unterbechung/Verdrängung von Prozess A durch Prozess B (Unten) EDF-Strategie mit Verdrängung]

Bei EDF ist die Laufzeitunterbrechung von länger laufenden Prozesse etwas günmstiger.

Eigentlich verpasst Prozess A hier seine Deadline da er mit B überlappt! ]

Vergleich der Strategien

Um verschiedene Strategien miteinander zu vergleichen, sind folgende Größen zur Beurteilung hilfreich:

.compact.smaller.up[

Wartezeit (wait time): Summe aller Zeiträume, in denen der Prozess am Warten war (auf irgendeine Ressource, das heißt auch auf die CPU).

Bedienzeit (service time): Summe aller Zeiträume, in denen der Prozess am Rechnen war (d.h. CPU belegt hat, also inklusive aktiver Warteschleifen).

Durchlaufzeit (turnaround time): Gesamter Zeitbedarf (verstrichene Zeit), bis ein Prozess vollständig abgearbeitet ist (Zeitpunkt Prozessbeendigung minus Zeitpunkt Prozessstart). Es gilt: Durchlaufzeit = Wartezeit plus Bedienzeit.

Antwortzeit (response time): Zeit zwischen dem Auftreten eines Ereignisses bis zur ersten Reaktion des Prozesses darauf. Entweder wird die Situation bei einem Einzelereignis bestimmt oder ein Mittelwert über eine Vielzahl an Ereignissen ermittelt.

Durchsatz (Throughput): Anzahl vollständig abgearbeiteter Prozesse pro Zeiteinheit. Zur Bestimmung wird ein Betrachtungszeitraum gewählt.

CPU-Auslastung (cpu usage): Zeiträume, während der die CPU irgendeinen Prozess ausgeführt hat, im Verhältnis zum gesamten Beobachtungszeitraum. Typischerweise erfolgt eine prozentuale Angabe. ]

.reference[bsdp] .centered.width100[

] .table[Vergleich ausgewählter Scheduling-Strategien. Teils am Beispiel eines Prozesses, teils für alle Prozesse.]

Zusammenfassung

Wichtigste Konzepte für Multiprozessausführung (Multiprogramming) sind

Prozessblockierung

Prozessunterbrechung (Präemption)

Prioritäten

Es wird verdrängende (präemptive) und nichtverdrängende Prozessumschaltung unterschieden

Wichtigstes Beurteilungskriterien:

Antwortzeit auf Ereignisse (Verzögerung)

Faire Prozessauswahl, Zeitscheiben

Auslastung des Prozessors (optimal 100%)

Overhead durch Prozesswechsel!

Möglichkeiten Prozesse messbar zu machen

Man unterscheidet beim Scheduling zwei wesentliche Prozessklassen:

Rechenlastig

Ereignislastig (E/A)

Die Prozessauswahl hängt ab von:

Prozessklasse (s.o)

Statistiken der laufende Prozesse

Prioritäten

Aktuell laufender Prozess!

. Das einfachste Multiprozessystem ist das Vordergund-Hintergrund System

Hauptprogramm ist Hintergrundprozessn (niedrigste Priorität)

Interrupthandler werden präemotiv aufgerufen und sind zum Vordergundprozess zusammengefasst (höchste Priorität).

Prozesse besitzen einen Daten- und Kontrollzuständ (Programmzähler), aber viel wichtiger werden sie für die Prozessverwaltung auf Metazustände abgebildet und in Wartelisten verarbeitet:

Laufend

Bereit

Wartend/Blockiert

...

Prozesswechsel bedeutet:

Overhead!

Umladung PCB und MMU Tabellen

Verwaltungsstrukutren, Wartelisten

Kontextwechsel! Das Betriebssystem ist selber ein Prozess.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Rechner- und Prozessorgrundlagen

Wie funktioniert ein Rechner?

.left[]

Wie werden Programme von einem Rechner verarbeitet?

Grundmodell eines Rechners

Programm

Eine Sequenz von Maschinenbefehlen (= Prozessorinstruktionen) wird zusammen mit ihrer Datenhaltung als Programm bezeichnet

Programmausführung

Entsprechend die Ausführung durch den Prozessor als Programmausführung.

Die meisten heute gebauten Computersysteme beruhen auf der Aufbaustruktur des Von-Neumann-Rechners, die John von Neumann 1946 aufgestellt hat. Seltener kommt die alternative Struktur des Harvard-Rechners zum Zug, benannt nach der Struktur des Mark-I-Rechners an der Harvard University (1939-44).

Von-Neumann Rechner

Der Von-Neumann-Rechner besteht aus vier Funktionseinheiten:

.reference[bsdp] .centered.width80[

] .figure[Funktionsblöcke des Von-Neumann-Rechners]

.smaller.box[ Leitwerk (Control Unit, CU) : Programme werden maschinenintern als Zahlen, auch Maschinenbefehle genannt, gespeichert. Die Maschinenbefehle legen die vom Prozessor auszuführenden Operationen fest. Das Leitwerk holt die Maschinenbefehle nacheinander aus dem Speicher, interpretiert sie und setzt sie in die zugehörigen Steueralgorithmen um. Das Leitwerk übernimmt damit als Befehlsprozessor die Steuerung der Instruktionsausführung. Unter Steueralgorithmen verstehen wir mögliche Prozessoroperationen, wie z.B. eine Addition, logische Oder-Verknüpfung oder Daten kopieren.

Rechenwerk (Processing Unit, PU)

Eingabedaten können neben den Maschinenbefehlen Bestandteile eines Programms sein (sie liegen dann im Speicher vor) oder werden während des Programmablaufs über die Funktionseinheit Ein-/Ausgabe von der Peripherie hereingeholt. Das Rechenwerk holt die Daten aus dem Speicher bzw. von der Eingabe, transformiert diese Daten mittels unterschiedlicher Steueralgorithmen und legt sie im Speicher ab bzw. übergibt sie der Ausgabe. Als eigentlicher Datenprozessor realisiert es die logischen und arithmetischen Operationen.

Speicher (Memory)

Er enthält die Maschinenbefehle und die zu verarbeitenden Daten. Eine Folge von logisch zusammengehörenden Maschinenbefehlen bezeichnen wir als Programm. Sowohl Befehle als auch Daten befinden sich in einem gemeinsamen Adressraum. Der Speicher dient somit der kombinierten Ablage von Programmen und Daten.

]

.smaller.box[ Ein-/Ausgabe (Input/Output, I/O) : Sie verbindet die Peripheriegeräte (z.B. Tastatur, Monitor, Drucker) mit dem Rechenwerk, stellt also eine oder mehrere Schnittstellen zur Umwelt dar. Mittels passender Maschinenbefehle werden über die Ein-/Ausgabe Daten von der Peripherie entgegengenommen oder dieser übergeben. Ursprünglich wurden das Eingabewerk und das Ausgabewerk als zwei getrennte Funktionsblöcke betrachtet. Heute werden sie zur Ein-/Ausgabe zusammengefasst.

Bussystem

Die Schaltzentrale um alle vier Werke miteinander zu verbinden. Gleichzeitig der Flaschenhals im gesamten Rechner (neben dem Speicher als zentrale Ressource für Programm und Daten)

]

.reference[bsdp] .centered.width60[

] .figure[Betriebssystem und Von-Neumann-Rechner]

Prozessoraufbau

Jede CPU-Familie besitzt einen spezifischen Instruktionssatz und Registeraufbau, mit dem sie sich von anderen Prozessorfamilien unterscheidet.

Deswegen muss ein Programm stets für die richtige CPU übersetzt sein, damit die Maschinenbefehle korrekt interpretiert werden.

Innerhalb einer sogenannten CPU-Familie kann jedoch der gleiche Code benutzt werden.

Ein Prozessor besteht grob gesehen aus den Teilen Leitwerk, Rechenwerk, Register, Adress- und Bussteuerung

.reference[bsdp] .centered.width60[

] .figure[Schematischer Prozessoraufbau]

Register

Register sind Speicherzellen, aber im Mikroprozessor integriert, und teils mit spezieller Bedeutung, wie z.B.:

PC: Program Counter (Code Adresszeiger)

SP: Stack Pointer (Daten Adresszeiger)

Die Registersätze verschiedener CPu Familien können sich erheblich unterscheiden

.smaller[

Man unterscheidet grob drei Klassen CPU Architekturen, mit weitreichenden Folgen für Betriebssysteme und Programme (Erstellung):

Complex Instrucion Set Computer (CISC): Wenige Register, Operationen sind hauptspeicherzentriert, variables Instruktionsformat und Anzahl von Operanden ⇒ Intel x86 usw.

Reduced Instruction Set Computer (RISC): Viele Register, Operationen sind registerzentriert, teils fixes Instruktionsformat, wenige Operanden ⇒ ARM Cortex, Sun UltraSparc usw.

Stack Processors: Wenige Register, Operationen sind stackzentriert, 0-Operand Instruktionen ⇒ Green Array F18A (Dual stack Forth architecture), Virtual Machines! ]

.centered.width60[

] .figure[Beispiele von CPU-Registern]

Grundlagen der Programmausführung

.centered.width60[

] .figure[Rechnereinteilung nach Adressanzahl: gezählt wird die Anzahl Operanden eines dyadischen Operators (z.B. einer Addition)]

Grundlagen des Adressraums

Der Zugriff auf die Befehle eines Programms erfolgt ebenso wie auf alle im Speicher abgelegten Operanden mittels Speicheradressen. Die Organisation des Adressraums stellt damit eine wichtige Eigenschaft und allenfalls auch Limitierung einer bestimmten Rechnerplattform sowie des Betriebssystems dar.

Ein Adressraum Σ ist gekennzeichnet durch:

.box[

Gesamtgröße in Bits (oder Bytes)

Anzahl N=|Σ| von Speicherzellen

Anzahl der möglichen Adressen (u.U. ≠ Bytes!)

Anzahl der Bits pro Speicherzelle W, d.h. die Wortbreite ]

Adressen sind numerische (ganzzahlige) Werte und referenzieren Speicherzellen

I\.A. wird die erste Adresse mit 0, die letzte als N-1 bezeichnet.

Achtung: Die Adresse kann sich entweder auf die Speicherzelle mit der Wortbreite W oder die Elementarzelle mit der Breite B Bits (i.A. 1 Byte) beziehen!

.reference[bsdp] .centered.width40[

] .figure[Grundprinzip des Adressraums, hier Byteadressierung]

Adressraumtypen

.up.compact[

Der (Haupt-) Speicher M ist zunächst passiv und enthält Daten und Programmcode

Aber neben dem Speicher müssen auch Ein- und Ausgabegeräte P "adressiert" (selektiert) und mit denen kommuniziert werden...

Es gibt grob zwei Adressraumtypen bezüglich Speicher und Peripheriegeräten, die auch für das Betriebssystem sehr relevant sind:

Gemeinsamer Adressraum Σ=⟨M,P⟩

Getrennte Adressräume ΣM=⟨M⟩, ΣP=⟨P⟩ ]

.up.centered.width50[

] .figure[Klassischer und erweiterter Von-Neumann-Rechner]

Speicher und Speicherverwaltung

Wie ist das Speichermodell eines Programs und Prozesses organisiert?

.left[]

Wie ist das Speichermodell eines Betriebssystems organisiert?

.left[]

Wie findet Speicherverwaltung statt?

Bytereihenfolge (byte ordering)

Ein ganz einfacher Prozessor könnte nur mit Datenwerten von 8 Bit Größe arbeiten, d.h., er würde über eine Speicheradresse stets nur ein einzelnes Byte lesen oder schreiben. Heutige Universalprozessoren unterstützen jedoch verschiedene Operandengrößen, die auch mehrere Byte umfassen können.

.centered.width80[

] .figure[Varianten der Bytereihenfolge bei Mehrbyte-Datenwerten (Beispiel für 4-Byte-Wert)]

Ausrichtungsregeln im Adressraum

.compact[

Die Ausrichtungsregeln (alignment rules) legen fest, auf welchen Adressen Variablen und Instruktionen liegen müssen. Sie sind maßgebend für die Programmübersetzung (Compiler, Assembler, Binder). Ihr Zweck liegt in der Erreichung optimaler Ausführungsgeschwindigkeiten auf dem benutzten Rechnersystem. Da sie von der Hardware abhängen, können sie entsprechend der benutzten Rechnerplattform variieren. Ausrichtungsregeln können sowohl für Code als auch Daten existieren.]

.up.reference[bsdp] .up.centered.width70[

] .up.figure[Fehlausrichtung im Adressraum und ihre Folgen (Beispiel für einen 32-Bit-Datenbus und einen Zugriff auf einen Datenwert von 32 Bit Größe)]

Bedeutung für Instruktionscode: Abhängig von der Prozessorhardware gelten andere Ausrichtungsregeln, z.B.:

Instruktionen müssen immer auf geradzahligen Adressen liegen.

Bedeutung für Daten: Die Datenbusbreite bestimmt die Ausrichtungsregeln für eine optimale Zugriffsgeschwindigkeit.

Z\.B. ist der Datenbus 32 Bit breit. Damit ist eine optimale Ausrichtung für 4-Byte-Werte eine sogenannte Langwortgrenze, d.h. eine ohne Rest durch vier teilbare Adresse.

Im vorherigen Beispiel ist die Notwendigkeit von zwei Zugriffszyklen auf dem Datenbus gezeigt, wenn die Ausrichtungsregel nicht eingehalten wird (misalignment).

Beim C-Strukturdatentyp (struct) bzw. dem C++-Klassendatentyp (class) gelten die Ausrichtungsregeln jeweils separat für die einzelnen Teilkomponenten. Neben dem Einfluss auf die Zugriffsgeschwindigkeit spielt dies wiederum sowohl für die Kompatibilität beim Datenaustausch wie auch für den effektiv belegten Platz im Adressraum eine Rolle.

.centered.width80[

] .figure[Einfluss der Ausrichtung auf die resultierende Adressraumplatzierung (Beispiel)]

Adressraumbelegung durch Programme

Die Belegung des Adressraums durch ein Programm wird einerseits durch die Übersetzungswerkzeuge, andererseits durch das Betriebssystem festgelegt.

Im Grundsatz können etwa fünf verschiedene Bereiche unterschieden werden:

Code und Konstanten: Die zugehörigen Speicherinhalte werden aus der ausführbaren Datei in den Hauptspeicher geladen. Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Initialisierte Daten: Ein passender Bereich des Adressraums wird reserviert und anschließend werden die Initialwerte der Variablen aus der ausführbaren Datei dorthin geladen. Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Nicht initialisierte Daten: Ein passender Speicherbereich wird reserviert und eventuell gelöscht (d.h. mit 0 geladen). Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Heap und Stack: Es wird ein gemeinsamer Bereich im Adressraum derart reserviert, dass die zwei Bereiche möglichst weit voneinander entfernt sind. Im Betrieb können sowohl Heap als auch Stack wachsen und schrumpfen.

Das Wachstum ist aber nur ungefährlich, solange sich die Heap- und Stack-Inhalte nicht überschneiden. Eine derartige Überschneidungssituation würde zu einem Fehler führen. Die Kunst liegt darin, diese Bereiche ausreichend groß zu wählen und eine mögliche Fehlsituation erkennen zu können. Ein Betriebssystem und/oder ein Laufzeitsystem einer Programmiersprache können hier allenfalls Hilfe anbieten, um Fehlsituationen zu erkennen.

Weitere Lösung des Kollisonsproblem: Getrennte Segmente für Heap und Stackspeicher eines Programms.

.centered.width60[

] .figure[Alternative Layouts des Adressraums eines Programms (oder Prozesses als Programm in Ausführung)]

Adressraumnutzung durch C-Programme

Für die Programmierung ist es hilfreich, die Platzierungsregeln für Variablen in der Programmiersprache C zu kennen. Nachfolgend sind sie kurz aufgeführt:

Global (d.h. außerhalb einer Funktion) deklarierte Variablen

Ohne Initialisierung werden sie im Bereich nicht initialisierte Daten platziert. Mit einer Initialisierung jedoch im Bereich initialisierte Daten. Diese unterschiedliche Behandlung wird deshalb gemacht, weil die Anfangswerte für initialisierte Daten sich aus der ausführbaren Datei laden lassen. Das Betriebssystem kann so diese zwei Fälle klar voneinander unterscheiden.

Innerhalb einer Funktion deklarierte Variablen

Ohne Angabe der Speicherklasse (implizit gilt auto) oder mit Angabe von auto: Es erfolgt eine Platzierung auf dem Stapel. Mit Angabe der Speicherklasse static: Platzierung wie global deklarierte Variablen.

Konstanten (const)

Platzierung im Bereich Code, Konstanten. Sowohl Code als auch Konstanten sind funktional gesehen nur lesbare Speicherinhalte. Da sie separat ausgezeichnet werden, kann das Betriebssystem die so belegten Speicherbereiche mit einem Schreibschutz belegen.

Dynamisch allozierte Daten

Platzierung auf der Halde (Heap). Reservation mittels der Bibliotheksfunktionen malloc() oder calloc(), Freigabe mittels der Bibliotheksfunktion free(). In C++ leisten die Operatoren new und delete Vergleichbares.

Aufrufparameter von Funktionen

Die Parameter stehen auf dem Stapel (Stack) bereit oder werden über allgemeine Prozessorregister übergeben (compilerabhängig konfigurierbar). Zu beachten ist, dass bei der Übergabe von Zeigern die Daten selbst anderswo liegen, d.h. nicht auf dem Stapel!

C2JS

+

.col1[

int x=0;
int foo(int x) {
  int t=x;
  return t+1;
}
int main() {
  int y;
  int *yp;
  y=foo(x);
  yp=(int*)malloc(sizeof(int));
  *yp=y;
}

.example[Beispiel eines C Programms] ] .col2[ .centered.width100[

] .figure[Variablenplatzierung im Adressraum (entsprechend Programmbeispiel)] ]

Heap versa Stack Speicher

.box[

Beide nehmen Datenvariablen auf (nur in speziellen Fällen Programmcode, z.B. bei Virtuellen Maschinen);

Der Heap Speicher braucht eine explizite Speicherverwaltung

Verwaltung von belegten und freien Bereich im Heap

Verwendung von Tabellen- oder Listenstrukturen

Speicherallokation und Speicherrückgabe erfolgt durch das Programm (oder Betriebssystem) explizit;

Der Stack Speicher braucht keine explizite Speicherverwaltung, er verwaltet sich automatisch über einen einzigen Zeiger (Stack Pointer SP)

Die Allokation ist lediglich eine Verschiebung des SP

Der Heap kann fragmentieren (Verlöcherung), der Stack nicht. ]

Funktionsweise des Stapels und Stapelzeigers (SP)

Wenn Funktionen (Unterprogramme) aufgerufen werden wird:

Ein Funktionsrahmen angelegt, der u.A. die Rücksprungadresse enthält (return);

Werden alle lokalen Variablen

Die dynamischen Daten eines Unterprogrammaufrufs werden auf dem Stapel als Teil des sogenannten Aufrufrahmens bzw. Aktivierungsrahmens (stack frame) gespeichert. Sie stehen dort nur für die Dauer der Unterprogrammausführung zur Verfügung.

Der bei Unterprogrammaufrufen benutzte Aktivierungsrahmen betrifft direkt die Programmierschnittstelle des Betriebssystems und die Prozess-/Thread-Verwaltung!

Der Stapelspeicher (stack) besteht genau genommen aus einem dafür reservierten Teil des Adressraums und dem Prozessorsteuerregister Stapelzeiger (Stack Pointer, SP).

Der Stapelzeiger enthält jederzeit die Adresse des aktuellen oberen Endes des Stapels (Top Of Stack, TOS).

Im Stapel selbst befinden sich die Aufrufrahmen (stack frames) aller nicht beendeten Unterprogrammaufrufe sowie eventuell andere zwischengespeicherte Werte. Entsprechend sieht man oft den Begriff des call stack, d.h. des Aufrufstapels.

Der Stapel wird im deutschen Sprachraum teilweise als Kellerspeicher bzw. Keller bezeichnet.

.centered.width60[

] .figure[Prinzip des Stapelspeichers]

.compact[

Der Stapel stellt die Grundinfrastruktur für die Unterprogrammtechniken dar.

Pro Unterprogrammaufruf wird ein Aktivierungsrahmen (stack frame), d.h. ein Datenbereich auf dem Stapel, angelegt.

Bei Beendigung des Unterprogramms wird der Datenbereich wieder freigegeben, was auch als Abräumen bezeichnet wird. Ein Aktivierungsrahmen enthält eines oder mehrere der folgenden Elemente:

Rücksprungadresse des Unterprogramms (d.h. Adresse, an der im aufrufenden Programm nach Unterprogrammende fortzufahren ist)

Unterprogramm-Aufrufparameter (Funktionsargumente)

Unterprogramm-Rückgabewerte

Lokale Variablen (in C sog. automatic variables)

Nach Bedarf weitere zwischengespeicherte Werte (computersprachabhängig) ]

Unterprogrammaufruf

Jeder Universalprozessor stellt für den Aufruf von Unterprogrammen und die Rückkehr in das Oberprogramm spezifische Maschinenbefehle zur Verfügung, die diesen Vorgang automatisieren helfen.

Wir bezeichnen nachfolgend diese zwei Maschineninstruktionen mit den Namen JSR (jump to subroutine) und RET (return from subroutine).

Abhängig vom benutzten Prozessor können diese Befehle etwas abweichend bezeichnet sein, arbeiten aber stets nach dem hier vorgestellten Verfahren.

Eine wichtige Grundeigenschaft des Unterprogrammaufrufs besteht darin, dass egal woher ein Unterprogramm aufgerufen wird, stets an die dem Aufruf direkt nachfolgende Programmstelle zurückgekehrt wird, wenn das Unterprogramm endet.

Dies wird technisch mithilfe des Stapels realisiert, indem die Rücksprungadresse für die Dauer der Unterprogrammausführung auf dem Stapel zwischengespeichert wird.

.smaller.box[Für den Unterprogrammaufruf dient der JSR-Befehl, der die Rücksprungadresse auf dem Stapel ablegt und den Programmzähler mit der Startadresse des Unterprogramms lädt. Die Rückkehr in das Oberprogramm wird durch den RET-Befehl veranlasst, indem er das oberste Element vom Stapel entfernt und als neuen Wert in den Programmzähler, womit der Programmablauf an der Rücksprungstelle fortfährt.]

.col1[ .centered.width100[

] .figure[Verwendung von Unterprogrammen (Beispiel)] ] .smaller.col2[

.invert[Aufruf]

Maschinenbefehl JSR

sichert Rücksprungadresse auf Stapel

lädt PC mit UP-Adresse

.invert[Rücksprung]

Maschinenbefehl RET

holt gesicherte Rücksprungadresse vom Stapel und lädt sie in den Programmzähler PC (damit: Rückkehr zu OP)

OP: Oberprogramm&br; UP: Unterprogramm ]

Durch die Verwendung des Stack Speichers und der LIFO Semantik wird auch Funktionsrekursion und geschachtelte Azfrufe unterstützt!

.reference[bsdp] .col1[.centered.width100[

]] .col2[.centered.width100[

]] .left[] .figure[Geschachtelter Unterprogrammaufruf (Beispiel)]

Paging und Segmentierung

Bisher gab es nur einen gesamten Speicherbereich mit der Unterteilung in die Bereiche Programm, statische Daten, dynamische Daten und temporäre Daten.

In einem Betriebssystem werden (außer bei Mikrokontrollerrechnern) mehrere Prozesse, d.h. Programme in Ausführung, in einem Multiplexbetrieb quasi parallel betrieben.

Jetzt muss jeder Prozess eigene Speichersegmente besitzen

Es bedarf jetzt einer hierarchischen Speicherverwaltung: 1. Segmente / alle Prozesse, 2. Speicherbereiche in Segmenten eines Prozesses.

Aber halt: schauen wir uns von einer Programmdatei die Symboltabelle an so finden wir z.B.:

objdump ball.exe  -h

ball.exe:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00006c68  80000000  80000000  00001000  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       0000000b  80007000  80007000  00008000  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .eh_frame     00001038  8000700c  8000700c  0000800c  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .data         00000004  8000a000  8000a000  0000a000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  4 .bss          00001238  8000a020  8000a020  0000a004  2**5
                  ALLOC

.example[Die Segmente eines Programms bevor es in den Speicher geladen wird. VMA: Virtual Mmeory Address, LMA: Linear Memory Address. Stack und Heap gibt es hier noch nicht! .text: Programmcode, .rodata: Konstante Daten, .data: statische globale und initialisierte Daten, .bss: statische globale nicht initialisierte Daten]

.reference[bsdp] .centered.width100[

] .figure[Gruppierung verschiedener Programmteile]

Würde man alle Programme nach dieser Anweisung in den Speicher laden würden sich alle Programme im Speicher überdecken.

Man könnte alle Programmadressen umrechnen (Offset), aber das müsste man nicht nur für die Segmente machen, sondern für alle Maschineninstruktionen die mit absoluten Adressen arbeiten. Keine gute Idee.

Man könnte Virtuelle Speicherräume und Virtuellen Speicher einführen. Gute Idee, aber...

Es nützt uns nichts wenn wir (oder das Betriebssystem) die physikalischen Speicheradressen (P Raum) auf virtuelle Adressen (für jeden Prozess, der V Raum) abbilden. Die Hardware muss das tun, und das geschieht durch die Memory Management Unit (MMU).

Die MMU arbeitet mit Tabellen die Adressen von Segmenten vom V in den P Raum transformieren. Damit die Tabellen um Umfang nicht zu groß werden werden Speichersegmente nur in Seiten (Pages) verwaltet, und Page Units (Größe z.B. 4096 Bytes).

Dynamische Segmentierung kann auch direkt vom Mikroprozessor unterstützt werden, so dass kein virtueller Speicherraum benötigt wird.

Dazu werden für einen Speicherzugriff Segmentregister verwendet, die dann den oberen Teil einer Speicheradresse zusammen mit der von einer Maschineninstruktion gelieferten Adresse zusammengerechnet werden:

                            ┌――――――――――┐ 0
   ┌――――――┐                 │          │
   │  SS  ├――――――――――――┐    │          │
   └――――――┘            │    │          │
                       └―――▶│          │5000
   ┌――――――┐                 │          │
┌――┤  DS  ├――――――――――――――――▶│          │10000
│  └――――――┘                 │          │10100
│                       ┌――▶│          │10102
│  ┌――――――┐             │   │          │
│  │  CS  ├―――――――――――――┼――▶│          │20000
│  └――――――┘             │   │          │
│                       │   │          │
│                       │   │          │
└―▶ add $100,$102 ――――――┘   │          │
                            │          │
                            └――――――――――┘ N-1

.example[Die Programmadresse ergibt sich aus PC+CS, die Datenadressen aus DS+$addr, und die Stackadressen aus SS+SP]

Verwaltung von Prozessadressräumen

.compact[

Dynamische Segmentierung kann erfolgen durch:

MMU (Tabellen)

Prozessor (Register)

Egal welche Methode, bei jedem Prozesswechsel müssen die Tabellen oder Register umgeladen werden.

Nachdem ein Programm übersetzt wurde, steht es dem Betriebssystem in Form einer ausführbaren Datei zur Verfügung.

Beim Laden eines Programms müssen Bereiche im Adressraum zum Beispiel für Code, Daten, Heap und Stack reserviert werden.

Ein laufendes Programm kann zudem Threads erzeugen, gemeinsame Speicherbereiche mit anderen Prozessen einrichten und weitere Aktionen ausführen, die wiederum Bereiche im Prozessadressraum betreffen.

Das Betriebssystem muss über alle diese Belegungen des Adressraums Buch führen. Nur so ist es in der Lage, neue Reservierungen ohne Kollisionen mit bestehenden Belegungen durchzuführen.]

.compact[

Prozessadressräume sind also i.A. virtuell, so dass jeder Prozess aus seiner Sicht immer die gleiche "Startadresse" hat.

Vorteile: Einfache Kompilierung und Mehrfachausführung einer Programmdatei ist möglich

Nachteile: Hardwareunterstützung (MMU), Verwaltungsaufwand, Prozesswechsel

Segmente sind Speicherregionen die vom Betriebssystem verwaltet werden müssen (Partitionierung des physikalischen Speichers).

Auch hier muss zwischen Anwendungsprozessen und dem Betriebssystemprozess unterschieden werden. Der virtuelle Adressraum ist auch zu partitionieren: ]
.up.centered.width60[

] .up.figure[Beispiele von Adressraumaufteilungen]

Für jeden Prozess benötigt das Betriebssystem eine Buchhaltung der im Adressraum belegten und freien Bereiche. Da bei vielen Prozessen der Adressraum nur schwach belegt ist, wird typischerweise nur die Belegung festgehalten.

Dies ist die Voraussetzung dafür, dass sich während des Prozessablaufs zusätzliche Bereiche kollisionsfrei reservieren lassen.

Steht eine passende Hardwareunterstützung (MMU) zur Verfügung, so können zudem Fehlzugriffe auf unbelegte Bereiche festgestellt werden.

.centered.width40[

] .figure[Funktionsweise einer MMU+CPU Architektur]

Dynamische Speicherreservierung von Regionen oder Erweiterungen

Anlässe für nachträgliche Reservierungen können sein:

Thread-Erzeugung: Es wird ein Stack-Bereich bereitgestellt (evtl. sogar zwei Stack-Bereiche, getrennt für Benutzer- und Kernmodus).

Einrichten gemeinsamen Speichers (shared memory): Es wird ein Adressbereich bereitgestellt, der den Datenaustausch zwischen mehreren Prozessen ermöglicht.

Laden einer Bibliotheksdatei: Ihr Inhalt wird geladen und in einem Bereich des Adressraums sichtbar gemacht.

Gemeinsame Bibliothek (shared library) nutzen: Von mehreren Prozessen gemeinsam genutzte Bibliotheken werden einmal geladen und für alle beteiligten Prozesse sichtbar gemacht.

Speicherbasierte Datei einrichten (memory mapped file): Der Dateiinhalt oder Teile davon werden über einen Bereich im Adressraum sicht- und änderbar gemacht.

Regionen

Im Adressraum belegte Bereiche werden Regionen genannt. Sie repräsentieren lückenlos zusammenhängende Adressbereiche mit unterscheidbaren Eigenschaften. Wichtige Attribute einer Region sind:

Startadresse: Dies ist die Adresse, mit der die Region beginnt (niedrigster Adresswert)

Größe: Die Anzahl zugehöriger Adresswerte (als Anzahl Byte)

Schutzattribute: Les-, schreib- oder ausführbar bzw. Kombinationen davon (die Durchsetzung benötigt spezielle Hardwareunterstützung)

Zugehöriger Hintergrundspeicher: Dateiname und Position innerhalb der Datei (kann undefiniert sein; benötigt zudem Softwareunterstützung)

Für das Suchen von Lücken im Adressraum sind nur die Attribute 1 und 2 von Belang. Wird ein virtuelles Speichersystem realisiert, so erlaubt das Attribut 3 die Erkennung von Fehlzugriffen und das Attribut 4 die Zuordnung zu einer Datei. Eine Region kann einem Hintergrundspeicher zur Auslagerung zugeordnet sein

Für die Verwaltung von Regionen werden i.A. verkettete Listen verwendet. Ausnahme bei hart echtzeitfähigen Systemen wo statische Tabellen verwendet werden (Hashtabellen).

.reference[bsdp] .centered.width90[

] .figure[Regionen pro Prozess]

.reference[bsdp] .centered.width90[

] .figure[Prozessadressraumbeschreibung unter Linux (Beispiel)]

... wenn wir schon virtuelle Speicherräume haben:

Swapping

Die Idee liegt darin, ganze Prozesse oder Speicherbereiche auszulagern, wenn sie im Moment keine Rechenzeit benötigen. Für diesen Zweck wird auf der Festplatte ein spezieller Bereich (Swapping Area) reserviert.

In seiner Grundform basiert das Swapping auf der Multiprogrammierung mit festen Partitionen, die um das Ein- und Auslagern von Prozessen erweitert wird.

Das Swapping wird vor allem dann benutzt, wenn mehrere Benutzer auf einem Rechner arbeiten (z.B. ältere Unix-Systeme).

Bei sehr vielen gleichzeitigen Benutzern kann es dann passieren, dass das Betriebssystem nur noch mit Swappen beschäftigt ist. In dieser Situation könnte es hilfreich sein, wenn das Betriebssystem den Grad der Multiprogrammierung reduziert bzw. beschränkt, was jedoch in Dialogsystemen unakzeptabel ist.

In einem Stapelverarbeitungssystem wäre hingegen das längere Anhalten eines Prozesses kein Problem, womit für die übrigen Aufträge ausreichend Speicher verfügbar wäre.

.centered.width60[

] .figure[Swapping Prinzip]

Bei der Partitionierung von Speicher legt man Regionen vorab fest die dann an Prozesse vergeben werden. Mit Swapping findet dann Austausch zur Laufzeit statt.

.centered.width80[

] .figure[Dynamische Partitionierung beim Swapping (Beispielsabfolge)]

Idee gut, aber zeitintensiv, gerade wenn ein Prozess nur kurz rechnet ...

.centered.width80[

] .figure[Beispiele für Zeitbedarfe beim Swapping-Verfahren]

Virtueller Speicher

Zusammengefasst sind dies die wesentlichsten Eigenschaften:

.compact[

Jeder Prozess kann den Adressraum fast beliebig belegen, d.h. teilt ihn höchstens mit dem Betriebssystem selbst.

Jeder Prozess ist gegen Fehlzugriffe aller anderen Prozesse geschützt (Schreibund Leseschutz!), auch das Betriebssystem ist geschützt.

Alle Prozesse sind von der vorhandenen Hauptspeichergröße weitgehend unabhängig (mehr Adressraum als real vorhanden). ] .centered.width70[

] .up.figure[Virtualisierung des Speichers (Beispiel für 32-Bit-Adressierung)]

Flexibilität ...

.centered.width80[

] .figure[Flexible Platzzuteilung dank Adressumsetzung. ]

Segmentierte versa seitenweise Adressierung, nicht ganz so flexibel aber einfacher und kompakter umzusetzen ...

.centered.width80[

] .figure[Segmentbasierte Adressumsetzung]

Seitenweise versa segmentierte Adressierung, nicht ganz so einfach aber viel flexibler, gerade bei Reallozierung und Bereichserweiterung ...

.centered.width80[

] .figure[Die Umsetzung einer virtuellen Adresse in eine physikalische Adresse mit doppelter Adressberechnung und Seitentabellen]

Man beachte, dass der tatsächlich vorhandene Hauptspeicher zwar ebenfalls in Seiten (Speicherpartitionen) eingeteilt sein kann, die effektive Lage und Größe der virtuellen Seite durch den Adressierungsmechanismus aber unabhängig davon ist. Die dadurch definierte, physikalische Speicherportion wird deshalb auch als Seitenrahmen (page frame ) bezeichnet.

Die MMU

Die Memory Management Unit ist eine Hardwarekomponente und verwaltet Abbildungen von physikalischen Speicher auf virtuelle Adressräume.

Dazu werden Umsetzungstabellen verwendet, die sog. Seitentabellen (Paging).

Der Speicher wird seitenweise aufgeteilt (z.B. 4kB Größe)

.centered.width60[

] .figure[Adressumsetzung mittels MMU und TLB (Beispiel)]

Zusammenfassung

Jedes Programm besteht aus verschiedenen Speicherbereichen: Heap, Stack, Datenbereiche, Code

C bildet das Prozesspeichermodell sehr konkret durch globale, lokale, und temporäre Variablen sowie Funktionen ab, und ermöglicht den Speicherzugriff über Zeiger ("Specihermodell")

Die einzelnen Programmbereiche können auf verschiedene Regionen im Speicher abgebildet werden.

Dynamische Speicherverwaltung benötigt Datenstrukturen die belegte und freie Regionen verwalten, der Prozess kann ebenfalls in seinen Regionen eine solche Speicherverwaltung haben (Verwaltungshierarchie)

Das Problem von Adresskollision kann durch Virtuelle Adressraume gelöst werden, die über eine MMU oder einen VMM auf physikalische Adressen abgebildet werden.

Was noch fehlt: Wie wird Speicher effizient verwaltet? Also die Algorithmik . coming soon.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - AG Praktische Informatik

]

Speicherverwaltung - Algorithmen

Eines der wichtigsten Betriebsmittel ist der Hauptspeicher . Die Verwaltung der Speicherressourcen ist deshalb auch ein kritisches und lohnendes Thema, das über die Leistungsfähigkeit eines Rechnersystems entscheidet. Dabei unterscheiden wir drei Bereiche, in denen unterschiedliche Strategien zur Speicherverwaltung angewendet werden:

Anwenderprogramme

Hauptspeicher

Massenspeicher

Anwenderprogramme

Der Speicherraum eines Prozesses

Die Hauptaufgabe besteht hierbei darin, interne Speicherbereiche des Prozesses (beispielsweise den Heap) mit dem Wissen um die speziellen Speicheranforderungen des Programms optimal zu verwalten.

Dies erfolgt durch spezielle Programmteile, z. B. den Speichermanager, oder durch sog. garbage collection Programme in Virtuellen Maschinen.

Hauptspeicher

Hier besteht das Problem in der optimalen Aufteilung des gesamten knappen Hauptspeicherplatzes auf die einzelnen Prozesse.

Die Aufgabe wird in der Regel durch spezielle Hardwareeinheiten unterstützt (MMU).

Besonders in Multiprozessorsystemen ist dies wichtig, um Konflikte zu vermeiden, wenn mehrere Prozessoren auf dieselben Speicherbereiche zugreifen wollen.

In diesem Fall helfen Techniken wie Non-Uniform Memory Access (NUMA) weiter, die zwischen einem Zugriff des Prozessors auf lokalen und auf globalen Speicher unterscheiden.

Massenspeicher

Abgesehen von der Dateiverwaltung, die noch besprochen wird, gibt es auch spezielle Dateien (z. B. bei Datenbanken), bei denen der Platz innerhalb der Datei optimal eingeteilt und verwaltet werden muss.

Ein Beispiel dafür ist die Swap-Datei, auf die Prozesse verlagert werden, die keinen Platz mehr im Hauptspeicher haben.

Direkte Speicherbelegung

In den frühen Tagen der Computernutzung war es üblich, für jeden Job den gesamten Computer mit seinem Speicher zu reservieren.

Der Betriebssystemkern bestand dabei oft aus einer Sammlung von Ein- und Ausgabeprozeduren, die als Bibliotheksprozeduren zusätzlich an den Job angehängt waren.

Musste man einen Job zurückstellen/wechseln, um erst einen anderen durchzuführen, so wurden alle Daten des dazugehörenden Prozesses auf den Massenspeicher verlagert und dafür die neuen Prozessdaten vom Massenspeicher in den Hauptspeicher befördert. (Swapping)

Viele Nachteile (Overhead, "Reibung"). Besser die Daten mehrerer Prozesse gleichzeitig im Speicher halten, sobald genügend Speicher vorhanden war.

Allerdings brachte dies weitere Probleme mit sich: Wie kann man den Speicher so aufteilen, dass möglichst viele Prozesse Platz haben? Diese Frage stellt sich prinzipiell nicht nur für den Hauptspeicher , sondern gilt natürlich auch für den Auslagerungsplatz auf der Festplatte, den swap space.

Zuordnung durch feste Tabellen

Die einfachste Möglichkeit besteht darin, ein verkleinertes Abbild des Speichers zu erstellen: die Speicherbelegungstabelle . Jede Einheit einer solchen Tabelle (beispielsweise ein Bit) ist einer größeren Einheit (z. B. einem 32-Bit-Wort) zugeordnet. Ist das Wort belegt, so ist das Bit Eins, sonst Null.

.reference[BSG] .centered.width60[

] .figure[Eine direkte Speicherbelegung und ihre Belegungstabelle]

Eine solche Belegungstabelle benötigt also bei 32 MiB Speicher selbst 1 MiB.

Soll ein freier Platz belegt werden, so muss dazu die gesamte Tabelle auf eine passende Anzahl von aufeinanderfolgenden Nullen durchsucht werden.

Komplexitätsklasse O(N) bei N Bytes

Besser: Paging (Einteilung des Speichers in Blöcke), reduziert die Tabellengröße

Zuordnung durch verzeigerte Listen

Der belegte bzw. freie Platz ist meist wesentlich länger als die Beschreibung davon.

Die Belegung des gesamten Hauptspeichers mit belegten und freien Plätzen kann dünn besetzt sein.

Aus diesem Grund lohnt es sich, anstelle einer festen Tabelle eine Liste von Einträgen über die Speicherbelegung zu machen, die in der Reihenfolge der Speicheradressen über Zeiger miteinander verbunden sind.

.shadowbox[

Ein Eintrag der Liste besteht aus drei Teilen:

der Startadresse des Speicherteils,

der Länge und

dem Index (Zeiger) des nächsten Eintrags. ]

.reference[BSG] .centered.width60[

] .figure[Eine Speicherbelegung und die verzeigerte Belegungsliste]

Eine solche Belegungsliste lässt sich auch in zwei Listen zerteilen:

eine für die belegten Bereiche, deren Einträge im Fall von Prozessen mit dem Prozesskontrollblock PCB verzeigert sind, und

eine nur für die unbelegten Bereiche.

Ordnen wir diese Liste noch zusätzlich nach der Größe der freien Bereiche, so muss normalerweise nie die ganze Liste durchsucht werden.

Allerdings wird auch dadurch das Verschmelzen angrenzender, freier Bereiche erschwert. Eine doppelte Verzeigerung ermöglicht das Durchsuchen der Liste in beiden Richtungen.

Beispiel: Heap Liste

typedef unsigned int memoryptr;
typedef int size;
struct heap_entry {
  memoryptr  adress;
  memorysize size;
  struct heap_entry next;
};
struct heap_entry *allocated;
struct heap_entry *free;

.definition[Eine listenbasierte Speicherverwaltung ist beispielsweise für das Speichermanagement eines Heaps sinnvoll. Es wird meistens in Blockgrößen gerechnet (z.B. 4kB), nicht in Byteeinheiten]

Es ist einfacher und schneller, nur eine einzige Liste zu führen, die Liste der freien Plätze.

Allerdings ist es sicherer, auch eine Liste der belegten Plätze zu führen, um bei der Platzrückgabe die Angaben über die Lage und die Länge des zurückgegebenen Platzes überprüfen zu können und so Fehlprogrammierungen des Benutzerprogramms aufzudecken.

Besonders in der Debugging-Phase ist dies durchaus sinnvoll; eine solche Überprüfung kann dann in späteren Phasen bei der Laufzeitoptimierung abgeschaltet werden.

Zuordnung durch Baumstrukturen

Listen haben eine Such- und Bearbeitungslaufzeit der Klasse O(N) bei N Elementen, Bäume hingegen (im Mittel) von log(N)N.

Daher bieten sich Bereichsbäume an um belegte und freie Speicherbereiche zu verwalten.

Jedoch bedürfen wie in der Natur Bäume einer Gartenpflege, Listen i.A. nicht.

Ein Baum ist nur dann effizient wenn er ausbalanziert ist.

Wenn er balanziert ist, dann ist die Suche schneller als bei Listen

Aber beim Speichermanagement müssen häufig Änderungen vorgenommen werden, was zum Ungleichgewicht führen kann, was dann wieder korrigiert werden muss.

Extremfall eines Baums: Die Liste!

Belegungsstrategien

Unabhängig von dem Mechanismus der Speicherbelegungslisten gibt es verschiedene Strategien, um aus der Menge der unbelegten Speicherbereiche den geeignetsten auszusuchen.

Ziel der Strategien ist es, die Anzahl der freien Bereiche möglichst klein zu halten und ihre Größe möglichst groß.

Die wichtigsten Strategien sind:

FirstFit

Das erste, ausreichend große Speicherstück, das frei ist, wird entsprechend belegt. Dies bringt meist ein Reststück mit sich, das unbelegt übrig bleibt.

NextFit

Die FirstFit-Strategie führt dazu, dass in den ersten freien Bereichen nur Reststücke (Verschnitt) übrigbleiben, die immer wieder durchsucht werden. Um dies zu vermeiden, geht man wie FirstFit vor, setzt aber beim nächsten Mal die Suche an der Stelle fort, an der man aufgehört hat.

BestFit

Die gesamte Liste bzw. Tabelle wird durchsucht, bis man ein geeignetes Stück findet, das gerade ausreicht, um den zu belegenden Bereich aufzunehmen.

WorstFit

Sucht das größte vorhandene freie Speicherstück, so dass das Reststück möglichst groß bleibt.

QuickFit

Für jede Sorte von Belegungen wird eine extra Liste unterhalten. Dies gestattet es, schneller passende Freistellen zu finden. Werden beispielsweise in einem Nachrichtensystem regelmäßig Nachrichten der Länge 1 KB verschickt, so ist es sinnvoll, eine extra Liste für 1-KB-Belegungen zu führen und alle Anfragen damit schnell und ohne Verschnitt zufriedenzustellen.

.reference[bsdp]

free={addr:0,                 last=free                       function WorstFit(size) {
      size:N,                 function NextFit(size) {          block=free; biggest=null
      next:null}                if (last && last.size>=size)    while(block) {
                                  block=last                      if (block.size>=size) {
function FirstFit(size) {       else                                if (!biggest ||
  block=free                      block=free                            biggest.size<block.size)
  while(block.size<size) {      while(block.size<size) {              biggest=block;
   block=block.next              block=block.next                 }
  }                             }                                 block=block.next
  if (!block)                   if (!block)                     }
    return null;                 return null;                   if (!biggest) return null;
  addr=block.addr               addr=block.addr                 addr=biggest.addr
  block.size-=size              block.size-=size                biggest.size-=size
  block.addr+=size              block.addr+=size                biggest.addr+=size
  return addr                   last=block                      return addr
}                               return addr                   }
                              }

.algorithm[FirstFit versa NextFit versa WorstFit]

Buddy-Systeme

Den Gedanken von QuickFit kann man nun dahin erweitern, dass man für jede gängige Belegungsgröße, am besten Speicher in den Größen von Zweierpotenzen, eine eigene Liste vorsieht und nur Speicherstücke einer solchen festen Größe vergibt.

Alle Anforderungen müssen also auf die nächste Zweierpotenz aufgerundet werden. Ein Speicherplatz der Länge 280 Bytes = 256 + 16 + 8 = 2⁸ + 2⁴ + 2³ Bytes muss also auf 2⁹ = 512 Bytes aufgerundet werden.

Ist kein freies Speicherstück der Größe 2k vorhanden, so muss ein freies Speicherstück der Größe 2k+1 in zwei Stücke von je 2k Byte aufgeteilt werden. Beide Stücke, die Partner (Buddy), sind genau gekennzeichnet: Ihre Anfangsadressen sind identisch bis auf das k-te Bit in ihrer Adresse, das invertiert ist. Beispiel: … XYZ0000 … und … XYZ1000 … sind die Anfangsadressen von Partnern.

Dies lässt sich ausnutzen, um sehr schnell (in einem Schritt!) prüfen zu können, ob ein freigewordenes Speicherstück einen freien Partner in der Belegungstabelle hat, mit dem es zu einem (doppelt so großen) Stück verschmolzen werden kann, ohne freie Reststücke zu hinterlassen.

Beispiel Buddy-Speicherzuweisung

Angenommen, wir benötigen Speicherstücke der Größen 80 KB, 8 KB, 9 KB, 3 KB und 50 KB in der genannten Reihenfolge von einem Speicherstück von 256 KB.

Dann lässt sich die Zuordnung ganz gut mit Hilfe eine Binärbaumes visualisieren, bei dem jedes Speicherstück durch seine beiden Buddy notiert ist.

Da es sich jedes Stück in zwei Unterstücke aufspalten lässt, bilden alle Speicherstücke zusammen einen Binärbaum.

.reference[bsdp] .centered.width60[

] .figure[Visualisierung einer Speicherzuteilung im Buddy-System]

Man sieht, dass jedes Speicherstück, das benutzt wird, meist noch ein unbenutztes Reststück, den Verschnitt, enthält.

Beide Vorgänge, sowohl das Suchen eines passenden freien Stücks (bzw. das dafür nötigen Auseinanderbrechen eines größeren) als auch das Zusammenfügen zu größeren Einheiten lässt sich rekursiv über mehrere Partnerebenen (mehrere Zweierpotenzen) durchführen.

Bewertung der Strategien

.shadowbox[Der Vergleich der verschiedenen Strategien führt zu einer differenzierten Bewertung. So stellt sich heraus, dass FirstFit von der Platzausnutzung etwas besser als NextFit und WorstFit ist und überraschenderweise auch besser als BestFit, das dazu neigt, nur sehr kleine, unbelegbare Reste übrig zu lassen. Eine Anordnung der Listenelemente nach Größe der Bereiche erniedrigt dabei die Laufzeit von BestFit und WorstFit.]

Wissen wir mehr über die Verteilung der Speicheranforderungen nach Zeit oder Beleggröße, so können mit QuickFit und anderen, speziellen Algorithmen bessere Resultate erzielt werden!

Die Leistungsfähigkeit des Buddy-Systems lässt sich mit folgender Rechnung kurz abschätzen: Nehmen wir an, für den Hauptspeicher der Größe 2n werden alle 2n möglichen Stückgrößen S daraus mit gleicher Wahrscheinlichkeit verlangt (was sicher nicht vorkommt, aber mangels plausiblerer Annahmen vorausgesetzt sei). Dann ist die mittlere Speicheranforderung 〈S(n)〉 mit

Profiling zur Laufzeit kann helfen den richtigen Algorithmus auszuwählen.

.reference[file:///home/sbosse/skripte/bs1k/doc/BetriebssystemeGrundlagen/html/49321_4_De_3_Chapter.xhtml#Sec3]

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Zeitmanagement

Timer

In einem Betriebssystem müssen häufig zeitliche Abläufe synchronisiert werden.

Zeitauflösung für Ereignisse könnte Mikro- oder Millisekunden sein

Eine solch hohe Auflösung ist aber meistens nicht erforderlich

Beispiel: Ein Mikroprozessr arbeitet mit einem Kerntakt von 72MHZ. Dann beträgt die minimale Ausführungszeit einer Maschineninstruktion ca. 20 ns, typischerweise nebötigt ein Funktionaufruf einer kleinen Funktion ca. 50 Instruktionen, was 1 μs entspricht.

Daher wird in den meisten Betriebssystemen eine virtuelle Zeiteinheit genutzt, die Ticks.

Ein Tick entspricht Zeitintervallen typischerweise im Breich 10-100 ms

Prozessscheduling wird bei Zeitscheibenverfahren in Ticks behandelt, d.h. eine Zeitscheibe häufig ein Tick

Programmable Interval Timer

Für die "Erzeugung" von Ticks werden Intervalltimer verwendet, also binäre Zähler. Diese stellen die Systemuhr des betriebssystem zur Verfügung. Nicht zu verwechseln mit der Real Time Clock (RTC), die eine nicht flüchtige Echtzeit in Sekunden usw. ausgibt.

.reference[Wikipedia]

Der Programmable Interval Timer (PIT) war ursprünglich ein spezieller Baustein (Chip) im IBM-PC, der für die zeitliche Steuerung von Prozessen sorgte. Die am häufigsten verwendeten Timer-Chips waren die Typen 8253 und 8254 von Intel mit drei 16-Bit breiten Zählern.

.reference[A\. Sahu, Dept. of Comp. Sc. & Eng. IIT Guwahati] .centered.width60[

] .figure[Die Architektur eines PITs (8254)]

Die Ausgänge der drei Zähler können/sind mit dem Interruptcontroller verbunden

Der PIT wird, unabhängig von der Rechnergeschwindigkeit, stets im Takt von (idealerweise) 1,19318181818… MHz betrieben; das ist 1/3 der Farbträger-Frequenz des NTSC-Fernsehsystems!

In den ersten PCs von IBM wurde diese merkwürdig anmutende Frequenz gewählt, da für die Einbindung einer CGA-Grafikkarte, die auch Fernsehgeräte als Monitor verwenden konnte, ohnehin ein Quarz mit einem Vielfachen des Farbträgers vorhanden sein musste.

Aus diesem Quarz konnte die PIT-Frequenz dann leicht mittels Frequenzteilung gewonnen werden; diese Vorgehensweise war günstiger als der Einsatz weiterer Quarze, da mit nur einem Quarz in der Schaltung keine störenden Schwebungen das Bildsignal beeinträchtigen können, die dann mit weiterem Schaltungsaufwand herausgefiltert werden müssten.

Auch der CPU-Takt der ersten PCs von 4,772727 MHz ergab sich aus einer solchen Erwägung, er betrug 4/3 der Farbträgerfrequenz.

Der PIT hat verschiedene Betriebsarten:

Standard Interrupt Modus: Interrupt bei Erreichen eines einstellbaren Zählwerts (Über- oder Unterlauf)

Periodischer Modus: Erzeugung eines Rechtecksignals

Periodischer Modus: Erzeugung von Pulssignalen

Software Modus: Softwaresteuerung des Zählers

Im Standard-Modus zählt der Timer vom Startwert (maximal 65535) auf 0 hinunter und fängt beim folgenden Überlauf wieder von vorne an.

Bei jedem solchen Überlauf sendet er ein Signal, den Timer-Interrupt (IRQ0), an den Prozessor.

Wenn man die Taktfrequenz durch die Takte bis zum erneuten Überlauf dividiert, erhält man die Frequenz, mit der der IRQ0 erzeugt wird.

Unter MS-DOS und älteren Windows-Versionen bis einschließlich Windows 98 und Windows ME wurde der Maximalwert 65535 in den Timer geladen, man erhält somit 1193181,8181… Hz / 65536 = ca. 18,2065 Hz.

Es kommt auf diese Art ein Intervall von ca. 55 ms zwischen den Aufrufen des IRQ 0 zustande.

Modernere Betriebssysteme (Linux, Windows NT und Abkömmlinge) verwenden kürzere Intervalle zwischen 1 und 10 ms.

Mehr Info gibt es hier:

https://wiki.osdev.org/Programmable_Interval_Timer

Ticks,Frequenzen, Zeiten und Timer

Folgende Parameter sind die Zutaten des Zeitmanagements im Betreibssystem:

image-placeholder
(30-1)

D.h. der Timer muss immer mit TIMER_COUNT intialisiert werden um die Vorgabe TICKS_PER_SECOND zu erreichen.

Im Basekernel werden die Ticks als Clicks bezeichnet.

Die kleinste von Programmen (und dem Kernel selber) messbare Zeiteinheit ist 1/TICKS_PER_SECOND, also z.B. 20 ms

Benötigt man eine genauere Zeitmessung so müssen entweder andere Timer oder der aktuelle Timercount des PIT benutzt werden.

Bekannt ist mindestens die Anzahl der Ticks (Clicks), dann kann man den aktuellen Zählerwert des PIT auslesen und kann die Zeit zwischen zwei Ticks intgerpolieren.

Bei ca. 1 MHZ Timerfrequenz ist die kleinste Auflösung 1 μs.

Aufgabe: Implementiere im Basekernel eine neue Funktion clock_micros die die Systemzeit in Mikrosekunden zurück gibt.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Virtuelle Machinen

Abstraktion von Rechnern, Betriebssystemen und Software

Virtuelle Maschinen und Virtualisierung sind zentraler Bestandteil im Betrieb moderner Rechneranlagen und Bestandteil von Betriebssystemen

Die Virtualisierung von Rechnerressourcen bringt in verschiedener Hinsicht neue Nutzen. Sie kann auf verschiedenen Stufen erfolgen und ist bereits teilweise in den Konzepten des Prozessmodells und des virtuellen Speichers realisiert.

Anwendungsbereiche

Eine Reihe von Virtualisierungsansätzen im Umfeld des Betriebssystems:

.shadowbox[

Virtuelle Prozessoren: Simulation einer CPU-Hardware durch Software

Virtuelle Prozessumgebungen: Nutzung virtueller Adressräume für Prozesse (Raummultiplex) zusammen mit CPU-Scheduling (Zeitmultiplex)

Virtuelles Betriebssystem: Ausführung unveränderter (Binär-)Programme eines Betriebssystems A unter einem Betriebssystem B

Virtueller Desktop: entfernter Zugriff auf einen grafischen Desktop über das Netzwerk

Virtuelle Ressourcen: Sekundärspeicher- und Netzwerkvirtualisierung

Sandboxing: virtuelle Prozesslaufzeitumgebungen inklusive Betriebssystem (Abstrakter Prozessor)

Virtuelle Computer: Virtualisierung der gesamten Computerhardware mithilfe eines Virtual Machine Monitor (Hypervisor) ]

Taxonomie der Virtuellen Maschinen

Es können zwei wesentliche Klassen unterschieden werden:

Prozess Virtuelle Maschinen

Virtuelle Prozessoren oder Virtuelle Anwendungsmaschinen die eine Anwendung innerhalb oder außerhalb eines Betriebssystems ausführen und einen einzelnen Prozess unterstützen. Java Virtual Machines, auf denen in Java kompilierte Programme ausgeführt werden, sind Beispiele für Prozess-VMs. Es wird ein Prozessor simuliert.

System Virtuelle Maschinen

System-VMs ermöglichen die gemeinsame Nutzung der zugrunde liegenden physischen Maschinenressourcen durch verschiedene Virtual Machines, die jeweils ihr eigenes Betriebssystem ausführen. Es wird kein Prozessor simuliert, aber je nach Typ emuliert.

Simulation

Abtrakte Maschine ohne Kompatibilität zu bestehender Hardware

Emulation

Konkerete Maschinen mit bestmöglicherKompatibilität zu bestehender Hardware

.reference[VM,Smith,2005] .centered.width60[

] .figure[Übersicht und Klassendiagramm der VM Architekturen und Methodem]

Virtualisierung

.reference[VM,Smith,2005] .centered.width50[

] .figure[Virtualisierung. Formal ist Virtualisierung die Konstruktion eines Isomorphismus zwischen einem Gastsystem und einem Host; e' ◦ V(Si) = V ◦ e(Si). Dabei ist e eine Funktion im Gastsystem, die den Zustand Si in Sj überführt. Die Virtualisierung V "übersetzt" den Zustandsübergang in das Hostsystem. Im Host gibt es eine entsprechende Abfolge von Operationen e', die eine äquivalente Änderung des Hostzustands vornimmt (Änderungen S'i zu S'j). ]

.reference[VM,Smith,2005] .centered.width50[

] .figure[Implementierung virtueller Festplatten. Virtualisierung bietet eine andere Schnittstelle und / oder Ressourcen auf derselben Abstraktionsebene. Das Dateisystem ist Virtualisierung!]

Sandboxing (virtuelles Laufzeitsystem)

Die Laufzeitumgebung einer Applikation wird vom restlichen System abgeschirmt, d.h. virtualisiert (Container-based Operating Systems, COS).

Diese Möglichkeit wird von einigen Betriebssystemen unterstützt, zum Beispiel von Oracle Solaris mit den Containers, vom FreeBSD-Unix mit den Jails und von Linux mit dem Vserver.

Dasselbe Konzept wird aber beispielsweise auch in Webbrowsern im Kleinen genutzt, um einzelne Webseiten oder Plug-ins zu isolieren.

Beim Sandboxing sieht jeder Prozess nur sich selbst und das Betriebssystem, wodurch eine wechselseitige Beeinflussung von Applikationen untereinander verhindert wird.

Dazu gehört, dass jedem Anwenderprozess nur ein begrenzter Anteil an der Nutzung gemeinsamer Ressourcen gewährt wird.

Jede Sandbox ist ein separater User Space, der sich mit anderen Sandboxen lediglich den geschützten Kernel Space teilt.

Die im Unix-Bereich klassische Lösung der chroot-Umgebung kann nicht dieselbe Isolation gewähren wie das Sandboxing-Konzept, da die damit realisierbare Isolierung unvollständig ist.

Virtual Hosting

Anwendung findet das Sandboxing bei Anbietern von Virtual Hosting, zur erhöhten Isolierung kritischer Applikationen bei sich wechselseitig nicht trauenden Systembenutzern, zur feineren Kontrolle der Ressourcenzuteilung und zur Ermöglichung einfacher Migrierungen von Applikationen.

Im Vergleich zu Lösungen mit einem Virtual Machine Monitor ist der Zusatzaufwand gering, da stets dieselbe Systemprogrammierschnittstelle genutzt wird und spezielle Hardware unnötig ist. Allerdings wird nicht dieselbe Flexibilität erreicht, da nur Applikationen für dieselbe Betriebssystemplattform lauffähig sind.

Virtual Private Server

Ein Schritt weiter: Ein ganzer Rechner wird aufgeteilt und wird mehreren Betriebssystemen und Betreibern (Kunden) zur Verfügung gestellt.

Beispiel: https://edu-9.de ist Virtual Private Server Hosting!

Auf einem virtuellen privaten Server wird eine eigene Kopie eines Betriebssystems (OS) ausgeführt, mit möglicherweise Zugriff auf die Superuser-Ebene dieses Betriebssysteminstanz, sodass sie nahezu jede Software installieren können, die auf diesem Betriebssystem ausgeführt wird.

Für viele Zwecke entspricht es funktional einem dedizierten physischen Server und kann softwaredefiniert einfacher erstellt und konfiguriert werden (z.B. webbasierte Installationd eines Betriebssystems).

Container-Systeme

Für den betrieblichen Einsatz ist eine möglichst einfache Bereitstellung von Anwendungen wichtig, die voneinander isoliert ablaufen und wenig Systemabhängigkeiten zeigen (Bibliotheken!).

.shadowbox[

Dies ermöglichen Container-Systeme, wie z.B. Docker oder rkt, die beide auf Linux aufsetzen.

Dazu werden Anwendungen, wie z.B. ein Webserver oder ein Datenbanksystem, mithilfe entsprechender Werkzeuge als Standardpakete bereitgestellt.

Werden diese Pakete, als Abbilder (Images) bezeichnet, auf einer webbasierten Plattform allgemein nutzbar bereitgestellt, so benötigt eine Installation nur noch wenige Arbeitsschritte.

Jedes Abbild, als einzelne Datei verfügbar, enthält nicht nur die Anwendung selbst, sondern auch alle Drittsoftware, die bei traditioneller Bereitstellung zusätzlich zu installieren wäre.

Für Docker-Images ist dies mit dem Docker Hub realisiert, der als Onlinedienst sowohl eine öffentliche wie auch eine private Bereitstellung erlaubt. ]

System-VM

Emulation von Prozessoren unter Verwendung physischer Prozessoren;

Emulation von Hardwaregeräten unter Verwendung von Betriebssystemmitteln;

Virtualisierung von Speicher unter Verwendung von Betriebssystemmitteln.

.centered.width60[

] .figure[Eine virtuelle Systemmaschine. Virtualisierungssoftware übersetzt die von einer Hardwareplattform verwendete ISA in eine andere und bildet eine virtuelle Systemmaschine, die in der Lage ist, eine Systemsoftwareumgebung auszuführen, die u.U. auch für eine andere Hardware entwickelt wurde.]

.centered.width100[

] .figure[Beispiele für Anwendungen virtueller Maschinen. (a) Emulieren eines Befehlssatzes mit einem anderen; (b) Replizieren einer virtuellen Maschine, so dass mehrere Betriebssysteme gleichzeitig unterstützt werden können; (c) Komponieren von Software für virtuelle Maschinen zu einem komplexeren, flexibleren System.]

Virtual Machine Monitor bzw. Hypervisor

Neben der Speichervirtualisierung ist heute die Bereitstellung virtueller Rechnerumgebungen durch einen Virtual Machine Monitor (VMM) bzw. Hypervisor die wichtigste Virtualisierungsform.

Der VMM erlaubt die Erstellung mehrerer virtueller Umgebungen (hier dann VM genannt) in denen sich unterschiedliche Betriebssysteme installieren lassen.

.centered.width60[

] .figure[VMM-Typen: Ohne (bare metal) und mit zwischengeschalteten Betriebssystem]

.shadowbox.smaller[Ein VMM kann auf zwei Arten realisiert werden, die als Typ-I- und Typ-II-VMM bezeichnet werden.

Beim Typ I setzt der VMM direkt auf der Hardware auf und virtualisiert diese (bare-metal). Der VMM stellt ein eigenes Betriebssystem dar, dessen Aufgabe in der Ressourcenverwaltung und der Bereitstellung von VMs liegt. Die Ressourcenverwaltung umfasst die Aufteilung der Rechenzeit (scheduling) und die Zuteilung (allocation) von Speicher und Peripherie auf die einzelnen VMs. Beispiel eines solchen Produkts ist VMware ESX, das aber nur auf Hardware ablaufen kann, für die es eigene Treiber besitzt.

Ein Typ-II-VMM läuft als eine von mehreren Applikationen (hosted) auf einem Gastgeber-Betriebssystem (host operating system). Damit er jedoch die nötigen Rechte auf der Hardware erhält, benutzt er einen speziellen VMM-Treiber, der unter dem Gastgeber-Betriebssystem installiert ist und dadurch dem Hypervisor Kernmodus-Privilegien eröffnet. Jedes weitere Betriebssystem, das unter dem VMM läuft, wird als Gastbetriebssystem (guest operating system) bezeichnet. Der VMM kann auf Mechanismen des unterliegenden Gastgeber-Betriebssystems für die Ressourcenverwaltung zurückgreifen. Typ-II-Produkte sind VMware Workstation, Virtual-Box und Xen.

Eine zwischen Typ I und Typ II anzusiedelnde Variante sind Hypervisors, die in das Betriebssystem von Haus aus bereits integriert sind. Realisiert wird dies in Linux durch die KVM (Kernel-based Virtual Machine) und unter Windows mit Hyper-V. Diese Zwischenform lässt sich so einsetzen, dass keine Applikationen auf dem Betriebssystem selbst laufen, sondern nur der Hypervisor genutzt wird. In diesem Szenario sind keine Leistungsnachteile im Vergleich zu reinen Typ-I-VMM zu erwarten. ]

Anforderungen

Ein VMM muss nach Popek/Goldberg (1974) drei Kerneigenschaften unterstützen:

.shadowbox[

Ausführungsumgebung (fidelity, equivalence): Programme laufen auf einem virtualisierten Rechner identisch ab wie auf einem realen Rechner, mit Ausnahme der Geschwindigkeit.

Effizienz (performance, efficiency): Die große Mehrheit aller Maschinenbefehle, die innerhalb einer VM abgearbeitet werden, müssen direkt durch den realen Prozessor ausgeführt werden ohne die Intervention durch den VMM.

Ressourcenverwaltung (safety, resource control): Die Ressourcen werden vollständig durch den VMM verwaltet und den einzelnen VMs zugeteilt bzw. wieder entzogen. ]

Funktionsweise eines VMM

In der Anfangszeit der VMM in der PC-Welt fehlte eine passende Hardwareunterstützung, sodass sich der VMM und das in der VM ablaufende Betriebssystem den Kernmodus teilten.

Ohne Unterstützung durch Hardware

Ein sicherer VM-Monitor setzt eine voll virtualisierbare CPU voraus. Das heißt, dass alle Instruktionen, die auf die Hardware zugreifen oder indirekt die Hardware beeinflussen können, nur privilegiert ausgeführt werden. Dazu muss die CPU zwischen einem privilegierten und nicht privilegierten Betriebsmodus unterscheiden. Typischerweise werden diese als Kernmodus (kernel/supervisor mode) und Benutzermodus (user mode) bezeichnet. Prozessorintern wird der privilegierte Modus durch ein gesetztes KM-Bit repräsentiert (KM für Kernel Mode). Dieses Bit kann im Benutzermodus natürlich nicht direkt gesetzt werden. Erfolgt jedoch ein Hardware- oder Software-Interrupt, so wird es automatisch durch die CPU selbst gesetzt.

Funktionsweise eines VMM

.centered.width60[

] .figure[Zustandsmodell für Privilegiensystem der CPU]

Funktionsweise eines VMM

Was passiert nun, wenn ein Programm im Benutzermodus eine privilegierte Instruktion ausführen will? Die CPU wird in diesem Fall den Befehl nicht ausführen, sondern einen Software-Interrupt (trap) auslösen. Wird ein VMM eingesetzt, so läuft nur der VMM im privilegierten Modus. Aller restlicher Code, einschließlich irgendwelcher Betriebssysteme, läuft im Benutzermodus. Die Unterscheidung eines Betriebssystems zwischen Benutzer- und Kernmodus wird nämlich durch den VMM ebenfalls virtualisiert.

.centered.width60[

] .figure[Sichere Virtualisierung mittels Privilegiensystem: KM\*: virtualisiertes Privilegien-Bit (pro VM)]

Funktionsweise eines VMM

Mit Unterstützung durch Hardware

Moderne CPUs enthalten eine Zusatzhardware für eine einfachere Realisierung des VMM, der dann als Hardware-assisted VMM bezeichnet wird. Dies trifft für viele Prozessortypen zu, auch für die x86- und x86-64-Prozessoren. Ergänzend zu den Privilegienstufen, die das Betriebssystem nutzt, steht eine nochmals höher privilegierte Hypervisor-Stufe zur Verfügung, in die bei der Ausführung kritischer Instruktionen verzweigt wird. Bei x86-Prozessoren wird diese als Root-Betriebsmodus bezeichnet.

Einsatz von VMM für ...

.centered.width80[

] .figure[VMM für die Serverzusammenführung (Konsolidierung) auf einer Hardware]

Einsatz von VMM für ...

.centered.width80[

] .figure[VMM-Einsatzszenario Applikationsmigration]

Einsatz von VMM für ...

.centered.width80[

] .figure[VMM-Einsatzszenario Softwaredistribution (Beispiel) mit Replikation]

Prozess-VM

Beispiele für Hochsprachen Prozess-VMs:

Java Virtual Machine (Bytecode, Stack- und Registerprozessor)

Lua (Lua, LuaJit, Bytecode)

JavaScript (Google V8 - Bytecode und nativer Maschinenkode, Spidermonkey - Bytecode, WebAssembly)

Continuum (JavaScript -> JavaScipt)

Kompiler werden eine Programmiersprache in ein i.A. lineares Maschinenprogramm übersetzen. Dabei wird die Komplexität reduziert.

Für die zuvor beschriebenen Prozess-VMs ist die plattformübergreifende Portabilität eindeutig ein sehr wichtiges Ziel. Vollständige plattformübergreifende Portabilität lässt sich leichter erreichen, wenn die Prozess-VM in ein gesamtes Software-Framework integriert wird.

.centered.width60[

] .figure[Hochsprachenumgebungen. (a) Ein herkömmliches System, in dem plattformabhängiger Objektcode erzeugt, verteilt und ausfgeführt wird → starke Betriebssystem- und Rechnerabhängigkeit; (b) eine HLL-VM-Umgebung, in der portabler Zwischencode von einer plattformabhängigen virtuellen Maschine "ausgeführt" wird → schwache Betriebssystem- und Rechnerabhängigkeit.]

.reference[VM,Smith,2005] .centered.width80[

] .figure[Eine virtuelle Prozessmaschine ist Virtualisierungssoftware die einen Satz von Anweisungen auf Betriebssystem- und Benutzerebene übersetzt, und eine Plattform auf eine andere abbildet - quasi ein Kompiler mit laufzeitumgebung (runtime).]

Klassen der Hochsprachen Prozess-VMs:

Interpreter ("eat and think", Programmkode wird sofort ausgeführt)

Basic

Bash Shell

interpret(method) {
  while( code remains in sequence ) {
    read the next code from the sequence;
    if (the code needs more data){
      read more data from the sequence;
    }
    perform actions specified by the code;
  }
}

.algorithm[Der Interpreter]

Die Komplexität verbirgt sich im Schritt “Aktionen ausführen, die durch den Code definiert sind."

Wenn der Code beispielsweise eine neue Instanz einer Klasse erstellen soll, ruft der Interpreter den Garbage Collector (Speichermanager der VM) auf, um einen Speicherabschnitt zuzuweisen, den Speicherinhalt auf Null zu setzen, den Objektheader zu initialisieren (z.B. einen vtable-Zeiger der Klasse zu installieren) und dann gibt er den Objektzeiger zurück.

Wenn der Code eine virtuelle Methode aufrufen soll, muss der Interpreter die Methodenadresse herausfinden, einen Stapelrahmen vorbereiten, die Argumente auf den Stapel laden, die Methode aufrufen, was rekursives interpretieren bedeuten kann, und das Ergebnis zurückgeben.

Beim Aufruf einer Zielmethode/Funktion muss u.U. der Methodencode geladen und analysiert werden, wenn er sich noch nicht im Speicher befindet oder noch nicht initialisiert wurde.

Verzweigungen im Programmfluss (Sprünge) müssen ggfs. im Programmkode gesucht werden.

Besser: Bytecode oder Super Code Maschinen (abstrakte Software ISA)

int x; x=0; x=x+1; printf("%d",x);
if (x<0) { x=-x; } else { x=1; };
------------------------------------------------
ALLOCATE(4) VARIABLE(x) NUMBER(0) WRITE
VARIABLE(x) VARIABLE(x) READ NUMBER(1) ADD WRITE
STRING("%d") VARIABLE(x) READ CALL(printf)
VARIABLE(x) NUMBER(0) LT BRANCHZ(5)
VARIABLE(x) VARIABLE(x) READ NEG WRITE BRANCH(3)
VARIABLE(x) NUMBER(1) WRITE

.example[Beispiel Überstzung C Programm in lineare Instruktionssequenz für abstrakte Stackmaschine]

Abstrakte Maschine

.col1.width50[ Register Maschine

Die meisten Instruktionen werden mit einem Registersatz und direkten Speicherzugriff ausgeführt

Einen Stack gibt es meist nur für temporäre Varibalen

Das Instruktionsformat kann keinen, einen, zwei oder mehr Operanden enthalten (Konstante, Register, Speicheradresse) ] .col2.width50[ Stack Maschine

Die meisten Instruktionen werden über einen oder mehreren Stapelspeichern und einem Akkumulator ausgeführt

Eventuell kleiner Registersatz für temporäre Ergebnisse

Das Instruktionsformat enthält i.A. keine Operanden, diese befinden sich auf dem Stack, und auch Ergebnisse von Berechnungen werden wieder auf dem Stack abgelegt,. ]

Prozess-VM Interpreter Loop

Die Ausführung von abstrakten Instruktionen findet vollständig in Software statt, was langsam ist.

Es gibt i.A. einen Schleife die aus einem Programmcodespeicher Instruktionen liest, diese kodieren und schließelich ausführen muss.

CS[];
ip=0;
while(!terminated) {
  code=CS[ip]
  instr=decode(cmd)
  status=execute(instr)
  ip=next(instr,status)
}

.algorithm[Die Main Loop]

Instruktionsformat

Neben "binären" Bytecode (man spricht von seriellisierten komopakten Code) kann man auch nicht kodierte Instruktionsformate wählen die keine Dekodierung benötigen.

struct command {
  opcode   : opcodes[OPNAME],
  operands : operand [],
};
enum opcodes {
  OP1 = 1,
  OP2 = 2,
  ...
}
union operand {
  number_t constant;
  address_t address;
  address_t register;
}

.definition17-7-1

Instruktionsdekoder

Es muss eine spezifische opCode Funktion ausgeführt werden (Mikrooperationen). Wir gehen davon aus dass der opCode bereits dekodiert ist.

Wie bildet man einen (numerischen oder symbolischen) opCode auf eine Funktion effizient ab?

Eine lange if-then-else-if Kaskadenstruktur die den opCode mit Anweisungen verbindet und diese direkt ausführt ⇒ nicht konstante Laufzeit!

Eine switch-case Mehrfachauswahl. Zunächst kein große Verbesserung zu 1. da auf Maxhinenebene ein switch-case in eine Vielzahl von Vergleichsanweisungen mit bedingten und unbedingten Sprüngen umgesetzt wird (wie if-then-else-if)

Irgeneine Form eine Lookup Tabelle deren Index der opCode ist und der Tabelleneintgrag entweder eine Funktions- und interne Codesprungadresse ausgibt.

Hier müssen die opCodes (bestenfalls) aufeinander folgend nummeriert werden.

Hier gibt es konstante Dekodierungslaufzeit!

Prozess-VM Interpreter Loop (Option 2)

while() {
  op=CS[ip]
  if (op==OP1) { status=doop1(arguments); }
  else if (op==OP1) { status=doop1(arguments); }
  else if (op==OP2) { status=doop2(arguments); }
  ...
  else if (op==OPN) { status=doopN(arguments); }
  ip=next(ip,op,status);
}

.algorithm[Nicht effiziente Implementierung einer Instruktionsschleife in C mit linearer Ausführungszeit ohnen Möglichkeit der Optimierung durch Compiler - Option 1]

while() {
  op=CS[ip]
  switch (op.code) {
    case OP1: status=doop1(arguments); break;
    case OP2: status=doop2(arguments); break;
    ...
    case OPN: status=doop3(arguments); brak;
  }
  ip=next(ip,op,status);
}

.algorithm[Effiziente Implementierung einer Instruktionsschleife in C mit linearer Ausführungszeit (Mulitplexer), aber mit Möglichkeit für den Compiler mittels Tabellen in konstanter Laufzeit zu realisieren - Option 2]

Prozess-VM Interpreter Loop (Option 3)

enum opCode {
  OP1,
  OP2,
  ...
  MAXOP
};
void op1(..); ...
OPTABLE[MAXOP]={op1,op2,..}
...
while() {
  next=OPTABLE[op.code]
  next()
}

.algorithm[Effiziente Implementierung einer Instruktionsschleife in C mit konstanter (?) Ausführungszeit (LUT) - Option 3]

JIT Übersetzung

.compact[

Die Ausführung von Maschineninstruktionen in Software führt zu geringer Verarbeitungsgeschwindigkeit. Typisch werden 100 native Maschineninstruktionen für die Ausführung einer abstrakte Instruktion benötigt.

Die JIT-Kompilierung kompiliert einen Teil des Anwendungscodes zur Laufzeit in binären (nativen) Maschinencode und ermöglicht es der VM dann, den generierten Code direkt auszuführen, anstatt den ursprünglichen Teil des Anwendungscodes zu interpretieren.

Es ist, als würde man den gesamten Anwendungscode als eine einzige Superanweisung behandeln.

Man unterscheidet:

Übersetzung von Programmkode

Übersetzung von Daten(strukturen)

Die erste Frage an JIT ist, wie der zu kompilierende Anwendungscode ausgewählt wird?]

Eine Methode oder Funktion wird aufgrund ihrer genau definierten semantischen Grenze als Kompilierungseinheit betrachtet.

Bei Daten wird es schwieriger die semantische Grenze zu finden!

.centered.width45[

] .figure[Googles V8 Engine als dominanter Vertreter einer JIT HLL Prozess-VM. Bei numerischen Berechnungen erreicht V8 nahezu native Code Performanz (z.B. Matrixmultiplikation)]

.reference[DOI:10.1145/3067695.3082521] .centered.width80[

] .figure[Java-Quellcode wird zuerst zu Bytecode kompiliert und anschließend als nativer Code interpretiert oder ausgeführt. Umfangreiche Optimierungen sind für die JIT-Kompilierungsphase reserviert. JAVA JVM JIT erzeugt aber schlechteren Code (langsamer) wie V8 JIT und die Konzepte konzentrien sich auf Programmcode (und nicht Daten). Dafür werden eine Vielzahl von Prozessoren und Rechneranlagen unterstützt - V8 ist nur für ia32/ia64 optimiert!]

JIT benötigt Datentypen und Speicherinformation von Variablen, Funktionen und Funktionsargumenten!

Dynamisch typisierte Sprachen (JavaScript, Lua) können diese Informationen nicht liefern, sondern Typsignaturen müssen zur Laufzeit vom JIT Compiler abgeleitet werden. Zeitaufwändige Operation!

Und noch viel schlimmer: Datentypen von Variablen, Funktionsargumenten und Rückgabewerten könen sich zur Laufzeit ändern!

Es muss für jede Typsigantur einer Funktion (oder Datenstruktur/Objekt) eine eigene native Übersetzung angelegt werden. Teuer im Speicher!

Statisch typisierte Sptachen wie Java haben dieses Problem nicht, jedoch ist fraglich woher die Typinformationen kommen. Im Bytecode sind nur sehr begrenzte Informationen verfügbar.

Ein abschließendes Beispiel:

Der gute alte Basic Interpreter

Aufteilung eines Programms in Zeilen (ursprünglich mit vorangestellter Zeilennummer)

Es gibt Sprungbefehle wie goto <line>

Basic wird Zeile für zeile entweder interpretiert oder kompiliert

Bei einem Vorwärtssprung ist die Programmcodeadresse einer noch folgenden Zeile noch nicht bekannt, erst bei der Ausführung wenn das Programm vollständig ist.

Wenn aber die Zeile des Ziels ermittelt wurde kann diese durch eine Codeadresse ersetzt werden: JIT

10: a=1
15: goto 20
20: b=a
------------------------------------------------------------------------------------------------
0              2           4      5         7              9    10         12
LINENUMBER(10) VARIABLE(a) ASSIGN NUMBER(1) LINENUMBER(15) GOTO LINENUMBER(20) LINENUMBER(20) ...
LINENUMBER(10) VARIABLE(a) ASSIGN NUMBER(1) LINENUMBER(15) GOTO ADDRESS(12)    LINENUMBER(20) ...

Vertiefende Literatur

[1] J. E. Smith and R. Nair, Virtual Machines Versatile Platforms for Systems and Processes. Elsevier, 2005.

[2] X.-F. Li, Advanced Design and Implementation of Virtual Machines. CRC Press, 2017.

[3] Reinhard Wilhelm , H. Seidl, Übersetzerbau VirtuelleMaschinen. Springer, 2007.

[4] lain D. Craig, Virtual Machines. Springer London, 2006.

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Kommunikation und Synchronisaztion von Prozessen

Lernziele

Sie beurteilen die Eignung nachrichten- und speicherbasierter Verfahren zur Kommunikation zwischen parallelen Abläufen.

Sie analysieren Anwendungsprobleme von Unix-Pipes.

Sie unterscheiden zwei Implementierungsarten von Monitoren.

Sie erläutern das Grundprinzip eines Rendezvous paralleler Prozesse.

Sie entwickeln eigene Programme, die Berkeley-Sockets bzw. RPC zur rechnerübergreifenden Datenverarbeitung nutzen.

Taxonomie

Wie können Prozesse kommunizieren? Kommunikation ist örtlich, temporal und zustandsbasiert mit Daten.

IPC: Interprocess Communication

Ein Prozess wartet auf die Terminierung eines anderen (Eltern-Kind Gruppe);

Ein Prozess wartet auf Daten von einem anderen; Beispiel: Anforderung von Daten eines Web Servers über HTTP (remote) oder ein Prozess waretet auf Daten einer Datenbank; → Kooperation

Prozesse wollen quasi gleichzeitig (oder zeitlich sehr nahe beiandner) einen gemeinsamen Punkt im Programmfluss erreichen; Barriere

Prozesse greifen auf geteilte Ressourcen qusi parallel zu und der Zugriff muss sequenzialisiert werden; Wettbewerb

Prozesse warten auf zeitliche Ereignisse (ist es der gleiche Timer und mehrere Prozesse warten darauf dann ist das IPC)

.shadowbox.size09[

Synchrone versa asynchrone Kommunikation (mit Signalen oder Nachrichten)

Synchron bedeutet dass auf eine Antwort oder Bestätigung gewartet wird (mit Prozessblockierung und Prozesswechsel)

Asynchron bedeutet dass der Prozess nicht wartet und ggf. später durch eine Rückruffunktion (ähnlich Interruptbetrieb) eine Antwort erhält

Wir unterscheiden Signale (Events) und Nachrichten mit Daten; beide können zu einer Synchronisation von Prozessen führen;

Synchronisation kann Daten enthalten - aber kein Muss.

Es gibt eine Vielzahl von IPC Objketen. Auch eine Datei kann dazu gehören (Schreib/Lese Sperren)

Geteilte Speicherbereiche

Dateien, Pipes (Warteschlangen über das Dateisystem)

Semaphore

Nachrichten

Synchronisation führt i.A. zur Prozessblockierung und Prozesswechsel bzw. Warten. ]

Prozesse und Threads können lokal auf dem gleichen Rechner oder mithilfe der Netzwerksoftware des Betriebssystems in einer verteilten Umgebung kommunizieren.

.reference[bsdp] .centered.width70[

] .figure[Überblick über Synchronisation und Kommunikation]

Es wird unterschieden zwischen den zwei Gruppen »Synchronisation« und »Kommunikation«.

Bei der Kommunikation wird weiter unterteilt in »speicherbasierte Verfahren« und »nachrichtenbasierte Verfahren«.

Die Kommunikation unterscheidet sich von der Synchronisation, indem explizit Daten zwischen den Prozessen ausgetauscht werden und nicht nur eine Koordination im zeitlichen Ablauf oder Ressourcenzugriff realisiert wird.

.centered.width60[

] .figure[IPC-Verfahren aus Sicht der Programmierung]

.box[

Mit der Eigenschaft »systemgebunden« ist gemeint, dass das Verfahren als Funktionsaufruf der Betriebssystem-Programmierschnittstelle zur Verfügung steht.

Mit der Eigenschaft »sprachgebunden« wird der Fall umschrieben, bei dem ein Funktionsaufruf des Laufzeitsystems der Programmiersprache bzw. der Sprachbibliothek zur Verfügung steht. ]

Jedes der Programmierkonzepte weist bezüglich einer bestimmten Problemklasse seine Vorzüge auf.

Wesentlich ist jedoch, dass alle Konzepte funktional äquivalent sind, d.h., jedes Konzept kann in seiner Wirkungsweise durch jedes andere nachgebildet werden.

Nachrichtenbasierte Verfahren

Unter nachrichtenbasierten Verfahren fassen wir alle Mechanismen zusammen, die einen Datenaustausch zwischen Prozessen und Threads mithilfe von Systemfunktionen bewerkstelligen.

Allgemeine Aspekte

Datenabgrenzung bei der Kommunikation

Die Datenabgrenzung bei der Kommunikation hängt von der Form der ausgetauschten Daten ab. Diese ist bestimmend für mögliche Lösungen der Datenabgrenzung.

Datenaustausch mittels Nachrichten (message passing)

Datenaustausch mittels Datenströmen (streaming)

Datenaustausch mittels Paketen (packeting)

Beim Datenaustausch mittels Nachrichten haben wir eine abgegrenzte Datenmenge bei der Kommunikation in Form der Meldung (message).

Die Meldungsgröße kann systemabhängig fest oder variabel sein.

Kommen hingegen für den Datenaustausch Datenströme zur Anwendung, so ist die Nachrichtengrenze für den Sender und den Empfänger unsichtbar.

Die übertragbare Datenmenge ist theoretisch unbeschränkt.

Typischerweise werden die Dateisystemfunktionen read() und write() für eine stromartige Datenübertragung genutzt.

E/A ist auch Kommunikation, aber zunächst nur zwischen Prozessen und Geräten.

Beim Datenaustausch mittels Paketen kommen feste, oft standardisierte Datenformate zum Einsatz.

Sie sind im Rahmen von Kommunikationsprotokollen definiert (z.B. IP-Paketformat).

Für die Applikationsprogrammierung sind die Pakete nicht sichtbar (transparent).

Beim Übertragen der Pakete kann eine Fragmentierung (= Aufteilung in Teilpakete) und eine Defragmentierung (= Zusammensetzen aus Teilpaketen) stattfinden.

Dies wird versteckt (hidden) durch die Netzwerksoftware realisiert.

Der Datenaustausch mittels Paketen ist ein Thema der Computernetze und wird daher hier nicht weiter berücksichtigt. Die zwei verbleibenden Arten des Datenaustauschs wollen wir jedoch anhand von Realisierungsformen kennenlernen.

Synchronität bei der Kommunikation

Unter einer synchronen Kommunikation versteht man den Fall, dass der Sender warten muss, bis der Empfänger zur Entgegennahme der Daten bereit ist (Rendezvous). Bei der asynchronen Kommunikation läuft der Sender hingegen weiter, auch wenn der Empfänger gerade nicht für den Datenempfang bereit ist.

Bei der synchronen Kommunikation muss entweder der Sender (A1) oder der Empfänger (A2) warten, wenn nicht gerade das Senden und Empfangen zeitlich zusammenfallen.

Bei der asynchronen Kommunikation (B) kann hingegen das Senden sofort erfolgen, auch wenn der Empfänger nicht gerade für den Empfang bereit ist. Es ist also ein Puffer für die Zwischenlagerung von Daten, ein sogenannter Nachrichtenpuffer, notwendig.

.centered.width90[

] .figure19-2-3

Die asynchrone Kommunikation entkoppelt den Ablauf des Senders und des Empfängers und hilft, Geschwindigkeitsunterschiede in der Datenverarbeitung auszugleichen.

Genau genommen läuft bei der asynchronen Kommunikation der Sender erst dann weiter, wenn die Nachricht vollständig in einen Zwischenspeicher kopiert wurde.

Dies ist in der Abbildung durch die schräg verlaufenden Pfeile angedeutet und gilt auch für den Nachrichtenempfang.

send    (destination, &message);
receive (source,      &message);

.code[Programmierschnittstelle für die synchrone und asynchrone Nachrichtenkommunkation. Bei einer synchronen Art können beide Funktionen den Prozess blockieren. Ein Nachrichtenpuffer message trägt eine Identifikation (Textname, Deskriptor, Handle o.Ä.).]

Synchrone versa Asynchone Kommunikation

.reference[bsdp] .centered.width100[

] .table[Vergleich von synchroner und asynchroner Kommunikation]

Bei der synchronen Kommunikation übergibt der Sender die Nachricht direkt an den Empfänger. Typischerweise wird der Datenaustausch in dieser Situation in der Art eines Rendezvous erledigt. Nur wenn sowohl Sender als auch Empfänger bereit sind, kann die Datenübertragung stattfinden.

Synchrone versa Asynchone Ein- und Ausgabe

Auch bei der Grerätekommunikation kann A. vs S. unterschieden werden.

Bei der S. Kommunikation muss beim Lesen und Schreiben der Prozess warten bis die Operation ausgeführt wurde (und beim Lesen die Daten verfügbar sind).

Bei der A. Kommunikation kann der Prozess eine E/A Operation starten aber mit der Programmausführung fortsetzen. Der Prozess kann auch mehrere E/A Operationen starten.

Nur dann ist die Frage wie er mitbekommt wann E/A Operationen ausgeführt wurden (also auch Daten verfügbar sind).

Viele Programmiersprachen sind vom Paradigma her strikt sequenziell. Es ist nicht möglich mehrer Operationen parallel auszuführen.

A\. E/A ist daher in Virtuellen Maschinen eine gängige Möglichkeit auf der Programmierebene das strikt sequenzielle Paradigma aufrecht zu erhalten und unter der Motorhaube E/A Operationen mit Threads parallel auszuführen.

Mittels Callback Funktionen können dann erfüllte Operationen ihr Ergebnis wieder an das Programm übermitteln.

function callback(status,data) {
  if (!status)   print("Error:",status)
  else if (data) processData(data)
}
send('edu-9.de','mymessage1',callback) // Time 1
send('ag-0.de','mymessage2',callback)  // Time 2
receive('edu-9.de',callback)           // Time 3
receive('ag-0.de',callback)            // Time 4
print('I am finished with my computation. Waiting for I/O completion')
// Time 10: callback(...)
// Time 12: callback(...)

.definition[Asynchone E/A mit Callback Funktionen am Beispiel von JavaScript]

Das Problem: Die Callbackhölle, Verschachtelung, und schwierige Fehlerbehandlung.

Z\.B. wenn die zweite Send Operation auf Daten von der ersten Receive Operation warten muss ist eine Verlegung der Send Operation in den Callback Handler notwendig!

Vergleich von Kommunikationsverfahren

.reference[bsdp] .centered.width100[

] .table[Vergleich von Kommunikationsverfahren: Speicherbasiert versa nachrichtenbasiert.]

Ein Datenaustausch mittels Speicher kann mithilfe von Systemfunktionen erfolgen oder in Selbstverwaltung, nachdem ein gemeinsamer Speicherbereich eingerichtet wurde.

Bei der Selbstverwaltung muss in der Regel mittels Semaphoren die Koordinierung der beteiligten Threads bzw. Prozesse sichergestellt werden.

Somit stellt der Einsatz gemeinsamen Speichers meist eine Mischform dar, die sich für den schnellen Transfer großer Datenmengen besonders anbietet.

Geteilter Speicher

Threads besitzen ein inherentes geteiltes Specihermodell und können somit unmittelbar ohne weitere System- und Hilfsfunktionen über getilte Speichervariablen kommunizieren, anders als isolierte Prozesse.

Prozesse können sich über das Betriebssystem einen Speicherbereich teilen.

Dieser ist aber nur ein Bytepuffer ohne Datentyp.

Ein sinnvoller Datentyp kann mit C durch Typecasting erstellt werden.

void* create_shared_memory(size_t size) {
  // Our memory buffer will be readable and writable:
  int protection = PROT_READ | PROT_WRITE;
  // The buffer will be shared (meaning other processes can access it), but
  // anonymous (meaning third-party processes cannot obtain an address for it),
  // so only this process and its children will be able to use it:
  int visibility = MAP_SHARED | MAP_ANONYMOUS;
  // The remaining parameters to `mmap()` are not important for this use case,
  // but the manpage for `mmap` explains their purpose.
  return mmap(NULL, size, protection, visibility, -1, 0);
}
...
void* shmem = create_shared_memory(16);
int * counter = (int*)&shmem[0];
*counter=0;
int pid = fork();
if (pid == 0) { // child process
  *counter++;
} else {        // parent process
  *counter--;
}

.example[SHM für zwei Prozesse, hier als "Vererbung" durch das Forking vpn Kindprozessen]

Welchen Wert hat die Zählervariable am Ende? Gibt es Gebote? Wir nehmen einen Mehrkernrechner an.

.left[]

Keine Ahnung. Die möglichen Ergebniswerte von counter können {-1,0,1} sein. Das Ergebnis ist nicht deterministisch.

Und das ist das Problem bei SHM Kommunikation: Es gibt keine Programmflusssteuerung! Diese muss dann zusätzlich z.B. mit Semaphoren erfolgen.

.reference[Andrew Forney] .centered.width60[

] .figure[Vergleich von Kommunikationsverfahren: Nachrichtenbasiert versa speicherbasiert.]

Verbindungsorientierung

Die Verbindungsorientierung spielt eine Rolle beim asynchronen Datenaustausch, wenn wiederholt Datenübertragungen mit gleichen Kommunikationspartnern anstehen.

Unabhängig davon, ob man streng abgegrenzte Nachrichten oder einfach Datenströme austauschen will, können zwei Verbindungsformen unterschieden werden.

.shadowbox.smaller[

Arbeitet man verbindungsorientiert (connection-oriented), so muss zuerst ein logischer Verbindungskanal eingerichtet werden. Dazu muss die Identität des Empfängers bekannt sein.

Ist die Verbindung aufgebaut, so werden Daten an den Verbindungskanal übergeben, der eine Kennung trägt.

Die Angabe der Empfängeridentität ist dann nicht mehr nötig.

Sind alle Daten ausgetauscht, was typischerweise nach einer Vielzahl an einzelnen Datentransfers zutrifft, wird die Verbindung explizit wieder abgebaut.

Dies kann über eine »disconnect«-Funktion oder ein Schließen des Verbindungskanals erfolgen.

Je nach Form des Verbindungskanals sind nur zwei Kommunikationspartner erlaubt, oder es sind auch m:n-Konstellationen möglich. ]

Der logische Verbindungskanal kann zu jedem Zeitpunkt nur einen Datentransfer in eine Richtung (unidirektional) oder gleichzeitig in beide Richtungen erlauben (bidirektional).

Der unidirektionale Datenaustausch (mit alternierender Richtung) wird als Halbduplex-, der bidirektionale Datenaustausch als (Voll-) Duplexbetrieb bezeichnet.

Ist generell nur eine Kommunikationsrichtung möglich, so handelt es sich um einen Simplex-Betrieb.

.centered.width90[

] .figure[Halb- und Vollduplex-Betrieb]

Ein verbindungsloser (connectionless, datagram-oriented) Datenaustausch benutzt für jeden einzelnen Datentransfer die Identität des Empfängers als Adresse.

Es können sofort Daten übertragen werden, ohne dass zuerst ein Verbindungskanal angelegt werden muss.

.compact[

Diese Kommunikationsform empfiehlt sich, wenn nur einzelne Datentransfers, aber möglicherweise an viele verschiedene Empfänger, nötig sind.]

.reference[bsdp] .centered.width100[

] .table[Vergleich von verbindungsorientierter und verbindungsloser Kommunikation.]

Empfängeradressierung

Neben der Frage wie zwei Prozesse miteinander kommunizieren können und sich "finden" kann man adressierte Kommunikation in verschiedene Klassen einteilen.

Diese Klassen bestimmen die Empfänger von Nachrichten (lösen aber nicht das Problem der eindeutige Adressierung der Prozesse an sich)

.centered.width100[

] .figure[Empfängeradressierung bei der Kommunikation]

Bei einem unicast (1:1) wird genau ein bestimmter Empfänger angesprochen. Bei einem multicast (1:m) wird eine definierte Gruppe an Empfängern (m) adressiert. Der anycast übermittelt Daten an irgendeinen Empfänger, mindestens einen einzigen (1:1..n). Der broadcast schließlich geht an alle Empfänger (n), die sich finden lassen (1:n). Welche Empfängeradressierung eine Lösung unterstützt, ist implementationsabhängig. Meistens ist es aber nur der unicast.

Prioritäten

Erfolgen bei einem Datenaustausch alle Übertragungen mit der gleichen Priorität, so werden sie bei einer Zwischenpufferung in der FIFO-Reihenfolge abgelegt.

Können unterschiedliche Prioritäten benutzt werden, so ist es möglich, neue Daten vor alten Daten in den Nachrichtenpuffer einzutragen. Bei Datenströmen sind Prioritäten meistens nicht sinnvoll, daher ist diese Möglichkeit in der Regel dem Meldungsaustausch vorbehalten.

Im einfachsten Fall sind zwei Prioritäten definiert, nämlich »normal« und »dringend«. Anspruchsvollere Lösungen können feinere Prioritätsabstufungen anbieten.

Anwendungsszenario Client/Server

Der gepufferte Nachrichtenaustausch eignet sich zur Realisierung eines Client/Server-Betriebs.

.shadowbox[

Client: Prozess oder Thread, der eine Dienstleistung beansprucht (Auftraggeber).

Server: Prozess oder Thread, der eine Dienstleistung erbringt (Auftragnehmer). Beispiele: Dateiverzeichnis liefern, Faxmeldung übermitteln.

Funktionsweise: Ein Client beansprucht vom Server eine Dienstleistung und sendet dem Server deshalb eine Meldung. Wenn der Server die notwendigen Arbeiten erledigt hat, liefert er mittels einer Meldung die Antwort an den entsprechenden Client zurück.

Absenderkennung: Da Meldungen von sich aus in der Regel keine Absenderangaben enthalten, muss die Absenderadresse bzw. die Bezeichnung der Nachrichtenwarteschlange für die Rückmeldung in der Meldung selbst enthalten sein. ]

.centered.width90[

] .figure[Client/Server-Prinzip mittels Meldungen]

Pipe als Verbindungskanal zweier Prozesse

Eine namenlose Pipe für die Kommunikation zweier Prozesse anzulegen erfordert insgesamt drei Prozesse und die folgenden Schritte:

.shadowbox[

Der Elternprozess erzeugt die Pipe durch Aufruf von pipe(). Er erhält für beide Enden der Pipe die vom Betriebssystem zugeteilten Deskriptoren.

Der Elternprozess erzeugt nun zwei Kindprozesse (oder er selbst ist ein Kommunikationspartner).

Diese erhalten automatisch Kopien der Pipe File Deskriptoren.

Jeder Prozess schließt mittels close() die Enden der Pipe, die er nicht braucht.

Der Elternprozess schließt beide Enden, Kindprozess 1 das »obere« Ende und Kindprozess 2 das »untere« Ende.

Nun kann Kindprozess 1 mittels write() Daten über die Pipe an Kindprozess 2 senden, der diese mittels read() entgegennimmt. ]

int main()
{
  int fds[2];                             // Für Pipe-Dateideskriptoren
  char *text = "Hallo da!\n";             // Zu transferierender Text
  char buffer [5];                        // Lesepuffer
  int count, status;                      // Leseanzahl bzw. Endstatus
  pipe(fds);                              // Unnamed pipe erzeugen
  if (fork() == 0) {                      // Kindprozess 1:
     dup2(fds[1], 1);                     // stdout auf Pipe-Eingang legen
     close(fds[0]);                       // Pipe-Ausgang schließen
     write(1,text,strlen(text)+1);        // Text in Pipe schreiben
  } else if (fork() == 0) {               // Kindprozess 2:
     dup2(fds[0], 0);                     // Pipe-Ausgang auf stdin legen
     close(fds[1]);                       // Pipe-Eingang schließen
     while ((count = read(0, buffer, 4)) != 0) { // Wiederholt auslesen
        buffer[count]= 0;                 // »string terminating zero«
        printf("%s", buffer);             // Laufend ausgeben
     }
  } else {                                // Elternprozess:
     close(fds[0]);                       // Pipe-Ausgang schließen
     close(fds[1]);                       // Pipe-Eingang schließe
     wait(&status);                       // Warte auf erstes Kind
     wait(&status);                       // Warte auf zweites Kind
  }
  exit(0);
}

.example[Ein einfaches Zwei-Prozess-Kommunikationssystem über Pipers]

Benannte Pipes

Hier werden die Pipes als "virtuelle" Dateien über ds Dateisystem von Prozessen mit üblichen Read und Write Operationen geteilt.

.centered.width90[

] .figure[Client/Server-Konstellation mit benannten Unix-Pipes]

Message Queues

Die Unix-Pipes sind gut geeignet für die Übertragung von Daten, wenn eine klare Abgrenzung der einzelnen gesendeten Daten voneinander nicht notwendig ist (Bytestrom). Stehen gut definierte Meldungen im Vordergrund, so bieten sich die Message Queues an.

.box.smaller[

Sie übertragen Meldungen unterschiedlicher Länge.

Die einzelnen Meldungen sind so gegeneinander abgegrenzt, dass beim Lesen der Message Queue jeweils genau eine Meldung entnommen wird.

Der Inhalt und die Länge einer einzelnen Meldung sind frei wählbar, wobei die Meldungsgröße nicht den für die Message Queue festgelegten Maximalwert überschreiten darf (attr.mq\_msgsize).

Beim Auslesen einer Meldung wird dem Empfänger die genaue Länge der Meldung mitgeteilt.

Die in der Message Queue gepufferten Meldungen sind entsprechend ihrer Meldungspriorität sortiert, wobei Meldungen der gleichen Priorität eine FIFO-Reihenfolge einnehmen.

Die Meldungspriorität wird durch den Sender zusammen mit der Meldung der Message Queue übergeben. ]

.reference[bsdp] .centered.width80[

] .figure[Aufbau einer Message Queue]

Eine Meldung besteht immer aus den zwei Teilen msg_typ und msg_txt, die den Meldungstyp und den Meldungsinhalt repräsentieren. Wie in einer eigenen Applikation ein Meldungstyp deklariert werden kann, zeigt nachfolgendes Beispiel:

struct meine_nachricht {
  long msg_typ;
  char msg_txt[100];
}

Mutualer Ausschluss und Semaphor

Bisher wurde nachrichtenbasierte Kommunikation eingeführt. Nebem dem Nachrichtenaustausch (Kooperation) ist aber auch die Konfliktlösung bei Wettbewerb wichtig, also eine sequenzielle Synchronisation.

Vorteilhafter als eine Lösung mit Selbstverwaltung ist der Einsatz von sogenannten Semaphoren. Es handelt sich dabei um Betriebssystem- und Synchronisationsobjekte für die Absicherung und Synchronisation von Prozessen bzw. Datenkonsistenz. Der Semaphor und ihre Operationen P und V wurden 1962 von E. Dijkstra erstmals systematisch entwickelt und dargestellt

Wir unterscheiden wieder zwei Arten der Prozessinteraktion:

Kooperation, d.h. Produzenten-Konsumenten Anwendungen

Wettbewerb, d.h. Konflikte bei der Nutzung von geteilten Ressource.

Der Semaphor kann zur Synchronisation für beide Arten verwenden werden.

.reference[bsdp] .centered.width100[

] .figure[Semaphor mit einer Identität (Instanzenbezeichnung), den Operationen P/V und den Attributen Zähler (für Marken) und Warteschlange (für Prozesse)]

Der Semaphor implementiert einen geschützten Zähler (Marken, Tokens) mit einer Prozesswarteschlange:

Invariante: Der Zähler darf nie negativ werden

Prozesse werden blockiert die diese Invariante verletzen würden

Es gibt zwei Operationen: V(+) oder Up und P(-) oder Down. Beide Operationen verändern den Zähler um den Wert 1.

.centered.width60[

] .centered.width60[

] .figure[(Oben) Definition der P-Operation: Der aufrufende Prozess erhält entweder sofort eine Marke oder muss warten, bis eine verfügbar wird (dazu wird er an die Warteschlange des Semaphors angefügt). (Unten) Definition der V-Operationen: Der aufrufende Prozess übergibt eine Marke entweder dem Semaphor oder einem am Semaphor wartenden Prozess, der damit wieder weiterlaufen kann.]

Semaphortypen

Es existieren zwei Grundtypen von Semaphoren:

Binärer Semaphor (binary semaphore, mutex): Es sind genau 0 oder 1 Marke erlaubt ⇒ Lösung des mutualen Ausschlussproblems (entweder oder), Mutex Lock

Zählsemaphor (counting semaphore, general semaphore): Es sind beliebig viele Marken erlaubt, dies stellt die universellere Form dar.

Unteilbarkeit von P/V

Die Unteilbarkeit der P- und V-Operationen muss durch ihre Implementierung sichergestellt werden. Mögliche Lösungen dafür sind:

Ausschalten der Prozessorneuzuteilung für die Operationsdauer

Vorübergehend höhere Priorität als alle anderen Prozesse

Ausschalten aller Interrupts (= Gewaltlösung) während der P- und V-Operationen; ist aber nur für Einprozessorsysteme ausreichend

Verwendung atomarer Prozessorinstruktionen (v.a. der TAS-Befehl, siehe S. 201)

Aktives oder passives Warten

Eine zweite Implementierungsfrage betrifft die Art des Wartens an der Semaphor-Warteschlange.

In der Regel heißt dies, dass der Prozess für die Dauer des Wartens blockiert wird, d.h. die CPU freigibt und in den Zustand »Wartend« wechselt (passives Warten). Dies erlaubt anderen Prozessen die Wartezeit zu nutzen.

Unter speziellen Bedingungen kann jedoch ein aktives Warten die bessere Lösung sein:

Sehr kurze Wartezeit: Der Zeitbedarf für ein Blockieren kann größer sein als für ein aktives Warten. Das Blockieren und spätere Aufwecken des Prozesses beinhaltet zwei Prozessumschaltungen, für die eine zwar kleine, aber doch gegebene Zeitdauer nötig ist.

Multiprozessorsysteme: Da eine echte Parallelität möglich ist, wird in verschiedenen Situationen die Wartezeit sehr klein sein. Dies macht ebenfalls ein Blockieren unattraktiv.

Für diese speziellen Situationen bieten manche Betriebssysteme einen speziellen Semaphortyp an, der als Spinlock bezeichnet wird (ein passender deutscher Begriff fehlt).

In einer ersten Variante wartet ein Spinlock beliebig lange in einer aktiven Warteschlange.

Dies ist unattraktiv, wenn nicht bekannt ist, wie lange das Warten dauert.

Eine zweite Variante wartet in einer aktiven Warteschleife, kennt dafür aber ein Zeitlimit.

Wird dieses überschritten, so wird ein passives Warten, d.h. ein Blockieren des Prozesses, durchgeführt.

Wartereihenfolge

Eine dritte Implementierungsfrage betrifft die Einreihung in der Semaphor-Warteschlange.

Erfolgt dies nach dem FIFO-Prinzip, so ist sichergestellt, dass jeder Prozess in endlicher Zeit drankommt, sofern die einer P-Operation folgende V-Operation stets in endlicher Zeit ausgeführt wird.

Wird für die Einreihung die Prozesspriorität benutzt, so besteht die Gefahr, dass niederpriore Prozesse nie drankommen (verhungern), wenn hochpriore Prozesse genügend oft die P-Operation aufrufen.

Anwendung der Semaphoren

Eine hervorragende Bedeutung fällt Semaphoren bei der Absicherung kritischer Bereiche zu, indem sie den wechselseitigen Ausschluss sicherstellen.

Da ein kritischer Bereich jederzeit von maximal einem Prozess betreten werden darf, kommt ein binärer Semaphor zum Einsatz.

Betritt ein Prozess einen kritischen Bereich, so sperrt er diesen mithilfe des Semaphors ab.

Ein so eingesetzter Semaphor wird manchmal als Sperre (lock), die P- und V-Operationen als Sperren (lock) und Freigeben (release) bezeichnet.

Kritischer Bereich bedeutet der Schutz von Daten und Wahrung der Konsistenz von Daten. Nicht der Programmcode muss geschützt werden, sonder die Daten die er verarbeitet.

Konkret beinhaltet die Absicherung eines kritischen Bereichs die folgenden Elemente:

Den Semaphorzähler mit 1 initialisieren (eine Berechtigungsmarke bereitstellen, d.h. kritischer Bereich als frei markieren)

Die P-Operation vor dem Eintritt in den kritischen Bereich ausführen (= reservieren bzw. Berechtigungsmarke beziehen)

Die V-Operation beim Austritt aus dem kritischen Bereich ausführen (= freigeben bzw. Berechtigungsmarke an Semaphor zurückgeben)

Das Betriebssystem blockiert den Prozess bei der P-Operation, wenn die Berechtigungsmarke bereits vergeben ist

Das Betriebssystem weckt einen wartenden Prozess bei der nächsten V-Operation, die auf diesem Semaphor ausgeführt wird (da Berechtigungsmarke nun verfügbar ist)

.centered.width100[

] .figure[Absicherung kritischer Bereiche mittels Semaphor (Beispiel)]

Eine weiter Anwendung ist Kooperation ind Produzenten-Konsumenten Systemen mit einem Datenpuffer (Kapazität N Datensätze).

Eine Menge von Prozessen P schreibt Daten in den Puffer (atomar)

Ein oder mehrere Prozesse C lesen dei Daten aus dem Puffer (atomar) für weitere Verarbeitung

Es gibt zwei Situationen die mit Semaphoren abgesichert werden können:

Der Puffer ist leer ⇒ Semaphor Empty(0)

Der Puffer ist voll ⇒ Semaphor Full(N)

Die Operationen (Write, Read) verändern dann die Semaphoren und können blockieren (; ist ein Zeitschritt):

Read() ⇒ P(Empty) ; V(Full)

Write() ⇒ P(Full) ; V(Empty)

.centered.width80[

] .figure[Synchronisierte Datenwarteschlange mit Semaphoren für Produzenten-Konsumenten Systeme]

Und es geht schief: Das Problem der fünf dinierenden Philosophen ist ein Beispiel für Deadlocks (Verklemmung) in verteilten und parallelen (asynchronen) Systemen mit Kommuniaktion!

.col1[

] .up.col2[ - 5 Philosophen an einem Tisch - 5 Gabeln, jeweils eine zwischen zwei Philosophen - Ein Philosoph braucht zwei Gabeln zum Essen! - Eine Gabel wird durch eine Semaphore repräsentiert (atomare Ressource, Startwert des Zählers ist 1) ]

Implementierung mit Kanälen

.compact[

Bisher haben wir nur Kommunikationskanäle wie Sockets oder Pipes kennen gelernt. Mit denen kann unter bestimmten Bedingungen ein Semaphor implementiert werden.

Wir nehmen an wir haben ein Prozesssystem aus N+1 Prozessen.

Ein Prozess soll den Semaphor implementiert und überwachen.

Die anderen N Prozess wollen sich über den Semaophor mittels Nachrichtenaustausch synchronisieren. .size09[

Dazu wird wie in der Übung ein Request und eine Acknowledge Kanale (dort Pipe) verwendet.

je nachdem wie es mit der Atomarität von Nachrichten auf dem Kanel aussieht können Kanäle von Prozessen geteilt werden oder nicht

Also zwei Prozesse P1 und P2 schreiben eine zweistellige Nachricht 1+ und 2- in den Requestkanal. Dann muss am anderen Ende entweder 1+2- oder 2-1+ gelesen werden.

Beim Acknowledge Kanal (da N Leser) muss je einer pro Prozess existieren (es sei denn der Kanal ist wieder atomar in seinen Nachrichten und arbeitet im 1:N Multicasting, d.h. alle Leser bekommen alle Nachrichten). ]]

.smaller[Protokoll: <ID>+ für V und <ID>- für P Operation und ID als Prozessnummer 1,2,3,..,N]

channel req,ack1,ack2,... ;   waiters = [] ; counter = INIT
while (!terminate) {
  received=read(req)
  messages=split(received,2) // 1+2- => [1+,2-]
  for(message of messages) {
    switch (message[1]) {
      case '+':
        if (length(waiters)) {
          next=head(waiters); waiters=tail(waiters)
          write(ack[next],'!')
        } else {
          counter++;
        }
        write(ack[Number(message[0]),'!') ; break
      case '-':
        if (counter>0) {
          counter--;
          write(ack[Number(message[0]),'!')
        } else {
          push(waiters,Number(message[0]))
        } ; break
    }
  }
}

.algorithm[Der Semaphor Master Prozess]

function P() {
  write(req,String(myid)+'-')
  reply=read(ack[myid])
}
function V() {
  write(req,String(myid)+'+')
  reply=read(ack[myid])
}

.algorithm[Der Semaphor Klienten Prozess]

Zusammenfassung

.box[

Neben impliziter Synchronisation wie z.B. beim Lesen aus Dateien bedarf es in Multiprozesssystemen explizite Synchronisation

Ein beispiel ist der Semaphor der:

mutualen Ausschluss (Wettbewerb)

Kooperation ermöglicht.

Pipes sind i.A. inhärent synchron (geht aber auch asynchron)

Ein Semaphroe kann mit Kanälen und einem zentralen Masterprozess realisiert werden

Prozessblockierung (also Prozesswechsel) ist wesentliches Mermal von Synchronsiation

Es kann auch schief gehen wenn Prozesse mit mehreren Synchronisationsobjekten wie Semaphoren umgehen (Deadlock) ]

.title[

Grundlagen der Betriebssysteme

Praktische Einführung mit Virtualisierung

Stefan Bosse

Universität Koblenz - FB Informatik

]

Zusammenfassung

Gerätemanagement

device.c (Gerätetreiberschnittstelle)

pipe.c (Queues zwischen Prozessen)

Dateisystem

diskfs.c

fs.c (allgemeine Dateisystemschnittstelle)

cdromfs.c

(Block) Cache

bcache.c

Ein- und Ausgabe (eher minimale Standardblibliothek für den Kernel)

console.c

`graphics.c'

printf.c

string.c ] ]

Kernel

Weitere Bestandteile der nullten Schicht sind hardwarenahe Komponenten:

Gerätetreiber

console.c

graphics.c

pic.c (Interruptcontroller)

ata.c (Parallele Geräteschnittstelle für Blockgeräte, HDD, CDROM)

clock.c (Timer)

keyboard.c (Tastatur)

serical.c (Serielle UART Schnittstelle)

rtc.c (Real Time Clock ⇒ Systemuhr)

Datenstrukturen

list.c

hash_set.c

Abbildung 36-1. Schichtenaufbau des monolithischen Basekernel

Standardbibliothek

Die Standardbibliothek implementiert häufig vorkommende Funktionen für:

Ein- und Ausgabe

Dateioperationen

Speichermanagement (oder eine Schnittstelle dazu)

Systemaufrufe

Sie wird von allen Nutzerprogrammen (user space) verwendet. Funktionen für Netzwerkzugriff (Sockets) und vieles mehr kann noch enthalten sein.

Systemaufrufschnittstelle

C syscalls.s

Assembler syscall.S

Speichermanagement (USer space)

malloc.c

Ein- und Ausgabe

stdio.c

Sonstiges

user_start-c (Start eines Benutzerprogramms)

errno.c (Fehlerbehandlung)

Zeichenketten

string.c

Allgemeine Funktionen

stdlib.c (Programmausführung, Zeit, Systeminformation usw.)

Konsole und Grafiksystem (Fenster)

nwindow.c

Benutzerpogramme

Im wesentlichen gibt es nur eine Shell. Die Aufgabe einer Shell ist:

Interaktive eingegebene Befehle von einer Kommandozeile unmittelbar ausführen ⇒ Nutzerbetrieb

Shellskripte (kleine Programme als Sammlung von Shellbefehlen) ausführen ⇒ Stapelbetrieb

Dabei sollen zwei primäre Schnittstellen zum Betriebssystem auf programmatische und interaktive Weise zur Verfügung gestellt werden (Operationalität):

Dateisystem, Textdatein

Prozesse und Programme

Ein-und Ausgabe (Terminal)

Shell

Die meisten gängigen Unix-Shells bieten die folgenden Funktionen:

[Wikipedia]

Starten von Kommandos

Dateinamen-Wildcards (globs) als Kommandoargumente

Bedingungen (if, case) und Schleifen (while, for)

interne Kommandos (cd, read)

interne Variablen ($HOME)

Manipulation der Umgebungsvariablen für die neuen Prozesse

Ein-/Ausgabeumlenkung

Starten mehrerer Prozesse, Verkettung über Pipes

Starten von Prozessen im Hintergrund

Moderne Shells können darüber hinaus:

Vervollständigung von Kommandos, Dateinamen und Variablen (completion system)

Editieren der Kommandozeile (command line editing)

Wiederholung und Editieren früherer Kommandos (command history)

Stoppen und erneutes Starten von Prozessen (job control)

Verschieben von Prozessen aus dem Vordergrund in den Hintergrund und umgekehrt (job control)

Eingebautes Kommando zur Durchführung von Berechnungen ($((2+2)))

Eingebautes Kommando zum Testen von Dateieigenschaften (test)

Skripte

Shellskripte sind Text-Dateien und stellen kleine Programme dar.

Sie werden vom Anwender geschrieben und beim Aufruf von der Shell gelesen und ausgeführt.

Muss man z. B. immer wieder in seinem Arbeitsalltag fünf Befehle nacheinander in die Shell eintippen, kann man das vereinfachen, indem man diese Befehle in einem Skript sammelt und dann nur noch das ganze Skript aufruft.

Die Shell liest das Skript aus und führt die Befehle entsprechend aus. Zu beachten ist, dass man alle Befehle, die man „händisch“ in die Shell eingibt, auch über ein Skript ausführen kann und umgekehrt.

Dienstprogramme

Shell

shell.c (sehr einfacher Shell Intepreter, kein Skripting)

Prozess Manager

Nur Prozessinformation durch procstat.c

Steuerung von Prozessausführung (stoppen) gibt es hier nicht

Systeminformationen

systats.c

Dateisystemedienste

livestat.c (Information über Gerätetreiber/Gerät und Cache)

copy.c (Datei kopieren)

Fenstermanager

manager.c (Grafiksystem, Verwaltung von Fenstern)

Der Erzeugungsprozess

Es müssen folgende Komponenten von Quellcode (C/Assembler) in binäre Programmdaten übersetzt werden:

Der Kernel (kernel) und Bootloader (bootblock)

Die Standardbibliothek (baselib.a)

Hier nur als Archiv (a), nicht als geteilte (shared) Bibliothek (dll/so) baselib.a

Alle Programme werden statisch (d.h. vollständig enthaltend) kompiliert und erzeugt.

Die Nutzerprogramme

Für den gesamtn Übersetzungsprozess wird ein GCC Kompiler verwendet. Dieser enthält neben dem Präprozessor cpp, den eigentlichen Kompiler cc noch einen Assembler as und einen Linker ld. Weitere Hilfsprogramme vor allem für das ELF Format werden benutzt (objdump, objcopy, ar).

Es muss GCC mit 32 Bit x86 ELF (Executable Link Format) verwendet werden. Andere Prrogrammformate und Prozessorarchitekturen führen zu einem nicht funktionieren Basekernl OS (sowohl Kernel als auch Nutzerprogramme).

Der gesamte Übersetzungsprozess wird mit dem make Programm gesteuert.

make (englisch für machen, erstellen) ist ein Build-Management-Tool, das Kommandos in Abhängigkeit von Bedingungen ausführt. Es wird hauptsächlich bei der Softwareentwicklung als Programmierwerkzeug eingesetzt.

make liest eine teils prozedurale und teils deklarative Anweisungsdatei (Makefile), in dem die Abhängigkeiten des Übersetzungsprozesses von Programmen formalisiert erfasst sind.

Diese Formalisierung beschreibt, welche Quelltextdateien auf welche Weise durch den Compiler oder durch andere Programme zu welchen Objektdateien bzw. Ergebnissen verarbeitet werden, bzw. welche Objektdateien vom Linker zu Programmbibliotheken oder ausführbaren Programmen verbunden werden.

Alle Schritte erfolgen unter Beachtung der im Makefile erfassten Abhängigkeiten.[2]

Wenn das Makefile vom make-Programm abgearbeitet wird, wird eine Umwandlung etwa einer Quelldatei in eine Objektdatei nur dann vorgenommen, wenn die Quelldatei neuer als die bereits vorliegende Version der Ergebnisdatei ist, bzw. wenn keine Ergebnisdatei vorhanden ist.

Bei der Entwicklung großer Programmpakete mit vielen Quelldateien und vielen ausführbaren Programmen entfällt so die Notwendigkeit, bei einer Reihe kleiner Veränderungen alle Kompilierungen erneut durchzuführen.

Makefile (main)

include Makefile.config

LIBRARY_SOURCES=$(wildcard library/*.c)
LIBRARY_HEADERS=$(wildcard library/*.h)
USER_SOURCES=$(wildcard user/*.c)
USER_PROGRAMS=$(USER_SOURCES:c=exe)
KERNEL_SOURCES=$(wildcard kernel/*.[chS])
WORDS=/usr/share/dict/words

.PHONY: build-kernel build-library build-userspace build-cdrom-image

all: build-cdrom-image

build-kernel: kernel/basekernel.img

build-library: library/baselib.a

build-userspace: $(USER_PROGRAMS)

build-cdrom-image: basekernel.iso

kernel/basekernel.img: $(KERNEL_SOURCES) $(LIBRARY_HEADERS)
	cd kernel && make

library/baselib.a: $(LIBRARY_SOURCES) $(LIBRARY_HEADERS)
	cd library && make

$(USER_PROGRAMS): $(USER_SOURCES) library/baselib.a $(LIBRARY_HEADERS)
	cd user && make

image: kernel/basekernel.img $(USER_PROGRAMS)
	rm -rf image
	mkdir image image/boot image/bin image/data
	cp kernel/basekernel.img image/boot
	cp $(USER_PROGRAMS) image/bin
	if [ -f ${WORDS} ]; then head -2000 ${WORDS} > image/data/words; fi

basekernel.iso: image
	${ISOGEN} -input-charset utf-8 -iso-level 2 -J -R -o $@ -b boot/basekernel.img image

disk.img:
	qemu-img create disk.img 10M

run: basekernel.iso disk.img
	qemu-system-i386 -cdrom basekernel.iso -hda disk.img

debug: basekernel.iso disk.img
	qemu-system-i386 -cdrom basekernel.iso -hda disk.img -s -S &

clean:
	rm -rf basekernel.iso image disk.img
	cd kernel && make clean
	cd library && make clean
	cd user && make clean

Makefile (config)

KERNEL_CCFLAGS=-Wall -c -ffreestanding -fno-pie -g -std=gnu99

# These settings select the native compiler,
# which is likely to work on native linux-x86.
#
CC=gcc -m32
LD=ld -melf_i386
#LD=ld -arch i386
AR=ar
OBJCOPY=objcopy -R .note.gnu.property
ISOGEN=genisoimage

# If you are compiling from another platform,
# then use the script build-cross-compiler.sh
# add cross/bin to your path, and uncomment these lines:
#CC=i686-elf-gcc
#LD=i686-elf-ld
#AR=i686-elf-ar
#OBJCOPY=i686-elf-objcopy

# If building on OSX, then install mkisofs via ports or brew
# and uncomment this:
#ISOGEN=mkisofs

Makefile (kernel)

include ../Makefile.config

KERNEL_OBJECTS=kernelcore.o main.o console.o page.o keyboard.o mouse.o event_queue.o clock.o interrupt.o kmalloc.o pic.o ata.o cdromfs.o string.o bitmap.o graphics.o font.o syscall_handler.o process.o mutex.o list.o pagetable.o rtc.o kshell.o fs.o hash_set.o diskfs.o serial.o elf.o device.o kobject.o pipe.o bcache.o printf.o is_valid.o window.o

basekernel.img: bootblock kernel
	cat bootblock kernel /dev/zero | head -c 1474560 > basekernel.img

kernel: kernel.elf
	${OBJCOPY} -O binary $< $@

bootblock: bootblock.elf
	${OBJCOPY} -O binary $< $@

kernel.elf: ${KERNEL_OBJECTS}
	${LD} ${KERNEL_LDFLAGS} -Ttext 0x10000 ${KERNEL_OBJECTS} -o $@

bootblock.elf: bootblock.o
	${LD} ${KERNEL_LDFLAGS} -Ttext 0 $< -o $@

%.o: %.c
	${CC} ${KERNEL_CCFLAGS} -I ../include $< -o $@

%.o: %.S
	${CC} ${KERNEL_CCFLAGS} -I ../include $< -o $@

clean:
	rm -rf basekernel.img *.o *.elf kernel bootblock bootblock.o

make it all

cd kernel && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/kernel'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bootblock.S -o bootblock.o
ld -melf_i386  -Ttext 0 bootblock.o -o bootblock.elf
objcopy -R .note.gnu.property -O binary bootblock.elf bootblock
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kernelcore.S -o kernelcore.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include main.c -o main.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include console.c -o console.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include page.c -o page.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include keyboard.c -o keyboard.o
In file included from keyboard.c:42:
keymap.de.pc.c:5:7: warning: multi-character character constant [-Wmultichar]
 {'3', '§', '3', 0},
       ^~~~
keymap.de.pc.c:5:7: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49831’ to ‘-89’ [-Woverflow]
keymap.de.pc.c:13:2: warning: multi-character character constant [-Wmultichar]
 {'ß', '?', 'ß','\\'},
  ^~~~
keymap.de.pc.c:13:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50079’ to ‘-97’ [-Woverflow]
keymap.de.pc.c:13:13: warning: multi-character character constant [-Wmultichar]
 {'ß', '?', 'ß','\\'},
             ^~~~
keymap.de.pc.c:13:13: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50079’ to ‘-97’ [-Woverflow]
keymap.de.pc.c:14:2: warning: multi-character character constant [-Wmultichar]
 {'´', '`', '´',0},
  ^~~~
keymap.de.pc.c:14:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49844’ to ‘-76’ [-Woverflow]
keymap.de.pc.c:14:13: warning: multi-character character constant [-Wmultichar]
 {'´', '`', '´',0},
             ^~~~
keymap.de.pc.c:14:13: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49844’ to ‘-76’ [-Woverflow]
keymap.de.pc.c:27:2: warning: multi-character character constant [-Wmultichar]
 {'ü', 'Ü', 0, 0},
  ^~~~
keymap.de.pc.c:27:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50108’ to ‘-68’ [-Woverflow]
keymap.de.pc.c:27:8: warning: multi-character character constant [-Wmultichar]
 {'ü', 'Ü', 0, 0},
        ^~~~
keymap.de.pc.c:27:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50076’ to ‘-100’ [-Woverflow]
keymap.de.pc.c:40:2: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
  ^~~~
keymap.de.pc.c:40:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50102’ to ‘-74’ [-Woverflow]
keymap.de.pc.c:40:8: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
        ^~~~
keymap.de.pc.c:40:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50070’ to ‘-106’ [-Woverflow]
keymap.de.pc.c:40:14: warning: multi-character character constant [-Wmultichar]
 {'ö', 'Ö', 'ö', 0},
              ^~~~
keymap.de.pc.c:40:14: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50102’ to ‘-74’ [-Woverflow]
keymap.de.pc.c:41:2: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
  ^~~~
keymap.de.pc.c:41:2: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50084’ to ‘-92’ [-Woverflow]
keymap.de.pc.c:41:8: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
        ^~~~
keymap.de.pc.c:41:8: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50052’ to ‘-124’ [-Woverflow]
keymap.de.pc.c:41:14: warning: multi-character character constant [-Wmultichar]
 {'ä', 'Ä', 'ä', 0},
              ^~~~
keymap.de.pc.c:41:14: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘50084’ to ‘-92’ [-Woverflow]
keymap.de.pc.c:42:7: warning: multi-character character constant [-Wmultichar]
 {'^', '°', '^', 0},
       ^~~~
keymap.de.pc.c:42:7: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘49840’ to ‘-80’ [-Woverflow]
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include mouse.c -o mouse.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include event_queue.c -o event_queue.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include clock.c -o clock.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include interrupt.c -o interrupt.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kmalloc.c -o kmalloc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pic.c -o pic.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include ata.c -o ata.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include cdromfs.c -o cdromfs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include string.c -o string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bitmap.c -o bitmap.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include graphics.c -o graphics.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include font.c -o font.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include syscall_handler.c -o syscall_handler.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include process.c -o process.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include mutex.c -o mutex.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include list.c -o list.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pagetable.c -o pagetable.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include rtc.c -o rtc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kshell.c -o kshell.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include fs.c -o fs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include hash_set.c -o hash_set.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include diskfs.c -o diskfs.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include serial.c -o serial.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include elf.c -o elf.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include device.c -o device.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kobject.c -o kobject.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include pipe.c -o pipe.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include bcache.c -o bcache.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include printf.c -o printf.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include is_valid.c -o is_valid.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include window.c -o window.o
ld -melf_i386  -Ttext 0x10000 kernelcore.o main.o console.o page.o keyboard.o mouse.o event_queue.o clock.o interrupt.o kmalloc.o pic.o ata.o cdromfs.o string.o bitmap.o graphics.o font.o syscall_handler.o process.o mutex.o list.o pagetable.o rtc.o kshell.o fs.o hash_set.o diskfs.o serial.o elf.o device.o kobject.o pipe.o bcache.o printf.o is_valid.o window.o -o kernel.elf
objcopy -R .note.gnu.property -O binary kernel.elf kernel
cat bootblock kernel /dev/zero | head -c 1474560 > basekernel.img
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/kernel'
cd library && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/library'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include user-start.c -o user-start.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include errno.c -o errno.o
gcc -m32    -c -o syscall.o syscall.S
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include syscalls.c -o syscalls.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include string.c -o string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include stdio.c -o stdio.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include stdlib.c -o stdlib.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include malloc.c -o malloc.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include kernel_object_string.c -o kernel_object_string.o
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -I ../include nwindow.c -o nwindow.o
ar rv baselib.a errno.o syscall.o syscalls.o string.o stdio.o stdlib.o malloc.o kernel_object_string.o nwindow.o
ar: creating baselib.a
a - errno.o
a - syscall.o
a - syscalls.o
a - string.o
a - stdio.o
a - stdlib.o
a - malloc.o
a - kernel_object_string.o
a - nwindow.o
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/library'
cd user && make
make[1]: Entering directory '/home/sbosse/proj/vm42/src/basekernel/user'
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include ball.c -o ball.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o ball.o ../library/baselib.a -o ball.exe
objcopy -R .note.gnu.property -R .comment --strip-debug ball.exe
objcopy: ball.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include clock.c -o clock.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o clock.o ../library/baselib.a -o clock.exe
objcopy -R .note.gnu.property -R .comment --strip-debug clock.exe
objcopy: clock.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include copy.c -o copy.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o copy.o ../library/baselib.a -o copy.exe
objcopy -R .note.gnu.property -R .comment --strip-debug copy.exe
objcopy: copy.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include livestat.c -o livestat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o livestat.o ../library/baselib.a -o livestat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug livestat.exe
objcopy: livestat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include manager.c -o manager.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o manager.o ../library/baselib.a -o manager.exe
objcopy -R .note.gnu.property -R .comment --strip-debug manager.exe
objcopy: manager.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include fractal.c -o fractal.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o fractal.o ../library/baselib.a -o fractal.exe
objcopy -R .note.gnu.property -R .comment --strip-debug fractal.exe
objcopy: fractal.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include procstat.c -o procstat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o procstat.o ../library/baselib.a -o procstat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug procstat.exe
objcopy: procstat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include saver.c -o saver.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o saver.o ../library/baselib.a -o saver.exe
objcopy -R .note.gnu.property -R .comment --strip-debug saver.exe
objcopy: saver.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include shell.c -o shell.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o shell.o ../library/baselib.a -o shell.exe
objcopy -R .note.gnu.property -R .comment --strip-debug shell.exe
objcopy: shell.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include snake.c -o snake.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o snake.o ../library/baselib.a -o snake.exe
objcopy -R .note.gnu.property -R .comment --strip-debug snake.exe
objcopy: snake.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
gcc -m32 -Wall -c -ffreestanding -fno-pie -g -std=gnu99 -O2 -I ../include sysstat.c -o sysstat.o
ld -melf_i386  -Ttext 0x80000000 ../library/user-start.o sysstat.o ../library/baselib.a -o sysstat.exe
objcopy -R .note.gnu.property -R .comment --strip-debug sysstat.exe
objcopy: sysstat.exe: warning: empty loadable segment detected at vaddr=0x8048000, is this intentional?
rm manager.o ball.o copy.o shell.o saver.o fractal.o snake.o livestat.o procstat.o clock.o sysstat.o
make[1]: Leaving directory '/home/sbosse/proj/vm42/src/basekernel/user'
rm -rf image
mkdir image image/boot image/bin image/data
cp kernel/basekernel.img image/boot
cp user/manager.exe user/fractal.exe user/sysstat.exe user/shell.exe user/saver.exe user/clock.exe user/copy.exe user/livestat.exe user/ball.exe user/procstat.exe user/snake.exe image/bin
if [ -f /usr/share/dict/words ]; then head -2000 /usr/share/dict/words > image/data/words; fi
genisoimage -input-charset utf-8 -iso-level 2 -J -R -o basekernel.iso -b boot/basekernel.img image
Size of boot image is 2880 sectors -> Emulating a 1440 kB floppy
Total translation table size: 2048
Total rockridge attributes bytes: 1946
Total directory bytes: 6854
Path table size(bytes): 46
Max brk space used 1a000
1159 extents written (2 MB)

TODO

Prozesse und Prozessverwaltung

Einführung des Prozessmodells

Datenstrukturen von Prozessen

Multiprozesssysteme und Scheduling (Prozessumschaltung)

Terminologie

Programm

Ausführungsvorschrift für eine Maschine, i.A. eine lineare Liste von Instruktionen.

Prozessor

Physische (oder virtuelle) Ausführungseinheit einer Maschine (Rechner) um Instruktionen auszuführen. Maschineninstruktionen sind Daten- und Kontrollanweisungen, d.h. Berechnung und Kontrollflusssteuerung.

Prozess

Ein Orogramm in Ausführung, entweder im Vordergrund durch einen Prozessor, oder im Hintergrund durch einen Speicherzustand (inkl. Auslagerung auf einen Datenträger).

Abbildung 37-1. Unterscheidung Programm zu Prozess [bsdp]

Allgemein gilt: kommt ein Programm zur Ausführung, dann wird ein neuer Prozess gestartet. Der Prozess belegt Hauptspeicherplatz, Prozessorregister, belegt Rechenzeit und nutzt die Peripherie. Ein bestimmtes Programm kann bei Bedarf auch mehrfach parallel ablaufen. Es liegen dann mehrere Prozesse vor, aber immer noch nur ein Programm.

Threads

Threads sind parallel ablaufende Aktivitäten innerhalb einer gemeinsamen Prozessumgebung. Der Code einzelner Threads ist Teil des bereits geladenen Programms. Jeder Thread gehört damit zu einem bestimmten Prozess.

Abbildung 37-2. Varianten der Software-Parallelität [bsdp]

Nebenläufigkeit

Nebenläufigkeit (concurrency) bedeutet, dass die Software mehrere Programmanweisungen gleichzeitig ausführen kann (abstrakte Parallelität). Parallelität im strikten Sinne bezieht sich hingegen auf die tatsächlich gleichzeitige Ausführung (konkrete Parallelität). Eine weniger strikte Definition der Parallelität schlie0t die Nebenläufigkeit ein, da dies Praxisgebrauch darstellt.

Task

Primär ist diese Bezeichnung bei eingebetteten Systemen (embedded systems) üblich und bezeichnet dort parallel ausgeführte Programmteile. Einige Betriebssysteme benutzen den Begriff Task hingegen als einen Oberbegriff, der Prozesse und Threads umfasst.

Job und Session

Darunter sind voneinander unabhängige und gegenseitig geschützte Anwendungen auf einer Rechneranlage zu verstehen. Eine derartige Anwendung besteht dabei aus einer Gruppe von Prozessen.

Echtzeitbetrieb

Echtzeitbetrieb ist ein Betrieb eines Rechensystems, bei dem Programme zur Verarbeitung anfallender Daten ständig betriebsbereit sind derart, dass die Verarbeitungsergebnisse innerhalb einer vorgegebenen Zeitspanne verfügbar sind. Die Daten können je nach Anwendungsfall nach einer zufälligen zeitlichen Verteilung oder zu vorbestimmten Zeitpunkten auftreten (DIN 44 300).

Man unterscheidet harte und weiche Echtzeitsysteme. Das Überschreiten von Zeitgrenzen (von Prozessen) wird im Softbetrieb toleriert, im Hartbetrieb wird diese als Systemfehler gewertet mit Anhalten der Rechneranlage.

Prozess

Ein Prozess i hat einen Zustand Σi. Ein Prozess in Ausführung ändert seinen Zustand.

Der Prozesszustand setzt sich aus zwei Teilzuständen zusammen:

Kontrollzustand γ, gegeben durch den Befehlszähler;

Datenzustand σ, gegeben durch alle Prozessvariablen.

Der Prozess ist ein Grundbaustein der Parallelverarbeitung mit einem Betriebssystem.

Das Prozessmodell ist daher ein zentrales Konzept moderner Betriebssysteme. Ergänzt wird es durch das Thread-Modell, das für die Parallelisierung innerhalb von Applikationen die optimalere Lösung darstellt.

Ein Prozess kann sich in drei wesentlichen Zuständen befinden:

Ready: Rechenbereit, aber nicht in Ausführung durch einen Prozessor;

Run: In Ausführung durch einen Prozessor; wesentlich Berechnung;

Wait: Wartend (auf Ereignisse).

Das Warten bedeutet i.A. Ein- und Ausgabe, Kommunikation oder die Verzögerung

Es gibt zwei Arten um auf Ereignisse zu warten:

Durch wiederholte Abfrage (Polling);

Durch Prozessblockierung (Suspendierung) und Wiederaufnahme (Resume) beim Eintreten des Ereignisses (Interrupt).

Ereignisse

Man kann verschiedene Klassen von Ereignissen unterscheiden, die von Prozessen berarbeitet werden:

Daten oder Ereignisse von Kommunikationsgeräten (WIFI/WLAN, Bluetooth, USB, Scanner, Drucker "kein Papier");

Zeitablauf (Verzögerung, Timer);

Synchronisation zwischen Prozessen, z.B. Steuerung einer Warteschlange, Bereistellen von Daten zur Abholung usw.;

Wettbewerbsauflösung durch Sequenzialisierung, d.h., Nutzung geteilter Ressourcen.

Warten auf Ereignisse bedingt das Anhalten des Kontrollflusses eine Prozesses (Prozessblockierung), wenn auch beim Pollingbetrieb nur eine wiedeholende Schleife den Programmfortschritt blockiert.

Prozesszustände

[bsbrause]

Abbildung 37-3. (a) Allgemein Prozesszustände und Übergänge (b) Prozesszustände und Übergänge bei UNIX (c) Prozesszustände in Windows NT

Die Zustände „bereit“ und „blockiert“ enthalten eine oder mehrere Warteschlangen (Listen), in die die Prozesse mit diesem Zustand eingetragen werden.

Der bereit-Zustand ist besonders ausgezeichnet: Alle Prozesse, die Ereignisse erhalten und so entblockt werden, werden zunächst in die bereit-Liste (ready-queue) verschoben und erhalten dann in der Reihenfolge den Prozessor zur Ausführung.

Prozessmodell

Das Prozessmodell ist ein zentrales Konzept der meisten Betriebssysteme.

Kerneigenschaft dabei ist, dass jeder Prozess virtuell den ganzen Rechner für sich alleine zur Verfügung hat, d.h., er kann den Adressraum frei belegen (abgesehen von reservierten Systembereichen) und kann die Prozessorregister nach Belieben benutzen.

Das Prozessmodell realisiert durch die nebenläufige Ausführung mehrerer Prozesse den Mehrprogrammbetrieb.

In einem Einprozessorsystem wird eine Aufteilung der Rechenzeit auf mehrere ablaufwillige Prozesse durchgeführt.

Es findet dabei ein Zeitmultiplex des Prozessors statt.

Dies ergibt für den Benutzer die Illusion der echt parallelen Programmausführung, obwohl zu jedem Zeitpunkt nur immer gerade ein einziges Programm von der CPU abgearbeitet wird.

Auf Multiprozessorsystemen ist eine mehrheitlich gleichzeitige Ausführung von Programmen möglich.

Prozessverwaltung

Die Programme und damit die Prozesse existieren nicht ewig, sondern werden irgendwann erzeugt, in eine Wartschlange eingereiht und auch beendet.

Dabei verwalten die Prozesse aus Sicherheitsgründen sich nicht selbst, sondern das Einordnen in die Warteschlangen wird von einer besonderen Instanz des Betriebssystems, dem Scheduler, nach einer Strategie geplant.

Bei einigen Betriebssystemen gibt es darüber hinaus eine eigene Instanz, den Dispatcher, der das eigentliche Überführen von einem Zustand in den nächsten bewirkt.

Im Unterschied zu dem Maschinencode werden die Zustandsdaten der Hardware (CPU, FPU, MMU), mit denen der Prozess arbeitet, als Prozesskontext bezeichnet.

Der Teil der Daten, der bei einem blockierten Prozess den letzten Zustand der CPU enthält und damit wie ein Abbild der CPU ist, kann als virtueller Prozessor angesehen werden und muss bei einer Umschaltung zu einem anderen Prozess bzw. Kontext (context switch) neu geladen werden.

Die verschiedenen Betriebssysteme differieren in der Zahl der Ereignisse, auf die gewartet werden kann, und der Anzahl und Typen von Warteschlangen, in denen gewartet werden kann. Sie unterscheiden sich auch darin, welche Strategien sie für das Erzeugen und Terminieren von Prozessen sowie die Zuteilung und Einordnung in Wartelisten vorsehen.

Prozessumschaltung

Synonym für Scheduling oder Prozessmultiplexing

Multiplexing: Die wechselseitige Nutzung einer geteilten Ressource, z.B. eines Funkkanals, Datenbusses, Prozessors, oder des Speichers. Beim Prozessor handelt es sich um Zeitmultiplexing.

Abbildung 37-4. Kanalmultiplexing (grün) im Vergleich zum Prozessmultiplexing (rot). Im Grunde werden Daten geschaltet.

Abbildung 37-5. Multiprozessorsysteme können als multiple Kommunikationskanäle verstanden werden.

Die Prozessumschaltung bewirkt letztlich eine wechselseitige Verarbeitung von Datenströmen.

Prozessgruppen

Prozesse können einzeln gestartet oder in Gruppen zugeordnet werden.

In Unix gibt es die Eltern-Kind Prozessgruppe, unter Windows nicht.

Ein Kindprozess wird i.A. als Kopie des Elternprozesses erzeugt (Forking)

Abbildung 37-6. Prozessverdoppelung bei fork(). PID: Process Identifier Number (numerischer ganzzahliger Wert der einen Prozess im System eindeutig identifiziert)

Prozessumschaltung

Abbildung 37-7. Zeitmultiplexing

Prozessverwaltung

Abbildung 37-8. Mehrprogrammbetrieb mit vier Prozessen (Beispiel) [bsdp]

Prozessumschaltung

Prozesswechsel benötigt eien Verwaltungstabelle für Prozesse mit einem Process Control Block (PCB), kann noch durch erweiterte Tabellen überdeckt sein.

Ablauf für einen Prozesswechse:

Der laufende Prozess wird gestoppt (»eingefroren«): Damit übernimmt der Prozesswechselcode des Betriebssystems die Kontrolle.

Kontextsicherung des aktuellen Prozesses: Die aktuellen CPU-Registerinhalte werden aus der CPU in den PCB1 kopiert.

Auswahl nächster Prozess: Entsprechend einer Prozessorzuteilungsstrategie wird der nächste auszuführende Prozess bestimmt (sog. CPU-Scheduling).

Kontextwiederherstellung neuer Prozess: Die gesicherten CPU-Registerinhalte werden aus dem PCB2 in die CPU kopiert.

Neuer Prozess wird gestartet (»aufgetaut«): die Kontrolle wird neuem Prozess übergeben, der am letzten Unterbrechungspunkt fortfährt.

Abbildung 37-9. Prozessumschaltung und Rolle des PCB in fünf Schritten (vereinfachtes Beispiel) [bsdp]

Vordergrund/Hintergrund System

Das einfachste Betriebssystem ist keines (im Sinne eines eigenständigen Programms). vor allem in Eingebetteten Systemen (Einplatinenrechner, Mikrokontroller) kommt das Einprogrammsystem zum Einsatz.

Es gibt nur ein Programm welches monolithisch verschiedene Aufgaben ausführt:

Berechnung

Ein- und Ausgabe

Kommunikation

Jetzt gibt es aber ein Problem: Wie soll ein Programm ohne Prozesse diese verschiedenen Aufgaben nebenläufig (quasi parallel) ausführen?

Das Warten auf Ereignisse müsste "parallel" im Polling geschehen

Wenn in Berechnung dann können keine Ereignisse überwacht und verarbeitet werden.

Besser: Hardwareunterstützung durch den Prozessor mit Interruptbetrieb

Durch den Interruptbetrieb kann das Programm rechnen und beim Eintreten einen Ereignisses (Daten verfügbar, Ressource verfügbar, Fehler usw.) verzweigt der Prozessor in ein Unterpogramm welches das Ereignis bearbeitet.

Kurios: Das Hauptprogramm ist ein Hintergrundprozess, das Interrupt Unterprogramm (Interrupthandler) ist ein Vordergrundprozess mit Kontextwechsel!

Grund: Interrupt Unterprogramme werden präemptiv aufgerufen, d.h. es wird das Hauptprogramm "sofort" unterbrochen und in das Unterpogramm verzweigt.

Der Interrupthandler hat die höhere Priorität und ist daher (wenn auch selten aktiv) der Vordergrundprozess.

Aber: Präemption bedeutet Gefahr der Dateninkonsistenz. Beim (partiellen) Kontextwechsel werden nur einige Register gesichert, einige werden vom Hauptprogramm weiter verwendet und dürfen nicht modifiziert werden.

Daher sollte in den Interrupt Unterprogrammen nur das Ereignis (beim Gerät) bestätigt werden und das Hauptprogramm über Speichervariablen über das Ereignis informiert werden. Den Rest muss das Hauptprogramm bearbeiten.

Main IO Loop

Wenn wir ein Einprogrammsystem betrachten dann muss es ungeachtet von Interruptbetrieb eine Hauptschleife geben die Berechnung und Ereignisverarbeitung "fair" aufteilt.

Ansonsten müsste das Hauptprogramm ständig nach Ereignissen fragen (Verteiltes Polling)

void setup() {
  Startcode
}
void loop () {
  if (Ereignis) handle(Ereignis)
  if (Berechnung) process(Berechnung)
}
setup();
while (1) loop();

Kode 37-1. Zwei Funktionen (Arduino Style!): `setup` wird einmal zu Beginn, und `loop` "endlos" immer wieder aufgerufen.

Damit die Hauptschleife durchläuft muss die eigentliche Berechnung gestückelt sein. I.a. folgt die Berechnung auf Ereignisse.

int timeout=0;
struct list { int type; struct list *next };
void add(struct list* l, int ev) { add event ev to list l };
void loop () {
  int now=milliseconds();
  struct list events, eventp=events;
  if (Serial1.available) add(events,EV1);
  if (Serial2.available) add(events,EV2);
  if (timeout!=0 && timeout<now) { timeout=0; add(events,EV3); }
  ...
  while (eventp) {
    switch (eventp->type) {
      case EV1: procev1(); domore(); break;
      case EV2: procev2(); break;
      ...
    }
    eventp=eventp->next;
  }
}

Event Handler (Ein- und Ausgabe usw.) haben nur kurze Laufzeit, können aber häufig aufgereufen werden. Die Laufzeit kann durch Monitoring abgeschätzt werden (Min., Mittelwert, Max.).

Berechnungsfunktionen können beliebig lange laufen, die Laufzeit ist i.A. nicht über Monitoring abschätzbar.

Ein großes Problem (nicht nur in Echtzteitbetriebssystemen): Eine lange Laufzeit in einer Berechnung kann die Ereignsiverarbeitung derart einschränken dass es zu Verlusten von Ereignissen und Daten kommen kann. Es können sogar Hardware Geräte in einen blockierten/unbrauchbaren Zustand übergehen!

Und noch schlimmer: Die meisten Programmiersprachen erlauben verschachtelte Funktionen. Eine lineare Liste von Instruktionen können unterbrochen werden, eine tief aufgerufene Funktion nicht ohne weiteres.

Es gibt zwei Lösungen:

Präemptives Zeitscheibenverfahren sofern es möglich ist aus Funktionen heraus zu springen und später zurück zukehren ("Long Jumps");

Transformation einer ganzen Berechung (funktionale/prozedural) in eine lineare Liste von abstrakten Instruktionen die von einer Virtuellen Maschine sukzessive verarbeitet werden kann.

Eine einfache VM

Jedes zusammenhängende (atomare) Syntaxelement einer fiktiven Programmiersprache, sagen wir Basic, wird einen Sequenz von Tokens (Symbolen) transformiert.

a=1             LHS VAR a 1 EOL
b=2             LHS VAR b 2 EOL
x=3             LHS VAR x 3 EOL
for i = 1 to 10 FOR VAR i 1 10 EOL
  x=x+i+a+b     LHS VAR ADD VAR x ADD VAR a ADD VAR b
next i          NEXT i
print x         PRINT VAR X

Beispiel 37-1. Transformation eines Programms in eine lineare Folge von Symbolen

Die einzelnen Tokens können nun linear von der VM abgearbeitet werden und jederzeit unterbrochen werden: Mikrostepping.

Die Tokens können direkt in einem Speicherarray abgelegt (kompiliert) werden

// VM state
int pc; int sp; uint16_t mem[SIZE]; // .....
// VM Execution Loop
int vmloop(steps) {
  for (int step=0;step<steps;step++) {
    int next=mem[pc++];
    switch (next) {
      case LHS:    xassign(); break;
      case VAR:    xvar();    break;
      case PRINT:  xprint();  break;
      case DELAY:  xdelay();  return -1;
      case END:    xend();    return 0;
      ...
    }
    if (interrupt) return 1;
  }
  return 1;
}

Beispiel 37-2. VM Execution Loop

// Interrupt Handler
int interrupt=0,
    pendingtokens=0,
    runnable=0;
void handleIRQn() { interrupt++; }
// IO Loop
void loop () {
  if (interrupt) handle interrupts;
  interrupt=0;
  if (events) handle events;
  if (pendingtokens && runnable) {
    status=vmloop(STEPSMAX);
    if (status!=0) pendingtokens=1;
    else           pendingtokens=0;
    if (status>0)  runnable=1;
    else           runnable=0;
  }
  if (newcode) {
    tokenize(newcode);
    pendingtokens=1;
    runnable=1;
  }
  ...
}
while (1) loop();

Beispiel 37-3. Einbindung einer VM Execution Loop in eine IO Loop

Prozesserzeugung und Terminierung

Prozessstart

In sehr einfachen Systemen, z.B. in vielen eingebetteten Systemen, existieren alle Prozesse von Anfang an und laufen ewig, d.h., solange das System läuft. Auf Universalrechnern (general-purpose computers) ist es hingegen notwendig, Prozesse bei Bedarf zu erzeugen und gelegentlich wieder zu terminieren. Auslösende Ereignisse für die Prozess- bzw. Thread-Erzeugung können dabei sein:

Systemstart (Initialisierung)

Systemdienstaufruf zur Prozesserzeugung durch irgendeinen laufenden Prozess

Benutzeranforderung zum Starten eines neuen Prozesses (Applikationsstart)

Auslösung eines Stapelauftrags (batch job)

Es gibt drei grundlegende Möglichkeiten für den Start eines neuen Prozesses:

Prozessverkettung (chaining): Der laufende Prozess startet einen neuen Prozess und terminiert sich damit selbst. Der Code des neuen Prozesses ist unabhängig (z.B. in separater ausführbarer Datei vorliegend).

Prozessvergabelung (forking): Der laufende Prozess startet einen neuen Prozess, läuft selbst aber weiter. Der Code beider Prozesse ist gemeinsam.

Prozesserzeugung (creation): Der laufende Prozess startet einen unabhängigen neuen Prozess. Der Code beider Prozesse ist unabhängig (z.B. in zwei separaten ausführbaren Dateien vorliegend).

Abbildung 37-10. Prozessstartformen: (a) Prozessverkettung, Sequenz (b) Prozessvergabelung, Forking (c) Unabhängige Prozesserzeugung, Create

Prozessbeendigung

Neben der Erzeugung interessiert auch die Terminierung von Prozessen. Übliche Formen sind:

Normale Beendigung (freiwillig)

Vorzeitige Beendigung bei einem durch den Prozess selbst erkannten Fehler (freiwillig)

Vorzeitige Beendigung bei einem katastrophalen Fehler, erkannt durch das System (unfreiwillig)

Terminierung durch einen anderen Prozess (unfreiwillig)

Abbildung 37-11. Prozessvereinigungsformen: Prozesse können auf andere warten.

Prozessverwaltung: API

Abbildung 37-12. (a) Systemdienstaufrufe für Prozess-/Thread-Start (b) Weitergabe von Daten an neue Prozesse/Threads (c) Systemdienstaufrufe für die Prozess-/Thread-Vereinigung

Prozessorzuteilungsstrategien

Für die Ausführung paralleler Prozesse wäre idealerweise pro Prozess ein eigener Prozessor nötig. Da dies technisch nicht sinnvoll ist und nicht skalierbar ist (oder sehr aufwendig ist), hat man Verfahren entwickelt, die es erlauben, mehrere Prozesse quasi gleichzeitig auf dem gleichen Prozessor im Zeitmultiplexverfahren auszuführen.

Aber wie sollen die Prozesse der Ressource Prozessor zugeteilt werden, und wie lange sollen oder dürfen sie den Prozessor belegen? Reicht kooperatives Scheduling (Prozesse geben freiwillig die Ressource zurück, yielding), oder muss das Betriebssystem dieses erzwingen?

Die Prozesszuteilung erfolgt nach einer Strategie und einem Algorithmus.

Die Prozesszuteilungsstrategie gilt gleichermaßen für voll Prozesse und dür Subprozesse (Threads).

Abbildung 37-13. Vergleich echte 1:1 Prallelitäz versa "simulierte" N:M Parellelität durch Zeitmultiplexing [bsdp]

Welchen Nachteil hat die 1:1 Zeuteilung (von der nicht vorhandenen Skalierbarkeit abgesehn)?

Ein wichtiger Faktor ist die Auslastung einer Rechneranlage, die wie bei einer Heizungsanlage 100% betragen sollte (d.h. durchgehender Rechenbetrieb).

Bei einer festen 1:1 Zuteilung würden Prozessoren beim Warten von Prozessen auf Ereignisse untätig sein - keine Auslastung von 100%!

Wie können nun N Prozesse auf M Prozessoren verteilt werden?

Priorität & Präemption

Die Auswahl kann von der Wichtigkeit von Prozessen beeinflusst werden, d.h. die Priorität, eine Ganzzahl.

Je größer die Priorität, desto eher könnte der Prozess ausgewählt werden oder

ein Prozess niedrigere Priorität könnte beim Bereitsein eines Prozesse höherer Priorität unterbrochen werden, d.h. es findet Präemption statt.

Pritoritäten haben Einfluss auf das Zustandsmodell von Prozessen

Abbildung 37-14. Grundmodell mit drei Zuständen

Abbildung 37-15. Erweitertes Modell der Prozesszustände vier Zuständen und Priorität

Warteschlangen

Für die Prozesszuteilung und dem Prozessscheduling gibt es mehrere Warteschlangen, d.h. im i.A. eine Warteschalnge pro Prozesszustand:

Bereit

Laufend

Blockiert

...

Abbildung 37-16. Konzeptionelle Prozessverwaltung (Prozessorganisation)

Ein Prozess ist entweder der CPU zugeteilt oder in einer der Ressourcen-Warteschlangen

Es findet ein Wechsel der Prozesse zwischen den Warteschlangen je nach Prozesszustand statt.

Die Prozessorzuteilungsstrategie (CPU scheduling) legt fest, welcher Prozess als nächster die CPU zugeteilt erhält.

Der ausgewählte Prozess wird aus der Bereitliste entfernt und der CPU zugeordnet.

Die Reihenfolge der Prozesse in der Bereitliste wird hierbei strategieabhängig unterschiedlich berücksichtigt.

Gibt ein laufender Prozess freiwillig die CPU ab, so wechselt er in die Bereitliste an die vorderste Position.

Wird einem Prozess die CPU entzogen, weil er sein Zeitquantum erschöpft hat (Präemption), dann wechselt er in die Bereitliste an die hinterste Position seiner Priorität.

Wenn der laufende Prozess auf eine Ressource/ein Ereignis zu warten beginnt, so wechselt er in die zugehörige Warteschlange.

Welcher Prozess aus einer Ressourcen-Warteschlange die frei werdende Ressource erhält, ist eine Frage der E/A-Zuteilungsstrategie (I/O scheduling).

Diese kann ressourcenabhängig unterschiedlich sein.

Wie sollen nun Prozesse aus der Bereitwarteschlange der CPU zugeteilt werden? Was sind die Ziele?

Prozesszuteilung

Man unterscheidet zunächst:

Process based scheduling: Findet die Prozessorzuteilung nur zu ganzen Prozessen statt, so kennt der Systemkern keine Threads (single-threaded process). Dies bedingt ein Multithreading vollständig im Benutzermodus (user level threads).

Thread based scheduling: Findet die Prozessorzuteilung zu einzelnen Threads statt, so sind unterschiedliche Varianten des Multithreading möglich. Sowohl ein Multithreading auf der Benutzerebene als auch die direkte Nutzung von KL-Threads (kernel level threads) sowie verschiedene Zwischenformen sind denkbar.

Es gibt wesentlich zwei Arten von Prozessausführung (auch abwechselnd):

Betrachtet man typische Prozessabläufe, so kann man zwei wichtige Grundmuster (pattern) unterscheiden (siehe auch Abb. 4–31):

CPU-lastig (CPU-bound): Die Aktivität nutzt viel Rechenzeit und wartet im Vergleich dazu selten auf die Ein-/Ausgabe.

E/A-lastig (I/O-bound): Die Aktivität rechnet wenig, wartet hauptsächlich auf die Ein-/Ausgabe (Input/Output), d.h. nutzt Peripherie.

Generell können Wartezeiten auf die Ein-/Ausgabe für die Ausführung parallel laufender Berechnungen genutzt werden. Besonders lohnend ist dies für »E/A-lastige« Aktivitäten. Grundprinzip der asynchronen E/A, z.B. in virtuellen Maschinen.

E/A-lastig versa CPU-lastig hat große Auswirkung auf das Scheduling!

Abbildung 37-17. Prozessablaufmuster (E/A- bzw. CPU-lastig)

Bei einer reinen Berechnung spielen Verzögerung i.A. ein nicht so große Rolle als wie bei der Verarbeitung von E/A Ereignissen!

Optimierungsziele

Für eine optimale Zuteilung der CPU zu rechenwilligen Prozessen wurde eine Vielzahl an Strategien entwickelt. Diese Vielfalt kommt daher, dass je nach verfolgten Optimierungszielen unterschiedliche Lösungen optimal sind. Aus Sicht des Anwenders können folgende Ziele wichtig sein:

Durchlaufzeit (turnaround time): Gesamtzeit von Prozessstart bis Prozessbeendigung

Antwortzeit (response time): Zeit zwischen einer Eingabe und der Reaktion des Systems darauf

Endtermin (deadline): Zeitpunkt, zu dem eine vorgegebene Aktion erfolgt sein muss

Aus Sicht eines optimalen Ressourceneinsatzes können weitere Ziele interessant sein, wie Vorhersagbarkeit, Durchsatz (Anzahl erledigter Aufträge pro Zeiteinheit) und Prozessorauslastung.

Verdrängende und nicht verdrängende Zuteilungsstrategien

Eine Zuteilungsstrategie wählt aus der Menge aller Prozesse, die sich im »Bereit«-Zustand befinden, einen bestimmten Prozess aus. Zuteilungsstrategien unterscheiden sich durch das maßgebende Auswahlkriterium.

Wichtig ist dabei, zu verstehen, dass ein zum Auswahlzeitpunkt im Zustand »Laufend« befindlicher Prozess an dieser Auswahl ebenfalls teilnimmt.

Damit kann der Auswahlentscheid auch lauten, dass kein Prozesswechsel stattfindet, sondern dem gleichen Prozess weiter die CPU zugeteilt wird.

Eine weitere spezielle Situation liegt vor, wenn der laufende Prozess in den Zustand »Wartend« gewechselt hat.

In diesem Fall ist das Ziel der Auswahl auf jeden Fall die Zuteilung der CPU zu einem neuen Prozess.

Eine solche Auswahl eines Prozesses, der als nächster laufen soll, wird als Neuzuteilung bezeichnet (rescheduling). Eine Neuzuteilung kann auch die Auswahl des bereits laufenden Prozesses bedeuten.

Zuteilungszeitpunkte

Ein Prozess wechselt vom Zustand »Laufend« in den Zustand »Wartend« (dadurch wird die CPU frei und kann neu zugeteilt werden).

Ein Prozess wechselt vom Zustand »Wartend« in den Zustand »Bereit« (dadurch könnte eine Prioritätsregel verlangen, dass dieser Prozess Vorrang vor dem laufenden Prozess erhält).

Ein Prozess wechselt vom Zustand »Laufend« in den Zustand »Bereit« (dies könnte nach Ablauf einer bestimmten Zeit von einer Zeitquotenregel erzwungen werden).

Ein Prozess wechselt von einem der Zustände »Laufend«/»Bereit«/»Wartend« in den Zustand »Inaktiv« (dies wäre eine freiwillige oder erzwungene Prozessbeendigung).

Ein Prozess wechselt vom Zustand »Inaktiv« in den Zustand »Bereit« (dies trifft für einen neu gestarteten Prozess zu, der durch eine Prioritätsregel den Vorrang vor einem laufenden Prozess erhalten könnte).

Eine Zuteilungsstrategie gilt als nicht verdrängend (nonpreemptive), wenn die Neuzuteilung nur zum 1. und 4. Zeitpunkt stattfindet. Eine nicht verdrängende Neuzuteilung hat die Eigenschaft, dass sie kooperatives Verhalten der laufenden Prozesse erwartet. Beansprucht ein laufender Prozess für sehr lange Zeit fortlaufend immer die CPU, so haben ablaufbereite Prozesse keine Möglichkeit, dies zu ändern. Dies gilt nicht für verdrängende (preemptive) Zuteilungsstrategien, da bei ihnen eine Neuzuteilung zu allen fünf oben aufgeführten Zeitpunkten stattfindet.

FIFO Strategie

Die FIFO-Strategie (First In First Out) ist auch unter dem Namen FCFS (First Come First Served) bekannt. Die Reihenfolge der Prozessausführung richtet sich streng nach der Reihenfolge, in der die Prozesse ablaufbereit wurden (d.h. Wechsel in den Zustand »Bereit«).

Es handelt sich um eine nicht verdrängende Strategie, sodass neu ablaufbereite Prozesse keine Neuzuteilung zur Folge haben.

Man bezeichnet dies auch als kooperative Zuteilung, da eine Neuzuteilung erst möglich wird, wenn der laufende Prozess die CPU freiwillig freigibt.

Diese einfache Strategie erlaubt einen minimalen Verwaltungsaufwand. Damit eine Neuzuteilung des Prozessors stattfinden kann, muss der laufende Prozess entweder zu warten beginnen oder seine Ausführung terminieren.

Alle Prozesse besitzen die gleiche Priorität. Dieses Verfahren funktioniert nur so lange, bis ein Prozess die CPU nicht mehr abgibt.

Die Reaktionszeiten hängen zudem direkt von den durch die einzelnen Prozesse bedingten Laufzeiten ab (sog. Konvoieffekt).

Abbildung 37-18. Beispielszenario mit FIFO-Strategie

Ein Prozess A startet zum Zeitpunkt 0 und läuft für 40 Zeiteinheiten. Dann wartet er auf eine Ein-/Ausgabe, die nach 50 Zeiteinheiten abgeschlossen ist. Dadurch wird er wieder ablaufbereit und belegt seine restlichen 30 Zeiteinheiten.

Die Wartezeit (hier 50) für den Prozess A startet von dem Zeitpunkt an, wenn der Prozess blockiert, weil ein Prozess eine Ein-/Ausgabe anfordert und dann für die Dauer der Ein-/Ausgabe warten muss. Die Verzögerung (hier 40) startet ab dem Eintreten des Eireignisses.

Prozess B startet nach 20 Zeiteinheiten und will dann die CPU für 90 Zeiteinheiten belegen.

Prozess C startet nach 30 Zeiteinheiten und benötigt ab dann die CPU für 30 Zeiteinheiten.

SJF Strategie

Bei der nicht verdrängenden Strategie SJF (Shortest Job First), die auch unter dem Namen SPN (Shortest Process Next) fungiert, wird bei einer Neuzuteilung derjenige Prozess ausgewählt, der den kleinsten erwarteten Rechenzeitbedarf hat.

Diese Strategie lässt sich naturgemäß nur anwenden, wenn Angaben über den Rechenzeitbedarf zum Entscheidungszeitpunkt dem System zugänglich sind.

Der Benutzer kann Angaben zur Laufzeit machen

Aaus der Vergangenheit kann eine Vorhersage gemacht werden (durch Messung). Besonders bei E/A-lastigen Prozessen kann das System repräsentative Daten über den vergangenen Rechenzeitbedarf sammeln. Diese Daten ermöglichen dann eine Hochrechnung, deren Qualität letztlich durch die Vorhersagbarkeit des zukünftigen Prozessverhaltens bestimmt wird.

Abbildung 37-19. Beispielszenario mit SJF-Strategie

E/A Reaktionszeiten können auch hier schlecht sein (lange Verzögerung)

SRT Strategie

Die Strategie SRT (Shortest Remaining Time) stellt eine Abwandlung des SJF-Verfahrens dar. Bei einer Neuzuteilung wird derjenige Prozess ausgewählt, der den kleinsten verbleibenden Rechenzeitbedarf hat.

Da es sich um eine verdrängende Strategie handelt, kann eine Neuzuteilung einem laufenden Prozess die CPU entziehen, wenn ein anderer Prozess weniger Rechenzeit benötigt. - Im Übrigen gelten die gleichen Einschränkungen wie bei der SJF-Strategie.

Abbildung 37-20. Beispielszenario mit SRT-Strategie

Es fällt auf, dass zu den Zeitpunkten 20 und 30 jeweils eine Neuzuteilung stattfindet. Da aber Prozess A in beiden Fällen den geringsten verbleibenden Rechenzeitbedarf hat, wird er weder durch B noch durch C verdrängt.

Erst zum Zeitpunkt 40 findet ein Prozesswechsel statt, da A zu warten beginnt. Zu diesem Zeitpunkt erhält Prozess C den Vorrang, da er weniger Rechenzeit benötigt als B.

Nach dem Prozessende von C wird die CPU frei und kann daher dem einzigen dann ablaufbereiten Prozess B zugeteilt werden.

Zum Zeitpunkt 90 wird A wieder ablaufbereit, da die Ein-/Ausgabe abgeschlossen ist.

Da Prozess A einen geringeren Restzeitbedarf aufweist als B, erfolgt eine Verdrängung. Prozess B kann erst nach dem Prozessende von A seine restliche Rechenzeit beanspruchen.

RR Strategie

Bei der RR-Strategie (Round Robin, im Kreis herum) erhält jeder Prozess ein Quantum an Rechenzeit zugeteilt, für das er ununterbrochen die CPU belegen kann.

Periodisch (z.B. alle 10 ms, "tick") wird der Zeitquantumszähler des laufenden Prozesses dekrementiert.

Fällt damit sein Zeitquantum auf null, so wird eine Neuzuteilung veranlasst.

Enthält die Liste der ablaufbereiten Prozesse mindestens einen Eintrag, so wird der laufende Prozess verdrängt. Er wechselt damit in den Zustand »Bereit« und wird zuhinterst in der Liste ablaufbereiter Prozesse eingetragen.

Danach wird der vorderste Prozess der Liste entnommen, sein Zeitquantumszähler auf ein volles Zeitquantum gesetzt und ihm die CPU zugeteilt.

Ist die Liste der ablaufbereiten Prozesse leer, wird der Zeitquantumszähler des laufenden Prozesses wieder auf ein volles Zeitquantum gesetzt und belegt die CPU weiter.

Neu gestartete Prozesse werden anfänglich in den »Bereit«-Zustand versetzt und zuhinterst in die Liste der ablaufbereiten Prozesse eingereiht.

Diese Strategie versucht die Rechenzeit möglichst gleichmäßig auf alle ablaufbereiten Prozesse zu verteilen, indem sich diese zyklisch abwechseln.

Da jedoch das Dekrementieren des Zeitquantumszählers nur in einem festen Zeitraster erfolgt, werden Prozesse benachteiligt, die erst kurz vor dieser periodischen Prüfung die CPU zugeteilt bekamen.

Ihr Zeitquantum ist damit um maximal eine Zeitscheibe verkürzt.

Diesem Effekt kann begegnet werden, indem ein volles Zeitquantum mehrere Zeitscheiben umfasst (z.B. 10 ticks).

Die Implementierung des RR-Verfahrens kann unterschiedlich aufwendig sein.

Eine einfache Implementierung wird einen festen Wert für das volle Zeitquantum nutzen. Wird ein volles Zeitquantum nur eine einzige Zeitscheibe groß gewählt, so stellt dies ebenfalls eine Vereinfachung dar.

Andererseits lässt sich das Verfahren verfeinern, indem prozessabhängig oder lastabhängig der Wert für ein volles Zeitquantum variiert wird.

Eine weitere Variante teilt Vordergrundprozessen ein größeres Zeitquantum zu und erlaubt ihnen so, möglichst gut auf Benutzereingaben zu reagieren.

Das RR-Verfahren geht davon aus, dass alle Prozesse zu jedem Zeitpunkt gleich wichtig sind, womit sich nur einfachere Systeme bauen lassen.

Vorteilhaft ist der vergleichsweise bescheidene Verwaltungsaufwand.

Ein weiterer Vorteil ist, dass einzelne Prozesse mit Busy Waits (E/A Polling, Schleifen) das System nicht lahmlegen können.

Neuzuteilungen finden immer dann statt, wenn der laufende Prozess die CPU freigibt (zu warten beginnt oder terminiert) oder wenn sein Zeitquantum erschöpft ist.

Dieses Verfahren baut auf dem FIFO-Prinzip (kooperative Zuteilung) auf und erweitert es um die Zeitquantenumschaltung.

Abbildung 37-21. Beispielszenario mit RR-Strategie: Zeitscheibengröße 40. Erreicht ein Prozess laufend das Zeitscheibenende, so wird ihm die CPU entzogen und dem nächsten bereiten Prozess zugeteilt.

Die Zeitscheibengröße wird exemplarisch auf 40 Zeiteinheiten festgelegt und das volle Zeitquantum ist eine Zeitscheibe. Implementierung durch periodischen Uhreninterrupt.

Am Anfang ist Prozess A lauffähig und erhält die CPU zugeteilt. Nach 20 bzw. 30 Zeiteinheiten werden B und C ablaufbereit. Dies ändert jedoch nichts, da Verdrängung nur bei Ablauf eines Zeitquantums möglich ist.

Zum Zeitpunkt 40 beginnt Prozess A zu warten, wodurch die CPU frei wird. Da Prozess B vor Prozess C ablaufbereit wurde, kann jetzt B laufen (erhält ein volles Zeitquantum von eins).

Zum Zeitpunkt 80 ist das Zeitquantum von B erschöpft, womit C die CPU zugeteilt bekommt.

Kurz darauf zum Zeitpunkt 90 wird A wieder ablaufbereit nach abgeschlossener Ein-/Ausgabe. Prozess A wird hinter B in der Bereitliste eingereiht.

Abbildung 37-22. Beispielszenario mit erweiterter RR-Strategie: Zeitscheibengröße 20, wobei jeder Prozess jeweils über ein Zeitquantum von 2 verfügt. Erreicht ein Prozess laufend zweimal das Zeitscheibenende, so wird ihm die CPU entzogen und dem nächsten bereiten Prozess zugeteilt.

Kleinere Zeitscheibengrößen bei höherer Anzahl von Zeitscheiben kann zu einer schlechteren E/A Reaktionszeit führen. Die Wartezeit von bereiten Prozessen kann sich verbesssern. Das hängt aber vom aktuellen Zustand der Prozesse und der Wartelisten ab!.

ML Strategie

Die ML Strategie (Multi-Level Priority) basiert auf Prioritäten, die den einzelnen Prozessen zugeordnet sind. Diese Prioritätszuordnung obliegt dem Anwender, der damit die Wichtigkeit der einzelnen Prozesse bestimmt.

Die Priorität wird meistens mit Zahlenwerten umschrieben, z.B. 0..100. Ob eine hohe Zahl einer hohen Priorität entspricht oder umgekehrt, ist eine Implementationsfrage.

Typischerweise ist diese Strategie verdrängend realisiert (Präemtpion), sodass stets derjenige ablaufbereite Prozess die CPU besitzt, der die höchste Priorität hat.

Sind mehrere Prozesse auf dieser Prioritätsstufe ablaufbereit, so gilt sekundär das FIFO-Prinzip.

Nachteilig bei dieser Strategie ist, dass die CPU von höherpriorisierten Prozessen monopolisiert werden kann. Dies ist dann der Fall, wenn diese zu oft ablaufbereit sind, sodass niederpriore Prozesse nie die CPU erhalten. Man sagt dann, dass diese niederprioren Prozesse verhungern (starvation).

Gründe für das Auftreten dieser Situation können falsch ausgelegte Prozesse, unerwartete Anwendungssituationen mit erhöhtem Rechenzeitbedarf oder eine zu leistungsschwache CPU sein.

Die ML Strategie wird gerne für Echtzeitsysteme genutzt, da sie bei nur einem einzigen Prozess auf der höchsten Prioritätsstufe diesem stets sofort die CPU zuteilt, wenn er ablaufen will.

Eine Neuzuteilung des Prozessors findet nicht nur statt, wenn der aktuelle Prozess die CPU freigibt (zu warten beginnt oder terminiert), sondern auch dann, wenn ein anderer Prozess ablaufbereit wird.

In der Praxis wird diese Strategie oft mit der RR-Strategie kombiniert, d.h., existieren ablaufbereite Prozesse mit gleicher Priorität wie der laufende Prozess, so wechseln sie zyklisch ab (round robin).

Die Priorität kann auch dynamisch sein, d.h. sie ändert sich mit der Laufzeit von Prozessen. So werden Prozesse die häufig die Zeitscheiben ausschöüfen degradiert, welche die häufig auf E/A warten und nur kurz rehchen befördert.

Abbildung 37-23. Beispielszenario mit ML Strategie. Das Diagramm wurde um eine Prioritätsrangierung erweitert in der Art, dass A die höchste, B eine mittlere und C die tiefste Priorität zugeordnet bekommt.

Da Prozess A als einziger die höchste Priorität besitzt, erhält er stets sofort die CPU zugeteilt, wenn er ablaufen will. So verdrängt er beispielsweise zum Zeitpunkt 90 den Prozess B, wenn er die Ein-/Ausgabe abgeschlossen hat. Prozess B kann immer dann laufen, wenn A die CPU nicht besetzt. Prozess C schließlich muss warten, bis die CPU für ihn als Letzten frei wird.

MLF Strategie

Die MLF Strategie (Multi-Level Feedback) begründet ihre Scheduling-Entscheide auf der aufgelaufenen Rechenzeit der ablaufbereiten Prozesse. Dazu verwendet sie eine feste Anzahl von Prioritätsstufen, z.B. 1..10.

Anfänglich ist einem Prozess die höchste Priorität zugeteilt.

Die Auswahl des nächsten Prozesses bei einer Neuzuteilung berücksichtigt primär die Priorität und sekundär das FIFO-Prinzip, wenn mehrere wählbare Prozesse der gleichen Priorität vorliegen.

Jeder Prioritätsstufe ist zudem ein festes Zeitquantum zugeordnet.

Hat ein Prozess auf einer Prioritätsstufe das geltende Zeitquantum ausgeschöpft, so wird seine Priorität um eins erniedrigt und eine Neuzuteilung ausgelöst.

Abbildung 37-24. Warteschlagenorganisation für die MLF-Strategie (Beispiel). Prio: Priorität, Q: Zeitquantum. T: Zeitscheibengröße

Damit werden Prozesse mit kleinem Rechenzeitbedarf bevorzugt, da lang laufende Prozesse schrittweise in ihrer Priorität heruntergestuft werden.

Abbildung 37-25. Beispielszenario mit MLF-Strategie

Vorteilhafte Eigenschaften besitzt diese Strategie für interaktive Systeme, da einerseits keine Vorkenntnisse über erwartete Rechenzeiten nötig sind und andererseits E/A-lastige Prozesse bevorzugt werden. Dies sind typischerweise auch Prozesse, die einen intensiven Benutzerdialog besitzen und mit dieser Strategie kurze Antwortzeiten realisieren können. Länger laufende Hintergrundprozesse nutzen so die verbleibende Rechenzeit, ohne dass sie die Reaktionszeit der Vordergrundprozesse negativ beeinflussen.

RM Strategie

Das RM-Verfahren (Rate Monotonic) bietet sich für periodisch ablaufende Prozesse an, wie sie oft in Echtzeitsystemen anzutreffen sind. Dabei wird von einer festen Periodenlänge ausgegangen. Für periodische Aktivitäten gilt typischerweise als spätester Endtermin der Anfang der nächstfolgenden Periode.

Besitzt ein Prozess die Periodenlänge T, so wird er alle T Zeiteinheiten starten und muss seine Berechnung vor dem Start der nächsten Zeiteinheit (also nach T Zeiteinheiten) abschließen.

Es handelt sich um ein verdrängendes Zuteilungsverfahren, bei dem derjenige ablaufbereite Prozess gewählt wird, dessen Periodenlänge am kürzesten ist.

Abbildung 37-26. Beispielszenario mit RM Strategie (periodische Prozessabläufe) ohne Verdrängung/Präemption. Gelbe Bereiche: CPU nicht genutzt.

Prozess A läuft periodisch alle 60 Zeiteinheiten und er braucht für jeden Ablauf 40 Einheiten an Rechenzeit. Prozess B hat eine Periode von 40 und einen Rechenzeitbedarf von 10 Zeiteinheiten pro Aktivierung.

EDF Strategie

Bei der EDF-Strategie (Earliest Deadline First) wird derjenige Prozess ausgewählt, dessen Restzeit bis zum Ablauf seines Endtermins am kleinsten ist.

Wie bei dem RM-Verfahren gehen wir von periodisch ablaufenden Prozessen aus, die mit festen Intervallen arbeiten. Der Endtermin einer anstehenden Bearbeitung ist stets der Anfang der nächstfolgenden Periode.

Es handelt sich um ein verdrängendes Verfahren, das sich gut für Echtzeitsysteme eignet.

Wie bei dem RM-Verfahren gehen wir von periodisch ablaufenden Prozessen aus, die mit festen Intervallen arbeiten. Der Endtermin einer anstehenden Bearbeitung ist stets der Anfang der nächstfolgenden Periode. Es handelt sich um ein verdrängendes Verfahren, das sich gut für Echtzeitsysteme eignet.

%% FIGURE [] (Oben) Beispielszenario für RM Strategie mit Unterbechung/Verdrängung von Prozess A durch Prozess B (Unten) EDF-Strategie mit Verdrängung

Bei EDF ist die Laufzeitunterbrechung von länger laufenden Prozesse etwas günmstiger.

Eigentlich verpasst Prozess A hier seine Deadline da er mit B überlappt!

Vergleich der Strategien

Um verschiedene Strategien miteinander zu vergleichen, sind folgende Größen zur Beurteilung hilfreich:

Wartezeit (wait time): Summe aller Zeiträume, in denen der Prozess am Warten war (auf irgendeine Ressource, das heißt auch auf die CPU).

Bedienzeit (service time): Summe aller Zeiträume, in denen der Prozess am Rechnen war (d.h. CPU belegt hat, also inklusive aktiver Warteschleifen).

Durchlaufzeit (turnaround time): Gesamter Zeitbedarf (verstrichene Zeit), bis ein Prozess vollständig abgearbeitet ist (Zeitpunkt Prozessbeendigung minus Zeitpunkt Prozessstart). Es gilt: Durchlaufzeit = Wartezeit plus Bedienzeit.

Antwortzeit (response time): Zeit zwischen dem Auftreten eines Ereignisses bis zur ersten Reaktion des Prozesses darauf. Entweder wird die Situation bei einem Einzelereignis bestimmt oder ein Mittelwert über eine Vielzahl an Ereignissen ermittelt.

Durchsatz (Throughput): Anzahl vollständig abgearbeiteter Prozesse pro Zeiteinheit. Zur Bestimmung wird ein Betrachtungszeitraum gewählt.

CPU-Auslastung (cpu usage): Zeiträume, während der die CPU irgendeinen Prozess ausgeführt hat, im Verhältnis zum gesamten Beobachtungszeitraum. Typischerweise erfolgt eine prozentuale Angabe.

[bsdp]

Vergleich ausgewählter Scheduling-Strategien. Teils am Beispiel eines Prozesses, teils für alle Prozesse.

Zusammenfassung

Wichtigste Konzepte für Multiprozessausführung (Multiprogramming) sind

Prozessblockierung

Prozessunterbrechung (Präemption)

Prioritäten

Es wird verdrängende (präemptive) und nichtverdrängende Prozessumschaltung unterschieden

Wichtigstes Beurteilungskriterien:

Antwortzeit auf Ereignisse (Verzögerung)

Faire Prozessauswahl, Zeitscheiben

Auslastung des Prozessors (optimal 100%)

Overhead durch Prozesswechsel!

Möglichkeiten Prozesse messbar zu machen

Man unterscheidet beim Scheduling zwei wesentliche Prozessklassen:

Rechenlastig

Ereignislastig (E/A)

Die Prozessauswahl hängt ab von:

Prozessklasse (s.o)

Statistiken der laufende Prozesse

Prioritäten

Aktuell laufender Prozess!

. Das einfachste Multiprozessystem ist das Vordergund-Hintergrund System

Hauptprogramm ist Hintergrundprozessn (niedrigste Priorität)

Interrupthandler werden präemotiv aufgerufen und sind zum Vordergundprozess zusammengefasst (höchste Priorität).

Prozesse besitzen einen Daten- und Kontrollzuständ (Programmzähler), aber viel wichtiger werden sie für die Prozessverwaltung auf Metazustände abgebildet und in Wartelisten verarbeitet:

Laufend

Bereit

Wartend/Blockiert

...

Prozesswechsel bedeutet:

Overhead!

Umladung PCB und MMU Tabellen

Verwaltungsstrukutren, Wartelisten

Kontextwechsel! Das Betriebssystem ist selber ein Prozess.

Rechner- und Prozessorgrundlagen

Wie funktioniert ein Rechner?

Wie werden Programme von einem Rechner verarbeitet?

Grundmodell eines Rechners

Programm

Eine Sequenz von Maschinenbefehlen (= Prozessorinstruktionen) wird zusammen mit ihrer Datenhaltung als Programm bezeichnet

Programmausführung

Entsprechend die Ausführung durch den Prozessor als Programmausführung.

Die meisten heute gebauten Computersysteme beruhen auf der Aufbaustruktur des Von-Neumann-Rechners, die John von Neumann 1946 aufgestellt hat. Seltener kommt die alternative Struktur des Harvard-Rechners zum Zug, benannt nach der Struktur des Mark-I-Rechners an der Harvard University (1939-44).

Von-Neumann Rechner

Der Von-Neumann-Rechner besteht aus vier Funktionseinheiten:

Abbildung 38-1. Funktionsblöcke des Von-Neumann-Rechners [bsdp]

Leitwerk (Control Unit, CU)

Programme werden maschinenintern als Zahlen, auch Maschinenbefehle genannt, gespeichert. Die Maschinenbefehle legen die vom Prozessor auszuführenden Operationen fest. Das Leitwerk holt die Maschinenbefehle nacheinander aus dem Speicher, interpretiert sie und setzt sie in die zugehörigen Steueralgorithmen um. Das Leitwerk übernimmt damit als Befehlsprozessor die Steuerung der Instruktionsausführung. Unter Steueralgorithmen verstehen wir mögliche Prozessoroperationen, wie z.B. eine Addition, logische Oder-Verknüpfung oder Daten kopieren.

Rechenwerk (Processing Unit, PU)

Eingabedaten können neben den Maschinenbefehlen Bestandteile eines Programms sein (sie liegen dann im Speicher vor) oder werden während des Programmablaufs über die Funktionseinheit Ein-/Ausgabe von der Peripherie hereingeholt. Das Rechenwerk holt die Daten aus dem Speicher bzw. von der Eingabe, transformiert diese Daten mittels unterschiedlicher Steueralgorithmen und legt sie im Speicher ab bzw. übergibt sie der Ausgabe. Als eigentlicher Datenprozessor realisiert es die logischen und arithmetischen Operationen.

Speicher (Memory)

Er enthält die Maschinenbefehle und die zu verarbeitenden Daten. Eine Folge von logisch zusammengehörenden Maschinenbefehlen bezeichnen wir als Programm. Sowohl Befehle als auch Daten befinden sich in einem gemeinsamen Adressraum. Der Speicher dient somit der kombinierten Ablage von Programmen und Daten.

Ein-/Ausgabe (Input/Output, I/O)

Sie verbindet die Peripheriegeräte (z.B. Tastatur, Monitor, Drucker) mit dem Rechenwerk, stellt also eine oder mehrere Schnittstellen zur Umwelt dar. Mittels passender Maschinenbefehle werden über die Ein-/Ausgabe Daten von der Peripherie entgegengenommen oder dieser übergeben. Ursprünglich wurden das Eingabewerk und das Ausgabewerk als zwei getrennte Funktionsblöcke betrachtet. Heute werden sie zur Ein-/Ausgabe zusammengefasst.

Bussystem

Die Schaltzentrale um alle vier Werke miteinander zu verbinden. Gleichzeitig der Flaschenhals im gesamten Rechner (neben dem Speicher als zentrale Ressource für Programm und Daten)

Abbildung 38-2. Betriebssystem und Von-Neumann-Rechner [bsdp]

Prozessoraufbau

Jede CPU-Familie besitzt einen spezifischen Instruktionssatz und Registeraufbau, mit dem sie sich von anderen Prozessorfamilien unterscheidet.

Deswegen muss ein Programm stets für die richtige CPU übersetzt sein, damit die Maschinenbefehle korrekt interpretiert werden.

Innerhalb einer sogenannten CPU-Familie kann jedoch der gleiche Code benutzt werden.

Ein Prozessor besteht grob gesehen aus den Teilen Leitwerk, Rechenwerk, Register, Adress- und Bussteuerung

Abbildung 38-3. Schematischer Prozessoraufbau [bsdp]

Register

Register sind Speicherzellen, aber im Mikroprozessor integriert, und teils mit spezieller Bedeutung, wie z.B.:

PC: Program Counter (Code Adresszeiger)

SP: Stack Pointer (Daten Adresszeiger)

Die Registersätze verschiedener CPu Familien können sich erheblich unterscheiden

Man unterscheidet grob drei Klassen CPU Architekturen, mit weitreichenden Folgen für Betriebssysteme und Programme (Erstellung):

Complex Instrucion Set Computer (CISC): Wenige Register, Operationen sind hauptspeicherzentriert, variables Instruktionsformat und Anzahl von Operanden ⇒ Intel x86 usw.

Reduced Instruction Set Computer (RISC): Viele Register, Operationen sind registerzentriert, teils fixes Instruktionsformat, wenige Operanden ⇒ ARM Cortex, Sun UltraSparc usw.

Stack Processors: Wenige Register, Operationen sind stackzentriert, 0-Operand Instruktionen ⇒ Green Array F18A (Dual stack Forth architecture), Virtual Machines!

Abbildung 38-4. Beispiele von CPU-Registern

Grundlagen der Programmausführung

Abbildung 38-5. Rechnereinteilung nach Adressanzahl: gezählt wird die Anzahl Operanden eines dyadischen Operators (z.B. einer Addition)

Grundlagen des Adressraums

Der Zugriff auf die Befehle eines Programms erfolgt ebenso wie auf alle im Speicher abgelegten Operanden mittels Speicheradressen. Die Organisation des Adressraums stellt damit eine wichtige Eigenschaft und allenfalls auch Limitierung einer bestimmten Rechnerplattform sowie des Betriebssystems dar.

Ein Adressraum Σ ist gekennzeichnet durch:

Gesamtgröße in Bits (oder Bytes)

Anzahl N=|Σ| von Speicherzellen

Anzahl der möglichen Adressen (u.U. ≠ Bytes!)

Anzahl der Bits pro Speicherzelle W, d.h. die Wortbreite

Adressen sind numerische (ganzzahlige) Werte und referenzieren Speicherzellen

I\.A. wird die erste Adresse mit 0, die letzte als N-1 bezeichnet.

Achtung: Die Adresse kann sich entweder auf die Speicherzelle mit der Wortbreite W oder die Elementarzelle mit der Breite B Bits (i.A. 1 Byte) beziehen!

Abbildung 38-6. Grundprinzip des Adressraums, hier Byteadressierung [bsdp]

Adressraumtypen

Der (Haupt-) Speicher M ist zunächst passiv und enthält Daten und Programmcode

Aber neben dem Speicher müssen auch Ein- und Ausgabegeräte P "adressiert" (selektiert) und mit denen kommuniziert werden...

Es gibt grob zwei Adressraumtypen bezüglich Speicher und Peripheriegeräten, die auch für das Betriebssystem sehr relevant sind:

Gemeinsamer Adressraum Σ=⟨M,P⟩

Getrennte Adressräume ΣM=⟨M⟩, ΣP=⟨P⟩

Abbildung 38-7. Klassischer und erweiterter Von-Neumann-Rechner

Speicher und Speicherverwaltung

Wie ist das Speichermodell eines Programs und Prozesses organisiert?

Wie ist das Speichermodell eines Betriebssystems organisiert?

Wie findet Speicherverwaltung statt?

Bytereihenfolge (byte ordering)

Ein ganz einfacher Prozessor könnte nur mit Datenwerten von 8 Bit Größe arbeiten, d.h., er würde über eine Speicheradresse stets nur ein einzelnes Byte lesen oder schreiben. Heutige Universalprozessoren unterstützen jedoch verschiedene Operandengrößen, die auch mehrere Byte umfassen können.

Abbildung 39-1. Varianten der Bytereihenfolge bei Mehrbyte-Datenwerten (Beispiel für 4-Byte-Wert)

Ausrichtungsregeln im Adressraum

Die Ausrichtungsregeln (alignment rules) legen fest, auf welchen Adressen Variablen und Instruktionen liegen müssen. Sie sind maßgebend für die Programmübersetzung (Compiler, Assembler, Binder). Ihr Zweck liegt in der Erreichung optimaler Ausführungsgeschwindigkeiten auf dem benutzten Rechnersystem. Da sie von der Hardware abhängen, können sie entsprechend der benutzten Rechnerplattform variieren. Ausrichtungsregeln können sowohl für Code als auch Daten existieren.

Abbildung 39-2. Fehlausrichtung im Adressraum und ihre Folgen (Beispiel für einen 32-Bit-Datenbus und einen Zugriff auf einen Datenwert von 32 Bit Größe) [bsdp]

Bedeutung für Instruktionscode: Abhängig von der Prozessorhardware gelten andere Ausrichtungsregeln, z.B.:

Instruktionen müssen immer auf geradzahligen Adressen liegen.

Bedeutung für Daten: Die Datenbusbreite bestimmt die Ausrichtungsregeln für eine optimale Zugriffsgeschwindigkeit.

Z\.B. ist der Datenbus 32 Bit breit. Damit ist eine optimale Ausrichtung für 4-Byte-Werte eine sogenannte Langwortgrenze, d.h. eine ohne Rest durch vier teilbare Adresse.

Im vorherigen Beispiel ist die Notwendigkeit von zwei Zugriffszyklen auf dem Datenbus gezeigt, wenn die Ausrichtungsregel nicht eingehalten wird (misalignment).

Beim C-Strukturdatentyp (struct) bzw. dem C++-Klassendatentyp (class) gelten die Ausrichtungsregeln jeweils separat für die einzelnen Teilkomponenten. Neben dem Einfluss auf die Zugriffsgeschwindigkeit spielt dies wiederum sowohl für die Kompatibilität beim Datenaustausch wie auch für den effektiv belegten Platz im Adressraum eine Rolle.

Abbildung 39-3. Einfluss der Ausrichtung auf die resultierende Adressraumplatzierung (Beispiel)

Adressraumbelegung durch Programme

Die Belegung des Adressraums durch ein Programm wird einerseits durch die Übersetzungswerkzeuge, andererseits durch das Betriebssystem festgelegt.

Im Grundsatz können etwa fünf verschiedene Bereiche unterschieden werden:

Code und Konstanten: Die zugehörigen Speicherinhalte werden aus der ausführbaren Datei in den Hauptspeicher geladen. Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Initialisierte Daten: Ein passender Bereich des Adressraums wird reserviert und anschließend werden die Initialwerte der Variablen aus der ausführbaren Datei dorthin geladen. Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Nicht initialisierte Daten: Ein passender Speicherbereich wird reserviert und eventuell gelöscht (d.h. mit 0 geladen). Dieser Adressbereich ändert seine Größe während der Programmausführung nicht.

Heap und Stack: Es wird ein gemeinsamer Bereich im Adressraum derart reserviert, dass die zwei Bereiche möglichst weit voneinander entfernt sind. Im Betrieb können sowohl Heap als auch Stack wachsen und schrumpfen.

Das Wachstum ist aber nur ungefährlich, solange sich die Heap- und Stack-Inhalte nicht überschneiden. Eine derartige Überschneidungssituation würde zu einem Fehler führen. Die Kunst liegt darin, diese Bereiche ausreichend groß zu wählen und eine mögliche Fehlsituation erkennen zu können. Ein Betriebssystem und/oder ein Laufzeitsystem einer Programmiersprache können hier allenfalls Hilfe anbieten, um Fehlsituationen zu erkennen.

Weitere Lösung des Kollisonsproblem: Getrennte Segmente für Heap und Stackspeicher eines Programms.

Abbildung 39-4. Alternative Layouts des Adressraums eines Programms (oder Prozesses als Programm in Ausführung)

Adressraumnutzung durch C-Programme

Für die Programmierung ist es hilfreich, die Platzierungsregeln für Variablen in der Programmiersprache C zu kennen. Nachfolgend sind sie kurz aufgeführt:

Global (d.h. außerhalb einer Funktion) deklarierte Variablen

Ohne Initialisierung werden sie im Bereich nicht initialisierte Daten platziert. Mit einer Initialisierung jedoch im Bereich initialisierte Daten. Diese unterschiedliche Behandlung wird deshalb gemacht, weil die Anfangswerte für initialisierte Daten sich aus der ausführbaren Datei laden lassen. Das Betriebssystem kann so diese zwei Fälle klar voneinander unterscheiden.

Innerhalb einer Funktion deklarierte Variablen

Ohne Angabe der Speicherklasse (implizit gilt auto) oder mit Angabe von auto: Es erfolgt eine Platzierung auf dem Stapel. Mit Angabe der Speicherklasse static: Platzierung wie global deklarierte Variablen.

Konstanten (const)

Platzierung im Bereich Code, Konstanten. Sowohl Code als auch Konstanten sind funktional gesehen nur lesbare Speicherinhalte. Da sie separat ausgezeichnet werden, kann das Betriebssystem die so belegten Speicherbereiche mit einem Schreibschutz belegen.

Dynamisch allozierte Daten

Platzierung auf der Halde (Heap). Reservation mittels der Bibliotheksfunktionen malloc() oder calloc(), Freigabe mittels der Bibliotheksfunktion free(). In C++ leisten die Operatoren new und delete Vergleichbares.

Aufrufparameter von Funktionen

Die Parameter stehen auf dem Stapel (Stack) bereit oder werden über allgemeine Prozessorregister übergeben (compilerabhängig konfigurierbar). Zu beachten ist, dass bei der Übergabe von Zeigern die Daten selbst anderswo liegen, d.h. nicht auf dem Stapel!

C2JS

int x=0;
int foo(int x) {
  int t=x;
  return t+1;
}
int main() {
  int y;
  int *yp;
  y=foo(x);
  yp=(int*)malloc(sizeof(int));
  *yp=y;
}

Beispiel 39-1. Beispiel eines C Programms

Abbildung 39-5. Variablenplatzierung im Adressraum (entsprechend Programmbeispiel)

Heap versa Stack Speicher

Beide nehmen Datenvariablen auf (nur in speziellen Fällen Programmcode, z.B. bei Virtuellen Maschinen);

Der Heap Speicher braucht eine explizite Speicherverwaltung

Verwaltung von belegten und freien Bereich im Heap

Verwendung von Tabellen- oder Listenstrukturen

Speicherallokation und Speicherrückgabe erfolgt durch das Programm (oder Betriebssystem) explizit;

Der Stack Speicher braucht keine explizite Speicherverwaltung, er verwaltet sich automatisch über einen einzigen Zeiger (Stack Pointer SP)

Die Allokation ist lediglich eine Verschiebung des SP

Der Heap kann fragmentieren (Verlöcherung), der Stack nicht.

Funktionsweise des Stapels und Stapelzeigers (SP)

Wenn Funktionen (Unterprogramme) aufgerufen werden wird:

Ein Funktionsrahmen angelegt, der u.A. die Rücksprungadresse enthält (return);

Werden alle lokalen Variablen

Die dynamischen Daten eines Unterprogrammaufrufs werden auf dem Stapel als Teil des sogenannten Aufrufrahmens bzw. Aktivierungsrahmens (stack frame) gespeichert. Sie stehen dort nur für die Dauer der Unterprogrammausführung zur Verfügung.

Der bei Unterprogrammaufrufen benutzte Aktivierungsrahmen betrifft direkt die Programmierschnittstelle des Betriebssystems und die Prozess-/Thread-Verwaltung!

Der Stapelspeicher (stack) besteht genau genommen aus einem dafür reservierten Teil des Adressraums und dem Prozessorsteuerregister Stapelzeiger (Stack Pointer, SP).

Der Stapelzeiger enthält jederzeit die Adresse des aktuellen oberen Endes des Stapels (Top Of Stack, TOS).

Im Stapel selbst befinden sich die Aufrufrahmen (stack frames) aller nicht beendeten Unterprogrammaufrufe sowie eventuell andere zwischengespeicherte Werte. Entsprechend sieht man oft den Begriff des call stack, d.h. des Aufrufstapels.

Der Stapel wird im deutschen Sprachraum teilweise als Kellerspeicher bzw. Keller bezeichnet.

Abbildung 39-6. Prinzip des Stapelspeichers

Der Stapel stellt die Grundinfrastruktur für die Unterprogrammtechniken dar.

Pro Unterprogrammaufruf wird ein Aktivierungsrahmen (stack frame), d.h. ein Datenbereich auf dem Stapel, angelegt.

Bei Beendigung des Unterprogramms wird der Datenbereich wieder freigegeben, was auch als Abräumen bezeichnet wird. Ein Aktivierungsrahmen enthält eines oder mehrere der folgenden Elemente:

Rücksprungadresse des Unterprogramms (d.h. Adresse, an der im aufrufenden Programm nach Unterprogrammende fortzufahren ist)

Unterprogramm-Aufrufparameter (Funktionsargumente)

Unterprogramm-Rückgabewerte

Lokale Variablen (in C sog. automatic variables)

Nach Bedarf weitere zwischengespeicherte Werte (computersprachabhängig)

Unterprogrammaufruf

Jeder Universalprozessor stellt für den Aufruf von Unterprogrammen und die Rückkehr in das Oberprogramm spezifische Maschinenbefehle zur Verfügung, die diesen Vorgang automatisieren helfen.

Wir bezeichnen nachfolgend diese zwei Maschineninstruktionen mit den Namen JSR (jump to subroutine) und RET (return from subroutine).

Abhängig vom benutzten Prozessor können diese Befehle etwas abweichend bezeichnet sein, arbeiten aber stets nach dem hier vorgestellten Verfahren.

Eine wichtige Grundeigenschaft des Unterprogrammaufrufs besteht darin, dass egal woher ein Unterprogramm aufgerufen wird, stets an die dem Aufruf direkt nachfolgende Programmstelle zurückgekehrt wird, wenn das Unterprogramm endet.

Dies wird technisch mithilfe des Stapels realisiert, indem die Rücksprungadresse für die Dauer der Unterprogrammausführung auf dem Stapel zwischengespeichert wird.

Für den Unterprogrammaufruf dient der JSR-Befehl, der die Rücksprungadresse auf dem Stapel ablegt und den Programmzähler mit der Startadresse des Unterprogramms lädt. Die Rückkehr in das Oberprogramm wird durch den RET-Befehl veranlasst, indem er das oberste Element vom Stapel entfernt und als neuen Wert in den Programmzähler, womit der Programmablauf an der Rücksprungstelle fortfährt.

Abbildung 39-7. Verwendung von Unterprogrammen (Beispiel)

Aufruf

Maschinenbefehl JSR

sichert Rücksprungadresse auf Stapel

lädt PC mit UP-Adresse

Rücksprung

Maschinenbefehl RET

holt gesicherte Rücksprungadresse vom Stapel und lädt sie in den Programmzähler PC (damit: Rückkehr zu OP)

OP: Oberprogramm&br; UP: Unterprogramm

Durch die Verwendung des Stack Speichers und der LIFO Semantik wird auch Funktionsrekursion und geschachtelte Azfrufe unterstützt!

[bsdp]

Abbildung 39-8. Geschachtelter Unterprogrammaufruf (Beispiel)

Paging und Segmentierung

Bisher gab es nur einen gesamten Speicherbereich mit der Unterteilung in die Bereiche Programm, statische Daten, dynamische Daten und temporäre Daten.

In einem Betriebssystem werden (außer bei Mikrokontrollerrechnern) mehrere Prozesse, d.h. Programme in Ausführung, in einem Multiplexbetrieb quasi parallel betrieben.

Jetzt muss jeder Prozess eigene Speichersegmente besitzen

Es bedarf jetzt einer hierarchischen Speicherverwaltung: 1. Segmente / alle Prozesse, 2. Speicherbereiche in Segmenten eines Prozesses.

Aber halt: schauen wir uns von einer Programmdatei die Symboltabelle an so finden wir z.B.:

objdump ball.exe  -h

ball.exe:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00006c68  80000000  80000000  00001000  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .rodata       0000000b  80007000  80007000  00008000  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .eh_frame     00001038  8000700c  8000700c  0000800c  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .data         00000004  8000a000  8000a000  0000a000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  4 .bss          00001238  8000a020  8000a020  0000a004  2**5
                  ALLOC

Beispiel 39-2. Die Segmente eines Programms bevor es in den Speicher geladen wird. VMA: Virtual Mmeory Address, LMA: Linear Memory Address. Stack und Heap gibt es hier noch nicht! .text: Programmcode, .rodata: Konstante Daten, .data: statische globale und initialisierte Daten, .bss: statische globale nicht initialisierte Daten

Abbildung 39-9. Gruppierung verschiedener Programmteile [bsdp]

Würde man alle Programme nach dieser Anweisung in den Speicher laden würden sich alle Programme im Speicher überdecken.

Man könnte alle Programmadressen umrechnen (Offset), aber das müsste man nicht nur für die Segmente machen, sondern für alle Maschineninstruktionen die mit absoluten Adressen arbeiten. Keine gute Idee.

Man könnte Virtuelle Speicherräume und Virtuellen Speicher einführen. Gute Idee, aber...

Es nützt uns nichts wenn wir (oder das Betriebssystem) die physikalischen Speicheradressen (P Raum) auf virtuelle Adressen (für jeden Prozess, der V Raum) abbilden. Die Hardware muss das tun, und das geschieht durch die Memory Management Unit (MMU).

Die MMU arbeitet mit Tabellen die Adressen von Segmenten vom V in den P Raum transformieren. Damit die Tabellen um Umfang nicht zu groß werden werden Speichersegmente nur in Seiten (Pages) verwaltet, und Page Units (Größe z.B. 4096 Bytes).

Dynamische Segmentierung kann auch direkt vom Mikroprozessor unterstützt werden, so dass kein virtueller Speicherraum benötigt wird.

Dazu werden für einen Speicherzugriff Segmentregister verwendet, die dann den oberen Teil einer Speicheradresse zusammen mit der von einer Maschineninstruktion gelieferten Adresse zusammengerechnet werden:

                            ┌――――――――――┐ 0
   ┌――――――┐                 │          │
   │  SS  ├――――――――――――┐    │          │
   └――――――┘            │    │          │
                       └―――▶│          │5000
   ┌――――――┐                 │          │
┌――┤  DS  ├――――――――――――――――▶│          │10000
│  └――――――┘                 │          │10100
│                       ┌――▶│          │10102
│  ┌――――――┐             │   │          │
│  │  CS  ├―――――――――――――┼――▶│          │20000
│  └――――――┘             │   │          │
│                       │   │          │
│                       │   │          │
└―▶ add $100,$102 ――――――┘   │          │
                            │          │
                            └――――――――――┘ N-1

Beispiel 39-3. Die Programmadresse ergibt sich aus PC+CS, die Datenadressen aus DS+

Verwaltung von Prozessadressräumen

Dynamische Segmentierung kann erfolgen durch:

MMU (Tabellen)

Prozessor (Register)

Egal welche Methode, bei jedem Prozesswechsel müssen die Tabellen oder Register umgeladen werden.

Nachdem ein Programm übersetzt wurde, steht es dem Betriebssystem in Form einer ausführbaren Datei zur Verfügung.

Beim Laden eines Programms müssen Bereiche im Adressraum zum Beispiel für Code, Daten, Heap und Stack reserviert werden.

Ein laufendes Programm kann zudem Threads erzeugen, gemeinsame Speicherbereiche mit anderen Prozessen einrichten und weitere Aktionen ausführen, die wiederum Bereiche im Prozessadressraum betreffen.

Das Betriebssystem muss über alle diese Belegungen des Adressraums Buch führen. Nur so ist es in der Lage, neue Reservierungen ohne Kollisionen mit bestehenden Belegungen durchzuführen.

Prozessadressräume sind also i.A. virtuell, so dass jeder Prozess aus seiner Sicht immer die gleiche "Startadresse" hat.

Vorteile: Einfache Kompilierung und Mehrfachausführung einer Programmdatei ist möglich

Nachteile: Hardwareunterstützung (MMU), Verwaltungsaufwand, Prozesswechsel

Segmente sind Speicherregionen die vom Betriebssystem verwaltet werden müssen (Partitionierung des physikalischen Speichers).

Auch hier muss zwischen Anwendungsprozessen und dem Betriebssystemprozess unterschieden werden. Der virtuelle Adressraum ist auch zu partitionieren:

Abbildung 39-10. Beispiele von Adressraumaufteilungen

Für jeden Prozess benötigt das Betriebssystem eine Buchhaltung der im Adressraum belegten und freien Bereiche. Da bei vielen Prozessen der Adressraum nur schwach belegt ist, wird typischerweise nur die Belegung festgehalten.

Dies ist die Voraussetzung dafür, dass sich während des Prozessablaufs zusätzliche Bereiche kollisionsfrei reservieren lassen.

Steht eine passende Hardwareunterstützung (MMU) zur Verfügung, so können zudem Fehlzugriffe auf unbelegte Bereiche festgestellt werden.

Abbildung 39-11. Funktionsweise einer MMU+CPU Architektur

Dynamische Speicherreservierung von Regionen oder Erweiterungen

Anlässe für nachträgliche Reservierungen können sein:

Thread-Erzeugung: Es wird ein Stack-Bereich bereitgestellt (evtl. sogar zwei Stack-Bereiche, getrennt für Benutzer- und Kernmodus).

Einrichten gemeinsamen Speichers (shared memory): Es wird ein Adressbereich bereitgestellt, der den Datenaustausch zwischen mehreren Prozessen ermöglicht.

Laden einer Bibliotheksdatei: Ihr Inhalt wird geladen und in einem Bereich des Adressraums sichtbar gemacht.

Gemeinsame Bibliothek (shared library) nutzen: Von mehreren Prozessen gemeinsam genutzte Bibliotheken werden einmal geladen und für alle beteiligten Prozesse sichtbar gemacht.

Speicherbasierte Datei einrichten (memory mapped file): Der Dateiinhalt oder Teile davon werden über einen Bereich im Adressraum sicht- und änderbar gemacht.

Regionen

Im Adressraum belegte Bereiche werden Regionen genannt. Sie repräsentieren lückenlos zusammenhängende Adressbereiche mit unterscheidbaren Eigenschaften. Wichtige Attribute einer Region sind:

Startadresse: Dies ist die Adresse, mit der die Region beginnt (niedrigster Adresswert)

Größe: Die Anzahl zugehöriger Adresswerte (als Anzahl Byte)

Schutzattribute: Les-, schreib- oder ausführbar bzw. Kombinationen davon (die Durchsetzung benötigt spezielle Hardwareunterstützung)

Zugehöriger Hintergrundspeicher: Dateiname und Position innerhalb der Datei (kann undefiniert sein; benötigt zudem Softwareunterstützung)

Für das Suchen von Lücken im Adressraum sind nur die Attribute 1 und 2 von Belang. Wird ein virtuelles Speichersystem realisiert, so erlaubt das Attribut 3 die Erkennung von Fehlzugriffen und das Attribut 4 die Zuordnung zu einer Datei. Eine Region kann einem Hintergrundspeicher zur Auslagerung zugeordnet sein

Für die Verwaltung von Regionen werden i.A. verkettete Listen verwendet. Ausnahme bei hart echtzeitfähigen Systemen wo statische Tabellen verwendet werden (Hashtabellen).

Abbildung 39-12. Regionen pro Prozess [bsdp]

Abbildung 39-13. Prozessadressraumbeschreibung unter Linux (Beispiel) [bsdp]

... wenn wir schon virtuelle Speicherräume haben:

Swapping

Die Idee liegt darin, ganze Prozesse oder Speicherbereiche auszulagern, wenn sie im Moment keine Rechenzeit benötigen. Für diesen Zweck wird auf der Festplatte ein spezieller Bereich (Swapping Area) reserviert.

In seiner Grundform basiert das Swapping auf der Multiprogrammierung mit festen Partitionen, die um das Ein- und Auslagern von Prozessen erweitert wird.

Das Swapping wird vor allem dann benutzt, wenn mehrere Benutzer auf einem Rechner arbeiten (z.B. ältere Unix-Systeme).

Bei sehr vielen gleichzeitigen Benutzern kann es dann passieren, dass das Betriebssystem nur noch mit Swappen beschäftigt ist. In dieser Situation könnte es hilfreich sein, wenn das Betriebssystem den Grad der Multiprogrammierung reduziert bzw. beschränkt, was jedoch in Dialogsystemen unakzeptabel ist.

In einem Stapelverarbeitungssystem wäre hingegen das längere Anhalten eines Prozesses kein Problem, womit für die übrigen Aufträge ausreichend Speicher verfügbar wäre.

Abbildung 39-14. Swapping Prinzip

Bei der Partitionierung von Speicher legt man Regionen vorab fest die dann an Prozesse vergeben werden. Mit Swapping findet dann Austausch zur Laufzeit statt.

Abbildung 39-15. Dynamische Partitionierung beim Swapping (Beispielsabfolge)

Idee gut, aber zeitintensiv, gerade wenn ein Prozess nur kurz rechnet ...

Abbildung 39-16. Beispiele für Zeitbedarfe beim Swapping-Verfahren

Virtueller Speicher

Zusammengefasst sind dies die wesentlichsten Eigenschaften:

Jeder Prozess kann den Adressraum fast beliebig belegen, d.h. teilt ihn höchstens mit dem Betriebssystem selbst.

Jeder Prozess ist gegen Fehlzugriffe aller anderen Prozesse geschützt (Schreibund Leseschutz!), auch das Betriebssystem ist geschützt.

Alle Prozesse sind von der vorhandenen Hauptspeichergröße weitgehend unabhängig (mehr Adressraum als real vorhanden).

Abbildung 39-17. Virtualisierung des Speichers (Beispiel für 32-Bit-Adressierung)

Flexibilität ...

Abbildung 39-18. Flexible Platzzuteilung dank Adressumsetzung.

Segmentierte versa seitenweise Adressierung, nicht ganz so flexibel aber einfacher und kompakter umzusetzen ...

Abbildung 39-19. Segmentbasierte Adressumsetzung

Seitenweise versa segmentierte Adressierung, nicht ganz so einfach aber viel flexibler, gerade bei Reallozierung und Bereichserweiterung ...

Abbildung 39-20. Die Umsetzung einer virtuellen Adresse in eine physikalische Adresse mit doppelter Adressberechnung und Seitentabellen

Man beachte, dass der tatsächlich vorhandene Hauptspeicher zwar ebenfalls in Seiten (Speicherpartitionen) eingeteilt sein kann, die effektive Lage und Größe der virtuellen Seite durch den Adressierungsmechanismus aber unabhängig davon ist. Die dadurch definierte, physikalische Speicherportion wird deshalb auch als Seitenrahmen (page frame ) bezeichnet.

Die MMU

Die Memory Management Unit ist eine Hardwarekomponente und verwaltet Abbildungen von physikalischen Speicher auf virtuelle Adressräume.

Dazu werden Umsetzungstabellen verwendet, die sog. Seitentabellen (Paging).

Der Speicher wird seitenweise aufgeteilt (z.B. 4kB Größe)

Abbildung 39-21. Adressumsetzung mittels MMU und TLB (Beispiel)

Zusammenfassung

Jedes Programm besteht aus verschiedenen Speicherbereichen: Heap, Stack, Datenbereiche, Code

C bildet das Prozesspeichermodell sehr konkret durch globale, lokale, und temporäre Variablen sowie Funktionen ab, und ermöglicht den Speicherzugriff über Zeiger ("Specihermodell")

Die einzelnen Programmbereiche können auf verschiedene Regionen im Speicher abgebildet werden.

Dynamische Speicherverwaltung benötigt Datenstrukturen die belegte und freie Regionen verwalten, der Prozess kann ebenfalls in seinen Regionen eine solche Speicherverwaltung haben (Verwaltungshierarchie)

Das Problem von Adresskollision kann durch Virtuelle Adressraume gelöst werden, die über eine MMU oder einen VMM auf physikalische Adressen abgebildet werden.

Was noch fehlt: Wie wird Speicher effizient verwaltet? Also die Algorithmik . coming soon.

Speicherverwaltung - Algorithmen

Eines der wichtigsten Betriebsmittel ist der Hauptspeicher . Die Verwaltung der Speicherressourcen ist deshalb auch ein kritisches und lohnendes Thema, das über die Leistungsfähigkeit eines Rechnersystems entscheidet. Dabei unterscheiden wir drei Bereiche, in denen unterschiedliche Strategien zur Speicherverwaltung angewendet werden:

Anwenderprogramme

Hauptspeicher

Massenspeicher

Anwenderprogramme

Der Speicherraum eines Prozesses

Die Hauptaufgabe besteht hierbei darin, interne Speicherbereiche des Prozesses (beispielsweise den Heap) mit dem Wissen um die speziellen Speicheranforderungen des Programms optimal zu verwalten.

Dies erfolgt durch spezielle Programmteile, z. B. den Speichermanager, oder durch sog. garbage collection Programme in Virtuellen Maschinen.

Hauptspeicher

Hier besteht das Problem in der optimalen Aufteilung des gesamten knappen Hauptspeicherplatzes auf die einzelnen Prozesse.

Die Aufgabe wird in der Regel durch spezielle Hardwareeinheiten unterstützt (MMU).

Besonders in Multiprozessorsystemen ist dies wichtig, um Konflikte zu vermeiden, wenn mehrere Prozessoren auf dieselben Speicherbereiche zugreifen wollen.

In diesem Fall helfen Techniken wie Non-Uniform Memory Access (NUMA) weiter, die zwischen einem Zugriff des Prozessors auf lokalen und auf globalen Speicher unterscheiden.

Massenspeicher

Abgesehen von der Dateiverwaltung, die noch besprochen wird, gibt es auch spezielle Dateien (z. B. bei Datenbanken), bei denen der Platz innerhalb der Datei optimal eingeteilt und verwaltet werden muss.

Ein Beispiel dafür ist die Swap-Datei, auf die Prozesse verlagert werden, die keinen Platz mehr im Hauptspeicher haben.

Direkte Speicherbelegung

In den frühen Tagen der Computernutzung war es üblich, für jeden Job den gesamten Computer mit seinem Speicher zu reservieren.

Der Betriebssystemkern bestand dabei oft aus einer Sammlung von Ein- und Ausgabeprozeduren, die als Bibliotheksprozeduren zusätzlich an den Job angehängt waren.

Musste man einen Job zurückstellen/wechseln, um erst einen anderen durchzuführen, so wurden alle Daten des dazugehörenden Prozesses auf den Massenspeicher verlagert und dafür die neuen Prozessdaten vom Massenspeicher in den Hauptspeicher befördert. (Swapping)

Viele Nachteile (Overhead, "Reibung"). Besser die Daten mehrerer Prozesse gleichzeitig im Speicher halten, sobald genügend Speicher vorhanden war.

Allerdings brachte dies weitere Probleme mit sich: Wie kann man den Speicher so aufteilen, dass möglichst viele Prozesse Platz haben? Diese Frage stellt sich prinzipiell nicht nur für den Hauptspeicher , sondern gilt natürlich auch für den Auslagerungsplatz auf der Festplatte, den swap space.

Zuordnung durch feste Tabellen

Die einfachste Möglichkeit besteht darin, ein verkleinertes Abbild des Speichers zu erstellen: die Speicherbelegungstabelle . Jede Einheit einer solchen Tabelle (beispielsweise ein Bit) ist einer größeren Einheit (z. B. einem 32-Bit-Wort) zugeordnet. Ist das Wort belegt, so ist das Bit Eins, sonst Null.

Abbildung 40-1. Eine direkte Speicherbelegung und ihre Belegungstabelle [BSG]

Eine solche Belegungstabelle benötigt also bei 32 MiB Speicher selbst 1 MiB.

Soll ein freier Platz belegt werden, so muss dazu die gesamte Tabelle auf eine passende Anzahl von aufeinanderfolgenden Nullen durchsucht werden.

Komplexitätsklasse O(N) bei N Bytes

Besser: Paging (Einteilung des Speichers in Blöcke), reduziert die Tabellengröße

Zuordnung durch verzeigerte Listen

Der belegte bzw. freie Platz ist meist wesentlich länger als die Beschreibung davon.

Die Belegung des gesamten Hauptspeichers mit belegten und freien Plätzen kann dünn besetzt sein.

Aus diesem Grund lohnt es sich, anstelle einer festen Tabelle eine Liste von Einträgen über die Speicherbelegung zu machen, die in der Reihenfolge der Speicheradressen über Zeiger miteinander verbunden sind.

Ein Eintrag der Liste besteht aus drei Teilen:

der Startadresse des Speicherteils,

der Länge und

dem Index (Zeiger) des nächsten Eintrags.

Abbildung 40-2. Eine Speicherbelegung und die verzeigerte Belegungsliste [BSG]

Eine solche Belegungsliste lässt sich auch in zwei Listen zerteilen:

eine für die belegten Bereiche, deren Einträge im Fall von Prozessen mit dem Prozesskontrollblock PCB verzeigert sind, und

eine nur für die unbelegten Bereiche.

Ordnen wir diese Liste noch zusätzlich nach der Größe der freien Bereiche, so muss normalerweise nie die ganze Liste durchsucht werden.

Allerdings wird auch dadurch das Verschmelzen angrenzender, freier Bereiche erschwert. Eine doppelte Verzeigerung ermöglicht das Durchsuchen der Liste in beiden Richtungen.

Beispiel: Heap Liste

typedef unsigned int memoryptr;
typedef int size;
struct heap_entry {
  memoryptr  adress;
  memorysize size;
  struct heap_entry next;
};
struct heap_entry *allocated;
struct heap_entry *free;

Definition 40-1. Eine listenbasierte Speicherverwaltung ist beispielsweise für das Speichermanagement eines Heaps sinnvoll. Es wird meistens in Blockgrößen gerechnet (z.B. 4kB), nicht in Byteeinheiten

Es ist einfacher und schneller, nur eine einzige Liste zu führen, die Liste der freien Plätze.

Allerdings ist es sicherer, auch eine Liste der belegten Plätze zu führen, um bei der Platzrückgabe die Angaben über die Lage und die Länge des zurückgegebenen Platzes überprüfen zu können und so Fehlprogrammierungen des Benutzerprogramms aufzudecken.

Besonders in der Debugging-Phase ist dies durchaus sinnvoll; eine solche Überprüfung kann dann in späteren Phasen bei der Laufzeitoptimierung abgeschaltet werden.

Zuordnung durch Baumstrukturen

Listen haben eine Such- und Bearbeitungslaufzeit der Klasse O(N) bei N Elementen, Bäume hingegen (im Mittel) von log(N)N.

Daher bieten sich Bereichsbäume an um belegte und freie Speicherbereiche zu verwalten.

Jedoch bedürfen wie in der Natur Bäume einer Gartenpflege, Listen i.A. nicht.

Ein Baum ist nur dann effizient wenn er ausbalanziert ist.

Wenn er balanziert ist, dann ist die Suche schneller als bei Listen

Aber beim Speichermanagement müssen häufig Änderungen vorgenommen werden, was zum Ungleichgewicht führen kann, was dann wieder korrigiert werden muss.

Extremfall eines Baums: Die Liste!

Belegungsstrategien

Unabhängig von dem Mechanismus der Speicherbelegungslisten gibt es verschiedene Strategien, um aus der Menge der unbelegten Speicherbereiche den geeignetsten auszusuchen.

Ziel der Strategien ist es, die Anzahl der freien Bereiche möglichst klein zu halten und ihre Größe möglichst groß.

Die wichtigsten Strategien sind:

FirstFit

Das erste, ausreichend große Speicherstück, das frei ist, wird entsprechend belegt. Dies bringt meist ein Reststück mit sich, das unbelegt übrig bleibt.

NextFit

Die FirstFit-Strategie führt dazu, dass in den ersten freien Bereichen nur Reststücke (Verschnitt) übrigbleiben, die immer wieder durchsucht werden. Um dies zu vermeiden, geht man wie FirstFit vor, setzt aber beim nächsten Mal die Suche an der Stelle fort, an der man aufgehört hat.

BestFit

Die gesamte Liste bzw. Tabelle wird durchsucht, bis man ein geeignetes Stück findet, das gerade ausreicht, um den zu belegenden Bereich aufzunehmen.

WorstFit

Sucht das größte vorhandene freie Speicherstück, so dass das Reststück möglichst groß bleibt.

QuickFit

Für jede Sorte von Belegungen wird eine extra Liste unterhalten. Dies gestattet es, schneller passende Freistellen zu finden. Werden beispielsweise in einem Nachrichtensystem regelmäßig Nachrichten der Länge 1 KB verschickt, so ist es sinnvoll, eine extra Liste für 1-KB-Belegungen zu führen und alle Anfragen damit schnell und ohne Verschnitt zufriedenzustellen.

[bsdp]

free={addr:0,                 last=free                       function WorstFit(size) {
      size:N,                 function NextFit(size) {          block=free; biggest=null
      next:null}                if (last && last.size>=size)    while(block) {
                                  block=last                      if (block.size>=size) {
function FirstFit(size) {       else                                if (!biggest ||
  block=free                      block=free                            biggest.size<block.size)
  while(block.size<size) {      while(block.size<size) {              biggest=block;
   block=block.next              block=block.next                 }
  }                             }                                 block=block.next
  if (!block)                   if (!block)                     }
    return null;                 return null;                   if (!biggest) return null;
  addr=block.addr               addr=block.addr                 addr=biggest.addr
  block.size-=size              block.size-=size                biggest.size-=size
  block.addr+=size              block.addr+=size                biggest.addr+=size
  return addr                   last=block                      return addr
}                               return addr                   }
                              }

Algorithmus 40-1. FirstFit versa NextFit versa WorstFit

Buddy-Systeme

Den Gedanken von QuickFit kann man nun dahin erweitern, dass man für jede gängige Belegungsgröße, am besten Speicher in den Größen von Zweierpotenzen, eine eigene Liste vorsieht und nur Speicherstücke einer solchen festen Größe vergibt.

Alle Anforderungen müssen also auf die nächste Zweierpotenz aufgerundet werden. Ein Speicherplatz der Länge 280 Bytes = 256 + 16 + 8 = 2⁸ + 2⁴ + 2³ Bytes muss also auf 2⁹ = 512 Bytes aufgerundet werden.

Ist kein freies Speicherstück der Größe 2k vorhanden, so muss ein freies Speicherstück der Größe 2k+1 in zwei Stücke von je 2k Byte aufgeteilt werden. Beide Stücke, die Partner (Buddy), sind genau gekennzeichnet: Ihre Anfangsadressen sind identisch bis auf das k-te Bit in ihrer Adresse, das invertiert ist. Beispiel: … XYZ0000 … und … XYZ1000 … sind die Anfangsadressen von Partnern.

Dies lässt sich ausnutzen, um sehr schnell (in einem Schritt!) prüfen zu können, ob ein freigewordenes Speicherstück einen freien Partner in der Belegungstabelle hat, mit dem es zu einem (doppelt so großen) Stück verschmolzen werden kann, ohne freie Reststücke zu hinterlassen.

Beispiel Buddy-Speicherzuweisung

Angenommen, wir benötigen Speicherstücke der Größen 80 KB, 8 KB, 9 KB, 3 KB und 50 KB in der genannten Reihenfolge von einem Speicherstück von 256 KB.

Dann lässt sich die Zuordnung ganz gut mit Hilfe eine Binärbaumes visualisieren, bei dem jedes Speicherstück durch seine beiden Buddy notiert ist.

Da es sich jedes Stück in zwei Unterstücke aufspalten lässt, bilden alle Speicherstücke zusammen einen Binärbaum.

Abbildung 40-3. Visualisierung einer Speicherzuteilung im Buddy-System [bsdp]

Man sieht, dass jedes Speicherstück, das benutzt wird, meist noch ein unbenutztes Reststück, den Verschnitt, enthält.

Beide Vorgänge, sowohl das Suchen eines passenden freien Stücks (bzw. das dafür nötigen Auseinanderbrechen eines größeren) als auch das Zusammenfügen zu größeren Einheiten lässt sich rekursiv über mehrere Partnerebenen (mehrere Zweierpotenzen) durchführen.

Bewertung der Strategien

Der Vergleich der verschiedenen Strategien führt zu einer differenzierten Bewertung. So stellt sich heraus, dass FirstFit von der Platzausnutzung etwas besser als NextFit und WorstFit ist und überraschenderweise auch besser als BestFit, das dazu neigt, nur sehr kleine, unbelegbare Reste übrig zu lassen. Eine Anordnung der Listenelemente nach Größe der Bereiche erniedrigt dabei die Laufzeit von BestFit und WorstFit.

Wissen wir mehr über die Verteilung der Speicheranforderungen nach Zeit oder Beleggröße, so können mit QuickFit und anderen, speziellen Algorithmen bessere Resultate erzielt werden!

Die Leistungsfähigkeit des Buddy-Systems lässt sich mit folgender Rechnung kurz abschätzen: Nehmen wir an, für den Hauptspeicher der Größe 2n werden alle 2n möglichen Stückgrößen S daraus mit gleicher Wahrscheinlichkeit verlangt (was sicher nicht vorkommt, aber mangels plausiblerer Annahmen vorausgesetzt sei). Dann ist die mittlere Speicheranforderung 〈S(n)〉 mit

Profiling zur Laufzeit kann helfen den richtigen Algorithmus auszuwählen.

[file:///home/sbosse/skripte/bs1k/doc/BetriebssystemeGrundlagen/html/49321_4_De_3_Chapter.xhtml#Sec3]

Zeitmanagement

Timer

In einem Betriebssystem müssen häufig zeitliche Abläufe synchronisiert werden.

Zeitauflösung für Ereignisse könnte Mikro- oder Millisekunden sein

Eine solch hohe Auflösung ist aber meistens nicht erforderlich

Beispiel: Ein Mikroprozessr arbeitet mit einem Kerntakt von 72MHZ. Dann beträgt die minimale Ausführungszeit einer Maschineninstruktion ca. 20 ns, typischerweise nebötigt ein Funktionaufruf einer kleinen Funktion ca. 50 Instruktionen, was 1 μs entspricht.

Daher wird in den meisten Betriebssystemen eine virtuelle Zeiteinheit genutzt, die Ticks.

Ein Tick entspricht Zeitintervallen typischerweise im Breich 10-100 ms

Prozessscheduling wird bei Zeitscheibenverfahren in Ticks behandelt, d.h. eine Zeitscheibe häufig ein Tick

Programmable Interval Timer

Für die "Erzeugung" von Ticks werden Intervalltimer verwendet, also binäre Zähler. Diese stellen die Systemuhr des betriebssystem zur Verfügung. Nicht zu verwechseln mit der Real Time Clock (RTC), die eine nicht flüchtige Echtzeit in Sekunden usw. ausgibt.

[Wikipedia]

Der Programmable Interval Timer (PIT) war ursprünglich ein spezieller Baustein (Chip) im IBM-PC, der für die zeitliche Steuerung von Prozessen sorgte. Die am häufigsten verwendeten Timer-Chips waren die Typen 8253 und 8254 von Intel mit drei 16-Bit breiten Zählern.

Abbildung 41-1. Die Architektur eines PITs (8254) [A\. Sahu, Dept. of Comp. Sc. & Eng. IIT Guwahati]

Die Ausgänge der drei Zähler können/sind mit dem Interruptcontroller verbunden

Der PIT wird, unabhängig von der Rechnergeschwindigkeit, stets im Takt von (idealerweise) 1,19318181818… MHz betrieben; das ist 1/3 der Farbträger-Frequenz des NTSC-Fernsehsystems!

In den ersten PCs von IBM wurde diese merkwürdig anmutende Frequenz gewählt, da für die Einbindung einer CGA-Grafikkarte, die auch Fernsehgeräte als Monitor verwenden konnte, ohnehin ein Quarz mit einem Vielfachen des Farbträgers vorhanden sein musste.

Aus diesem Quarz konnte die PIT-Frequenz dann leicht mittels Frequenzteilung gewonnen werden; diese Vorgehensweise war günstiger als der Einsatz weiterer Quarze, da mit nur einem Quarz in der Schaltung keine störenden Schwebungen das Bildsignal beeinträchtigen können, die dann mit weiterem Schaltungsaufwand herausgefiltert werden müssten.

Auch der CPU-Takt der ersten PCs von 4,772727 MHz ergab sich aus einer solchen Erwägung, er betrug 4/3 der Farbträgerfrequenz.

Der PIT hat verschiedene Betriebsarten:

Standard Interrupt Modus: Interrupt bei Erreichen eines einstellbaren Zählwerts (Über- oder Unterlauf)

Periodischer Modus: Erzeugung eines Rechtecksignals

Periodischer Modus: Erzeugung von Pulssignalen

Software Modus: Softwaresteuerung des Zählers

Im Standard-Modus zählt der Timer vom Startwert (maximal 65535) auf 0 hinunter und fängt beim folgenden Überlauf wieder von vorne an.

Bei jedem solchen Überlauf sendet er ein Signal, den Timer-Interrupt (IRQ0), an den Prozessor.

Wenn man die Taktfrequenz durch die Takte bis zum erneuten Überlauf dividiert, erhält man die Frequenz, mit der der IRQ0 erzeugt wird.

Unter MS-DOS und älteren Windows-Versionen bis einschließlich Windows 98 und Windows ME wurde der Maximalwert 65535 in den Timer geladen, man erhält somit 1193181,8181… Hz / 65536 = ca. 18,2065 Hz.

Es kommt auf diese Art ein Intervall von ca. 55 ms zwischen den Aufrufen des IRQ 0 zustande.

Modernere Betriebssysteme (Linux, Windows NT und Abkömmlinge) verwenden kürzere Intervalle zwischen 1 und 10 ms.

Mehr Info gibt es hier:

https://wiki.osdev.org/Programmable_Interval_Timer

Ticks,Frequenzen, Zeiten und Timer

Folgende Parameter sind die Zutaten des Zeitmanagements im Betreibssystem:

image-placeholder
(41-1)

D.h. der Timer muss immer mit TIMER_COUNT intialisiert werden um die Vorgabe TICKS_PER_SECOND zu erreichen.

Im Basekernel werden die Ticks als Clicks bezeichnet.

Die kleinste von Programmen (und dem Kernel selber) messbare Zeiteinheit ist 1/TICKS_PER_SECOND, also z.B. 20 ms

Benötigt man eine genauere Zeitmessung so müssen entweder andere Timer oder der aktuelle Timercount des PIT benutzt werden.

Bekannt ist mindestens die Anzahl der Ticks (Clicks), dann kann man den aktuellen Zählerwert des PIT auslesen und kann die Zeit zwischen zwei Ticks intgerpolieren.

Bei ca. 1 MHZ Timerfrequenz ist die kleinste Auflösung 1 μs.

Aufgabe: Implementiere im Basekernel eine neue Funktion clock_micros die die Systemzeit in Mikrosekunden zurück gibt.

Virtuelle Machinen

Abstraktion von Rechnern, Betriebssystemen und Software

Virtuelle Maschinen und Virtualisierung sind zentraler Bestandteil im Betrieb moderner Rechneranlagen und Bestandteil von Betriebssystemen

Die Virtualisierung von Rechnerressourcen bringt in verschiedener Hinsicht neue Nutzen. Sie kann auf verschiedenen Stufen erfolgen und ist bereits teilweise in den Konzepten des Prozessmodells und des virtuellen Speichers realisiert.

Anwendungsbereiche

Eine Reihe von Virtualisierungsansätzen im Umfeld des Betriebssystems:

Virtuelle Prozessoren: Simulation einer CPU-Hardware durch Software

Virtuelle Prozessumgebungen: Nutzung virtueller Adressräume für Prozesse (Raummultiplex) zusammen mit CPU-Scheduling (Zeitmultiplex)

Virtuelles Betriebssystem: Ausführung unveränderter (Binär-)Programme eines Betriebssystems A unter einem Betriebssystem B

Virtueller Desktop: entfernter Zugriff auf einen grafischen Desktop über das Netzwerk

Virtuelle Ressourcen: Sekundärspeicher- und Netzwerkvirtualisierung

Sandboxing: virtuelle Prozesslaufzeitumgebungen inklusive Betriebssystem (Abstrakter Prozessor)

Virtuelle Computer: Virtualisierung der gesamten Computerhardware mithilfe eines Virtual Machine Monitor (Hypervisor)

Taxonomie der Virtuellen Maschinen

Es können zwei wesentliche Klassen unterschieden werden:

Prozess Virtuelle Maschinen

Virtuelle Prozessoren oder Virtuelle Anwendungsmaschinen die eine Anwendung innerhalb oder außerhalb eines Betriebssystems ausführen und einen einzelnen Prozess unterstützen. Java Virtual Machines, auf denen in Java kompilierte Programme ausgeführt werden, sind Beispiele für Prozess-VMs. Es wird ein Prozessor simuliert.

System Virtuelle Maschinen

System-VMs ermöglichen die gemeinsame Nutzung der zugrunde liegenden physischen Maschinenressourcen durch verschiedene Virtual Machines, die jeweils ihr eigenes Betriebssystem ausführen. Es wird kein Prozessor simuliert, aber je nach Typ emuliert.

Simulation

Abtrakte Maschine ohne Kompatibilität zu bestehender Hardware

Emulation

Konkerete Maschinen mit bestmöglicherKompatibilität zu bestehender Hardware

Abbildung 42-1. Übersicht und Klassendiagramm der VM Architekturen und Methodem [VM,Smith,2005]

Virtualisierung

Abbildung 42-2. Virtualisierung. Formal ist Virtualisierung die Konstruktion eines Isomorphismus zwischen einem Gastsystem und einem Host; e' ◦ V(S~i~) = V ◦ e(S~i~). Dabei ist e eine Funktion im Gastsystem, die den Zustand S~i~ in S~j~ überführt. Die Virtualisierung V "übersetzt" den Zustandsübergang in das Hostsystem. Im Host gibt es eine entsprechende Abfolge von Operationen e', die eine äquivalente Änderung des Hostzustands vornimmt (Änderungen S'~i~ zu S'~j~). [VM,Smith,2005]

Abbildung 42-3. Implementierung virtueller Festplatten. Virtualisierung bietet eine andere Schnittstelle und / oder Ressourcen auf derselben Abstraktionsebene. Das Dateisystem ist Virtualisierung! [VM,Smith,2005]

Sandboxing (virtuelles Laufzeitsystem)

Die Laufzeitumgebung einer Applikation wird vom restlichen System abgeschirmt, d.h. virtualisiert (Container-based Operating Systems, COS).

Diese Möglichkeit wird von einigen Betriebssystemen unterstützt, zum Beispiel von Oracle Solaris mit den Containers, vom FreeBSD-Unix mit den Jails und von Linux mit dem Vserver.

Dasselbe Konzept wird aber beispielsweise auch in Webbrowsern im Kleinen genutzt, um einzelne Webseiten oder Plug-ins zu isolieren.

Beim Sandboxing sieht jeder Prozess nur sich selbst und das Betriebssystem, wodurch eine wechselseitige Beeinflussung von Applikationen untereinander verhindert wird.

Dazu gehört, dass jedem Anwenderprozess nur ein begrenzter Anteil an der Nutzung gemeinsamer Ressourcen gewährt wird.

Jede Sandbox ist ein separater User Space, der sich mit anderen Sandboxen lediglich den geschützten Kernel Space teilt.

Die im Unix-Bereich klassische Lösung der chroot-Umgebung kann nicht dieselbe Isolation gewähren wie das Sandboxing-Konzept, da die damit realisierbare Isolierung unvollständig ist.

Virtual Hosting

Anwendung findet das Sandboxing bei Anbietern von Virtual Hosting, zur erhöhten Isolierung kritischer Applikationen bei sich wechselseitig nicht trauenden Systembenutzern, zur feineren Kontrolle der Ressourcenzuteilung und zur Ermöglichung einfacher Migrierungen von Applikationen.

Im Vergleich zu Lösungen mit einem Virtual Machine Monitor ist der Zusatzaufwand gering, da stets dieselbe Systemprogrammierschnittstelle genutzt wird und spezielle Hardware unnötig ist. Allerdings wird nicht dieselbe Flexibilität erreicht, da nur Applikationen für dieselbe Betriebssystemplattform lauffähig sind.

Virtual Private Server

Ein Schritt weiter: Ein ganzer Rechner wird aufgeteilt und wird mehreren Betriebssystemen und Betreibern (Kunden) zur Verfügung gestellt.

Beispiel: https://edu-9.de ist Virtual Private Server Hosting!

Auf einem virtuellen privaten Server wird eine eigene Kopie eines Betriebssystems (OS) ausgeführt, mit möglicherweise Zugriff auf die Superuser-Ebene dieses Betriebssysteminstanz, sodass sie nahezu jede Software installieren können, die auf diesem Betriebssystem ausgeführt wird.

Für viele Zwecke entspricht es funktional einem dedizierten physischen Server und kann softwaredefiniert einfacher erstellt und konfiguriert werden (z.B. webbasierte Installationd eines Betriebssystems).

Container-Systeme

Für den betrieblichen Einsatz ist eine möglichst einfache Bereitstellung von Anwendungen wichtig, die voneinander isoliert ablaufen und wenig Systemabhängigkeiten zeigen (Bibliotheken!).

Dies ermöglichen Container-Systeme, wie z.B. Docker oder rkt, die beide auf Linux aufsetzen.

Dazu werden Anwendungen, wie z.B. ein Webserver oder ein Datenbanksystem, mithilfe entsprechender Werkzeuge als Standardpakete bereitgestellt.

Werden diese Pakete, als Abbilder (Images) bezeichnet, auf einer webbasierten Plattform allgemein nutzbar bereitgestellt, so benötigt eine Installation nur noch wenige Arbeitsschritte.

Jedes Abbild, als einzelne Datei verfügbar, enthält nicht nur die Anwendung selbst, sondern auch alle Drittsoftware, die bei traditioneller Bereitstellung zusätzlich zu installieren wäre.

Für Docker-Images ist dies mit dem Docker Hub realisiert, der als Onlinedienst sowohl eine öffentliche wie auch eine private Bereitstellung erlaubt.

System-VM

Emulation von Prozessoren unter Verwendung physischer Prozessoren;

Emulation von Hardwaregeräten unter Verwendung von Betriebssystemmitteln;

Virtualisierung von Speicher unter Verwendung von Betriebssystemmitteln.

Abbildung 42-4. Eine virtuelle Systemmaschine. Virtualisierungssoftware übersetzt die von einer Hardwareplattform verwendete ISA in eine andere und bildet eine virtuelle Systemmaschine, die in der Lage ist, eine Systemsoftwareumgebung auszuführen, die u.U. auch für eine andere Hardware entwickelt wurde.

Abbildung 42-5. Beispiele für Anwendungen virtueller Maschinen. (a) Emulieren eines Befehlssatzes mit einem anderen; (b) Replizieren einer virtuellen Maschine, so dass mehrere Betriebssysteme gleichzeitig unterstützt werden können; (c) Komponieren von Software für virtuelle Maschinen zu einem komplexeren, flexibleren System.

Virtual Machine Monitor bzw. Hypervisor

Neben der Speichervirtualisierung ist heute die Bereitstellung virtueller Rechnerumgebungen durch einen Virtual Machine Monitor (VMM) bzw. Hypervisor die wichtigste Virtualisierungsform.

Der VMM erlaubt die Erstellung mehrerer virtueller Umgebungen (hier dann VM genannt) in denen sich unterschiedliche Betriebssysteme installieren lassen.

Abbildung 42-6. VMM-Typen: Ohne (bare metal) und mit zwischengeschalteten Betriebssystem

Ein VMM kann auf zwei Arten realisiert werden, die als Typ-I- und Typ-II-VMM bezeichnet werden.

Beim Typ I setzt der VMM direkt auf der Hardware auf und virtualisiert diese (bare-metal). Der VMM stellt ein eigenes Betriebssystem dar, dessen Aufgabe in der Ressourcenverwaltung und der Bereitstellung von VMs liegt. Die Ressourcenverwaltung umfasst die Aufteilung der Rechenzeit (scheduling) und die Zuteilung (allocation) von Speicher und Peripherie auf die einzelnen VMs. Beispiel eines solchen Produkts ist VMware ESX, das aber nur auf Hardware ablaufen kann, für die es eigene Treiber besitzt.

Ein Typ-II-VMM läuft als eine von mehreren Applikationen (hosted) auf einem Gastgeber-Betriebssystem (host operating system). Damit er jedoch die nötigen Rechte auf der Hardware erhält, benutzt er einen speziellen VMM-Treiber, der unter dem Gastgeber-Betriebssystem installiert ist und dadurch dem Hypervisor Kernmodus-Privilegien eröffnet. Jedes weitere Betriebssystem, das unter dem VMM läuft, wird als Gastbetriebssystem (guest operating system) bezeichnet. Der VMM kann auf Mechanismen des unterliegenden Gastgeber-Betriebssystems für die Ressourcenverwaltung zurückgreifen. Typ-II-Produkte sind VMware Workstation, Virtual-Box und Xen.

Eine zwischen Typ I und Typ II anzusiedelnde Variante sind Hypervisors, die in das Betriebssystem von Haus aus bereits integriert sind. Realisiert wird dies in Linux durch die KVM (Kernel-based Virtual Machine) und unter Windows mit Hyper-V. Diese Zwischenform lässt sich so einsetzen, dass keine Applikationen auf dem Betriebssystem selbst laufen, sondern nur der Hypervisor genutzt wird. In diesem Szenario sind keine Leistungsnachteile im Vergleich zu reinen Typ-I-VMM zu erwarten.

Anforderungen

Ein VMM muss nach Popek/Goldberg (1974) drei Kerneigenschaften unterstützen:

Ausführungsumgebung (fidelity, equivalence): Programme laufen auf einem virtualisierten Rechner identisch ab wie auf einem realen Rechner, mit Ausnahme der Geschwindigkeit.

Effizienz (performance, efficiency): Die große Mehrheit aller Maschinenbefehle, die innerhalb einer VM abgearbeitet werden, müssen direkt durch den realen Prozessor ausgeführt werden ohne die Intervention durch den VMM.

Ressourcenverwaltung (safety, resource control): Die Ressourcen werden vollständig durch den VMM verwaltet und den einzelnen VMs zugeteilt bzw. wieder entzogen.

Funktionsweise eines VMM

In der Anfangszeit der VMM in der PC-Welt fehlte eine passende Hardwareunterstützung, sodass sich der VMM und das in der VM ablaufende Betriebssystem den Kernmodus teilten.

Ohne Unterstützung durch Hardware

Ein sicherer VM-Monitor setzt eine voll virtualisierbare CPU voraus. Das heißt, dass alle Instruktionen, die auf die Hardware zugreifen oder indirekt die Hardware beeinflussen können, nur privilegiert ausgeführt werden. Dazu muss die CPU zwischen einem privilegierten und nicht privilegierten Betriebsmodus unterscheiden. Typischerweise werden diese als Kernmodus (kernel/supervisor mode) und Benutzermodus (user mode) bezeichnet. Prozessorintern wird der privilegierte Modus durch ein gesetztes KM-Bit repräsentiert (KM für Kernel Mode). Dieses Bit kann im Benutzermodus natürlich nicht direkt gesetzt werden. Erfolgt jedoch ein Hardware- oder Software-Interrupt, so wird es automatisch durch die CPU selbst gesetzt.

Funktionsweise eines VMM

Abbildung 42-7. Zustandsmodell für Privilegiensystem der CPU

Funktionsweise eines VMM

Was passiert nun, wenn ein Programm im Benutzermodus eine privilegierte Instruktion ausführen will? Die CPU wird in diesem Fall den Befehl nicht ausführen, sondern einen Software-Interrupt (trap) auslösen. Wird ein VMM eingesetzt, so läuft nur der VMM im privilegierten Modus. Aller restlicher Code, einschließlich irgendwelcher Betriebssysteme, läuft im Benutzermodus. Die Unterscheidung eines Betriebssystems zwischen Benutzer- und Kernmodus wird nämlich durch den VMM ebenfalls virtualisiert.

Abbildung 42-8. Sichere Virtualisierung mittels Privilegiensystem: KM\*: virtualisiertes Privilegien-Bit (pro VM)

Funktionsweise eines VMM

Mit Unterstützung durch Hardware

Moderne CPUs enthalten eine Zusatzhardware für eine einfachere Realisierung des VMM, der dann als Hardware-assisted VMM bezeichnet wird. Dies trifft für viele Prozessortypen zu, auch für die x86- und x86-64-Prozessoren. Ergänzend zu den Privilegienstufen, die das Betriebssystem nutzt, steht eine nochmals höher privilegierte Hypervisor-Stufe zur Verfügung, in die bei der Ausführung kritischer Instruktionen verzweigt wird. Bei x86-Prozessoren wird diese als Root-Betriebsmodus bezeichnet.

Einsatz von VMM für ...

Abbildung 42-9. VMM für die Serverzusammenführung (Konsolidierung) auf einer Hardware

Einsatz von VMM für ...

Abbildung 42-10. VMM-Einsatzszenario Applikationsmigration

Einsatz von VMM für ...

Abbildung 42-11. VMM-Einsatzszenario Softwaredistribution (Beispiel) mit Replikation

Prozess-VM

Beispiele für Hochsprachen Prozess-VMs:

Java Virtual Machine (Bytecode, Stack- und Registerprozessor)

Lua (Lua, LuaJit, Bytecode)

JavaScript (Google V8 - Bytecode und nativer Maschinenkode, Spidermonkey - Bytecode, WebAssembly)

Continuum (JavaScript -> JavaScipt)

Kompiler werden eine Programmiersprache in ein i.A. lineares Maschinenprogramm übersetzen. Dabei wird die Komplexität reduziert.

Für die zuvor beschriebenen Prozess-VMs ist die plattformübergreifende Portabilität eindeutig ein sehr wichtiges Ziel. Vollständige plattformübergreifende Portabilität lässt sich leichter erreichen, wenn die Prozess-VM in ein gesamtes Software-Framework integriert wird.

Abbildung 42-12. Hochsprachenumgebungen. (a) Ein herkömmliches System, in dem plattformabhängiger Objektcode erzeugt, verteilt und ausfgeführt wird → starke Betriebssystem- und Rechnerabhängigkeit; (b) eine HLL-VM-Umgebung, in der portabler Zwischencode von einer plattformabhängigen virtuellen Maschine "ausgeführt" wird → schwache Betriebssystem- und Rechnerabhängigkeit.

Abbildung 42-13. Eine virtuelle Prozessmaschine ist Virtualisierungssoftware die einen Satz von Anweisungen auf Betriebssystem- und Benutzerebene übersetzt, und eine Plattform auf eine andere abbildet - quasi ein Kompiler mit laufzeitumgebung (runtime). [VM,Smith,2005]

Klassen der Hochsprachen Prozess-VMs:

Interpreter ("eat and think", Programmkode wird sofort ausgeführt)

Basic

Bash Shell

interpret(method) {
  while( code remains in sequence ) {
    read the next code from the sequence;
    if (the code needs more data){
      read more data from the sequence;
    }
    perform actions specified by the code;
  }
}

Algorithmus 42-1. Der Interpreter

Die Komplexität verbirgt sich im Schritt “Aktionen ausführen, die durch den Code definiert sind."

Wenn der Code beispielsweise eine neue Instanz einer Klasse erstellen soll, ruft der Interpreter den Garbage Collector (Speichermanager der VM) auf, um einen Speicherabschnitt zuzuweisen, den Speicherinhalt auf Null zu setzen, den Objektheader zu initialisieren (z.B. einen vtable-Zeiger der Klasse zu installieren) und dann gibt er den Objektzeiger zurück.

Wenn der Code eine virtuelle Methode aufrufen soll, muss der Interpreter die Methodenadresse herausfinden, einen Stapelrahmen vorbereiten, die Argumente auf den Stapel laden, die Methode aufrufen, was rekursives interpretieren bedeuten kann, und das Ergebnis zurückgeben.

Beim Aufruf einer Zielmethode/Funktion muss u.U. der Methodencode geladen und analysiert werden, wenn er sich noch nicht im Speicher befindet oder noch nicht initialisiert wurde.

Verzweigungen im Programmfluss (Sprünge) müssen ggfs. im Programmkode gesucht werden.

Besser: Bytecode oder Super Code Maschinen (abstrakte Software ISA)

int x; x=0; x=x+1; printf("%d",x);
if (x<0) { x=-x; } else { x=1; };
------------------------------------------------
ALLOCATE(4) VARIABLE(x) NUMBER(0) WRITE
VARIABLE(x) VARIABLE(x) READ NUMBER(1) ADD WRITE
STRING("%d") VARIABLE(x) READ CALL(printf)
VARIABLE(x) NUMBER(0) LT BRANCHZ(5)
VARIABLE(x) VARIABLE(x) READ NEG WRITE BRANCH(3)
VARIABLE(x) NUMBER(1) WRITE

Beispiel 42-1. Beispiel Überstzung C Programm in lineare Instruktionssequenz für abstrakte Stackmaschine

Abstrakte Maschine

Register Maschine

Die meisten Instruktionen werden mit einem Registersatz und direkten Speicherzugriff ausgeführt

Einen Stack gibt es meist nur für temporäre Varibalen

Das Instruktionsformat kann keinen, einen, zwei oder mehr Operanden enthalten (Konstante, Register, Speicheradresse)

Stack Maschine

Die meisten Instruktionen werden über einen oder mehreren Stapelspeichern und einem Akkumulator ausgeführt

Eventuell kleiner Registersatz für temporäre Ergebnisse

Das Instruktionsformat enthält i.A. keine Operanden, diese befinden sich auf dem Stack, und auch Ergebnisse von Berechnungen werden wieder auf dem Stack abgelegt,.

Prozess-VM Interpreter Loop

Die Ausführung von abstrakten Instruktionen findet vollständig in Software statt, was langsam ist.

Es gibt i.A. einen Schleife die aus einem Programmcodespeicher Instruktionen liest, diese kodieren und schließelich ausführen muss.

CS[];
ip=0;
while(!terminated) {
  code=CS[ip]
  instr=decode(cmd)
  status=execute(instr)
  ip=next(instr,status)
}

Algorithmus 42-2. Die Main Loop

Instruktionsformat

Neben "binären" Bytecode (man spricht von seriellisierten komopakten Code) kann man auch nicht kodierte Instruktionsformate wählen die keine Dekodierung benötigen.

struct command {
  opcode   : opcodes[OPNAME],
  operands : operand [],
};
enum opcodes {
  OP1 = 1,
  OP2 = 2,
  ...
}
union operand {
  number_t constant;
  address_t address;
  address_t register;
}

Definition 42-1. Instruktionsformat

Instruktionsdekoder

Es muss eine spezifische opCode Funktion ausgeführt werden (Mikrooperationen). Wir gehen davon aus dass der opCode bereits dekodiert ist.

Wie bildet man einen (numerischen oder symbolischen) opCode auf eine Funktion effizient ab?

Eine lange if-then-else-if Kaskadenstruktur die den opCode mit Anweisungen verbindet und diese direkt ausführt ⇒ nicht konstante Laufzeit!

Eine switch-case Mehrfachauswahl. Zunächst kein große Verbesserung zu 1. da auf Maxhinenebene ein switch-case in eine Vielzahl von Vergleichsanweisungen mit bedingten und unbedingten Sprüngen umgesetzt wird (wie if-then-else-if)

Irgeneine Form eine Lookup Tabelle deren Index der opCode ist und der Tabelleneintgrag entweder eine Funktions- und interne Codesprungadresse ausgibt.

Hier müssen die opCodes (bestenfalls) aufeinander folgend nummeriert werden.

Hier gibt es konstante Dekodierungslaufzeit!

Prozess-VM Interpreter Loop (Option 2)

while() {
  op=CS[ip]
  if (op==OP1) { status=doop1(arguments); }
  else if (op==OP1) { status=doop1(arguments); }
  else if (op==OP2) { status=doop2(arguments); }
  ...
  else if (op==OPN) { status=doopN(arguments); }
  ip=next(ip,op,status);
}

Algorithmus 42-3. Nicht effiziente Implementierung einer Instruktionsschleife in C mit linearer Ausführungszeit ohnen Möglichkeit der Optimierung durch Compiler - Option 1

while() {
  op=CS[ip]
  switch (op.code) {
    case OP1: status=doop1(arguments); break;
    case OP2: status=doop2(arguments); break;
    ...
    case OPN: status=doop3(arguments); brak;
  }
  ip=next(ip,op,status);
}

Algorithmus 42-4. Effiziente Implementierung einer Instruktionsschleife in C mit linearer Ausführungszeit (Mulitplexer), aber mit Möglichkeit für den Compiler mittels Tabellen in konstanter Laufzeit zu realisieren - Option 2

Prozess-VM Interpreter Loop (Option 3)

enum opCode {
  OP1,
  OP2,
  ...
  MAXOP
};
void op1(..); ...
OPTABLE[MAXOP]={op1,op2,..}
...
while() {
  next=OPTABLE[op.code]
  next()
}

Algorithmus 42-5. Effiziente Implementierung einer Instruktionsschleife in C mit konstanter (?) Ausführungszeit (LUT) - Option 3

JIT Übersetzung

Die Ausführung von Maschineninstruktionen in Software führt zu geringer Verarbeitungsgeschwindigkeit. Typisch werden 100 native Maschineninstruktionen für die Ausführung einer abstrakte Instruktion benötigt.

Die JIT-Kompilierung kompiliert einen Teil des Anwendungscodes zur Laufzeit in binären (nativen) Maschinencode und ermöglicht es der VM dann, den generierten Code direkt auszuführen, anstatt den ursprünglichen Teil des Anwendungscodes zu interpretieren.

Es ist, als würde man den gesamten Anwendungscode als eine einzige Superanweisung behandeln.

Man unterscheidet:

Übersetzung von Programmkode

Übersetzung von Daten(strukturen)

Die erste Frage an JIT ist, wie der zu kompilierende Anwendungscode ausgewählt wird?

Eine Methode oder Funktion wird aufgrund ihrer genau definierten semantischen Grenze als Kompilierungseinheit betrachtet.

Bei Daten wird es schwieriger die semantische Grenze zu finden!

Abbildung 42-14. Googles V8 Engine als dominanter Vertreter einer JIT HLL Prozess-VM. Bei numerischen Berechnungen erreicht V8 nahezu native Code Performanz (z.B. Matrixmultiplikation)

Abbildung 42-15. Java-Quellcode wird zuerst zu Bytecode kompiliert und anschließend als nativer Code interpretiert oder ausgeführt. Umfangreiche Optimierungen sind für die JIT-Kompilierungsphase reserviert. JAVA JVM JIT erzeugt aber schlechteren Code (langsamer) wie V8 JIT und die Konzepte konzentrien sich auf Programmcode (und nicht Daten). Dafür werden eine Vielzahl von Prozessoren und Rechneranlagen unterstützt - V8 ist nur für ia32/ia64 optimiert! [DOI:10.1145/3067695.3082521]

JIT benötigt Datentypen und Speicherinformation von Variablen, Funktionen und Funktionsargumenten!

Dynamisch typisierte Sprachen (JavaScript, Lua) können diese Informationen nicht liefern, sondern Typsignaturen müssen zur Laufzeit vom JIT Compiler abgeleitet werden. Zeitaufwändige Operation!

Und noch viel schlimmer: Datentypen von Variablen, Funktionsargumenten und Rückgabewerten könen sich zur Laufzeit ändern!

Es muss für jede Typsigantur einer Funktion (oder Datenstruktur/Objekt) eine eigene native Übersetzung angelegt werden. Teuer im Speicher!

Statisch typisierte Sptachen wie Java haben dieses Problem nicht, jedoch ist fraglich woher die Typinformationen kommen. Im Bytecode sind nur sehr begrenzte Informationen verfügbar.

Ein abschließendes Beispiel:

Der gute alte Basic Interpreter

Aufteilung eines Programms in Zeilen (ursprünglich mit vorangestellter Zeilennummer)

Es gibt Sprungbefehle wie goto <line>

Basic wird Zeile für zeile entweder interpretiert oder kompiliert

Bei einem Vorwärtssprung ist die Programmcodeadresse einer noch folgenden Zeile noch nicht bekannt, erst bei der Ausführung wenn das Programm vollständig ist.

Wenn aber die Zeile des Ziels ermittelt wurde kann diese durch eine Codeadresse ersetzt werden: JIT

10: a=1
15: goto 20
20: b=a
------------------------------------------------------------------------------------------------
0              2           4      5         7              9    10         12
LINENUMBER(10) VARIABLE(a) ASSIGN NUMBER(1) LINENUMBER(15) GOTO LINENUMBER(20) LINENUMBER(20) ...
LINENUMBER(10) VARIABLE(a) ASSIGN NUMBER(1) LINENUMBER(15) GOTO ADDRESS(12)    LINENUMBER(20) ...

Vertiefende Literatur

[1] J. E. Smith and R. Nair, Virtual Machines Versatile Platforms for Systems and Processes. Elsevier, 2005.

[2] X.-F. Li, Advanced Design and Implementation of Virtual Machines. CRC Press, 2017.

[3] Reinhard Wilhelm , H. Seidl, Übersetzerbau VirtuelleMaschinen. Springer, 2007.

[4] lain D. Craig, Virtual Machines. Springer London, 2006.

Kommunikation und Synchronisaztion von Prozessen

Lernziele

Sie beurteilen die Eignung nachrichten- und speicherbasierter Verfahren zur Kommunikation zwischen parallelen Abläufen.

Sie analysieren Anwendungsprobleme von Unix-Pipes.

Sie unterscheiden zwei Implementierungsarten von Monitoren.

Sie erläutern das Grundprinzip eines Rendezvous paralleler Prozesse.

Sie entwickeln eigene Programme, die Berkeley-Sockets bzw. RPC zur rechnerübergreifenden Datenverarbeitung nutzen.

Taxonomie

Wie können Prozesse kommunizieren? Kommunikation ist örtlich, temporal und zustandsbasiert mit Daten.

IPC: Interprocess Communication

Ein Prozess wartet auf die Terminierung eines anderen (Eltern-Kind Gruppe);

Ein Prozess wartet auf Daten von einem anderen; Beispiel: Anforderung von Daten eines Web Servers über HTTP (remote) oder ein Prozess waretet auf Daten einer Datenbank; → Kooperation

Prozesse wollen quasi gleichzeitig (oder zeitlich sehr nahe beiandner) einen gemeinsamen Punkt im Programmfluss erreichen; Barriere

Prozesse greifen auf geteilte Ressourcen qusi parallel zu und der Zugriff muss sequenzialisiert werden; Wettbewerb

Prozesse warten auf zeitliche Ereignisse (ist es der gleiche Timer und mehrere Prozesse warten darauf dann ist das IPC)

Synchrone versa asynchrone Kommunikation (mit Signalen oder Nachrichten)

Synchron bedeutet dass auf eine Antwort oder Bestätigung gewartet wird (mit Prozessblockierung und Prozesswechsel)

Asynchron bedeutet dass der Prozess nicht wartet und ggf. später durch eine Rückruffunktion (ähnlich Interruptbetrieb) eine Antwort erhält

Wir unterscheiden Signale (Events) und Nachrichten mit Daten; beide können zu einer Synchronisation von Prozessen führen;

Synchronisation kann Daten enthalten - aber kein Muss.

Es gibt eine Vielzahl von IPC Objketen. Auch eine Datei kann dazu gehören (Schreib/Lese Sperren)

Geteilte Speicherbereiche

Dateien, Pipes (Warteschlangen über das Dateisystem)

Semaphore

Nachrichten

Synchronisation führt i.A. zur Prozessblockierung und Prozesswechsel bzw. Warten.

Prozesse und Threads können lokal auf dem gleichen Rechner oder mithilfe der Netzwerksoftware des Betriebssystems in einer verteilten Umgebung kommunizieren.

Abbildung 43-1. Überblick über Synchronisation und Kommunikation [bsdp]

Es wird unterschieden zwischen den zwei Gruppen »Synchronisation« und »Kommunikation«.

Bei der Kommunikation wird weiter unterteilt in »speicherbasierte Verfahren« und »nachrichtenbasierte Verfahren«.

Die Kommunikation unterscheidet sich von der Synchronisation, indem explizit Daten zwischen den Prozessen ausgetauscht werden und nicht nur eine Koordination im zeitlichen Ablauf oder Ressourcenzugriff realisiert wird.

Abbildung 43-2. IPC-Verfahren aus Sicht der Programmierung

Mit der Eigenschaft »systemgebunden« ist gemeint, dass das Verfahren als Funktionsaufruf der Betriebssystem-Programmierschnittstelle zur Verfügung steht.

Mit der Eigenschaft »sprachgebunden« wird der Fall umschrieben, bei dem ein Funktionsaufruf des Laufzeitsystems der Programmiersprache bzw. der Sprachbibliothek zur Verfügung steht.

Jedes der Programmierkonzepte weist bezüglich einer bestimmten Problemklasse seine Vorzüge auf.

Wesentlich ist jedoch, dass alle Konzepte funktional äquivalent sind, d.h., jedes Konzept kann in seiner Wirkungsweise durch jedes andere nachgebildet werden.

Nachrichtenbasierte Verfahren

Unter nachrichtenbasierten Verfahren fassen wir alle Mechanismen zusammen, die einen Datenaustausch zwischen Prozessen und Threads mithilfe von Systemfunktionen bewerkstelligen.

Allgemeine Aspekte

Datenabgrenzung bei der Kommunikation

Die Datenabgrenzung bei der Kommunikation hängt von der Form der ausgetauschten Daten ab. Diese ist bestimmend für mögliche Lösungen der Datenabgrenzung.

Datenaustausch mittels Nachrichten (message passing)

Datenaustausch mittels Datenströmen (streaming)

Datenaustausch mittels Paketen (packeting)

Beim Datenaustausch mittels Nachrichten haben wir eine abgegrenzte Datenmenge bei der Kommunikation in Form der Meldung (message).

Die Meldungsgröße kann systemabhängig fest oder variabel sein.

Kommen hingegen für den Datenaustausch Datenströme zur Anwendung, so ist die Nachrichtengrenze für den Sender und den Empfänger unsichtbar.

Die übertragbare Datenmenge ist theoretisch unbeschränkt.

Typischerweise werden die Dateisystemfunktionen read() und write() für eine stromartige Datenübertragung genutzt.

E/A ist auch Kommunikation, aber zunächst nur zwischen Prozessen und Geräten.

Beim Datenaustausch mittels Paketen kommen feste, oft standardisierte Datenformate zum Einsatz.

Sie sind im Rahmen von Kommunikationsprotokollen definiert (z.B. IP-Paketformat).

Für die Applikationsprogrammierung sind die Pakete nicht sichtbar (transparent).

Beim Übertragen der Pakete kann eine Fragmentierung (= Aufteilung in Teilpakete) und eine Defragmentierung (= Zusammensetzen aus Teilpaketen) stattfinden.

Dies wird versteckt (hidden) durch die Netzwerksoftware realisiert.

Der Datenaustausch mittels Paketen ist ein Thema der Computernetze und wird daher hier nicht weiter berücksichtigt. Die zwei verbleibenden Arten des Datenaustauschs wollen wir jedoch anhand von Realisierungsformen kennenlernen.

Synchronität bei der Kommunikation

Unter einer synchronen Kommunikation versteht man den Fall, dass der Sender warten muss, bis der Empfänger zur Entgegennahme der Daten bereit ist (Rendezvous). Bei der asynchronen Kommunikation läuft der Sender hingegen weiter, auch wenn der Empfänger gerade nicht für den Datenempfang bereit ist.

Bei der synchronen Kommunikation muss entweder der Sender (A1) oder der Empfänger (A2) warten, wenn nicht gerade das Senden und Empfangen zeitlich zusammenfallen.

Bei der asynchronen Kommunikation (B) kann hingegen das Senden sofort erfolgen, auch wenn der Empfänger nicht gerade für den Empfang bereit ist. Es ist also ein Puffer für die Zwischenlagerung von Daten, ein sogenannter Nachrichtenpuffer, notwendig.

Abbildung 43-3. Synchrone und asynchrone Kommunikation

Die asynchrone Kommunikation entkoppelt den Ablauf des Senders und des Empfängers und hilft, Geschwindigkeitsunterschiede in der Datenverarbeitung auszugleichen.

Genau genommen läuft bei der asynchronen Kommunikation der Sender erst dann weiter, wenn die Nachricht vollständig in einen Zwischenspeicher kopiert wurde.

Dies ist in der Abbildung durch die schräg verlaufenden Pfeile angedeutet und gilt auch für den Nachrichtenempfang.

send    (destination, &message);
receive (source,      &message);

Kode 43-1. Programmierschnittstelle für die synchrone und asynchrone Nachrichtenkommunkation. Bei einer synchronen Art können beide Funktionen den Prozess blockieren. Ein Nachrichtenpuffer `message` trägt eine Identifikation (Textname, Deskriptor, Handle o.Ä.).

Synchrone versa Asynchone Kommunikation

[bsdp]

Vergleich von synchroner und asynchroner Kommunikation

Bei der synchronen Kommunikation übergibt der Sender die Nachricht direkt an den Empfänger. Typischerweise wird der Datenaustausch in dieser Situation in der Art eines Rendezvous erledigt. Nur wenn sowohl Sender als auch Empfänger bereit sind, kann die Datenübertragung stattfinden.

Synchrone versa Asynchone Ein- und Ausgabe

Auch bei der Grerätekommunikation kann A. vs S. unterschieden werden.

Bei der S. Kommunikation muss beim Lesen und Schreiben der Prozess warten bis die Operation ausgeführt wurde (und beim Lesen die Daten verfügbar sind).

Bei der A. Kommunikation kann der Prozess eine E/A Operation starten aber mit der Programmausführung fortsetzen. Der Prozess kann auch mehrere E/A Operationen starten.

Nur dann ist die Frage wie er mitbekommt wann E/A Operationen ausgeführt wurden (also auch Daten verfügbar sind).

Viele Programmiersprachen sind vom Paradigma her strikt sequenziell. Es ist nicht möglich mehrer Operationen parallel auszuführen.

A\. E/A ist daher in Virtuellen Maschinen eine gängige Möglichkeit auf der Programmierebene das strikt sequenzielle Paradigma aufrecht zu erhalten und unter der Motorhaube E/A Operationen mit Threads parallel auszuführen.

Mittels Callback Funktionen können dann erfüllte Operationen ihr Ergebnis wieder an das Programm übermitteln.

function callback(status,data) {
  if (!status)   print("Error:",status)
  else if (data) processData(data)
}
send('edu-9.de','mymessage1',callback) // Time 1
send('ag-0.de','mymessage2',callback)  // Time 2
receive('edu-9.de',callback)           // Time 3
receive('ag-0.de',callback)            // Time 4
print('I am finished with my computation. Waiting for I/O completion')
// Time 10: callback(...)
// Time 12: callback(...)

Definition 43-1. Asynchone E/A mit Callback Funktionen am Beispiel von JavaScript

Das Problem: Die Callbackhölle, Verschachtelung, und schwierige Fehlerbehandlung.

Z\.B. wenn die zweite Send Operation auf Daten von der ersten Receive Operation warten muss ist eine Verlegung der Send Operation in den Callback Handler notwendig!

Vergleich von Kommunikationsverfahren

[bsdp]

Vergleich von Kommunikationsverfahren: Speicherbasiert versa nachrichtenbasiert.

Ein Datenaustausch mittels Speicher kann mithilfe von Systemfunktionen erfolgen oder in Selbstverwaltung, nachdem ein gemeinsamer Speicherbereich eingerichtet wurde.

Bei der Selbstverwaltung muss in der Regel mittels Semaphoren die Koordinierung der beteiligten Threads bzw. Prozesse sichergestellt werden.

Somit stellt der Einsatz gemeinsamen Speichers meist eine Mischform dar, die sich für den schnellen Transfer großer Datenmengen besonders anbietet.

Geteilter Speicher

Threads besitzen ein inherentes geteiltes Specihermodell und können somit unmittelbar ohne weitere System- und Hilfsfunktionen über getilte Speichervariablen kommunizieren, anders als isolierte Prozesse.

Prozesse können sich über das Betriebssystem einen Speicherbereich teilen.

Dieser ist aber nur ein Bytepuffer ohne Datentyp.

Ein sinnvoller Datentyp kann mit C durch Typecasting erstellt werden.

void* create_shared_memory(size_t size) {
  // Our memory buffer will be readable and writable:
  int protection = PROT_READ | PROT_WRITE;
  // The buffer will be shared (meaning other processes can access it), but
  // anonymous (meaning third-party processes cannot obtain an address for it),
  // so only this process and its children will be able to use it:
  int visibility = MAP_SHARED | MAP_ANONYMOUS;
  // The remaining parameters to `mmap()` are not important for this use case,
  // but the manpage for `mmap` explains their purpose.
  return mmap(NULL, size, protection, visibility, -1, 0);
}
...
void* shmem = create_shared_memory(16);
int * counter = (int*)&shmem[0];
*counter=0;
int pid = fork();
if (pid == 0) { // child process
  *counter++;
} else {        // parent process
  *counter--;
}

Beispiel 43-1. SHM für zwei Prozesse, hier als "Vererbung" durch das Forking vpn Kindprozessen

Welchen Wert hat die Zählervariable am Ende? Gibt es Gebote? Wir nehmen einen Mehrkernrechner an.

Keine Ahnung. Die möglichen Ergebniswerte von counter können {-1,0,1} sein. Das Ergebnis ist nicht deterministisch.

Und das ist das Problem bei SHM Kommunikation: Es gibt keine Programmflusssteuerung! Diese muss dann zusätzlich z.B. mit Semaphoren erfolgen.

Abbildung 43-4. Vergleich von Kommunikationsverfahren: Nachrichtenbasiert versa speicherbasiert. [Andrew Forney]

Verbindungsorientierung

Die Verbindungsorientierung spielt eine Rolle beim asynchronen Datenaustausch, wenn wiederholt Datenübertragungen mit gleichen Kommunikationspartnern anstehen.

Unabhängig davon, ob man streng abgegrenzte Nachrichten oder einfach Datenströme austauschen will, können zwei Verbindungsformen unterschieden werden.

Arbeitet man verbindungsorientiert (connection-oriented), so muss zuerst ein logischer Verbindungskanal eingerichtet werden. Dazu muss die Identität des Empfängers bekannt sein.

Ist die Verbindung aufgebaut, so werden Daten an den Verbindungskanal übergeben, der eine Kennung trägt.

Die Angabe der Empfängeridentität ist dann nicht mehr nötig.

Sind alle Daten ausgetauscht, was typischerweise nach einer Vielzahl an einzelnen Datentransfers zutrifft, wird die Verbindung explizit wieder abgebaut.

Dies kann über eine »disconnect«-Funktion oder ein Schließen des Verbindungskanals erfolgen.

Je nach Form des Verbindungskanals sind nur zwei Kommunikationspartner erlaubt, oder es sind auch m:n-Konstellationen möglich.

Der logische Verbindungskanal kann zu jedem Zeitpunkt nur einen Datentransfer in eine Richtung (unidirektional) oder gleichzeitig in beide Richtungen erlauben (bidirektional).

Der unidirektionale Datenaustausch (mit alternierender Richtung) wird als Halbduplex-, der bidirektionale Datenaustausch als (Voll-) Duplexbetrieb bezeichnet.

Ist generell nur eine Kommunikationsrichtung möglich, so handelt es sich um einen Simplex-Betrieb.

Abbildung 43-5. Halb- und Vollduplex-Betrieb

Ein verbindungsloser (connectionless, datagram-oriented) Datenaustausch benutzt für jeden einzelnen Datentransfer die Identität des Empfängers als Adresse.

Es können sofort Daten übertragen werden, ohne dass zuerst ein Verbindungskanal angelegt werden muss.

Diese Kommunikationsform empfiehlt sich, wenn nur einzelne Datentransfers, aber möglicherweise an viele verschiedene Empfänger, nötig sind.

[bsdp]

Vergleich von verbindungsorientierter und verbindungsloser Kommunikation.

Empfängeradressierung

Neben der Frage wie zwei Prozesse miteinander kommunizieren können und sich "finden" kann man adressierte Kommunikation in verschiedene Klassen einteilen.

Diese Klassen bestimmen die Empfänger von Nachrichten (lösen aber nicht das Problem der eindeutige Adressierung der Prozesse an sich)

Abbildung 43-6. Empfängeradressierung bei der Kommunikation

Bei einem unicast (1:1) wird genau ein bestimmter Empfänger angesprochen. Bei einem multicast (1:m) wird eine definierte Gruppe an Empfängern (m) adressiert. Der anycast übermittelt Daten an irgendeinen Empfänger, mindestens einen einzigen (1:1..n). Der broadcast schließlich geht an alle Empfänger (n), die sich finden lassen (1:n). Welche Empfängeradressierung eine Lösung unterstützt, ist implementationsabhängig. Meistens ist es aber nur der unicast.

Prioritäten

Erfolgen bei einem Datenaustausch alle Übertragungen mit der gleichen Priorität, so werden sie bei einer Zwischenpufferung in der FIFO-Reihenfolge abgelegt.

Können unterschiedliche Prioritäten benutzt werden, so ist es möglich, neue Daten vor alten Daten in den Nachrichtenpuffer einzutragen. Bei Datenströmen sind Prioritäten meistens nicht sinnvoll, daher ist diese Möglichkeit in der Regel dem Meldungsaustausch vorbehalten.

Im einfachsten Fall sind zwei Prioritäten definiert, nämlich »normal« und »dringend«. Anspruchsvollere Lösungen können feinere Prioritätsabstufungen anbieten.

Anwendungsszenario Client/Server

Der gepufferte Nachrichtenaustausch eignet sich zur Realisierung eines Client/Server-Betriebs.

Client: Prozess oder Thread, der eine Dienstleistung beansprucht (Auftraggeber).

Server: Prozess oder Thread, der eine Dienstleistung erbringt (Auftragnehmer). Beispiele: Dateiverzeichnis liefern, Faxmeldung übermitteln.

Funktionsweise: Ein Client beansprucht vom Server eine Dienstleistung und sendet dem Server deshalb eine Meldung. Wenn der Server die notwendigen Arbeiten erledigt hat, liefert er mittels einer Meldung die Antwort an den entsprechenden Client zurück.

Absenderkennung: Da Meldungen von sich aus in der Regel keine Absenderangaben enthalten, muss die Absenderadresse bzw. die Bezeichnung der Nachrichtenwarteschlange für die Rückmeldung in der Meldung selbst enthalten sein.

Abbildung 43-7. Client/Server-Prinzip mittels Meldungen

Pipe als Verbindungskanal zweier Prozesse

Eine namenlose Pipe für die Kommunikation zweier Prozesse anzulegen erfordert insgesamt drei Prozesse und die folgenden Schritte:

Der Elternprozess erzeugt die Pipe durch Aufruf von pipe(). Er erhält für beide Enden der Pipe die vom Betriebssystem zugeteilten Deskriptoren.

Der Elternprozess erzeugt nun zwei Kindprozesse (oder er selbst ist ein Kommunikationspartner).

Diese erhalten automatisch Kopien der Pipe File Deskriptoren.

Jeder Prozess schließt mittels close() die Enden der Pipe, die er nicht braucht.

Der Elternprozess schließt beide Enden, Kindprozess 1 das »obere« Ende und Kindprozess 2 das »untere« Ende.

Nun kann Kindprozess 1 mittels write() Daten über die Pipe an Kindprozess 2 senden, der diese mittels read() entgegennimmt.

int main()
{
  int fds[2];                             // Für Pipe-Dateideskriptoren
  char *text = "Hallo da!\n";             // Zu transferierender Text
  char buffer [5];                        // Lesepuffer
  int count, status;                      // Leseanzahl bzw. Endstatus
  pipe(fds);                              // Unnamed pipe erzeugen
  if (fork() == 0) {                      // Kindprozess 1:
     dup2(fds[1], 1);                     // stdout auf Pipe-Eingang legen
     close(fds[0]);                       // Pipe-Ausgang schließen
     write(1,text,strlen(text)+1);        // Text in Pipe schreiben
  } else if (fork() == 0) {               // Kindprozess 2:
     dup2(fds[0], 0);                     // Pipe-Ausgang auf stdin legen
     close(fds[1]);                       // Pipe-Eingang schließen
     while ((count = read(0, buffer, 4)) != 0) { // Wiederholt auslesen
        buffer[count]= 0;                 // »string terminating zero«
        printf("%s", buffer);             // Laufend ausgeben
     }
  } else {                                // Elternprozess:
     close(fds[0]);                       // Pipe-Ausgang schließen
     close(fds[1]);                       // Pipe-Eingang schließe
     wait(&status);                       // Warte auf erstes Kind
     wait(&status);                       // Warte auf zweites Kind
  }
  exit(0);
}

Beispiel 43-2. Ein einfaches Zwei-Prozess-Kommunikationssystem über Pipers

Benannte Pipes

Hier werden die Pipes als "virtuelle" Dateien über ds Dateisystem von Prozessen mit üblichen Read und Write Operationen geteilt.

Abbildung 43-8. Client/Server-Konstellation mit benannten Unix-Pipes

Message Queues

Die Unix-Pipes sind gut geeignet für die Übertragung von Daten, wenn eine klare Abgrenzung der einzelnen gesendeten Daten voneinander nicht notwendig ist (Bytestrom). Stehen gut definierte Meldungen im Vordergrund, so bieten sich die Message Queues an.

Sie übertragen Meldungen unterschiedlicher Länge.

Die einzelnen Meldungen sind so gegeneinander abgegrenzt, dass beim Lesen der Message Queue jeweils genau eine Meldung entnommen wird.

Der Inhalt und die Länge einer einzelnen Meldung sind frei wählbar, wobei die Meldungsgröße nicht den für die Message Queue festgelegten Maximalwert überschreiten darf (attr.mq\_msgsize).

Beim Auslesen einer Meldung wird dem Empfänger die genaue Länge der Meldung mitgeteilt.

Die in der Message Queue gepufferten Meldungen sind entsprechend ihrer Meldungspriorität sortiert, wobei Meldungen der gleichen Priorität eine FIFO-Reihenfolge einnehmen.

Die Meldungspriorität wird durch den Sender zusammen mit der Meldung der Message Queue übergeben.

Abbildung 43-9. Aufbau einer Message Queue [bsdp]

Eine Meldung besteht immer aus den zwei Teilen msg_typ und msg_txt, die den Meldungstyp und den Meldungsinhalt repräsentieren. Wie in einer eigenen Applikation ein Meldungstyp deklariert werden kann, zeigt nachfolgendes Beispiel:

struct meine_nachricht {
  long msg_typ;
  char msg_txt[100];
}

Mutualer Ausschluss und Semaphor

Bisher wurde nachrichtenbasierte Kommunikation eingeführt. Nebem dem Nachrichtenaustausch (Kooperation) ist aber auch die Konfliktlösung bei Wettbewerb wichtig, also eine sequenzielle Synchronisation.

Vorteilhafter als eine Lösung mit Selbstverwaltung ist der Einsatz von sogenannten Semaphoren. Es handelt sich dabei um Betriebssystem- und Synchronisationsobjekte für die Absicherung und Synchronisation von Prozessen bzw. Datenkonsistenz. Der Semaphor und ihre Operationen P und V wurden 1962 von E. Dijkstra erstmals systematisch entwickelt und dargestellt

Wir unterscheiden wieder zwei Arten der Prozessinteraktion:

Kooperation, d.h. Produzenten-Konsumenten Anwendungen

Wettbewerb, d.h. Konflikte bei der Nutzung von geteilten Ressource.

Der Semaphor kann zur Synchronisation für beide Arten verwenden werden.

Abbildung 43-10. Semaphor mit einer Identität (Instanzenbezeichnung), den Operationen P/V und den Attributen Zähler (für Marken) und Warteschlange (für Prozesse) [bsdp]

Der Semaphor implementiert einen geschützten Zähler (Marken, Tokens) mit einer Prozesswarteschlange:

Invariante: Der Zähler darf nie negativ werden

Prozesse werden blockiert die diese Invariante verletzen würden

Es gibt zwei Operationen: V(+) oder Up und P(-) oder Down. Beide Operationen verändern den Zähler um den Wert 1.

%% FIGURE [] (Oben) Definition der P-Operation: Der aufrufende Prozess erhält entweder sofort eine Marke oder muss warten, bis eine verfügbar wird (dazu wird er an die Warteschlange des Semaphors angefügt). (Unten) Definition der V-Operationen: Der aufrufende Prozess übergibt eine Marke entweder dem Semaphor oder einem am Semaphor wartenden Prozess, der damit wieder weiterlaufen kann.

Semaphortypen

Es existieren zwei Grundtypen von Semaphoren:

Binärer Semaphor (binary semaphore, mutex): Es sind genau 0 oder 1 Marke erlaubt ⇒ Lösung des mutualen Ausschlussproblems (entweder oder), Mutex Lock

Zählsemaphor (counting semaphore, general semaphore): Es sind beliebig viele Marken erlaubt, dies stellt die universellere Form dar.

Unteilbarkeit von P/V

Die Unteilbarkeit der P- und V-Operationen muss durch ihre Implementierung sichergestellt werden. Mögliche Lösungen dafür sind:

Ausschalten der Prozessorneuzuteilung für die Operationsdauer

Vorübergehend höhere Priorität als alle anderen Prozesse

Ausschalten aller Interrupts (= Gewaltlösung) während der P- und V-Operationen; ist aber nur für Einprozessorsysteme ausreichend

Verwendung atomarer Prozessorinstruktionen (v.a. der TAS-Befehl, siehe S. 201)

Aktives oder passives Warten

Eine zweite Implementierungsfrage betrifft die Art des Wartens an der Semaphor-Warteschlange.

In der Regel heißt dies, dass der Prozess für die Dauer des Wartens blockiert wird, d.h. die CPU freigibt und in den Zustand »Wartend« wechselt (passives Warten). Dies erlaubt anderen Prozessen die Wartezeit zu nutzen.

Unter speziellen Bedingungen kann jedoch ein aktives Warten die bessere Lösung sein:

Sehr kurze Wartezeit: Der Zeitbedarf für ein Blockieren kann größer sein als für ein aktives Warten. Das Blockieren und spätere Aufwecken des Prozesses beinhaltet zwei Prozessumschaltungen, für die eine zwar kleine, aber doch gegebene Zeitdauer nötig ist.

Multiprozessorsysteme: Da eine echte Parallelität möglich ist, wird in verschiedenen Situationen die Wartezeit sehr klein sein. Dies macht ebenfalls ein Blockieren unattraktiv.

Für diese speziellen Situationen bieten manche Betriebssysteme einen speziellen Semaphortyp an, der als Spinlock bezeichnet wird (ein passender deutscher Begriff fehlt).

In einer ersten Variante wartet ein Spinlock beliebig lange in einer aktiven Warteschlange.

Dies ist unattraktiv, wenn nicht bekannt ist, wie lange das Warten dauert.

Eine zweite Variante wartet in einer aktiven Warteschleife, kennt dafür aber ein Zeitlimit.

Wird dieses überschritten, so wird ein passives Warten, d.h. ein Blockieren des Prozesses, durchgeführt.

Wartereihenfolge

Eine dritte Implementierungsfrage betrifft die Einreihung in der Semaphor-Warteschlange.

Erfolgt dies nach dem FIFO-Prinzip, so ist sichergestellt, dass jeder Prozess in endlicher Zeit drankommt, sofern die einer P-Operation folgende V-Operation stets in endlicher Zeit ausgeführt wird.

Wird für die Einreihung die Prozesspriorität benutzt, so besteht die Gefahr, dass niederpriore Prozesse nie drankommen (verhungern), wenn hochpriore Prozesse genügend oft die P-Operation aufrufen.

Anwendung der Semaphoren

Eine hervorragende Bedeutung fällt Semaphoren bei der Absicherung kritischer Bereiche zu, indem sie den wechselseitigen Ausschluss sicherstellen.

Da ein kritischer Bereich jederzeit von maximal einem Prozess betreten werden darf, kommt ein binärer Semaphor zum Einsatz.

Betritt ein Prozess einen kritischen Bereich, so sperrt er diesen mithilfe des Semaphors ab.

Ein so eingesetzter Semaphor wird manchmal als Sperre (lock), die P- und V-Operationen als Sperren (lock) und Freigeben (release) bezeichnet.

Kritischer Bereich bedeutet der Schutz von Daten und Wahrung der Konsistenz von Daten. Nicht der Programmcode muss geschützt werden, sonder die Daten die er verarbeitet.

Konkret beinhaltet die Absicherung eines kritischen Bereichs die folgenden Elemente:

Den Semaphorzähler mit 1 initialisieren (eine Berechtigungsmarke bereitstellen, d.h. kritischer Bereich als frei markieren)

Die P-Operation vor dem Eintritt in den kritischen Bereich ausführen (= reservieren bzw. Berechtigungsmarke beziehen)

Die V-Operation beim Austritt aus dem kritischen Bereich ausführen (= freigeben bzw. Berechtigungsmarke an Semaphor zurückgeben)

Das Betriebssystem blockiert den Prozess bei der P-Operation, wenn die Berechtigungsmarke bereits vergeben ist

Das Betriebssystem weckt einen wartenden Prozess bei der nächsten V-Operation, die auf diesem Semaphor ausgeführt wird (da Berechtigungsmarke nun verfügbar ist)

Abbildung 43-11. Absicherung kritischer Bereiche mittels Semaphor (Beispiel)

Eine weiter Anwendung ist Kooperation ind Produzenten-Konsumenten Systemen mit einem Datenpuffer (Kapazität N Datensätze).

Eine Menge von Prozessen P schreibt Daten in den Puffer (atomar)

Ein oder mehrere Prozesse C lesen dei Daten aus dem Puffer (atomar) für weitere Verarbeitung

Es gibt zwei Situationen die mit Semaphoren abgesichert werden können:

Der Puffer ist leer ⇒ Semaphor Empty(0)

Der Puffer ist voll ⇒ Semaphor Full(N)

Die Operationen (Write, Read) verändern dann die Semaphoren und können blockieren (; ist ein Zeitschritt):

Read() ⇒ P(Empty) ; V(Full)

Write() ⇒ P(Full) ; V(Empty)

Abbildung 43-12. Synchronisierte Datenwarteschlange mit Semaphoren für Produzenten-Konsumenten Systeme

Und es geht schief: Das Problem der fünf dinierenden Philosophen ist ein Beispiel für Deadlocks (Verklemmung) in verteilten und parallelen (asynchronen) Systemen mit Kommuniaktion!

5 Philosophen an einem Tisch

5 Gabeln, jeweils eine zwischen zwei Philosophen

Ein Philosoph braucht zwei Gabeln zum Essen!

Eine Gabel wird durch eine Semaphore repräsentiert (atomare Ressource, Startwert des Zählers ist 1)

Implementierung mit Kanälen

Bisher haben wir nur Kommunikationskanäle wie Sockets oder Pipes kennen gelernt. Mit denen kann unter bestimmten Bedingungen ein Semaphor implementiert werden.

Wir nehmen an wir haben ein Prozesssystem aus N+1 Prozessen.

Ein Prozess soll den Semaphor implementiert und überwachen.

Die anderen N Prozess wollen sich über den Semaophor mittels Nachrichtenaustausch synchronisieren.

Dazu wird wie in der Übung ein Request und eine Acknowledge Kanale (dort Pipe) verwendet.

je nachdem wie es mit der Atomarität von Nachrichten auf dem Kanel aussieht können Kanäle von Prozessen geteilt werden oder nicht

Also zwei Prozesse P1 und P2 schreiben eine zweistellige Nachricht 1+ und 2- in den Requestkanal. Dann muss am anderen Ende entweder 1+2- oder 2-1+ gelesen werden.

Beim Acknowledge Kanal (da N Leser) muss je einer pro Prozess existieren (es sei denn der Kanal ist wieder atomar in seinen Nachrichten und arbeitet im 1:N Multicasting, d.h. alle Leser bekommen alle Nachrichten).

Protokoll: <ID>+ für V und <ID>- für P Operation und ID als Prozessnummer 1,2,3,..,N

channel req,ack1,ack2,... ;   waiters = [] ; counter = INIT
while (!terminate) {
  received=read(req)
  messages=split(received,2) // 1+2- => [1+,2-]
  for(message of messages) {
    switch (message[1]) {
      case '+':
        if (length(waiters)) {
          next=head(waiters); waiters=tail(waiters)
          write(ack[next],'!')
        } else {
          counter++;
        }
        write(ack[Number(message[0]),'!') ; break
      case '-':
        if (counter>0) {
          counter--;
          write(ack[Number(message[0]),'!')
        } else {
          push(waiters,Number(message[0]))
        } ; break
    }
  }
}

Algorithmus 43-1. Der Semaphor Master Prozess

function P() {
  write(req,String(myid)+'-')
  reply=read(ack[myid])
}
function V() {
  write(req,String(myid)+'+')
  reply=read(ack[myid])
}

Algorithmus 43-2. Der Semaphor Klienten Prozess

Zusammenfassung

Neben impliziter Synchronisation wie z.B. beim Lesen aus Dateien bedarf es in Multiprozesssystemen explizite Synchronisation

Ein beispiel ist der Semaphor der:

mutualen Ausschluss (Wettbewerb)

Kooperation ermöglicht.

Pipes sind i.A. inhärent synchron (geht aber auch asynchron)

Ein Semaphroe kann mit Kanälen und einem zentralen Masterprozess realisiert werden

Prozessblockierung (also Prozesswechsel) ist wesentliches Mermal von Synchronsiation

Es kann auch schief gehen wenn Prozesse mit mehreren Synchronisationsobjekten wie Semaphoren umgehen (Deadlock)