Ana içeriğe geç

Bölüm 7:Zamanı Dilimle

13 dakikalık okumaGüncelleme:

Diyelim ki bir işletim sistemi yazıyorsun ve kullanıcıların aynı anda birden fazla program çalıştırabilmesini istiyorsun. Ama elinde çok çekirdekli bir işlemcinin lüksü yok; CPU aynı anda yalnızca tek bir talimat yürütebiliyor.

Neyse ki akıllı bir işletim sistemi geliştiricisisin. CPU’yu process’ler arasında sırayla paylaştırarak sahte bir paralellik yaratabileceğini fark ediyorsun: aralarında yeterince hızlı geçiş yapar ve her birine küçük bir çalışma payı verirsen, hiçbiri CPU’yu tek başına işgal etmeden sistem duyarlı kalır.

Bu bölümde çalışan her programa process diyeceğiz. Terimi baştan sabitliyorum, çünkü aynı şey için “görev”, “süreç” ve “iş” kelimelerinin dönüşümlü kullanılması, scheduler anlatısını gereksiz yere bulandırıyor.

Bu bölümde neyi çözüyoruz?

  • Bir CPU çekirdeğinin aynı anda tek yürütme akışı çalıştırmasına rağmen çoklu görev hissini nasıl verdiğini göreceğiz.
  • Timer interrupt, preemption ve context switch kavramlarını ayıracağız.
  • Linux scheduler anlatısını güncel EEVDF modeliyle uyumlu ama basit bir zihinsel modele indireceğiz.

Peki program kodu çalışırken kontrolü geri nasıl alacaksın? Biraz araştırınca, çoğu bilgisayarda timer chip’ler bulunduğunu keşfediyorsun. Belirli bir süre geçince işletim sisteminin interrupt handler’ına geçişi tetikleyecek şekilde bu timer chip’leri programlayabiliyorsun.

Donanım Interrupt’ları

Bir önceki bölümde kontrolün user-space programından işletim sistemine nasıl geçtiğini görmüştük: program özel bir talimat çalıştırıyor, CPU mode değiştirip kernel’ın önceden belirlediği bir adrese atlıyordu.

Buradaki kilit ayrıntıyı fark et: geçişi program başlatıyordu. Yani kernel ancak program ona söz verdiği anda söz sahibi oluyordu. Çoklu görev için bu yeterli değil — sonsuz döngüye giren bir program hiçbir zaman söz vermez ve makine kilitlenir. Kernel’ın, kimseden izin almadan sözü geri alabilmesi gerekiyor.

Donanım interrupt'larının normal yürütmeyi nasıl böldüğünü gösteren bir çizim.

Neyse ki donanımın kendisi de CPU’nun sözünü kesebilir. Bir interrupt’ı program değil de donanım tetikliyorsa buna hardware interrupt denir; farkı kimin başlattığıdır.

En sezgisel örneği yukarıdaki çizimde: klavyede bir tuşa bastığında klavye denetleyicisi CPU’ya bir sinyal gönderir. CPU o an ne yapıyorsa yapsın, sıradaki talimatı almadan önce işini askıya alır, nerede kaldığını kaydeder, kernel’ın ilgili handler’ına atlar; handler tuşu okuyup işini bitirince CPU kaldığı yerden devam eder. Kesilen programın bundan haberi bile olmaz.

Scheduler’ın bütün numarası, bu mekanizmayı kendi lehine kullanmaktır. Klavyeden gelen sinyali beklemek yerine, kendi sinyalini kendisi sipariş eder — bunu bir timer chip’e söyleyerek yapar. Klasik örnek PIT’tir; modern x86 makinelerde işi her çekirdeğin kendi local APIC timer’ı, ARM tarafında ise generic timer üstlenir. Fikir hepsinde aynı: donanıma “şu kadar sonra beni dürt” demek.

  1. Program koduna geçmeden önce işletim sistemi, timer chip’i belirli bir süre sonra interrupt tetikleyecek şekilde ayarlar.
  2. İşletim sistemi user mode’a geçer ve programın bir sonraki talimatına atlar.
  3. Süre dolunca timer chip, kernel mode’a geçişi ve işletim sistemi kodunun devreye girmesini sağlayan bir hardware interrupt üretir.
  4. İşletim sistemi artık mevcut programın durumunu kaydedebilir, başka bir programı yükleyebilir ve aynı döngüyü sürdürebilir.

Buna preemptive multitasking denir; bir process’in zorla kesilmesine de preemption denir. Bu satırları tarayıcıda okurken aynı anda müzik de dinliyorsan, bilgisayarın şu anda bu döngüyü saniyede binlerce kez çeviriyor.

Timeslice Hesabı

Peki bu süreyi ne kadar tutacaksın? Scheduler’ın bir process’e, kesilmeden önce tanıdığı bu süreye timeslice deniyor ve seçimi tamamen sana ait. En basit yol herkese aynı süreyi verip sırayla dönmektir; buna fixed-timeslice round-robin scheduling denir.

Küçük bir jargon notu

Timeslice için bazen “quantum” dendiğini biliyor muydun? Artık biliyorsun ve bunu uygun bir ortamda kullanarak teknik arkadaşlarını etkileyebilirsin. Kitap boyunca her cümlede quantum kelimesini kullanmadığım için takdir bekliyorum.

Linux kernel geliştiricileri ayrıca timer tick’lerini saymak için jiffy adlı zaman birimini kullanır. Frekans kernel config’e göre değişebilir; masaüstü/server kurulumlarında 100, 250 veya 1000 Hz gibi değerler görebilirsin.

Günümüz Linux kurulumları tickless çalışır ama bunun iki ayrı seviyesi var, karıştırmak kolay. CPU boştayken tick’i durdurmak (CONFIG_NO_HZ_IDLE) bugün varsayılan davranıştır; laptop pilini ve CPU’nun uyku modlarına (C-states) inebilmesini doğrudan etkiler. Bir çekirdekte tek bir görev varken tick’i tamamen kesmek (CONFIG_NO_HZ_FULL) ise ayrı bir yapılandırmadır, varsayılan değildir ve ancak çekirdek izole edilerek elle açılır — yüksek başarımlı hesaplama ve düşük gecikmeli iş yüklerinin dünyasıdır.

Sabit timeslice yaklaşımının küçük ama önemli bir geliştirmesi, “herkes makul sürede tekrar CPU görebilsin” hedefini koymaktır. Eski CFS anlatılarında bu fikir çoğu zaman target latency üzerinden açıklanırdı: makul sayıda process varken birinin preempt edildikten sonra tekrar CPU görmesi için geçmesini istediğin ideal en uzun süre. Tanım kulağa soyut geliyor; birazdan göreceğin diyagramda yerine oturacak.

Timeslice süresi, target latency’nin toplam process sayısına bölünmesiyle hesaplanır. Bu, sabit süre vermekten daha iyidir; çünkü çalışan process sayısı az olduğunda gereksiz context switch’leri azaltır. Örneğin target latency 15 ms ve toplam 10 process varsa, her biri yaklaşık 1,5 ms çalışır. Yalnızca 3 process varsa her biri 5 ms’lik daha uzun bir dilim alır ve yine aynı target latency içinde sıraya girmiş olur.

Context Switch Tam Olarak Neyi Değiştirir?

“Context switch pahalıdır” cümlesini sık duyarsın ama neyin pahalı olduğu genelde söylenmez. Açalım, çünkü kitabın geri kalanında birkaç kez daha karşımıza çıkacak.

Bir process’i durdurup başka birine geçmek için kernel’ın kaydetmesi gereken “durum” üç parçadan oluşur:

  1. CPU register’ları. Genel amaçlı register’lar, instruction pointer, stack pointer, flag’ler. Bunlar RAM’e, kernel’ın o process için tuttuğu veri yapısına yazılır (Linux’ta task_struct içindeki alanlara). Birkaç yüz bayt; kopyalaması ucuz.
  2. Bellek haritası. CPU’ya, sanal adresleri çevirirken artık başka bir tablo kullanması söylenir. 13. bölümde ayrıntısına ineceğiz.
  3. Kernel tarafındaki muhasebe. Hangi process ne kadar çalıştı, sırada kim var.

Şimdi sürprize gel: yukarıdakilerin hiçbiri asıl maliyet değil. Asıl maliyet, geçişin kendisinden sonra ortaya çıkar. Yeni process çalışmaya başladığında CPU’nun cache’i hâlâ bir öncekinin verisiyle doludur ve adres çevirilerini hızlandıran TLB de öyle. Yeni process ilk birkaç bin talimatı boyunca sürekli ıskalar; cache ve TLB yeniden ısınana kadar yavaş çalışır. Kopyalanan birkaç yüz bayt nanosaniyeler sürer, bu ısınma ise mikrosaniyeler.

Bu yüzden çok küçük timeslice’lar performansı düşürür: sistem, işi yapmaktan çok cache ısıtmakla uğraşır. Scheduler’larda bir minimum granularity sınırının bulunmasının sebebi budur. Sınır devreye girdiğinde target latency aşılabilir, ama sistem kendi kuyruğunu kovalamaktan kurtulur.

Bu sayıları “Linux her zaman tam şunu yapar” diye okuma. CFS döneminde bu adalet hesabını sched_latency_ns ve sched_min_granularity_ns gibi ayarlar ile virtual runtime fikri belirlerdi. Linux 6.6’dan itibaren ise fair scheduler’ın karar mantığı CFS’den EEVDF’ye (Earliest Eligible Virtual Deadline First) taşındı; bu bir belge güncellemesi değil, kodun kendisinde olan bir değişiklik. Bugün masaüstünde çalışan kernel’da işi yapan hesap budur ve eski ayarların yerini tek bir sched_base_slice_ns aldı. EEVDF de CPU süresini adil bölüştürmeye çalışır, ama bunu görevlerin geride kalma miktarını (lag) ve sanal deadline’larını hesaba katarak yapar.

DerinleşmeCFS neyi çözemedi, EEVDF ne getirdi?

CFS’nin fikri tek cümleyle anlatılır: her göreve virtual runtime adlı bir sayaç tut, kim en az CPU görmüşse onu çalıştır.

Sayacın adındaki “virtual” boşuna değil: biriktirdiği şey gerçek geçen süre değil, görevin ağırlığına bölünmüş süredir. Yüksek öncelikli (düşük nice değerli) bir görevin sayacı daha yavaş ilerler, dolayısıyla sıraya daha sık girer. Öncelik kavramı CFS’ye tam olarak buradan girer.

Adalet açısından kusursuz bir tasarım. Ama bir şeyi hiç sormaz: bu görev ne kadar acil?

Sorunu somutlaştıralım. Aynı makinede iki iş var:

  • Bir ses uygulaması. Saniyede yüzlerce kez uyanıp birkaç yüz mikrosaniyelik iş yapması gerekiyor. Toplamda çok az CPU tüketiyor, ama geç kalırsa ses cızırdıyor.
  • Bir derleme işi. CPU’yu doyasıya kullanmak istiyor, ne kadar geç başladığı hiç önemli değil.

CFS’nin gözünde ses uygulaması “az CPU almış” bir görevdir, o kadar. Uyandığında bir preemption kontrolü yapılırdı elbette — ama karar yalnızca vruntime farkına ve sched_wakeup_granularity_ns eşiğine bakardı. Ses uygulamasının “acilim” diyebileceği bir kanal yoktu; oysa az CPU almış olmak ile acil olmak aynı şey değildir. Adalet sağlanır, ses cızırdar. Bu yüzden yıllarca sched_latency_ns gibi ayarlar elle kurcalandı ve “düşük gecikmeli kernel” yamaları dolaştı.

EEVDF (Earliest Eligible Virtual Deadline First) iki kavram ekleyerek bunu düzeltir:

  • Lag (geride kalma). Her görev için “adil payına göre ne kadar geridesin?” hesaplanır. Lag pozitifse görev alacaklıdır ve eligible sayılır; negatifse hakkından fazlasını almıştır ve sırasını beklemek zorundadır.
  • Sanal deadline. Eligible görevler arasında yarış, deadline’a göre çözülür. Bir görevin deadline’ı, istediği dilim uzunluğuna göre hesaplanır: kısa dilim isteyen erken bir deadline alır.

İşte kritik nokta burada. Ses uygulaması “bana çok CPU ver” demez; “bana kısa dilimler ver ama çabuk ver” der. EEVDF’de bu bir dilek değil, hesabın parçasıdır: kısa dilim istemek erken deadline demektir, erken deadline önce çalışmak demektir. Karşılığında görev daha sık preempt edilir — yani gecikme ile verim arasındaki takası artık scheduler değil, görevin kendisi seçer.

Linux’ta sıradan bir görev bile sched_setattr çağrısının sched_runtime alanıyla “bana şu uzunlukta dilimler ver” diyebilir; bu, EEVDF ile gelen ve görev başına dilim uzunluğu tanımlayan yeni bir yetenektir. nice değerleri de kaybolmaz; ağırlık olarak lag hesabına girer.

Tarihsel bir not: EEVDF yeni bir icat değil. Ion Stoica ve Hussein Abdel-Wahab’ın 1995 tarihli oransal paylaşım (proportional share) çalışmasına dayanır. Yani otuz yıl bekleyip nihayet üretim scheduler’ı olan bir teori. Sistem araştırmalarında bu hiç de sıra dışı değildir.

Dinamik timeslice round-robin scheduling diyagramı. Üç işlem sırayla çalışıyor, aralara kernel scheduler blokları giriyor ve toplam döngü target latency'yi oluşturuyor.

Bu temel timeslice hesabı, scheduler’ı anlamak için iyi bir ilk basamak; ama günümüz makinelerinin yaptığı şeyin tamamı değil. Gerçek scheduler’lar öncelikleri, deadline’ları, uyku/uyanma davranışını ve CPU affinity’yi de hesaba katar.

Bunlardan sonuncusu bir sonraki bölüme doğrudan köprü kuruyor, o yüzden açalım: CPU affinity, bir görevin hangi çekirdeklerde çalışabileceğini belirler. Scheduler bir görevi mümkün olduğunca son çalıştığı çekirdekte tutmaya çalışır, çünkü o çekirdeğin cache’i zaten o görevin verisiyle doludur. Görevi başka bir çekirdeğe taşımak, biraz önce konuştuğumuz ısınma maliyetini sıfırdan ödetir. Yani “boştaki çekirdeğe ver” her zaman doğru cevap değildir.

Linux uzun süre Completely Fair Scheduler ile anlatıldı; güncel kernel belgeleri artık EEVDF tarafını anlatıyor. Temel ders değişmiyor: CPU kısa aralıklarla paylaştırılıyor, sadece “sıradaki kim?” sorusunun hesabı akıllanıyor.

Scheduler’ın ne yaptığını tam oturtmak için bir process’in hayatına yukarıdan bakalım. Bir process doğduğu andan (new) sonlandığı ana (terminated) kadar üç durum arasında gidip gelir: çalışmaya hazır bekliyor (ready), CPU’da çalışıyor (running), bir şey bekliyor (waiting). Scheduler’ın işi bu haritanın yalnızca iki okundan ibarettir: ready → running ve running → ready. waiting ise scheduler’ın kararı değildir — process diskten veri, ağdan paket ya da klavyeden tuş beklerken kendi isteğiyle oraya düşer.

Process state diyagramı: new, ready, running, waiting ve terminated durumları arasındaki geçişler.

Kaynak: Silberschatz, Galvin, Gagne — Operating System Concepts, Ch. 3 — Processes, Slide 9.

Yukarıdaki target latency diyagramını bir tarih dersi gibi oku: fikri doğru anlatır, ama bugünkü kernel’ın yaptığı şeyin birebir kendisi değildir. Gerçek scheduler’ın davranışı kernel sürümüne ve derleme yapılandırmasına göre değişir. Değişmeyen tek cümle şu: CPU kısa dilimlere bölünüyor.

İşletim sistemi bir process’i her preempt ettiğinde, sıradakinin kayıtlı context’ini (yürütme bağlamını) yüklemesi gerekir; buna bellek context’i de dâhildir. Bu, CPU’ya sanal adresleri çevirirken farklı bir page table kullanmasını söyleyerek yapılır. Dikkat: page table’ın kendisi RAM’de durur, CPU’ya verilen şey yalnızca o tablonun bellekteki adresidir (x86-64’te bu adres CR3 adlı özel bir register’a yazılır). Yani kernel tek bir register’ı değiştirerek CPU’nun gördüğü bütün bellek haritasını değiştirmiş olur. Programların birbirinin belleğine erişememesinin sebebi tam olarak budur; burası tek başına iki bölüm eden bir konu ve Bölüm 13 ile Bölüm 16 tam olarak oraya iniyor.

Bütün bu geçişi baştan sona izleyelim:

Bir context switch adım adım
USER MODEKERNEL MODEprocess Atimer chipsüre doldukesmeA'nın kaydırip, rsp, rax…kaydedildischedulersıra kimde?B'nin kaydıCR3 ← Bgeri yüklendiprocess B
A çalışıyor. Process A user mode'da yürüyor. Register'lar onun değerlerini taşıyor, sayfa tablosu onun belleğini gösteriyor.
  1. A çalışıyor. Process A user mode'da yürüyor. Register'lar onun değerlerini taşıyor, sayfa tablosu onun belleğini gösteriyor.
  2. Timer interrupt. Anakart üzerindeki timer'ın süresi doluyor ve CPU'ya bir hardware interrupt gönderiyor. A bunu istemedi, haberi bile yok.
  3. Kernel devralır. CPU ayrıcalık seviyesini yükseltir ve kernel'ın önceden kurduğu handler'a atlar. A'nın bütün görünür durumu — register'lar, instruction pointer, bayraklar — kernel'daki kaydına yazılır.
  4. Scheduler seçim yapar. Sırada kimin olduğuna scheduler karar verir. Linux'ta bugünkü varsayılan EEVDF; her process'in ne kadar hak ettiği ile ne kadar aldığı arasındaki farka bakar.
  5. B geri yüklenir. B'nin kaydı register'lara yazılır ve `CR3` değişir — tek bir register, process'in gördüğü bütün bellek haritasını değiştirir. User mode'a dönüldüğünde B, hiç durmamış gibi devam eder.

Öncelik Tersine Dönmesi: Mars’ta Yaşanmış Bir Hata

Scheduler’ın işini zorlaştıran şey yalnızca “kim ne kadar aldı” değil. Görevler birbirine bağlanabilir ve o zaman öncelik sıralaması tersine dönebilir.

Klasik senaryo üç görevle kurulur:

  1. Düşük öncelikli bir görev, paylaşılan bir kaynağın kilidini alır.
  2. Yüksek öncelikli bir görev uyanır, aynı kilidi ister ve bekler. Kilit düşük öncelikli görevde olduğu için bekleyecektir.
  3. Tam o sırada orta öncelikli bir görev uyanır. Düşük öncelikliyi preempt eder — çünkü ondan önceliklidir.

Sonuç şu: yüksek öncelikli görev, orta öncelikli görev yüzünden beklemektedir. Aralarında hiçbir doğrudan ilişki yoktur. Öncelik sıralaması fiilen tersine dönmüştür ve buna priority inversion denir.

Bu senaryonun en meşhur örneği bir laboratuvarda değil, Mars’ta yaşandı. 1997’de Mars Pathfinder’ın iniş aracı yüzeye kondaktan sonra düzenli aralıklarla kendini yeniden başlatmaya başladı. (Karıştırılması çok yaygın: sorun aracın taşıdığı Sojourner gezgininde değildi; gezginin bilgisayarı çok daha basit ve tamamen ayrı bir sistemdi.) Sebep tam olarak yukarıdaki üçgendi: yüksek öncelikli bir görev, paylaşılan veri alanını — VxWorks üzerindeki information bus’ı — koruyan yazılım kilidini bekliyor, bu arada orta öncelikli iletişim görevi çalışıyor ve watchdog zamanlayıcısı “yüksek öncelikli görev takıldı” deyip sistemi resetliyordu. Hata Dünya’daki ikiz sistemde yeniden üretildi ve milyonlarca kilometre öteye gönderilen bir yapılandırma değişikliğiyle düzeltildi.

Çözümün adı priority inheritance (öncelik devralma): kilidi tutan düşük öncelikli görev, kendisini bekleyen en yüksek önceliği geçici olarak devralır. Böylece orta öncelikli görev onu preempt edemez, kilit hızlıca bırakılır ve öncelikler normale döner.

Linux’ta programların birbirini beklemek için kullandığı temel kilit mekanizmasının adı futex’tir (fast userspace mutex). İsmindeki “fast” şuradan geliyor: kilit için çekişme yoksa iş tamamen user-space’te, kernel’a hiç girilmeden biter; yalnızca gerçekten beklemek gerektiğinde syscall yapılır. Öncelik devralmayı destekleyen sürümüne PI-futex denir ve gerçek zamanlı iş yükleri için kullanılan PREEMPT_RT yapılandırmasının önemli bir parçasıdır.

Buradaki asıl ders şu: scheduler tek başına adaleti garanti edemez. Görevler kaynak paylaşıyorsa, kilitlerin de öncelikten haberi olması gerekir.

Kenar Notu: Kernel’in Kendisi Kesilebilir mi?

Şu ana kadar yalnızca user-space process’lerinin preempt edilmesinden söz ettik. Oysa bir syscall’ın işlenmesi ya da driver kodunun yürütülmesi çok uzun sürerse, kernel kodu da programları yavaşlatabilir.

Linux kernel’ı derleme seçeneğine göre dört farklı preemption modelinden biriyle çalışır ve her biri farklı bir kullanıcıyı memnun etmek için var:

Genel kural şu: kernel kritik bir kilit tutmadığı sürece kendi kodu da kesilebilir ve scheduler başka bir işe geçebilir. Modern kernel’larda CONFIG_PREEMPT_DYNAMIC sayesinde bu seçim derleme anına çakılı değildir; boot sırasında belirlenebilir.

Kernel ya da driver yazmıyorsan bu ayrıntıyı ezberlemene gerek yok. Yine de aklının bir köşesinde dursun: bir programın neden “takıldığını” araştırırken karşılaştığın gecikmelerin bir kısmı user-space’te değil, tam olarak burada — kernel’ın kendi kodunda — doğar.

Kenar Notu: Programların Gönüllü Sıra Verdiği Günler

Klasik Mac OS ve NT öncesi Windows sürümleri dâhil, eski işletim sistemleri preemptive multitasking’i değil onun atası olan bir modeli kullanıyordu. İşletim sistemi “şimdi seni keseceğim” demiyordu; programlar kontrolü kendi istekleriyle bırakıyordu. Program bir software interrupt tetikleyip “istersen şimdi başkasını çalıştırabilirsin” diyordu ve işletim sisteminin sözü geri alabilmesinin tek yolu buydu.

Buna cooperative multitasking denir. Büyük kusurları vardır: kötü niyetli ya da kötü yazılmış programlar tüm sistemi kolayca dondurabilir ve zaman hassasiyetinin önemli olduğu işlerde kararlı davranış elde etmek neredeyse imkânsızdır. Bu yüzden teknoloji dünyası preemptive multitasking’e uzun zaman önce geçti ve bir daha geri dönmedi.

Özet

Peki, ne öğrendik?

  • Çoklu görev bir yazılım hilesi değil, bir donanım özelliğinin üstüne kurulur: timer chip’in düzenli aralıklarla ürettiği interrupt.
  • Interrupt geldiğinde kontrol kernel’a geçer; kernel çalışan process’in bütün durumunu kaydedip başka birininkini yükler. Buna context switch denir.
  • Hangi process’in ne kadar çalışacağına scheduler karar verir. Linux’ta bugünkü varsayılan EEVDF’tir.
  • Zaman dilimi sabit bir sayı değildir; sistemdeki yüke ve önceliklere göre hesaplanır.
  • Öncelik tersine dönmesi, düşük öncelikli bir işin yüksek öncelikli bir işi dolaylı yoldan bekletmesidir; Mars’a kadar gitmiş gerçek bir hata sınıfıdır.
  • Eski sistemlerdeki cooperative multitasking sırayı programın gönüllü bırakmasına dayanıyordu ve tek bir kötü program tüm sistemi dondurabiliyordu.

Peki CPU aynı anda tek bir talimat yürütüyorsa, bilgisayarın nasıl bu kadar hızlı olabiliyor? Cevap işlemcinin kendi içinde sakladığı hilelerde. Sıradaki bölümde onları tek tek açacağız.

8. bölüme devam et: İşlemciyi Hızlandıran Hileler