Birden Çok İstemciyle Çalışma
Çoğu durumda, bir sunucunun aynı anda birden çok istemciye hizmet vermesi beklenmektedir. Örneğin, ssh sunucusu üzerinden, çok sayıda uzak makina kullanıcısı sisteme giriş yapabilmektedir. ssh sunucusu, yeni bağlantıları kabul etmekte, aynı zamanda istemcilerden gelen komutları çalıştırarak sonuçları geri dönmektedir.
Belli bir anda, tek bir istemciyle çalışan ve istemcileri sırayla kabul eden sunucular tekrarlamalı sunucu (iterative server) olarak adlandırılırken, birden çok istemciyle aynı anda çalışabilen sunucular eş zamanlı sunucu (concurrent server) olarak adlandırılmaktadır. Buradaki eş zamanlılık tüm istemcilerin makul bir süre içinde sunucudan hizmet alabilmesini ifade etmektedir, hiçbir istemci diğerinin hizmet almasını engellememektedir.
Daha önce, socket fonksiyonu ile soketlerin blokeli modda oluşturulduğunu ve sonrasında fcntl ile blokesiz moda geçirilebildiklerini söylemiştik. Blokeli modda çalışılması durumunda yeni bağlantıları kabul eden accept ve soketten okuma yapan read, recv gibi fonksiyonlar program kodunu bloklayacaktır. Dolayısıyla, birden çok istemciyle eş zamanlı çalışmak mümkün olmayacaktır. Soketlerin blokesiz moda geçirilmesi durumunda ise fonksiyonlar bloklamayacak ve bir döngü içerisinde isteklerden haberdar olmak için sürekli bir yoklama (polling) işlemi yapılacaktır. Bu tür döngüler meşgul döngü (busy loop) olarak adlandırılmakta ve işlemci zamanının gereksiz yere harcanmasına neden olmaktadır.
Özetleyecek olursak, blokeli çalışma durumunda beklediğimiz olay gerçekleşene kadar prosesimiz uyutulup, olay gerçekleştiğinde uyandırılarak, işlemci zamanı etkin bir şekilde kullanılmakta fakat eş zamanlı çalışma mümkün olmamaktadır. Blokesiz modda ise eş zamanlı çalışma mümkün olmasına karşın işlemci zamanı boş yere harcanmaktadır. Bu durumda temel olarak aşağıdaki yöntemler kullanılmaktadır.
- Her bir istemci için sucunu tarafında yeni bir thread veya proses yaratılır.
- select ve poll sistem çağrıları ile soketlere ilişkin dosya betimleyicileri izlenerek (I/O multiplexing) accept ve read işlemleri gerektiğinde yapılır.
Bu bölümdeki incelemelerimizi, istemciden gelen mesajları istemciye geri gönderen basit bir TCP sunucu (echo server) üzerinden yapacağız. İlk olarak istemcilerle sıralı çalışma şeklini sonrasında ise, yeni prosesler oluşturarak ve select çağrısıyla, eş zamanlı çalışma şekillerini inceleyeceğiz.
İstemcilerle Sıralı Çalışma
Bu çalışma şeklinde istemciler sunucudan sırayla hizmet alabilmektedir. Bir istemci sunucuya bağlandıktan sonra, bağlantı sonlandırılana kadar, yeni bir istemci bağlanamamaktadır. Bu durumu aşağıdaki şekille gösterebiliriz.
Sunucu istemciyi kabul ettikten sonra blokeli modda read işleminde beklemektedir. Sunucudan mesaj geldiğinde mesaj okunmakta, okunan mesaj istemciye geri gönderilmekte ardından yeniden read ile beklenmektedir. İstemci bağlantıyı sonlandırdığında sunucu tekrar accept fonksiyonunu çağırarak yeni gelecek bağlantıyı bekleyecektir. Örnek istemci ve sunucu kodları aşağıdaki gibidir.
client.c :
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 100
int main(int argc, char **argv) {
int sfd;
int bytes;
struct sockaddr_in sa;
char buf[BUF_SIZE];
ssize_t numRead;
if (argc != 2) {
printf("usage: client <IP adresi>\n");
return 1;
}
if ((sfd = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
return 1;
}
bzero(&sa, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons(PORT);
sa.sin_addr.s_addr = inet_addr(argv[1]);
if (connect(sfd, (struct sockaddr *)&sa, sizeof sa) < 0) {
perror("connect");
close(sfd);
return 2;
}
for (;;) {
printf("Mesaj: ");
fgets(buf, BUF_SIZE, stdin);
if (write(sfd, buf, strlen(buf)) < 0) {
return 1;
}
if(numRead = read(sfd, buf, BUF_SIZE) < 0) {
break;
}
write(1, "Yanıt: ", 8);
puts(buf);
}
close(sfd);
return 0;
}
server.c :
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 100
#define BACKLOG 4
char buf[BUF_SIZE];
int numRead;
void echo(int cfd) {
while ((numRead = read(cfd, buf, BUF_SIZE)) > 0) {
if (write(cfd, buf, numRead) != numRead)
return;
}
}
int main() {
int sfd;
int cfd;
int b;
struct sockaddr_in sa;
FILE *client;
fd_set readfds, masterfds;
int nready;
int i;
int maxfd;
if ((sfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
return 1;
}
bzero(&sa, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons(PORT);
if (INADDR_ANY)
sa.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sfd, (struct sockaddr *)&sa, sizeof sa) < 0) {
perror("bind");
return 2;
}
if (listen(sfd, BACKLOG) == -1)
return 1;
for (;;) {
cfd = accept(sfd, NULL, NULL);
if (cfd == -1)
return 1;
echo(cfd);
close(cfd);
}
}
Sunucu, istemci bağlantıyı sonlandırana kadar echo fonksiyonunda bloklanmaktadır. İstemci bağlantıyı sonlandırdığında read 0 değerine dönmekte ve echo fonksiyonundan çıkılmaktadır. Örneğimiz üzerinden bu durumu inceleyelim.
Sunucu ve istemci uygulamalarımız derleyelim.
$ gcc -oserver server.c
$ gcc -oclient client.c
Sunucuyu, ardından istemciyi çalıştırıp bir mesaj gönderelim.
$ ./server
$ ./client 127.0.0.1
Mesaj: istemci konuşuyor
Yanıt: istemci konuşuyor
Mesaj:
İstemcinin mesajının kendisine geri gönderildiğini görüyoruz. Şimdi başka bir terminale geçerek ikinci bir istemci çalıştıralım ve bir mesaj göndermeyi deneyelim.
$ ./client 127.0.0.1
Mesaj: ikinci istemci konuşuyor
İkinci istemci, bağlantı isteği sunucu tarafından kabul edilmediğinden, write işleminde bloklanmaktadır. İlk istemciyi sonlandırdığımızda, ikinci istemcinin bağlantı isteği sunucu tarafından kabul edilecek ve ikinci istemci sunucu ile haberleşebilecektir.
İstemcilerle Eş Zamanlı Çalışma
Birden çok istemciyle eş zamanlı çalışmanın ilk olarak proseslerle sonrasında ise select sistem çağrısıyla nasıl gerçeklendiğine bakalım.
Yeni Proses Oluşturma
Bu yöntemde her bir istemci için yeni bir proses oluşturulmakta ve haberleşme, blokeli modda, bu yeni proses üzerinden sağlanmaktadır. Bu durumu aşağıdaki şekil ile gösterebiliriz.
Bu durumda, kodun diğer kısımlarına dokunmaksızın, sunucunun yaşam döngüsünü oluşturan for bloğunu aşağıdaki gibi değiştireceğiz.
for (;;) {
cfd = accept(sfd, NULL, NULL);
if (cfd == -1)
return 1;
if (fork() == 0) {
/*istemciye tahsis edilmiş alt proses kodu*/
close(sfd);
echo(cfd);
close(cfd);
exit(0);
}
close(cfd);
}
Sunucu kodu ilk çalışmaya başladığında var olan proses (üst proses) temel olarak yeni bağlantıları kabul etmek ve yeni alt prosesler oluşturmaktan sorumludur. Haberleşme bu alt prosesler üzerinden sağlanmakta ve bu sayede çok sayıda istemciyle paralel olarak çalışılabilmektedir. Üst proses ve bir alt proseste geçen olayları sırasıyla aşağıdaki gibi sıralayabiliriz.
Üst proses:
- Blokeli modda yeni bir bağlatıyı bekle
- Bağlantı isteği olması durumunda kabul et
- Yeni bir alt proses oluştur
- Alt prosesin haberleşme için kullanacağı soketi kapat
- Tekrardan bağlantı isteklerini bekle
Alt proses:
- Üst proses tarafından yeni bağlantıları dinlemek için kullanılan soketi kapat
- Blokeli modda okuma ve yazma işlemlerini yap
- Sunucu bağlantıyı kapatmışsa haberleşme soketini kapat, ardından prosesi sonlandır
Not: Dosya betimleyicilerinin üst prosesten alt prosese miras kaldığını hatırlayınız.
Bu yöntemde aslında her bir istemci için bir iteratif sunucu oluşturmuş olduk. Farklı terminaller üzerinde istemciler çalıştırarak, beraber çalışabildiklerini gözleyebilirsiniz.
$ ./server
$ ./client 127.0.0.1
Mesaj: 1. istemci konusuyor
Yanıt: 1. istemci konusuyor
$ ./client 127.0.0.1
Mesaj: 2. istemci konuşuyor
Yanıt: 2. istemci konuşuyor
Son olarak benzer çalışma modelinin select ile nasıl yapılabildiğine bakalım.
select
select ile, yeni prosesler veya thread'ler oluşturmaksızın, tek bir proses üzerinden birçok istemciye hizmet vermek mümkündür. select ile dosya betimleyicilerin durumu izlenmektedir. select blokeli modda çalışmakta, izlenen dosya betimleyicilerin durumdalarında bir değişiklik olması durumunda bloke çözülmektedir. Ayrıca blokenin çözülmesi için bir zaman aşımı süresi de belirlenebilir. select fonksiyonunun prototipi ve aldığı parametreler aşağıdaki gibidir.
#include <sys/time.h>
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
Parametre | Görevi |
---|---|
nfds | İzlenecek maksimum dosya betimleyici sayısı (en yüksek betimleyici numarası + 1) |
readfds | Okuma amaçlı izlenecek dosya betimleyici kümesi |
writefds | Yazma amaçlı izlenecek dosya betimleyici kümesi |
exceptfds | Hata durumları izlenecek dosya betimleyici kümesi |
timeout | Blokenin çözüleceği zaman aşımı süresi |
select aşağıdaki değerlerin biriyle dönmektedir.
Geri Dönüş Değeri | Anlamı |
---|---|
0 dışı değer | Hazır betimleyici sayısı |
0 | Zaman aşımı |
-1 | Hata durumu, errno değişkenine hatanın nedeni yazılır |
select çağrısını, soketleri gerektiğinde bloke olmadan okuyabilmek için aşağıdaki biçimde kullanacak ve ilgilenmediğimiz parameterlere NULL değerini geçireceğiz.
select (nfds, readfds, NULL, NULL, NULL)
readfds, fd_set türünde bir adres olup, okuma amaçlı izlenecek dosya betimleyici kümesini göstermektedir. fd_set türü, her bir biti bir dosya betimleyicisini temsil etmek üzere, 1024 (proses başına açılabilecek maksimum dosya sayısı) bitlik bir alanı göstermektedir. readfds üzerinden konuşacak olursak, ilk önce tüm bitleri sıfıra çekmeli, ardından izlemek istediğimiz dosya betimleyicilere karşılık gelen bitleri 1 değerine çekmeliyiz. select, readfds argümanını hem okuma hem de yazma amaçlı (value-result argument) olarak kullanmaktadır. readfds, fonksiyon çağrılmadan önce ilgilendiğimiz dosya betimleyicilerini göstermesine karşın, eğer hata durumu veya zaman aşımı oluşmamışsa, fonksiyon döndüğünde okumaya hazır betimleyicileri göstermektedir. select ile daha kolay çalışabilmek için aşağıdaki makrolar tanımlanmıştır.
Not: select'e ilk argüman olarak geçirdiğimiz, nfds değeri select fonksiyonunun daha hızlı çalışması için kullanılmaktadır. Bu sayede select 1024 bitten oluşan kümelerin (örneğin readfds) tamamı üzerinde değil, ilk nfds biti üzerinde işlem yapacaktır.
Makro | Görevi |
---|---|
void FD_ZERO(fd_set *fdset) | fdset içindeki tüm bitleri sıfırlar |
void FD_SET(int fd, fd_set *fdset) | fd'ye karşılık gelen bitin değerini 1 yapar |
void FD_CLR(int fd, fd_set *fdset) | fd'ye karşılık gelen bitin değerini 0 yapar |
int FD_ISSET(int fd, fd_set *fdset | fdset içinde fd'ye karşılık gelen bitin değerinin 1 olup olmadığını döner |
Çoklu istemciye yanıt veren sunucuya ilişkin çalışma şeklini gösteren şekil ve örnek kod sırasıyla aşağıdaki gibidir.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 100
#define BACKLOG 4
char buf[BUF_SIZE];
int numRead;
int main() {
int sfd;
int cfd;
int b;
struct sockaddr_in sa;
FILE *client;
fd_set readfds, masterfds;
int nready;
int i;
int maxfd;
if ((sfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
return 1;
}
bzero(&sa, sizeof sa);
sa.sin_family = AF_INET;
sa.sin_port = htons(PORT);
if (INADDR_ANY)
sa.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sfd, (struct sockaddr *)&sa, sizeof sa) < 0) {
perror("bind");
return 2;
}
if (listen(sfd, BACKLOG) == -1)
return 1;
FD_ZERO(&masterfds);
FD_ZERO(&readfds);
FD_SET(sfd, &masterfds);
maxfd = sfd;
for (;;) {
readfds = masterfds;
nready = select(maxfd + 1, &readfds, NULL, NULL, NULL);
for (i = 0; i <= maxfd && nready > 0; ++i) {
if (!FD_ISSET(i, &readfds))
continue;
--nready;
/*bağlantı isteği*/
if (i == sfd) {
cfd = accept(sfd, NULL, NULL);
if (cfd == -1)
return 1;
FD_SET(cfd, &masterfds);
if (maxfd < cfd)
maxfd = cfd;
}
/*data*/
else {
numRead = read(i, buf, BUF_SIZE);
if (numRead > 0) {
write(i, buf, numRead);
}
else {
close(i);
FD_CLR(i, &masterfds);
}
}
}
}
}
İlk bakışta genel akışı gösteren şekil ve for döngüsünü oluşturan kod karışık gelebilir, ilgili kod bölümlerini bloklar şeklinde inceleyelim.
Daha önce select'in okuduğu değerleri değiştirdiğinden bahsetmiştik, bu amaçla fd_set türünden iki adet değişken tutuyoruz.
- masterfds: İzlenecek betimleyici kümesi
- readfds: masterfds'in kopyası alınarak select'e geçirilen küme
readfds, select döndüğünde sonuç değeri gösterebildiğinden, izlenecek fd kümesini masterfds isimli ayrı bir değişkende saklıyoruz.
FD_ZERO(&masterfds);
FD_ZERO(&readfds);
FD_SET(sfd, &masterfds);
maxfd = sfd;
masterfds ve readfds değişkenlerini sıfırlıyor ve bağlantı isteklerinin dinlendiği sfd'yi izlenecek fd kümesine ekliyoruz. Son olarak, en yüksek fd değerini tutan maxfd'ye ilk değerini veriyoruz.
for (;;) {
readfds = masterfds;
nready = select(maxfd + 1, &readfds, NULL, NULL, NULL);
for (i = 0; i <= maxfd && nready > 0; ++i) {
if (!FD_ISSET(i, &readfds))
continue;
--nready;
...
}
}
İlgilendiğimiz fd sayısını (maxfd+1) ve izlenecek fd seti masterfds'e eşitlediğimiz readfds adresini select'e geçiriyoruz. select döndüğünde maxfd'ye kadar olan aralıkta, nready tane bitin değeri değişmiş olacağından for döngüsünün şart ifadesini bu koşulu sağlayacak şekilde yazıyoruz. Bu aşamadan sonra readfds içinde değeri 1 olan bitleri bulmalıyız. FD_ISSET ile değeri 1 olan bir bite ulaşana kadar döngüyü devam ettiriyoruz. Değeri 1 olan bir bite ulaştığımızda bu bitin temsil ettiği sokete ulaştığımız için nready değerini 1 eksiltiyoruz.
Şimdi for içindeki sıradaki kod bloğuna bakalım.
if (i == sfd) {
cfd = accept(sfd, NULL, NULL);
if (cfd == -1)
return 1;
FD_SET(cfd, &masterfds);
if (maxfd < cfd)
maxfd = cfd;
}
Durumu değişen fd'nin yeni bağlantıları dinlediğimiz sfd olması durumunda, yeni bir bağlantı isteği geldiğini anlıyoruz. Sırasıyla, accept ile yeni bir haberleşme soketi oluşturuyor, bu sokete ilişkin fd'yi select'e geçirmek üzere masterfds değişkenine ekliyor ve maxfd değerini güncelliyoruz. Bu kod bloğu sayesine select yeni bağlanan istemciden gelen mesajlardan haberdar olabilecektir.
Şimdi for içindeki son kod bloğuna bakalım.
else {
numRead = read(i, buf, BUF_SIZE);
if (numRead > 0) {
write(i, buf, numRead);
}
else {
close(i);
FD_CLR(i, &masterfds);
}
}
Durumu değişen fd'nin istemci bağlantılarını dinlediğimiz sokete ilişkin olmaması durumunda, istemciden bir mesaj geldiğini anlıyoruz. fd okumaya hazır olduğundan bu aşamada read ile bloklanmadan okuma yapabiliriz. read fonksiyonunun sıfırdan büyük bir değer dönmesi durumunda istemciden gelen mesajı tekrar okuma yaptığımız soket üzerinden istemciye gönderiyoruz. İstemcinin bağlantıyı kapatması durumunda ise select bu durumdan haberdar olacak, bloke çözülecek ve read 0 değerine dönecektir. Bu durumda close ile bu haberleşme kanalını kapatıyor ve FD_CLR ile bu sokete ilişkin fd'yi izleme kümesinden çıkartıyoruz.
Özetleyecek olursak, şekilden de görüldüğü gibi, select döndüğünde akış ikiye ayrılmaktadır. Yeni bir istemci bağlanmak istediğinde, yeni bir bağlantı soketi oluşturulmakta ve sokete ilişkin fd izleme kümesine eklenerek akış yeniden select'e yönlendirilmektedir. Durumu değişen fd'nin bağlantıların dinlendiği sokete ilişkin olmaması durumunda ise istemcilerden mesaj geldiği anlaşılmakta ve bloklanmadan okuma işlemi yapılıp akış tekrar select'e yönlendirilmektedir. Bu sayede tek bir proses üzerinden birçok istemciyle çalışılabilmektedir. Farklı terminallerde birden çok istemci çalıştırarak bu durumu gözleyebilirsiniz.