PRINCIPLES OF OPERATING SYSTEMS PRINCIPLES OF

Transcription

PRINCIPLES OF OPERATING SYSTEMS PRINCIPLES OF
PRINCIPLES OF OPERATING SYSTEMS
L. BENTABET, Bishop’s University, Winter 2007
Notes par Philippe Giabbanelli
I.
Definitions and history
1) What is a Computer System ?
It is a set of users, system and applications, Operating System
and resources. The OS (i.e. the kernel that is running all the
time on the computer) controls the use of the hardware for the
different applications, which are obviously used to solve some
problems. The requirement depends on users and applications.
● Needs of users
Convenience → Monopolized by a single user
→ Run multiple programs
→ GUI for ease of use
→ Resources utilisations is not an issue
♦ Example : Linux, windows, Mac OS, for PC.
Efficiency
→ Large, shared multi user systems
→ OS designed to optimize the use of resources
→ Fair amount of resources for every user
→ Security and protection (on memory !)
→ Efficiency
Trade-off
between
efficiency and
convenience
Nothing
♦ Example : mainframe system, Unix/Irix/Vax.
→ Workstations connected through a network
→ Sharing of devices and Network
♦ Example : Windows NT, Linux.
♦ Embedded systems (input are sensors !).
● Needs of the system
→ Only resources allocation.
2) Evolution of Operating Systems
● Mainframe Systems
Batch Systems ♦ Rudimentary OS without concept of process but a
‘job’, composed of code+data+control information.
♦ No interaction with the user.
♦ Input : punched cards or magnetic tapes.
♦ Output : line printer.
♦ A batch is a set of jobs. The execution is sequential.
Multiprogram ♦ Several jobs in memory
Batch Systems ♦ A job has the CPU as long at it is not waiting for an
I/O ; it is the CPU management (scheduling).
Magnetic disk
Job 1
Job 2
OS
load
job
memory
Job 3
Job 1
Job 2 Job 3
Job 4
Job Selection
Virtual memory managment.
OS
Job 1
CPU
Job 2
Job 3
CPU multiplexing
● Timesharing Systems
♦ Interaction with the user
♦ Support multi-tasking
♦ The CPU is multiplexed between the tasks
♦ For the scheduling of processes, switching is performed on I/O and with a timer.
♦ We say “process” instead of “job”.
● Desktop Systems
♦ Use of timesharing
♦ One user
♦ We focus more on convenience than efficiency
● Multiprocessors Systems
CPU 1
CPU 2
Memory
CPU n
♦ Use of timesharing
bus
♦ Reliability (fault tolerant)
♦ Economical
♦ n processors : speed by k.n, where k < 1 depends on the cost of managements of the network.
♦ Two configurations :
♦ Symmetric (solaris 5) : all CPU identical from the point of view of responsibility. User
responsible for distributing to the CPU.
♦ Asymmetric (solaris 4) : 1 master CPU. The OS is responsible for dispatching, instead of user.
Le switch entre les processus est rapide car on laisse toutes leurs données
en mémoire. On gaspille ainsi de la mémoire en la laissant allouée à
quelque chose qui ne l’utilise pas, mais en revanche le switch devient très
rapide puisqu’on change uniquement les pointeurs. C’est pour cela qu’on
arrive à un switch en millisecondes avec des timer extrêmement réduits.
II.
Computer Organization
disks
CPU
monitor (écran)
Disk Controller
Graphics Adapter
printer
Memory
keyboard
mouse
USB Controller
bus
…
1) CPU Organization
3046
3050
a
Execute the code
located at this part
of the memory
R1
R2
R3
3054
PC
3050
IR
load R1,a
........
load R1, a
101….
Translated
load R2, b
add R1, R2 into machine 011….
code
store R3, a
110….
CPU
Les registres du CPU sont manipulés par l’ALU (Arithmetic Logic Unit). IR est le Instruction Register,
qui contient l’instruction courant. PC (Program Counter) pointe vers la prochaine instruction. Il y a un
autre registre DR (Data Register), utilisé lorsqu’on veut sauver quelque chose en mémoire.
2) Controllers Organization
● Similar to CPU’s. A controller is in charge (responsible) for
the input/output devices ; it will transfer the data from the
buffer to the input/output device (or vice versa).
● Controls the Input/Output operations between their own
buffers and external devices.
● The communication controllers/CPU is done through the
bus. At some points, the controller will need the bus; they will
have to wait until the bus becomes free. Therefore, the
controllers will execute concurrently with the CPU (il y a une
concurrence pour l’accès au bus; sinon, on parlerait
d’exécution simultanée).
Le device status a 2 bits importants : Busy et Done. Le code
pour l’erreur survenue est mis dans les bits restants.
Le command register a 2 valeurs : input (external device →
buffer), ou output (buffer → external device).
Command
register
Device Status
Register
LOGIC
CIRCUITS
(control unit)
BUFFER
(internal memory)
Busy
0
0
1
1
Done
0
1
0
1
Description
Idle (ready for use)
I/O finished
Working
Undefined (error…)
Example : Save f.out to the disk, and then load f.in from the disk.
♦ Save f.out
- OS (CPU) checks if the device is idle (i.e. ready for use).
- Copy partially the data from the memory to the buffer. Les données sont rarement copiées en
totalité car le buffer a une taille limitée, même s’il arrive maintenant jusqu’à 16 Mo.
- Set output in the command buffer.
- Status of the device : working. Move data from the buffer to the disk.
- Status of the device : finished. We tell it to the CPU through the BUS.
The controller sends interrupts to the CPU ; the CPU is stopped in what it was doing, it is an
interruption.
♦ Load f.in
- Check idle
- Command register : input
- Device : working
- Copy from disk to buffer
- Finished → interrupts the CPU
For each device, we have a queue
(file d’attente). These queues are all
Output 3
put together in a data structure
Input 1
Output 1
Output 2
Input 2
called
device
status
table,
implemented in the OS.
3) Interrupts Mechanism
read(x)
y=x+2
load R1, a
load R2, b
add R1, R2
store R3, c
1
2
1 will be waiting for the controller.
2 will be using the CPU (i.e. running).
1 aura fini après le ‘load R2, b’. Il fait un interrupt : interrompt les processus.
Pour intervertir, on sauvegarde le contenu (content/context) du CPU, c’est-àdire les registres, et on charge le nouveau processus.
Il faut avoir un moyen d’identifier le périphérique qui a envoyé l’interrupt. Nous allons voir 2 moyens.
● Le périphérique envoie un interrupt, qui n’est qu’un seul bit avec 0 ou 1. On fait un sondage linéaire
(polling : check sequentialy) pour trouver le premier périphérique qui a comme statut finished. Cela
nécessite de définir une séquence de recherche, et donc d’assigner certaines priorités aux périphériques.
● Le périphérique inclus son identité dans l’interrupt qu’il envoit : vectored interrupt system, car c’est un
vecteur de bits. Ce système est illustré ci-dessous :
Le périphérique 0
génère
l’interrupt
Finished ?
k
n = 2 entrées 000…1. On utilise le
…
vecteur de bits tout à 0
pour dire qu’il n’y a
k
pas d’interrupt, donc
2 → k Encoder
les périphériques sont
nommés à partir de 0
k sorties
mais
le
code
commence à 1.
4) I/O Handling (gestion des entrées/sorties)
On cherche par exemple à exécuter la routine associée à read(x), demandé par le périphérique 1. Chaque
périphérique a une routine propre, i.e. sa façon de fonctionner. La routine est stockée en mémoire, à un
emplacement déterminé par l’adresse [offset+binary code] ; l’offset est déterminé par le système
d’exploitation, et le code binaire est donné par l’API lorsque l’utilisateur appelle une fonction.
Adresse de la routine pour le 0
……
100 address 0
101 address 1
Address 1 Routine
for the
device 1
Exemple : copy x du buffer
vers la mémoire
Dans l’exemple ci-contre, l’offset est 100.
On donne le code binaire 0 : on regarde
l’adresse dans la table de pointeurs (map),
on exécute la routine concernée. On peut
alors finir l’interrupt en chargeant la
contexte sauvegardé et en le relançant.
Notons que IRQ : priorité des interrupts.
5) DMA (Direct Memory Access)
Le CPU n’est pas utilisé pour faire les transferts avec les périphériques. Au lieu de cela, on utilise le
DMA. Le problème est que le CPU travaille et a besoin d’accès à la mémoire : le DMA et le CPU entrent
en compétition pour le bus. Dans certaines architectures, chacun a son bus (dual-bus architecture).
microcontroller
Main Memory
DMA
I/O device
buffer
CPU
Process
6) Hardware Protection
♦ Several processes and the OS are running concurrently
♦ All are sharing the hardware resources
♦ If there exist a fatal error in one process, we don’t want it to disturb the execution of the other
processes; we need to isolate it.
Examples : Overwriting the data of other processes,
involves the memory.
Infinite loop,
involves the CPU.
Multiple Input/Output operation of the same device,
involves the devices.
We need a protection mechanism implemented at hardware level to ensure that it cannot be broken.
Dual Execution Mode.
A bit is added to the CPU : the mode bit. It defines 2 modes of execution :
- User mode (1). It is a user program that is running : execution of behalf of the user. It cannot
execute instructions that may harm the system.
- Monitor mode (2), or supervisor mode, done on behalf of the OS. Instructions may harm the
system, but we hope that they won’t because they are written in the OS. Example : cin<<, fopen, …
User Mode
(bit = 1)
fopen : trap
(call to the OS)
User Program
OS
Abnormal
Termination
finished
Hardware
Check the mode bit : it
is 1. Generates an interrupt to
create a fatal error.
OS
Le switch du mode utilisateur au mode superviseur se fait en générant une interrupt (aussi dit trap). En
pratique, cette interrupt est générée par l’API, lors de l’appel d’une fonction. Toute action qui peut
bloquer le système doit être faite en passant par une fonction de l’API, comme l’ouverture d’un fichier. Si
le programme tente de communiquer directement avec le matériel, celui-ci verra que le mode bit est
toujours à 1, et que l’accès est interdit ; il fera une interrupt et l’OS fermera le programme.
I/O protection.
I/O routines must execute in supervisor mode. L’API se charge de faire la correspondance lors de
l’appel : elle change le mode et trouve le n à utiliser dans la table de pointeurs. Si les paramètres à passer
sont trop gros, on les sauve dans le user space et on place juste un pointeur dans le CPU ; sinon on fait un
passage direct dans les registres.
System Call n (user)
API
Switch bit mode 0
Va à l’adresse offset+n de la routine
Exécution de la routine
Switch bit mode 1
Si on arrive à mettre notre adresse dans l’espace de l’OS, et que cette adresse pointe vers notre
programme, alors on met notre bit mode à 0 de façon permanente, et on exécute tout le programme en
mode supervisor : tout le mécanisme de sécurité tombe à l’eau. Il faut donc une protection de la mémoire.
Memory Protection.
We add 2 registers to the CPU : base register (smallest address of the process), limit register (range of
addresses allocated) ; they can be accessed only in supervisor mode.
OS
Process 1
Process 2
Process 3
Base value
0
256000
300040
420540
880000
CPU
address
>=
Base + limit
yes
no
Trap to OS for fatal error
<
yes
Access OK
En pratique, la mémoire est
fragmentée, ce n’est pas un
bloc continu.
Utilise le MMU (Memory
Mamagnment Unit).
CPU Protection.
Si un processus est en exécution dans une boucle infini, il ne va pas rendre le CPU : on protège donc le
CPU avec un timer qui génère un interrupt tous les ∆t. Le timer est un compteur qui va de n à 0, branché
sur l’horloge interne (déclenchement sur front). ∆t est une variable gérée par l’OS (voir scheduling).
7) The storage structure
Main Memory.
The only media that the CPU can access through address is the main memory. A program must be in main
memory in order to be executed. This memory is volatile. Access are done at the hardware level, using
machine instructions (e.g. load, store, …).
R/W
RAM
Cache Memory
Controllers Registers
Managed at hardware level
I/O Mapped Memory
Les contrôleurs ont des
adresses et peuvent être
accédés par load et store.
Il y a une plage
d’adresses contrôleurs.
We use high speed memory (cache memory) to hold recently used data. If the data is in the cache, we use
the cache; otherwise it is in the RAM, and we copy it to the cache, and then we use it. It increases the
performances of the system. We try to place 80% of the data needed in cache instead of the RAM. Since
the cache memory is small, we need a data replacement policy.
On fait passer un des blocs du
Cache (full)
RAM
cache dans la RAM, par
exemple « remove the least
CPU
recently used block ». Après,
on copie et on utilise le bloc
We need this block
demandé par le processeur.
Time neighbourhood : data used non only once; several access to the memory are expected.
Location/space neighbourhood : we move some blocks of data instead of only 1 or 2 bytes.
Secondary Storage.
Magnetic disks, non volatile, accessed through the OS.
load
load
Magnetic Disk
Main Memory (RAM)
Cache
update
Update when we move the block
CPU
III.
Cache
Main Memory (RAM)
Cache
CPU
load
CPU registers
update
Dans la situation de deux
processeurs utilisant la
même mémoire, on établit
une communication entre
les deux caches.
OS Structure
1) OS Components
♦ Process = Running program + ressources (CPU time, files, messages…).
Process creation, deletion, suspension (operation d’I/O ou à cause du timer : demande de resources
non encore disponibles), resumption by the OS.
Interprocess communication and synchronization (partage de variables).
♦ Memory Manager. Allocate memory to processes, and keep track of it (e.g. use of limit + base
registers), and deallocate.
♦ File Manager. Mapping between the logical files and the secondary storage (où le fichier n’est pas un
bloc continu mais fragmenté). Manipulation of files and directories.
♦ Protection : CPU, I/O, Memory.
♦ Interfacing : On lit une commande. L’OS test la validité: si non, message d’erreur ; sinon trigger OS call.
2) System Calls : interface between processes and OS
indirect
Routines
implemented in
run-time library
Processes
Direct Call : fork(), read(), write(). Norme POSIX.
Readfile(), write() : win32API (windows).
OS
Read, write :
POSIX or win32API
♦ Use of registers to pass parameters
Load x
OS call 13
Routine B
Use x
x
On met les paramètres dans les registres et on appelle la
routine, qui va aller les chercher.
♦ Use of memory to pass parameters
x
Parameters
Load x
OS call 13
♦ Use of the stack (linux)
Calling process : push
OS process : pop
x
Read x
Load param
3) Useful OS calls
♦ Process Controlling
A new
command
Valid ?
Create new process.
Fork(), exec sur la commande.
Terminate the process : exit().
On a deux modes : wait() pour
une exécution en foreground, ou
rien si on veut du background.
Shell process
♦ File Management (POSIX)
Tous les périphériques d’entrées/sorties sont considérées comme des fichiers dans la norme POSIX :
open() retourne un fid, close(), read(), write(), lseek(), fcntl() envoie une requête spécifique comme
« don’t block the calling process. »
4) Device Managment
// user program
cout >> « x ? » ;
cin << x ;
L’utilisateur appelle
des fonctions de la
bibliothèque,
qui
font des appels au
noyau. L’OS fait le
lien entre l’appel et
le driver, et le lance
en mode superviseur.
Stdio.h
Write(deviceId, …). deviceId est un entier identifiant le périphérique :
disque dur en 3, floppy en 2 (major number), puis numéro : 1er
disque dur (numéro mineur 1), 2nd disque dur (numéro min. 2), etc.
Read(deviceId, …)
Device dependant drivers (they
are outside of the kernel, as we do
nowadays to keep the kernel as
small as possible).
OS call to the kernel. Switch in supervisor mode.
♦ Check value of the decice Id (switch…)
♦ Call the routine which knows how to
write to the right device. This routine is
responsible for error handling.
Rappelons qu’en POSIX, tout périphérique est vu comme un fichier. Quand on fait un cout, le
périphérique est l’écran : WONLY (write only) ; quand on fait un cin, c’est le clavier : RONLY (read only).
5) Interprocess Communication
Les processus doivent partager certaines données, et des problèmes de synchronisation se posent.
L’implémentation la plus
P1
courante consiste à autoriser
update
retrieve
x
le partage de mémoire dans le
Pr1
Pr2
P2
mappage des adresses des
update
y
processus. On peut aussi
P1base : 1000
use
utiliser
des pipes (se gère
P1limit : 1000
Exemple : un processus produit des données et
comme des fichiers avec write
P2base : 1900
un autre les utilise.
P2limit : 2100
et read dans le pipeId) ; par
contre ils entrainent un grand
nombre de context switching :
P1 Information to share
P1
Copy of the info
Pipe ID
time consuming !
6) OS Organization
MSDOS a été conçu pour fournir le plus de services dans le moins possibles d’espaces. Il adoptait une
architecture en couches, mais ne la faisait pas respecter : une application peut accéder directement au
matériel (c.f. flèches rouges). Il y a donc de graves problèmes de protection. Ceci se comprend car la 1ère
version était implémentée dans le processeur 8088 qui n’avait pas de mode bit.
Application programs
Resident Program
(toujours en mémoire)
Drivers
Hardware
Kernel
Les anciennes versions d’UNIX adoptaient aussi une structure en couche, mais la faisaient respecter. Le
problème était que tout était dans le noyau, il n’y avait aucun module : on parle d’une structure
monolithique, difficile à débuguer. De plus, chaque fois qu’on a un nouveau driver, il faut recompiler le
noyau ce qui fait augmenter ça taille et prend du temps. Le fonctionnement, lui, est rapide car peu de calls.
♦ memory manager
Using
System
The
♦ CPU/file manager
system
Call
Hardware
users programs
♦ drivers
Interface
Dans le micronoyau (Micro Kernel), on veut garder le noyau aussi petit que possible : la gestion de la
mémoire, du CPU, les communications et les drivers sont en dehors du noyau. La structure en couche doit
être respectée, mais le nombre d’appels est plus important. On va de l’user space (programme) dans le
monitor space (noyau) à l’user space (prendre les drivers) : 2 context switches. Ainsi, la 1ère version de
Windows NT (pour les serveurs) utilisait ce système et était plus lente que Windows 95 (paradoxe…). Le
système est plus stable, portable et facile à débuguer en conservant un petit noyau certifié (Linux).
La dernière architecture est celle à ‘modules’. L’OS est fait d’un micro kernel et d’un ensemble de
modules chargeables dynamiquement (runtime : quand on les appelle), et/ou au démarrage. Si un module
est dans le noyau, on l’exécute en mode superviseur. C’est l’architecture de certains Solaris où on peut
choisir ses modules.
IV.
Process Managment
1) Déroulement général et relations père-fils
A process is all what a program needs in order to be executed : text segment (program code), program
counter (address of the next instruction), data segment (global data defined within the program), stack
(memory ressource allocated to thje process in order to save temporary variables such as return
addresses), heap (memory that is allocated dynamically).
Only one process
new
admitted
ready
Just created
timer
complete
In memory, waiting
for the CPU
scheduled
waiting
terminated
Deallocation.
The
OS will release the
process ressources.
running
Request I/O or
waiting for event
There is a need for an efficient identification of each process, to know exactly its state, what it is using
and what it’s requiring. We use the PCB (Process Control Block) : an OS data structure that identifies
each process in the system (i.e. chaque processus a son PCB). It is updated on change of state. It contains :
- Process State
- Process ID (integer)
- PC and all the registers from the CPU. This is not updated during the execution.
- CPU scheduling information : priority, address of the next process.
- Memory Information. Everything related to the mapping, e.g. base and limit registers.
- Accounting information (used CPU time, time limit, …) for real time application where there
is a time deadline.
- List of files that are opened by this process, stored as a set of file identifiers (fid).
Head
Tail
La file contient les PCB, chacun ayant
l’adresse du PCB suivant. Une valeur
spéciale signifie « end of the tail ».
Le scheduling est en charge de la migration des processus entre
les files d’attentes (queues). Il peut y avoir plusieurs ready
queue, dépendantes des priorités ; de même, il y a plusieurs
waiting queue car une pour chaque périphérique sollicitée.
Ready et waiting ne sont pas deux files mais plutôt deux
familles de files. Chaque file est généralement implémentée par
une liste chaînée (linked list).
Lorsqu’un parent fait un fork,
Create new process
finish
le parent ou (exclusif) le fils
Ready queue
CPU
doit aller dans la ready queue. The parent goes to
Quand le segment de temps the ready queue
Request I/O
Time slice
finished
alloué à un programme vient à
expires
expiration et qu’on manque de
I/O queue
mémoire, on met le processus
dans le disque et on le
Is there enough place
Fork a child
rechargera quand on aura de la
in memory to store the
place ; on fait ça quand ils ont
The child execute.
process back in the
une basse priorité, ce qu’on
ready queue ?
nomme le medium term
yes
Swap out
scheduler. Celui ci-contre est
Ready queue
no : swap in
le short time scheduler, car il
Disk
to the disk
est invoqué souvent.
P1
Save PCB0
…
…
Copy of PCB1
resume
…
Save PCB0
resume
…
Copy of PCB1
interrupt
CPU Idle
interrupt
Context Switching
CPU Idle
P0
Il faut généralement minimiser les
context switching car ils utilisent le CPU.
Sur certains systèmes, le context
switching est supporté par le matériel :
- Machines de Sun Microsystem,
où le CPU a plusieurs ensembles
de registres et chaque PCB a son
propre ensemble.
- Une instruction spécifique de bas
niveau, au lieu d’une pléiade de
copy et load.
Les processus sont organisés sous forme d’arbre, basé
sur une relation parent-fils. Le processus faisant le fork
est le parent, et celui en résultant est le fils.
Shell
> ls
Parent
process
Le fils peut partager tout ou une partie des ressources du
parent, ou avoir de nouvelles ressources allouées par
l’OS. La définition de l’OS doit permettre ces 3
possibilités (policy).
L’exécution peut se faire en foreground (on ne détache pas le
processus, on attend qu’il ait fini avant de reprendre la main) : parent
will wait for child termination. L’exécution peut aussi se faire en
background : les processus sont en concurrences, considérés comme au
même niveau, et c’est au scheduler de s’en charger (execute
concurrently).
Child process
that will execute
the command ls
Execution
1.
P0 is running. Fork() P1
2.
P1 is ready
3.
P0 goes in waiting
4.
P1 starts executing
5.
P0 waits for P1 to resume
execution (ready list)
Fork() éxecute une copie des données du parent.
Fork() + execv charge un nouveau programme avec ses propres données dans l’address space du fils.
pid = fork()
my pid is 214 (le parent, car il a exécute l’instruction)
scheduler
printf(« my pid is %d\n »,pid) ;
my pid is 0 (le fils, car il n’a pas fait pid = fork() !)
Le scheduler décide de l’ordre de l’exécution. Ainsi, dans l’exemple précédent, l’ordre des messages est
indéterminé. Pour terminer un processus, exit() l’arrête et l’OS désalloue les ressources, ou avec
abord() le parent va terminer le processus fils. Si le processus parent termine, soit tous les fils s’arrêtent,
soit on tente de régler le problème en mettant un nouveau parent à la racine de l’arbre.
2) CPU I/O Burst Cycles
♦ A process is CPU bound if it spends most of its time using the CPU (i.e. process uses the CPU a lot).
Example : a program that calculates primes numbers; we do not need interaction with the user. It is most
often in the running state, and does not have to wait for any input or access to devices.
♦ A process is I/O bound if it spends most of its time in access read/write. It is most often in waiting state.
→ A well balanced program alternates between CPU bound and I/O bound.
CPU bound
I/O bound
Une
importante
invocation
du
scheduler se fait sur front haut,
lorsqu’un processus a fini son attente et
demande à nouveau un accès au CPU
3) Scheduling Mechanism and Criteria
Généralement, le scheduler est invoqué dans 4 cas :
Dans les vieux systèmes, on invoquait le scheduler
- terminaison d’un processus
juste sur les 2 premières situations : non-preemptive
- switch de l’état running à waiting
scheduling. Windows 3.x par exemple.
- switch de l’état running à l’état ready
- switch de l’état waiting à l’état ready
dispatcher
loader
Les systèmes d’exploitations modernes utilisent ces 4 situations, on parle de preemptive scheduling.
Les critères ont de gros impacts sur les performances du système.
Context Switch :
♦ Maximiser l’usage du CPU (minimiser le idle time).
♦ Switch to supervisor mode.
♦ Maximiser le throughput (nombre de processus terminés par
♦ Save PCB1 (free CPU registers)
unités de temps, par exemples 30 processus à la seconde).
♦ Load dispatcher
♦ Minimiser le turn around (temps total nécessaire pour exécuter
♦ Select a process from ready list
un processus, i.e. celui passé en ready/waiting/running).
♦ Switch to user mode
♦ Minimiser le temps de réponse (entre création et 1ère exécution)
♦ Load PCB2 into the CPU
♦ Minimiser le temps d’attente (passé dans la waiting queue)
Tous les critères ne peuvent être
satisfaisants en même temps. En
général, on n’en cherche qu’un.
CPU
P1
Scheduler
invoked
CPU
Scheduler
Select a process from
the ready queue
CPU
P2
4) Scheduling Policy : First Come, First Served (FCFS)
Processus
CPU Burst Time
Rappel : le CPU Burst Time est le temps nécessaire à un processus
24
P1
pour exécuter sa partie s’il dispose d’un plein accès au CPU.
P2
3
P3
3
CPU : P1
P2 P3
Average Waiting Time :
24 27 30
0 pour process 1
24 pour process 2 Moyenne 17
23 pour Process 3
Average Response Time : (0 + 24 + 27)/3 = 17. Pareil que le temps d’attente car non-preemptive (une seule
exécution pour chaque processus).
Average Turn Around Time : On suppose que tous les processus ont été créées au même temps T = 0. On a alors
(24 + 27 + 30)/3 = 27.
Si on inverse l’ordre d’arrivée des processus, on a : Average Waiting Time (0+3+6)/3 = 3
Average Turn Around Time : (3+6+30)/3 = 13
C’est donc un système très sensible à l’ordre. On remarque de meilleurs performances en exécutant les
plus courts processus d’abord, d’où l’idée de l’algorithme que nous allons voir maintenant.
5) Scheduling Policy : Shortest Job First (SJF)
Chaque processus est associé avec le prochain CPU burst time. Comme on ne le connaît pas, on fait une
prédiction à partir des précédents. En général, on ne conserve que les 10 dernières valeurs (on affirme que
le comportement sera le même que dans une période de temps récente). On peut prendre la médiane ou la
moyenne. Pour l’implémentation, il suffit de faire un tableau de taille 10 et l’ajout en (i++)%10 ; d’abord
on lance FCFS le temps de recueillir assez de valeurs pour les prédictions, puis on passe en SJF.
SJF réduit le average waiting time. C’est une méthode non-preemptive.
6) Scheduling Policy : Shortest Remaining Time First
Si un processus Pj entre dans la ready queue avec un CPU burst time tj < temps restant ti du processus
courant, alors on switch pour lancer Pj. Si un nouveau processus peut-être fini plus vite, on le lancera lui.
Process
P1
P2
P3
P4
Arrival Time
0
1
2
3
Predicted Burst Time
8
4
P1
9
5
0
1
CPU Chart :
P2
P4
5
P1
10
P3
17
Average Waiting Time : [0 + (10 – 1) + (1 – 1) + (5 – 3) + (17 – 2)]/4 = 6,5
-----P1------- ---P2--- ---P4--- ---P3--Pour chaque processus, on regarde le temps qu’il a passé à attendre; on divise par le nombre de processus.
Cet algorithme minimise le temps d’attente moyen. SJF sur le même exemple donne 7,75. Par contre, SJF
fait moins de context switching : dans notre calcul du temps d’attente moyen, on considère les
changements de contexte comme négligeables alors que par grands nombres ils commencent à coûter.
7) Première étude comparative
Imaginons que l’on ait les trois applications suivantes lancées en même temps :
- (1) traitement de texte
- (2) jeu 3D
- (3) calcul des nombres premiers
SJF va donner une grosse priorité au jeu 3D car le burst time est très bas : un jeu 3D s’arrête très
fréquemment pour les entrées/sorties (souris, clavier…) et fait rarement un moment de calcul pur. En
revanche, le calcul des nombres premiers sera pénalisé par SJF alors qu’il faudrait peut être allouer plus.
Par conséquent, SJF donne davantage de priorité aux processus I/O bound qu’à ceux CPU bound. L’ordre
d’exécution sera 213. Si on souhaite allouer au processus qui a le plus besoin de ressources en calcul, on
prend la politique longest job first (on remarque au passage qu’elle n’optimise aucun de nos ‘critères)’.
8) Principles of Priority scheduling
Un nombre entier est associé à chaque processus. Cela peut représenter la priorité de façon croissante ou
décroissante, cela dépend du système. On le détermine de deux façons :
- Externe. Dépend juste de la nature du processus : OS, interaction, real time (temps limite
auquel l’exécution doit avoir été faite), batch (aucune intéraction).
- Interne. On prend des critères comme le nombre de requêtes pour la mémoire, le nombre de
fichiers, ou la moyenne (I/O time)/(CPU time).
Le scheduling peut se faire de deux façons :
- Pre-emptive. Un nouveau processus dans la ready queue avec une plus grande priorité que
celui du CPU va dégager celui du CPU.
- Non-preemptive : je suis dans le CPU, j’y reste jusqu’au bout.
Les processus avec une priorité basses risquent de souffrir de starvation (‘mort de faim’) car ils ne vont
jamais accéder aux ressources qu’ils demandent. La solution fournie par la plupart des O/S est de faire
une priorité dynamique, qui va changer durant l’exécution : c’est l’aging. La priorité des processus
augmente avec le temps, pour être certain qu’ils passent un jour. Sous Linux, on parle de ‘crédits’ au lieu
de priorités, mais ils sont aussi mis à jour de façon dynamique. SJF est une politique de priorité où on
calcule la priorité sur la base du temps nécessaire pour la prochaine exécution (un critère interne).
26
9) Round-Robin (RR) Scheduling
Politique bonne pour l’intéractivité : on se focalise sur le temps de réponse. RR a été développé pour les
systèmes à partage de temps (timesharing). La ready queue est circulaire sur le principe FIFO (premier
entré, premier servi). Si un processus est scheduled (i.e. qu’il utilise le CPU), on démarre un timer.
Notons bien que c’est la première fois qu’on utilise un timer dans nos politiques de gestion.
Après un temps ∆t (time quantum), généralement 10ms ≤ ∆t ≤ 100ms, le processus en cours est
interrompu. Tous les ∆t le timer envoie un interrupt, et le processus arrêté va dans la queue de la file
d’attente (à la fin). Le processus de tête est alors lancé.
Process
P1
P2
P3
P4
CPU Burst Time
53
P1
17
68
0
20
24
Average Waiting Time :
Average Response Time :
CPU Chart (∆t = 20ms) :
P2
P3
37
P4
57
Timer reset
P1 (0+77-20+121-97)
P2 (20-0)
P3 (33-0+97-57+134-117)
P1 0
P2 20
P1
77
P3 37
P3
97
P4
117
P1
121
P3
134
154
P4 57 (très bas)
RR fait beaucoup de context swtiching, par exemple davantage que SJF. On peut vouloir prendre en
compte le temps nécessaire pour faire le changement de contexte quand on dessine la charge. Il faut en
tout cas savoir qu’à peu prêt 10% du temps est passé à faire des changements de contextes. Si ∆t
augmente il y a moins de context swtiching, mais cela ne sert alors plus à grand chose (si ∆t est trop haut
c’est équivalent à la méthode FCFS). Avec les nouveaux processeurs, ∆t est en ms et un changement de
contexte peut se faire en µs.
10) Comparaison des performances
Nom de la méthode
FCFS
SJF
RR
Average Waiting Time
+
-
Average Turn Around Time
+
-
Average Response Time
+
FCFS n’est bon nul part (naïf). SJF est bon pour les processus CPU bound. RR est bon pour les processus
I/O bound (i.e. interactif). La solution retenue dans la réalité n’est pas une méthode mais plusieurs : on
utilise plusieurs files d’attentes avec chacun son scheduler correspondant à une méthode.
11) Multi-level queues
La ready queue est divisée en plusieurs sous-queues. Par exemple :
♦ Les processus intéractifs (I/O bound, small CPU burst time) ont besoin d’un bon temps de réponse.
C’est la Foreground Queue, gérée par Round Robin.
♦ Les autres processus (CPU bound, large CPU burst time) sont dans la background queue, gérée SJF.
Foreground (Round Robin)
Background (SJF)
Mechanism for queue
scheduling
CPU
Pour gérer les priorités des queues,
il serait stupide de faire passer tout
le foreground avant par exemple
(problème de starvation). On fait un
RR mixé avec les priorités.
Remarques : dans ce modèle les processus ne sont pas autorisés à passer d’une file à l’autre, c’est peu
flexible. En réalité, les processus sautent entre les queues et il faut savoir à quelle queue un processus est
associée (dans windows 16 ou 32 queues). La solution la plus élaborée est le multi-level feedback queues:
on peut aller up et down dans les files d’attentes, et on a un point d’entrée à la création du processus.
12) Multi-level feedback queues
Un processus peut bouger entre les files. Une Multi-Level Feedback Queues (MFQ) se caractérise par :
- le nombre de files
- le scheduling policy pour chaque file (la façon de les gérer : round robin, SJF, …)
- quand un processus peut aller en haut ou en bas dans la hiérarchie
- dans quelle file un processus entre lorsqu’il a besoin du CPU
Par exemple, la configuration suivant défini de façon complète une MFQ :
- On a 3 files Q0, Q1, Q2. Q0 : RR (∆t = 8), Q1 : RR (∆t = 16), Q2 : FCFS.
- Un processus ne va jamais en haut.
- Un processus va en bas : Q0 → Q1 s’il n’a pas été terminé après ∆t = 8
Q1 → Q2 s’il n’a pas été terminé après ∆t = 16
- les processus entrent tous dans Q0
Q0 (∆t = 8)
C
P
U
Q1 (∆t = 16)
Q2 (FCFS)
Les processus interactifs vont rester dans Q0 voire Q1 car ils ne sont pas longs.
Les processus non-interactifs vont aller dans Q3. On met des priorités entre les files, comme Q0 > Q1 > Q2
afin de gérer l’accès au CPU. On a toujours les deux gestions possibles : preemptive (relâcher le CPU
pour un autre processus : problème de starvation) et non-preemptive (le processus dans le CPU se finira).
Processes
P1
P2
P3
P4
P5
P6
Arrival Time
0
1
2
3
4
5
CPU Burst Time
31
5
9
10
6
17
P1
(from Q0)
0
8
P5
(termine)
29
V.
P2
(termine)
P3
(from Q0)
13
P6
(from Q0)
35
P4
(from Q0)
21
P1
(from Q1)
43
P3
(from Q1)
59
60
Processes Synchronization
1) Exemple de problème : Bounded Buffer
Des processus coopératifs doivent partager des données, donc il y a un besoin de synchronisation. Un
exemple classique est celui du Bounded Buffer. Un objet produit des données pendant qu’un autre veut
les retirer, le tout par l’intermédiaire d’un tableau de taille n.
out
Buffer
Producer
add
Consumer
The producer :
while(1){
while(count==N} // wait
in
Problème : count est une variable partagée.
}
The consumer :
while(1){
while(count==0} // wait
// produce item
// grab an item
buffer[in] = item ;
in = (in+1)%N ;
count++ ;
item = buffer[out] ;
out = (out+1)%N ;
count-- ;
}
Soit count = 5. Le producer et le consumer vont exécuter count++ et count—de façon concurrente. Quelle
est la nouvelle valeur de count ? Cela peut-être aussi bien 4, 5 ou 6. Comme ce ne sont pas des
instructions atomiques, il est possible qu’elles soient interrompues. Incrément et décrément se fait avec :
Incrémenter : R1 = count
Décrémenter : R2 = count
R1 = R1 + 1
R2 = R2 – 1
count = R1
count = R2
Détaillons ce qui peut se produire lors de l’exécution du programme pour comprendre l’importance
d’avoir des instructions atomiques (qui ne peuvent pas être interrompues par timer ou quoi que ce soit) :
1. P1 R1 = count
[5]
2. P1 R1 = R1 + 1 [ 6 ] timer interrupt
3. P2 R2 = count
[5]
4. P2 R2 = R2 – 1 [ 4 ] timer interrupt
5. P1 count = R1
[ 6 ] timer interrupt
6. P2 count = R2
[4]
On arrive à count = 4, et on voit qu’avec des interrupts au timer différentes alors le résultat serait différent.
2) Solutions matérielles
Pour synchroniser, un bloque un des processus si l’autre est en train d’utiliser une variable partagée ; on
parle de Critical Section pour désigner la partie du code où un programme manipule des variables
partagées. Un seul processus peut-être dans sa Critical Section à un moment donné. S’il y a partage de
variables sans synchronisation, on parle de race condition : les processus se font la course.
La première idée et d’activer et désactiver les interruptions avec un bit dans le CPU. Cela donnerait :
Disable interrupts
Disable interrupts
Sauf si on veut donner des
Count ++
// critical section
Count -// critical section
priorités, les solutions de
Enable interrupts
Enable interrupts
synchro. sont symétriques
Cependant, s’il y a une boucle infinie dans la critical section, alors le CPU n’est plus protégé, on crash.
L’alternative est ‘test and set instruction’. Soit TS(m) avec m un emplacement mémoire, ou TS(R,m) avec
R un registre et m un emplacement. On ajoute cette instruction, qui consiste à faire 3 opérations :
Load R with the content of m
Test the content of R (i.e. return it)
Set m = true
Ce qui est capital c’est que ceci est géré comme une seule instruction, elle est atomique. Un exemple :
R
m false
Before executing TS(r,m)
Exemple :
false
}
lock = false ;
P1
P1
P1
P2
P2
P2
P1
P1
// put lock back to false
m true
After executing TS(r,m)
while(true){
While(TS(lock)) // these two lines disable interrupt
P1.yield()
// critical section
Trace :
R
La Critical Section est comme
une pièce fermée et le lock en
est la clé : on la demande, on
rentre et on ferme derrière soit ;
on rend la clé en ressortant.
lock → false
TS(lock) ← true
enter the critical section
Timer interrupt
TS(lock) → true
lock → true
yield() // relâche le CPU et on passe en ready queue
lock → false
CS exécutée
Il y autant de critical sections
que de variables partagées :
ici, il y en a 2 puisque le lock
en lui-même est une variable
partagée. Ainsi créer des
solutions à des problèmes de
synchronisation peut faire
intervenir d’autres CS.
La dernière possibilité est l’instruction machine swap, qui peut remplacer le TS. On a swap(m1,m2) qui
échange les contenus des emplacements mémoire. On prend m2 pour le lock partagé et m1 pour le lock
local (pour chaque processus on a un m1). Cela nous donne la gestion suivante :
Processus P1 : boolean key1 = true ;
// local key
While(true){
Key1 = true;
do{
swap(Key1, lock)
}while(key1 = true)
// ces deux lignes sont à la place du yield
// critical section…
lock = false;
}
Trace : P1 is executing.
Key1 = true
Key2 = false
Lock = true
Timer interrupt
P2 is executing
Key2 = true
Lock = true
Timer interrupt
P1 exits the critical section, puts lock to false
Timer interrupt
P2
Key2 = false, enter the critical section
3) Solution logicielle : sémaphore
Un sémaphore S (aussi dit mutex, ou mutual exclusion) est une variable de type entier accessible
uniquement par deux opérations atomiques : acquire (a lock : wait instruction) noté P(s), et release V(s) :
Modèle pour acquire
Implémentation de la boucle
While(s ≤ 0){ ; } // busy wait loop
while(s ≤ 0){
s-- ;
// atomique
EnableInterrupt() ;
// des interrupts sont autorisés dans la boucle
DisableInterrupt() ;
}
L’instruction release, notée V(S) est uniquement s++. On la nomme signal instruction. Un exemple :
Semaphore mutex = 1 ;
P1 :
while(true){
P2 :
while(true){
Acquire(mutex); // mutex.P()
Acquire(mutex);
}
// critical section
// critical section
Release(mutex); // mutex.V()
Release(mutex);
}
Si l’on écrit le code dans un ordre différent, on fait des priorités. Par exemple, A s’éxécutera avant B :
Semaphore mutex = 0 ;
P1 :
A
P2 :
Acquire(mutex)
Release(mutex)
B
Si les processus s’attendent l’un l’autre, il y a blocage (deadlock). C’est le problème le plus classique
lorsqu’on veut gérer la concurrence. Le problème devient encore plus compliqué lorsqu’il y a deux
classes, chacune écrite par un programmeur différent. On ne peut pas savoir qui met quoi dans quel ordre.
P1 :
Semaphore s = 1, q = 1 ;
Acquire(s)
Acquire(q)
P2 :
// critical section
// critical section
Release(s)
Release(q)
Acquire(q)
Acquire(s)
Release(q)
Release(s)
Dans cette configuration là, la seule solution est d’utiliser un AND qui fait l’opération d’acquisition
simultanément sur plusieurs sémaphores. Ainsi, chaque programmeur n’a pas à se soucier de l’ordre dans
lequel son collègue a écrit sa classe.
Si s2{0, 1} alors on parle de sémaphore binaire. Si s2N, c’est un counting semaphore.
L’implémentation est exactement la meme. La difference en terme d’application est que plusieurs
personnes peuvent avoir accès à la section critique, alors que le binaire n’en autorise qu’une. Exemple :
out
Buffer
Producer
add
Semaphore mutex = 1 ;
Semaphore full = 0 ;
Semaphore empty = n ;
Producer :
in
Consumer Le buffer est une critical section pour lequel on utilise
un sémaphore binaire. On utilise aussi un counting
semaphore count pour le nombre d’objets en buffer.
If(count==0) BlockConsumer()
// semaphore empty
Else if(count == n) BlockProducer() // semaphore full
// le buffer est libre
// counting semaphore : au départ aucune cellule n’est pleine
// un autre counting semaphore
while(true){
Produce nextItem
Acquire empty // n – 1
Acquire mutex
Add NextItem to buffer
Release Mutex
Release Full
}
Consumer :
while(true){
Acquire Full
Acquire mutex
Consume item
Release mutex
Release Empty
}
Un problème classique est celui des philosophes qui dînent, pour illustrer les deadlock. On a 5
philosophes et 5 baguettes. Chaque philosophe a besoin de deux baguettes pour manger ses nouilles, puis
il les repose pour qu’un autre les utilise, et pendant ce temps-là il pense. Le but est de synchroniser… Les
philosophes sont des processus, les baguettes des sémaphores et les nouilles la critical section.
Chaque philosophe a une baguette à sa gauche et une à sa droite. La table est un cercle. Ainsi le
philosophe 1 a accès aux baguettes 1 et 2, le philosophe 2 aux baguettes 2 et 3, …, le philosophe 5 aux
baguettes 5 et 1. Le code pour résoudre le problème est le suivant :
While(true){
AND(acquire(chopstick[i]),chopstick[(i+1)%5])) ;
// eat
Release(chopstick[i]);
Release(chopstick[(i+1)%5]);
// think
}
Le problème des readers/writers : on a un fichier qui est une ressource, accédée par certains processus en
lecture et en écriture. On autorise plusieurs readers à accéder le fichier, mais s’il y a un processus en train
d’écrire dans le fichier alors personne ne doit pouvoir y accéder. On a donc deux situations : plusieurs
readers ou un seul writer.
Mettons en place une 1ère approche par sémaphores. On a deux types de processus, chacun avec son code:
Semaphore write_block = 1 ;
// initially we are not doing any block at all
int read_count = 0;
// count how many readers we have
Writer :
while(true){
Reader : while(true){
acquire(write_block)
read_count++;
// write to the file
if(read_count == 1) acquire(write_block);
release(write_block)
// read from the file
}
read_count--;
if(read_count==0) release(write_block);
}
On veut savoir si ce qu’on a écrit est bon. Est-ce que toutes les variables partagées sont correctement
gérées ? Non : read_count est une variable partagée entre les readers, et il faut aussi la synchroniser ! On
définit un nouveau sémaphore, ce qui entraîne quelques modifications sur le code du reader :
Semaphore mutex = 1 ;
Faisons une trace de l’exécution. Arrivent R1, R2, W1, R3.
while(true){
R1 : read_count = 1, write_block = 0, read <timer>
acquire(mutex) ;
R2 : read_count = 2, read
<timer>
read_count++ ;
W1: write_block is 0 so we are blocked
<timer>
if(read_count == 1) acquire(write_block) ;
R3 : read_count = 3, read
<timer>
release(mutex);
R1 : finished, read_count = 2
// read from the file
R2 : finished, read_count = 1
acquire(mutex);
R3 : finished, read_count = 0
read_count--;
W1 : write !
if(read_count == 0) release(write_block);
On voit qu’un writer doit attendre tous les readers, ce qui
release(mutex);
n’est pas génial… On va utiliser des monitors plutôt.
}
4) Solution logicielle : monitor
Un monitor est implémenté comme un Abstract Data Type pour lequel sont définies des données privées
et des méthodes publiques. Les processus font appels aux méthodes du monitor. Un monitor n’autorise
qu’un seul processus à un moment donné, i.e. plusieurs processus ne peuvent pas exécuter une ou
différentes méthodes du monitor à un moment donné ; on parle de Mutual Exclusion Principle. Exemple :
monitor shared count {
int count ;
public entry increment(){ count++ ; }
public entry decrement(){ count-- ; }
}
Les variables de condition sont un type de données qui apparaît seulement dans les monitors. On peut y
accéder par trois opérations :
- wait (équivalent de Acquire). On suspend le processus qui appelle, et on le met dans une file
associée avec la variable de condition. On relâche le monitor. On attend un signal call (on est
dans le wait state est un autre processus va nous faire revenir en ready grâce à son signal).
- Signal (équivalent de Release). On reprend uniquement un processus de la file associée ; s’il
n’y a aucun processus en attente, alors il n’y a pas d’effet (différence importante avec release).
- Queue. Retourne vrai s’il y a au moins un processus en attente, et faux sinon.
Exemple : soit A dans P1 et B dans P2 tel que A s’exécute avant B, en utilisant des variables de condition.
Condition okgo ;
P1 : A
P2 : okgo.wait()
Okgo.signal()
B
Cependant, il y a un problème ici. On part du principe que P2 s’exécute d’abord, il se met en attente, A est
fait et va libérer P2. Cependant, si P1 exécute en premier, alors il va finir et P2 va se mettre en attente pour
un signal qui ne viendra jamais… De même, analysons la solution suivante au problème readers/writers :
monitor readerWriter_1 {
int numberOfReaders = 0;
int numberOfWriters = 0;
boolean busy = FALSE;
public:
startRead() {
while(numberOfWriters != 0) ;
numberOfReaders++;
};
finishRead() {
numberOfReaders--;
};
startWrite() {
numberOfWriters++;
while(busy || (numberOfReaders > 0)) ;
busy = TRUE;
};
finishWrite() {
numberOfWriters--;
busy = FALSE;
};
};
Analyse sur la séquence R1 R2 W1 R3
R1 : numberOfReader = 1, read
R2 : numberOfReader = 2, read
W1 : numberOfWriters = 1, wait
R3 : wait
R1 : done, numberOfReader = 1
R2 : done, numberOfReader = 0
W1 : writes, numberOfWriters = 0
R3 : numberOfReader = 1,
numberOfReader = 0
C’est mieux dans le sens où le writer
n’a pas à attendre tous les readers…
Cependant, cette solution est théoriquement fausse car un seul processus peut exécuter dans le monitor.
Ainsi, quand W1 est dans le while loop et puisque cette boucle est dans le monitor alors on a un deadlock.
monitor readerWriter_2 {
int numberOfReaders = 0;
boolean busy = FALSE;
condition okToRead, okToWrite;
public:
startRead() {
if(busy || (okToWrite.queue())
okToRead.wait();
numberOfReaders++;
okToRead.signal();
};
finishRead() {
numberOfReaders--;
if(numberOfReaders == 0)
okToWrite.signal();
};
startWrite() {
if((numberOfReaders != 0)
|| busy)
okToWrite.wait();
busy = TRUE;
};
finishWrite() {
busy = FALSE;
if(okToRead.queue())
okToRead.signal()
else
okToWrite.signal()
};
};
On utilise donc la solution améliorée ci-contre.
Regardons son fonctionnement sur R1R2W1R3 :
R1. if(busy||okToWrite.queue()) est false. NbReader =
1. OkToRead.signal() sans effet. READ
R2. NbReader = 2. READ
W1. NbReader ≠ 0, waiting queue of
okToWrite, invoke scheduler.
R3. okToWrite() est vrai, waiting queue de
okToRead
R1. FINISH READ. NbReader = 1.
R2. FINISH READ. NbReader = 0. Signal the
writer W1.
W1. busy = true. WRITE. On entre dans
finishWrite(), busy = false, signal the reader R3.
R3. READ. On entre dans finishRead(). nbReader
= -1.
Que se passe-t-il lorsque R2 appelle okToWrite.signal() ? Il y a deux possibilités d’implémentations :
- On fait un context switch, R2 va dans la waiting queue et W1 passe à l’exécution. Il y a un
context-switch lorsque W1 termine, R2 exécute et termine immédiatement, re-context switch.
La condition « nombre de readers à 0 » peut ne pas être vrai indéfiniment : un changement de
contexte immédiatement nous permet de certifier que la condition qu’on veut signaler est
toujours vraie. C’est la méthode « signal et wait », qui a besoin de plus de context switches.
- R2 termine et passe en terminated state. On fait un context switch à W1. La condition n’est pas
re-testée comme c’était un if, mais entre temps le nombre de readers peut être différent de 0.
Solution est plus rapide mais plus risqué. Méthode signal & continue. Utiliser un while, pas if.
VI.
Memory Managment
1) Les différents moments où faire la liaison
Le programme de l’utilisateur doit passer par
plusieurs étapes avant l’exécution. L’objectif est
de comprendre comment les adresses sont
transformées durant ces étapes. Les adresses des
variables pointent sur des adresses physiques :
c’est l’address binding.
Un programme dans le disque peut-être chargé
plusieurs fois en mémoire (dû au swap par
exemple) et ainsi une même donnée peut se
retrouver à des endroits différents lors de chaque
chargement, puisqu’on a un Random Access
Memory. Le but de l’address binding est de
s’assurer que les adresses dans le programme
correspondent aux adresses physiques : le address
binding peut se passer lors de chacune des étapes.
Dans les systèmes d’exploitations modernes, on
préfère repousser l’address binding jusqu’au
moment de l’exécution, ce qui nécessite un support
matériel (contrairement aux 2 étapes précédentes).
Code Source
Compile Time
1
Compilateur
Relocatable
Object Code
Load Time
2
Object
Modules
Linker
Static System
Library
Load Module
Loader
Execution Time
3
In Memory
Executable
Dynamic Linking
(Runtime Libraries)
Address Binding at Compile Time. Il faut connaître les emplacements mémoires où le programme va
être chargé, sinon il est impossible d’écrire une adresse physique. Dès qu’on change d’emplacement, il
faut recompiler le programme.
// starting address
Exemple :
int gvax ;
1000 …
int proc1(int arg){
1008 entry proc_a
gvar = 7 ;
…
put_record(gvar) ;
1220
load R1, 7
}
1224
store R1, 3004 // address de la variable
// address de la routine
1228
call 2334
Physical Address = Relative Address
…
2334 entry put_record
C’est le type de liaison utilisé dans MS-DOS
…
pour les exécutables .com servant à faire les 3004 [gvar space]
commandes.
↑ relative addresses
Le chargement du programme se fera ici à partir de l’emplacement mémoire 1000 : il n’y a pas le choix.
Address Binding at Load Time. L’emplacement mémoire d’où on charge est inconnu. On génère des
relocatable code lors de la compilation. Dans l’adresse relative, on affirme que le programme commence
à 0000. On aura donc store R1 2004, call 1334, etc. On parle ici d’un espace d’adresse logique (logical address
space), qui démarre donc à 0. Lorsque le programme est chargé, on met à jour l’adresse relative (en
utilisant une base address : on l’ajoute) et on obtient le physical address space (les adresses réelles).
Address Binding at Execution Time. Les programmes peuvent bouger d’un endroit à un
autre dans la mémoire, donc on fait la liaison au dernier moment : lors de l’exécution ;
comme on l’a vu, il faut un support matériel.
P1 (110K)
Les programmes bougent en mémoire afin de garantir une utilisation optimale. Mettons
qu’à un temps t1 le programme P2 termine. Il n’y a pas de bloc continue de plus de 30 Ko,
ce qui peut être gênant : on choisit de faire bouger le nouvel espace libre à la fin, et on
réduit la fragmentation. Il faut alors qu’on décale tous les autres programmes… Ainsi,
bouger les programmes en mémoire intervient lorsqu’on veut réduire la fragmentation.
P3 (75K)
P2 (20K)
P4 (80K)
Free (32K)
L’adresse logique est celle générée par le CPU, et l’adresse physique est celle reçue par la mémoire.
Call 1334
CPU
1334
MMU
Adresse logique
Adresse physique
Le Memory Mapping Unit ajoute le
base register (version simplifiée du
vieux MMU sur Intel 8086).
Si l’adresse logique = l’adresse physique, alors la liaison s’est faite à la compilation.
Sinon, la liaison est faite au load time ou à l’execution time.
Dynamic Linking (performed at run time). La liaison est reportée jusqu’à l’exécution pour :
- Sauver de la mémoire et de l’espace disque
- Pouvoir mettre à jour les librairies utilisées sans avoir à recompiler tous les programmes qui
les utilisent.
Attention : le stub call doit être exécuté en
#include <system library>
load module
supervisor mode pour être capable d’accéder
…
…
compiled
à l’espace mémoire d’autres programmes. Il
call system routine 1
call stub function
y a besoin d’un support de l’OS.
…
Des systèmes d’exploitations entry of the stub
comme windows permettent
if the called routine is already in the memory
Un stub désigne
d’avoir plusieurs versions des
execute a call to this routine
une fonction qui
librairies en mémoire ; le
else load the routine in the process address space
fait l’intermédiaire
numéro de version identifie.
2) Allocation mémoire continue
Un processus se voit allouer un bloc de mémoire contiguë. Il y a deux méthodes pour le faire.
Multiple Partition Method. Les blocs sont déjà calculés et à tailles fixes (on trouve de 4 Kb à 4 Mo…).
Les blocs ont des tailles en puissance de 2 (puisque la mémoire est elle-même une puissance de 2).Un
trou (hole) est un ensemble de partitions libres (i.e. pas encore utilisés par un processus). Si un processus
fait une demande d’espace, on lui trouve des partitions dans un trou qui est ‘assez grand’.
OS loaded in
low memory 4K
P5
4K
P8
12K
P2
4K
Si P8 termine, on a un trou de 12k. Mettons que P10 arrive à ce moment là, on
va le stocker dans ce trou s’il fait moins de 12k. Il demande 7k, donc on lui
donne la quantité de blocs nécessaires, soit 8k. On voit que :
Mémoire allouée ≥ Mémoire demandée
Si quelqu’un veut alors 5k, on lui chercher 8k mais on ne les a pas. Alors qu’en
pratique P10 nous laisse encore 1k de libre et qu’on a un bloc, ça pourrait
loger… La mémoire dans un bloc non utilisée par un processus (comme le 1k
de P10) est dite fragmentation interne : on ne peut pas l’allouer à d’autres
processus, elle est perdue. Plus grande est la taille des partitions et plus il y a
de fragmentation interne, mais plus il y a de partitions et plus l’address binding
a un coût élevé.
Variable Partition Method. Mémoire allouée = mémoire demandée. La stratégie d’allocation est : un
processus demande un bloc mémoire de taille n, et on essaye de l’avoir à partir d’un trou. Soit n = 15 avec
des trous n1 = 10k, n2 = 20k, n3 = 16k, n4 = 40k. On a plusieurs stratégies : minimiser la taille des trous
restants (on prend n3), maximiser (on prend n4)… On les nomme comme suit :
- First Fit Strategy : allouer le premier trou assez grand (soit n2). Rapide.
- Best Fit Strategy : allouer le plus petit trou assez grand pour contenir le processus (soit n3).
Avantage : on minimise la mémoire laissée (left over), on tente d’optimiser son utilisation.
- Worst Fit Strategy : allouer le plus grand trou (soit n4), afin que le nouveau trou restant soit
assez grand pour pouvoir contenir d’autres processus.
Il n’y a pas de fragmentation interne mais une fragmentation externe : la mémoire n’est pas perdue parce
qu’elle est demandée par un processus qui n’en fait rien, mais parce qu’il reste des miennes inutilisables.
3) Memory Paging
On subdivise un processus en plusieurs blocs. La mémoire logique d’un processus est ainsi coupée en
blocs de tailles fixes appelés pages. La mémoire physique est aussi divisée en blocs de tailles fixes
appelés frames. La taille d’une page est la même que la taille d’une frame (ceci est une assertion cruciale).
Process
Page number
00
01
Frame Number
000
001
10
11
010
011
100
101
110
111
Memory
Pour lancer un processus de n pages, on
a besoin de trouver n frames en
mémoire : chaque page est chargée
dans une frame. Les frames n’ont pas
besoin
d’être
continues.
La
fragmentation sera interne car il faut
exprimer la taille du processus en
fonctions d’un nombre de pages à taille
constante. Pour garder la trace des
frames allouées à un processus, on
utilise une fonction de mappage
implémentée dans une lookup table (LUT).
Call logical address
La traduction d’une adresse logique dans une page en adresse physique dans la mémoire se fait avec :
Physical(page number . offset) = LUT(page number) . offset
CPU
Adresse logique
0000
a
000…
xx.yy
b 0001
offset 01
Adresse logique
page nbr.offset
001…
0010
ijkl
c
d 0011
Page nbr Offset
Adresse physique
010…
mnop
0100
e
xxx.yy
0101
f
Frame nbr.offset
g 0110
011..
LUT
h 0111
1000
i
100..
1001
j
00 101
k 1010
101…
01 110
Frame nbr Offset
1011
l
10 001
abcd
110…
m 1100
11 010
n 1101
Adresse physique
LUT
e
f
g
h
o 1110
111…
p 1111
Mémoire
Frame Number
Adresse Logique
Translation faite par le MMU
(pages de 4b)
Considérons que nous avons un processeur 32 bits. La taille d’une page est mise à 4Kb = 212 bits.
Quelle est la taille de l’offset d ? 12.
Quelle est la taille du page number p ? 32 – 12 = 20 bits sont libres pour écrire p, soit 220 entrées en LUT.
Problème : on veut avoir quelque chose de rapide dans le LUT, donc on utilise des registres. Ici, on a
besoin de 1 Mo en registres, ce qui coûte extrêmement cher… Pour avoir une facture modérée sur le
matériel, on va de 256 à 1024 registres. On a donc le choix entre 2 implémentations pour la LUT :
- Soit des registres rapides dédiés, mis à jour à chaque changement de contexte (puisqu’ils
représentent un processus). Problème : il en faut 220 et même 252 sur processeur 64 bits, $$$ !
- La LUT de chaque processus est gardée en mémoire dans l’espace mémoire du processus. On
a besoin d’un pointeur (page table base register) pour connaître où la table se trouve. Ce
pointeur vers l’emplacement mémoire du LUT est contenu dans le MMU. Pas coûteux du tout.
OS
P1
LUT for Process 1
P2
LUT for Process 2
Address 1
Address 2
Si le processus 1 est en exécution, PTBR contient address 1. On
fait un context switch, le processus 2 se met en route : PTBR
contiendra address 2, etc. L’adresse de la table est stockée dans
le PCB.
CPU → a = page number p . offset d → *(*(PTBR + p) + d)
On fait un accès à la zone de la LUT et on se décale pour avoir
la bonne page, soit PTBR + p. On regarde où cela pointe pour
avoir la frame, et on rajoute l’offset soit *(PTBR +p) + d.
Finalement, on va à l’adresse mémoire que cela pointe. Il y a
donc deux indirections, ce qui ralentit la mémoire par deux…
Un autre problème se pose : la mémoire est fragmentée, et le LUT est assez gros. S’il se retrouve
fragmenté lui aussi, alors il faudra une autre table pour stocker sa propre fragmentation et c’est sans fin. Ce
qui impose donc qu’il nous faut un LUT non fragmenté.
On choisit un compromis : on stocke 64 à 256 page entries dans un ensemble de registres dédiés, nommé
le TLB (translation look-aside buffer : ce n’est pas un lookup table), en mettant dedans les pages les plus
fréquemment utilisées. Toutes les entrées sont stockées dans la mémoire. Si la page qu’on cherche n’est
pas dans le TLB (TLB miss) alors on va la prendre dans la mémoire et on la met dans le buffer (à la place
de la page la moins utilisée) ; si la page qu’on cherche est dans le TLB (TLB hit), on l’utilise et on
incrémente son utilisation. Le TLB est implémentée de façon matérielle comme un tableau.
TLB
update
2 7
3 4
1
50
p = 10 d = 10
Mémoire
CPU
OS
3
5
7
4
0
1
2
3
frame page
TLB miss
Shift 2
1 5
3 4
30
50
Frame number
Page number
Frequence
A Translation Lookaside Buffer (TLB) is a
cache in a CPU that is used to improve the
speed of virtual address translation. A
TLB has a fixed number of entries
containing parts of the page table which
translate virtual addresses into physical
addresses. It is typically a contentaddressable memory (CAM), in which the
search key is the virtual address and the
search result is a physical address.
Exemple : pour un processus donné, on a 4 pages chacun de 4
bites. On a d = 2 (car 2 bits sont nécessaires pour accéder à 4
bites) et p = 2 (car 2 bits sont nécessaires pour accéder à 4
pages). Comme c’est un exemple, on se permet de ne pas
avoir |p| = |f|, mais on rappelle qu’il le faut dans la réalité.
4) Nouvelle génération d’algorithmes de paging : hierarchical paging
On a un livre de 1000 pages, et on décide de le diviser en 10 chapitres de 100 pages. Si on a un processeur
de 32 bits et une taille des pages de 4 Kb, un single paging serait [p : 20 bits ; d : 12 bits]. Dans le
hierarchical paging, chaque numéro de page est lui-même pagé (similaire à la création de chapitres).
Ainsi, on divise p : [p1 : 10 bits page number ; p2 : 10 bits page offset ; d : 12 bits]. Cette solution est celle
implémentée dans le Vax et le Pentium 2.
a
CPU
p d
b
Appliquer un
00 2
000 2
f d
01 1
001 1
décalage de 01
10 4
010 4
p1 p2
11 7
011 7
0 address 1
100 0
1 address 2
mémoire
101 6
p1
110 5
111 3
Cependant, sur une machine 64 bits on se retrouverait avec un p1 de 42 bits ce qui est trop
p1 p2 f
grand car les registres sont trop chers. Il faut alors augmenter le niveau de hiérarchie, ce
qui réduit d’autant la vitesse de la mémoire…. Ainsi, on va introduire les hashed page
tables, et le inverted page tables, spécialement pour les architectures 64 bits.
5) Inverted Page Table
Si chaque processus a sa Page Table, alors on a besoin de devoir changer toutes les entrées de la Page
Table en mémoire lorsqu’on change de processus. Ca nécessite du temps, et c’est d’une utilité assez faible
puisqu’un processus a peu de chances d’utiliser toute la mémoire. Ainsi, on décide d’avoir une seule table
où les entrées sont les frames au lieu d’être les pages (d’où l’idée d’une table inversée).
P1
PID
Page Number
PID, adresse a
CPU
On réduit les temps pour les
changements de contextes et la
mémoire nécessaire, mais la
recherche peut être coûteuse :
on fait intervenir un TLB pour
éviter une recherche trop chère.
…
Frame 1
Pn
Frame 2n-1
PID Page Number Offset
Search table
Frame Number
Offset
6) Partage de pages
Editor1
Editor2
Editor3
Data1
1
2
4
6
Editor1
Editor2
Editor3
Data1
1
2
4
7
Mémoire physique →
1
2
Editor1
Editor2
3
4
5
6
7
Editor3
Editor6
Editor7
On veut partager certaines pages. Par
exemple, un petit éditeur de texte est partagé
par deux processus, mais chacun a ses
données propres. Comment peut-on garantir
qu’il y a bien sécurité, en différenciant bien
les données qui sont partagées du reste ?
Comment donner une sémantique à ce
partage ? On va introduire les segments.
7) Segmentation
Il s’agit d’un schéma de gestion mémoire qui permet de donner un sens à un processus : on le considère
comme une collection d’unités logiques appelés segments, tels que le programme principal, les routines,
les objets, les variables globales…
Les segments (de
1
routines
1
taille
variable)
sont
Les segments
System
chargés d’un seul
sont numérotés
Library
3
2
bloc en mémoire
main
3
à la compilation
Sqrt
4
stack
(i.e. aucun paging)
Process
A la compilation, l’appel à une routine est traduit en « call <segment number 2, offset d> » où la routine
est à la dième entrée. Pour chaque segment, on utilise un base register et un limit register : le segment
appelé est transformé en la base, et la partie interne est l’offset (dont on vérifie qu’il est inférieur à la
limite sinon on dépasse du segment : segmentation fault !).
CPU
a
Segment number
Base Value
On a une segment table propre à
chaque processeur, et elle
change
donc
s’il
y
a
changement de contexte. Cette
solution a été développée avant
le memory paging, mais on peut
trouver les deux combinées, par
exemple dans le Pentium 2.
Offset
Limit Value
<
b