Yaptığımız projelerin bir bölümü eninde sonunda bu konuya dayanıyor. Genelde kanal gerekli olduğu halde Timer ya da Application.ProcessMessages gibi çarelere gitmeye çalışıyoruz Ve devamında hem görsel hem de işleyiş açısından istemediğimiz sonuçlarla karşılaşıyoruz. Halbuki kanalların kullanılması sanıldığı kadar zor değildir. Bu ön yargıyı bu makalede aşmaya çalışacağız.
Bu makale, ileri seviye Windows programcılığına geçiş kapısıdır. Bu makeledeki konuları öğrendikten sonra Win32 programcılığının en güçlü özelliklerini öğrenmiş olacaksınız.
Yanlız "ileri seviye" tabiri gözünüzü korkutmasın. Çünkü makeleyi bitirdiğinizde thread kullanımının aslında ne kadar kolay olduğunu göreceksiniz. Bununla birlikte bu makalede bu konu ile ilgili her ayrıntıyı anlatabilmemiz için yüzlerce sayfalık kitap yazmamız gereklidir. Bu yüzden burada işin mantığını kapıp, ihtiyacınız oldukça yardım dosyalarına müracaat etmeniz gerekebilir.
Kanallar hakkında hiç bir bilginiz olmayabilir. Bu makalede işin temelinden alacağız ve makaleyi bitirdiğinizde kendi programlarınıza kanal ekleyebilecek duruma geleceğinize inanıyorum.
Bu makale serisinde kanallar temelden anlatılmakla beraber, ileri seviye kanal kullanımı, VCL bileşenlerinde kanal kullanımı ve Muteksler de ana konularımız arasına girecektir.
Giriş
İlk başta bir kaç teorik bilgi ile başlamak istiyorum. Bu bilgilieri olabildiğince sıkmadan, kısa tutmaya çalışacağım.
Disk üzerindeki her bir dosya çalıştırıldığında artık windows için birer işlem(process) olurlar. Bir işlem Windows için fazla bir şey ifade etmez. Çünkü işlemler sadece hafızada belli bir bölgede var olmaktan sorumludur. Esas işlemi yapan kısım kanallardır(thread). Bir işlem en az bir adet kanala sahiptir. Win 3.1 gibi işletim sistemleri sadece bir adet kanala sahiptir. Ama Windows 95 ve üstü, Unix, OSX gibi işletim sistemleri birden fazla kanala sahip olabilirler.
İşletim sistemi bir programı ya da bir DLL’i ilk başta işlem olarak hafızaya taşır. Bu esnada işlem eylemsiz olarak durur. Bu işleme ait kanallar ise bizim belirlediğimiz ölçüde programın kodlarını çalıştırmaya başlar.
Her kanal, çalışma esnasında kendi eax, ebx, edx gibi register verilerini tutan bir yapıya sahiptir. Bu yapıya context(içerik) adı verilmektedir. Bu yapının Delphi’deki karşılığı TContext olup, Windows unitindedir. Bu record tipini incelediğinizde bir çok register’ı barındırdığını göreceksiniz. Eğer buradaki Delphi ve Assembler ile ilgili makaleyi ve buradaki fonksiyon çağırım makenizmalarını okudu iseniz şimdi anlatacaklarımız size tanıdık gelecektir. Çünkü kanalların işleyiş mantığı fonksiyon çağırımlarına benzemektedir. Ama devam edebilmek için bunları okumak zorunda değilsiniz.
Her bir kanal sahip olduğu önceliğe göre işletim sistemi tarafından işleme alınacaktır. Bu işleme alış esnasında sahip olduğu context‘deki register değerleri yüklenir. Esp, Ebp, Eip gibi registerlar da yüklendiğinden, kanal önceden kaldığı yerden önceki stack değerlerine göre işlemine devam edecektir. Ve yine önceliğine göre belli bir süre sonunda işletim sistemi bu kanalı durdurup register değerlerini context yapısında saklar. Daha sonra diğer bir kanalın context verisini yükleyerek başka bir kanalı işlemeye geçer. Ve hakeza bu işlem tüm kanallar için devam edip gider. İşletilme ve geçiş süreleri çok kısa olduğundan, biz sanki aynı anda tüm kanallar işletiliyormuş gibi zannederiz. Halbuki bütün kanallar belli bir sıra ile çok kısa sürelerde işletilip diğer bir kanala geçilmektedir. Yani her kanal aynı anda çalışmaz, biz öyle zannederiz. Tabi bu anlattıklarımız tek işlemci ve tek çekirdek için geçerlidir. Kanalların işletilme sürelerini ise kanalların önceliği belirlemektedir. Hangi kanal daha yüksek öncelikli ise o kanal daha fazla işletilme süresine sahip olur. Eğer kanal arkaplanda çalışma üzere ayarlanmış ise, diğer kanallar işlemlerini tamamladıktan sonra ancak bu kanala sıra gelecektir. Eğer kanal çok yüksek önceliğe sahip ise uzun bir süre bu kanal işleme devam edecek ve diğer kanallara sıra gelmeyecektir.
Demek ki, aynı anda bir kaç işlem yapmak istiyorsak kafa patlatıp, parmak yormamıza gerek yokmuş. Çünkü işletim sistemi zaten sizin için bu sistemi kurmuştur. Sadece biraz vaktiniz ayırmanız ve kanallar nasıl oluşturuluyor öğrenmeniz yeterlidir.
Kanal Oluşturma Yöntemleri
Aslında böyle bir başlıkla konuya giriş yapmak istemezdim. Ama kanal oluşturmayı öğrenmeden önce bir kaç şeyi açığa kavuşturmak istiyorum. Gerek forumlardan gerekse haber guruplarından kanallarla ilgili gelen sorulardan bir çoğu kanalların yanlış oluşturulması üzerine kaynaklanan yanlışlıklardan gelmektedir.
Normalde bir kanal windows ortamında CreateThread Windows API’si ile oluşturulur. Fakat VCL bize TThread isminde bir sınıf da sunmuştur. Kullanım açısından ayırt edebileceğiniz temel nokta şudur. Eğer kanalda yapacağınız işlemler VCL sınıflarını etkilemesi gerekiyorsa TThread sınıfını kanal oluşturmak için kullanıyoruz. Yok eğer VCL sınıflarını ilgilendiren bir işlem söz konusu değilse CreateThread API’sini kullanıyoruz. Bunun neden böyle olduğunu ileride VCL ve Kanal kullanımı ile alakalı başlıkda vereceğim. Muhtemelen Bölüm 2′de göreceksiniz. Bu yüzden biraz sabır.
Başlarken bu şekilde bir giriş yapmamın sebibi buradan kaynaklanıyor. Bir çok kişi belki de Windows API’lerine karşı bir soğukluktan dolayı CreateThread ile uğraşmak istemiyor ve CreateThread ile ilgili kısımları gözü kapalı atlayabiliyor. Bu yüzden eksik kanal bilgisi ile ileride bir çok sorunla karşılaşabiliyor. Ama şimdi göreceğimiz gibi kanal oluşturma işlemi sanıldığı kadar zor ve karmaşık bir işlem değil.
Bir Kanal Oluşturalım
Kanal oluşturma ile sorumlu Windows fonksiyonu CreateThread fonksiyonudur ve aşağıdaki gibi tanımlanmıştır:
function CreateThread(lpThreadAttributes: Pointer; dwStackSize: DWORD; lpStartAddress: TFNThreadStartRoutine; lpParameter: Pointer; dwCreationFlags: DWORD; var lpThreadId: DWORD): THandle; stdcall;
Bu fonksiyonun parametrelerinin kısaca açıklamasını verelim.
lpThreadAttributes: Kanalımıza atanacak olan bir çok güvenlik özelliğini belirtmemize yarar. Eğer nil girerseniz varsayılan güvenlik özellikleri kullanılacaktır. Win9x ve WinMe için bu değer nil olmalıdır. Bu konu ile alakalı daha fazla bilgi almak için sdk yardım dosyalarından SECURITY_ATTRIBUTES başlığına müracaat edebilirsiniz.
dwStackSize: Eğer kanal için özel bir stack büyüklüğü belirlemeyecekseniz bu değeri 0 olarak girmelisiniz. Böylelikle kanalın stack büyüklüğü program ile aynı olacaktır. Gerektiğinde stack boyutu otomatik olarak genişletilebilir.
lpStartAddress: Bu parametre esas işi gören parametredir. Çünkü kanal çalışmaya bu parametredeki fonksiyon işaretçisi ile beraber başlar. Burada tek yapmamız gereken fonksiyonun isminin önüne @ işareti vererek girmektir.
lpParameter: Bir önceki parametrede girdiğimiz kanal fonksiyonuna parametre yollamak istiyorsanız, bu parametrelerin içinde bulunduğu bir record’un adresini buraya girmelisiniz. Ayrıca record haricinde karakter veya sayı değelerini de parametre olarak yollayabiliriz.
dwCreationFlags: Bu parametre ile kanalın oluşturulur oluşturulmaz çalışıp çalışmayacağını belirliyoruz. Eğer bu parametreye CREATE_SUSPENDED girerseniz, kanal oluşturulur fakat kendisine çalışmaya başlaması için izin verilmez. Bu durumda siz ResumeThread fonksiyonu ile bu kanala çalışma talimatı vermedikçe kanal o şekilde çalışmadan duracaktır. Bir kez ResumeThread ile çalışmaya başladıktan sonra SuspendThread ile tekrar kanalı durdurabilirsiniz. Eğer bu parametreye 0 girerseniz, kanal oluşturulur oluşturulmaz çalışmak için sıraya girecektir.
lpThreadId: Bu parametreye kanal kimlik ID’si atanır. Buraya değeri olmayan bir Dword değişken giriyoruz. Değişken gireceğiz çünkü "var" olarak tanımlanmış. Çünkü kanal oluşturulduktan sonra Windows NT,2000 ve üstü işletim sistemlerinde kanalın ID’si bu değişkene yüklenir. Windows 9x/Me işletim sistemlerinde bu değer daima 0′dır.
Şimdi gelin bu fonksiyonu kullanarak bir kanal oluşturalım.
var KanalID: DWORD; begin CreateThread(nil, 0, @KanalFonksiyonu, nil, 0, KanalID);
İşte hepsi bu! Tek yaptığımız sadece gerekli parametreleri kullanmak, gerisini duruma göre nil veya 0 yapmak. Bu fonskiyon çağrıldığında KanalFonksiyonu isimli fonksiyonumuz kanal içinde çalışmaya başlayacaktır.
Dilerseniz KanalFonksiyonu isimli fonksiyonumuzun tanımlamasını şimdi yapacağımız örnek içinde verelim.
Basit Bir Örnek
Şimdi oluşturduğumuz bu kanalın nasıl çalıştığını basit bir örnek ile görmeye çalışalım. Yeni bir uygulama oluşturalım ve Form üzerine bir adet button ve bir adet label koyalım. Button’nun Caption özelliğini "Oluştur ve Çalıştır" olarak değiştirelim. Ve Button’nun OnClick olayına kanalımızı oluşturan şu kodları girelim:
procedure TForm1.Button1Click(Sender: TObject); var KimlikID: DWORD; begin CreateThread(nil, 0, @KanalFonksiyonu, nil, 0, KimlikID); end;
Şimdi de kanalımızda esas işi yapan KanalFonksiyonu isimli fonksiyonumuzun tanımlamasını girelim:
function KanalFonksiyonu(P: Pointer): Longint; stdcall;
...
implementation
...
function KanalFonksiyonu(P: Pointer): Longint; stdcall;
var
i: Integer;
Toplam: Int64;
begin
Toplam := 0;
for i := 0 to 1000000000 do
begin
Toplam := Toplam + i;
Toplam := Toplam shl 4;
end;
Form1.Label1.Caption := 'Kanal işlemini tamamladı. Sonuç:' + IntToStr(Toplam);
end;
Kanal fonksiyonumuzun tanımlamasını istediğimiz gibi yapabilirdik. Burada mesela P: Pointer parametresine ihtiyaç yoktu. Ayrıca Longint çıktısını vermeye de ihtiyacımız yok. CreateThread fonksiyonunun parametre açıklamalarını hatırlarsanız, kanal fonksiyonumuza belli parametreler yollayabiliyorduk. İşte bu parametreleri pointer olarak buradan alıyoruz. Ama bu meseleye sonra geçeceğiz. Ayrıca fonksiyon tanımlamasındaki stdcall ifadesine yabancı iseniz buradaki fonksiyon çağırım makenizmaları ile ilgili makaleyi okuyabilirsiniz.
Bu fonksiyonda ekstra bir şey yapmıyoruz. Sadece bir for döngüsü var ve matematiksel bir işlem yapılıyor. For döngüsü bittiğinde ise formdaki label’a bir sonuç yazdırıyoruz.
Bu for döngüsünü, ayrı bir kanalda değil de programın ana kanalında çalıştırmaya kalksaydık muhtemelen Application.ProcessMessages ve TTimer gibi çözümlere gidecektik. Bu durum da çok fazla miktarda performans düşüklüğüne sebep olacaktı. Ayrıca işlemlerimiz genelde buradaki gibi basit matematiksel işlemler olmadığından, programda çökmelere ve donmalara sebep olacaktık.
Bu programı çalıştıralım ama button’a basmayalım. Eğer açık değilse View menüsünden "Thread Status" penceresini ve "Event Log" penceresini açalım ve görebileceğimiz bir köşeye yerleştirelim. Eğer BDS 2005 ve üstü kullanıyorsanız bu pencereler debug esnasında altta görünür biçimde hazır olacaklardır.
Ardından buttona bir kez basalım ve "Thread Status" penceresine yeni bir eleman eklendiğine dikkat edelim. Eğer buradaki eleman çok çabuk bir şekilde görünüp kayboldu ise işlemciniz benimkinden hızlı demektir
. Bu yüzden for döngüsündeki limit değeri biraz artırıp tekrar çalıştıralım.
Button’a her tıkladığınızda "Thread Status" pencersinden de takip edebileceğiniz gibi yeni bir kanal eklenecek. Tabi butona basma işini o kadar çok abartmayın. Her kanal oluşturulduğunda kendisine bir ID atanacak. Örneğimizde bu değer KimlikID değişkeninde tutuluyor. "Thread Status" pencersinde ise ThreadID sütununda bu değeri görebiliriz. Her kanal işlemini bitirdiğinde "Thread Status" pencersinden kaybolduğunu göreceksiniz. Aynı şekilde "Event Log" pencersinde de kanal’ın ID’sine göre başlama ve bitişi ile ilgili mesajları görebilirsiniz.
Kanal çalışırken program üzerinde istediğiniz işleme devam edebilirsiniz. Formu taşıyabilir, boyutlandırabilir, başka bileşen ve nesneler var ise onları kullanabilirsiniz. Ve bunu yaparken bir yandan kanal(lar) da çalışmaktadır. Aynı kanal fonksiyonunu doğrudan çağırmaya kalktığımızda formumuza döngü bitene kadar müdahale edemeyecektik.
Gördüğünüz gibi bir kaç satır kod ile kanal oluşturuveriyoruz. Tek bilmemiz gereken kısım CreateThread fonksiyonunun parametreleri. Bunların çoğunu da kullanmadık. Ama makalenin ilerleyen kısımlarında bunları da yavaş yavaş kullanıma dahil edeceğiz. İşte bir tanesi geliyor.
Kanal Fonksiyonlarına Parametre Gönderimi
CreateThread fonksiyonunun parametrelerini açıklarken lpParameter‘den söz etmiştik ve bu parametre ile kanal fonksiyonuna istediğimiz parametreyi gönderebileceğimizi belirtmiştik. Şimdi bunu nasıl yapacağımızı görelim.
Bunun için yukarıda yaptğımız örnekten faydalanacağız. İlk başta type bloğunda aşağıdaki record tanımlamasını yapalım.
PKanalParametresi = ^TKanalParametresi;
TKanalParametresi = record
BirParametre: string;
end;
Ardından kanal fonksiyonumuzu aşağıdaki gibi değiştirelim.
function KanalFonksiyonu(P: Pointer): Longint; stdcall;
var
Parametreler: PKanalParametresi;
begin
Parametreler := PKanalParametresi(P); //Parametremizi alalım.
while True do
begin
Form1.Label1.Caption := Form1.Label1.Caption + Parametreler^.BirParametre;
Sleep(1000); //İşlemcinin %99 çalışmasını istemiyiz değil mi....
end;
Dispose(Parametreler);
end;
En son olarak button’nun OnClick olayını da aşağıdaki gibi olacak şekilde değiştirelim.
procedure TForm1.Button1Click(Sender: TObject); var KimlikID: DWORD; Parametreler: PKanalParametresi; begin New(Parametreler); Parametreler^.BirParametre := 'a'; CreateThread(nil, 0, @KanalFonksiyonu, Parametreler, 0, KimlikID); end;
Aslında çok fazla bir şey yapmadık(en azından siz kopyala yapıştır yaptınız:) ). Burada basit bir işaretçi kullanımını görüyoruz. Ve parametre olarak bir record’u kullandık. Record yerine başka veri tiplerini de kullanabilirdik. Ama genelde karşılaşılan sorunlar recordları parametre olarak yollamaktan gelmektedir.
En başta verdiğimiz record tanımlamasına bakarsanız, PKanalParametresi isminde bu recordumuza işaretçi olan bir tip göreceksiniz. Parametremizi işaretçi olarak kullanacağımızdan bu tipe ihtiyacımız olacak. Ardından Button’nun OnClick olayına bakalım. Burada önceki örneğe göre fazladan 2 satır ekledik. İlk başta var bloğunda Parametreler isminde bir record işaretçisi tanımladık. Ardından işaretçi hiç bir şey ifade etmediğinden, işaretçiye ait bir recordun hafızada oluşturulması için New rutinini kullandık. Devamında bu record’un değişkenlerinden birine bir string değer atadık. CreateThread fonksiyonu parametreyi işaretçi olarak istediğinden buraya direk olarak Parametreler değişkenimizi girebiliriz. Çünkü bu değişken bir record işaretçsidir.
En son olarak yeni kanal fonksiyonumuza baktığımızda, işaretçi olarak gelen parametremizin nasıl kullanıldığını görüyoruz. Burada bizim için önemli olan kısım Dispose kısmıdır. Çünkü burada New ile oluşturduğumuz ve işaretçiye ait olan recordu hafızadan siliyoruz.
Record işaretçisi yerine normal record kullansa idik ve sonra da bunu parametre olarak geçirirken pointer olarak geçirse idik olmazmıydı. Olurdu… Üstelik New ve Dispose rutinlerini de kullanmak zorunda kalmazdık. Ama, bu durumda boş yere hafızda yer işgal edecektik. Çünkü parametre recordu sadece bir kerelik işimize yarayacak. Bu yüzden işimiz bitince Dispose ile hafızdan siliyoruz.
Bu örneğimizi de çalıştırıp deneyebilirsiniz. Ayrıca Görev Yöneticisi ile işlemciyi yüzde kaç kullandığını da görebilirsiniz. Tabi burada Sleep rutinini kullanmak yerine daha başka yapılabilecek şeyler var ama şimdilik kafa karıştırmamak için bunları sonraya bırakıyorum.
Sonuç
Bu bölümü burada noktalayalım. Gelecek bölümde kanalların nasıl birbirleri ile birlikte çalışabileceğini göreceğiz. Ayrıca Mutex’ler ve VCL ile kanal kullanımı da gelecek bölümde göreceğimiz konular arasında olacak. Böylece kanallar konusunda hiç bir şüpheniz, korkunuz kalmayacağı gibi programlarınıza da profesyonellik katacaksınız.
Gelecek bölümde buluşmak ümidi ile… Yorumlarınızı ve eleştirilerinizi bekliyorum. Yine her zaman ki gibi sorularınızı buradan yada Delphi Türkiye forumlarından iletebilirsiniz
Fatih Tolga Ata © 2007
Kaynaklar
- Windows SDK Yardım Dosyası
- Delphi 4 Unleashed, Charlie Calvert, Sams Pub., 1999

(18 kişi oy kullanmış, ortalama: 5 üzerinden 4,78)
[...] *** Fatih Tolga Ata’dan Delphi ve Kanallar ile ilgili çok güzel bir makale. Delphi ile Thread(Kanal) Kullanımı – Bölüm 1 [...]
Dostum yiner bir yaramıza derman oldun. Ne kadar teşekkür etsek az. Senin gibi kaliteli makale yazan kişiye az rastlanıyor. Devamını dileriz.
Sağolasın dostum. Sizin gibi okuyan ve ilgilenen oldukça, elimizden geldiğince yazmaya devam edeceğiz inş.
Thread ve Veritabanı uygulamaları uzun SQL leri nasıl thread kanalında kullanabiliriz imkanı varmı ? üstad imkanı varsa hemen bir makale isterim
eline sağlık bu arada
İstediğin şey olur mu bilmem ama 2. Bölümde VCL ile kullanımı anlatırken veritabanından örnek vermeyi düşünüyordum kısmetse.
AsyncCalls ‘i winapi ve TThread sinifi ile ugrasmak istemeyen arkadaslara tavsiye ederim. yazdiginiz her hangi bir fonksiyonu parametre olarak belirtip ilgili fonksiyonun ayri bir thread icinde calismasini sagliyor.
kanal olusturma yontemlerini ogrendik peki ya kanal durdurma yontemlerini ne zaman ogrenecez ?
http://andy.jgknet.de/async/
bu ipucunun ardindan yaziyla ilgili olarak sunu sormak istiyorum
terminate ile cok guzel sonlaniyor bu kanallar ama benim ugrasipta yapamadigiim calisan bir kanalin calismasini ana kanaldan aninda durdurmak. mesela icinde kanalin durdurulmasi gerektigini kontrol eden herhangi bir kod barindirmayan bir kanal nasil durdurulur. kanal da su kodun calistigini varsayalim.
ben ana kanaldan bu kanali durdurmayi basaramadim.
ikinci bir husus ise araya vcl i karistirinca kanallar bazen kilitlenmeye neden oluyor.
mesela AsyncCalls kullanilarak hazirlanmis surdaki ornekte birinci start dugmesine basinca progressbar adim adim ilerlemeye basliyor ve o sirada biz formdaki diger elemanlari gayet rahat bir sekilde kullanmaya devam edebiliyoruz. ne zaman ki ikinci start dugmesine basiyoruz progressbarlar ilerlemesine ragmen form ve uzerindeki bilesenler donuyorlar…
http://rapidshare.com/files/60459720/ornek1.rar.html
makalenin ikinci baskisi gelene kadar database ve thread icin, huseyin hocam suraya goz atabilirsin.
http://delphi.about.com/od/kbthread/a/query_threading.htm
AsyncCalss ile ilgili söyleyebileceğim bir şey yok. Kanal durdurma hadisesinde ve VCL kullanımını eğer ikinci bölümü beklerseniz cevaplarınızı bulacaksınız.
Teşekkürler.
vesselam.
Ellerine sağlık hocam, başarılarının devamını dileriz.
Size ne kadar tesekkur etsek azdir.Cok guzel ve aydinlatici bir makale.Benzer makalelerinizin devamini dileriz.iyi calismalar
arkadaş anlamadığım bir konu ama ileri seviye delpiyide öğrenmek istiyorum bunun adına da çok güzel bi amakale olmuş devamlarını dilerim
Gördüğüm en yararlı çalışmalardan biri. Teşekkür ediyorum.
Çalışmanız cok iyi aslında cok iyi anlatmışsınız ama ben yapamadım bu thread olayını bir formun mimized ve maximized olayına nasıl adapte ederiz
ben formumun ekrana arasıra gelmesini istiyorm timer nesnesine bağladım istediğimi alamadım butonlar pasif oluyo(tıklayamıyorum) thread a adapte edebilirsem cözmüş olacam teşekkürler.
[...] Bölüm 1 [...]
Merhaba üstad,
Öncelikle böyle güzel bir yazı dizisi için çok teşekkürler. Yalnız bir sorum olacaktı. CreateThread ile çalıştırılan bir kanalın içerisinden yine CreateThread metoduyla başka bir kanal açabiliyor muyuz?
Saygılar
Bir kanalın içinde başka bir kanal diyorsan böyle bir şey olmaz. Eğer bir kanalın içinden CreateThread ile bir kanal oluşturursan bu kanal farklı bir kanal olacaktır, diğer kanalın içinde olmayacaktır. Sonuçta oluşturduğun tüm kanallar, zaten bir başka kanalın içinden CreateThread çağrılarak oluşturulur. Mesela genelde, kanalları oluşturduğumuz programın ana kanalı da sonuçta bir kanaldır, her ne kadar ana kanal otomatik olarak oluşssa da…
Fatih Bey Merhaba,
Konu anlatımınız ve paylaşımınız için çok teşekkürler, emeğinize sağlık. Benim size sormak istediğim bir kaç konu var thread ile ilgili. Mailinizi öğrenemedim.
Threat icinden forma ait textbox yada label gibi nesenelerin text iceriklerini degistirebilirmiyim?
emeğinize sağlık.
çok güzel bir makale olmuş