Kendisini köle gibi çalıştırıp sırtından kırbacı eksik etmediğimiz bir HTTP servisimiz vardı, buna Server adını verelim. Yaptığımız işlerden birisi de, görev gereği bu Server arkadaşı birkaç dakika içinde yüzlerce defa çağırıp, sonuçları toparlayıp hazırlamaktı. Bunu yapan arkadaşa da bundan sonra Client diyelim.

Dikkat edilmesi gereken konulardan biri, hem Client hem de Server, Backend servis olarak çalışan bileşenler, yani bu HTTP istekleri klasik bir tarayıcı üzerinden gönderilmiyordu. Günlerden bir gün, Client arkadaşın sonuçları yeterince hızlı derleyip toparlayamadığını gözlemledik, bu sebeple konuyu derinlemesine inceleyip neler yapabiliriz çıkardık ve belki çoğu kişinin bildiği bir konuyu farlı bir açıdan ölçmek istedim.

Yazı boyunca kullanılan kod örneklerini buradan edilebilirsiniz.

HTTP Request Dediğin Nedir?

HTTP bir uygulama protokolü ama gönderilen request ve response aslında TCP (en azından HTTP/3 haricinde diyelim) protokolü üzerinden taşınıyor. Tarayıcınız üzerinden bir web sitesine ya da servise istek attığınızda, öncelikle karşı tarafa bir TCP bağlantısı açılıp ardından HTTP protokolü ile ilgili olan çoğu şey TCP içinde Data kısmında gönderiliyor.

Durum böyle olunca, HTTP konuşmak için TCP kurallarına uymak gerekiyor, ilk kurallardan biri ise TCP three-way handshake. Yani sunucu ile HTTP konuşmaya başlamadan önce TCP üzerinden el sıkışıp şartlarda anlaşmamız gerekiyor, sonra diğer işlemler takip ediyor.

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: 1. SYN (Synchronize)
    Server-->>Client: 1. SYN-ACK (Synchronize-Acknowledge)
    Client->>Server: 1. ACK (Acknowledge)
    Client->>Server: 1. HTTP Request
    Server-->>Client: 1. HTTP Response
    Client->>Server: 1. FIN (Finish)
    Server-->>Client: 1. ACK (Acknowledge)
    Server-->>Client: 1. FIN (Finish)
    Client->>Server: 2. SYN (Synchronize)
    Server-->>Client: 2. SYN-ACK (Synchronize-Acknowledge)
    Client->>Server: 2. ACK (Acknowledge)
    Client->>Server: 2. HTTP Request
    Server-->>Client: 2. HTTP Response
    Client->>Server: 2. FIN (Finish)
    Server-->>Client: 2. ACK (Acknowledge)
    Server-->>Client: 2. FIN (Finish)
    Client-->>Server: 2. ACK (Acknowledge)

Tabi istek bittiğinde de, TCP için yine bağlantıyı kapatmak için yukarıdaki gibi termination paketlerini de göndermek gerekiyor. Bu paketlerin her biri tabi hem sunucu hem de istemci tarafında az da olsa ek bir işlemci gücü ve zaman gerektiriyor. Az sayıda istek alan bir sunucu ya da istemci için bu paketlerin maliyeti pek göze batmazken, bizim gibi yoğun istek altında kısa sürede işini bitirmesi gereken bir sunucu ya da istemci için oldukça önem arz edebiliyor.

HTTP Protokol Farklılıkları

HTTP protokolleri ve kullandığı farklı bağlantı yöntemleri Mozilla tarafından güzel şekilde özetlenmiş. HTTP oldukça eski bir protokol, zamanla web kullanımı arttıkça, yukarıdaki durum için aslında çözüm geliştirilmiş ve her seferinde TCP bağlantısı açık kapatmaktansa bu bağlantıyı koru ve aynı bağlantı üzerinden diğer isteklerini gönder demişler.

Bu yöntemin adına ise Mozilla linkinde ki gibi Persistent Connection ya da namı diğer keep-alive deniliyor. Keep-alive yöntemi ile yukarıdaki gibi grafiği olan 2 adet HTTP Request/Response aşağıdaki gibi gözüküyor.

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: 1. SYN (Synchronize)
    Server-->>Client: 1. SYN-ACK (Synchronize-Acknowledge)
    Client->>Server: ACK (Acknowledge)
    Client->>Server: 1. HTTP Request
    Server-->>Client: 1. HTTP Response
    Client->>Server: 2. HTTP Request
    Server-->>Client: 2. HTTP Response
    Client->>Server: 1. FIN (Finish)
    Server-->>Client: 1. ACK (Acknowledge)
    Server-->>Client: 1. FIN (Finish)
    Client-->>Server: 1. ACK (Acknowledge)

Keep-alive Olmazsa Neler Oluyor

Basit bir Python kodu ile HTTP server oluşturalım. Protokol olarak 1.0 yerine 1.1 kullandım çünkü 1.0 bildiğim kadarıyla keep-alive özelliğini desteklemiyor.

import http.server
import socketserver
import sys

class HttpServer(http.server.SimpleHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def handle(self):
        self.close_connection = False
        while not self.close_connection:
            self.handle_one_request()

def run(port):
    with socketserver.TCPServer(("", port), HttpServer) as httpd:
        print("HTTP 1.1 Persistent Connections Server running at port", port)
        httpd.serve_forever()

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 5000
    run(port)

Yukarıdaki sunucu gelen isteğe bulunduğu dizindeki dosyaların adlarını listeleyerek cevap dönüyor, bu şekilde olması iyi çünkü mümkün olduğunca basit, başka bir konuda zaman alan bir işlem yapmıyor ve bize asıl ölçmek istediğimiz el sıkışmanın maliyetini daha iyi ortaya çıkarmamazı sağlıyor.

Benze şekilde bu sunucuya, çok sayıda istek gönderecek basit bir curl komutu hazırlayalım.

#!/bin/sh 

PROTOCOL="http"
SERVER="localhost"
PORT=5000
URL="$PROTOCOL://$SERVER:$PORT"

/usr/bin/time curl --silent -H "Connection: close"  "$URL" \
  "$URL" \
  "$URL" \
  ...

Curl komutunun içinde ise 500 defa aynı URL tekrarlandı, iki sebebi var. Birincisi her HTTP isteği için yeniden bir curl process oluşturmak ayrı süre alacağı için bundan kaçınmak istedim, ikincisi ise keep-alive özelliğini kullanmak için istekleri aynı curl process içinde göndermek gerekiyor yoksa, keep-alive curl içinde varsayılan olarak gelse de işlem sonlandığında eski TCP bağlantısını tutma şansı kalmıyor.

Curl tarafında keep-alive maalesef varsayılan olarak geldiği için, kapat gibi bir flag yok, bu sebeple aynı şeyi yani her istekte bağlantıyı server tarafında kapatmaya zorlayan Connection: close header kullandık.

# python3 http11_server.py 5000
HTTP 1.1 Persistent Connections Server running at port 5000

Local ortamda sunucuyu çalıştırdık, şimdi de test için yazdığımız curl kodunu çalıştırıp sonuçlara bakalım. Aynı zamanda Wireshark üzerinden de paket trafiğini kaydedeceğim.

# ./curl-connection-close.sh
        1.27 real         0.12 user         0.15 sys

Ortalama olarak yukarıdaki gibi 1.20 saniye civarında işlem bitiyor. Paket trafiğinde ise kaç defa el-sıkışma paketi gönderdiğini görmek için aşağıdaki gibi bir filtre kullandım.

tcp.flags.syn == 1 && tcp.flags.ack == 1

Curl 1

Ekran görüntüsünden de görüldüğü gibi her istek için TCP el-sıkışma işlemleri yapılıyor.

Keep-alive ile Durum Nasıl

Bu sefer de aynı sunucu tarafını keep-alive kullanan curl komutu ile çağıralım. Curl kodunu aşağıdaki gibi değiştirdim

#!/bin/sh 

PROTOCOL="http"
SERVER="localhost"
PORT=5000
URL="$PROTOCOL://$SERVER:$PORT"

/usr/bin/time curl --silent "$URL" \
  "$URL" \
  "$URL" \
  ...

Herhangi bir ek header bilgisi eklemeye gerek yok, çünkü dediğim gibi varsayılan değer olarak zaten bu gönderiliyor. Aslında curl de burada kullandığımızı tarayıcılara benziyor, onlar da varsayılan değer olarak keep-alive header bilgisini gördüğüm kadarıyla otomatik olarak gönderiyorlar. Şimdi sonuçları test edelim ve test ederken de yine benzer şekilde paket trafiğini kaydedelim.

# ./curl-keep-alive.sh
        0.56 real         0.08 user         0.06 sys

Ortalama olarak 0.60 saniye olarak çıktığını görüyoruz. Paket trafiği ise aşağıdaki gibi gözüküyor.

Curl 2

Beklenildiği gibi, paket trafiğinde 500 tane el-sıkışma paketi yerine sadece 1 adet TCP hand-shake var. Performans olarak da ilk versiyona göre neredeyse, %100 daha hızlı diyebiliriz. Tabi burada yaptığımız mikro ölçümleme oranında canlı ortamlarda iyileşme olmayabilir ama mutlaka bizim gibi sık sık kısa süreli istek gönderen ortamlarda önemli oranda iyileşme sağlanabilir.

Peki NodeJs Tarafında Durum Nasıl

Çıkış noktamızdan bahsettiğimde asıl sorunu, NodeJs client olarak diğer sunucuyu çağırdığında yaşıyorduk. Bu yüzden aynı sunucuyu bu sefer de Node kullanarak test edelim.

const http = require('http');

const options = {
    hostname: 'localhost',
    port: 5000,
    path: '/',
    method: 'GET'
};

const count = 500;
let requestsCompleted = 0;

function sendRequest() {
    return new Promise((resolve, reject) => {
        const req = http.request(options, (res) => {
            res.on('data', () => {});
            res.on('end', () => {
                requestsCompleted++;
                console.log(`Request ${requestsCompleted} completed.`);
                resolve();
            });
        });

        req.on('error', (error) => {
            console.error('Request error:', error);
            reject(error);
        });

        req.end();
    });
}

async function sendSequentialRequests() {
    for (let i = 0; i < count; i++) {
        try {
            await sendRequest();
        } catch (error) {
            console.error('Failed to send request:', error);
        }
    }
    console.log('All requests completed.');
}

sendSequentialRequests();

Yukarıdaki kod parçacığında curl benzeri 500 adet sıralı istek atıyoruz ve sonucun ne kadar süre aldığını ölçüyoruz.

# /usr/bin/time node node-client-no-keepalive.js
Request 1 completed.
Request 2 completed.
...
...

All requests completed.
        2.20 real         0.54 user         0.25 sys

Paket trafiği yukarıdaki keep-alive olmayan ile aynı şekilde olduğu için tekrar koymuyorum ama özetle, 500 adet TCP handshake mesajı görülüyor ve ortalama 2.20 saniye sürüyor. Açıkçası bu şekilde yapılan bir isteği aynı curl benzeri varsayılan değer olarak keep-alive yöntemi kullanmasını beklerdim ama öyle değil, görüldüğü gibi bu kullanılmayıp her defasında yeni bir TCP bağlantı oluşuyor. Peki Node tarafında bunu önlemek için ne yapmak lazım?

NodeJs İle Keep-alive Kullanımı

Kodu çok az değiştirip keep-alive kullanımı için HTTP Agent kullanıyoruz ve testimizi tekrar çalıştırıyoruz.

const http = require('http');

const options = {
    hostname: 'localhost',
    port: 5000,
    path: '/',
    method: 'GET',
    agent: new http.Agent({ keepAlive: true })
};

const count = 500;
let requestsCompleted = 0;

function sendRequest() {
    return new Promise((resolve, reject) => {
        const req = http.request(options, (res) => {
            res.on('data', () => {}); 
            res.on('end', () => {
                requestsCompleted++;
                console.log(`Request ${requestsCompleted} completed.`);
                resolve();
            });
        });

        req.on('error', (error) => {
            console.error('Request error:', error);
            reject(error);
        });

        req.end();
    });
}

async function sendSequentialRequests() {
    for (let i = 0; i < count; i++) {
        try {
            await sendRequest();
        } catch (error) {
            console.error('Failed to send request:', error);
        }
    }
    console.log('All requests completed.');
}

sendSequentialRequests();
# /usr/bin/time node node-client-keepalive.js
Request 1 completed.
Request 2 completed.
...
...

All requests completed.
        0.90 real         0.35 user         0.08 sys

Testin sonucunda ortalama 0.90 saniye çıkıyor, curl testinde çıkan değerden de daha iyi bir oranda iyileşme var diyebiliriz. Belki burada node tarafında Connection: close yapısından farklı olarak agent kullanımı ile istemci tarafında bağlantı sonlandırmasının etkisi de olabilir. Ama yine de oldukça iyi bir oranda iyileşme sağladığı görülüyor.

Sonuç

Node tarafında açıkçası beni yanıltan varsayılan HTTP istek modülünün keep-alive kullanmamasıydı. Bunu yapmak için agent kullanmanız gerekli. Fakat, tarayıcınız, curl vb. araçlarda ise bu davranış varsayılan değer olarak ekleniyor.

xychart-beta horizontal
    title "Performance Comparison - 500 Requests"
    x-axis ["Curl(Keep-alive)", "Curl (Connection-close)","NodeJs(Keep-alive)","NodeJs"]
    y-axis "Seconds" 0 --> 5
    bar [0.56,1.27, 0.90, 2.20]
    

Bu iyileştirme sayesinde, biz de neredeyse benzer oranlarda bahsettiğim problemi iyileştirdik diyebilirim. Tabi bunu yaparken ezbere yapmak yerine her zaman arka planda yatan sebepleri irdelemek ve işin temelini anlayarak yapmak daha güzel oluyor.


<
Previous Post
Core Dump Stack Analizi 3 - Otomasyon
>
Next Post
Shell Hileleri - Vim ile Özel Karakterler