Eşzamanlı programlama - Kavramlar ve işlemlere giriş

ArticleCategory:

SoftwareDevelopment

AuthorImage:

[Leonardo]

TranslationInfo:

original in it: Leonardo Giordani

it to en: Leonardo Giordani

en to tr Erdal Mutlu

AboutTheAuthor:

Yazar, Milan Politecnico Üniversitesinin Telekomunikasyon Mühendisliğinde okumakta ve bilgisayar ağları yöneticisi olarak çalışmaktadır. Daha çok Assemble ve C/C++ olmak üzere, programlamaya ilgi duymaktadır. 1999 yılından beri neredeyse tamamen Linux/Unix ile çalışmaktadır.

Abstract:

Bu yazı dizisinin amacı, okuyculara Linux işletim sistemindeki çokluişlem (multitasking) ve uygulamasına giriş oluşturmaktadır. Çokluişlem teorisinin temellerinden başlayarak, işlemler veya süreçler (processes) arasındaki haberleşmeyi, basit ve bir o kadar da etkili bir protokol kullanarak gösteren, bir program yazarak konuyu tamamlayacağız. Yazıyı anlayabilmek için gerekli önbilgiler şunlardır: Man sayfalarına olan referanslar, komutların hemen ardından parantez içinde verilmiştir. Glibc fonksiyonların tümü, info sayfalarında belgelendirilmiştir (info Libc veya konqueror'da info:/libc/Top).

ArticleIllustration:

[run in paralell]

ArticleBody:

Giriş

Çeşitli programları parça parça (interlaced) çalıştırarak, sistem kaynaklarını daha iyi bir şekilde kullanılmasını sağlayan bir yöntem olan çokluişlem kavramının getirilmesi, işletim sistemleri tarihinde bir dönüm noktası olmuştur. Sözgelimi, kelime işlemci, ses çalar, yazıcı kuyrugu, sanaldoku gezgini ve daha fazla uygulamanın çalıştığı bir bilgisayarı düşünelim. Bu yöntem modern işletim sistemleri için önemli bir kavramdır. İleride de keşfedeceğimiz gibi, yukarıda saydığımız uygulamalar, her ne kadar görsel uygulamalar olsalar da, bilgisayarınızda çalışan programların küçük bir kısmıdır.

İşlem kavramı

Birden fazla programı aynı anda çalıştırabilmek için işletim sisteminin yapması gereken karmaşık işlemlere gereksinimi vardır. Çalışan programlar arasında oluşabilecek karışıklığı önlemek için, programların çalıştırılmaları ile ilgili gerekli tüm bilginin bir yerde saklanması kaçınılmazdır.

Linux bilgisayarımızda ne olup bittiğine geçmeden önce biraz genel kültür bilgisi verelim: çalışan bir PROGRAM verildiğinde, programı oluşturan komutlar kümesine CODE(Program kodu), program verilerin bilgisayar belleğinde kapladığı alana MEMORY SPACE(Bellek alanı) ve mikroişlemcinin parametre değerleri, çeşitli bayrak değerleri veya program sayacına (Bir sonra çalıştırılacak olan komutun adres değeri.), PROCESSOR STATUS (İlemci durumu) denilmektedir.

RUNNING PROGRAM (Çalışan program) kavramını, Program kodu, Bellek alanı ve İşlemci durumu nesnelerinden oluşan bir bütün olarak tanımlıyoruz. Eğer, herhangi bir zaman diliminde, bu bilgiyi kaydedip, başka programın bilgileri ile değiştirdiğimizde, kaydettiğimiz programa tekrar dönüldüğünde, program kaldığı yerden çalışmasına devam edebilmedir: Bu çalışma yöntemi birden fazla programın aynı anda ve birbirleri ile çakışmadan çalışıyor gibi görünmesini sağlamaktadır. PROCESS veya TASK (Süreç veya işlem) kelimesi böyle çalışan bir programı tanımlamak için kullanılmaktadır.

Şimdi, yazının başında sözünü ettiğimiz Linux bilgisayarında neler olduğuna bir göz atalım. Herhangi bir anda, bilgisayarda sadece bir adet işlem çalıştırılabilmektedir (Sistemde sadece bir mikroişlemci vardır ve bu işlemci aynı anda iki işlemi yerine getirememektedir.). Çalışan programın da saedece belli bir kısmı işlem görmektedir. Belli bir süre sonra, buna QUANTUM denmektedir, çalışan işlem durdurulmakta, daha sonra kaldığı yerden devam etmek üzere işlem ile ilgili bilgiler bir yerde ve beklemekte olan başka bir işlemin bilgileri ile değiştirilerek, bir quantum süresi boyunca çalıştırılmaktadır. Aynı anda çalışıyor gibi izlenim veren işlemler böylece çalıştırılmaktadır. Buna biz multitasking (çokluişlem) diyoruz.

Daha önce de söylediğim gibi, çokluişlem kullanılıyor olması bazı sorunları da beraberinde getirmektedir. Sözgelimi, kuyrukta bekleyen işlemlerim yönetimi (SCHEDULING). Herneyse, bunlar işletim sisteminin yapısı ile ilgili konular. Bu belkide başka bir yazı yazımı için uygun bir konu olabilir. Linux çekirdeğini tanıtıcı bir yazı yazılabilir.

Linux ve Unix'teki işlemler

Bilgisayarımızda çalışan işlemler ile ilgili biraz bilgi edinelim. Bu bilgiyi sağlayan komutun adı ps(1) dir ve "process status" (işlem durumu) sözcüklerin kısaltmasıdır. Bir terminal açıp, ps komutunu çalıştrırsanız, aşağıdakine benzer bir çıktı elde edersiniz.

  PID TTY          TIME CMD
 2241 ttyp4    00:00:00 bash
 2346 ttyp4    00:00:00 ps

Daha önce de belirtiğim gibi bu çıktı tam değil, ancak şimdilik buna odaklanalım: ps komutu bize terminalde çalışan işlemlerin listesini vermektedir. Çıktının son sütünunda, işlemin çalıştırıldığı komutun adını görmekteyiz. Sözgelimi, Mozilla sanaldoku gezgini için mozilla, GNU derleyicisi için gcc vs. Çalışmakta olan işlemlerin listesi ekranda gösterilirken ps işlemi de çalıştığından, kendisin de bu listede görünmesi çok doğal. Listede görünen diğer işlemler şunlardır: Benim terminalde çalışan Bourne Again Shell (kabuk ortamı) dir.

Bir an için TIME ve TTY bilgilerini gözardı edelim ve PID (işlem numarası) bilgisine bakalım. Pid, her işleme tekil olarak verilen, sıfırdan farklı pozitif bir sayıdır. İşlem sona erdiğinde, işlem numarası tekrar kullanılabilir. Ancak, işlem çalıştığı sürece, bu numaranın deyişmeyeceği garantilenmektedir. Sizin ps komutundan elde edeceğiniz çıktı, yukarıdakinden farklı olacaktır. Söylediklerimin doğruluğunu sınamak için, başka bir terminal açıp orada ps komutunu çalıştırdığınızda aynı listeyi elde edeceksiniz, ancak bu sefer işlem numaraları farklı olacaktır. Bu da, çalışan işlemlerin birbirlerinden farklı olduklarını göstermektedir.

Linux makinamızda çalışan tüm işlemlerin listesini elde etmek de mümkündür. ps komutunun man sayfasına göre, -e parametresi, tüm işlemleri seçmek için kullanılmaktadır. Şimdi bir terminalde "ps -e" komutunu çalıştıralım. Elde edeceğimiz liste, yukarıdaki biçimde, ancak çok daha uzun olacaktır. Listeyi daha rahat bir şekilde incelebilmek için ps komutunun çıktısını ps.log dosyasına yönlendirelim:

ps -e > ps.log

Oluşan dosyayı, tercih ettiğiniz herhangi bir kelime işlemci veya en basitinden less komutu yardımıyla inceleyebilirsiniz. Yazının başında da sözünü ettiğim gibi, çalışan işlem sayısı, tahmin ettiğmizden daha fazladır. Ayrıca, listede komut satırından veya grafik ortamdan çalıştırdığımız programların dışında da işlemler çalştığını fark ediyoruz. Bu işlemlerden bazılarının isimleri de biraz garip. Sistemizde çalışan işlemler ve sayıları, sisteminizin yapılandırılmasına bağlıdır. Ancak, bazı ortak işlemler de vardır. Sistemde yaptığınız ayarlar ne olursa olsun, işlem numarası 1 ve tüm işlemlerin babası olan işlemin adı hep "init" tir. İşlem numarasının 1 olması, bu programın işletim sistemi tarafından hep ilk sırada çalıştırılmasındandır. Fark edeceğimiz başka bir konu da, bazı isimlerin sonlarının hep "d" karakteri olmasıdır. Bunlara verilen genel isim "daemons" dur ve sistemin en önemli işlemlerindendirler. init ve daemon ları daha sonraki bir yazıda ayrıntılı olarak ele alacağız.

libc'de çokluişlem

İşlem kavramını ve onun işletim sistemimiz için olan önemini kavradığımıza göre, daha ileriye giderek çokluişlem yapan programlar yazmaya başlayacağız. Aşıkar olan aynı anda birden fazla işlemin birden çalıştırılmasından, daha yeni bir problem olan, işlemler arası haberleşme ve zamanlamasına (senkronizasyon) geçeceğiz. Bu problemi iki güzel yöntem olan mesajlar (messages) ve sayaçlar (semaphors) ile çözeceğiz. Bunlar süreçler (threads) konusunu işleyeceğimiz sonraki bir yazıda ayrıntılı olarak anlatılacaktır. Daha sonra da, tüm anlatılan yöntemleri kullanarak, kendi uygulamalarımızı yazma zamanı gelecektir.

Standart C kütüphanesi (Linux,taki libc, glibc tarafından uyarlanmıştır.) Unix System V'in çokluişlem yöntemlerini kullanmaktadır. Unix System V (Bundan sonra SysV diyeceğiz.) ticari bir Unix uyarlaması olup, BSD Unix ailesi gibi SysV Unix ailesinin de yaratıcısıdır.

libc'de işlem numarasının değerini tutmak üzere, tamsayı (integer) şeklinde, pid_t değişken tipi tanımlanmıştır. Bunadan böyle, pid_t değişken tipini işlem numaraları için kullanacağız. Sırf daha basit olması açısından tamsayı değişken tipini kullanmak da mümkündür.

Programımızın işlem numarasını elde etmeye yarayan fonksiyona bir gözatalım.

pid_t getpid (void)

Bu fonksiyon, pid_tile birlikte unistd.h ve sys/types.h başlık dosyalarında tanımlanmıştır. Şimdi, amacı kendi işlem numarasını bildiren bir program yazalım. Tercih ettiğiniz bir kelime işlemcisi yardımı ile aşağıdaki programı yazınız.

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

int main()
{
  pid_t pid;
  
  pid = getpid();
  printf("The pid assigned to the process is %d\n", pid);

  return 0;
}
Programı print_pid.c adında kaydedin ve aşağıdaki komutla derleyin:
gcc -Wall -o print_pid print_pid.c
Derleme sonucunda, print_pid adlı çalıştırılabilir bir program elde etmiş olacaksınız. Eğer, bulunduğunuz dizin yoltanımında yoksa, programı ancak "./print_pid" olarak çalıştırabilirsiniz. Programı çalıştırdığınızda çok büyük bir sürpriz ile karşılaşmayacaksınız: Ekrana pozitif bir sayı görüntileyecektir ve eğer birden fazla çalıştırılırsa da, sayılar hep birbirinden farklı ve birer birer artan sayılar olacaktır. Ardışık sayılar elde edilmesi herzaman mümkün olmayabilir. Bu zaten olması gereken bir durum da değil. Çünkü, print_pid programının ardışık çalıştırılması arasında başka işlemler sistem tarafından çalıştırılmış olabilir. Ardışık çalıştırma arasında sözgelimi, ps komutunu çaştırın.

Şimdi bir işlemin nasıl yaratılacağını öğrenme zamanı geldi. Ancak, daha önce, bu işlem sırasında gerçekte neler olduğunu açıklamak gerek. Bir A işlemi içerisinden B işlemi yaratıldığında, iki işlem benzerdir, yani ikisi de aynı koda sahip, bellek alanı aynı veriler ile dolu (Bellek alanı aynı değil.) ve aynı işlemci durumuna sahiptir. Bu aşamadan sonra işlemler iki farklı şekilde devam edebilir: Kullanıcının vereceği giriş bilgisine veya rastgele olan bir veriye göre. A işlemine baba "father", B işlemine de oğul (son) adı verilmektedir. init işleminin tüm işlemlerin babası deyimi şimdi daha iyi anlaşılıyor olması gerekir. Yeni işlem yaratan fonksiyonun adı:

pid_t fork(void)
dır. Fonksiyonun dönüş değeri işlem numarasıdır. Daha önce de söylediğimiz gibi, işlem kendisini baba ve oğul olarak, daha sonraları farklı işler yerine getirmek için, ikiye ayırmaktadır (kopyalamaktadır). Ancak, bu işlemden hemen sonra, hangisi önce çalışacaktır? Baba mı, oğul mu? Cevap çok basit: İkisinden biri. Hangi işlemin çalıştırılması gerektiği işletim sistemin zamanlayıcı(scheduler) adı verilen bir kısmı tarafından denetlenmektedir ve bu kısım, işlemin baba mı, oğul mu olup olmadığına bakmaksızın, başka parametreleri göze alarak belli bir algoritmaya göre karar vermektedir.

Herneyse, ancak hangi işlemin hangisi olduğunu bilmek önemlidir, çünkü programın kodu aynıdır. Her iki işlem de hem baba işlemin hem de oğul işlemin kodunu içerecektir. Önemli olan herbirinin kendine düşen payı çalıştırmalarıdır. Konuyu daha anlaşılır yapmak için, programlama ötesi bir dil gibi gözüken aşağıdaki algoritmaya bir gözatalım:

- FORK
- EĞER OĞUL İSEN BUNU ÇALIŞTIR (...)
- EĞER BABA İSEN BUNU ÇALIŞTIR (...)
fork fonksiyonu dönüş değeri olarak, oğul işleme '0' işlem numarasını, baba işleme de oğul işlemin işlem numarasını üretmektedir. Dolayısıyla dönüş değerine bakarak hangi işlemde olduğumuzu öğrenmemiz mümkündür. C programlama dilindeki karşılığı aşağıdaki gibi olacaktır.
int main()
{
  pid_t pid;

  pid = fork();
  if (pid == 0)
  {
    OĞUL İŞLEMİN KODU
  }
  BABA İŞLEMİN KODU
}
Çokluişlem olarak çalışan gerçek bir program yazma zamanı geldi artık. Aşağıdaki, programı fork_demo.c dosyası olarak kayıt edip, yukarıdaki komutlara benzer şekilde derleyebilirsiniz. Program kendi kopyasını yaratacak ve hem baba ve hem de oğul işlemler birşey yazacaktır. Herşey yolunda giderse, elde edeceğiniz çıktı ikisinin bir karışımı olacaktır.
(01) #include <unistd.h>
(02) #include <sys/types.h>
(03) #include <stdio.h>

(04) int main()
(05) {
(05)   pid_t pid;
(06)   int i;
  
(07)   pid = fork();
  
(08)   if (pid == 0){
(09)     for (i = 0; i  < 8; i++){
(10)       printf("-SON-\n");
(11)     }
(12)     return(0);
(13)   }
  
(14)   for (i = 0; i < 8; i++){
(15)     printf("+FATHER+\n");
(16)   }

(17)   return(0);
(18) }

(01)-(03) satırları gerekli başlık kütüphaneleri (standart Giriş/Çıkıs I/O, çokluişlem) programa dahil etmektedir.
GNU ortamlarında olduğu gibi, main programı bir tamsayıyı, program hatasız bir şekilde sona erdiğinde sıfır, hatalı olarak sona erirse hata numarasını, dönüş değeri olarak üretmektedir. Şu an için herşeyin hatasız sonuçlanacağını varsayalım. Temel kavramlar anlaşıldıkça programa, hata denetimleri de ekleyeceğiz. (05) satırında, işlem numarasını tutacak değişkeni tanımlıyoruz. (06) satırda, döngülerde kullanacağımız bir tamsayı değişkeni tanımlıyoruz. Daha öncede açıklandığı gibi tamsayı ve pid_t tipleri eşdeğerdir. Biz burada sırf daha anlaşılır olsun diye böyle kullanıyoruz.
(07) satırında, oğul işlemine sıfır ve baba işlemine oğul işlemin işlem numarasını geri gönderecek olan fork fonksiyonunu çağırıyoruz. Hangisinin hangisi olduğu denetim satırı (08) dir. (09)-(13) satırları oğul, geriye kalan (14)-(16) satırları da baba işleminde çalıştırılacaktır.
Hangi işlemin çalıştığına bağlı olarak, kısımların yaptığı tek şey 8'er defa ekrana "-SON-" veya "+FATHER+" yazıp 0 değeri ile programı bitirmektedir. Bu çok önemlidir, çünkü "return" olmadan, oğul işleminin komutlarını yerine getrdikten sonra programın akışı gereği baba işlemlerine geçerek devam edebilir. İsterseniz bir deneyin. Bilgisayarınıza zarar vermeyecektir, ancak istediğimizi de tama olarak yerine getirmemiş olacaktır. Bu şekildeki hataların ayıklanması zor olmaktadır, çünkü özellikle karmaşık çokluişlem programların çalıştırılması sonucunda farklı sonuçlar elde edilecek ve böylece hata ayıklanması imkansızlaşacaktır.

Programı çalıştırdığınızda belkide sonuçtan memnun kalmayacaksınız. Nedenine gelince, elde edilen sonuçların "+FATHER+" ve "-SON-" bir karışımından ziyade, önce "+FATHER+" satırlarının ardından "-SON-" gelecek veya tam tersi de olabilir. Ancak, programı birden fazla defa çalıştırın, belki o zaman sonuç değişebilir.

Herbir printf'ten önce rastgele bekleme süresi eklersek, daha fazla çokluişlem hissi elde etmiş oluruz. Bunu yapabilmek için sleep ve rand fonksiyonlarını kullanıyoruz.

sleep(rand()%4)
Bu komut sayesinde program, 0 ile 3 arasında (% tamsayı bölmede kalan kısmını üretmektedir.) rastgele bir sayı kadar bekleme yapacaktır. Şimdi program aşağıdaki gibi değişmiştir:
(09)  for (i = 0; i < 8; i++){
(->)    sleep (rand()%4);
(10)    printf("-FIGLIO-\n");
(11)  }
Aynı şeyi baba işlemin program parçası için de yapmak gerek. Değiştirilen programı fork_demo2.c adı altında saklayın ve derledikten sonra bir çalıştırın. Şimdi daha yavaş çalışmaktadır, ancak çıktıdaki sıra farklılığını daha iyi görme fırsatımız oldu:
[leo@mobile ipc2]$ ./fork_demo2
-SON-
+FATHER+
+FATHER+
-SON-
-SON-
+FATHER+
+FATHER+
-SON-
-FIGLIO-
+FATHER+
+FATHER+
-SON-
-SON-
-SON-
+FATHER+
+FATHER+
[leo@mobile ipc2]$

Şimdi karşımıza çıkacak problemlere bir göz gezdirelim: Çokluişlem ortamında baba işleminden farklı işler yapabilecek birden fazla oğul işlemi yaratmamız olasıdır. Doğru zamanda doğru işi yapabilmek için, baba ilşlem, oğul işlemler ile haberleşmek zorundadır. En azından bir zaman ayarlanması (synchronize) yapılması gerekmektedir. Böyle bir zaman ayarlanmasını olası kılan ilk yöntem wait fonksiyonunu kullanmaktır.

pid_t waitpid (pid_t PID, int *STATUS_PTR, int OPTIONS)
Buradaki PID, sonuçlanmasını beklediğimiz işlemin işlem numarası, STATUS_PTR oğul işlemin sonucunun saklandığı tamsayı tipinde bir işaretçi (Eğer, bilgi gerekmiyorsa, bu işaretçinin değeri NULL olmaktadır.) ve OPTIONS çeşitli seçeneklerin seçilebileceği bir değeri, ancak biz şimdilik onunla ilgilenmiyoruz, göstermektedir. Bu, baba işlemin oğul işlemin sona ermesini beklediği ve daha sonra programı sona erdirdiği bir örnektir.
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>

int main()
{
  pid_t pid;
  int i;
  
  pid = fork();
  
  if (pid == 0){
    for (i = 0; i < 14; i++){
      sleep (rand()%4);
      printf("-SON-\n");
    }
    return 0;
  }

  sleep (rand()%4); 
  printf("+FATHER+ Waiting for son's termination...\n");
  waitpid (pid, NULL, 0);
  printf("+FATHER+ ...ended\n");

  return 0;
}
Baba işleminin kısmına sleep fonksiyonu, farklı çalıştırma durumlarını ortaya koymak için eklenmiştir. Programı fork_demo3.c olarak kayıt edelim ve derledikten sonra da çalıştıralım. İşte şimdi zaman ayarlaması yapılmış ilk programımızı yazmış olduk!

Bir sonraki yazıda zamanlama ayarlaması ve işlemler arası iletişim konusu hakkında daha fazla bilgi edineceğiz. Şu ana kadar anlatılan fonksiyonları kullanarak kendi programlarınızı yazın ve açıklamaları ile birlikte .c dosyası halinde bana gönderin. Böylece ben aralarından iyi olan çözümler ile kötüleri gösterme şansı elde etmiş olurum. Adınızı ve e-posta adresinizi yazmayı unutmaın. İyi çalışmalar!

Önerilen kaynaklar