WebProxy-Lab 회고

스레드 락(thread lock) 은 멀티스레딩 환경에서 공유 자원 접근을 동기화하기 위해 매우 중요한 개념이다. 특히 캐시 같은 공유 메모리 구조는 여러 스레드가 동시에 접근하면 데이터 레이스, 비일관성, 세그멘테이션 폴트 등 심각한 문제가 발생할 수 있어, 락이 필수적이다.

더보기

1. 스레드 락의 목적

하나의 스레드만 임계 구역(critical section)에 진입할 수 있도록 제한하는 동기화 장치

  • 임계 구역: 동시에 둘 이상이 들어가면 문제가 발생하는 코드 영역 (예: 캐시 삽입, 삭제, 접근)
  • 문제점 예시:
    • 두 스레드가 동시에 같은 URI를 캐시에 삽입하거나 제거하면 → 메모리 누수, 중복 삽입, 충돌 발생
    • 한 스레드가 next 포인터를 변경하고 있는 도중 다른 스레드가 이를 따라 접근 → 세그폴트

주제 : 캐시 정말 좋은가? 정말 빠른가?

사실, 여러 스레드가 캐시에서 동시에 읽을 수 있어야 한다는 특별한 요구 사항이 있습니다. 물론 한 번에 하나의 스레드만 캐시에 쓸 수 있어야 하지만, 리더(reader)에는 이러한 제한이 없어야 합니다. -proxylab.pdf

캐시기능을 구현하면서 지침서를 확인하니 읽기 처리는 병렬성을 가지고 쓰기는 단일 동작을 하는 것에 대한 언급이 있어 rwlock을 이용해 구현을 진행했다.

하지만 구현을 하다 보니 읽기라고는 하지만 결국 캐시에 데이터가 있는 경우 해당 캐시 블록을 다시 tail로 옮기는 쓰기 작업이 포함되어 있다는 것을 알게 되었고 그렇게 되면 읽기 작업도 사실상 쓰기 작업을 해야 하기 때문에 rdlock 이 아닌 rwlock을 수행하고 있다는 것을 깨닫게 되었다.

“그래도 서버에서 매번 가져오는 것보다는 빠르지 않을까?”

이런 생각이 들었기에 실제 테스트를 해 봤다.

테스트 결과.. 오히려 그냥 서버에서 가져오는 것이 훨씬 빨랐다.

그러면 왜 캐시를 쓰지? 가져오는 것 이 더 빠를 정도로 락을 획득하고 해제하는 것이 오래 걸리는 작업일까?

처음엔 환경의 문제라고 생각했다.

"tiny 서버니까 원래 빠른 거 아닌가?"
"진짜 캐시가 비효율적인 건가?" 

이 질문에 답을 찾기로 했다.

원인 분석과 개선 진행

원인을 추적하니 LRU 갱신 시 모든 캐시 접근이 write-lock을 걸고 있었다. 요청 수가 많을수록 락 경합이 심해지고, 캐시를 쓰는 쪽이 오히려 병목이 됐던 것이다.

어떻게 개선해야 할까?

현재 문제가 되고 있는 것은 읽기 작업이었다. 캐시 히트 상황에서 데이터를 전달하고 최신화하는 과정에서 쓰기 락이 걸리는 상황이 문제가 되는 것 같았고 그렇다면 읽기 작업에서 락을 없앨 수 있을까?, 비동기 처리?

결국 GPT의 힘을 빌렸다.

비동기 큐를 구현

GPT는 LRU에 따라 캐시를 새로 갱신하는 과정을 비동기 큐 즉 별도의 큐를 만들어 큐에 해당 변경을 넣고 다른 스레드에서 비동기적으로 처리하도록 구현할 것을 추천했다.

typedef struct {
    cache_block_t *nodes[QUEUE_SIZE];
    int front, rear;
    pthread_mutex_t lock;
} lru_update_queue_t;

lru_update_queue_t lru_queue;

큐를 사용해서 별도로 쓰기 작업만 처리할 것이다.

 void cache_init() {
  cache.head = NULL;
  cache.tail = NULL;
  cache.total_size = 0;
  pthread_rwlock_init(&cache.lock, NULL);
  pthread_mutex_init(&cache.lru_lock, NULL);
  pthread_mutex_init(&lru_queue.lock, NULL);
  lru_queue.front = 0;
  lru_queue.rear = 0;
  pthread_t tid;
  pthread_create(&tid, NULL, lru_worker, NULL);
}

비동기 큐를 초기화하기 위해 기존 cache_init()에 코드를 추가한다.

int cache_find(cache_list_t *cache, const char *uri, char *object_buf, int *size_buf, cache_block_t **hit_block) {
  pthread_rwlock_rdlock(&cache->lock);
  unsigned int idx = hash_uri(uri);
  cache_block_t *node = cache->hash_table[idx];
  while (node) {
    if (strcmp(node->uri, uri) == 0) {
      memcpy(object_buf, node->object, node->size);
      *size_buf = node->size;
      *hit_block = node;
      pthread_rwlock_unlock(&cache->lock);
      enqueue_lru_update(node);  // async update
      return 1;
    }
    node = node->hnext;
  }
  *hit_block = NULL;
  return 0;
}

void enqueue_lru_update(cache_block_t *node) {
  pthread_mutex_lock(&lru_queue.lock);
  int next_rear = (lru_queue.rear + 1) % LRU_QUEUE_SIZE;
  if (next_rear != lru_queue.front) { // not full
    lru_queue.queue[lru_queue.rear] = node;
    lru_queue.rear = next_rear;
  }
  pthread_mutex_unlock(&lru_queue.lock);
}

cache_find 내부에서 큐에 LRU 갱신을 요청한다.

void *lru_worker(void *arg) {
  while (1) {
    cache_block_t *node = NULL;
    pthread_mutex_lock(&lru_queue.lock);
    if (lru_queue.front != lru_queue.rear) {
      node = lru_queue.queue[lru_queue.front];
      lru_queue.front = (lru_queue.front + 1) % LRU_QUEUE_SIZE;
    }
    pthread_mutex_unlock(&lru_queue.lock);

    if (node) {
      pthread_mutex_lock(&cache.lru_lock);
      if (node != cache.tail) {
        if (node->prev) node->prev->next = node->next;
        else cache.head = node->next;

        if (node->next) node->next->prev = node->prev;
        else cache.tail = node->prev;

        node->prev = cache.tail;
        node->next = NULL;

        if (cache.tail) cache.tail->next = node;
        else cache.head = node;

        cache.tail = node;
      }
      pthread_mutex_unlock(&cache.lru_lock);
    } else {
      usleep(100);
    }
  }
  return NULL;
}

별도의 스레드가 큐를 감시하고 큐에 새로운 작업요청이 들어오면 LRU를 갱신한다.


테스트 결과

테스트 용으로 만든 (GPT가 만든) 스크립트를 이용해 테스트를 진행했다. 스크립트를 확인하고 싶다면 더 보기를 눌러 확인하라

더보기
  • cache_test.sh - 10초간 home.html을 연속으로 호출한다.
#!/bin/bash
#
# loadtest.sh - Proxy Lab 성능 테스트용 스크립트 (driver.sh 기반 개선)
#

HOME_DIR=$(pwd)
TIMEOUT=5
MAX_RAND=63000
PORT_START=1024
PORT_MAX=65000
MAX_PORT_TRIES=10
FETCH_FILE="home.html"

function wait_for_port_use() {
    timeout_count=0
    while [ "$timeout_count" -lt "$MAX_PORT_TRIES" ]; do
        if netstat -an | grep LISTEN | grep -q ":$1"; then
            return
        fi
        timeout_count=$((timeout_count + 1))
        sleep 1
    done
    echo "Timeout waiting for port $1"
    exit 1
}

function free_port {
    port=$(((RANDOM % MAX_RAND) + PORT_START))
    while true; do
        if ! netstat -an | grep LISTEN | grep -q ":$port"; then
            echo $port
            return
        fi
        port=$((port + 1))
        if [ $port -gt $PORT_MAX ]; then
            echo "-1"
            return
        fi
    done
}

# Kill any stray processes
killall -q proxy tiny 2>/dev/null
sleep 1

# Check required files
[ ! -d ./tiny ] && echo "Error: ./tiny directory not found." && exit 1
[ ! -x ./tiny/tiny ] && echo "Building tiny server..." && (cd tiny && make)
[ ! -x ./proxy ] && echo "Error: ./proxy not found. Please build it." && exit 1
[ ! -f ./tiny/${FETCH_FILE} ] && echo "Error: ./tiny/${FETCH_FILE} not found." && exit 1

# Start Tiny
tiny_port=$(free_port)
echo "Starting tiny on port $tiny_port"
(cd tiny && ./tiny $tiny_port > /dev/null 2>&1 &) 
tiny_pid=$!
wait_for_port_use $tiny_port

# Start Proxy
proxy_port=$(free_port)
echo "Starting proxy on port $proxy_port"
./proxy $proxy_port > /dev/null 2>&1 &
proxy_pid=$!
wait_for_port_use $proxy_port

# Warm-up cache (optional)
echo "Warming up cache with curl..."
curl -s -o /dev/null --proxy http://localhost:$proxy_port http://localhost:$tiny_port/$FETCH_FILE
if [ $? -ne 0 ]; then
    echo "⚠️  Cache warm-up failed (curl returned non-zero). Check proxy or tiny status."
fi

# Run wrk
echo "Running wrk load test (4 threads, 50 connections, 10s)..."
wrk -t4 -c10 -d10s --latency \
  -H "Host: localhost:$tiny_port" \
  http://localhost:$proxy_port/http://localhost:$tiny_port/home.html


if [ $? -ne 0 ]; then
    echo "❌ wrk execution failed. Check proxy for read/write error handling or URI parsing logic."
fi

# Cleanup
echo "Cleaning up: Killing tiny and proxy..."
kill $tiny_pid 2>/dev/null && wait $tiny_pid 2>/dev/null
kill $proxy_pid 2>/dev/null && wait $proxy_pid 2>/dev/null

# Backup kill just in case
pkill -f "./tiny" 2>/dev/null
pkill -f "./proxy" 2>/dev/null

echo "✅ Load test completed and processes cleaned up."
  • random_file.lua - wrk 가 랜덤한 테스트를 진행할 수 있도록 한다.
math.randomseed(os.time())

request = function()
  local i = math.random(1, 10)
  local uri = "/http://localhost:26814/home" .. i .. ".html"
  return wrk.format("GET", uri, {["Host"] = "localhost:26814"})
end
  • cache_test2.sh - home1.html ~ home100.html 파일을 랜덤으로 호출한다.
#!/bin/bash
#
# loadtest.sh - Proxy Lab 성능 테스트 스크립트 (랜덤 파일 요청 포함)
#

HOME_DIR=$(pwd)
TIMEOUT=5
MAX_RAND=63000
PORT_START=1024
PORT_MAX=65000
MAX_PORT_TRIES=10
FILE_COUNT=10
FETCH_FILE="home1.html"  # 캐시 워밍업용
LUA_SCRIPT="random_files.lua"

function wait_for_port_use() {
    timeout_count=0
    while [ "$timeout_count" -lt "$MAX_PORT_TRIES" ]; do
        if netstat -an | grep LISTEN | grep -q ":$1"; then
            return
        fi
        timeout_count=$((timeout_count + 1))
        sleep 1
    done
    echo "Timeout waiting for port $1"
    exit 1
}

function free_port {
    port=$(((RANDOM % MAX_RAND) + PORT_START))
    while true; do
        if ! netstat -an | grep LISTEN | grep -q ":$port"; then
            echo $port
            return
        fi
        port=$((port + 1))
        if [ $port -gt $PORT_MAX ]; then
            echo "-1"
            return
        fi
    done
}

function generate_test_files() {
    echo "Generating $FILE_COUNT test files (~100KB each)..."
    mkdir -p tiny
    cd tiny
    for i in $(seq 1 $FILE_COUNT); do
        fname="home${i}.html"
        head -c 102000 < /dev/urandom | base64 | head -c 102000 > "$fname"
    done
    cd ..
    echo "✅ Files generated in ./tiny/"
}

function generate_lua_script() {
    echo "Generating Lua script for wrk: $LUA_SCRIPT"
    cat > $LUA_SCRIPT <<EOF
math.randomseed(os.time())

request = function()
  local i = math.random(1, $FILE_COUNT)
  local uri = "/http://localhost:$tiny_port/home" .. i .. ".html"
  return wrk.format("GET", uri, {["Host"] = "localhost:$tiny_port"})
end
EOF
    echo "✅ Lua script created."
}

# ===== Main Execution =====

# Kill any stray processes
killall -q proxy tiny 2>/dev/null
sleep 1

# Generate test files
generate_test_files

# Check required binaries
[ ! -x ./tiny/tiny ] && echo "Building tiny server..." && (cd tiny && make)
[ ! -x ./proxy ] && echo "Error: ./proxy not found. Please build it." && exit 1

# Start Tiny
tiny_port=$(free_port)
echo "Starting tiny on port $tiny_port"
(cd tiny && ./tiny $tiny_port > /dev/null 2>&1 &) 
tiny_pid=$!
wait_for_port_use $tiny_port

# Start Proxy
proxy_port=$(free_port)
echo "Starting proxy on port $proxy_port"
./proxy $proxy_port > /dev/null 2>&1 &
proxy_pid=$!
wait_for_port_use $proxy_port

# Generate Lua script for wrk
generate_lua_script

# Warm-up cache
echo "Warming up cache with curl (for $FETCH_FILE)..."
curl -s -o /dev/null --proxy http://localhost:$proxy_port http://localhost:$tiny_port/$FETCH_FILE
if [ $? -ne 0 ]; then
    echo "⚠️  Cache warm-up failed"
fi

# Run wrk test
echo "Running wrk with random file access (4 threads, 50 connections, 10s)..."
wrk -t4 -c50 -d10s --latency -s $LUA_SCRIPT http://localhost:$proxy_port

# Cleanup
echo "Cleaning up: Killing tiny and proxy..."
kill $tiny_pid 2>/dev/null && wait $tiny_pid 2>/dev/null
kill $proxy_pid 2>/dev/null && wait $proxy_pid 2>/dev/null
pkill -f "./tiny" 2>/dev/null
pkill -f "./proxy" 2>/dev/null

echo "✅ Test completed and processes cleaned up."

1. home.html을 10초간 반복 호출한 결과

항목 개선 전 (기존 동기 LRU) 개선 후 (비동기 LRU 큐)
평균 요청 수 (Requests/sec) 3455.94 4466.27
총 요청 수 (requests) 34,891 44,681
평균 Latency 4.24ms 1.89ms
최대 Latency 136.84ms 30.02ms
표준편차 (Stdev) 10.15ms 1.54ms
  • 요청 처리 속도(req/sec)가 ~29% 증가했다.
    • 3455 → 4466 req/sec
  • 평균 응답 시간(latency)도 절반 이상 줄었다.
    • 4.24ms → 1.89ms

2. home1.html ~ home100.html을 랜덤으로 10 초간 호출을 총 10 회 실행

1번 테스트가 하나의 파일을 10초간 반복적으로 호출한 결과였다면 이번에는 캐시 미스로 교체를 하고 캐시 히트가 발생하는 경우 등을 랜덤으로 테스트할 수 있도록 구성했고 이를 10초간 테스트한다.

그리고 이런 테스트를 총 10회 실시해서 즉 100초간 테스트를 진행한 결과의 평균, 표준편차, 신뢰구간을 구했다.

개선 전:

📊 Performance Summary (Requests/sec):
Avg: 1382.70, Stddev: 17.08, Min: 1356.62, Max: 1405.71, Samples: 10

개선 후:

📊 Performance Summary (Requests/sec):
Avg: 4511.70, Stddev: 84.90, Min: 4268.24, Max: 4570.47, Samples: 10
버전 평균 처리속도 (req/sec) 표준편차 최소 최대
기존 구현 1382.70 17.08 1356.62 1405.71
비동기 큐 LRU 4511.70 (+226%) 84.90 4268.24 4570.47

회고

이 실험을 통해 확인한 사실로는 캐시 자체는 빠르다. 그러나 락 경합으로 느려질 수 있다.

구조적인 방법을 개선을 할 수 있지만 모든 기술에는 트레이드오프가 있다는 것을 느꼈다. 캐시 성능을 좋게 만들기 위해 비동기 큐를 사용하면 다시 그 비동기 큐의 크기를 관리해야 하는 관리 오버헤드가 발생할 수 있다는 것을 알게 되었다.