1. Amaç    2

2. Thread Kavramı    2

3. Thread Oluşturma    3

4. Thread Kimliği    4

5. Thread Sonlandırma    5

void pthread_exit(void *rval_ptr);    5

int pthread_join(pthread_t thread, void **rval_ptr);    5

int pthread_cancel(pthread_t tid);    7

void pthread_cleanup_push(void (*rtn)(void *), void *arg);    8

void pthread_cleanup_pop(int execute);    8

6. Thread Senkronizasyon    12

6.1 Mutex    12

6.2 Deadlock Kaçınmak    15

6.3 Zaman Aşımlı Mutex    15

6.4 Okuma-Yazma Kilitlemeleri (Read-Write Locks)    16

6.5 Zaman Aşımlı Okuma-Yazma Kilitlemeleri (Read-Write Locks)    20

6.6 Koşul Değişkenli Kilitlemeler (Condition Variables)    20

6.7 Spin Lock    23

6.8 Bariyer Kullanımı    25

7. Son    28

Bu belgenin Pdf halini buradan indirebilirsiniz.

1. Amaç

Bu belgenin amacı thread kullanımını ve thread senkronizasyonunu açıklamaktır. Bu ana başlıklar altında aşağıdaki konulara değinilecektir. 

  • Thread oluşturma
  • Thread kimliği
  • Thread sonlandırma
  • Threadlerin senkronizasyonu
    • Mutex
    • Deadlock kaçınmak
    • Zaman aşımlı Mutex
    • Okuma-Yazma Kilitlemeleri (Read-Write Locks)
    • Zaman aşımlı Okuma-Yazma Kilitlemeleri (Read-Write Locks with timeouts)
    • Koşul Değişkenli Kilitlemeler (Condition Variables)
    • Spin Lock
    • Bariyer Kullanımı

Bu konular aslında “POSIX thread” kütüphanesinin sağladığı özelliklerden oluşmaktadır. Dolayısı ile bu kütüphaneyi kullanmak için projemize “pthread” kütüphanesini eklememiz gerekmektedir. 

2. Thread Kavramı

Bilindiği üzere uygulamalar/işlemler (process) işletim sistemi tarafından yürütülür ve her bir program bir zaman diliminde tek bir işlem yapabilir. İşte bu aynı anda tek işlem yapma problemini çözmek için thread kullanılır. Thread uygulamanın “alt iş parçacığı” anlamına gelir ve kendisine verilen görevi yerine getirir. Dolayısı ile birden fazla thread sahip olan bir uygulama aynı anda birden fazla işi  yapabilir hale gelir. Aynı anda birden fazla iş yapma faydasını yanında thread kullanımı bize birçok fayda sağlar. 

  • Asenkron(ayrık zamanlı) oluşan olayların her birine bir thread(iş parçacığı) atayarak oluşan olayları ilgili thread içinde ele alınabilir.
  • Uygulamaların aksine threadler içinde bulundukları uygulamaya ait hafıza alanlarına ve uygulama içinde oluşturulmuş dosya tanımlayıcılarına kolaylıkla erişebilir.(same memory address space and file descriptors.) Yani kısaca threadler aynı değişkene ve aynı dosyaya erişme kolaylığı vardır. Fakat uygulamalar arası bu işlemler kolaylıkla ve hızlıca yapılamaz.
  • Kullanıcı etkileşimli programlar kullanıcı girdi ve çıktılarını ayrı bir thread içinde yaparak daha hızlı kullanıcı yanıtı oluşturabilirler. Arka planda yapılması gereken tüm işler girdi ve çıktı threadlerinin dışındaki başka bir thread koşar.

Çoklu thread yapısının çoklu işlemcili yada çekirdekli donanım gerektirmesi söz konusu değildir. Posix thread tek çekirdekli bir işlemci üzerinde de kullanılabilir. 

Geliştirme yapılan projede Posix Thread aktif olup olmadığını anlamak için _POSIX_THREADS makrosu #ifdef ile sınanabilir. Tabi bu sınama derleme zamanına aittir. Eğer çalışma zamanında bu sınama yapmak istenirse sysconf() fonksiyonu _SC_THREADS parametresi ile çağrılmalıdır.

3. Thread Oluşturma

#include <pthread.h>

int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr,                           

void *(*start_rtn)(void *), void *restrict arg);
                                                Returns: 0 if OK, error number on failure

Projemize pthread ekledikten sonra yukarıda bildirilen pthread_create() fonksiyonu ile thread oluşturulur. Pthread_t türündeki tidp işaretçisi pthread_create() fonksiyonun başarılı olması sonucu oluşan threadin kimlik değerini taşır. Bu sayede bu thread için yapılması ihtiyaç olabilecek bazı işlemlerde bu kimlik değeri kullanılacaktır. attr argümanı oluşturulacak thread özelliklerini belirtmek için kullanılır. Fakat bu değere NULL değeri atanması durumunda thread varsayılan özelliklerde oluşturulur. start_rtn argümanı threadin koşturacağı işin bulunduğu fonksiyonun adresidir. arg ise thread koşacağı fonksiyona geçirilecek parametrenin adresini taşır. 

Thread oluşturulması ile thread start_rtn adresinde belirtilen fonksiyonu koşmaya başlar. Fakat önce thread fonksiyonu mu yoksa thread oluşturmak isteyen kod parçası mı çalışır bu net değildir. Örnek olarak aşağıdaki kod parçasına baktığımızda öce printf(“func foo \n”); satırı mı yoksa printf(” calling thread \n”); satırı mı koşar bu net değildir.

void * foo(void *arg)
{
    printf("func foo \n");
}
pthread_create(&tid, NULL, foo, NULL);

printf(" calling thread \n");

pthread_create() fonksiyonu başarısız olduğunda hata değeri döner. Fakat bu hata durumaları için şu önemli bilgiyi de vermek gerekir. Hata durumunda pthread_create() fonksiyonu diğer POSIX fonksiyonları gibi global errno değişkenine hata değerin yazmaz. Bu durumda hata fonksiyonun geri dönüş değeri ile sadece çağrıldığı fonksiyon içinde görülür. 

#include <stdio.h>
#include <pthread.h>

pthread_t tid;

void * threadFunc(void *arg)
{
    printf("Thread function \n");
    return((void *)0);
}

int main(void)
{
    int err;
    err = pthread_create(&tid, NULL, threadFunc, NULL);

    if (0 != err)
            printf("can’t create thread \n");

    printf("main thread \n");
    sleep(1);
    return 0;
}

Yukarıdaki kod thread oluşturmaya örnek olması amacıyla verilmiştir. Fakat daha öncede belirtildiği gibi önce thread fonksiyonu mu yoksa main fonksiyonu mu çağrılacağı garantisi yoktur. Ayrıca buradaki sleep(1) çağrımının mecburiyeti bu tutarsızlıktan kaynaklanmaktadır. Çünkü önce main fonksiyonu koşma durumunda thread fonksiyonu sonlanmadan main fonksiyonu sonlanabilir. Zaten thread kullanımının zorluklarını bu gibi durumlar ve senkronizasyon oluşturmaktadır.

4. Thread Kimliği

Thread oluşturma başlığı altında her oluşturulan threadin bir kimlik değeri(thread ide) olduğunu görmüştük. Bu kimlik değeri pthread_t türündendir. 

Thread kimliğini ayrı bir başlık altında anlatma gereksinimi linux sürümlerinden ve POSIX destekleyen diğer sistemlerden kaynaklanmaktadır. Bu farkları bilmediğimiz durumda hem taşınabilir hem de doğru kodlama yapmamış oluruz. Linux 3.2.0, pthread_t veri türü için “unsigned long int” kullanır. Solaris 10, pthread_t veri türünü “unsigned int” olarak temsil eder. FreeBSD 8.0 ve Mac OS X 10.6.8, pthread_t veri türü için “struct pthread_t” kullanır.

#include <pthread.h>int pthread_equal(pthread_t tid1, pthread_t tid2);
                                                                                                      Returns: nonzero if equal, 0 otherwise
#include <pthread.h>pthread_t pthread_self(void);
                                                                            Returns: the thread ID of the calling thread

Yukarıdaki fonksiyonlar taşınabilir kod yazmamızı sağlar.

5. Thread Sonlandırma

Threadller üç farklı yöntemle sonlandırılabilir.

  1. Thread fonksiyonu içinde return ile çıkılması.
  2. Bir başka thread tarafından sonlandırılması (pthread_cancel() )
  3. Thread içinde pthread_exit() çağrılması

 Bu fonksiyonların kullanımı yerine ve amaca göre değişiklik gösterir. Bu üç kullanım temelde thread sonlandırsa da çalışma biçimlerinden dolayı üç kullanımında detaylarını bilmek gerekir.

#include <pthread.h>
void pthread_exit(void *rval_ptr);

Thread içinde pthread_exit() fonksiyonu çağrılarak içinde bulunduğu thread çağrıldığı noktadan itibaren sonlanmasını sağlar. rval_ptr pointer kullanımına dikkat etmek gerekir. Eğer sonlandırılan thread için pthread_join() fonksiyonu kullanılmış ise rval_ptr pointer pthread_join() tarafından kullanılır. Bu kullanımda genellikle rval_ptr pointer için otomatik ömürlü değişkenin adresinin yüklenmesi hatası yapılıyor. pthread_join() çağrımı sonrasında bu adres kullanılacak ise rval_ptr yüklenen adres thread sonlandırılması sonrasında da geçerli olmalıdır. rval_ptr içeriği genelde thread sonlanma nedenini açıklayan değer ile ilişkilendirilir. Tabi kullanıcı kullanım kuralına uyarak istediği verinin adresini de yükleyebilir. Eğer bu pointer içeriği ile ilgilenilmek istenmiyor ise NULL adres verilebilir.

#include <pthread.h>
int pthread_join(pthread_t thread, void **rval_ptr);
                                                                                                Returns: 0 if OK, error number on failure

pthread_join() bildiriminde **rval_ptr thread sonlanması sonrasında gönderilen veriyi taşır. Bu değer yukarıda belirtilen üç farklı sonlandırma yöntemi tarafından doldurulur. Return ve pthread_exit() ile kullanıcı istediği değeri geri dönebilir iken pthread_cancel() kullanımı sonrasında rval_ptr içeriği PTHREAD_CANCELED olur. 

pthread_join() ilgili thread sonlanmasına kadar çağrım yerindeki thread bloklar. Tabi pthread_join fonksiyonunun hata ile karşılaşmaması durumunda blocklar. Örneğin pthread_join() ilk argümanında belirtilen thread eğer çoktan sonlanmış ise bu durumda EINVAL hata değeri ile döner ve block gerçekleşmez.

    err = pthread_create(&tid1, NULL, funcThread, NULL); 
    if (err != 0)
            err_exit(err, "can’t create thread 2");

    err = pthread_join(tid1, &tret);  // tid1 thread sonlana kadar burada kalır

    printf("After pthread_join \n"); //tid1 thread sonlanması sonrasında çalışır
#include <stdio.h>
#include <pthread.h>

void * thr_fn1(void *arg)
{
    printf("thread 1 returning\n");
    return((void *)1);
}
void * thr_fn2(void *arg)
{
    printf("thread 2 exiting\n");
    pthread_exit((void *)2);
}

int main(void)
{
    int err;
    pthread_t tid1, tid2;
    void *tret;

    err = pthread_create(&tid1, NULL, thr_fn1, NULL);
    if (err != 0)
            printf("can’t create thread 1");

    err = pthread_create(&tid2, NULL, thr_fn2, NULL);
    if (err != 0)
            printf("can’t create thread 2");

    err = pthread_join(tid1, &tret);
    if (err != 0)
            printf("can’t join with thread 1");
    printf("thread 1 exit code %ld\n", (long)tret);

    err = pthread_join(tid2, &tret);
    if (err != 0)
            printf("can’t join with thread 2");
    printf("thread 2 exit code %ld\n", (long)tret);

    exit(0);
}

Yukarıdaki örnek kodumuzu koştuğumuz zaman aşağıdaki çıktıyı alırız.
thread 1 returning
thread 2 exiting
thread 1 exit code 1
thread 2 exit code 2

Thread sonlandırmak için kullanılan son fonksiyon ise pthread_cancel() fonksiyonudur. 

#include <pthread.h>int pthread_cancel(pthread_t tid);
                                                                            Returns: 0 if OK, error number on failure

Sonlanmasını istediğimiz thread kimlik değeri bu fonksiyona gönderilerek thread sonlanma isteği gerçekleşir. pthread_cancel() aslında direk thread sonlandırmaz, sonlanması için istekte bulunur. Istek biçiminde çalıştırken isteğinin gerçekleşmesine kadar sistemi bloklamaz. Bundan dolayı sonlanma işlemini gerçekleşmesini beklemek için pthread_cancel() çağrımından hemen sonra pthread_join() çağrılarak sonlanma beklenmiş olur. pthread_cancel() çağrımı ile sonlanan thread pthread_join() fonksiyonuna geri dönüş değeri olarak PTHREAD_CANCELED değeri döner. 

#include <stdio.h>
#include <pthread.h>

void * thr_fn1(void *arg)
{
    while(1)
    {
            printf("thread 1 returning\n");
            sleep(1);
    }
}
int main(void)
{
    int err;
    pthread_t tid1;
    void *tret;

    err = pthread_create(&tid1, NULL, thr_fn1, NULL);
    if (err != 0)
            printf("can’t create thread 1");

    sleep(3);

    pthread_cancel(tid1);
    sleep(2);
    pthread_join(tid1, &tret);

    if (PTHREAD_CANCELED == tret)
    {
            printf(" Thread Cancelled \n");
    }

    return 0;
}

Bu örnekte okuyucunun şuna dikkat etmesini isterim: *tret void türden bir pointer ve karşılaştırma satırında – if (PTHREAD_CANCELED == tret) – tret göstergesinin içeriğine bakılmıyor (dereference). Dolayısı ile bu if içindeki karşılaştırma pointerın taşıdığı adresin karşılaştırması oluyor.  Bu durumda PTHREAD_CANCELED makrosu da adres taşıyor olması lazım ve hatta türü de (void *) olmasılır. #define PTHREAD_CANCELED ((void *) -1) olduğunu phtread.h dosyasına bakınca görüyoruz.

#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void *), void *arg);
void pthread_cleanup_pop(int execute);

Thread sonlanmasından hemen sonra otomatik olarak bir fonksiyonun çağrımını sağlayabiliriz. Kısacası callback fonksiyonları olan bu bu fonksiyonlara “thread cleanup handlers” denir.

Thread sonlandırma başlığı altında thread sonlandıran üç adım belirtmiştik. O adımların üçü thread sonlandırsa da sadece pthread_exit() ve pthread_cancel() sonrasında thread sonlanma geri bildirim (thread cleanup handlers)  fonksiyonu çalışır. 

  • pthread_exit() çağrımı
  • pthread_cancel() çağrımının karşılık bulması,
  • pthread_cleanup_pop() sıfır olmayan argüman geçirilmesi.

Eğer pthread_cleanup_pop() sıfır argümanı geçilir ise thread sonlanması sonrası çağrılacak olan geri bildirim fonksiyonu çalışmaz. Kısacası pthread_cleanup_pop() aktif yada pasif yapmak için kullanılır. 

pthread_cleanup_push() ve pthread_cleanup_pop() fonksiyon olmayıp birer makrodur ve bir çift olarak kullanılmak zorundadır. Yani pthread_cleanup_push() makrosu kullanıldı ise arkasındapthread_cleanup_pop() makrosu kullanılmak zorundadır. Aksi durumda derleme hatası alınır. pthread_cleanup_pop() çağrımının yeri de önemlidir ve genelde thread fonksiyonunun en altında olur. Çünkü pthread_cleanup_pop() satırı sonrasında callback fonksiyonu çağrılır. Eğer pthread_cleanup_pop() thread  fonkisyonunun ortasında olur ise thread sonlanmamasına rağmen sonlanma geri bildirim fonksiyonu çağrılmış olur ki bu durum mantıksız kodlamaya yol açar. Aşağıda doğru ve yanlış kullanımı açıklayan birkaç örnek bulunmaktadır. 

Bir thread için birden fazla geri bildirim(cleanup handlers) fonksiyonu tanımlanabilir. Fakat çağrımları tanımlanma sırasının tersine olur.  Yani ilk tanımlanmaya ait callback fonksiyonu enson çağrılır.

Çıktı:
thread 1 start
cleanup: thread 1 second handler
cleanup: thread 1 first handler

Örneğin daha net anlaşılması için mavi ve kırmızı renkler ile pthread_cleanup_push() – pthread_cleanup_pop() çiftleri belirtilmiştir. Kırmızı çift mavi çiftin dışında olduğu için ilk olarak mavi çiftin çıktısı görülür.

6. Thread Senkronizasyon

Thread senkronizasyonu, thread kullanımında önemli yer edinir. Bu başlık altındaki bilgiler olmadan projelerde thread kullanımı yapılamaz. Örneğin birden fazla thread olan bir uygulamada eğer bu theadler ortak bellek alanına erişiyor ise her bir thread doğru değerleri görmelidir. 

Tüm threadler okuma amaçlı bir ortak belleğe erişmesi durumunda sorun oluşmaz. Fakat thread bazıları bu alandaki verileri güncellemeye kalkması durumunda uygumala içinde problemlere neden olur. Çünkü bilindiği üzere thread aynı anda birbirinden bağımsız olarak çalışan iş parçacıkları olduğu için bir thread yazarken diğer thread aynı veriyi okumaya kalkışması sonucu yanlış değeri almış olur. Aşağıdaki resim bu problemi göstermektedir.

Problemin çözümü için belirlenen alan üzerinde threadlerin işlerini sırasıyla yapmasını sağlamak gerekir. Yani A-Thread yazma işlemi bitene kadar B-Thread bu alandan okuma yapması bekletilmelidir. Bundan sonraki alt başlıklarda bu ve buna benzer durumlar için çözüm sağlayan Posix thread senkronizasyon fonksiyonlarını göreceğiz.

6.1 Mutex

Kritik bir alanın aynı anda sadece bir thread tarafından işlenmesi istenildiği zaman ilk gelen thread izin verilip diğer threadlerin beklemesini sağlamak gerekir. İşte bu senaryoyu gerçekleştirmek için Mutex fonksiyonları kullanılır.

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
                                              const pthread_mutexattr_t *restrict attr);int

pthread_mutex_destroy(pthread_mutex_t *mutex);

                                                                                       Both return: 0 if OK, error number on failure

Mutex bir anahtar olarak düşünürsek  ilk  olarak bu anahtarı oluşturmamız gerekir. Bunun için mutex kullanımında ilk olarak pthread_mutex_init() fonksiyonu kullanılır. attr NULL adres yüklenirse mutex varsayılan değerlerde oluşturulur. Anahtar oluşturmanın bir diğer yolu da pthread_mutex_t türündeki değişkene ilk değer atamasında PTHREAD_MUTEX_INITIALIZER kullanarak olur. İlk değer ataması olduğuna dikkat etmenizi isterim. 

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_init() fonksiyonu ile yada ilk değer ataması ile oluşturulan mutex değişkeni artık kilitleme ve akabinde açmaya hazırdır. 

#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_trylock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);
                                                                                              All return: 0 if OK, error number on failure

pthread_mutex_lock() ve pthread_mutex_trylock() kitleme için pthread_mutex_unlock() ise kitlemeyi kaldırmak için kullanılır. 

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void foo(void)
{
    pthread_mutex_lock(&mtx);
    counter++;
    /**
     * some critical codes
     */
    pthread_mutex_unlock(&mtx);
}

Yukarıda mutex örnek kodunun birden fazla thread tarafından çağrıldığını düşünelim. pthread_mutex_lock() fonksiyonuna ilk ulaşan thread kitleme işlemini yapmış sayılır ve kodun geri kalanını koşmaya devam eder. Kitleme için geç kalan diğer threadler ise pthread_mutex_lock() çağrımını gerçekleştiremez ve dolayısı ile o noktada kilidin kalkmasını beklerler. Linux çekirdeği bekleyen bu threadleri anahtar açılana kadar uyutur. Böylece threadler işlemci için boşuna yük oluşturmaz. İlk giren thread işlerini bitirip pthread_mutex_unlock() fonksiyonunu çağırdığında artık kilit açılmıştır ve diğer bir thread uyanıp içeri girebilir. 

pthread_mutex_lock() için bekleyen thread uykuya alınması duruma göre hem avantaj hem de dezavantaj oluşturmaktadır. Çünkü çok kısa bir bekleme için thread uyutulması maliyeti avantajının önüne geçip dezavantaj oluşturmaktadır. Bu durumlar için farklı senkronizasyon fonksiyonları da var. İlerleyen başlıklar altında bu durumu da inceleyeceğiz.

pthread_mutex_trylock() fonksiyonu bloklu olmayacak şekilde kilitleme yapmaya çalışır. Eğer ilgili alan daha öncesinden kilitlenmiş ise EBUSY değerini döner ve kitleme gerçekleşmez.

#include <pthread.h>
#include <stdio.h>
#include <stddef.h>
#include <errno.h>

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

void foo(char *msg)
{
    if (EBUSY == pthread_mutex_trylock(&mtx))
    {
            printf("Try Lock return EBUSY \n");
    }
    else
    {
            return;
    }

    while(1)
    {
            /** critical codes */
            printf("Msg: %s \n", msg);
            sleep(3);
            break;
    }

    pthread_mutex_destroy(&mtx);
}

Farklı kodlama yöntemleri ile pthread_mutex_trylock() fonksiyonunun kullanımı daha avantajlı hale getirilebilir. Mutex kullanımında thread beklemeye girdiğini ve bu bekleme ufak zaman dilimleri için dezavantajlı olduğunu söylemiştik. İşte bu dezavantajı bir nebze gidermek için aşağıdaki kullanım uygulanabilir.  

       while (pthread_mutex_trylock(&mutex) != 0) {
            // wait - treated as spin lock in this example
        } 
        counter++;
        pthread_mutex_unlock(&mutex);

Bu kullanımda while içinde kilitleme işlemi yapılana kadar boşta zaman harcanır. Tabi boşta zaman harcama cpu kullanımına neden olur. Zaten bu kullanım çok kısa beklemelerin olduğu yerde kullanılır.

6.2 Deadlock Kaçınmak

Deadlock kaçınmak tamamen programcının elinde olan bir durumdur. Deadlock tamamen kodlama hatasından kaynaklanır. Bu başlık altında yapılan belli başlı hatalar  dile getirilecektir. 

  1. Aynı mutex peş peşe iki defa kilitlemeye çalışmak: Bu hata genelde çalışma zamanında fonksiyonların çağrılma sırası yada koşulu gözden kaçınca oluşmaktadır.
void foo(void)
{
    pthread_mutex_lock(&mtx);

    /** .....  */

    pthread_mutex_unlock(&mtx);
}

void func(void)
{
    pthread_mutex_lock(&mtx);

    /** .....  */
    foo();

    pthread_mutex_unlock(&mtx);
}

Örnekte func fonksiyonu içinde foo() çağrılması durumunda deadlock meydana gelir. Çünkü func() içinde mutex kilitlendiği için foo() içinde tekrar kilitleme çağrımı yapılıyor. Bu çağrımdan dolayı bloklanma yaşanır ve bu bloklanma artık açılamaz ve deadlock durumuna düşülmüş olur.

6.3 Zaman Aşımlı Mutex

#include <pthread.h>

#include <time.h>

int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,
                                                            const struct timespec *restrict tsptr);

                                                                                                 Returns: 0 if OK, error number on failure

Normal mutex kullanımına ek olarak zaman aşımı özelliği sunmaktadır. Verilen süre boyunca blocklu olarak bekler. Eğer zaman dolduysa ve hala kilitleme işlemini yapamadıysa ETIMEDOUT değeri ile döner. Zaman değişkeni olarak timespec structure kullanılır. 

Zaman aşımı sapmalarla gerçekleşebilir. Çünkü fonksiyon çağrısında saniye sayıcı herhangi bir değerde olup o değerden devam edebilir, sistem zaman sayıcısı istenilen hassasiyette olmayabilir, çekirdek zamanlayıcısının(scheduling) sapmaları olabilir. Bu fonksiyon çok kısa zaman aralıkları için kullanım avantajlı değildir. Küçük bir zaman dilimi için sistemde timer çalışması maliyeti artırır.

6.4 Okuma-Yazma Kilitlemeleri (Read-Write Locks)

Okuma yazma kilitlemeleri Mutex sertliğini düşürmek için kullanılır. Mutex kilitlemesinde kullanıcının ne yapacağını önemsemeden direk seçili kod bölgesi kilitlenir. Aslında  birden fazla kullanıcı sadece okuma amacıyla gelmiş olduğunda bunları engellemeden çalışmasına izin verilse ve gelen okuyup gitse buna karşılık yazma amacıyla gelen bir kişi normal mutex gibi herkesi bekletse daha etkili kodlama yapılmış olur. Okuma-Yazma Kilitlemeleri işte tam bunu yapmakatadır. Okuma alanındaki kilitleme birden fazla thread için izin verirken yazma alanındaki thread tek bir threade izin verir. Yazma kilitlemesi aktif olduğunda hem okuma hem de başka yazma amacıyla gelen threadler bekletilir. 

#include <pthread.h>

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

                                                                                                 All return: 0 if OK, error number on failure
#include <pthread.h>

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
                                              const pthread_rwlockattr_t *restrict attr);int

pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

                                                                                        Both return: 0 if OK, error number on failure

Olayı birkaç senaryo ile daha net anlaşılabilir kılalım. Örneğin bir pthread_rwlock_t değişkeni foo_r fonksiyonu içinde thread_1 tarafından pthread_rwlock_rdlock() fonksiyon ile okuma korumalı kilitlemesi yapılmış olsun. Tam bu esnada thread_2 ve thread_3 de foo_1 fonksiyonunu çağırmış olsun. Bu durumda foo_r fonksiyonu içinde pthread_rwlock_rdlock() okuma kilitlemesi yaptığı için gelen her thread izin vererek çalışmasına izin verir. foo_wr fonksiyonu içinde ise aynı pthread_rwlock_t değişkeni pthread_rwlock_wrlock() ile yazma kilitlemesi yapıyor olsun. Thread_1, thrad_2 ve thread_3 foo_r fonksiyonu içinde okuma yaparken thread_4 yazma amacıyla foo_wr çağırdığı anda bloklanır. Çünkü foo_r içinde okuma yapılıyor. foo_r içinde pthread_rwlock_unlock() çağrılarak kalkan kilit sonrasında thread_4 foo_wr içinde engellemesi kalkar ve yazma işlemi bölgesinde çalışmaya başlar.

Senaryonun tam tersini anlamak ise daha basittir. Bir fonksiyon içinde pthread_rwlock_wrlock() ile yazma kilitlemesi yapılmış ise artık seçili bölgeler hem okumaya hem de yazmaya kapalıdır. Aslında pthread_rwlock_wrlock() kullanımı normal Mutex kullanımı ile aynı davranışı göstermektedir.

Burdada şu noktayı kaçırmak gerekir. Hem pthread_rwlock_rdlock() hem de pthread_rwlock_wdlock() için aynı pthread_rwlock_t değişkeni kullanılmalıdır ve bu değişken daha öncesinde init edilmelidir. Init için tıpkı mutex olduğu gibi iki yöntem bulunmaktadır. Ya PTHREAD_RWLOCK_INITIALIZER makrosu ile ilk değer vererek yada pthread_rwlock_init() fonksiyonu ile init işlemi yapılır. Aşağıda doğru ve yanlış kullanım örnekleri verilmiştir.

Aşağıdaki kodlar doğru kodlama örneğidir ve bu örneğin altındaki diğer örnekler bu kodların bazı bölümlerini alınarak oluşturulmuştur. 

#include <pthread.h>
#include <stdio.h>
#include <stddef.h>
#include <errno.h>
#include <string.h>

struct Data
{
    int x;
    /** ... */
}gData;

pthread_rwlock_t rwMtx = PTHREAD_RWLOCK_INITIALIZER; // init rw-mutex

void readData(struct Data *d)
{
    pthread_rwlock_rdlock(&rwMtx); //read mutex

    memcpy(d, &gData, sizeof(struct Data));

    pthread_rwlock_unlock(&rwMtx); //unlock read mutex
}

void writeData(const struct Data *d)
{
    pthread_rwlock_wrlock(&rwMtx); //write mutex

    memcpy(&gData, d, sizeof(struct Data));

    pthread_rwlock_unlock(&rwMtx); //unlock write mutex
}

void printfData(char *str)
{
    struct Data d;
    readData(&d);

    printf("Caller: %s - Data::x %d \n", str, d.x);
}

void * thrFunc_r1(void *p)
{
    while(1)
    {
        printfData("thrFunc_1");
        sleep(1);
    }
}

void * thrFunc_r2(void *p)
{
    while(1)
    {
        printfData("thrFunc_2");
        sleep(1);
    }
}

void * thrFunc_w3(void *p)
{
    struct Data d = {.x = 1};
    while(1)
    {
        sleep(3);
        writeData(&d);
        d.x++;
    }
}
int main(void)
{
    int err;
    pthread_t tid1, tid2, tid3;

    err = pthread_create(&tid1, NULL, thrFunc_r1, NULL);
    if (err != 0)
            printf("can’t create thread 1");

    err = pthread_create(&tid2, NULL, thrFunc_r2, NULL);
    if (err != 0)
            printf("can’t create thread 2");

    err = pthread_create(&tid3, NULL, thrFunc_w3, NULL);
    if (err != 0)
            printf("can’t create thread 3");

    while(1) sleep(2);

    exit(0);
}

Çıktı:
Caller: thrFunc_2 – Data::x 0
Caller: thrFunc_1 – Data::x 0
Caller: thrFunc_2 – Data::x 0
Caller: thrFunc_1 – Data::x 0
Caller: thrFunc_2 – Data::x 0
Caller: thrFunc_1 – Data::x 0
Caller: thrFunc_2 – Data::x 1
Caller: thrFunc_1 – Data::x 1
Caller: thrFunc_1 – Data::x 1
Caller: thrFunc_2 – Data::x 1
Caller: thrFunc_1 – Data::x 1
Caller: thrFunc_2 – Data::x 1

Görüldüğü üzere okuma kilitleme ile çalışma threadler aynı anda okuma işlemini yapabilmektedir.

void readData(struct Data *d)
{
    pthread_rwlock_rdlock(&rwMtx); //read mutex

    memcpy(d, &gData, sizeof(struct Data));

//    pthread_rwlock_unlock(&rwMtx); //unlock read mutex
}

Yukarıdaki örnekte okuma kilidini kaldıran satır kaldırılmıştır. Dolayısı ile okuma kilidi hep aktif kalır ve yazma işlemi kilidinin çalışmasına fırsat vermez. Bundan dolayı thrFunc_w3 yazma kilidini kurmak istediğinde karşısına hep meşgul olan bir okuma kilidi çıkar ve hiç bir zaman yazma işlemini yapamaz. (Bu değişikliği kodunuzda yaparak uygulam açıktısını gözlemleyin.)

void writeData(const struct Data *d)
{
    pthread_rwlock_wrlock(&rwMtx); //write mutex

    memcpy(&gData, d, sizeof(struct Data));

//    pthread_rwlock_unlock(&rwMtx); //unlock write mutex
}

Yukarıdaki örnekte ise yazma kilidini kaldıran satır kaldırılmıştır. Dolayısı ile yazma kilidi hep aktif kalacaktır. Dolayısı ile thrFunc_w3 ilk writeData() çağrımında gerekli işler yapılır fakat ikinci çağrımında yazma kilidi aktif olduğundan tekrar yazma işlemini yapamaz ve kilit açılanan kadar beklemede kalır. (kilit açma olmadığı için sonsuza kadar çakılı kalır. deadlock) Bu esnada okuma threadleri okuma yapmak istediklerinde yazma kilidinin aktif olmasından dolayı okuma işlemi yapamaz ve o iki thread de sonsuza kadar beklemede kalır.

Yukarıdaki örneklerde okuma ve yazma kilitlerinde aynı pthread_rwlock_t değişkeni olan rwMtx kullanılmıştır. Eğer farklı değişken kullanılırsa kilitleme yapısı çalışmaz. Gerçi bu kural tüm mutex türleri için geçerlidir.

6.5 Zaman Aşımlı Okuma-Yazma Kilitlemeleri (Read-Write Locks)

#include <pthread.h>

#include <time.h>int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock,
                                                                const struct timespec *restrict tsptr);int

pthread_rwlock_timedwrlock(pthread_rwlock_t *restrict rwlock,
                                                                 const struct timespec *restrict tsptr);

                                                                                           Both return: 0 if OK, error number on failure

Zaman aşımlı Okuma-Yazma kilitlemeleri, normal okuma-yazma kilitlemelerindeki gibi bloklu olarak çalışmaz. Kilitleme yapamadığında kilitleme için zaman aşımı süresi kadar beklerler. Süre bildirimi timespec struct ile yapılır. Eğer kilitlenme olmayıp zaman aşımı dolduysa ETIMEDOUT değeri döner. 

6.6 Koşul Değişkenli Kilitlemeler (Condition Variables)

Bu başlığa kadar anlatılanları okuyucu hızlı bir şekilde anlayarak/okuyarak gelmiş olabilir. Mutex yapısını aslında anlamak zor değildir. Görüldüğü üzere kullanımı kolay bazı fonksiyonlardan oluşmaktadır. Hatta neredeyse hepsi iki yada üç parametreden oluşan fonksiyonlardır.

Mutex kullanımının zor kısmı çalışma zamanında yazılımcının kod akışını gözünde canlandırmasıdır.  Ne eksik nede fazla koruma yapılmaması gerekir.(deadlock) Thread başlama anları sistem bağımlı olmaktan çıkarılmalıdır. Threadlerin birbiri ile olan bağımlılıkları göz önüne alınarak gerekli korumalar ve senkronizasyonlar yapılmalıdır. Aslında thread senkronizasyon fonksiyonlarının çoğu kodlamayı kolaylaştırmak için büyük nimetlerdir.

#include <pthread.h>

int pthread_cond_init(pthread_cond_t *restrict cond,
                                           const pthread_condattr_t *restrict attr);

int pthread_cond_destroy(pthread_cond_t *cond);

                                                                                           Both return: 0 if OK, error number on failure
int pthread_cond_wait(pthread_cond_t *restrict cond,
                                              pthread_mutex_t *restrict mutex);

int pthread_cond_timedwait(pthread_cond_t *restrict cond,
                                                        pthread_mutex_t *restrict mutex,
                                                         const struct timespec *restrict tsptr);

                                                                                            Both return: 0 if OK, error number on failure
#include <pthread.h>

int pthread_cond_signal(pthread_cond_t *cond);

int pthread_cond_broadcast(pthread_cond_t *cond);

                                                                                           Both return: 0 if OK, error number on failure

Koşul değişkenli kilitlemeler ile thread içinde istenilen bir koşulun başkası sağlanana kadar threadin uykuya girmesi sağlanır. Koşul değişkeni başka bir thread tarafından uygun değere getirildikten sonra yine bu thread tarafından bu koşul nedeniyle uykuda olan threadler pthread_cond_signal() fonksiyonu ile uyandırılır. 

Yukarıdaki anlatıma örnek olması  için şu senaryoyu düşünelim: metin içeriği tutan bir bellek alanını okuyup metni yazan/gönderen bir thread düşünelim. Eğer bu bellek alanı boş ise bu thread birşey yapmayıp uyusun. (boşta beklemekte bir seçenek tabi ama bu bilgileri hiçe sayıp kötü kodlama yapmış oluruz) Başka bir thread ise bu metin alanını doldurup bu metni bekleyen threadi uyandırdığını planlayalım. Bu senaryoda ortak bellek alanı kullanımı olduğu için her iki thread de mutex kullanması gerekmektedir. 

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

char *msg = NULL;
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

void* print(void * v)
{
    while (1)
    {
            pthread_mutex_lock(&qlock);
            while (NULL == msg)
            {
                    pthread_cond_wait(&qready, &qlock);
            }

            printf("%s", msg);
            msg =  NULL; //message printed/sent, clear
            pthread_mutex_unlock(&qlock);
    }
}

void loadMsg(char *str)
{
    pthread_mutex_lock(&qlock);

    msg = str;
    pthread_mutex_unlock(&qlock);
    pthread_cond_signal(&qready);
}

int main(void)
{
    char str[64] = "";
    int counter = 0;
    pthread_t t;

    pthread_create(&t, NULL, print, NULL);

    sleep(1);

    while (1)
    {
            sprintf(str, "Test %d \n", counter++);
            loadMsg(str);
            sleep(2);
    }
    return 0;
}

while (NULL == msg) satırındaki koşul doğru ise threadin koşması için uygun koşul oluşmamaştır ve pthread_cond_wait(&qready, &qlock) çağrımı ile thread koşul oluşana kadar uykuya sokulur. Daha sonrasında loadMsg() çağrımı ile msg adresi doldurulur ve akabinde pthread_cond_signal() çağrımı ile bu koşulu bekleyen thread uyandırılır.

Burada dikkatli okuyucuların gözüne iki nokta takılmış olabilir. Birincisi neden if sınaması yerine while (NULL == msg) kullanıldı. Madem bu thread uykuda ne gerek var while kullanmaya. Uykuda iken while döngüsü mü dönecek. Cevap aslında temiz kodlamaya dayanmakta. Çünkü pthread_cond_signal() çağrımı sonra uyanan thread koşulu tekrardan doğru olup olmadığnı sınaması gerekir. Böylece koşul sağlanmadan uyandırılan thread tekrar uyutulmuş olur. İkinci  konu ise print() içindeki mutex ile uykuya girme sonrasında kilitli kalmasına rağmen(pthread_mutex_unlock() uyku nedeniyle çağrılmadığına dikkat edin) nasıl oluyor da loadMsg() içinde mutex kilitlemesi deadlock sebep olmuyor. Cevap pthread_cond_wait() parametrelerinden birinin bu mutex değişkeni olması.(qlock değişkeni). Uykuya girme esnasında kilitlenme geçici olarak iptal ediyormuş gibi düşünülebilir.

Bu arada aslında sleep(1) çağrımına da gerek yok. Bunun nedenini açıklamak okuyucuya bırakılmıştır. 🙂 Ayrıca isteyen aşağıdaki fonksiyon ile çağrım yaparak neler olduğunu gözlemleyebilir.

void* print(void* v)
{
    while (1)
    {
        pthread_mutex_lock(&qlock);

        printf("%s", msg);
        msg =  NULL; //message printed/sent, clear
        pthread_mutex_unlock(&qlock);
    }
}

6.7 Spin Lock

Spin Lock tıpkı Mutex gibi çalışır. İstenilen kritik alanı aynı anda tek bir thread tarafındak kullanılmasına izin verir. Fakat kilitleme sırasını beklerken Mutex gibi thread uykuya sokmaz. Bunun yerine boşta thread uyutmadan bekler. Bu durum fazla cpu kullanılmasına neden olsa da thread uyuma-uyanma maliyetinden kurtulunmuş olur. Dolayısı ile Spin Lock kullanımı çok kısa bekleme zamanlarını ön görüldüğü noktalarda kullanılır. 

#include <pthread.h>

int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

int pthread_spin_destroy(pthread_spinlock_t *lock);

                                                                                             Both return: 0 if OK, error number on failure
#include <pthread.h>

int pthread_spin_lock(pthread_spinlock_t *lock);

int pthread_spin_trylock(pthread_spinlock_t *lock);

int pthread_spin_unlock(pthread_spinlock_t *lock);

                                                                                                All return: 0 if OK, error number on failure

Spin Lock kullanımı önleyici olmadan çalışan kernel(çekirdek) kodlamasında kullanışlıdır. Alt seviye kodlama olan çekirdek içerisinde donanım kesmelerini(interrupt) yönetmek gerekir. Spin lock donanım kesmelerini de bloklar, böylece daha öncesinden kilitlenmiş alanı kesme fonksiyonu almaya çalıştığında deadlock durumu oluşmaz. Sistem çekirdek kodlarında(kernel) donanım kesmeleri uyutulamıyacağından Spin Lock kullanımı çekirdek katmanında mecburidir.

Fakat kullanıcı katmanında Spin Lock kullanımı avantajlı değildir. Çünkü uygulama seviyesini yöneten zaman yöneticisini(schedule) çalışmasının verimsiz olmasını sağlar. Zaten birçok Mutex kodlamasının hızı Spin Lock kadar iyidir. Çünkü Mutex’lede belli bir süre kilitleme çalışmasını tıpkı spin lock gibi boşta dönerek dener. Eğer bu süre belirlenen sürenin üzerine çıkar ise thread o zaman uykuya girer. 

Linux Kernel seviyesinde çalışabilecek örnek bir spin lock örneği

int  thread_fn2()
{
    int ret=0;
    msleep(100);
    ret=spin_trylock(&my_lock);
    
    if(!ret)
    {
            printk(KERN_INFO "Unable to hold lock");
            return 0;
    }
    else
    {
            printk(KERN_INFO "Lock acquired");
            spin_unlock(&my_lock);
            return 0;
    }
}

6.8 Bariyer Kullanımı

#include <pthread.h>

int pthread_barrier_init(pthread_barrier_t *restrict barrier,
                                          const pthread_barrierattr_t *restrict attr,
                                          unsigned int count);

int pthread_barrier_destroy(pthread_barrier_t *barrier);

                                                                          Both return: 0 if OK, error number on failure

Bariyerler birden fazla threadi paralel çalışmada senkronize etmek için kullanılır. Farklı zamanlarda başlayan tüm threadlerin aynı noktaya ulaşmasını beklemek gerektiğinde kullanılabilir. Yada bir işi birden fazla threade yaptırırken tü threadlerin işii bitirdiğinden emin olmak gerektiğinde de kullanılabilir.

Bariyer kullanımı yukarıda verilen senaryoların dışında birçok amaçla kullanılacağı açıktır. Özetle bariyer, istenilen sayıda threadin işlemini tamamlandığından emin olunmak için kullanılır. İşi tamamlaması beklenecek tread sayısı pthread_barrier_init() fonsiyonundaki count parametresi ile belirlenir.

#include <pthread.h>

int pthread_barrier_wait(pthread_barrier_t *barrier);

                    Returns: 0 or PTHREAD_BARRIER_SERIAL_THREAD if OK, error number on failure

Tüm threadler işini bitirdiğinde pthread_barrier_wait() fonksiyonu PTHREAD_BARRIER_SERIAL_THREAD değeri döner, hata durumunda ise hata kodu ile döner.

Bariyer kullanımı için söyle bir senaryo düşünelim. Elimizde 10000000 adet rastgele değerlerde olan tam sayı (int) olsun. Bu sayıları sıralamak isteyelim. Tek bir thread kullanımında on milyon sayıyı dizmek aşağı yukarı 2-2.5 saniye sürecektir. Fakat biz bu işlemi her 1000000 adeti bir thread üzerinde koşsak ve kendi içinde sıralanmış bu bir milyon sayıyı sonra grup halinde getirmek için birleştirsek işlem süresi oldukça kısa olacaktır. Not: sürenin sıralama algoritmasına doğrudan bağlı olduğunu da unutmamak gerekir.

#include <pthread.h>
#include <limits.h>
#include <sys/time.h>
#include <stdlib.h>
#include <stdio.h>

#define NTHR           (10)
#define NUMNUM         (10000000L)
#define TNUM           (NUMNUM/NTHR)
long nums[NUMNUM];
long snums[NUMNUM];

pthread_barrier_t b;

#ifdef LONG_MAX
#define heapsort qsort
#else
extern int heapsort(void *, size_t, size_t, int (*)(const void *, const void *));
#endif

/** Compare two long integers */
int complong(const void *arg1, const void *arg2)
{
    long l1 = *(long *)arg1;
    long l2 = *(long *)arg2;

    if (l1 == l2)
            return 0;
    else if (l1 < l2)
            return -1;
    else
            return 1;
}
/** Worker thread to sort a portion of the set of numbers. */
void * thr_fn(void *arg)
{
    long idx = (long)arg;

    heapsort(&nums[idx], TNUM, sizeof(long), complong);
    pthread_barrier_wait(&b);

    return((void *)0);
}
/** Merge the results of the individual sorted ranges */
void merge()
{
    long idx[NTHR];
    long i, minidx, sidx, num;

    for (i = 0; i < NTHR; i++)
        idx[i] = i * TNUM;

    for (sidx = 0; sidx < NUMNUM; sidx++)
    {
            num = LONG_MAX;

            for (i = 0; i < NTHR; i++)
            {
                    if ((idx[i] < (i+1)*TNUM) && (nums[idx[i]] < num))
                    {
                             num = nums[idx[i]];
                             minidx = i;
                  }
         }
             snums[sidx] = nums[idx[minidx]];
             idx[minidx]++;
    }
}
int main(void)
{
    unsigned long i;
    struct timeval start, end;
    long long startusec, endusec;
    int err;
    double elapsed;
    pthread_t tid;

    srandom(1);
    for (i = 0; i < NUMNUM; i++)
            nums[i] = random(); //load random list


    pthread_barrier_init(&b, NULL, NTHR+1);

    gettimeofday(&start, NULL); // get starting time
    for (i = 0; i < NTHR; i++)
    {
            err = pthread_create(&tid, NULL, thr_fn, (void *)(i * TNUM));
            if (err != 0)
                    printf("can’t create thread %d \n", err);
    }

    pthread_barrier_wait(&b);

    merge();
    gettimeofday(&end, NULL);

    startusec = start.tv_sec * 1000000 + start.tv_usec;
    endusec = end.tv_sec * 1000000 + end.tv_usec;
    elapsed = (double)(endusec - startusec) / 1000000.0; //calculate total time

    printf("Sorting Time: %.4f seconds\n", elapsed);

    exit(0);
}

Yukarıdaki örnekte #define NTHR (10) tanımlanmasından dolayı on milyon sayı 10 thread tarafında sıralanır. Eğer NTHR makrosuna 1 değeri atanır ise tüm sıralama sadece bir thread tarafından yapılır. Zaman farkını görmek için NTHR makrosuna 1 ve 10 değerleri vererek kodu koşturduğumuzda benim aldığım çıktılar aşağıdaki gibidir.
            NTHR ⇒ 1                                                            NTHR ⇒ 10
Sorting Time: 2.0459 seconds                      Sorting Time: 0.6765 seconds

7. Son

Bu belgede thread ve thread senkronizasyonu hakkında özel bilgiler verild. Thread kullanımı ve özellikle senkronizasyon yapılandırması bolca pratik ile yazılımcının tam kavrayacağı konulardır. Bu nedenle tıpkı diğer konulardo olduğu gibi bu belge okumasından sonra bolca kodlama pratiği öneririm. 

            Zafer Satılmış – 13.12.202 

Yağmurdan Sonra Dağ Çileği