İşletim Sistemleri · Ders Notu

Bölüm 3: Süreçler

Bir bilgisayarda aynı anda bir tarayıcı, bir müzik çalar, bir derleyici ve onlarca arka plan hizmeti çalışır. İşletim sistemi bunların her birini ayrı bir süreç olarak görür. Her sürecin kendi belleği, kendi yazmaç değerleri ve kendi açık dosyaları vardır. Süreç, işletim sisteminin iş bölümünün temel birimidir: işlemci süreçlere dağıtılır, bellek süreçlere ayrılır, dosyalar süreçler adına açılır. Not, sürecin ne olduğu ve bellekte nasıl yerleştiği sorusuyla başlar. Ardından UNIX'in fork, exec, wait ve exit çağrılarıyla süreçlerin nasıl doğup öldüğünü kod izlemeleriyle anlatır. Süreç durumları, süreç kontrol bloğu, kuyruklar ve bağlam değiştirme, çekirdeğin süreçleri nasıl izlediğini gösterir. Son kesimler süreçlerin birbiriyle nasıl haberleştiğini ve mobil sistemlerin arka plandaki süreçleri neden sınırladığını ele alır.

Öğrenme çıktıları

Süreç modeli
Program ile süreç arasındaki farkı ve bir sürecin bellek yerleşimini açıklamak; bir C programındaki her değişkenin hangi bölgede durduğunu söylemek.
Yaşam döngüsü
Süreç durumlarını ve aralarındaki geçişleri tanımlamak; süreç kontrol bloğunun (PCB) içeriğini ve görevini açıklamak.
UNIX API
fork(), exec(), wait(), exit() ve pipe() kullanan küçük programları satır satır izlemek; çıktıyı ve oluşan süreç sayısını bulmak.
Çizelgeleme altyapısı
Hazır ve bekleme kuyruklarını, çoklu programlamanın CPU kullanımına etkisini ve bağlam değiştirmenin maliyetini açıklamak ve hesaplamak.
IPC
Paylaşımlı bellek, ileti geçirme, boru ve soketleri hız, eşgüdüm ve kullanım alanı bakımından karşılaştırmak.
Mobil çoklu görev
iOS ve Android'in arka plandaki süreçleri neden ve nasıl sınırladığını açıklamak.

3.1Süreç kavramı

İşletim sistemi, donanımı doğrudan kullanmanın güçlüğünü birkaç soyutlamanın arkasına saklar. Dosya, diskteki blokların soyutlamasıdır; adres alanı, fiziksel belleğin soyutlamasıdır. Süreç ise işlemcinin soyutlamasıdır. Tek bir işlemci bile, süreç kavramı sayesinde, sanki her programa ayrı bir işlemci verilmiş gibi kullanılabilir. Bu kesim sürecin tanımını, bellekteki görünümünü ve aynı anda pek çok sürecin nasıl ilerlediğini anlatır.

3.1.1Program ve süreç

Diskte duran a.out ya da vim dosyası bir programdır. Program, makine komutlarından ve başlangıç verilerinden oluşan bir çalıştırılabilir dosyadır (executable file). Kendi başına hiçbir şey yapmaz; disk üzerinde bekler. Bu yüzden program pasif bir varlıktır.

Program çalıştırıldığında işletim sistemi onun için bellek ayırır, komutlarını belleğe yükler ve işlemciye “buradan başla” der. O andan sonra ortada yürümekte olan bir iş vardır. Bu işin bir program sayacı (sıradaki komutun adresi), yazmaç değerleri, bir yığını, açık dosyaları ve bir kimlik numarası olur. Bu canlı varlığa süreç denir.

Tanım 3.1

Süreç (process), yürütülmekte olan bir program örneğidir. Bir sürecin üç bileşeni vardır: programın kodunu ve verisini taşıyan adres alanı, işlemcinin o anki durumu (program sayacı ve yazmaçlar) ve kaynaklar. Kaynaklar, işletim sisteminin sürece ayırdığı açık dosyalar, bellek bölgeleri ve kimlik bilgileridir. Her süreç sistem içinde tekil bir süreç kimliği (process identifier, PID) taşır.

Program ile süreç arasındaki ilişki, yemek tarifi ile mutfakta pişen yemek arasındaki ilişkiye benzer. Tarif kâğıt üzerinde durur ve değişmez. Pişirme ise bir süreçtir: hangi adımda olunduğu, tencerede ne olduğu, ocağın açık olup olmadığı her an değişir. Aynı tariften iki ayrı mutfakta aynı anda iki yemek pişebilir; ikisi birbirini etkilemez.

Aynı durum programlarda da geçerlidir. İki terminal penceresinde vim açıldığında diskteki tek bir dosyadan iki süreç oluşur. Her birinin ayrı adres alanı, ayrı açık dosyaları ve ayrı imleç konumu vardır. Birinde yazılan metin ötekinde görünmez. Programın salt okunur kodu ise iki süreç arasında paylaşılabilir; aynı makine komutlarının bellekte iki kopyasını tutmak gereksizdir. Şekil 3.1 bu durumu gösterir.

a.outprogram (diskte)pasif · dosyaçalıştırçalıştırsüreç · PID 4021metinveriöbekyığınaktif · bellekte · kendi PCB’sisüreç · PID 4022metinveriöbekyığınaktif · bellekte · kendi PCB’siaynı programdaniki ayrı süreç:ayrı adres alanı,ayrı durum ve kaynaklar
Şekil 3.1: Diskteki tek bir a.out programı iki kez çalıştırılınca iki ayrı süreç oluşur (PID 4021 ve 4022). Her sürecin kendi metin, veri, öbek ve yığın bölgeleri ve kendi PCB'si vardır.

Bir sürecin içinde komutlar tek bir sıra hâlinde de yürüyebilir, birden çok sıra hâlinde de. Tek iş parçacıklı (thread) bir süreçte tek bir program sayacı vardır ve komutlar birbiri ardına yürütülür. Çok iş parçacıklı bir süreçte birden çok program sayacı bulunur. Bu iş parçacıkları aynı adres alanını paylaşır ve çok çekirdekli bir makinede gerçekten aynı anda ilerleyebilir. Bu notta aksi söylenmedikçe süreçler tek iş parçacıklıdır.

Sık yapılan hata

“Program” ile “süreç” sözcüklerini aynı anlamda kullanmak. Program bir dosyadır ve tek başına kaynak tüketmez. Süreç ise bellek, işlemci zamanı ve açık dosyalar tüketir. Bir programın hiç çalışmayan, bir kez çalışan ya da aynı anda yüz kez çalışan hâlleri olabilir; her çalışma ayrı bir süreçtir.

3.1.2Bellekte süreç yerleşimi

Her süreç, belleği kendisine ait sanki tek parça bir dizi gibi görür. Bu dizinin adreslerine adres alanı (address space) denir. 0'dan başlayıp en yüksek adrese kadar uzanan bu alan birkaç bölgeye ayrılır. Her bölgenin ayrı bir görevi ve ayrı erişim hakları vardır. Şekil 3.2, alttan üste doğru bu bölgeleri gösterir.

çekirdek alanıher sürece eşlenir; kullanıcı modunda erişilemezyığın (stack)yerel değişkenler, parametreler, dönüş adresleriboş alanöbek (heap)malloc() / new ile çalışma anında ayrılan bellekBSSilk değer verilmemiş global / static (sıfırlanır)veri (data)ilk değer verilmiş global / static değişkenlermetin (text)programın makine kodu (salt okunur, paylaşılabilir)büyürbüyüryüksek adresadres 0’a yakın
Şekil 3.2: Bir sürecin bellek yerleşimi. Düşük adreslerde metin, veri ve BSS bölgeleri sabit boyutludur. Öbek yukarı, yığın aşağı doğru büyür; aradaki boş alan ikisinin de büyümesine yer bırakır. En üstteki çekirdek alanı her sürece eşlenir ama kullanıcı modunda erişilemez.

Bölgeleri en düşük adresten başlayarak tek tek ele alalım:

  • Metin (text) bölgesi programın derlenmiş makine kodunu taşır. Salt okunurdur: süreç kendi kodunu yanlışlıkla değiştiremez. Aynı programdan oluşan süreçler bu bölgeyi paylaşabilir.
  • Veri (data) bölgesi ilk değeri verilmiş global ve static değişkenleri tutar. Bu değerler çalıştırılabilir dosyanın içinde saklanır ve yükleme sırasında belleğe kopyalanır.
  • BSS bölgesi ilk değeri verilmemiş global ve static değişkenleri tutar. Adı eski bir assembly yönergesinden (block started by symbol) gelir. Bu değişkenlerin hepsi sıfırla başlar. Dosyada yalnız bölgenin boyutu yazılır; yükleyici o kadar sıfırlı bellek ayırır. Böylece 4 MB'lık sıfır dolu bir dizi çalıştırılabilir dosyayı 4 MB büyütmez.
  • Öbek (heap) çalışma sırasında malloc() (C++'ta ve Java'da new) ile istenen belleği karşılar. Program bellek istedikçe öbek yukarı doğru büyür; free() ile bırakılan alan yeniden kullanılır.
  • Yığın (stack) fonksiyon çağrılarını izler. Her çağrıda yığına bir çerçeve eklenir: parametreler, yerel değişkenler ve dönüş adresi bu çerçevede durur. Fonksiyon dönünce çerçeve kaldırılır. Yığın yüksek adreslerden aşağı doğru büyür.
  • Çekirdek alanı adres alanının en üst kısmıdır. Çekirdeğin kodu ve verisi buraya eşlenir; böylece bir sistem çağrısı sırasında adres alanını değiştirmeye gerek kalmaz. Kullanıcı modunda çalışan kod bu bölgeye dokunamaz; dokunmaya çalışırsa donanım bir hata üretir.

Öbek ile yığının karşılıklı büyümesi bilinçli bir tasarımdır. İki bölgenin ne kadar büyüyeceği önceden bilinmez: özyinelemeli bir program çok yığın, büyük veri işleyen bir program çok öbek kullanır. Aralarındaki boş alanı ortak kullanmaları, belleğin ikisi arasında esnekçe paylaşılmasını sağlar.

Program sayacı bu resimde yer almaz, çünkü bellekte değil işlemcinin içinde bir yazmaçtır. Program sayacı metin bölgesindeki bir adresi gösterir: sürecin “şu anki konumu” odur. Yığının tepesini gösteren yığın işaretçisi de bir yazmaçtır. Süreç işlemciden ayrıldığında bu yazmaçların değeri kaybolmasın diye çekirdek onları bir yere kaydeder. Bu kaydın yeri 3.4.4'te ele alınan süreç kontrol bloğudur.

Gerçek sistemlerde bölgelerin adresleri her çalıştırmada aynı değildir. Adres alanı düzeninin rastgeleleştirilmesi (address space layout randomization, ASLR) yığının, öbeğin ve paylaşılan kütüphanelerin başlangıç adreslerini her çalıştırmada rastgele kaydırır. Saldırgan, bellekte neyin nerede olduğunu kestiremediği için pek çok saldırı zorlaşır. Bölgelerin sırası ve büyüme yönleri ise değişmez.

3.1.3Değişkenler nereye yerleşir?

Bölgeleri somutlaştırmanın en iyi yolu küçük bir C programındaki her adın nereye düştüğünü bulmaktır. Aşağıdaki program sunumdakiyle aynıdır:

#include <stdlib.h>
int sayac = 5;    /* data */
int tablo[1000];  /* bss */

int main(int argc, char *argv[]) {
    int yerel = 3;
    static int adet;
    int *p = malloc(4 * sizeof *p);
    p[0] = yerel + sayac;
    free(p);
    return 0;
}

Programdaki her öğenin yerini sırayla bulalım. Şekil 3.3 beş durumu yan yana gösterir.

Örnek 3.1
  1. Metin. main fonksiyonunun derlenmiş komutları metin bölgesindedir. malloc ve free'nin kodu da C kütüphanesinin metin bölgesinde durur. Bu bölge salt okunurdur ve aynı programın süreçleri arasında paylaşılabilir.
  2. Veri. int sayac = 5; global ve ilk değerlidir. 5 değeri çalıştırılabilir dosyada saklanır; yükleme sırasında veri bölgesine kopyalanır.
  3. BSS. int tablo[1000]; global ama ilk değersizdir; 4000 bayt sıfırla başlar ve dosyada yer kaplamaz. main içindeki static int adet; de BSS'dedir. Fonksiyonun içinde tanımlanmış olması onu yığına koymaz; static sözcüğü değişkenin bütün program boyunca yaşamasını sağlar.
  4. Yığın. main çağrıldığında yığında bir çerçeve açılır. argc, argv, yerel yerel = 3, işaretçi p'nin kendisi ve dönüş adresi bu çerçevededir. main dönünce çerçeve ortadan kalkar.
  5. Öbek. malloc(4 * sizeof *p) öbekten 4 · 4 = 16 bayt ayırır (int 4 bayt varsayımıyla). p yığındadır ama gösterdiği 16 bayt öbektedir. p[0] = yerel + sayac satırı öbekteki ilk hücreye 3 + 5 = 8 yazar. free(p) bu alanı geri verir; p değişkeni ise yığında kalmaya devam eder.
çekirdek alanıyığın (stack)↓ boş ↑öbek (heap)BSSveri (data)metin (text)sayac = 5 çekirdek alanıyığın (stack)↓ boş ↑öbek (heap)BSSveri (data)metin (text)tablo[1000]adet (static)
çekirdek alanıyığın (stack)↓ boş ↑öbek (heap)BSSveri (data)metin (text)argc, argvyerel = 3p (işaretçinin kendisi)dönüş adresi çekirdek alanıyığın (stack)↓ boş ↑öbek (heap)BSSveri (data)metin (text)*p → 16 bayt(malloc ile)
Şekil 3.3: Örnek programdaki adların yerleri. (a) Veri: sayac = 5. (b) BSS: tablo[1000] ve static adet. (c) Yığın: main'in çerçevesi; argc, argv, yerel, p ve dönüş adresi. (d) Öbek: malloc ile ayrılan 16 bayt.

Linux'ta bu yerleşim komut satırından da görülebilir. size a.out komutu metin, veri ve BSS bölgelerinin boyutunu yazar. Çalışan bir sürecin bütün bölgeleri ise cat /proc/PID/maps ile listelenir. Bu listede [heap] ve [stack] adlı satırlar ile programın ve kütüphanelerin eşlendiği aralıklar görünür.

Sık yapılan hata

“İşaretçi öbekte durur” demek. int *p = malloc(16); satırında iki ayrı bellek parçası vardır. p değişkeni yerel olduğu için yığındadır ve 8 baytlık bir adres taşır. Bu adresin gösterdiği 16 bayt öbektedir. Fonksiyon dönünce p kaybolur ama free çağrılmadıysa öbekteki alan ayrılmış kalır. Bellek sızıntısı tam olarak budur.

3.1.4Sözde paralellik ve çoklu programlama

Tek çekirdekli bir işlemci herhangi bir anda yalnız bir komut dizisini yürütebilir. Buna karşın kullanıcı aynı anda müzik dinleyip belge yazabilir. Bu, işlemcinin süreçler arasında çok hızlı geçiş yapmasıyla mümkün olur. Her süreç birkaç milisaniye çalışır, sonra işlemci bir sonrakine geçer. Saniyede yüzlerce geçiş olduğu için kullanıcı bütün süreçlerin aynı anda çalıştığını sanır. Bu yanılsamaya sözde paralellik (pseudoparallelism) denir.

Tanım 3.2

Çoklu programlama (multiprogramming), bellekte aynı anda birden çok süreç tutup işlemciyi aralarında paylaştırmaktır. Amaç, bir süreç beklerken (örneğin diskten veri gelmesini beklerken) işlemciyi boş bırakmamak ve başka bir süreçle meşgul etmektir. İşlemciyi hangi sürecin alacağına çizelgeleyici (scheduler) karar verir.

Şekil 3.4 bu durumu üç açıdan gösterir. (a) bölümünde fiziksel gerçek vardır: tek bir program sayacı A, B, C ve D süreçleri arasında sırayla dolaşır. (b) bölümü kavramsal modeldir: her süreç kendi mantıksal program sayacı olan bağımsız bir iş gibi düşünülür. Süreç işlemciden ayrılırken fiziksel program sayacının değeri o sürecin mantıksal sayacına kaydedilir. (c) bölümü zaman eksenidir: uzun bir aralığa bakıldığında dört süreç de ilerlemiştir, ama herhangi bir anda yalnız biri çalışır.

(a) tek program sayacıABCDsüreçdeğişimi(b) dört bağımsız süreçABCDher birinin mantıksal PC’si var(c) bir anda yalnız biri çalışırzamanDCBA
Şekil 3.4: Çoklu programlama. (a) Tek program sayacı dört süreç arasında geçiş yapar. (b) Kavramsal olarak dört bağımsız süreç vardır; her birinin mantıksal program sayacı bulunur. (c) Zaman ekseninde her süreç ilerler, ama bir anda yalnız biri çalışır.

Geçişlerin sıklığını belirleyen büyüklüğe zaman dilimi (time slice, quantum) denir. Tipik değerler birkaç milisaniyedir. Güncel Linux çekirdeklerinin çizelgeleyicisi EEVDF'de (Earliest Eligible Virtual Deadline First) taban dilim yaklaşık 3 ms'dir. Eski CFS (Completely Fair Scheduler) çizelgeleyicisinde ise hazır süreçlerin hepsinin bir kez çalışması için hedeflenen süre, çekirdek sayısına göre 6–24 ms arasındaydı.

Sözde paralellik kullanıcı için rahattır ama programcı için bir uyarı taşır: bir sürecin ne zaman kesileceği önceden bilinemez. Kesilme, komutların ortasında, iki satır arasında, hatta bir değişkenin okunmasıyla yazılması arasında olabilir. Bir sürecin hızı da sabit değildir; aynı döngü bir çalıştırmada 2 ms, ötekinde 20 ms sürebilir. Bu yüzden doğru çalışan bir program, zamanlamayla ilgili hiçbir varsayım yapmaz. “Çocuk süreç bu sırada çoktan bitmiştir” ya da “bu iki satır kesintisiz çalışır” gibi düşünceler, ancak açık bir eşgüdüm mekanizmasıyla güvence altına alınabilir.

3.1.5Eş zamanlılık ve paralellik

Gündelik dilde aynı anlamda kullanılan iki sözcük, işletim sistemlerinde farklı şeyleri anlatır.

Tanım 3.3

Eş zamanlılık (concurrency), birden çok işin aynı zaman aralığında ilerlemesidir. İşler tek bir çekirdekte iç içe geçerek (interleaving) yürüyebilir; herhangi bir anda yalnız biri çalışıyor olabilir. Paralellik (parallelism) ise birden çok işin aynı anda, fiziksel olarak birlikte yürütülmesidir ve birden çok çekirdek ya da işlemci gerektirir.

Şekil 3.5 farkı açıkça gösterir. Üst satırda tek çekirdek A, B ve C işlerini sırayla parça parça çalıştırır: üç iş de aynı aralıkta ilerler, ama hiçbir an ikisi birlikte çalışmaz. Alt kısımda iki çekirdek vardır; A ile C aynı anda yürür. Paralel bir sistem her zaman eş zamanlıdır, ama eş zamanlı bir sistem paralel olmak zorunda değildir.

Eş zamanlılık(1 çekirdek)ABCABCABParalellik(2 çekirdek)AAABBBAACCCCAACCçekirdek 0çekirdek 1zaman
Şekil 3.5: Tek çekirdekte eş zamanlılık ve iki çekirdekte paralellik. Üstte işler iç içe geçer; altta iki çekirdek aynı anda farklı işleri yürütür.

Çok çekirdekli işlemciler artık her yerdedir; 2026 itibarıyla telefonlarda bile 6–10 çekirdek bulunur. Bu, geçişlere gerek kalmadığı anlamına gelmez. Sıradan bir dizüstü bilgisayarda bile yüzlerce süreç vardır ve çekirdek sayısı bunun çok altındadır. Bu yüzden çoklu programlama ve süreçler arası geçiş her sistemde, çekirdek sayısı ne olursa olsun yaşar.

3.1.6Örnek: Chrome'un çok süreçli mimarisi

Süreç kavramının pratikteki değerini en iyi gösteren örneklerden biri Chrome tarayıcısıdır. Eski tarayıcılar tek bir süreç olarak çalışırdı. Bir sekmedeki hatalı bir sayfa ya da eklenti bütün tarayıcıyı çökertebiliyordu. Chrome bunun yerine işi birçok sürece böler (Şekil 3.6).

Tarayıcı süreciarayüz, sekmeler, izinler, diskOluşturucusite AOluşturucusite BGPU süreciçizimAğ hizmetiHTTP, önbellekEklenti / yardımcıuzantı, ses...IPC (Mojo)oluşturucular korumalı alanda (sandbox) çalışır;bir sekme çökse de tarayıcı ve diğer sekmeler yaşar
Şekil 3.6: Chrome'un çok süreçli mimarisi. Ayrıcalıklı tarayıcı süreci, korumalı alanda çalışan oluşturucu süreçleri ve GPU, ağ, yardımcı süreçleri Mojo adlı IPC sistemiyle haberleşir.
  • Tarayıcı süreci tektir ve ayrıcalıklıdır. Arayüzü, sekmeleri, disk erişimini ve izinleri yönetir.
  • Oluşturucu (renderer) süreçleri HTML, CSS ve JavaScript'i işler. Site yalıtımı (site isolation) sayesinde her site ayrı bir oluşturucu sürecinde çalışır. Bu süreçler korumalı alanda (sandbox) çalışır: diske ve ağa doğrudan erişemez, her isteği tarayıcı sürecine iletir.
  • GPU, ağ ve yardımcı süreçler çizimi, ağ trafiğini ve uzantıları ayrı süreçlerde yürütür. Eski eklenti desteği ise kaldırılmıştır: NPAPI eklentileri 2015'te, Flash 2020 sonunda.

Bu tasarımın üç kazancı vardır. Birincisi kararlılıktır: bir sekme çökerse yalnız onun oluşturucu süreci ölür, tarayıcı ve öteki sekmeler çalışmaya devam eder. İkincisi güvenliktir: kötü niyetli bir sayfa kendi oluşturucu sürecini ele geçirse bile korumalı alandan çıkamaz ve öteki sitelerin verisini okuyamaz. Üçüncüsü eş zamanlılıktır: farklı sekmeler farklı çekirdeklerde paralel çalışabilir. Bedeli ise bellektir; her süreç kendi adres alanını ve veri yapılarını taşır. Bu yüzden çok sekmeli Chrome, tek süreçli bir tarayıcıdan daha çok bellek kullanır.

3.2Süreç oluşturma

Sistemdeki her süreç bir başka süreçten doğar. Bu kesim süreçlerin hangi olaylarla ve hangi sistem çağrılarıyla oluştuğunu anlatır. Ağırlık UNIX'in iki temel çağrısındadır: sürecin kopyasını çıkaran fork() ve süreçteki programı değiştiren exec(). İkisinin birlikte kullanımı, kabuğun bir komutu nasıl çalıştırdığını ve çıktıyı nasıl dosyaya yönlendirdiğini de açıklar.

3.2.1Süreç oluşturan olaylar ve sistem çağrıları

Yeni bir süreç dört tür olayla ortaya çıkar:

  1. Sistem açılışı. Çekirdek yüklendikten sonra ilk kullanıcı sürecini başlatır. Linux'ta bu genellikle systemd'dir. O da arka planda sürekli çalışan hizmetleri başlatır. Kullanıcıyla doğrudan etkileşmeyen bu hizmetlere daemon denir; web sunucusu, yazdırma hizmeti ve günlük tutucu bu türdendir.
  2. Çalışan bir sürecin isteği. Bir süreç, işini bölmek için yeni süreçler oluşturabilir. Örneğin bir derleme aracı her kaynak dosya için ayrı bir derleyici süreci başlatır.
  3. Kullanıcının bir komut ya da uygulama başlatması. Kabukta ls yazmak ya da masaüstünde bir simgeye tıklamak yeni bir süreç oluşturur.
  4. Toplu bir işin başlatılması. Büyük sunucularda ve hesaplama kümelerinde kuyruğa alınmış işler, kaynak boşaldıkça süreç olarak başlatılır.

Bu dört durum dışarıdan farklı görünür, ama teknik olarak aynı şey olur: var olan bir süreç, yeni süreç oluşturan bir sistem çağrısı yapar. Sistem açılışında bile ilk süreci çekirdek kendisi hazırlar; sonraki her süreç bir öncekinin çağrısıyla doğar.

Tablo 3.1: Süreç oluşturan sistem çağrıları.
SistemÇağrı
UNIX / Linuxfork() + exec(); ayrıca posix_spawn(), Linux'ta clone()
WindowsCreateProcess(): oluşturma ve program yüklemeyi tek çağrıda yapar

UNIX işi iki adıma böler. fork() çağıran sürecin bir kopyasını oluşturur; exec() ise bir sürecin içindeki programı başka bir programla değiştirir. Windows'un CreateProcess() çağrısı ikisini birden yapar: yeni süreci oluşturur ve içine verilen programı yükler. posix_spawn() UNIX'te de tek adımlı bir yol sunar. Linux'un clone() çağrısı ise yeni görevin ebeveyniyle neyi paylaşacağını (bellek, dosyalar, sinyal işleyicileri) tek tek seçtirir; iş parçacıkları da bu çağrıyla oluşturulur.

Süreci oluşturan sürece ebeveyn (parent), oluşan sürece çocuk (child) denir. Çocuk da kendi çocuklarını oluşturabilir. Her süreç tekil bir PID taşır ve ebeveyninin PID'ini bilir.

3.2.2Süreç ağacı

Ebeveyn–çocuk ilişkileri bir ağaç oluşturur. Linux'ta ağacın kökü açılışta PID 1 ile başlayan systemd'dir (ya da başka bir init programı). PID 2 olan kthreadd ise çekirdek iş parçacıklarının ebeveynidir; kworker gibi çekirdek içi işçiler onun altında durur. Şekil 3.7 tipik bir masaüstü sistemin ağacından bir kesit gösterir. Şekildeki PID değerleri örnektir; gerçek değerler her açılışta değişir.

systemdPID 1kthreaddPID 2journaldPID 312sshdPID 890gdmPID 1003kworker/0:1PID 57sshd (oturum)PID 2101gnome-shellPID 1520bashPID 2105firefoxPID 2310vimPID 2230psPID 2241çekirdek işparçacıkları$ pstree -p$ ps -ef --forest
Şekil 3.7: Linux süreç ağacından bir kesit. systemd (PID 1) kullanıcı süreçlerinin, kthreadd (PID 2) çekirdek iş parçacıklarının köküdür. Ağaç pstree -p ya da ps -ef --forest ile görülebilir.

Ağaçta bir kabuk oturumunun izi kolayca okunur. Kullanıcı SSH ile bağlandığında sshd (PID 890) oturum için yeni bir sshd (PID 2101) oluşturur. O, kullanıcının kabuğunu (bash, PID 2105) başlatır. Kabukta yazılan her komut, örneğin vim ya da ps, kabuğun çocuğu olur. ps çıktısında her sürecin yanında ebeveyninin PID'i (PPID) yazar; bu sütun ağacın dallarını verir.

3.2.3Ebeveyn ve çocuk ilişkisi

Yeni bir süreç oluşurken işletim sistemi dört soruya cevap verir:

  • Kaynak paylaşımı: Çocuk, ebeveynin kaynaklarının tümünü, bir kısmını ya da hiçbirini paylaşmayabilir. UNIX'te çocuk, ebeveynin açık dosya tanımlayıcılarını miras alır; aynı dosyalara aynı numaralarla erişir.
  • Yürütme: Ebeveyn ve çocuk eş zamanlı çalışabilir. Ya da ebeveyn wait() ile çocuğun bitmesini bekleyebilir.
  • Adres alanı: UNIX'te fork() sonrasında çocuk, ebeveynin adres alanının bir kopyasını alır. Aynı belleği paylaşmaz. Çocuk ardından exec() ile içine yeni bir program yükleyebilir.
  • Kimlikler: getpid() sürecin kendi PID'ini, getppid() ebeveyninin PID'ini döndürür.

Adres alanının kopyalanması pahalı görünür: ebeveynin yüzlerce megabaytlık belleği varsa hepsi mi kopyalanır? Hayır. Çocuğun adres alanı mantıksal olarak ebeveyninkinden ayrıdır; fiziksel sayfalar ise ancak biri yazana kadar ortak tutulur. Bu tekniğin adı yazınca kopyalamadır ve bir sonraki alt kesimin konusudur.

3.2.4fork(): bir çağrı, iki dönüş

fork() sıradan fonksiyonlara benzemez: bir kez çağrılır ama iki kez döner. Biri ebeveynde, öteki yeni oluşan çocukta. İki süreç de çağrının hemen arkasındaki satırdan devam eder. Hangisinin hangisi olduğunu ayırt etmenin tek yolu dönüş değeridir.

Tanım 3.4

pid_t fork(void) çağıran sürecin bir kopyası olan yeni bir süreç oluşturur. Başarı durumunda ebeveyne çocuğun PID'ini (pozitif bir sayı), çocuğa 0 döndürür. Başarısızlıkta çocuk oluşmaz ve yalnız ebeveyne −1 döner; neden errno değişkenine yazılır.

Bu üç durum, her fork sonrasında yazılan kalıbı belirler:

pid_t pid = fork();
if (pid < 0)        { perror("fork"); exit(1); }
else if (pid == 0)  { /* çocuk: fork 0 döndürdü */ }
else                { /* ebeveyn: pid = çocuğun PID'i */ }

Çocuk neden 0 alır da kendi PID'ini almaz? Çünkü çocuk kendi PID'ini her zaman getpid() ile öğrenebilir; ebeveynini de getppid() ile. Ebeveynin ise çocuğun PID'ini öğrenecek başka bir yolu yoktur. Bir ebeveynin birçok çocuğu olabilir ve onları beklemek ya da onlara sinyal göndermek için PID'lerini bilmesi gerekir. 0 değeri de hiçbir kullanıcı sürecinin PID'i olamayacağı için güvenli bir işarettir.

fork çekirdekte şunları yapar: yeni bir PCB ayırır ve ebeveyninkini içine kopyalar; yeni PID'i atar; açık dosya tablosunu kopyalar; sayfa tablosunu kopyalar. Sayfaların kendisini kopyalamaz. Şekil 3.8 bunun nasıl çalıştığını üç aşamada gösterir.

ebeveyn (PID 1234)sayfa tablosufiziksel bellek (çerçeveler)sayfa Asayfa Bsayfa C
ebeveyn (PID 1234)sayfa tablosuçocuk (PID 1235)sayfa tablosufiziksel bellek (çerçeveler)sayfa Asayfa Bsayfa Cyazmaya karşıkorumalı paylaşım(salt okunur)
ebeveyn (PID 1234)sayfa tablosuçocuk (PID 1235)sayfa tablosufiziksel bellek (çerçeveler)sayfa Asayfa Bsayfa Csayfa C′kopyalayazmaya karşıkorumalı paylaşım(salt okunur)çocuk C’ye yazdı →sayfa hatası →çekirdek kopyaladı
Şekil 3.8: fork() ve yazınca kopyalama. (a) Ebeveynin sayfa tablosu fiziksel A, B, C sayfalarını gösterir. (b) fork sonrası çocuğun sayfa tablosu aynı sayfaları gösterir; sayfalar iki tarafa da salt okunur eşlenir. (c) Çocuk C'ye yazınca sayfa hatası oluşur; çekirdek yalnız o sayfayı kopyalar (C′) ve çocuğun tablosunu yeni kopyaya yönlendirir.
Tanım 3.5

Yazınca kopyalama (copy-on-write, COW), iki sürecin aynı fiziksel sayfayı salt okunur olarak paylaşması ve sayfanın ancak süreçlerden biri ona yazmaya kalktığında kopyalanmasıdır. Yazma girişimi bir sayfa hatası (page fault) üretir; çekirdek sayfayı kopyalar, yazan sürecin sayfa tablosunu yeni kopyaya yönlendirir ve yazma iznini açar.

Yazınca kopyalama sayesinde fork'un maliyeti adres alanının boyutuyla değil, sayfa tablosunun boyutuyla orantılıdır. Daha önemlisi, çocuk hemen exec çağıracaksa (kabukta neredeyse her zaman böyledir) neredeyse hiçbir sayfa kopyalanmaz. Kopyalanacak sayfalar zaten exec sırasında atılacaktır. COW'un ayrıntıları sanal bellek konusunun parçasıdır; burada yalnız sonucu önemlidir: mantıksal olarak çocuk ebeveynin tam bir kopyasıdır ve iki taraf birbirinin değişkenlerini değiştiremez.

Sık yapılan hata

fork sonrasında ebeveyn ile çocuğun değişkenleri paylaştığını sanmak. Global bir değişken, öbekteki bir dizi ya da yığındaki bir yerel değişken, fork anında iki ayrı kopya hâline gelir. Çocuğun yaptığı atama ebeveynin kopyasını etkilemez. Süreçlerin veri paylaşması için açıkça paylaşımlı bellek, boru ya da başka bir IPC aracı kurmak gerekir.

3.2.5Kod izleme: fork() ve wait()

Aşağıdaki program, fork'tan sonra iki sürecin ayrı değişkenlerle çalıştığını ve wait'in çıktı sırasını nasıl sabitlediğini gösterir:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    int x = 10;
    pid_t pid = fork();
    if (pid == 0) {
        x = x + 5;
        printf("çocuk: x=%d\n", x);
        return 0;
    }
    wait(NULL);
    printf("ebeveyn: x=%d\n", x);
    return 0;
}

Program 6. satırda x = 10 ile başlar ve 7. satırda fork çağırır. Bundan sonra iki süreç vardır ve her birinde ayrı bir x bulunur. Çocukta pid == 0 olduğu için if bloğuna girilir; ebeveyn bloğu atlayıp wait(NULL)'a gelir. wait, çağıran sürecin çocuklarından biri bitene kadar onu bekletir ve biten çocuğun PID'ini döndürür. NULL argümanı, çocuğun çıkış durumuyla ilgilenilmediğini söyler. Ebeveynin PID'i 1234, çocuğunki 1235 kabul edilsin. Tablo 3.2 bir çalıştırmanın adımlarını verir.

Tablo 3.2: fork() + wait() programının izlemesi (ebeveyn 1234, çocuk 1235).
AdımSatırEbeveyn (1234)Çocuk (1235)Çıktı
16çalışıyor; x = 10—
27pid = 1235, x = 10oluştu; pid = 0, x = 10
313wait(NULL) → bekliyorhazır
48bekliyorçalışıyor; pid == 0 doğru
59bekliyor; x = 10x = 15
610bekliyorprintfçocuk: x=15
711uyandı (SIGCHLD)return 0 → zombi
813wait 1235 döndürürbiçildi (girdisi silindi)
914printf; x hâlâ 10—ebeveyn: x=10
1015sonlandı—

İzlemenin üç noktası önemlidir. Birincisi, 5. adımda çocuk x'i 15 yapar ama ebeveynin x'i 10'da kalır. Aynı adı taşıyan iki ayrı değişken vardır; 9. adımda ebeveyn 10 yazar. İkincisi, tablodaki 3. ve 4. adımların sırası değişebilir. fork'tan sonra önce çocuk da çalışabilir; o zaman çocuk yazıp çıkar, ebeveyn wait'e geldiğinde çocuk zaten bitmiştir ve wait hemen döner. Her iki sırada da çıktı aynıdır:

çocuk: x=15
ebeveyn: x=10

Üçüncüsü, 7. adımda çocuk main'den döndüğünde hemen yok olmaz. C çalışma zamanı main'in dönüş değeriyle exit(0)'ı çağırır. Çekirdek çocuğun belleğini geri alır, ama çıkış durumunu ebeveyn okuyana kadar saklar. Bu kısa aralıkta çocuk zombidir. Çekirdek ebeveyne SIGCHLD sinyali gönderir ve onu uyandırır. 8. adımda wait çocuğun durumunu alır ve girdisini süreç tablosundan siler. Bu işleme biçme (reaping) denir. Zombi ve biçme 3.3.4'te ayrıntılı ele alınır.

3.2.6wait() olmadan çıktı sırası belirsizdir

Önceki programdan wait çıkarılırsa ne olur? Aşağıdaki kısa parçaya bakalım:

pid_t pid = fork();
if (pid == 0) printf("çocuk\n");
else          printf("ebeveyn\n");

fork'tan sonra iki süreç de hazırdır ve hangisinin önce çalışacağına çizelgeleyici karar verir. Programın bu kararı etkileyecek hiçbir yolu yoktur. Şekil 3.9 tek çekirdekte iki olası çizelgeyi gösterir: birinde önce ebeveyn, ötekinde önce çocuk yazar. Çok çekirdekli bir makinede iki süreç aynı anda bile çalışabilir. Linux'ta hangisinin önce çalıştığı sürüme ve ayara göre değişmiştir; programcı buna güvenemez.

Çizelge 1fork()ebeveyn: printfçocuk: printfebeveynçocukÇizelge 2fork()çocuk: printfebeveyn: printfçocukebeveynzaman (tek çekirdek)
Şekil 3.9: fork sonrası iki farklı çizelge ve iki farklı çıktı sırası. Üstte önce ebeveyn, altta önce çocuk çalışır; terminal kutuları ekranda görülen çıktıyı gösterir.

Programın doğruluğu bu sıraya bağlıysa (örneğin ebeveyn, çocuğun yazdığı bir dosyayı okuyacaksa) sıra açıkça kurulmalıdır. En basit yol wait() ya da waitpid()'dir. Daha karmaşık durumlar için borular, semaforlar ve başka eşgüdüm araçları vardır. Çıktının sırasının çalıştırmadan çalıştırmaya değişmesi, eş zamanlı programlardaki en temel sorunun ilk belirtisidir: sonuç, denetlenemeyen bir zamanlamaya bağlı olmamalıdır.

3.2.7Kaç süreç oluşur? Tamponlu çıktı ve fork()

fork'un “her çağrıda süreç sayısını ikiye katlama” davranışı sınav sorularının vazgeçilmezidir. Kuralı bir kez doğru kurmak, en karışık görünen soruyu bile kolaylaştırır. Kural şudur: bir fork satırına o anda k süreç ulaşıyorsa, satırdan sonra 2k süreç vardır. Çünkü her biri bir çocuk doğurur ve çocuk da aynı satırdan devam eder.

fork();
fork();
printf("X\n");          /* kaç kez yazılır? */

for (int i = 0; i < 3; i++)
    fork();
printf("Y\n");          /* ya bu? */
Örnek 3.2
  1. İlk parça. Başta tek süreç vardır. Birinci fork'tan sonra 2 süreç olur. İki süreç de ikinci fork'u çalıştırır; sonra 4 süreç olur. Dördü de printf'e ulaşır: 4 satır X yazılır. Yeni oluşan süreç sayısı 3'tür.
  2. Döngülü parça. Döngü i = 0, 1, 2 için üç kez döner. Her turda süreç sayısı ikiye katlanır: 1 → 2 → 4 → 8. Çocuklar da döngü değişkeninin kopyasını taşır ve döngüye kaldıkları yerden devam eder. Sonuçta 23 = 8 süreç Y yazar; 7'si yenidir.
  3. Genelleme. Koşulsuz n tane fork art arda çalışırsa 2n süreç oluşur ve 2n − 1'i yenidir. Koşullu fork'larda (örneğin if (fork() && fork())) bu kural doğrudan uygulanamaz; her süreç için dönüş değerini ayrı izlemek gerekir.

Süreç sayısını bulmak yetmez; bazen çıktının içeriği de şaşırtıcıdır. Şu üç satıra bakalım:

printf("A");
fork();
printf("B\n");

Akla gelen cevap “bir A, iki B”dir. Oysa program iki satır AB yazar. Bunun nedeni C kütüphanesinin çıktı tamponudur. printf karakterleri hemen çekirdeğe göndermez; önce sürecin kendi belleğindeki bir tampona yazar. Tampon ancak belirli durumlarda boşaltılır ve içeriği write sistem çağrısıyla çekirdeğe gider. Terminale yazarken tampon satır tamponludur: satır sonu (\n) gelince boşaltılır. Bir dosyaya ya da boruya yazarken ise tam tamponludur: tampon dolunca ya da süreç çıkarken boşaltılır.

İlk satırdaki "A" satır sonu içermez, bu yüzden fork anında hâlâ tampondadır. Tampon sürecin belleğinde durduğu için fork onu da kopyalar. Artık iki sürecin tamponunda da A vardır. Her süreç "B\n"'yi ekler ve satır sonuyla birlikte AB yazar. Çıktı terminale de gitse, dosyaya da yönlendirilse iki kez AB görülür.

İlk satır printf("A\n") olsaydı sonuç çıktının nereye gittiğine bağlı olurdu. Terminalde satır tamponu A'yı fork'tan önce boşaltır ve çıktı A, B, B olur. Çıktı ./prog > cikti.txt ile dosyaya yönlendirilirse tam tampon A\n'yi tutmaya devam eder. Bu durumda iki süreç de çıkışta A, B yazar; dosyada dört satır bulunur: A, B, A, B.

Sık yapılan hata

printf çağrısının döndüğü anda metnin ekrana ya da dosyaya yazıldığını varsaymak. fork ya da exec öncesinde tampon boşaltılmazsa yazılmamış veri ya çoğalır ya kaybolur. Güvenli yol, fork'tan önce fflush(stdout) çağırmaktır. Hata iletileri için stderr tercih edilir, çünkü stderr tamponsuzdur.

3.2.8exec() ailesi

fork yalnız bir kopya üretir; kabuk her komutta kendini kopyalamakla kalsaydı hiçbir zaman ls çalıştıramazdı. Başka bir programı çalıştırmak için ikinci bir çağrı gerekir. exec ailesindeki fonksiyonlar, çağıran sürecin içindeki programı diskteki başka bir programla değiştirir.

Tanım 3.6

exec ailesi, çağıran sürecin bellek görüntüsünü (kod, veri, öbek ve yığın) verilen çalıştırılabilir dosyadan yüklenen yeni bir görüntüyle değiştirir. Yeni süreç oluşmaz: PID, ebeveyn ilişkisi ve kapatılmamış açık dosya tanımlayıcıları korunur. Başarılı bir exec çağıran koda geri dönmez; dönüyorsa bir hata olmuştur ve dönüş değeri −1'dir.

exec öncesiexeclp("ls", ...) sonrasıPID 1235 · PPID 1234metin: a.out koduveri / BSSöbekyığınaçık dosyalar 0,1,2cwd, kimliklerPID 1235 · PPID 1234metin: ls koduyeni veri / BSSyeni (boş) öbekyeni yığın: argvaçık dosyalar 0,1,2cwd, kimlikleradres alanı tümüyleyeniden yüklenirkorunan: PID, PPID,açık dosya tanımlayıcıları
Şekil 3.10: exec süreç görüntüsünü değiştirir. Soldaki a.out kodu ve verisi gider; yerine ls'nin kodu, yeni veri, boş öbek ve argv taşıyan yeni bir yığın gelir. PID, PPID, açık dosyalar, çalışma dizini ve kimlikler korunur.

Ailenin altı üyesi aynı işi yapar; yalnız argümanları verme biçimleri farklıdır. Adlarındaki harfler bu farkı kodlar. l (list): argümanlar tek tek, NULL ile biten bir liste olarak verilir. v (vector): argümanlar bir dizi olarak verilir. p (path): program PATH ortam değişkenindeki dizinlerde aranır. e (environment): ortam değişkenleri açıkça verilir.

Tablo 3.3: exec ailesi.
ÇağrıArgümanlarPATH aramasıOrtam
execl / execvliste / dizihayırdevralınır
execlp / execvpliste / dizievetdevralınır
execle / execveliste / dizihayıraçıkça verilir

Bu fonksiyonların hepsi C kütüphanesinde tanımlıdır ve sonunda tek bir sistem çağrısına varır: execve(). Örneğin execlp("ls", "ls", "-l", NULL) önce PATH içinde ls'yi arar ve /usr/bin/ls'yi bulur. Sonra argümanları {"ls", "-l", NULL} dizisine çevirip execve'yi çağırır. İlk argümanın programın adını tekrar etmesi bir alışkanlıktır: bu değer yeni programda argv[0] olur.

Sık yapılan hata

Argüman listesini NULL ile bitirmeyi unutmak ya da argv[0]'ı atlamak. execlp("ls", "-l", NULL) çağrısında -l programın adı (argv[0]) sayılır ve ls hiçbir seçenek almadan çalışır. NULL unutulursa kütüphane listenin sonunu bulamaz ve bellekteki rastgele değerleri argüman sanır.

3.2.9exec çekirdekte ne yapar?

Dışarıdan bakınca exec tek bir adımdır: süreç bir programken öteki program olur. Çekirdeğin içinde ise sıralı altı iş yapılır.

  1. Dosyayı aç; izni ve biçimi denetle. Dosya yoksa, çalıştırma izni yoksa ya da biçimi tanınmıyorsa exec −1 döndürür ve eski program kaldığı yerden sürer. Linux iki biçimi tanır: ELF başlığıyla başlayan ikili dosyalar ve #! satırıyla başlayan betikler. Betikte çekirdek, #!'den sonra yazan yorumlayıcıyı (örneğin /bin/sh) çalıştırır ve betiğin adını ona argüman olarak verir.
  2. Eski adres alanını bırak. Kod, veri, öbek ve yığın eşlemeleri silinir; süreçteki öteki iş parçacıkları sonlandırılır. Bu noktadan sonra geri dönülecek bir program yoktur. Bu yüzden bütün denetimler birinci adımda yapılır.
  3. close-on-exec işaretli tanımlayıcıları kapat; sinyal işleyicilerini sıfırla. O_CLOEXEC ile açılmış dosyalar kapatılır, ötekiler açık kalır. Yakalanan sinyaller varsayılan eyleme döner, çünkü işleyici fonksiyonun kodu artık bellekte değildir. Yok sayılan sinyaller yok sayılmaya devam eder.
  4. Kod ve veri bölümlerini dosyadan eşle. Kopyalama yapılmaz. Bir sayfa ilk kez kullanıldığında sayfa hatası oluşur ve o sayfa diskten getirilir. Bu tembel yükleme sayesinde büyük bir programın hiç kullanılmayan kısımları belleğe hiç gelmez.
  5. BSS, öbek ve yığın için sıfırlı bellek ayır. Yığının tepesine argv ve envp dizgileri, onları gösteren işaretçi dizileri ve argc yerleştirilir.
  6. Program sayacını giriş noktasına ayarla; kullanıcı moduna dön. Giriş noktası main değil, C çalışma zamanının _start yordamıdır. _start çalışma zamanını kurar ve main(argc, argv, envp)'yi çağırır. main dönünce dönüş değeriyle exit'i çağırır. Bütün bu süreçte PID hiç değişmemiştir.
eski kod, veri, öbek,yığın: silinirkorunur:PID ve ebeveynaçık fd’ler (cloexec hariç)çalışma dizini, kullanıcısinyal maskesiprog (ELF)başlık.text.datayığınargc, argv, envpboşöbek (boş)bss (sıfırlı)verimetin (kod)PC = giriş (_start)eşleme tembeldir: sayfa ilk erişimde okunur
Şekil 3.11: exec sonrası yeni adres alanı. Metin ve veri dosyadan tembel olarak eşlenir; BSS, öbek ve yığın sıfırlı bellekten gelir; yığının tepesinde argc, argv, envp durur. Eski kod, veri, öbek ve yığın silinmiştir; PID, ebeveyn, açık tanımlayıcılar (close-on-exec olanlar hariç), çalışma dizini ve kullanıcı kimliği korunur.

Bu sıralamadan önemli bir sonuç çıkar: exec'ten sonra gelen satır, yalnız exec başarısız olursa çalışır. Bu yüzden exec'in arkasına if (… == -1) denetimi yazmaya gerek yoktur; arkasındaki satıra ulaşılmışsa zaten hata vardır. Doğru kalıp, exec'in hemen ardından hatayı yazdırıp süreci sonlandırmaktır.

3.2.10Kod izleme: fork, exec ve waitpid

Aşağıdaki program, UNIX'te başka bir programı çalıştırmanın tam kalıbıdır: kopyala, çocukta programı değiştir, ebeveynde bekle.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
  pid_t pid = fork();
  if (pid == 0) {
    execlp("ls", "ls", "-l", NULL);
    perror("execlp");  /* hata */
    exit(1);
  }
  int st;
  waitpid(pid, &st, 0);
  printf("bitti: %d\n", WEXITSTATUS(st));
  return 0;
}

Burada wait yerine waitpid kullanılmıştır. waitpid(pid, &st, 0) belirli bir çocuğu bekler ve onun çıkış durumunu st'ye yazar. Üçüncü argüman seçenekleri taşır; 0 “bitene kadar bekle” demektir. st yalnız çıkış kodunu değil, sürecin nasıl bittiğini de kodlar. WIFEXITED(st) normal çıkış olup olmadığını, WEXITSTATUS(st) çıkış kodunu, WIFSIGNALED(st) ise bir sinyalle öldürülüp öldürülmediğini söyler.

Tablo 3.4: fork + exec + waitpid izlemesi (ebeveyn 1234, çocuk 1235).
AdımSatırEbeveyn (1234)Çocuk (1235)Çıktı
17pid = 1235pid = 0; aynı a.out görüntüsü
214waitpid(1235, …) → bekliyorhazır
39bekliyorexeclp: PATH'te ls bulunur; görüntü /usr/bin/ls olur, PID yine 1235
4—bekliyorls -l çalışır; miras aldığı stdout'a yazar(ls -l çıktısı)
514uyandı; waitpid döner, st = kodlanmış 0exit(0) → biçildi
615WEXITSTATUS(st) = 0 yazılır—bitti: 0

3. adım programın kalbidir. execlp başarılı olduğu için çocuktaki 10. ve 11. satırlar hiç çalışmaz: o satırları içeren kod artık bellekte değildir. Çocuk süreç bundan sonra ls programıdır. ls, kendisini kimin başlattığını bilmez; yalnız 1 numaralı dosya tanımlayıcısına yazar. O tanımlayıcı ebeveynden miras kalmıştır ve terminali gösterir. Bu yüzden liste ekrana çıkar.

Dosya adı yanlış yazılsaydı, örneğin execlp("lss", …), execlp −1 döndürürdü. Çocuk 10. satırda perror ile execlp: No such file or directory iletisini yazar ve exit(1) ile çıkardı. Ebeveyn bu durumda bitti: 1 yazardı. exit(1) satırı unutulsaydı ne olurdu? Çocuk if bloğundan çıkıp 13. satırdan devam eder ve ebeveynin kodunu çalıştırırdı. Sonuçta iki süreç waitpid çağırır ve iki kez “bitti” yazılırdı. Bu, fork kullanan programlarda en sık görülen hatalardan biridir.

3.2.11Kabuk döngüsü: fork, exec, wait

Komut satırı kabuğu, önceki alt kesimdeki kalıbı sonsuz bir döngüde çalıştıran bir programdır. Döngünün her turu altı adımdan oluşur. Şekil 3.12 son durumu gösterir.

  1. Kabuk (bash, PID 2105) istemi gösterir ve kullanıcının yazdığı ls komutunu okur.
  2. fork() ile kendinin bir kopyasını (PID 2300) oluşturur. Ebeveyn waitpid(2300, …) ile beklemeye geçer.
  3. Çocuk execvp("ls", argv) çağırır; görüntüsü ls ile değişir.
  4. ls çalışır ve miras aldığı terminal stdout'una yazar.
  5. ls çıkar. Çekirdek ebeveyni SIGCHLD ile bilgilendirir ve waitpid çocuğu biçer.
  6. Kabuk yeni istemi gösterir ve döngü başa döner.
bashPID 2105lsPID 2300fork()execvp("ls")görüntü ls ile değişirwaitpid(2300)→ bekliyorlsçalışırstdout’a yazarexit(0) → SIGCHLDçocuk biçilir (reap)$ yeni istem (prompt)
Şekil 3.12: Kabuk döngüsünün bir turu. bash çatallanır, çocuk ls'ye dönüşüp terminale yazar, çıkınca SIGCHLD gelir ve kabuk çocuğu biçip yeni istemi gösterir.

Komutun sonuna & eklenirse (sleep 60 &) kabuk 2. adımda waitpid çağırmaz; hemen yeni istemi gösterir. Bu bir arka plan işidir. Arka plandaki çocuk bittiğinde de biçilmesi gerekir. Kabuk bunu SIGCHLD geldiğinde ya da bir sonraki istemden önce bekleyen çocukları toplayarak yapar.

3.2.12Neden fork ve exec ayrı? Çıktı yönlendirme

Windows tek bir CreateProcess çağrısıyla yetinirken UNIX neden iki çağrı kullanır? Cevap, iki çağrı arasındaki kısa aralıktadır. fork'tan sonra ve exec'ten önce çocuk hâlâ kabuğun kodunu çalıştırır. Bu sırada kendi ortamını istediği gibi değiştirebilir: dosya tanımlayıcılarını yönlendirebilir, çalışma dizinini ya da önceliğini değiştirebilir, bazı sinyalleri yok sayabilir. Sonra exec ile yeni programa geçer. Yeni program bu değişiklikleri miras alır ama onlardan haberi yoktur.

Kabukta wc -l notlar.txt > sayim.txt yazıldığında olan budur. Aşağıdaki program aynı işi yapar:

int main(void) {
  pid_t pid = fork();
  if (pid == 0) {    /* çocuk */
    close(1);        /* stdout boşalır */
    int fd = open("sayim.txt",
             O_WRONLY | O_CREAT | O_TRUNC, 0644);
    /* en küçük boş numara: fd == 1 */
    execlp("wc", "wc", "-l", "notlar.txt", NULL);
    perror("execlp");
    exit(1);
  }
  waitpid(pid, NULL, 0);
  printf("bitti\n");
  return 0;
}

Programın anahtarı, open'ın her zaman en küçük boş tanımlayıcı numarasını vermesidir. Çocuk önce close(1) ile stdout'u kapatır; 1 numara boşalır. Hemen ardından açılan sayim.txt bu yüzden 1 numarayı alır. Şekil 3.13 çocuğun dosya tanımlayıcı tablosunun nasıl değiştiğini gösterir.

çocuğun fd tablosu (ebeveynin kopyası)0 → tty1 → tty2 → ttyebeveyn: 0, 1, 2 → terminal (değişmez) çocuğun fd tablosu0 → tty1 → (boş)2 → ttyebeveyn: 0, 1, 2 → terminal (değişmez)
çocuğun fd tablosu0 → tty1 → sayim.txt2 → ttyebeveyn: 0, 1, 2 → terminal (değişmez) çocuk = wc · fd tablosu korundu0 → tty1 → sayim.txt2 → ttyebeveyn: 0, 1, 2 → terminal (değişmez)
Şekil 3.13: Çocuğun dosya tanımlayıcı tablosu. (a) fork sonrası: 0, 1, 2 terminali gösterir. (b) close(1): 1 numara boşalır. (c) open en küçük boş numarayı, 1'i verir; stdout artık dosyadır. (d) exec kodu değiştirir, tabloyu korur: wc farkında olmadan dosyaya yazar. Ebeveynin tablosu baştan sona değişmez.
Tablo 3.5: Çıktı yönlendirme izlemesi (ebeveyn 1500, çocuk 1501).
AdımSatırÇocuğun fd 1'iOlayEkran
12terminalfork: çocuk ebeveynin fd tablosunu kopyalar
212terminalebeveyn waitpid ile bekler
34(boş)çocuk close(1) yapar
45–6sayim.txtopen 1 döndürür: fd == 1
58sayim.txtexec: çocuk artık wc -l; sonucu fd 1'e yazar
613—wc biter; ebeveyn kendi fd 1'ine, terminale yazarbitti

Ekranda yalnız bitti görünür. wc'nin çıktısı (örneğin 42 notlar.txt) sayim.txt'ye gitmiştir; cat sayim.txt ile görülebilir. wc'nin kaynak kodunda yönlendirmeyle ilgili tek satır yoktur. Program yalnız 1 numaraya yazar; o numaranın neyi gösterdiğini ise kabuk exec'ten önce ayarlamıştır.

Bu programda close ile open arasında başka bir dosya açılmadığı varsayılır. Gerçek kabuklar aynı işi daha güvenli biçimde dup2(fd, 1) ile yapar: dosyayı herhangi bir numarayla açar, sonra 1 numarayı ona yönlendirir. Windows'un CreateProcess'i aynı ayarları (standart giriş ve çıkış, çalışma dizini, öncelik) parametre olarak ister. Bu yüzden çağrının on parametresi vardır. UNIX'te ise her ayar ayrı, küçük bir sistem çağrısıdır ve fork ile exec arasında istenen sırayla yapılabilir.

3.3Süreç sonlandırma

Her süreç bir gün biter. Bitiş, sürecin kendi isteğiyle olabileceği gibi bir hata ya da başka bir sürecin müdahalesiyle de olabilir. Hangi yolla biterse bitsin, çekirdek sürecin kaynaklarını geri alır. Yalnız küçük bir parça, çıkış durumu, ebeveyn onu okuyana kadar saklanır. Bu kesim bitişin türlerini, sinyalleri ve bu küçük parçadan doğan zombi ve yetim süreçleri anlatır.

3.3.1Sonlandırma nedenleri, exit() ve abort()

Bir süreç dört nedenle sonlanır. İlk ikisi gönüllüdür: süreç bitmeye kendisi karar verir. Son ikisi istemsizdir: süreç dışarıdan durdurulur.

Tablo 3.6: Süreç sonlandırma nedenleri.
NedenTürÖrnek
Normal çıkışgönüllüreturn 0, exit(0); derleyicinin işi bitti
Hata sonrası çıkışgönüllüdosya bulunamadı → exit(1)
Ölümcül hataistemsizgeçersiz bellek erişimi (SIGSEGV), sıfıra bölme (SIGFPE)
Başka bir sürecin müdahalesiistemsizkill(pid, SIGTERM); Windows'ta TerminateProcess()

Gönüllü çıkışın olağan yolu exit(durum)'dur. main'den return ile dönmek de aynı sonucu verir, çünkü C çalışma zamanı main'in dönüş değeriyle exit'i çağırır. exit bir C kütüphanesi fonksiyonudur ve üç iş yapar: stdio tamponlarını boşaltır, atexit ile kaydedilmiş işleyicileri çalıştırır, sonra _exit sistem çağrısıyla çekirdeğe geçer. _exit doğrudan çağrılırsa tamponlar boşaltılmaz. Bu fark, fork sonrasında çocuğun tamponları ikinci kez yazmasını önlemek için kullanılır.

Çıkış durumu bir tamsayıdır, ama ebeveyne yalnız alttaki 8 biti ulaşır. Bu yüzden anlamlı değerler 0–255 aralığındadır. Kural olarak 0 başarı, sıfırdan farklı her değer bir hata türü demektir. Kabukta son komutun çıkış durumu $? ile görülür ve &&, || gibi işleçler bu değere göre çalışır.

abort() ise anormal bir çıkıştır. Çağıran süreç kendine SIGABRT sinyali gönderir ve bu sinyalle ölür. Sistem ayarına göre bir çekirdek dökümü (core dump) dosyası oluşabilir. assert makrosu başarısız olunca abort'u çağırır. Başka bir süreci sonlandırmak için ise kill() sistem çağrısı kullanılır.

Süreç hangi yolla biterse bitsin çekirdek aynı temizliği yapar: sürecin belleğini serbest bırakır, açık dosyalarını kapatır, öteki kaynaklarını geri alır. Geriye yalnız süreç tablosundaki girdi kalır: PID, çıkış durumu ve kullanılan CPU zamanı gibi birkaç bilgi. Bu bilgiler ebeveyn wait çağırana kadar saklanır.

Ebeveyn önce biterse çocuklara ne olacağı sistemden sisteme değişir. Bazı sistemler ebeveynle birlikte bütün çocukları da sonlandırır; buna basamaklı sonlandırma (cascading termination) denir. UNIX'te ise çocuklar yaşamaya devam eder ve yeni bir ebeveyne bağlanır.

3.3.2Ölümcül hatalar

Bir süreç geçersiz bir işlem yaptığında işlemci bunu bir istisna olarak algılar ve denetimi çekirdeğe verir. Çekirdek istisnanın türüne göre sürece bir sinyal gönderir. Sinyal, bir sürece bir olayın olduğunu bildiren küçük bir eşzamansız iletidir. Süreç o sinyal için bir işleyici kurmamışsa varsayılan eylem uygulanır; ölümcül hatalarda bu eylem sürecin sonlandırılmasıdır.

Tablo 3.7: Ölümcül hatalar ve sinyaller.
HataSinyal
Geçersiz ya da izinsiz bellek erişimiSIGSEGV
Tamsayı sıfıra bölmeSIGFPE
Geçersiz komutSIGILL
Hizalama ya da veri yolu hatasıSIGBUS
Kapalı boruya yazmaSIGPIPE

Şekil 3.14 en bilinen örneği adım adım gösterir. Program p = NULL iken *p = 5 yazmaya çalışır. 0 adresi hiçbir sayfaya eşlenmemiştir; bellek yönetim birimi (MMU) bir sayfa hatası üretir. Çekirdek adresin geçersiz olduğunu görür ve sürece SIGSEGV gönderir. Süreç bu sinyal için işleyici kurmadıysa sonlanır ve isteğe bağlı olarak bir çekirdek dökümü yazılır.

p = NULL; *p = 5MMU: sayfa hatasıçekirdek: geçersiz adresSIGSEGV gönderilirsonlan + coreişleyici yoksa
Şekil 3.14: Ölümcül bir hatanın akışı: NULL işaretçiye yazma → MMU sayfa hatası → çekirdek adresin geçersiz olduğunu görür → SIGSEGV → işleyici yoksa süreç sonlanır ve çekirdek dökümü alınır.

Çekirdek dökümü, sürecin öldüğü andaki belleğinin ve yazmaçlarının bir fotoğrafıdır. Hata ayıklayıcı bu dosyayla açılınca (gdb a.out core; systemd kullanan dağıtımlarda coredumpctl) programın hangi satırda, hangi çağrı zinciriyle öldüğü görülür. Bu inceleme türüne çökme dökümü analizi denir.

Sık yapılan hata

Bellek sızıntısını ölümcül hata saymak. free edilmeyen bellek ya da kapatılmayan dosyalar süreci kendiliğinden öldürmez; süreç çalışmayı sürdürür ve kaynakları süreç bitince zaten geri alınır. Uzun ömürlü bir sunucuda ise sızıntı belleği giderek tüketir. Linux'ta bellek tükenirse OOM killer (out-of-memory killer) en çok bellek tutan süreçlerden birini seçip SIGKILL ile sonlandırabilir. Süreç o zaman da sızıntı yüzünden değil, bu sinyal yüzünden ölür.

3.3.3Sinyallerle sonlandırma

Bir süreç başka bir süreci kill(pid, sinyal) sistem çağrısıyla sonlandırabilir. Adı yanıltıcıdır: kill yalnız bir sinyal gönderir; sinyalin sonucu sürecin ölmesi olmak zorunda değildir. Komut satırındaki kill, pkill ve killall komutları bu çağrının kullanıcı arayüzleridir.

Tablo 3.8: Süreç denetiminde kullanılan sinyaller (numaralar x86 ve ARM Linux içindir).
SinyalNoAnlamı
SIGTERM15kibarca bitir; süreç yakalayıp temizlik yapabilir (kill'in varsayılanı)
SIGKILL9zorla bitir; yakalanamaz, yok sayılamaz, temizlik yapılamaz
SIGINT2terminalde Ctrl+C
SIGTSTP20terminalde Ctrl+Z (duraklat); fg ya da bg sürdürür
SIGSTOP / SIGCONT19 / 18durdur (yakalanamaz) / sürdür

Sinyal numaraları mimariden mimariye değişebilir; tablodaki değerler x86 ve ARM Linux içindir. Bu yüzden taşınabilir kod numaraları değil adları kullanır: kill(pid, 15) yerine kill(pid, SIGTERM). Komut satırında tipik bir kullanım şöyledir:

$ ps -e | grep yaz
 1234 ?  00:00:03 yaz
$ kill 1234       # SIGTERM
$ kill -9 1234    # SIGKILL
$ pkill yaz       # ada göre
$ killall yaz

İyi bir alışkanlık, önce SIGTERM göndermek ve süreç yanıt vermezse SIGKILL'e geçmektir. SIGTERM'i yakalayan bir program geçici dosyalarını siler, yarım kalan yazmaları bitirir, bağlantılarını düzgünce kapatır. SIGKILL bu fırsatı tanımaz: süreç olduğu yerde durdurulur. Bir veritabanını kill -9 ile öldürmek, bir sonraki açılışta kurtarma işlemi gerektirebilir.

Her süreç her sürece sinyal gönderemez. Gönderen sürecin hedefle aynı kullanıcıya ait olması ya da yetkili kullanıcı (root) olması gerekir. Aksi hâlde kill “işlem izni yok” hatasıyla döner. Bu kural, bir kullanıcının başka bir kullanıcının süreçlerini sonlandırmasını engeller.

3.3.4Zombi ve yetim süreçler

Ebeveyn ile çocuğun bitiş sırası iki özel durum doğurur. İkisi de wait çağrısıyla ilgilidir.

Tanım 3.7

Zombi süreç (zombie process), bitmiş ama ebeveyni henüz wait ile çıkış durumunu almamış süreçtir. Belleği ve kaynakları serbest bırakılmıştır; süreç tablosunda yalnız PID'i ve çıkış durumu kalır. Yetim süreç (orphan process) ise ebeveyni kendisinden önce sonlanmış ve hâlâ çalışan süreçtir.

Şekil 3.15 iki senaryoyu yan yana koyar. Birinci senaryoda ebeveyn (PID 100) ve çocuk (PID 101) çalışıyor. Çocuk exit ile biter, ama ebeveyn henüz wait çağırmamıştır. Çekirdek çocuğun çıkış durumunu atamaz, çünkü ebeveyn onu ileride isteyebilir. Çocuk zombi olur: ps çıktısında durumu Z, adının yanında <defunct> yazar. Ebeveyn wait çağırdığında durumu alır ve çekirdek girdiyi siler. Bu adıma biçme denir; ardından zombi kalmaz.

İkinci senaryoda ebeveyn çocuktan önce biter. Çocuk (PID 101) hâlâ çalışıyor ve artık yetimdir. Çekirdek yetimi sahipsiz bırakmaz: onu init ya da systemd'ye (PID 1) bağlar. Sistemde bir alt-biçici (subreaper) süreç tanımlanmışsa yetim ona bağlanır; konteyner yöneticileri ve bazı oturum yöneticileri bu özelliği kullanır. Çocuk artık getppid() çağırırsa 1 alır (ya da alt-biçicinin PID'ini). Yetim bitince yeni ebeveyni onu wait ile biçer. init bu işi düzenli yaptığı için yetimlerden zombi birikmez.

Senaryo 1: çocuk önce biterebeveyn · PID 100çalışıyorçocuk · PID 101ZOMBİ (Z) · çıkış kodu saklı
Senaryo 2: ebeveyn önce bitersystemd · PID 1init / subreaperçocuk · PID 101evlat edinildi · PPID = 1
Şekil 3.15: Zombi ve yetim. (a) Senaryo 1: çocuk (101) bitti ama ebeveyn (100) henüz wait çağırmadı; çocuk zombidir ve çıkış kodu saklıdır. (b) Senaryo 2: ebeveyn önce bitti; yetim kalan çocuk systemd'ye (PID 1) bağlandı, PPID'i artık 1'dir.

Zombiler bellek ya da işlemci harcamaz, ama bir sınırlı kaynağı tüketir: PID'i. Linux'ta PID sayısı sınırlıdır (varsayılan üst sınır sisteme göre 32 768 ya da daha büyük bir değerdir). wait çağırmayan uzun ömürlü bir sunucu her istekte bir çocuk oluşturursa binlerce zombi biriktirir. Sonunda yeni süreç oluşturulamaz ve fork başarısız olur. Zombiyi kill ile öldürmek de işe yaramaz, çünkü zaten ölüdür. Çözüm ya ebeveynin wait çağırmasıdır ya da ebeveynin sonlandırılmasıdır: o zaman zombiler init'e geçer ve biçilir.

Çocuk oluşturan bir sunucu, çocukları bitirdikçe biçmek için genellikle SIGCHLD sinyalini yakalar. Aşağıdaki iskelet bu kalıbı gösterir:

#include <signal.h>
#include <sys/wait.h>

void bic(int sig) {
    (void)sig;
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;                       /* biten bütün çocukları topla */
}

int main(void) {
    signal(SIGCHLD, bic);       /* çocuk bitince bic() çalışsın */
    /* ... sunucu döngüsü: her istek için fork() ... */
    return 0;
}

waitpid'in ilk argümanı −1 “herhangi bir çocuk” demektir. WNOHANG seçeneği ise bitmiş çocuk yoksa beklemeden 0 döndürür. Döngü, aynı anda biten birden çok çocuğun hepsini toplar. Bu önemlidir, çünkü aynı türden birden çok sinyal art arda gelirse tek bir sinyal olarak teslim edilebilir.

Sık yapılan hata

Zombi ile yetimi karıştırmak ya da ikisinin aynı ölçüde zararlı olduğunu sanmak. Zombi bitmiş ama biçilmemiş bir süreçtir; yetim ise çalışan ama ebeveyni ölmüş bir süreçtir. Yetimler zararsızdır, çünkü init onları evlat edinir ve bitince biçer. Biriken zombiler ise PID tükenmesine yol açabilir; sorunun kaynağı wait çağırmayan ebeveyndir.

3.4Süreç durumları ve PCB

Bir süreç ömrü boyunca sürekli çalışmaz. Çoğu zaman işlemciyi bekler ya da bir olayın olmasını bekler. Çekirdek her sürecin o an hangi durumda olduğunu bilmek ve ona dair her şeyi bir yerde tutmak zorundadır. Bu kesim süreç durumlarını ve bu bilgilerin tutulduğu veri yapısını, süreç kontrol bloğunu anlatır.

3.4.1Beş durumlu model

Tanım 3.8

Süreç durumu (process state), sürecin o anki etkinliğini anlatır. Beş durumlu modelde durumlar şunlardır:

  • Yeni (new): süreç oluşturuluyor.
  • Hazır (ready): süreç çalışmaya hazır, işlemciyi bekliyor.
  • Çalışıyor (running): komutları işlemcide yürütülüyor.
  • Bekliyor (waiting, blocked): bir olayı (G/Ç'nin bitmesi, bir sinyal, bir çocuğun sonlanması) bekliyor.
  • Sonlandı (terminated): yürütmesi bitti.
kabul (admitted)çizelgeleyici seçti (dispatch)kesme / zaman dilimi dolduG/Ç ya da olay bekleG/Ç ya da olay tamamlandıçıkış (exit)YeninewHazırreadyÇalışıyorrunningBekliyorwaitingSonlandıterminated
Şekil 3.16: Beş durumlu süreç modeli ve altı geçiş: kabul, çizelgeleyici seçti, kesme ya da zaman dilimi doldu, G/Ç ya da olay bekle, G/Ç ya da olay tamamlandı, çıkış.

Şekil 3.16'daki oklar, durumlar arasındaki geçişlerdir. Her geçişin bir nedeni vardır:

  • Yeni → Hazır (kabul): Çekirdek süreç için gereken yapıları kurmuş ve süreci hazır kuyruğuna koymuştur.
  • Hazır → Çalışıyor (çizelgeleyici seçti): Çizelgeleyici hazır süreçlerden bunu seçer ve bağlamını işlemciye yükler. Bu adıma görevlendirme (dispatch) denir.
  • Çalışıyor → Hazır (kesme): Zaman dilimi dolmuş ya da daha öncelikli bir süreç hazır olmuştur. Süreç hiçbir şey beklemez; yalnız sırası geçmiştir.
  • Çalışıyor → Bekliyor (olay bekle): Süreç diskten okuma, klavyeden giriş ya da bir çocuğun bitmesi gibi bir olayı beklemek zorundadır. İşlemciyi kendi bırakır.
  • Bekliyor → Hazır (olay tamamlandı): Beklenen olay olmuştur. Süreç doğrudan çalışmaz, hazır kuyruğuna döner.
  • Çalışıyor → Sonlandı (çıkış): Süreç exit çağırmış ya da öldürülmüştür.

Tek çekirdekte herhangi bir anda yalnız bir süreç çalışıyor durumunda olabilir. n çekirdekte en çok n süreç çalışabilir. Sistemdeki yüzlerce sürecin çoğu ise hazır ya da bekliyor durumundadır. Gerçekte süreçlerin büyük kısmı bekliyor durumundadır: bir editör tuş basılmasını, bir sunucu ağ isteğini bekler.

3.4.2Durum geçişleri adım adım

Modeli somutlaştırmak için bir sürecin ömrünü baştan sona izleyelim. Süreç bir kez diskten veri okusun, bir kez de zaman dilimini doldursun.

Örnek 3.3
  1. Yeni. Kullanıcı programı başlattı. Çekirdek PCB oluşturuyor ve bellek ayırıyor.
  2. Yeni → Hazır. Çekirdek süreci kabul etti ve hazır kuyruğuna koydu.
  3. Hazır → Çalışıyor. Çizelgeleyici bu süreci seçti; bağlamı yüklendi.
  4. Çalışıyor → Bekliyor. Süreç diskten read() istedi. Veri gelene kadar yapacak işi yoktur ve işlemciyi bırakır.
  5. Bekliyor → Hazır. Disk kesmesi geldi: veri hazır. Süreç doğrudan çalışmaz, hazır kuyruğuna döner. O anda işlemcide başka bir süreç çalışıyor olabilir.
  6. Hazır → Çalışıyor. Yeniden seçildi.
  7. Çalışıyor → Hazır. Zamanlayıcı kesmesi geldi: zaman dilimi doldu. Bu kesmeli (preemptive) çizelgelemedir; süreç bir şey beklemediği için bekliyor durumuna değil hazır durumuna geçer.
  8. Hazır → Çalışıyor. Tekrar seçildi.
  9. Çalışıyor → Sonlandı. Süreç exit() çağırdı. Ebeveyn wait edene kadar zombi olarak süreç tablosunda kalır.

Bu ömür boyunca süreç hazır kuyruğuna 3 kez girdi (2., 5. ve 7. adımlarda), 1 kez bekledi ve işlemciye 3 kez seçildi.

kabul (admitted)çizelgeleyici seçti (dispatch)kesme / zaman dilimi dolduG/Ç ya da olay bekleG/Ç ya da olay tamamlandıçıkış (exit)YeninewHazırreadyÇalışıyorrunningBekliyorwaitingSonlandıterminated
kabul (admitted)çizelgeleyici seçti (dispatch)kesme / zaman dilimi dolduG/Ç ya da olay bekleG/Ç ya da olay tamamlandıçıkış (exit)YeninewHazırreadyÇalışıyorrunningBekliyorwaitingSonlandıterminated
Şekil 3.17: İzlemenin iki anı. (a) 4. adım: süreç read() çağırdı ve bekliyor durumuna geçti. (b) 5. adım: disk kesmesiyle olay tamamlandı; süreç hazır durumuna döndü.
Sık yapılan hata

Beklediği olay gerçekleşen sürecin doğrudan çalışmaya geçtiğini düşünmek. Modelde Bekliyor → Çalışıyor oku yoktur. Olay gerçekleştiğinde işlemci büyük olasılıkla başka bir sürece aittir. Uyanan süreç önce hazır kuyruğuna girer; işlemciyi alıp almayacağına ve ne zaman alacağına çizelgeleyici karar verir. Bir başka yaygın hata, zaman dilimi dolan süreci bekliyor durumuna koymaktır. Zaman dilimi dolan süreç hiçbir şey beklemez; yalnız sırası geçmiştir ve hazır durumuna döner.

3.4.3Linux'ta süreç durumları

Beş durumlu model kavramsaldır; gerçek çekirdekler bazı durumları birleştirir, bazılarını böler. Linux'ta durumlar ps komutunun STAT sütununda tek harfle gösterilir:

Tablo 3.9: Linux süreç durumları ve beş durumlu modeldeki karşılıkları.
STATAnlamModeldeki karşılığı
Rçalışıyor ya da çalışmaya hazırÇalışıyor + Hazır
Skesilebilir uyku (olay bekliyor)Bekliyor
Dkesilemez uyku (genelde disk G/Ç)Bekliyor
Tdurdurulmuş (SIGSTOP, hata ayıklayıcı)askıya alınmış
ZzombiSonlandı (biçilmedi)
Iboşta çekirdek iş parçacığı—
$ ps -eo pid,stat,comm
  PID STAT COMMAND
    1 Ss   systemd
  890 Ss   sshd
 2105 Ss   bash
 2241 R+   ps
 2307 Z    yaz <defunct>

Bu çıktıda systemd, sshd ve bash S durumundadır: hepsi bir olay bekler. Sonlarındaki s harfi sürecin bir oturum lideri olduğunu gösterir. ps'in kendisi R+'dır: o anda çalışan tek süreçtir ve + ön plan süreç grubunda olduğunu belirtir. yaz adlı süreç ise Z durumundadır; biçilmemiş bir zombidir.

Linux hazır ile çalışıyor durumlarını ayırmaz; ikisi de R ile gösterilir. Bekliyor durumu ise ikiye bölünür. S durumundaki süreç bir sinyalle uyandırılabilir; örneğin Ctrl+C ile klavye bekleyen bir program hemen sonlanır. D durumundaki süreç ise sinyallere yanıt vermez. Bu durum, yarıda kesilmesi tutarsızlık doğuracak kısa disk işlemleri için kullanılır. D'de uzun süre takılı kalan süreçler genellikle yanıt vermeyen bir disk ya da ağ dosya sistemine işaret eder; bunlar kill -9 ile bile sonlandırılamaz.

3.4.4Süreç kontrol bloğu

Bir süreç işlemciden ayrıldığında yazmaçlarındaki değerler bir yere kaydedilmelidir; yoksa süreç kaldığı yerden devam edemez. Çekirdeğin sürece dair bilmesi gereken başka şeyler de vardır: durumu, önceliği, belleği, açık dosyaları. Bunların hepsi tek bir kayıtta toplanır.

Tanım 3.9

Süreç kontrol bloğu (process control block, PCB), çekirdeğin her süreç için tuttuğu ve o sürece dair bilinmesi gereken her şeyi içeren veri yapısıdır. Bütün PCB'lerin oluşturduğu yapıya süreç tablosu denir. PCB'nin, süreç işlemciden ayrılırken kaydedilen ve geri dönerken yüklenen kısmına (program sayacı ve yazmaçlar) bağlam (context) denir.

süreç durumusüreç kimliği (PID)program sayacıyazmaçlarçizelgeleme bilgisibellek yönetim bilgisiaçık dosyalar listesimuhasebe bilgisi (CPU süresi)• • •bağlam(context)
Şekil 3.18: Bir PCB'nin başlıca alanları: süreç durumu, PID, program sayacı, yazmaçlar, çizelgeleme bilgisi, bellek yönetim bilgisi, açık dosyalar listesi ve muhasebe bilgisi. Program sayacı ile yazmaçlar sürecin bağlamını oluşturur.

PCB'ler çekirdek belleğinde durur. Kullanıcı süreçleri onlara doğrudan erişemez; kendi PCB'lerindeki bir bilgiyi ancak sistem çağrılarıyla okuyabilir ya da değiştirebilir. getpid() PCB'den PID'i okur; nice() önceliği değiştirir; open() açık dosyalar listesine bir girdi ekler. Bu kısıtlama güvenliğin temelidir: bir süreç kendi önceliğini keyfince yükseltemez ya da başka bir sürecin dosyalarına erişemez.

3.4.5PCB alanları

Tanenbaum, bir süreç tablosu girdisindeki alanları sorumlu oldukları yönetim işine göre üç gruba ayırır. Tablo 3.10 bu gruplamayı verir.

Tablo 3.10: Tanenbaum'a göre PCB alanları.
Süreç yönetimiBellek yönetimiDosya yönetimi
yazmaçlar · program sayacı · program durum sözcüğü (PSW) · yığın işaretçisi · süreç durumu · öncelik · çizelgeleme parametreleri · PID · ebeveyn süreç · süreç grubu · sinyaller · başlama zamanı · kullanılan CPU zamanı · çocukların CPU zamanı · sonraki alarm zamanımetin, veri ve yığın bölümlerinin işaretçileri; günümüzde sayfa tablosu işaretçisi ve bellek bölgeleri listesikök dizin · çalışma dizini · dosya tanımlayıcıları · kullanıcı kimliği (UID) · grup kimliği (GID)

Tablodaki üç alan ayrıca açıklanmayı hak eder:

  • Program durum sözcüğü (program status word, PSW): işlemcinin durum bayraklarını (sıfır, taşma, elde …), çalışma modunu (kullanıcı ya da çekirdek) ve kesme izinlerini tutan yazmaçtır. Bir karşılaştırmanın sonucu bu bayraklarda durduğu için PSW de bağlamın parçasıdır. Kesilen süreç, karşılaştırma ile koşullu atlama arasında kesilse bile doğru dala gitmelidir.
  • Süreç grubu: birlikte yönetilen süreçlerdir. Kabukta ls | grep x | wc -l gibi bir boru hattı tek bir grup oluşturur; Ctrl+C'ye basıldığında SIGINT grubun hepsine gider.
  • Sonraki alarm zamanı: süreç alarm(saniye) ile bir süre sonra uyarılmak isterse, SIGALRM sinyalinin teslim edileceği zaman burada tutulur.

Bellek yönetimi sütunundaki “metin, veri ve yığın bölümlerinin işaretçileri” ifadesi eski, bölümlemeli bellek düzenini yansıtır. Günümüzde PCB sürecin sayfa tablosunu ve bellek bölgelerinin listesini gösterir. Şekil 3.19, süreç tablosunun PID ile erişilen girdilerden oluştuğunu ve her girdinin bu üç gruptan bilgi taşıdığını gösterir.

süreç tablosu141210331034…PID ile indislenen dizi ya da listesüreç: durum, yazmaçlar, öncelikbellek: sayfa tablosu işaretçisidosya: açık dosyalar, çalışma diziniPCB (PID 1033)
Şekil 3.19: Süreç tablosu: her girdi bir PCB'dir. PID 1033'ün girdisinde süreç (durum, yazmaçlar, öncelik), bellek (sayfa tablosu işaretçisi) ve dosya (açık dosyalar, çalışma dizini) bilgileri vardır.

3.4.6Linux'ta PCB: task_struct

Linux'ta PCB'nin adı task_struct'tır ve <linux/sched.h> başlık dosyasında tanımlıdır. Linux süreç ile iş parçacığını aynı yapıyla temsil eder; ikisi de çekirdek gözünde bir “görev”dir (task). Yapının birkaç yüz alanı vardır; en önemlileri şunlardır:

struct task_struct {          /* <linux/sched.h> */
    pid_t pid;                     /* süreç kimliği */
    unsigned int __state;          /* süreç durumu */
    int prio;                      /* öncelik */
    struct sched_entity se;        /* CFS/EEVDF bilgisi */
    struct task_struct *parent;    /* ebeveyn süreç */
    struct list_head children;     /* çocuklar */
    struct files_struct *files;    /* açık dosyalar */
    struct mm_struct *mm;          /* adres uzayı */
    /* ... yüzlerce alan daha ... */
};

Alanların her biri, Tablo 3.10'daki bir grubun karşılığıdır. pid kimliği, __state durumu, prio önceliği taşır. se çizelgeleyicinin süreç başına tuttuğu bilgilerdir: sürecin ne kadar CPU zamanı kullandığı ve çizelgeleyicinin ağacındaki yeri. parent ve children süreç ağacını kurar. files açık dosya tablosunu, mm ise adres alanını (sayfa tablosu ve bellek bölgeleri) gösterir. mm ile files'ın işaretçi olması önemlidir: aynı süreçteki iş parçacıkları aynı mm ve files yapısını gösterir, böylece belleği ve dosyaları paylaşır.

task_structPID 1task_structPID 2105task_structPID 2241currenttasks: çift yönlü bağlı liste
Şekil 3.20: Bütün task_struct'lar çift yönlü bağlı bir listededir. current, o çekirdekte çalışan görevi gösterir.

Çekirdek bütün görevleri çift yönlü bağlı bir listede tutar (Şekil 3.20). Her çekirdekte current adlı bir işaretçi o anda çalışan görevin task_struct'ını gösterir. Bir sistem çağrısı geldiğinde çekirdek current üzerinden çağıranın bilgilerine ulaşır. Örneğin getpid() kabaca current->pid değerini döndürür (gerçekte iş parçacığı grubunun kimliğini döndürür).

Çekirdek kodu zamanla değişir; ders kitaplarındaki alan adları da eskiyebilir. Eski çekirdeklerde durum alanının adı long state idi ve time_slice diye ayrı bir alan vardı. Güncel çekirdeklerde durum __state adını almıştır. Zaman dilimi bilgisi de sched_entity yapısının içine taşınmıştır.

3.5Çizelgeleme altyapısı

Çoklu programlamanın amacı işlemciyi sürekli meşgul tutmaktır. Bunun için çekirdeğin hangi süreçlerin işlemciyi beklediğini, hangilerinin bir olayı beklediğini bilmesi ve işlemciyi bir süreçten ötekine aktarabilmesi gerekir. Hangi sürecin seçileceğini belirleyen politikalar (ilk gelen önce, en kısa iş önce, zaman paylaşımlı sıralı …) CPU çizelgeleme konusunun parçasıdır. Bu kesim politikaların üzerine kurulduğu mekanizmayı anlatır: kuyrukları, çoklu programlamanın yararını ölçen basit bir modeli ve işlemciyi süreçler arasında aktaran bağlam değiştirmeyi.

3.5.1Hazır ve bekleme kuyrukları

Çizelgeleyici, hazır süreçler arasından işlemciye geçecek olanı seçer. İki hedefi vardır: işlemciyi boş bırakmamak ve kullanıcıya hızlı yanıt vermek. Seçimi yapabilmesi için süreçlerin düzenli bir biçimde tutulması gerekir. Çekirdek bunun için kuyruklar kullanır.

Tanım 3.10

Hazır kuyruğu (ready queue), bellekte bulunan ve işlemciyi bekleyen süreçlerin listesidir. Bekleme kuyrukları (wait queues) ise belirli bir olayı (bir diskin G/Ç'yi bitirmesi, bir çocuğun sonlanması, ağdan paket gelmesi) bekleyen süreçleri tutar. Her olay türü ya da her aygıt için ayrı bir bekleme kuyruğu vardır.

hazır kuyruğubaş · sonPCB 7PCB 2PCB 9disk G/Ç beklemebaş · sonPCB 3PCB 14ağ / klavye beklemebaş · sonPCB 6CPUPCB 5 çalışıyorkuyruklar PCB’lere bağlı listeler olarak tutulur
Şekil 3.21: Kuyruklar PCB'lere bağlı listeler olarak tutulur. Hazır kuyruğunda PCB 7, 2 ve 9; disk G/Ç bekleme kuyruğunda PCB 3 ve 14; ağ/klavye bekleme kuyruğunda PCB 6 vardır. PCB 5 işlemcide çalışıyor.

Kuyruklar ayrı bir veri yapısı değil, PCB'ler arasındaki bağlantılardır. Her kuyruğun başını ve sonunu gösteren iki işaretçisi vardır; her PCB'de de sıradaki PCB'yi gösteren bir alan bulunur. Bir sürecin bir kuyruktan ötekine geçmesi, PCB'sinin bir listeden çıkarılıp ötekine eklenmesidir; PCB'nin kendisi kopyalanmaz. Bir süreç aynı anda yalnız bir kuyrukta bulunur, çalışan süreç ise hiçbir kuyrukta değildir.

Gerçek sistemler basit bir FIFO listeden daha akıllı yapılar kullanır. Linux her işlemci çekirdeğine ayrı bir çalışma kuyruğu (runqueue) atar. Böylece çekirdekler hazır süreç seçerken birbirini beklemez. CFS ve onun yerini alan EEVDF çizelgeleyicileri bu kuyruğu kırmızı-siyah ağaç olarak tutar: sıradaki süreci bulmak hızlıdır ve süreç ekleyip çıkarmak O(log n) sürer.

3.5.2Kuyruk diyagramı

Bir sürecin ömrü, kuyruklar arasında bir yolculuk olarak da çizilebilir. Şekil 3.22'deki kuyruk diyagramında dikdörtgenler kuyrukları, daire işlemciyi, oklar ise geçişleri gösterir.

hazır kuyruğuCPUsonlan (exit)G/Ç bekleme kuyruğuG/Ç isteğizaman dilimi dolduçocuğun bitmesini bekleçocuk oluştur (fork)kesme bekleme kuyruğukesme bekle
Şekil 3.22: Süreç çizelgelemenin kuyruk diyagramı. Süreç hazır kuyruğundan işlemciye geçer; oradan dört yoldan biriyle ayrılır: G/Ç isteği, zaman diliminin dolması, çocuk oluşturup bekleme ya da bir kesmeyi bekleme. Sonunda exit ile sistemden çıkar.

Yolculuk şöyle ilerler. Yeni süreç hazır kuyruğunun sonuna eklenir ve sırasını bekler. Çizelgeleyici onu seçtiğinde işlemcide çalışır. Çalışan süreç işlemciyi dört yoldan biriyle bırakabilir:

  • G/Ç isteği yapar ve ilgili G/Ç bekleme kuyruğuna geçer. G/Ç bitince hazır kuyruğuna döner.
  • Zaman dilimi dolar; zamanlayıcı kesmesiyle işlemciden alınır ve doğrudan hazır kuyruğuna döner. Bu yolda bekleme kuyruğu yoktur.
  • Çocuk oluşturur ve wait() çağırır; çocuk bitene kadar bekler.
  • Bir kesmeyi ya da olayı bekler; örneğin sleep() süresinin dolmasını ya da bir ağ paketinin gelmesini.

Süreç bu döngüyü işi bitene kadar tekrarlar. Sonunda exit() ile sistemden çıkar; PCB'si ebeveyn wait ettiğinde silinir. Diyagram, Şekil 3.16'daki durum modelinin kuyruklarla çizilmiş biçimidir: hazır kuyruğu “Hazır” durumuna, bekleme kuyrukları “Bekliyor” durumuna, işlemci “Çalışıyor” durumuna karşılık gelir.

3.5.3CPU'ya bağlı ve G/Ç'ye bağlı süreçler

Hemen her süreç iki tür etkinlik arasında gidip gelir: işlemcide hesap yapar, sonra G/Ç bekler, sonra yine hesap yapar. Kesintisiz hesap yapılan aralıklara CPU patlaması (CPU burst) denir. Süreçler bu patlamaların uzunluğuna göre iki gruba ayrılır (Şekil 3.23).

(a) CPU’ya bağlı(b) G/Ç’ye bağlıCPU patlaması (burst)G/Ç beklemezaman
Şekil 3.23: (a) CPU'ya bağlı süreç: uzun CPU patlamaları, seyrek G/Ç. (b) G/Ç'ye bağlı süreç: kısa CPU patlamaları, sık G/Ç bekleme.
Tanım 3.11

CPU'ya bağlı (CPU-bound) süreç zamanının çoğunu hesaplamayla geçirir; uzun CPU patlamaları ve seyrek G/Ç istekleri vardır. Derleme, video kodlama ve bilimsel hesap bu türdendir. G/Ç'ye bağlı (I/O-bound) süreç ise zamanının çoğunu G/Ç bekleyerek geçirir; CPU patlamaları kısadır. Metin editörü, web sunucusu ve veritabanı istemcisi bu türdendir.

Bir sürecin hangi grupta olduğunu patlamaların uzunluğu belirler; G/Ç'nin süresi belirlemez. G/Ç'ye bağlı bir süreç, verisi geldikten sonra az hesap yapıp hemen yeni bir G/Ç ister. İşlemciler disklerden çok daha hızlı geliştiği için zamanla daha çok süreç G/Ç'ye bağlı hâle gelmiştir. Aynı hesap her yıl daha kısa sürer, ama diski ya da ağı beklemek aynı oranda kısalmaz.

Bu ayrım çizelgeleyici için önemlidir. G/Ç'ye bağlı bir süreç hazır olduğunda işlemciyi hemen almalıdır; kısa işini bitirip yeni bir G/Ç başlatır ve diski meşgul eder. Bu süreç hazır kuyruğunda uzun bir CPU'ya bağlı sürecin arkasında beklerse disk boş kalır, kullanıcı da yavaş yanıt alır. İyi bir sistem iki türü dengeli karıştırır: CPU'ya bağlı süreçler işlemciyi, G/Ç'ye bağlı süreçler aygıtları meşgul eder.

3.5.4Çoklu programlamanın modellenmesi

Bellekte kaç süreç tutmak gerekir? Basit bir olasılık modeli bu soruya kaba ama öğretici bir cevap verir.

Teorem 3.1 (Çoklu programlama modeli)

Her süreç zamanının p kesrini G/Ç bekleyerek geçirsin ve süreçler birbirinden bağımsız olsun. Bellekte n süreç varsa CPU kullanımı

CPU kullanımı = 1 − pn

olur. Burada n'ye çoklu programlama derecesi denir.

İşlemci yalnız bellekteki süreçlerin hepsi aynı anda G/Ç beklerken boş kalır; en az biri hazırsa işlemci çalışır. Bir sürecin herhangi bir anda beklemekte olma olasılığı p'dir. Süreçler bağımsız olduğu için n sürecin hepsinin aynı anda beklemesi olasılığı olasılıkların çarpımıdır: p · p · … · p = pn. İşlemcinin boş olma olasılığı pn olduğuna göre meşgul olma olasılığı 1 − pn'dir.

∎

204060801000246810bellekteki süreç sayısı (çoklu programlama derecesi) nCPU %%20 G/Ç bekleme%50 G/Ç bekleme%80 G/Ç bekleme
Şekil 3.24: CPU kullanımının çoklu programlama derecesine göre değişimi; %20, %50 ve %80 G/Ç bekleme için üç eğri. Bekleme oranı yükseldikçe aynı kullanıma ulaşmak için daha çok süreç gerekir.

Şekil 3.24'teki eğriler modelin iki dersini gösterir. Birincisi, G/Ç'ye bağlı süreçlerde (p büyük) tek programlı sistem israftır. p = 0,8 ise tek süreçle kullanım yalnız %20'dir; 4 süreçle %59'a, 10 süreçle %89'a çıkar. İkincisi, eğri giderek yataylaşır. Her yeni süreç bir öncekinden daha az kazandırır, çünkü kullanım %100'e hiçbir zaman ulaşmaz.

Model kabadır. Süreçlerin bağımsız olduğu varsayılır; oysa tek bir diski bekleyen süreçler sırayla hizmet alır ve beklemeleri birbirine bağlıdır. Yine de model bellek planlaması için yararlıdır: bellek artırılınca kaç süreç daha sığacağı ve bunun kullanımı ne kadar artıracağı hızlıca kestirilebilir. Örneğin her süreç 2 GB kullanıyorsa ve p = 0,8 ise 8 GB'lık (4 süreç) bir makineye 8 GB daha eklemek kullanımı %59'dan %83'e çıkarır. Sonraki 8 GB ise yalnız %93'e çıkarır.

Örnek 3.4

Süreçler zamanlarının %60'ını G/Ç bekleyerek geçiriyor. (a) 3 süreçle CPU kullanımı nedir? (b) Kullanımın en az %95 olması için bellekte en az kaç süreç bulunmalıdır?

  1. (a) p = 0,6 ve n = 3: 0,63 = 0,216. Kullanım 1 − 0,216 = 0,784, yani %78,4'tür.
  2. (b) Eşitsizlik. 1 − 0,6n ≥ 0,95 olmalıdır; bu da 0,6n ≤ 0,05 demektir.
  3. Deneme. 0,65 = 0,07776 → kullanım %92,2; yetmez. 0,66 = 0,046656 → kullanım %95,3; yeter.
  4. Logaritmayla denetim. n ≥ ln 0,05 / ln 0,6 ≈ 5,86. n tamsayı olduğu için yukarı yuvarlanır: en az 6 süreç gerekir.
Sık yapılan hata

Logaritmayla bulunan değeri aşağı yuvarlamak. ln 0,05 / ln 0,6 ≈ 5,86 sonucunu 5'e yuvarlamak kullanımı %92,2'de bırakır ve koşul sağlanmaz. Benzer bir tuzak p'yi yanlış yorumlamaktır: p G/Ç bekleme oranıdır, CPU kullanım oranı değildir. “Süreçler zamanlarının %25'inde işlemci kullanıyor” denirse p = 0,75 alınır.

3.5.5Kod izleme: CPU kullanımı 1 − pn

Modelin değerlerini bir döngüyle tablo hâlinde üretmek kolaydır. Sunumdaki program p = 0,8 için n = 1…5 değerlerini hesaplar:

double p = 0.8;      /* G/Ç oranı */
double pn = 1.0;
for (int n = 1; n <= 5; n++) {
  pn = pn * p;       /* hepsi bekler */
  double kul = 1 - pn;
  printf("n=%d %%%.0f\n", n, kul * 100);
}

Programın püf noktası, pn'yi her turda baştan hesaplamamasıdır. pn değişkeni bir önceki turun değerini tutar ve her turda bir kez p ile çarpılır. Böylece n. turda pn = pn olur. printf'teki %% bir yüzde işareti basar; %.0f ise sayıyı ondalıksız, yuvarlayarak yazar.

Tablo 3.11: Döngünün izlemesi (p = 0,8).
npnkulÇıktı satırıArtış (puan)
10,80,2n=1 %20—
20,640,36n=2 %3616
30,5120,488n=3 %4912,8
40,40960,5904n=4 %5910,24
50,327680,67232n=5 %678,19

Tablonun son sütunu modelin “azalan kazanç” özelliğini sayılarla gösterir. İkinci süreç kullanımı 16 puan artırır; beşinci süreç yalnız 8,19 puan. Aslında her artış bir öncekinin tam 0,8 katıdır: n'den n + 1'e geçişte artış pn − pn+1 = pn(1 − p) olur. Çıktıda 48,8'in %49 olarak, 67,232'nin %67 olarak yazılması da %.0f'in yuvarlamasından gelir.

%0%50%100n=1%20n=2%36n=3%49n=4%59n=5%67boşboş olasılığı pⁿ = 0,3277
Şekil 3.25: Döngü bittiğinde n = 1…5 için CPU kullanımı: %20, %36, %49, %59, %67. Çubukların üst kısmı işlemcinin boş kaldığı kesirdir (pn).

3.5.6Bağlam değiştirme

Çizelgeleyici bir süreci seçtiğinde işlemcinin o sürece geçmesi gerekir. Bu geçiş, işlemcide o an çalışan sürecin durumunu bir yere saklamayı ve seçilen sürecin durumunu işlemciye yüklemeyi gerektirir.

Tanım 3.12

Bağlam değiştirme (context switch), işlemciyi bir süreçten ötekine aktarma işlemidir. Çekirdek çalışan sürecin bağlamını (program sayacı, yazmaçlar, PSW) onun PCB'sine kaydeder, seçilen sürecin bağlamını kendi PCB'sinden yükler ve adres alanını yeni sürecinkine çevirir.

süreç P₀işletim sistemisüreç P₁kesme ya da sistem çağrısıdurumu PCB₀’a kaydetdurumu PCB₁’den yükledurumu PCB₁’e kaydetdurumu PCB₀’dan yüklezaman
Şekil 3.26: İki süreç arasında bağlam değiştirme. P₀ çalışırken bir kesme ya da sistem çağrısı gelir; çekirdek P₀'ın durumunu PCB₀'a kaydeder, P₁'inkini PCB₁'den yükler. Sonra ters yönde aynı iş yapılır. Gri bölümlerde hiçbir kullanıcı işi yapılmaz.

Şekil 3.26 zaman ekseninde iki geçişi gösterir. P₀ çalışırken bir kesme ya da sistem çağrısı gelir. İşlemci çekirdek moduna geçer ve P₀'ın program sayacıyla yazmaçları PCB₀'a kaydedilir. Çizelgeleyici P₁'i seçer; P₁'in bağlamı PCB₁'den yüklenir ve adres alanı değişir. P₁ çalışırken P₀ boştadır: hazır ya da bekliyor durumundadır. Bir sonraki kesmede P₁'in durumu PCB₁'e kaydedilir, PCB₀'dakiler geri yüklenir ve P₀ kaldığı yerden devam eder.

Şekildeki gri bölümler bağlam değiştirmenin bedelidir. O sürede işlemci çalışır ama hiçbir kullanıcı programı ilerlemez. Bu yüzden bağlam değiştirme saf ek yüktür (overhead).

3.5.7Kod izleme: bağlam değiştirme

Bağlam değiştirme gerçek çekirdeklerde assembly ile yazılır, çünkü yazmaçlara doğrudan erişmek gerekir. Mantığı ise birkaç satır C ile modellenebilir. Aşağıdaki programda P dizisi süreç tablosudur; her PCB'de üç yazmaç (pc, sp, r1) ve bir durum harfi vardır. Global pc, sp ve r1 değişkenleri işlemcinin yazmaçlarını temsil eder.

struct pcb { int pc, sp, r1; char durum; };
struct pcb P[2];   /* süreç tablosu */
int pc, sp, r1;    /* CPU yazmaçları */
void baglam_degistir(int e, int y) {
  P[e].pc = pc;  P[e].sp = sp;  /* kaydet */
  P[e].r1 = r1;  P[e].durum = 'H';
  pc = P[y].pc;  sp = P[y].sp;  /* yükle */
  r1 = P[y].r1;  P[y].durum = 'C';
}
/* kesme */  baglam_degistir(0, 1);
/* ... P1 çalışır ... */
/* kesme */  baglam_degistir(1, 0);

baglam_degistir(e, y) iki yarıdan oluşur. İlk yarı eski sürecin (e) yazmaçlarını onun PCB'sine kaydeder ve durumunu 'H' (hazır) yapar. İkinci yarı yeni sürecin (y) yazmaçlarını işlemciye yükler ve durumunu 'C' (çalışıyor) yapar. Başlangıçta P₀ çalışıyor: pc = 104, sp = 896, r1 = 8. P₁'in son durumu PCB₁'de saklıdır: pc = 340, sp = 500, r1 = 2.

Tablo 3.12: Bağlam değiştirme izlemesi. Değerler pc/sp/r1 sırasıyla yazılmıştır; “—” henüz kaydedilmemiş alanı gösterir.
AdımSatırCPUPCB₀PCB₁Açıklama
110104/896/8 (P₀)—/—/— C340/500/2 Hzamanlayıcı kesmesi; çekirdek P₁'e geçmeye karar verir
25104/896/8104/896/— C340/500/2 HP₀'ın pc ve sp'si kaydedildi
36104/896/8104/896/8 H340/500/2 Hr1 de kaydedildi; P₀ hazır
47340/500/8104/896/8 H340/500/2 HP₁'in pc ve sp'si yüklendi
58340/500/2 (P₁)104/896/8 H340/500/2 Cr1 yüklendi; P₁ çalışıyor
611352/492/5104/896/8 H340/500/2 CP₁ birkaç komut çalıştırdı; PCB₁ artık güncel değil
712352/492/5104/896/8 H340/500/2 Cyeni kesme; P₀'a dönülecek
85352/492/5104/896/8 H352/492/2 CP₁'in pc ve sp'si kaydedildi
96352/492/5104/896/8 H352/492/5 Hr1 kaydedildi; P₁ hazır
107104/896/5104/896/8 H352/492/5 HPCB₀'dan pc ve sp yüklendi
118104/896/8 (P₀)104/896/8 C352/492/5 Hr1 = 8; P₀ kesildiği yerden sürer

İzlemede üç ara durum dikkat çeker. 4. adımda işlemcinin yazmaçları karışıktır: pc ve sp P₁'e, r1 hâlâ P₀'a aittir. Bu yarım durum, bağlam değiştirmenin kesintisiz yapılması gerektiğini gösterir. Gerçek çekirdekler bu sırada kesmeleri kapatır. 6. adımda ise PCB₁'deki değerler (340/500/2) güncel değildir; P₁'in gerçek durumu yalnız işlemcidedir. PCB, süreç çalışırken değil, işlemciden ayrılırken güncellenir. 11. adımda P₀ tam olarak kesildiği durumdan, pc = 104'ten devam eder ve kesildiğini fark etmez.

Model gerçeği sadeleştirir. Gerçek bir işlemcide onlarca genel yazmaç, kayan nokta ve vektör yazmaçları ile PSW vardır. Ayrıca adres alanı da değişir: işlemcinin sayfa tablosu yazmacı yeni sürecin tablosunu gösterecek biçimde yüklenir.

3.5.8Sınırlı doğrudan yürütme

Bağlam değiştirme bir soruyu cevapsız bırakır: işlemci bir kullanıcı programını çalıştırırken çekirdek nasıl olur da denetimi geri alır? Çekirdek de bir programdır ve işlemci aynı anda yalnız bir şey çalıştırır. Kullanıcı programı sonsuz bir döngüye girerse çekirdeğin kodu hiç çalışmayabilir.

Bir çözüm, kullanıcı programını bir yorumlayıcıda adım adım çalıştırmak olabilirdi; ama bu çok yavaştır. İşletim sistemlerinin kullandığı yol sınırlı doğrudan yürütmedir (limited direct execution). Program işlemcide doğrudan, tam hızla çalışır. Ama iki sınırı vardır: ayrıcalıklı işleri kendisi yapamaz, çekirdekten ister; işlemciyi de istediği kadar tutamaz, çekirdek onu zorla geri alır. Şekil 3.27 bu düzeni beş adımda gösterir.

  1. Açılışta (çekirdek modunda). İşletim sistemi donanıma hangi olayda hangi kodun çalışacağını bildirir: tuzak (trap) ve kesme tablosunu kurar. Ardından zamanlayıcıyı başlatır; zamanlayıcı birkaç milisaniyede bir kesme üretecektir. İki işlem de ayrıcalıklı komutlarla yapılır, kullanıcı programı bunları değiştiremez.
  2. A çalışıyor. Süreç A kullanıcı modunda doğrudan işlemcide çalışır; arada yorumlayıcı yoktur. Dosya açmak gibi ayrıcalıklı bir iş için sistem çağrısıyla tuzağa düşmek zorundadır.
  3. Zamanlayıcı kesmesi. A sonsuz döngüde olsa bile kesme gelir. Donanım A'nın yazmaçlarını çekirdek yığınına kaydeder, çekirdek moduna geçer ve işletim sisteminin kesme işleyicisine atlar. Denetim artık işletim sistemindedir.
  4. Çizelgeleyici B'yi seçer. A'nın zaman dilimi dolduğu için işletim sistemi B'ye geçmeye karar verir. switch() yordamı A'nın bağlamını PCB(A)'ya yazar, B'ninkini yükler ve B'nin çekirdek yığınına geçer.
  5. Tuzaktan dönüş. return-from-trap komutu, B sanki kesildiği yerden dönüyormuş gibi B'nin yazmaçlarını yükler ve kullanıcı moduna geçer. Artık B çalışır.
İS (çekirdek modu)DonanımProgram (kullanıcı modu)açılışçalışmatuzak tablosunu kurişleyici adreslerini saklazamanlayıcıyı başlatbirkaç ms’de bir kesme üretA çalışıyor (doğrudan CPU’da)zamanlayıcı kesmesiyazmaçlar(A) → çekirdek yığınıçizelgeleyici B’yi seçerswitch(): A → PCB(A), B’yi yüklereturn-from-trapyazmaçlar(B), kullanıcı moduB çalışıyor
Şekil 3.27: Sınırlı doğrudan yürütme. Açılışta işletim sistemi tuzak tablosunu ve zamanlayıcıyı kurar. A doğrudan işlemcide çalışırken zamanlayıcı kesmesi denetimi işletim sistemine verir; switch() A'dan B'ye geçer ve tuzaktan dönüşle B çalışmaya başlar.

Zamanlayıcı kesmesi bu düzenin bel kemiğidir. Kesme olmasaydı işletim sistemi denetimi ancak süreç kendi isteğiyle bir sistem çağrısı yaptığında geri alabilirdi. Bu yaklaşıma işbirlikçi çok görevlilik (cooperative multitasking) denir ve Windows 3.x ile eski Mac OS sürümleri böyle çalışırdı. Bu sistemlerde sonsuz döngüye giren tek bir program bütün makineyi kilitlerdi; çözüm makineyi yeniden başlatmaktı.

3.5.9Bağlam değiştirmenin maliyeti

Bağlam değiştirme bedavaya gelmez. İşlemci başka bir sürece geçerken eski sürecin durumunu kaydetmeli, yenisininkini yüklemelidir ve bu süre boyunca yararlı iş yapılmaz. PCB ne kadar büyük ve işletim sistemi ne kadar karmaşıksa geçiş o kadar uzun sürer. Maliyet iki türlüdür:

  • Doğrudan maliyet: yazmaçların kaydedilip yüklenmesi ve çizelgeleyici kodunun çalışması. Güncel Linux'ta tipik olarak birkaç mikrosaniyedir.
  • Dolaylı maliyet: önbelleğin ve TLB'nin (translation lookaside buffer, adres çevirilerini tutan küçük önbellek) “soğuması”. Yeni süreç ilk anlarda verisini önbellekte bulamaz ve çok sayıda önbellek ıskası yaşar. Bu dolaylı maliyet çoğu zaman doğrudan maliyetten büyüktür.

Donanım bu maliyeti azaltmak için yardım eder. Bazı işlemcilerde birden çok yazmaç kümesi bulunur; geçiş, kümeyi gösteren bir işaretçinin değiştirilmesinden ibaret olur. TLB girdilerine eklenen adres alanı etiketi (ARM'da ASID, x86'da PCID) ise her geçişte TLB'yi boşaltma zorunluluğunu kaldırır: farklı süreçlerin girdileri etiketleriyle ayırt edilir.

Bağlam değiştirmenin maliyeti, zaman dilimi seçiminde doğrudan rol oynar. Dilim çok kısa seçilirse işlemcinin büyük kısmı geçişlere gider; çok uzun seçilirse süreçler uzun süre sıra bekler.

Örnek 3.5

Bir bağlam değiştirme s = 0,05 ms sürüyor. Hazır kuyruğunda sırayla çalışan 3 CPU'ya bağlı süreç var ve her biri q = 4 ms'lik dilimini tam kullanıyor. (a) İşlemci zamanının yüzde kaçı geçişlere gider? (b) Bir süreç iki dilimi arasında en çok ne kadar bekler?

  1. Bir döngüyü belirle. Her dilimin ardından bir geçiş gelir. Yani her q + s = 4,05 ms'lik aralığın 0,05 ms'si ek yüktür.
  2. (a) Ek yük oranı s / (q + s) = 0,05 / 4,05 ≈ 0,0123, yani yaklaşık %1,23'tür.
  3. (b) Süreç A dilimini bitirince bir geçiş olur, B çalışır; bir geçiş, C çalışır; bir geçiş daha ve A yeniden başlar. A'nın beklediği süre 3 geçiş ve 2 dilimdir: 3 · 0,05 + 2 · 4 = 8,15 ms.
  4. Genelleme. k süreç için bekleme k · s + (k − 1) · q'dur. Dilim küçüldükçe bekleme kısalır ama ek yük oranı büyür.

3.5.10Kesmenin ele alınması

Bağlam değiştirmenin çoğu bir kesmeyle başlar. Tanenbaum, bir G/Ç kesmesi geldiğinde işletim sisteminin en alt katmanında olanları sekiz adımda özetler:

  1. Donanım program sayacını, PSW'yi ve birkaç yazmacı yığına kaydeder. Kesme isteği bir komutun ortasında değil, komut sınırında kabul edilir. İşlemci çekirdek moduna geçer.
  2. Donanım kesme vektöründen yeni program sayacını yükler. Kesme vektörü, her kesme türü için işleyicinin adresini tutan tablodur. İşletim sistemi bu tabloyu açılışta doldurur.
  3. Assembly yordamı yazmaçları kaydeder. Donanımın kaydetmediği yazmaçlar o anki sürecin PCB'sine ya da çekirdek yığınına yazılır.
  4. Assembly yordamı yeni bir yığın hazırlar. Kesme işleyicisi kullanıcının yığınında değil, çekirdek yığınında çalışır.
  5. C ile yazılmış kesme hizmet yordamı çalışır. Genellikle aygıttan gelen veriyi okur ve tampona koyar. Bu veriyi bekleyen bir süreç varsa onu hazır durumuna geçirir.
  6. Çizelgeleyici sıradaki süreci seçer. Uyanan süreç kesilen süreçten daha öncelikliyse o seçilebilir. Bu durumda kesme bir bağlam değiştirmeye dönüşür.
  7. C yordamı assembly koduna döner.
  8. Assembly yordamı seçilen süreci başlatır. Sürecin yazmaçları ve bellek eşlemesi yüklenir ve kullanıcı moduna dönülür.

Adımlardaki iş bölümü dikkat çekicidir. İlk iki adımı donanım yapar, çünkü o anda hiçbir yazılımın çalışabileceği güvenli bir ortam yoktur. Yazmaçlara doğrudan dokunan 3., 4. ve 8. adımlar assembly ile yazılır. Asıl iş ise taşınabilir C kodunda yapılır. Kesmeyle başlayan bu yol, kesilen sürecin kesildiğini fark etmeden devam edebilmesini sağlar; ya da çizelgeleyici karar verirse, işlemciyi bir başka sürece verir.

3.6Süreçler arası iletişim

Süreçlerin ayrı adres alanları, birinin hatasının ötekine bulaşmasını önler. Ama aynı ayrılık, süreçlerin birlikte çalışmasını zorlaştırır: bir süreç ötekinin değişkenini okuyamaz. İşbirliği yapan süreçler veri alışverişi için işletim sisteminin sunduğu araçları kullanır. Bu kesim bu araçların iki temel modelini, paylaşımlı belleği ve ileti geçirmeyi, ardından UNIX'in boru ve soketlerini anlatır.

3.6.1Bağımsız ve işbirlikçi süreçler

Bir süreç, başka süreçleri etkilemiyor ve onlardan etkilenmiyorsa bağımsızdır. Veri paylaşan ya da birbirinin işini etkileyen süreçler ise işbirlikçidir. Süreçlerin işbirliği yapmasının dört nedeni vardır:

  • Bilgi paylaşımı: Birden çok uygulama aynı veriye erişmek isteyebilir; örneğin bir panodaki kopyalanmış metne.
  • Hızlanma: Büyük bir iş parçalara bölünür ve parçalar farklı çekirdeklerde paralel işlenir.
  • Modülerlik: Sistem, her biri tek bir işi yapan küçük süreçlerden kurulur.
  • Güvenlik ve yalıtım: Riskli işler ayrı, yetkisi kısıtlı süreçlerde yapılır; Chrome'un oluşturucu süreçleri bunun örneğidir.
Tanım 3.13

Süreçler arası iletişim (interprocess communication, IPC), işbirlikçi süreçlerin veri alışverişi yapmasını ve eylemlerini eşgüdümlemesini sağlayan mekanizmaların genel adıdır. İki temel modeli vardır: paylaşımlı bellek ve ileti geçirme.

IPC üç soruya cevap vermelidir. Birincisi açıktır: bir süreç ötekine bilgiyi nasıl iletir? Öteki ikisi daha incedir. İkinci soru, süreçlerin birbirinin yoluna çıkmamasıdır. İki rezervasyon süreci aynı anda son boş koltuğu görüp ikisi de satarsa aynı koltuğa iki bilet kesilir. Üçüncü soru, bağımlılıklarda doğru sıranın sağlanmasıdır. Nişan almadan ateş edilmemeli; bir veri üretilmeden tüketilmemelidir.

Son iki soru eşgüdüm (synchronization) gerektirir. Paylaşılan veriye denetimsiz erişim, sonucun süreçlerin hangi sırayla çalıştığına bağlı olmasına yol açar. Bu duruma yarış durumu (race condition) denir. Aynı sorular, aynı adres alanını paylaşan iş parçacıkları için de geçerlidir.

3.6.2Paylaşımlı bellek ve ileti geçirme

İki model, verinin süreçler arasında nereden geçtiği bakımından ayrılır (Şekil 3.28).

(a) paylaşımlı bellek(b) ileti geçirmesüreç Apaylaşımlı bölgeiki adres alanına da eşlenirsüreç Bçekirdekyazsüreç Asüreç Bçekirdek: ileti kuyruğum₀m₁m₂
(a) paylaşımlı bellek(b) ileti geçirmesüreç Apaylaşımlı bölgeiki adres alanına da eşlenirsüreç Bçekirdeksüreç Asüreç Bçekirdek: ileti kuyruğum₁m₂m₃receive() → m₀
Şekil 3.28: İki IPC modeli. (a) Paylaşımlı bellek: çekirdek bir bölgeyi iki sürecin adres alanına eşler, A bölgeye doğrudan yazar. (b) İleti geçirme: çekirdek iletileri kendi kuyruğunda tutar; B receive() ile sıradaki iletiyi (m₀) alır.

Paylaşımlı bellekte çekirdek bir bellek bölgesi oluşturur ve onu iki sürecin adres alanına eşler. Sistem çağrısı yalnız kurulumda gerekir. Sonrasında A bölgeye sıradan bir bellek yazmasıyla yazar, B sıradan bir bellek okumasıyla okur. Çekirdek bu alışverişte hiç devreye girmez; bu yüzden paylaşımlı bellek çok hızlıdır. Bedeli şudur: kimin ne zaman yazacağını çekirdek denetlemez. Eşgüdümü süreçler kendileri, kilit ya da semafor gibi araçlarla sağlamalıdır.

İleti geçirmede A, send(m) çağrısıyla bir ileti gönderir; çekirdek iletiyi kendi kuyruğuna kopyalar. B, receive() çağrısıyla sıradaki iletiyi alır; çekirdek iletiyi bu kez B'nin belleğine kopyalar. Her iletide en az bir sistem çağrısı ve iki kopyalama vardır; bu yüzden ileti geçirme paylaşımlı bellekten yavaştır. Kazancı ise eşgüdümün çekirdekte olmasıdır: boş kuyruktan okumak isteyen süreç bekletilir, ileti yarım okunmaz. Ayrıca aynı arayüz ağ üzerinden de çalışabilir; paylaşımlı bellek ise yalnız aynı makinedeki süreçler için mümkündür.

Tablo 3.13: Paylaşımlı bellek ile ileti geçirmenin karşılaştırması.
ÖlçütPaylaşımlı bellekİleti geçirme
Çekirdeğin rolüyalnız kurulumdaher iletide
Kopyalamayokiletinin çekirdeğe ve alıcıya kopyalanması
Eşgüdümsüreçlerin sorumluluğuçekirdek sağlar
Uygun olduğu durumaynı makinede büyük veriküçük iletiler; dağıtık sistemler

3.6.3POSIX paylaşımlı bellek

POSIX standardı paylaşımlı belleği bir dosya gibi adlandırılan bir nesneyle kurar. Aşağıdaki kodda iki ayrı sürecin parçaları vardır: üstteki üretici, alttaki tüketicidir.

/* üretici */
int fd = shm_open("/os03", O_CREAT|O_RDWR, 0600);
ftruncate(fd, 4096);
char *p = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
               MAP_SHARED, fd, 0);
strcpy(p, "merhaba");   /* doğrudan yaz */

/* tüketici (başka süreç) */
int fd = shm_open("/os03", O_RDONLY, 0);
char *p = mmap(NULL, 4096, PROT_READ,
               MAP_SHARED, fd, 0);
printf("%s\n", p);
shm_unlink("/os03");    /* sil */

Kurulum üç adımdır. shm_open /os03 adlı bir paylaşımlı bellek nesnesi oluşturur ya da var olanı açar ve bir dosya tanımlayıcısı döndürür. Linux'ta bu nesne /dev/shm altında bir dosya olarak görünür. ftruncate nesnenin boyutunu 4096 bayta ayarlar. mmap ise nesneyi sürecin adres alanına eşler ve eşlemenin başlangıç adresini döndürür. MAP_SHARED bayrağı önemlidir: bu bölgeye yapılan yazmalar aynı nesneyi eşleyen bütün süreçlerde görünür.

Kurulumdan sonra paylaşımlı bellek sıradan bellektir. Üretici strcpy ile "merhaba"'yı doğrudan yazar; arada hiçbir sistem çağrısı yoktur. Tüketici aynı nesneyi salt okunur açar, eşler ve printf ile okur. İşi bitince shm_unlink nesnenin adını siler; nesne, onu eşlemiş son süreç de bıraktığında yok olur.

üretici süreçkodverip = 0x7f3a…yığıntüketici süreçkodverip = 0x7f91…yığın/os03 (4096 B)"merhaba"fiziksel bellek (/dev/shm)mmapmmap
Şekil 3.29: İki süreç aynı fiziksel bölgeyi (/os03, 4096 bayt) kendi adres alanlarında farklı sanal adreslere eşler: üreticide p = 0x7f3a…, tüketicide p = 0x7f91…. İkisi de aynı "merhaba" verisini görür.

Şekil 3.29 ince bir noktayı da gösterir: iki süreçteki p değerleri farklıdır. Bölge her sürecin adres alanında farklı bir sanal adrese eşlenebilir. Bu yüzden paylaşımlı bölgeye işaretçi saklamak tehlikelidir; bir süreçte geçerli olan adres ötekinde anlamsız olabilir. Paylaşımlı veri yapıları işaretçi yerine bölgenin başından itibaren uzaklık (offset) kullanır.

Sık yapılan hata

Paylaşımlı belleğin eşgüdümü de kendiliğinden sağladığını sanmak. Örnek kodda tüketici, üretici yazmadan önce çalışırsa bölgeyi boş görür ve boş bir dize yazdırır. Daha kötüsü, üretici yazarken tüketici okursa yarım bir veri görebilir. Gerçek kodda bölgeye erişim bir semafor ya da kilitle düzenlenmelidir. Bunun eski bir arayüzü de vardır: System V'nin shmget ve shmat çağrıları. Mantık aynıdır, yalnız nesneler dosya adı yerine sayısal bir anahtarla adlandırılır.

3.6.4İleti geçirme tasarımı

İleti geçirmenin iki temel işlemi vardır: send(mesaj) ve receive(mesaj). İki süreç haberleşmeden önce aralarında bir iletişim bağı kurulmalıdır. Bu bağın tasarımında dört soru öne çıkar.

Tablo 3.14: İleti geçirme tasarımının boyutları.
BoyutSeçenekler
Fiziksel bağpaylaşımlı bellek, donanım veri yolu, ağ
Adlandırmadoğrudan (sürecin adı) · dolaylı (posta kutusu ya da port)
Eş zamanlılıkbloklayan (senkron): gönderen ileti alınana kadar, alıcı ileti gelene kadar bekler · bloklamayan (eş zamansız): çağrı hemen döner
Tamponlamasıfır kapasite (gönderen bekler: buluşma) · sınırlı kapasite (kuyruk doluysa gönderen bekler) · sınırsız kapasite (gönderen hiç beklemez)

Tamponlama, bağın kaç iletiyi bekletebileceğidir. Sıfır kapasiteli bir bağda iletiyi saklayacak yer yoktur. Gönderen, alıcı receive çağırana kadar bekler ve ileti iki süreç buluştuğu anda doğrudan aktarılır. Sınırlı kapasiteli bir bağda kuyrukta N ileti bekleyebilir; kuyruk doluysa gönderen bekler. Sınırsız kapasite kavramsaldır; gerçekte bellek her zaman sınırlıdır.

Tanım 3.14

Buluşma (rendezvous), hem send'in hem receive'in bloklayan olduğu ileti geçirmedir. Gönderen, alıcı iletiyi alana kadar; alıcı, ileti gelene kadar bekler. Aktarım iki süreç aynı anda hazır olduğunda yapılır.

Buluşma, üretici–tüketici problemini kendiliğinden çözer. Üretici, tüketici bir iletiyi almadan yenisini gönderemez; tüketici de üretilmemiş bir iletiyi alamaz. Programcının ayrıca kilit ya da sayaç tutmasına gerek kalmaz. Gerçek sistemlerdeki örnekler arasında POSIX ileti kuyrukları (mq_send, mq_receive), macOS'un temelindeki Mach çekirdeğinin portları ve Windows'un ALPC (Advanced Local Procedure Call) mekanizması vardır.

Örnek 3.6

Bir ileti kuyruğunun kapasitesi 2'dir; send ve receive bloklayandır. Tüketici henüz hiç receive çağırmamışken üretici art arda m₁, m₂ ve m₃ iletilerini göndermek istiyor. Ne olur?

  1. send(m₁): Kuyruk boştur; m₁ kuyruğa kopyalanır ve çağrı hemen döner. Kuyrukta 1 ileti vardır.
  2. send(m₂): Yer vardır; m₂ eklenir. Kuyruk artık doludur (2/2).
  3. send(m₃): Kuyrukta yer yoktur; üretici bekliyor durumuna geçer.
  4. Tüketici receive() çağırır: m₁'i alır (ileti kuyruğu FIFO'dur). Bir yer boşalır; çekirdek bekleyen üreticiyi uyandırır ve m₃ kuyruğa eklenir. Kuyrukta m₂ ve m₃ vardır.

Kapasite sıfır olsaydı üretici daha ilk send'de bekler, tüketici receive çağırınca m₁ doğrudan aktarılırdı.

3.6.5Doğrudan ve dolaylı iletişim

Adlandırma sorusu, gönderenin iletiyi kime yolladığını nasıl söylediğidir (Şekil 3.30).

doğrudan: send(Q, m)PQtek bağlantı · bir çift süreçdolaylı: send(A, m)posta kutusu Aport / kuyrukPQRçok süreç · çok bağlantı
Şekil 3.30: Solda doğrudan iletişim: P, Q'ya adıyla gönderir; bağlantı tek bir süreç çiftine aittir. Sağda dolaylı iletişim: P, Q ve R, A posta kutusu üzerinden haberleşir; bir bağlantı çok sürece, bir çift çok bağlantıya ait olabilir.

Doğrudan iletişimde her süreç karşı tarafı adıyla anar: send(P, m) P'ye gönderir, receive(Q, m) Q'dan alır. Bağlantı otomatik kurulur ve tam olarak bir süreç çiftine aittir. Her çift arasında tek bir bağlantı vardır ve bağlantı genellikle çift yönlüdür. Bu yaklaşımın zayıflığı, süreç adlarının koda gömülmesidir: bir süreç yeniden başlayıp yeni bir kimlik alırsa onu anan bütün kodlar değişmelidir.

Dolaylı iletişimde iletiler bir posta kutusuna (mailbox) ya da porta gönderilir ve oradan alınır: send(A, m), receive(A, m). Her posta kutusunun tekil bir kimliği vardır; aynı kutuyu paylaşan süreçler haberleşebilir. Bir bağlantı ikiden çok sürece ait olabilir ve bir süreç çifti birden çok kutu üzerinden haberleşebilir. Bağlantı tek ya da çift yönlü olabilir. Bu esneklik bir soruyu da doğurur: P bir kutuya ileti koyduğunda Q ve R aynı anda receive çağırırsa iletiyi kim alır? Sistemler bunu ya aynı anda tek alıcıya izin vererek ya da alıcıyı kendisi seçerek çözer.

3.6.6Borular

Boru (pipe), UNIX'in en eski ve en çok kullanılan IPC aracıdır. Bir boru, çekirdekte tutulan ve iki ucu olan bir FIFO tampondur. Bir uçtan yazılan baytlar, aynı sırayla öteki uçtan okunur (Şekil 3.31).

ebeveynfd[0] kapalı · fd[1] yazarçocukfd[0] okur · fd[1] kapalıboru: çekirdekte FIFO tamponwrite(fd[1])read(fd[0])yazma ucu → okuma ucu (tek yönlü)
Şekil 3.31: Sıradan boru: ebeveyn fd[1] yazma ucuna yazar, çocuk fd[0] okuma ucundan okur. Veri çekirdekteki FIFO tampondan geçer ve tek yönlü akar. Her süreç kullanmadığı ucu kapatır.

Bir boru tasarlanırken dört soru sorulur. Akış tek yönlü mü (simplex), çift yönlü mü? Çift yönlüyse iki yön aynı anda mı kullanılabilir (tam çift yönlü, full duplex) yoksa sırayla mı (yarı çift yönlü, half duplex)? Haberleşen süreçler arasında ebeveyn–çocuk ilişkisi gerekli mi? Boru ağ üzerinden kullanılabilir mi? İki boru türü bu sorulara farklı cevaplar verir.

Tablo 3.15: Sıradan ve adlandırılmış borular.
Sıradan (adsız)Adlandırılmış (FIFO)
Erişimyalnız oluşturan süreç ve çocuklarıdosya adını bilen her süreç
Yöntek yönlüUNIX: yarı çift yönlü; Windows: tam çift yönlü
Ömürsüreçler kapatınca bitermkfifo ile oluşur; dosya sisteminde kalır
AğhayırUNIX: hayır; Windows: evet

Sıradan borunun adı yoktur; ona yalnız dosya tanımlayıcılarıyla erişilir. Bu tanımlayıcılar da yalnız fork ile miras yoluyla başka süreçlere geçebilir. Bu yüzden sıradan boru akraba süreçler arasında kullanılır. Adlandırılmış boru ise dosya sisteminde bir ad taşır (mkfifo /tmp/kanal). Birbirini tanımayan iki süreç bu adı açarak haberleşebilir. UNIX'te FIFO'lar her iki yönde kullanılabilse de pratikte her yön için ayrı bir FIFO açılır, çünkü iki süreç aynı FIFO'ya yazarsa veriler karışır.

3.6.7Kod izleme: pipe() ve fork()

Aşağıdaki program, ebeveynin bir boru üzerinden çocuğa "merhaba" göndermesini gösterir:

int main(void) {
  int fd[2];
  char buf[32];
  pipe(fd);
  pid_t pid = fork();
  if (pid == 0) {   /* çocuk: okur */
    close(fd[1]);
    ssize_t n = read(fd[0], buf, sizeof buf - 1);
    buf[n] = '\0';
    printf("çocuk aldı: %s\n", buf);
    close(fd[0]);
    return 0;
  }
  close(fd[0]);     /* ebeveyn: yazar */
  write(fd[1], "merhaba", 7);
  close(fd[1]);
  wait(NULL);
  return 0;
}

pipe(fd) çağrısı bir boru oluşturur ve iki tanımlayıcı döndürür: fd[0] okuma ucu, fd[1] yazma ucudur. 0, 1 ve 2 numaraları standart giriş, çıkış ve hata akışlarına ait olduğu için en küçük boş numaralar 3 ve 4'tür. Ardından fork gelir ve çocuk iki ucu da miras alır. Böylece borunun iki ucu da iki süreçte açıktır. Her süreç kullanmayacağı ucu kapatır: çocuk yazma ucunu, ebeveyn okuma ucunu. Ebeveynin PID'i 1234, çocuğunki 1235 olsun.

Tablo 3.16: pipe() + fork() izlemesi. “Açık uçlar” sütununda E ebeveyni, Ç çocuğu gösterir.
AdımSatırOlayAçık uçlarTamponÇıktı
14pipe(fd): fd[0] = 3, fd[1] = 4E: 3, 4boş
25fork: çocuk uçları miras alırE: 3, 4 · Ç: 3, 4boş
37çocuk yazma ucunu kapatırE: 3, 4 · Ç: 3boş
48çocuk read: boru boş → bekliyorE: 3, 4 · Ç: 3boş
514ebeveyn okuma ucunu kapatırE: 4 · Ç: 3boş
615ebeveyn 7 bayt yazar; çocuk uyanırE: 4 · Ç: 3merhaba
716–17ebeveyn yazma ucunu kapatır, wait ile beklerÇ: 3merhaba
88read 7 döndürür; n = 7Ç: 3boş
99–10buf[7] = '\0'; çocuk yazdırırÇ: 3boşçocuk aldı: merhaba
1011–12, 17–18çocuk okuma ucunu kapatıp çıkar; ebeveyn wait'ten döner ve biter——

İzlemedeki adımların sırası çizelgelemeye göre değişebilir. Örneğin ebeveyn, çocuk read çağırmadan önce yazabilir; o zaman çocuk hiç beklemeden veriyi alır. Çıktı her sırada aynıdır, çünkü yalnız çocuk yazdırır ve veri tek bir yoldan akar.

İzlemenin üç ayrıntısı boruların kurallarını gösterir. Birincisi, boş bir borudan okuyan süreç bekletilir (4. adım). İkincisi, PIPE_BUF'tan (Linux'ta 4096 bayt) küçük ya da ona eşit yazmalar bölünmez: ya bütünüyle yazılır ya hiç. Böylece aynı boruya yazan iki sürecin küçük iletileri birbirine karışmaz. Üçüncüsü, read en çok istenen kadar bayt döndürür ama daha azını da döndürebilir. Program 31 bayt istemiştir (sizeof buf - 1), boruda 7 bayt vardır ve read 7 döndürür. Dönen değer, dizenin sonuna '\0' koymak için kullanılır.

Yazma ucunun kapatılması da önemlidir. read, boru boşken ve hiçbir süreçte açık yazma ucu kalmamışken 0 döndürür; bu dosya sonu (EOF) demektir. Yazma ucu bir yerde açık kaldıkça okuyucu “belki daha veri gelir” diye bekler.

Sık yapılan hata

Kullanılmayan boru uçlarını kapatmamak. Çocuk EOF'a kadar okuyan bir döngü kullansın ve ebeveyn close(fd[1]) çağırmasın. Bu durumda boru boşaldığında bile bir yazma ucu açık kalır ve read hiçbir zaman 0 döndürmez. Çocuk sonsuza dek bekler, ebeveyn de wait'te çocuğu bekler: program kilitlenir. Çocuğun kendi yazma ucunu kapatmaması da aynı sonucu doğurur, çünkü o uç da “açık bir yazıcı” sayılır. Ters yönde de bir kural vardır: okuma ucu tamamen kapalı bir boruya yazan süreç SIGPIPE sinyali alır ve varsayılan olarak sonlanır.

3.6.8Kabukta boru hattı

Kabukta ls | wc -l yazıldığında ls'nin çıktısı wc'nin girdisi olur ve ekranda dizindeki dosya sayısı görünür. Kabuk bunu, boruyu yönlendirmeyle birleştirerek yapar:

int fd[2]; pipe(fd);
if (fork() == 0) { dup2(fd[1], 1); close(fd[0]); close(fd[1]);
                   execlp("ls", "ls", NULL); }
if (fork() == 0) { dup2(fd[0], 0); close(fd[0]); close(fd[1]);
                   execlp("wc", "wc", "-l", NULL); }
close(fd[0]); close(fd[1]); wait(NULL); wait(NULL);
bashpipe(), 2 × fork(), waitlsdup2(fd[1], 1) → stdoutwc -ldup2(fd[0], 0) → stdinboru (64 KiB tampon)her iki süreç de kullanmadığı uçları kapatmalı; aksi hâlde wc EOF göremez
Şekil 3.32: ls | wc -l boru hattı. Kabuk bir boru ve iki çocuk oluşturur; ls'nin standart çıkışı borunun yazma ucuna, wc'nin standart girişi okuma ucuna yönlendirilir. Linux'ta boru tamponu varsayılan olarak 64 KiB'dir.

Kabuk önce bir boru oluşturur, sonra iki kez fork çağırır. Birinci çocuk dup2(fd[1], 1) ile standart çıkışını borunun yazma ucuna bağlar. dup2(eski, yeni), yeni numaralı tanımlayıcıyı eski'nin kopyası yapar; yeni açıksa önce onu kapatır. Çocuk ardından iki boru tanımlayıcısını da kapatır, çünkü işi artık 1 numara üzerinden görecektir. Sonra ls'ye dönüşür. İkinci çocuk aynı işi okuma ucu ve standart giriş (0) için yapar ve wc -l'ye dönüşür. Kabuk kendi uçlarını kapatır ve iki çocuğu bekler.

Burada da kapatma kuralı belirleyicidir. Kabuk ya da ikinci çocuk yazma ucunu açık tutsaydı, ls bitip kendi ucunu kapattığında bile boruda açık bir yazıcı kalırdı. wc EOF göremez ve satır saymayı hiç bitiremezdi. Sunumdaki koddan hata denetimleri kısalık için çıkarılmıştır; gerçek bir kabuk her çağrının dönüş değerini denetler.

Bu tasarımın gücü, programların boruyu fark etmemesidir. ls standart çıkışa, wc standart girişe yazıp okur; onlar için terminal, dosya ya da boru arasında fark yoktur. Böylece birbirinden habersiz küçük programlar, kabukta | ile zincirlenerek büyük işler yapabilir. UNIX felsefesinin “her program tek bir işi iyi yapsın” ilkesi bu altyapıya dayanır.

3.6.9Soketler

Borular aynı makinedeki süreçleri birleştirir. Farklı makinelerdeki süreçlerin haberleşmesi için soketler kullanılır.

Tanım 3.15

Soket (socket), iletişimin bir uç noktasıdır. Ağ soketi bir IP adresi ile bir port numarasının birleşimiyle tanımlanır; örneğin 161.25.19.8:80. İletişim her zaman bir soket çifti arasında olur.

istemci 146.86.5.20soket146.86.5.20:1625web sunucusu 161.25.19.8soket161.25.19.8:80TCP bağlantısıgeçici port ↔ iyi bilinen port
Şekil 3.33: Soketlerle iletişim. İstemci (146.86.5.20) geçici bir port (1625) üzerinden web sunucusunun (161.25.19.8) iyi bilinen 80 numaralı portuna TCP bağlantısı kurar.

IP adresi makineyi, port ise makinedeki hizmeti ayırt eder. Bir web sunucusu 80 numaralı portu dinler; istemci bağlanırken işletim sistemi istemciye geçici bir port (şekilde 1625) atar. Bağlantıyı 146.86.5.20:1625 ve 161.25.19.8:80 soketlerinden oluşan çift tanımlar. Aynı makineden aynı sunucuya açılan ikinci bir bağlantı başka bir geçici port alır ve iki bağlantı karışmaz.

1024'ün altındaki portlar iyi bilinen hizmetlere ayrılmıştır: SSH 22, HTTP 80, HTTPS 443. Bu portlarda sunucu olarak dinlemeye başlamak (bind) yetki gerektirir; sıradan bir kullanıcının sahte bir SSH sunucusu açması böylece önlenir. 127.0.0.1 adresi (IPv6'da ::1) geri döngü (loopback) adresidir ve aynı makineyi gösterir; aynı makinedeki süreçler ağ soketleriyle de haberleşebilir.

Soketler iki temel iletişim türü sunar. TCP bağlantılıdır: veri sıralı, güvenilir bir bayt akışı olarak iletilir; kaybolan paket yeniden gönderilir. UDP bağlantısızdır: her datagram ayrı gönderilir ve ulaşacağı garanti edilmez; buna karşılık daha az ek yük taşır. Aynı makinede ise UNIX alan soketleri (AF_UNIX) daha hızlıdır, çünkü ağ yığınından geçmezler; dosya sisteminde bir yol adıyla adlandırılırlar. Docker, systemd ve X/Wayland görüntü sunucuları süreçlerle bu soketler üzerinden haberleşir.

3.7Mobil sistemlerde çoklu görev

Masaüstünde açık bırakılan bir uygulama, kullanıcı başka pencereye geçse de çalışmayı sürdürür. Telefonlarda durum farklıdır. Bellek sınırlıdır, takas alanı ya yoktur ya küçüktür; en önemlisi pil sınırlıdır. Arka planda sürekli işlemci kullanan bir uygulama pili saatler içinde bitirebilir. Bu yüzden mobil işletim sistemleri arka plandaki süreçleri sıkı biçimde sınırlar.

iOS ve iPadOS. İlk iPhone yazılımı (2007) yalnız tek bir kullanıcı uygulamasının çalışmasına izin veriyordu; ön plandaki uygulama dışındakiler kapatılırdı. iOS 4 (2010) sınırlı bir çoklu görev getirdi. Bugün de arka planda yalnız belirli işler sürebilir: ses çalma, konum izleme, VoIP (internet üzerinden sesli görüşme) ve BGTaskScheduler ile planlanmış görevler. Bu izinlerden birini kullanmayan bir uygulama arka plana alınınca kısa süre sonra askıya alınır: süreç bellekte kalır ama işlemci verilmez. Bellek gerektiğinde sistem askıdaki uygulamaları uyarı vermeden sonlandırabilir; kullanıcı geri döndüğünde uygulama durumunu yeniden kurar.

Android. Android baştan beri arka plan süreçlerine izin verir. Uzun süren işler hizmet (service) bileşeniyle yürütülür; bir müzik çalar, arayüzü askıdayken bile hizmeti sayesinde çalmayı sürdürür. Ancak Android 8'den (2017) bu yana arka plan kısıtları vardır. Uzun bir iş ya kullanıcıya bildirim gösteren bir ön plan hizmeti olarak çalışmalı ya da sistemin uygun bir anda çalıştırdığı WorkManager görevlerine bölünmelidir.

ön plan (foreground)kullanıcının etkileşimde olduğu etkinlikgörünür (visible)ekranda ama odakta değilhizmet (service)örn. arka planda senkronizasyonönbellekteki (cached)arka plan + boş süreçlerbellek azalınca önce alttakiler sonlandırılır (lmkd)
Şekil 3.34: Android'de süreçlerin önem sırası: ön plan, görünür, hizmet ve önbellekteki süreçler. Bellek azalınca lmkd en alttaki gruptan başlayarak süreçleri sonlandırır.

Android bellek darlığında hangi süreci sonlandıracağına bir önem sıralamasıyla karar verir (Şekil 3.34). En üstte kullanıcının etkileşimde olduğu ön plan süreci vardır. Onu ekranda görünen ama odakta olmayan görünür süreçler ve arka planda iş yapan hizmet süreçleri izler. En altta önbellekteki (cached) süreçler durur. Eski belgelerde ayrı ayrı anılan “arka plan” ve “boş” süreçler bugün bu grupta toplanır. Bellek azalınca lmkd (low memory killer daemon) en alttan başlayarak süreçleri sonlandırır. Önbellekteki bir süreci öldürmek kullanıcıya görünmez; uygulama yeniden açıldığında baştan başlatılır.

İki sistemin yaklaşımı farklı olsa da gerekçe aynıdır: sınırlı bellek ve pil. Masaüstünde “süreç kendi isteğiyle biter” varsayımı mobilde geçerli değildir. Mobil uygulamalar her an askıya alınabileceklerini ya da sonlandırılabileceklerini varsayarak yazılır ve durumlarını düzenli olarak kaydeder.

3.8Özet

  • Süreç, yürütülmekte olan bir program örneğidir. Program diskte duran pasif bir dosyadır; süreç ise adres alanı, işlemci bağlamı (program sayacı, yazmaçlar) ve kaynakları (açık dosyalar, kimlikler) olan etkin bir varlıktır. Aynı programdan birçok süreç oluşabilir.
  • Bir sürecin adres alanı alttan üste metin, veri, BSS, öbek ve yığın bölgelerinden oluşur; en üstte kullanıcı modunda erişilemeyen çekirdek alanı vardır. Öbek yukarı, yığın aşağı büyür. İlk değerli global ve static değişkenler veride, ilk değersizler BSS'de, yerel değişkenler yığında, malloc ile ayrılan alan öbektedir.
  • Tek çekirdek süreçler arasında hızla geçerek sözde paralellik yaratır; buna çoklu programlama denir. Eş zamanlılık işlerin aynı aralıkta ilerlemesi, paralellik aynı anda yürümesidir ve birden çok çekirdek gerektirir.
  • fork() çağıran sürecin kopyasını oluşturur; ebeveyne çocuğun PID'ini, çocuğa 0, hatada ebeveyne −1 döndürür. Kopyalama yazınca kopyalama ile tembel yapılır. Art arda n koşulsuz fork 2n süreç üretir. stdio tamponundaki yazılmamış veri fork ile çoğalır.
  • exec ailesi sürecin görüntüsünü yeni bir programla değiştirir; PID, ebeveyn ve kapatılmamış dosya tanımlayıcıları korunur. Başarılı exec geri dönmez. Altı fonksiyonun hepsi execve()'ye varır.
  • Kabuk her komutta fork → exec → wait döngüsünü çalıştırır. fork ile exec'in ayrı olması, çocuğun exec'ten önce kendi ortamını (yönlendirme, dizin, öncelik) hazırlamasını sağlar. open en küçük boş tanımlayıcı numarasını verir.
  • Süreç gönüllü olarak (exit) ya da istemsiz olarak (ölümcül hata, sinyal) sonlanır. Ebeveyne çıkış durumunun yalnız alttaki 8 biti ulaşır. SIGTERM yakalanabilir, SIGKILL yakalanamaz.
  • Zombi, bitmiş ama biçilmemiş süreçtir; PID tüketir. Yetim, ebeveyni ölmüş çalışan süreçtir; init/systemd (ya da bir alt-biçici) onu evlat edinir ve bitince biçer. Zombi birikmesinin çaresi wait/waitpid ya da SIGCHLD işleyicisidir.
  • Beş durumlu model: Yeni → Hazır ⇄ Çalışıyor → Bekliyor → Hazır … → Sonlandı. Bekliyor → Çalışıyor geçişi yoktur; zaman dilimi dolan süreç Hazır'a döner. Linux hazır ile çalışıyor durumunu R olarak birleştirir, beklemeyi S ve D olarak böler.
  • Çekirdek her süreci bir PCB ile izler; Linux'ta bu yapı task_struct'tır. PCB süreç, bellek ve dosya yönetimi bilgilerini taşır; program sayacı ve yazmaçlar sürecin bağlamıdır. Kuyruklar PCB'lerin bağlı listeleridir.
  • Bağımsız G/Ç bekleme modelinde CPU kullanımı 1 − pn'dir; her ek süreç bir öncekinden daha az kazandırır.
  • Bağlam değiştirme eski sürecin bağlamını kaydedip yenisininkini yükler ve saf ek yüktür; dolaylı maliyeti (önbellek ve TLB'nin soğuması) çoğu zaman doğrudan maliyetinden büyüktür. Sınırlı doğrudan yürütmede program işlemcide tam hızla çalışır; zamanlayıcı kesmesi denetimi işletim sistemine geri verir.
  • IPC'nin iki modeli vardır. Paylaşımlı bellek kurulumdan sonra çekirdeksiz ve çok hızlıdır ama eşgüdümü süreçlere bırakır. İleti geçirme her iletide çekirdeğe uğrar, eşgüdümü çekirdek sağlar ve ağ üzerinden de çalışır. Bloklayan send ve receive buluşma oluşturur.
  • Boru çekirdekteki tek yönlü bir FIFO tampondur. Boş borudan okuma bekletir; açık yazma ucu kalmayınca read 0 (EOF) döndürür. Kullanılmayan uçlar kapatılmalıdır. Kabuk boru hatlarını pipe, fork, dup2 ve exec ile kurar. Soket IP adresi ve porttan oluşan bir uç noktadır.
  • Mobil sistemler sınırlı bellek ve pil yüzünden arka plandaki süreçleri askıya alır ve gerekirse sonlandırır; Android bunu bir önem sıralamasına göre lmkd ile yapar.
Tablo 3.17: Bölümdeki temel UNIX çağrıları.
ÇağrıNe yapar?Dönüş değeri
fork()çağıran sürecin kopyasını oluştururebeveynde çocuğun PID'i, çocukta 0, hatada −1
exec…()süreçteki programı değiştirirbaşarıda dönmez; hatada −1
wait() / waitpid()bir çocuğun bitmesini bekler ve onu biçerbiten çocuğun PID'i; WNOHANG ile bitmiş yoksa 0
exit()tamponları boşaltıp süreci sonlandırırdönmez
kill()bir sürece sinyal gönderirbaşarıda 0, hatada −1
pipe()boru oluşturur: fd[0] okuma, fd[1] yazma ucubaşarıda 0, hatada −1
dup2(e, y)y tanımlayıcısını e'nin kopyası yapary

3.9Kavram listesi

Terimlerin İngilizce karşılıkları ve öteki bölümlerdeki kullanımları terim sözlüğünde bulunur.

3.10Kendini Dene

  1. Aşağıdaki parça çalıştırıldığında kaç satır X yazılır ve toplam kaç süreç oluşur? Süreç ağacı nasıldır?

    if (fork() == 0)
        fork();
    printf("X\n");
    Cevap

    Başta P0 vardır. İlk fork P1'i oluşturur. P0'da dönüş değeri çocuğun PID'idir, yani 0 değildir; koşul yanlıştır ve P0 doğrudan printf'e geçer. P1'de dönüş 0'dır; koşul doğrudur ve P1 ikinci fork ile P2'yi oluşturur. P1 ve P2 de printf'e ulaşır. Toplam 3 süreç (2'si yeni) ve 3 satır X vardır. Ağaç bir zincirdir: P0 → P1 → P2.

  2. Program ne yazdırır? Çıktının sırası kesin midir?

    int x = 1;
    pid_t pid = fork();
    if (pid == 0) x = x * 3;
    else { wait(NULL); x = x + 4; }
    printf("%d\n", x);
    Cevap

    Çocuk kendi kopyasında x = 1 · 3 = 3 hesaplar ve 3 yazar. Ebeveyn önce çocuğu bekler; sonra kendi kopyasında x = 1 + 4 = 5 hesaplar ve 5 yazar. Ebeveyn yazmadan önce wait ile çocuğun bitmesini beklediği için sıra kesindir: önce 3, sonra 5. Ebeveynin x'i 3 olmaz, çünkü fork sonrasında iki ayrı x vardır.

  3. Aşağıdaki program önce terminalde, sonra ./prog > c.txt ile çalıştırılıyor. İki durumda ne görülür? Neden?

    printf("önce\n");
    execlp("echo", "echo", "sonra", NULL);
    printf("hata\n");
    Cevap

    Terminalde stdout satır tamponludur; "önce\n" satır sonuyla hemen yazılır. Ardından exec başarılı olur ve echo sonra yazar. Ekranda iki satır görülür: önce, sonra. Dosyaya yönlendirmede stdout tam tamponludur; "önce\n" sürecin belleğindeki tamponda bekler. exec adres alanını atınca tampon da kaybolur. c.txt'de yalnız sonra bulunur. hata iki durumda da yazılmaz, çünkü exec başarılıdır. Çözüm, exec'ten önce fflush(stdout) çağırmaktır.

  4. Süreçler zamanlarının yarısını G/Ç bekleyerek geçiriyor. CPU kullanımının en az %99 olması için bellekte en az kaç süreç gerekir?

    Cevap

    p = 0,5 ve 1 − 0,5n ≥ 0,99 olmalıdır; yani 0,5n ≤ 0,01. 0,56 = 0,015625 → kullanım %98,44; yetmez. 0,57 = 0,0078125 → kullanım %99,22; yeter. Logaritmayla n ≥ ln 0,01 / ln 0,5 ≈ 6,64; yukarı yuvarlanınca 7 süreç gerekir.

  5. Bir bağlam değiştirme 0,02 ms sürüyor. Hazır kuyruğunda sırayla çalışan 4 CPU'ya bağlı süreç var ve zaman dilimi 1 ms. İşlemci zamanının yüzde kaçı geçişlere gider? Bir süreç iki dilimi arasında en çok ne kadar bekler?

    Cevap

    Ek yük oranı 0,02 / (1 + 0,02) = 0,02 / 1,02 ≈ %1,96'dır. Bir sürecin dilimi bitince 4 geçiş ve öteki 3 sürecin dilimi geçer: 4 · 0,02 + 3 · 1 = 3,08 ms.

  6. ps çıktısında STAT sütunu S, D ve Z olan üç süreç var. Hangisi Ctrl+C ile hemen sonlandırılabilir, hangisi kill -9'a bile yanıt vermez, hangisini öldürmek anlamsızdır? Sonuncusu nasıl temizlenir?

    Cevap

    S kesilebilir uykudadır; bir sinyal onu uyandırır ve SIGINT ile sonlanır. D kesilemez uykudadır, genellikle bir disk işlemini bekler; işlem bitene kadar SIGKILL bile teslim edilmez. Z zombidir, yani zaten bitmiştir; öldürülecek bir şey kalmamıştır. Zombiyi ebeveyni wait ile biçer. Ebeveyn bunu yapmıyorsa ebeveyn sonlandırılır; zombi init'e geçer ve init onu biçer.

  7. Çocuk süreç exit(256) ve başka bir çalıştırmada exit(-1) ile çıkıyor. Ebeveyn WEXITSTATUS(st) ile hangi değerleri okur? Kabuk birinci durumda komutu başarılı mı sayar?

    Cevap

    Ebeveyne yalnız alttaki 8 bit ulaşır. 256 mod 256 = 0, yani ebeveyn 0 okur. −1'in alttaki 8 biti 1111 1111'dir; ebeveyn 255 okur. Birinci durumda kabuk 0 gördüğü için komutu başarılı sayar, oysa program bir hata bildirmek istemiştir. Bu yüzden taşınabilir programlar yalnız 0–255 aralığını kullanır.

  8. Çocuk süreç exec'ten önce şu iki satırı çalıştırıyor. fd'nin değeri nedir? Sonra execlp("sort", "sort", NULL) çağrılırsa sort veriyi nereden okur? Bu, kabuktaki hangi yazımın karşılığıdır?

    close(0);
    int fd = open("girdi.txt", O_RDONLY);
    Cevap

    close(0) standart girişi kapatır ve 0 numara boşalır. open en küçük boş numarayı verdiği için fd = 0 olur. exec dosya tanımlayıcı tablosunu korur; sort standart girişten, yani 0 numaradan okur ve bu artık girdi.txt'dir. Bu, kabuktaki sort < girdi.txt yazımının karşılığıdır.

  9. Bir sunucu her istek için fork ile bir çocuk oluşturuyor ama hiç wait çağırmıyor. Saniyede 50 istek geliyor ve her çocuk işini hemen bitiriyor. Sistemde aynı anda en çok 32 000 süreç bulunabiliyor ve sunucu başladığında 500 süreç var. Ne kadar süre sonra fork başarısız olmaya başlar? Çare nedir?

    Cevap

    Her çocuk bitince zombi olur ve PID'ini tutmaya devam eder. Boş yer 32 000 − 500 = 31 500 süreçtir. Saniyede 50 zombi biriktiği için 31 500 / 50 = 630 saniye, yani 10,5 dakika sonra yeni süreç oluşturulamaz. Çare, SIGCHLD işleyicisinde waitpid(-1, NULL, WNOHANG) döngüsüyle biten çocukları biçmektir.

3.11İleri okuma

  • R. H. Arpaci-Dusseau ve A. C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, “Processes”, “Process API” ve “Limited Direct Execution” bölümleri: süreç soyutlamasını, fork/exec/wait kalıbını ve denetimin zamanlayıcı kesmesiyle geri alınmasını kısa bölümlerle anlatır. Kitap çevrimiçi ve ücretsizdir.
  • A. Silberschatz, P. B. Galvin ve G. Gagne, Operating System Concepts (10. baskı, 2018), Bölüm 3: süreç durumlarını, PCB'yi ve IPC modellerini Windows ve Android örnekleriyle daha geniş işler.
  • A. S. Tanenbaum ve H. Bos, Modern Operating Systems (4. baskı), Kesim 2.1: süreç modelini, süreç tablosu alanlarını, kesmenin ele alınmasını ve çoklu programlama modelini ayrıntılı verir.
  • M. Kerrisk, The Linux Programming Interface (2010), Bölüm 24–28 ve 44: fork, exec, wait, süreç sonlandırma ve borular için bir başvuru kitabıdır. Tamponlar, sinyaller ve zombiler gibi Linux'a özgü tuzakları ayrıntılı işler.

3.12Kaynaklar

  1. R. H. Arpaci-Dusseau, A. C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, Arpaci-Dusseau Books, pages.cs.wisc.edu/~remzi/OSTEP.
  2. Chromium Projects, “Multi-process Architecture” ve “Site Isolation” belgeleri.
  3. Android Developers, “Processes and app lifecycle”.
  4. M. Kerrisk, The Linux Programming Interface, No Starch Press, 2010.
  5. Linux kılavuz sayfaları: fork(2), execve(2), wait(2), pipe(7), signal(7), shm_overview(7).
  6. A. Silberschatz, P. B. Galvin, G. Gagne, Operating System Concepts, 10. baskı, Wiley, 2018.
  7. A. S. Tanenbaum, H. Bos, Modern Operating Systems, 4. baskı, Pearson, 2014 (5. baskı 2023).

İlgili sayfalar: Bölüm 3 sunumu · Çalışma soruları · Terim sözlüğü · © Sercan Külcü. Tüm hakları saklıdır.