Διεργασία (υπολογιστές)

Διεργασία (process) είναι ένας όρος της πληροφορικής ο οποίος περιγράφει το στιγμιότυπο ενός προγράμματος που εκτελείται σε έναν υπολογιστή. Σε αντιδιαστολή με την έννοια του προγράμματος, το οποίο είναι ένα στατικό σύνολο εντολών, μια διεργασία συνιστά την εκτέλεση αυτών των εντολών. Επομένως ένα πρόγραμμα γενικώς συσχετίζεται με περισσότερες από μία διεργασίες, μία για κάθε φορά που εκτελείται. Μια διεργασία αποτελείται από το ίδιο το πρόγραμμα και από κάποιες τιμές που περιέχονται στη μνήμη και στους καταχωρητές του επεξεργαστή, δηλαδή την κατάσταση του συστήματος, κάθε στιγμή που το πρόγραμμα εκτελείται. Τα σύγχρονα λειτουργικά συστήματα επιτρέπουν την ταυτόχρονη συνύπαρξη πολλαπλών διεργασιών στη μνήμη του υπολογιστή καθώς υποστηρίζουν πολυδιεργασία, μία μέθοδο υλοποίησης ταυτοχρονισμού με την οποία, είτε με κατάλληλη κατανομή του χρόνου του μοναδικού επεξεργαστή (ψευδοπαράλληλη εκτέλεση) είτε λόγω της ύπαρξης περισσοτέρων του ενός επεξεργαστών (παράλληλη εκτέλεση), είναι εφικτή η ταυτόχρονη εκτέλεση πολλαπλών διεργασιών.

Επισκόπηση 
Μία διεργασία υλοποιείται ως μία δομή δεδομένων του πυρήνα, η οποία κατασκευάζεται από το σύστημα όταν κάποιο πρόγραμμα φορτώνεται στη μνήμη για εκτέλεση και στην οποία συνήθως αποθηκεύονται τα εξής στοιχεία:

 Ένας ακέραιος αναγνωριστικός αριθμός
 Ο κώδικας του εκτελούμενου προγράμματος
 Τα καθολικά δεδομένα του προγράμματος, δηλαδή αυτά που είναι προσπελάσιμα από όλες τις διαδικασίες του
 Τα μη αρχικοποιημένα (δεν τους δίνεται ρητά τιμή από τον προγραμματιστή κατά τη δήλωσή τους στον κώδικα) καθολικά δεδομένα του προγράμματος, τα οποία κατά την έναρξη εκτέλεσης της διεργασίας ο πυρήνας μηδενίζει ώστε να έχουν αρχική τιμή
 Η στοίβα του εκτελούμενου προγράμματος, όπου αποθηκεύονται οι τοπικές μεταβλητές κάθε διαδικασίας και η ιεραρχία των καλούμενων υποπρογραμμάτων
 Ο σωρός, από όπου γίνονται δυναμικές (κατά τον χρόνο εκτέλεσης) δεσμεύσεις μνήμης για δομές δεδομένων του προγράμματος
 Ο πίνακας σελίδων, ο οποίος είναι μία εσωτερική δομή δεδομένων κάθε διεργασίας απαραίτητη για τη λειτουργία του μηχανισμού της εικονικής μνήμης
 Πόροι που το πρόγραμμα χρησιμοποιεί κάθε στιγμή (π.χ. ανοιχτά αρχεία)
 Η τρέχουσα τιμή του μετρητή προγράμματος, ενός ειδικού καταχωρητή του επεξεργαστή ο οποίος δηλώνει ποια είναι η επόμενη προς εκτέλεση εντολή του προγράμματος
 Τα περιεχόμενα των άλλων καταχωρητών του επεξεργαστή κατά την εκτέλεση του προγράμματος

Η διεργασία δηλαδή δεν είναι μόνον ο δυαδικός κώδικας, αλλά ένα πλήρες σύνολο πληροφοριών για το πρόγραμμα και την κατάστασή του κάθε στιγμή που αυτό εκτελείται.

Χρονοπρογραμματισμός διεργασιών 

Στα προεκτοπιστικά λειτουργικά συστήματα (preemptive) γίνεται αυτόματη εναλλαγή διεργασιών στον επεξεργαστή κάθε λίγες διακοπές του ρολογιού (στην αρχή κάθε «χρονικού κβάντου») ώστε να επιτευχθεί η ψευδοπαράλληλη εκτέλεση πολλαπλών διεργασιών. Στην πραγματικότητα οι διεργασίες εναλλάσσονται στον επεξεργαστή με εξαιρετικά μεγάλη συχνότητα (συνήθως το κβάντο διαρκεί κάποια millisecond). Η εναλλαγή αυτή ονομάζεται θεματική εναλλαγή (context switch) και, προκειμένου να είναι εφικτή, πρέπει όλες οι πληροφορίες που είναι αποθηκευμένες στην τοπική μνήμη του επεξεργαστή (στους καταχωρητές) για την εκτελούμενη διεργασία, να αποθηκευτούν σε έναν χώρο κάπου στη RAM κατά τη θεματική εναλλαγή. Έτσι, όταν έρθει ξανά η σειρά αυτής της διεργασίας να εκτελεστεί, θα μπορούν να φορτωθούν πάλι πίσω στους καταχωρητές και η εκτέλεση να συνεχίσει από εκεί που σταμάτησε. Τα δύο τελευταία στοιχεία της προηγούμενης λίστας παρέχουν αυτόν ακριβώς το χώρο. Το ποια διεργασία θα εκτελείται κάθε στιγμή καθορίζεται από έναν μηχανισμό του λειτουργικού συστήματος η συμπεριφορά του οποίου συνήθως δεν μπορεί να προβλεφθεί ή να τροποποιηθεί από τον χρήστη, τον χρονοπρογραμματιστή.

Καθώς μία διεργασία εκτελείται, αλλάζει κατάσταση (state). Η κατάσταση ορίζεται εν μέρει από την τρέχουσα δραστηριότητά της και από τον χρονοπρογραμματιστή. Κάθε διεργασία μπορεί να βρίσκεται σε μία από τις ακόλουθες καταστάσεις:
 Νέα (New): Η διεργασία δημιουργείται.
 Εκτελούμενη (Running): Εκτελούνται εντολές.
 Εν αναμονή (Waiting): Η διεργασία αναμένει να συμβεί κάποιο γενονός (όπως η λήψη ενός σήματος).
 Έτοιμη (Ready): Η διεργασία περιμένει να ανατεθεί σε έναν επεξεργαστή.
 Τερματισμένη (Terminated): Η εκτέλεση της διεργασίας έχει ολοκληρωθεί.

Διαδιεργασιακή επικοινωνία 

Στα μοντέρνα λειτουργικά συστήματα, χάρη στον μηχανισμό της εικονικής μνήμης, κάθε διεργασία έχει τον δικό της ιδιωτικό χώρο εικονικών διευθύνσεων στον οποίον έχει πρόσβαση μόνο αυτή και ο πυρήνας. Προκειμένου να υπάρχει μία στοιχειώδης προστασία μνήμης μεταξύ διαφορετικών διεργασιών, καμία διεργασία δεν έχει δικαίωμα ανάγνωσης ή εγγραφής στον χώρο διευθύνσεων των υπολοίπων. Αν λοιπόν χρειάζεται δύο διαφορετικές διεργασίες να επικοινωνήσουν μεταξύ τους ή να ανταλλάξουν δεδομένα, αυτό μπορεί να γίνει μόνο μέσω του συστήματος αρχείων (π.χ. μία διεργασία να γράψει ένα αρχείο και μία άλλη να το διαβάσει) ή μέσω μίας μεθόδου διαδιεργασιακής επικοινωνίας (InterProcess Communication, IPC). Πρόκειται για έναν τρόπο ανταλλαγής δεδομένων ή συγχρονισμού διεργασιών μέσω δομών δεδομένων του πυρήνα.

Σε πολλές περιπτώσεις ένα εκτελούμενο πρόγραμμα (η μητρική ή γονική διεργασία) δημιουργεί δευτερεύουσες (θυγατρικές) διεργασίες ώστε να εκμεταλλευτεί πιθανά οφέλη από τον ταυτοχρονισμό. Με αυτόν τον τρόπο, σε ένα παράλληλο σύστημα οι υπολογισμοί που απαιτούνται από μία εφαρμογή μπορούν να κατανεμηθούν σε πολλαπλούς επεξεργαστές με τον καθένα να εκτελεί διαφορετική διεργασία, ενώ σε ένα σειριακό σύστημα αν μία διεργασία ανασταλεί (π.χ. σε μία κλήση συστήματος) καθώς περιμένει την απελευθέρωση ενός πόρου (π.χ. πρόσβαση στον σκληρό δίσκο) ή μία είσοδο από τον χρήστη (π.χ. από το πληκτρολόγιο), κάποια άλλη διεργασία μπορεί να συνεχίσει τους υπολογισμούς. Είναι φανερό επομένως ότι η διαδιεργασιακή επικοινωνία δεν είναι απαραίτητη μόνο για την ανταλλαγή δεδομένων μεταξύ ανεξάρτητων διεργασιών, αλλά και για τον συντονισμό στενά συνεργαζόμενων διεργασιών οι οποίες εκτελούνται παράλληλα ή ψευδοπαράλληλα. Ωστόσο η κατασκευή μίας διεργασίας κοστίζει πολύ σε χρόνο και χώρο μνήμης, αφού πρέπει να δεσμευτεί πολύς χώρος και να τροποποιηθούν πολλές εσωτερικές δομές δεδομένων του πυρήνα. Το πρόβλημα αυτό μπορεί να επιλυθεί με χρήση νημάτων.

Νήματα και ίνες 

Σε κάθε διεργασία ο μετρητής προγράμματος και η στοίβα εκτέλεσης ορίζουν ένα νήμα εκτέλεσης, το οποίο μπορεί να γίνει αντιληπτό ως μία σειριακή αλληλουχία εντολών. Όταν η διεργασία ενεργοποιείται από τον χρονοπρογραμματιστή, ουσιαστικώς αυτό που εκτελείται είναι ένα νήμα το οποίο περιέχει όλο τον κώδικα της διεργασίας. Ωστόσο σε πολλά συστήματα είναι εφικτή η ρητή κατασκευή επιπλέον νημάτων στο εσωτερικό μίας διεργασίας, τα οποία χαρακτηρίζονται από τον δικό τους μετρητή προγράμματος και τη δική τους στοίβα αλλά, κατά τ' άλλα, μοιράζονται όλους τους άλλους πόρους της διεργασίας (κώδικας, καθολικά δεδομένα, σωρός, πίνακας σελίδων, πίνακας ανοιχτών αρχείων). Το καθένα από αυτά τα νήματα μπορεί να εκτελεί διαφορετική διαδικασία και χρονοπρογραμματίζεται από τον πυρήνα ανεξάρτητα από τα άλλα (ο τελευταίος δηλαδή χρονοπρογραμματίζει κάθε νήμα ξεχωριστά και όχι τη διεργασία ως σύνολο). Με αυτόν τον τρόπο τα νήματα της διεργασίας μπορούν να εκτελεστούν ταυτόχρονα, είτε παράλληλα (σε σύστημα με πολλαπλούς επεξεργαστές) είτε ψευδοπαράλληλα. Φυσικά, όπως και με τη δημιουργία πολλαπλών διεργασιών, στη δεύτερη περίπτωση δεν υπάρχει αύξηση των υπολογιστικών επιδόσεων, αφού το πρόγραμμα συνεχίζει ουσιαστικώς να εκτελείται σειριακά στον μοναδικό επεξεργαστή, αλλά τα διαφορετικά νήματα μπορούν να αναλάβουν διαφορετικές εργασίες (π.χ. το ένα να εκτελεί υπολογισμούς όσο το άλλο περιμένει είσοδο από τον χρήστη).

Σε κάθε περίπτωση η κατασκευή ενός νήματος στο εσωτερικό κάποιας διεργασίας είναι πολύ πιο «ελαφριά» (από άποψη υπολογιστικής επιβάρυνσης) διαδικασία σε σχέση με τη δημιουργία μίας νέας διεργασίας, αφού χρειάζεται να δεσμευτεί πολύ λιγότερος χώρος στη μνήμη και να γίνουν πολύ λιγότερες τροποποιήσεις σε δομές δεδομένων εκ μέρους του ΛΣ. Γι' αυτό και τα νήματα καλούνται «ελαφρές διεργασίες», ενώ ακόμα πιο ελαφριές οντότητες εκτέλεσης εντολών είναι τα νήματα επιπέδου χρήστη, τα οποία δεν είναι αντιληπτά και δεν υφίστανται διαχείριση από τον πυρήνα αλλά από μία βιβλιοθήκη χρόνου εκτέλεσης η οποία συνδέεται με το εκτελούμενο πρόγραμμα κατά τη μεταγλώττισή του και εκτελείται (για λόγους μείωσης της χρονικής επιβάρυνσης) αποκλειστικά στον χώρο του χρήστη, και οι ίνες, οντότητες του πυρήνα οι οποίες δεν υφίστανται (δαπανηρό από άποψη πόρων) προεκτοπιστικό χρονοπρογραμματισμό· η κάθε ίνα απελευθερώνει ρητά τον επεξεργαστή σε κατάλληλα σημεία του κώδικά της ώστε αυτός να εκτελέσει κάποια ίνα. Σε αντίθεση με τα νήματα επιπέδου χρήστη οι ίνες απαιτούν να τις υποστηρίζει το λειτουργικό σύστημα παρέχοντας κατάλληλες κλήσεις συστήματος για τη διαχείρισή τους.

Χειρισμός διεργασιών

Unix 
Στα συστήματα UNIX μία διεργασία μπορεί να δημιουργήσει μία θυγατρική της μέσω της κλήσης συστήματος fork(), η οποία κατασκευάζει ένα πλήρες αντίγραφο της καλούσας διεργασίας (συμπεριλαμβανομένου του κώδικα, των τρεχόντων δεδομένων της στη μνήμη και των πινάκων ανοιχτών αρχείων)· στη συνέχεια ο πυρήνας χρονοπρογραμματίζει τις δύο διεργασίες ξεχωριστά, αυτές ανταγωνίζονται μεταξύ τους και με όλες τις άλλες διεργασίες για την πρόσβαση στους πόρους του συστήματος και η εκτέλεσή τους συνεχίζει από την εντολή που ακολουθεί τη fork(). Ο κώδικάς τους μπορεί να διαφοροποιηθεί ρητά από τον προγραμματιστή μέσω κατάλληλου ελέγχου της τιμής επιστροφής της fork() αμέσως μετά την κλήση της: στη θυγατρική διεργασία η τιμή αυτή είναι 0 ενώ στη μητρική ισούται με το ακέραιο αναγνωριστικό της νέας διεργασίας, το οποίο της αποδίδεται αυτομάτως από τον πυρήνα. Αυτό το αναγνωριστικό η μητρική έχει την επιλογή να το αποθηκεύσει σε κάποια μεταβλητή, ώστε αργότερα να μπορεί να επικοινωνήσει ή να συγχρονιστεί με την μόλις κατασκευασθείσα θυγατρική της (π.χ. ώστε να είναι ικανή, μέσω της κλήσης συστήματος waitpid(pid, status), να ανασταλλεί αναμένοντας τον τερματισμό της διεργασίας με αναγνωριστικό pid, αν απαιτεί κάτι τέτοιο η λογική του προγράμματος).

Μία διεργασία μπορεί ακόμα να καλέσει την wait(status) σε κάποιο σημείο του κώδικά της, ώστε να ανασταλεί αναμένοντας τον τερματισμό οποιασδήποτε από τις θυγατρικές της διεργασίες (ξανά, αν απαιτεί έναν τέτοιο συγχρονισμό η λογική του προγράμματος)· η waitpid(pid) με όρισμα -1 αντί για το αναγνωριστικό κάποιας συγκεκριμένης διεργασίας λειτουργεί όπως η wait(). Κάθε θυγατρική διεργασία με τον τερματισμό της επιστρέφει μία ακέραια τιμή στον πυρήνα (π.χ. για έλεγχο σφαλμάτων) που παραμένει στη μνήμη μέχρι να διαβαστεί από τη μητρική μέσω της wait() ή της waitpid(), οι οποίες μετά το πέρας της αναστολής της καλούσας διεργασίας (που ίσως και να μην ανασταλεί, σε περίπτωση που η/οι θυγατρική/θυγατρικές έχει/έχουν ήδη τερματίσει πριν από την κλήση των waitpid()/wait()) αποθηκεύουν την εν λόγω τιμή στο όρισμά τους status. Οι διεργασίες των οποίων οι μητρικές τερματίστηκαν χωρίς να καλέσουν τη wait() ή την waitpid() ονομάζονται ζόμπι, καθώς συνεχίζουν να δεσμεύουν κάποιο χώρο στη μνήμη ακόμα και αν τερματίσουν την εκτέλεσή τους· το σύστημα ελέγχει περιοδικά για την ύπαρξη ζόμπι και απελευθερώνει τη μνήμη που δεσμεύουν. Η μητρική διεργασία έχει κι αυτή μία τιμή επιστροφής κατά τον τερματισμό της, αφού δεν αποτελεί παρά θυγατρική διεργασία του κελύφους διασύνδεσης με τον χρήστη.

Αντί ο προγραμματιστής να διαφοροποιήσει ρητά τον κώδικά του ανάλογα με την τιμή επιστροφής της fork(), μπορεί μέσω της κλήσης συστήματος execv(file) (ή διάφορες παραλλαγές της από σύστημα σε σύστημα, όπως οι execvp(), execl() κλπ) να αντικαταστήσει τον κώδικα και τα δεδομένα της θυγατρικής διεργασίας με αυτά που περιέχονται στο εκτελέσιμο αρχείο file (είναι βέβαια ξανά αναγκαίος ο έλεγχος για την τιμή επιστροφής της fork(), αφού η execv() πρέπει να αποτελεί την πρώτη εντολή που θα εκτελέσει η θυγατρική αλλά δεν πρέπει να εκτελεστεί από τη μητρική, αφού τότε θα αντικαταστήσει και τις δικές της εντολές και δεδομένα). Η εκτέλεση μίας διεργασίας μετά από μία κλήση execv(file) συνεχίζει από το σημείο εισόδου του περιεχόμενου στο εκτελέσιμο file κώδικα, το οποίο συνήθως είναι μία κεντρική διαδικασία όπου ο προγραμματιστής έχει οργανώσει σε υψηλό επίπεδο τη λειτουργικότητα και τη λογική του προγράμματος (π.χ. στη γλώσσα προγραμματισμού C τον ρόλο αυτό επιτελεί η συνάρτηση main())· οι άλλες διαδικασίες καλούνται ως υποπρογράμματα μέσα από την κεντρική.

Μία διεργασία μπορεί είτε να τερματιστεί αυτομάτως όταν η εκτέλεσή της φτάσει σε ένα σημείο εξόδου του κώδικά της (π.χ. αμέσως μετά το πέρας της τελευταίας εντολής που περιέχεται στη συνάρτηση main()), είτε να τερματιστεί με ρητή χρήση από τον προγραμματιστή της κλήσης συστήματος exit(returnvalue), η οποία γράφει τις όποιες εκκρεμείς προσωρινές ενδιάμεσες μνήμες Εισόδου / Εξόδου, απελευθερώνει ανοιχτούς πόρους (π.χ. αρχεία τα οποία έχει ανοίξει νωρίτερα η διεργασία), καλεί όποιους χειριστές εξόδου (διαδικασίες οι οποίες εκτελούνται αυτομάτως κατά τον τερματισμό του προγράμματος) έχει πιθανώς δηλώσει ο προγραμματιστής με τη συνάρτηση atexit() (παρεχόμενη από την πρότυπη βιβλιοθήκη της C) και, τελικώς, ενημερώνει κατάλληλα τις δομές δεδομένων του πυρήνα και τον χρονοπρογραμματιστή, αποθηκεύοντας παράλληλα την τιμή returnvalue (κωδικός εξόδου) ως τιμή επιστροφής της διεργασίας η οποία μόλις τερμάτισε. Με αυτόν τον τρόπο η μητρική διεργασία μπορεί να ειδοποιηθεί για τον τρόπο τερματισμού της θυγατρικής της ελέγχοντας τον κωδικό εξόδου· η πρότυπη βιβλιοθήκη της C παρέχει ένα σύνολο μακροεντολών με τις οποίες γίνεται αυτοματοποιημένος έλεγχος για τον τρόπο τερματισμού (π.χ. η μακροεντολή WIFEXITED(status) λαμβάνει θετική τιμή αν η διεργασία η οποία επέστρεψε τον κωδικό εξόδου status τερμάτισε φυσιολογικά).

Τα περισσότερα λειτουργικά συστήματα ελαφρύνουν τη χρονική επιβάρυνση που συνεπάγετα μία κλήση fork() αξιοποιώντας ποικίλες βελτιστοποιήσεις: π.χ. το τμήμα κώδικα δεν αντιγράφεται αφού ο κώδικας ενός προγράμματος στη μνήμη συνήθως είναι μη τροποποιήσιμος (μόνο για ανάγνωση), επομένως η νέα διεργασία που κατασκευάζεται μπορεί απλώς να απεικονίζει στον ιδιωτικό εικονικό της χώρο διευθύνσεων την περιοχή φυσικής μνήμης όπου είναι αποθηκευμένο το τμήμα κώδικα της γονικής της διεργασίας. Τα άλλα δεδομένα (π.χ. στοίβα, σωρός κλπ) μπορούν επίσης να μην αντιγράφονται αμέσως αλλά μόνο όταν, αργότερα, η μία από τις δύο διεργασίες τροποποιήσει το δικό της αντίγραφο κατά την εκτέλεσή της.

Ακολουθεί ένα παράδειγμα κώδικα χειρισμού διεργασιών στο Unix, σε γλώσσα προγραμματισμού C:
#include <stdio.h>   /* printf, stderr, fprintf */
#include <unistd.h>  /* _exit, fork */
#include <stdlib.h>  /* exit */
#include <errno.h>   /* errno */

int main(void)
{
   pid_t  pid;

   pid = fork();
   if (pid == 0)
   {
      /* Θυγατρική διεργασία:
       * Όταν η fork() επιστρέφει 0, τότε ο εκτελούμενος κώδικας είναι της θυγατρικής διεργασίας
       *
       * Μέτρηση ως το δέκα, ανά δευτερόλεπτο
       */
      int j;
      for (j = 0; j < 10; j++)
      {
         printf("child: %d\n", j);
         sleep(1);
      }
      _exit(0);  /* Δεν χρησιμοποιείται η exit() αλλά η _exit(0) η οποία δεν καλεί χειριστές εξόδου */
   }
   else if (pid > 0)
   { 
      /* Μητρική διεργασία:
       * Όταν η fork() επιστρέφει θετικό ακέραιο, τότε ο εκτελούμενος κώδικας είναι της μητρικής διεργασίας και ο ακέραιος το αναγνωριστικό της μόλις κατασκευασθείσας διεργασίας
       */
      int i;
      for (i = 0; i < 10; i++)
      {
         printf("parent: %d\n", i);
         sleep(1);
      }
      exit(0);
   }
   else
   {   
      /* Σφάλμα
       * Όταν μία κλήση συστήματος επιστρέφει αρνητικό αριθμό, κάποιο σφάλμα έχει συμβεί
       * (π.χ. το σύστημα έχει φτάσει στο επιτρεπτό πλήθος διεργασιών).
       */
      fprintf(stderr, "can't fork, error %d\n", errno);
      exit(1);
   }
}

Ακολουθεί ένα παράδειγμα χρήσης της execvp σε C:
int main(int argc, char *argv[])
{
   int i;
   printf(“exec %s via fork”,argv[1]);
   printf(“with parameters:\n”);
   for (i=1; i<argc; i++)
   {
      printf(“argv[%d]=%s\n”,i-1,argv[i]);
   }
   if (!fork())
   {
      execvp(argv[1],&argv[1]);
      /* Ο κώδικας της εκτελούμενης (θυγατρικής διεργασίας) μόλις 
       * αντικαταστάθηκε από το εκτελέσιμο αρχείο που μεταβιβάστηκε στη μητρική
       * διεργασία ως όρισμα γραμμής εντολών, επομένως οι επόμενες εντολές δεν 
       * μπορούν να εκτελεστούν ποτέ εκτός και αν έχει αποτύχει η κλήση της 
       * execv */
      perror(“execvp”);
      return;
   }
}

Πηγές 
 Αρχιτεκτονική Υπολογιστών: Μια Δομημένη Προσέγγιση, Tanenbaum Andrew S., Εκδ. Κλειδάριθμος
 Σύγχρονα Λειτουργικά Συστήματα, Tanenbaum Andrew S., Εκδ. Κλειδάριθμος

Τεχνολογία λειτουργικών συστημάτων