Daha dün gibi hatırlıyorum: Windows 95’in sahip olduğu işletim sistemi çekirdeğini (kernel) taskmanageri üzerinden şutlayabiliyordunuz. Akabinde tüm sistem çalışmaz hale geliyordu. Bu konularla ilgisi olmayanları kendine hayran bırakmak için fena bir yöntem değildi.
Bu bahsettiğim bilgisayarlar birkaç MB hafızası olan, tek bir işletim birimine (CPU) sahip, bir düğmesine basıldığında işletim biriminin çalışma hızını ikiye ya da üçe katlayan, bugünkü perspektiften bakıldığında taş devrinden kalma bilgisayarlardı. Geliştirilen uygulamalar da bu bilgisayarları limon gibi sıkıp, son hafıza hücresine ve işletim birimi döngüsüne (CPU cycle) kadar kullanmaya çalışırlar, ama kaynak yetersizliğinden dolayı bunda pek başarılı olamazlardı. Kısaca o zamanlarda kullanımda olan donanımın geliştirilen uygulamalar için yetersiz olduğunu söyleyebiliriz. Uygulamalar bir sonraki işletim birimi jenerasyonu ve daha fazla hafıza alanı ile hızlanır, bu donanımlarda geliştirilen uygulamalar aynı performansı göstermek için bir sonraki donanım jenerasyonunu beklemek zorunda kalırlardı.
Yazılımın hep böyle donanımı köşeye sıkıştırması, donanım mühendislerinin ışık hızı sınırlarına dayandıktan sonra, işletim birimlerinin hızını alıştıkları gibi artıramayacaklarını anlamaları ve çok çekirdekli donanım mimarisine yönelmelerine kadar devam etti. Bu noktaya gelene kadar yazılım mühendisleri donanım mühendislerine takılarak, “biz birden fazla işletim birimi varmış gibi programlarımızı yazarız, aynı işletim birimini paylaşarak çalışırlar ve en azından geliştirdiğimiz uygulamalar kullanıcıya birden fazla işletim birimi varmış gibi numara yaparlar” söylemleri ile yetindiler. Doğal olarak bu tür programlama tarzına alıştılar. Birden fazla işletim birimli sistemler çıktığında semaforlar ve threadler yardımı ile geliştirdikleri uygulamaları birden fazla işletim birimi üzerinde koşturmaya devam ettiler, eski program yazma stillerinden ödün vermeden, bunun gelecekte programcı olarak var olma ya da olmamanın cevabı olacağının farkına bile varamadan. Yazılımcıların çoklu thread (multithreading) uygulamaları geliştirmekte başarılı oldukları söylenemez. Günümüzde bile birçok programcı bir Java uygulamasında iki threadin senkronizyonunda zorluk çekmektedir.
Günümüzdeki kullanımda olan modern bir bilgisayarın en az iki ya da dört çekirdeği var. Bu yazıyı 2.8 GHz hızında, iki çekirdekli bir notebook üzerinde yazıyorum. Kullandığım işletim sistemi yükü eşit bir şekilde bu çekirdeklere dağıtmaya çalışıyor. Ne kadar başarılı olduğu tartışılır. Kullandığım bazı programlar birden fazla çekirdeğin üzerinde koşacak şekilde geliştirilmemiş. Bu yüzden beklenen performansı sağlayamıyorlar. Donanım mühendislerinin bir köşeden bizi izleyip “iki çekirdeği bile doğru dürüst programlayamıyorlar, kısa bir zaman sonra binli çekirdeklerle nasıl başa çıkacaklar acaba” dediklerini duyar gibiyim.
Bir uygulamanın optimal bir şekilde birden fazla çekirdeği kullanabilmesi için, uygulama parçalarının threadler yardımı ile birden fazla çekirdek üzerinde koşturulmaları gerekir. Bu çoğu zaman veri kaybını önlemek için threadler arası senkronizyonu zorunlu kılar. Java dilinde synchronized kelimesi ile birden fazla threadin aynı kod bloğunu koşturması engellenir. Bu tür bir sekronizasyon anlaşılması zor, komplike uygulamaların oluşmasına sebep verir. Tek bir thread ile istenilen tarzda bir davranış sergileyen bir uygulama, ikinci bir thread devreye girdiğinde çok başka bir davranış biçimi sergileyebilir. Böyle bir uygulamanın bünyesinde barındırdığı hataları debugging yöntemi ile keşfetmek çok zor bir hal alabilir. Kısaca bu tür uygulamaları geliştirmek, koşturmak ve hataları gidermek çok zordur ve çok az sayıda programcı bu durumlarla baş edebilecek yeteneklere sahiptir.
Kullandığımız imperatif (Java, C/C++, Python, PHP, Javascipt) diller thread senkronizyonu problemi haricinde başka bir sorunla karşılaşmamıza neden olmaktadırlar. İmperatif dillerde yapılan işlemler hafızada yer alan verilerin değiştirilmesi üzerine kuruludur. Bunun en basit örneği aşağıda yer alan Groovy kodunda görülmektedir.
for(i in 1..10){ println(i); }
Groovy örneğinde for döngüsü başlatılmadan önce i ismini taşıyan bir değişken oluşturulur. Bilgisayarın hafıyasında i değişkeninin sahip olduğu değeri tutabilmek için 32 bit (bir int) uzunluğunda bir alan kullanılır. i nin başlangıç değeri 1 olduğu için, bu hafıza alanında yer alan değer 1 (00000000 00000000 00000000 00000001) olacaktır. Birinci döngü sona erdikten sonra i ye yeni bir değer atanır. Bu değer 2 rakamıdır. Değişkenin ismi değişmemiş olsa bile sahip olduğu değer değişmiştir. Bu imperatif dillerde sıkça karşılaştığımız bir değer atama işlemidir. Daha öncede belirttiğim gibi imperatif dillerde yapılan işlemler hafızada yer alan değerlerin değiştirilmesi üzerine kuruludur. Uygulama doğrudan hafızaya erişme (sadece kendi hafıza alanına) ve orada yer alan değerleri manipüle etme yetkisine sahiptir. İşlem gören her satır uygulamanın sahip olduğu anlık durumun (state) degişmesi anlamına gelmektedir.
Eğer i global bir değişken ve for döngüsü bir hata nedeniyle sonlanmış olsaydı, i kendisine atanan en son değeri korurdu. Örneğin i değişkeninin sahip olduğu değere bağımlı olarak başka bir threadin işlem yapmak için beklediğini farz edelim. i döngü içinde istenilen değere ulaşamadığı için diğer thread belkide hayatının sonuna kadar beklemek zorunda kalabilirdi. Tabi bazı programlama teknikleri ile bu gibi durumların önüne geçmek mümkün. Lakin görüldüğü gibi imperatif dillerde uygulamanın sahip olduğu anlık durumun koşturulan her kod satırı ile değiştirilebilir olması, bu durumun geçerliliğinin (consistency) korunmasını çok zor kılmaktadır. Aynı durum OOP’de kullanılan nesneler için de geçerlidir. Nesneler sınıf değişkenleri aracılığı ile belli bir duruma (object state) sahiptirler. Sahip oldukları metotlar aracılığı ile bu durum değiştirilebilir. Herhangi bir metot bünyesinde bir hatanın oluşması, sınıf değişkenlerine geçersiz değerlerin atanmasına ve böylece nesnenin korumaya çalıştığı durumun geçersiz hale gelmesine sebep verebilir.
Şimdi on ya da yirmi sene sonrasını hayal edelim. Binlerce çekirdeği olan bir işletim birimini geliştirdiğimiz imperatif tarzı bir uygulama nasıl optimal kullanabilir? Threadleri senkronize etmeye çalışarak bunu başarmamız mümkün değildir. Bu sebepten dolayı şimdiden binlerce çekirdeği olan bir işletim birimini optimal bir şekilde nasıl programlarız sorusuna cevap aramamız gerekmektedir. Aslında bu soruyu sorarak programcı olarak nasıl bir geleceğe sahip olacağımızın cevabını arıyoruz. Programcı olarak geleceğimizin nasıl şekilleneceği bu sorunun cevabında gizli.
Bahsettiğim problemleri aşmanın ve binlerce çekirdeği olan bir sistemi programlamanın yolu, değişkenlere tekrar, tekrar değer atamamaktan geçmektedir. Değişen değer senkronizasyonu mecbur kılmaktadır. Bu mecburiyet tüm çekirdeklerin aynı anda uygulama tarafından kullanılmasının önünde büyük bir engel teşkil etmektedir. Bu mecburiyeti ortadan kaldırmak için kullandığımız veri yapılarını değiştirilemez (immutable) hale getirmemiz gerekmektedir. Bu bir değişkene bir kere bir değer atandıktan sonra, bu değerin bir daha değiştirilemez olması gerektiği anlamına gelmektedir. Bu değişken uygulama son bulana kadar sahip olduğu değeri koruyabildiği taktirde, threadler arası senkronizasyon gerekliliği ortadan kalkar. Değişmeyen bir değer için senkronizasyon gerekli değildir.
Java gibi dillerde bir değişkene sadece bir kere değer atanması ve değişkenin bu değeri koruması final kelime ile sağlanabilir. final int i = 10; şeklinde bir tanımlama, daha sonra i=11; atamasının yapılmasına izin vermeyecektir. i her zaman 10 değerine sahip olacaktır. Bunun yanı sıra konstrüktör parametreleri aracılığı ile oluşturulan ve sınıf değişkenlerine set metotları aracılığı ile sonradan atama yapılmasına izin vermeyen nesneler değiştirilemez (immutable object) türdedir. Bu nesnelerin threadler arası paylaşımı hiçbir sorun teşkil etmez. Bu gibi yapılar thread güvenlidir (thread safe), çünkü içerikleri değişikliğe uğramaz/uğrayamaz.
Binlerce çekirdeği olan bir sistemi optimal şartlarda programlamanın yolu sonradan değiştirilemeyen (immutable) veri yapılarından geçmektedir. Peki bunun fonksiyonel programlama ile ne ilgisi var? Açıklayayım.
Fonksiyonel dillerde fonksiyonların sahip oldukları bazı özellikler şöyledir:
- Fonksiyonlar metot parametreleri aynı olduğu sürece hep aynı neticeyi geri verirler.
- Metot parametreleri fonksiyonlara pass by value mantığı ile verilir. Bu parametreler fonksiyon tarafından değiştirilemez. Fonksiyon yaptığı işlemin değerini geri verebilmek için yeni değişkenler oluşturur.
- Fonksiyonlar kendi bünyeleri dışındaki hiçbir veri ya da durum (state) üzerinde değişiklik yapmazlar. Bu yüzden fonksiyonların yan etkileri (side effects) yoktur. Aynı fonksiyon durmadan koşturulsa bile hiç bir durum değişikliği olmaz.
- Safkan fonksiyonel dillerde bir değişkene bir değer atandığı zaman, bu değer program son bulana kadar değişmez. Bu metot bünyesinde tanımlanan metot değişkenleri için de geçerlidir. Bu sebepten dolayı fonksiyonel dillerde for döngüsü içinde i değişkenine yeni bir değer atamak mümkün değildir. Değer atanmış bir değişkenin değeri değiştirilemeyeceği için yukarıda yer alan Groovy örneğini birebir fonksiyonel bir dilde yazmak mümkün değildir. Bunu gerçekleştirmek için fonksiyonel dillerde rekursiyon (recursive programming) kullanılır.
- Bir fonksiyon geri verdiği değer ile birebir yer değiştirebilir. Bunun ne anlama geldiğini aşağıdaki Clojure örneğinde açıklamaya çalışacağım.
(+ 1 1)
Yukarıda yer alan Clojure örneğinde 1+1 işlemi yapılmaktadır. + burada kullanılan fonksiyonun kendisi 1 ve 1 bu fonksiyona gönderilen parametrelerdir. Parantezler kullanılarak fonksiyonun nerede başlayıp, kullanılan parametrelerin nerede bittiği ifade edilir.
5 + 9 / 4 * 2 işlemini gerçekleştirmek için Clojure dilinde şu şekilde bir fonksiyon yazabiliriz.
(/ (+ 5 9 ) (* 4 2))
Bu Clojure örneğinde 3 değişik fonksiyon kullanılmaktadır. Birinci fonksiyon / fonksiyonudur. Bu fonksiyona iki parametre verilmektedir. Birinci parametre 5+9 un neticesi olan 14 değeri, ikinci parametre 4*2’nin neticesi olan 8 değeridir. Yani yukarıda yer alan fonksiyonu şu şekilde de yazabilirdik:
(/ 14 8)
Görüldüğü gibi bir fonksiyon geri verdiği değer ile yer değiştirebilmektredir. Bu sadece ve sadece fonksiyonun işlem yaparken yan etki oluşturmaması ve aynı parametreler ile aynı neticeyi geri vermesi durumunda mümkündür. Eğer fonksiyon işlem yaparken durum (state) değişikliğine sebep vermiş olsaydı, yapılan durum değişikliğine göre (* 4 2) işleminde 8 değeri yerine başka bir değer geri alınabilirdi ki bu da bölme işleminin yanlış değerler kullanılarak yapılmasına sebep olurdu.
Paralel programlamanın önündeki en büyük engel kullanılan programlama dilinin hafıyaya doğrudan erişerek mevcut değişkenlerin sahip olduğu değerleri manipüle edebilmesidir. Uygulamanın her parçası hafızada yer alan değerleri herhangi bir zamanda değiştirebilir. Bu hem parallel programlamayı hem de uygulamanın anlık hangi durumda olduğunu öngörmeyi zorlaştıran bir durumdur. Değiştirilemez veri yapılarının (immutable data structures) kullanılması uygulamanın sahip olduğu anlık durumu (state) öngörülebilir hale getirmekte ve paralel programlama için gerekli altyapıyı sunmaktadır. Fonksiyonel dillerin değiştirilemez veri yapılarını temel olarak almaları, bu dillerin parallel programlamada daha avantajlı bir konumda olmalarını sağlamaktadır.
Gelecekte imperatif dillerin yanısıra fonksiyonel dilleri de sıklıkla kullanacağız. Donanımın geleceği parallelikte, yazılımcı olarak bizim geleceğimiz de bu paralelliğe nasıl hükmedeceğimizde. Bundan sonra geliştireceğimiz uygulamalar donanımın bize sağladığı her türlü kaynağı en optimal şekilde kullanacak yapıda olmalı. Bunun için yeni dönemde bizi bekleyen yeniliklere hazırlanmalıyız.
EOF (End Of Fun)
Özcan Acar
Hocam enfes bir yazı olmuş.
Çok güzel bir yazı olmuş. Gerçekten yazılım mimarilerinde çok büyük değişimlere hazırlıklı olmalıyız.
Çok enfes harika bir yazı peki imperatif dil bilmenin fonksiyonel dil öğrebirken bir dezavantaj oluşturacağını düşünürmüsünüz çünkü anladığıma göre temelde taban tabana zıtlar
Kesinlikle hayir. Bircok seyi bilmek, farkinda olmak adaptasyonu, algilamayi kolaylastirir.