CPU'ya“Sen”iKatmak yazısının parçası: bilgisayarının programları nasıl çalıştırdığına doğru inen uzun bir teknik tavşan deliği.
Bölüm 6:
Fork'lar ve COW'lar Hakkında Konuşalım
GitHub'da düzenle
Son soru: Buraya nasıl geldik? İlk process’ler nereden çıktı?
Bu makalenin son düzlüğündeyiz. Kapanışa yaklaşıyoruz. Birkaç kötü mecaz daha yapıp yolumuza devam edeceğim: yeni ufuklara doğru gidiyoruz, çimenlere dokunacağız ve belki de artık 6. bölüm kadar uzaklarda olmayacağız.
execve, mevcut process‘i değiştirerek yeni bir program başlatıyorsa, tamamen ayrı bir process içinde yeni bir programı nasıl başlatıyorsun? Bilgisayarda birden fazla şey yapmak istiyorsan bu kritik bir yetenek. Bir uygulamaya çift tıkladığında, onu açan program kaybolmaz; yeni uygulama ondan bağımsız şekilde yaşamına devam eder.
Cevap başka bir syscall: fork. Çoklu işlem dünyasının temel taşı budur. fork, mevcut process‘i ve belleğini klonlar, kaydedilmiş instruction pointer’ı olduğu yerde bırakır ve ardından iki process‘in de normal akışına devam etmesine izin verir. Müdahale edilmezse, iki process birbirinden bağımsız ama aynı koddaki farklı yürütmeler olarak devam eder.
Yeni ortaya çıkan process‘e “child”, onu başlatana da “parent” denir. Bir process, fork‘ü birden fazla kez çağırabilir ve böylece birden çok child yaratabilir. Her process‘in, 1’den başlayarak verilen bir process ID‘si (PID) vardır.
Tamamen aynı kodu körlemesine iki kopya hâline getirmek tek başına çok faydalı olmazdı. Bu yüzden fork, parent ve child tarafında farklı değer döndürür. Parent tarafında yeni child process‘in PID’sini döndürür, child tarafında ise 0 döner. Böylece her iki taraf da kendi rolüne göre farklı iş yapabilir.
pid_t pid = fork();
// Kod bu noktadan sonra normal şekilde devam eder,
// ama artık iki "özdeş" process üzerinde akmaktadır.
//
// Özdeş... ama fork'ün döndürdüğü PID hariç.
//
// Her iki programın da tek ayırt edici işareti budur.
if (pid == 0) {
// Child process'teyiz.
// Bir hesaplama yap ve sonucu parent'a ver!
} else {
// Parent process'teyiz.
// Muhtemelen kaldığımız işe devam ederiz.
}
Fork kavramını kafada oturtmak ilk anda biraz garip gelebilir. Bu noktadan sonra anladığını varsayacağım; eğer henüz oturmadıysa, şu korkunç görünümlü web sitesi bu işi aslında fena anlatmıyor.
Her neyse: Unix programları yeni bir program başlatmak istediklerinde tipik olarak önce fork çağırır, ardından child process içinde hemen execve çalıştırır. Buna fork-exec modeli denir. Bir programı başlattığında bilgisayarının yaptığı şey kabaca şuna benzer:
pid_t pid = fork();
if (pid == 0) {
// Child process'i hemen yeni programla değiştir.
execve(...);
}
// Buraya geldiysek process değiştirilmedi.
// Parent process'teyiz. İstersek yeni child'ın PID'si de elimizde.
// Parent program burada devam eder...
Moooo!
Fark etmiş olabilirsin: Başka bir program yüklerken bir process’in belleğini yalnızca biraz sonra çöpe atmak üzere tamamen kopyalamak kulağa verimsiz geliyor. Neyse ki elimizde bir MMU var. İşin pahalı kısmı fiziksel RAM’i çoğaltmaktır; page table’ları kopyalamak değildir. Bu yüzden ilk anda hiç RAM kopyalamayız. Yeni process için eski process’in page table’ının bir kopyasını çıkarır ve her iki tarafın da aynı fiziksel bellek bloklarına bakmasını sağlarız.
Ama child process’in parent’tan bağımsız ve yalıtılmış olması gerekir. Child’ın parent belleğine yazabilmesi ya da tam tersi kabul edilebilir değil.
İşte burada COW (copy on write) page’leri devreye girer. COW page’lerinde iki process de aynı fiziksel bellekten okuyabilir; ama içlerinden biri yazmaya kalktığı anda o page RAM içinde kopyalanır. Böylece her iki process de, baştan bütün bellek alanını çoğaltmanın maliyetine katlanmadan ayrı birer bellek görüntüsüne kavuşur. Fork-exec modelinin verimli olmasının nedeni tam olarak budur: Yeni binary yüklenmeden önce eski belleğe yazılmadığı için çoğu durumda gerçek kopyalama hiç gerekmez.
COW, pek çok eğlenceli şey gibi, page fault’lar ve interrupt yönetimiyle uygulanır. fork, parent’ı klonladıktan sonra iki process‘in de tüm page’lerini salt okunur olarak işaretler. Bir program belleğe yazmaya kalktığında, sayfa salt okunur olduğu için yazma başarısız olur. Bu da kernel’ın ele aldığı bir page fault tetikler. Kernel ilgili page’i kopyalar, yazılabilir hâle getirir ve sonra kesmeden çıkıp yazmayı tekrar dener.
A: Tak tak!
B: Kim o?
A: İneğin sözünü kesen.
B: İneğin sözünü kesen ki —
C: MOOOOO!
Başlangıçta (Yaratılış 1:1 Olan Değil)
Bilgisayarındaki her process, bir tanesi hariç, başka bir program tarafından fork edilip çalıştırılmıştır: init process. Init process, doğrudan kernel tarafından hazırlanır. Bu, user space’te çalışan ilk programdır ve sistem kapanırken en son ölen de odur.
Anında dramatik siyah ekran görmek ister misin? macOS veya Linux kullanıyorsan çalışmanı kaydet, terminal aç ve init process‘i (PID 1) öldür:
$ sudo kill 1
Yazar notu:
init process‘leriyle ilgili bu kısmın büyük bölümü maalesef yalnızca macOS ve Linux gibi Unix benzeri sistemler için geçerli. Buradan sonrası, çok farklı bir kernel mimarisine sahip Windows’u aynı şekilde anlamanı sağlamayacak.Tıpkı
execvebölümünde olduğu gibi bunu açıkça söylüyorum: NT kernel üzerine başlı başına ayrı bir makale yazılabilir. Şimdilik buna kendimi zor tutuyorum.
Init process, işletim sistemini oluşturan servislerin ve programların büyük bölümünü başlatmaktan sorumludur. Bu programların çoğu da daha sonra kendi çocuklarını üretir.

Init process‘i öldürmek, onun çocuklarını ve onların çocuklarını da zincirleme biçimde öldürerek işletim sistemi oturumunu fiilen kapatır.
Çekirdeğe Dönüş
Linux kernel kodunu kurcalamak Bölüm 3‘te bayağı eğlenceliydi; biraz daha yapalım. Bu kez kernel’ın init process‘i nasıl başlattığına bakacağız.
Bilgisayarın kabaca şu sırayla açılır:
- Anakart, bağlı disklerde bootloader denen küçük programı arayan temel bir yazılımla gelir. Bir bootloader seçer, onun makine kodunu RAM’e yükler ve çalıştırır.
- Unutma: Henüz tam anlamıyla çalışan bir işletim sistemi dünyasında değiliz. Kernel bir
init processbaşlatana kadar çoklu işlem, syscall ve benzeri üst seviye soyutlamalar aslında ortada yoktur. Boot öncesi bağlamda bir programı “çalıştırmak”, çoğu zaman RAM’deki makine koduna doğrudan atlamak demektir. - Bootloader, kernel’ı bulmak, RAM’e yüklemek ve çalıştırmaktan sorumludur. GRUB gibi bootloader’lar yapılandırılabilir ve hatta birden fazla işletim sistemi arasında seçim yapmana izin verebilir. BootX ve Windows Boot Manager, sırasıyla macOS ve Windows’un yerleşik bootloader’larıdır.
- Kernel artık çalışır durumdadır ve interrupt handler’ları kurmak, driver’ları yüklemek, ilk bellek eşlemelerini oluşturmak gibi geniş bir başlatma rutinine girer. Sonunda da ayrıcalık seviyesini user mode’a indirir ve init programını başlatır.
- İşte şimdi gerçekten bir işletim sisteminin kullanıcı alanındayız. Init programı init script’lerini çalıştırır, servisleri başlatır ve shell ile grafik arayüz gibi başka programları devreye sokar.
Linux’un Başlatılması
Linux’ta 4. adımın, yani kernel başlangıcının, büyük kısmı init/main.c içindeki start_kernel fonksiyonunda gerçekleşir. Bu fonksiyon yüzlerce satırlık başka init çağrılarıyla doludur; hepsini buraya taşımayacağım ama zamanın varsa göz atmanı öneririm. start_kernel‘ın sonlarında arch_call_rest_init adlı fonksiyon çağrılır:
/* Do the rest non-__init'ed, we're now alive */
arch_call_rest_init();
__init ne demek?
start_kernel, asmlinkage __visible void __init __no_sanitize_address start_kernel(void) olarak tanımlanır. __visible, __init ve __no_sanitize_address gibi tuhaf görünen anahtar sözcükler, Linux kernel’ında makrolar aracılığıyla fonksiyonlara ek anlamlar kazandırır.
Bu örnekte __init, boot tamamlandıktan hemen sonra ilgili fonksiyonun ve verisinin bellekten atılabileceğini söyler; amaç yer tasarrufudur.
Bunun nasıl çalıştığını çok detaya girmeden anlatırsak: Linux kernel’ı da sonuçta ELF benzeri bölümlere ayrılır. __init makrosu, kodu normal .text yerine .init.text bölümüne koyan bir derleyici yönergesine genişler. Benzer şekilde __initdata gibi makrolar da verileri özel init bölümlerine yerleştirir.
arch_call_rest_init, temelde yalnızca bir sarmalayıcıdır:
void __init __weak arch_call_rest_init(void)
{
rest_init();
}
Yorumda “gerisini non-__init olarak yap” denmesinin nedeni, rest_init fonksiyonunun __init ile işaretlenmemiş olmasıdır. Bu da onun, başlangıç belleği temizlenirken serbest bırakılmayacağı anlamına gelir:
noinline void __ref rest_init(void)
{
rest_init bu noktada init process için bir thread oluşturur:
/*
* We need to spawn init first so that it obtains pid 1, however
* the init task will end up wanting to create kthreads, which, if
* we schedule it before we create kthreadd, will OOPS.
*/
pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
user_mode_thread‘a verilen kernel_init fonksiyonu, birkaç son başlangıç adımını tamamladıktan sonra geçerli bir init programı arar. Bu yol üzerinde birçok detay var; bunların çoğunu atlayacağım ama free_initmem çağrısını anmak önemli. Çünkü burası, biraz önce konuştuğumuz .init bölümlerinin gerçekten serbest bırakıldığı yer:
free_initmem();
Ardından kernel, çalıştırabileceği uygun init programını arar:
/*
* We try each of these until one succeeds.
*
* The Bourne shell can be used instead of init if we are
* trying to recover a really broken machine.
*/
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0;
panic("Requested init %s failed (error %d).",
execute_command, ret);
}
if (CONFIG_DEFAULT_INIT[0] != '\0') {
ret = run_init_process(CONFIG_DEFAULT_INIT);
if (ret)
pr_err("Default init %s failed (error %d)\n",
CONFIG_DEFAULT_INIT, ret);
else
return 0;
}
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
Linux’ta init programı neredeyse her zaman /sbin/init konumundadır ya da oraya sembolik bağla işaret edilir. Yaygın init sistemleri arasında systemd, OpenRC ve runit bulunur. Kernel başka hiçbir şey bulamazsa son çare olarak /bin/sh‘i dener. Onu da bulamıyorsa işler gerçekten kötü demektir.
macOS’un da bir init programı vardır: adı launchd‘dir ve /sbin/launchd konumunda durur. Kernel olmadığın için bunu terminalden elle çalıştırmayı denemeni önermem.
Bu noktadan sonra artık boot sürecinin son adımındayız: init process, user space’te çalışıyor ve fork-exec modeliyle başka programları başlatıyor.
Çatal Bellek Eşlemesi
Linux kernel’ının process fork ederken sanal belleğin alt yarısını nasıl yeniden eşlediğini merak ettim, o yüzden biraz daha kurcaladım. kernel/fork.c, fork sürecinin büyük kısmını içeriyor gibi görünüyor. Dosyanın başındaki yorum beni doğru yere yönlendirdi:
/*
* 'fork.c' contains the help-routines for the 'fork' system call
* (see also entry.S and others).
* Fork is rather simple, once you get the hang of it, but the memory
* management can be a bitch. See 'mm/memory.c': 'copy_page_range()'
*/
Bu yoruma bakılırsa, copy_page_range adlı fonksiyon bellek eşlemesini kopyalayan kritik parçalardan biri. Çağırdığı fonksiyonlara bakınca, page’lerin COW hâline getirilmesi için salt okunur olarak işaretlenmesinin de bu yol üzerinde yapıldığını görüyorsun. Bunun gerekip gerekmediğini anlamak için is_cow_mapping adlı fonksiyona bakılıyor.
is_cow_mapping, include/linux/mm.h içinde tanımlı ve ilgili bellek eşlemesinin flag’lerinin belleğin yazılabilir ama süreçler arasında paylaşılmayan bir alanı işaret edip etmediğini kontrol ediyor. Paylaşılan bellek zaten birlikte kullanılmak üzere tasarlandığından COW gerektirmez. Biraz bitmask büyüsüne hayran kal:
static inline bool is_cow_mapping(vm_flags_t flags)
{
return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}
kernel/fork.c‘ye geri dönüp copy_page_range için kısa bir arama yaptığında, bunun dup_mmap içinden çağrıldığını görürsün. dup_mmap de dup_mm tarafından, o da copy_mm tarafından, o da sonunda devasa copy_process fonksiyonu tarafından çağrılır. Yani copy_process, fork‘ün kalbidir. Bir bakıma Unix sistemlerinin program başlatma modelinin merkezi burasıdır: her şey, başlangıçta oluşturulmuş bir şablon sürecin kopyalanıp uyarlanmasıyla ilerler.
Özetle…
Peki… programlar nasıl çalışır?
En düşük seviyede cevap şu: işlemciler aptaldır. Ellerinde bellekte bir işaretçi vardır ve onlara başka yere atlamalarını söyleyen bir talimata rastlamadıkları sürece talimatları art arda yürütürler.
Ama bu akış sadece jump talimatlarıyla değişmez; hardware ve software interrupt’lar da yürütmeyi, önceden belirlenmiş başka bir konuma sıçratarak bozabilir. Tek bir işlemci çekirdeği aynı anda birden fazla programı gerçekten çalıştıramaz, ama timer’lar aracılığıyla tekrar tekrar interrupt üretip kernel’ın farklı instruction pointer’lar arasında geçiş yapmasını sağlayarak bunu ikna edici biçimde taklit edebiliriz.
Programlar, aslında olduklarından çok daha düzenli bir ortamda çalıştıklarına inandırılır. User mode, sistem kaynaklarına doğrudan erişimi keser; sayfalama, bellek alanını yalıtır; syscall’lar ise süreçlerin altında yatan gerçek yürütme bağlamını bilmeden genel G/Ç yapabilmesini sağlar. Syscall dediğimiz şey, CPU’ya “kernel’ın önceden belirlediği şu kod yoluna git” diyen talimatlardır.
Ama… programlar nasıl çalışır?
Bilgisayar açıldıktan sonra kernel, init process‘i başlatır. Bu, makine kodunun çok fazla donanım ayrıntısıyla uğraşmak zorunda olmadığı ilk yüksek seviyeli programdır. Init, bilgisayarının grafik ortamını ve geri kalan servisleri başlatır; yani diğer yazılımların dünyaya gelmesinden sorumlu ana ebeveyndir.
Bir programı başlatmak için init ya da başka bir process önce fork ile kendini klonlar. Bu klonlama verimlidir; çünkü page’ler COW olarak paylaşılır ve fiziksel RAM’i baştan sona kopyalamak gerekmez. Linux tarafında bu hikâyenin büyük kısmı copy_process çevresinde akar.
Ardından child process, gerekirse parent’tan farklı yola sapar. Yeni programı gerçekten başlatmak istediğinde exec ailesinden bir syscall çağırır ve kernel’dan mevcut process’i yeni programla değiştirmesini ister.
Bu yeni program çoğu zaman bir ELF dosyasıdır. Kernel, ELF’yi ayrıştırıp kodun ve verinin yeni sanal bellek düzeninde nereye yükleneceğini belirler. Program dynamic linked ise, gerekirse ELF interpreter’ı da devreye girer.
Son adımda kernel yeni sanal bellek eşlemesini kurar ve user space’e geri döner. Pratikte bu, CPU’nun instruction pointer’ını yeni programın kodunun başlangıcına ayarlamak demektir. Ve işte, program gerçekten çalışmaya başlar.
7. bölüme devam et: Son Söz