728x90

Pint OS의 Project 1이 구현되기 전에는 단일 큐로 idle tick이 없고 오로지 ready list로만 관리된다. kernel단 까지 들어갈 필요가 없는 thread도 block 되지 않고 yield()가 되는게 busy wait 이라는 문제가 된다.

 

그래서 sleep_list를 만들어 준 뒤에, 당장에 불 필요한 thread들을 sleep 시켜줘야 한다.

void
timer_sleep (int64_t ticks) {
	int64_t start = timer_ticks ();

	ASSERT (intr_get_level () == INTR_ON);
	if(timer_elapsed(start) < ticks){
		thread_sleep(start + ticks);
	}
}

 

timer를 활용하여 sleep을 시키는데 이 때 thread를 block시키고, sleep list로 들어가면 해당 자원을 양보시켜줘야한다.

void
thread_sleep(int64_t ticks){
	struct thread *curr = thread_current();
	enum intr_level old_level;	

	ASSERT(!intr_context());

	old_level = intr_disable();
	curr->status = THREAD_BLOCKED;
	curr->wakeup_tick = ticks;
	
	if(curr!= idle_thread)
		list_insert_ordered(&sleep_list, &curr->elem, sleep_less , NULL);
	schedule();
	intr_set_level(old_level);
}

 

sleep을 시켰으니, 적당한 타임이 되면 thread를 깨워줘야한다. 인터럽트가 발생할 때마다, 틱을 증가시키는데, 이 때 thread가 깨어날 타이밍이라면 깨운다.

static void
timer_interrupt (struct intr_frame *args UNUSED) {
	ticks++;
	thread_tick ();
	thread_wakeup(ticks);
}

 

이 때, 깨울 타임이 같은 thread 중 우선순위가 높은 순서대로 ready_list에 들어가야 우선순위가 높은 thread 부터 실행이 될 수 있다. 고로, sleep된 thread 중 깨웠을 때 greater_list에 우선순위 순으로 정렬해서 넣고 그 greater_list가 ready_list에 들어간다면 될 것 이다.

void
thread_wakeup(int64_t ticks)
{
	if(list_empty(&sleep_list))
		return;

	enum intr_level old_level;
	struct thread *sleep_front_thread = list_entry(list_begin(&sleep_list),struct thread, elem);
	struct thread *sleep_pop_front_thread;

	while(sleep_front_thread->wakeup_tick <= ticks)
	{
		old_level = intr_disable();
		sleep_front_thread->status = THREAD_READY;
		sleep_pop_front_thread = list_entry(list_pop_front(&sleep_list), struct thread, elem);
		list_insert_ordered(&greater_list, &sleep_pop_front_thread->elem , cmp_priority, NULL);
		sleep_front_thread = list_entry(list_begin(&sleep_list),struct thread, elem);	
		intr_set_level(old_level);
	}

	while(!list_empty(&greater_list))
	{
		old_level = intr_disable();
		list_push_back(&ready_list, list_pop_front(&greater_list));
		intr_set_level(old_level);
	}
}

 

lock_acquire 함수는 주어진 락을 현재 스레드가 획득하려고 시도할 때 호출된다. 이미 누군가 그 락을 가지고 있다면, 현재 스레드는 대기 상태로 들어가게 되며, 락을 보유한 스레드가 lock_release를 호출하여 락을 해제할 때까지 기다려야 한다.

 

락의 holder가 NULL이 아니라면, 즉 누군가가 이미 락을 보유하고 있다면, 현재 스레드는 대기해야 한다. 이때, 현재 스레드의 우선순위가 락을 보유한 스레드의 우선순위보다 높다면, 우선순위 기부가 발생한다. 우선순위 기부는 락을 보유한 스레드의 우선순위를 현재 스레드의 우선순위로 상향 조정함으로써, 고우선순위 스레드가 더 빨리 실행될 수 있도록 한다.

 

이미 대기 중인 스레드가 있는 경우, 그 스레드들의 우선순위도 확인하고 필요에 따라 우선순위를 상속시켜 우선순위 역전 문제를 해결한다. 락을 획득할 수 있을 때까지 현재 스레드는 세마포어를 사용하여 대기 상태로 들어가고 sema_down 함수를 호출하여 세마포어의 값을 감소시키고, 필요하다면 스레드를 대기 상태로 만든다.

void
lock_acquire (struct lock *lock) {
	ASSERT (lock != NULL);
	ASSERT (!intr_context ());
	ASSERT (!lock_held_by_current_thread (lock));

	if(lock->holder != NULL)
	{
        thread_current()->wait_on_lock = lock;
        if(lock->holder->priority < thread_current()->priority)
        {
            list_insert_ordered(&lock->holder->donations, &thread_current()->d_elem, sleep_less, NULL);
		}
		struct lock *cur_wait_on_lock = thread_current()->wait_on_lock;
		while(cur_wait_on_lock  != NULL)
		{
			if(cur_wait_on_lock->holder->priority< thread_current()->priority)
			{
				cur_wait_on_lock->holder->priority = thread_current()->priority;
				cur_wait_on_lock = cur_wait_on_lock->holder->wait_on_lock;
			}
			else
				break;
		}
	}
	sema_down (&lock->semaphore);
	thread_current()->wait_on_lock = NULL;
	lock->holder = thread_current ();
}

 

sema_down 함수는 세마포어(semaphore)의 값을 감소시키려고 시도하는 연산이다. 세마포어는 동기화 목적으로 사용되는 변수로, 리소스의 사용 가능 여부를 나타낸다. sema_down 함수는 주로 락(lock) 획득이나 자원 접근 제어에 사용된다. 세마포어의 값이 0이면, 이는 해당 리소스가 사용 중임을 의미하며, 어떤 스레드도 리소스를 사용할 수 없다.

 

sema->value가 0인 경우, 이는 다른 스레드가 이미 리소스를 사용 중임을 의미하므로, 현재 스레드는 대기해야 한다. 이를 위해 현재 스레드는 세마포어의 대기 목록(sema->waiters)에 추가된다. 대기 목록에 추가될 때, 우선순위에 따라 정렬되어 대기 순서가 결정된다. 이후, thread_block 함수를 호출하여 현재 스레드를 대기 상태로 전환한다.

 

대기 중인 스레드 없이 sema->value가 0보다 큰 경우, 혹은 대기 중인 스레드가 깨어나 세마포어를 사용할 수 있는 경우, sema->value는 1 감소한다. 이는 리소스 하나가 사용 중임을 나타낸다.

void
sema_down (struct semaphore *sema) {
	enum intr_level old_level;

	ASSERT (sema != NULL);
	ASSERT (!intr_context ());

	old_level = intr_disable ();
	while (sema->value == 0) {
		list_insert_ordered (&sema->waiters, &thread_current ()->elem, cmp_priority, NULL);
		thread_block ();
	}
	sema->value--;
	intr_set_level (old_level);
}

 

lock_release 함수는 스레드가 보유한 락(lock)을 해제하고, 해당 락을 기다리는 다른 스레드들에게 양보한다.

 

락을 해제하는 스레드(lock->holder)의 우선순위 기부 목록(donations)을 확인하여, 이 락을 기다리고 있던 모든 스레드들의 기부 항목을 제거하고, 우선순위 기부 목록을 순회하면서, 현재 락(lock)을 기다리고 있던(wait_on_lock이 현재 락을 가리키는) 스레드를 찾아 해당 스레드의 d_elem(우선순위 기부 목록에서의 위치를 나타내는 리스트 요소)을 목록에서 제거하고, wait_on_lock을 NULL로 설정한다.(우선순위 기부가 더 이상 필요하지 않음)

 

우선순위 기부 목록이 비어있지 않다면(다른 스레드들로부터 우선순위 기부를 받고 있는 상황), 목록의 첫 번째 스레드(next_thread)의 우선순위를 현재 스레드의 우선순위로 설정한다.

 

우선순위 기부 목록이 비어있다면(더 이상 우선순위 기부를 받고 있지 않은 상황), 스레드의 우선순위를 원래의 우선순위(original_priority)로 복원한다.

 

락의 holder를 NULL로 설정하여, 락의 소유권을 해제하고, sema_up(&lock->semaphore)를 호출하여 세마포어의 값을 1 증가시키고, 세마포어 대기 목록에 있는 스레드 중 하나를 깨운다(있다면). 이는 락이 이제 사용 가능하게 되었음을 나타내며, 대기 중인 스레드가 락을 획득할 수 있게 한다.

void
lock_release (struct lock *lock) {
	ASSERT (lock != NULL);
	ASSERT (lock_held_by_current_thread (lock));

	if(!list_empty(&lock->holder->donations))
	{	
		struct list_elem *front_elem = list_begin(&lock->holder->donations);
		while(front_elem != list_end(&lock->holder->donations))
		{
			struct thread *front_thread = list_entry(front_elem, struct thread, d_elem);
			if(front_thread->wait_on_lock == lock)
			{
				list_remove(&front_thread->d_elem);
				front_thread->wait_on_lock = NULL;
			}			
			front_elem = list_next(front_elem);
		}
		if(!list_empty(&lock->holder->donations))
		{
			struct thread *next_thread = list_entry(list_front(&lock->holder->donations), struct thread, d_elem);
			lock->holder->priority = next_thread->priority;
		}
		else
			lock->holder->priority = lock->holder->original_priority;
	}
	lock->holder = NULL;
	sema_up (&lock->semaphore);
}

 

세마포어의 대기자 목록(sema->waiters)이 비어있지 않은 경우, 즉 대기 중인 스레드가 있는 경우에는 대기자 목록에서 가장 앞에 있는 스레드(가장 높은 우선순위를 가진 스레드)를 꺼내 thread_unblock 함수를 호출하여 실행 가능 상태로 만들고, 세마포어의 value를 1 증가시킨다. (추가적인 리소스가 사용 가능해졌거나, 더 이상 대기 중인 스레드가 없음)

void
sema_up (struct semaphore *sema) {
	enum intr_level old_level;

	ASSERT (sema != NULL);

	old_level = intr_disable ();
	if (!list_empty (&sema->waiters))
	{
		list_sort(&sema->waiters, cmp_priority, NULL);
		thread_unblock (list_entry (list_pop_front (&sema->waiters),
					struct thread, elem));
	}
	sema->value++;
	intr_set_level (old_level);
	thread_yield();
}

 

donations가 만약 비어있다면, 현재 스레드의 priority 필드를 새로운 우선순위 값으로 업데이트를 하여 기부를 받고 있지 않을 때만 스레드의 우선순위를 직접 변경할 수 있게 해야한다.

void
thread_set_priority (int new_priority) {
	if(list_empty(&thread_current()->donations))
		thread_current ()->priority = new_priority;
		
	thread_current ()->original_priority = new_priority;

	struct thread *head_thread = list_entry(list_begin(&ready_list), struct thread, elem);
	if(head_thread->priority > new_priority)
		thread_yield();
}

 

조건 변수의 대기자 목록이 비어 있지 않다면, 대기자 목록을 우선순위에 따라 정렬해야한다. 대기자 목록(waiters)에서 가장 앞에 있는 세마포어 요소를 꺼내, 그 세마포어를 통해 대기 중인 스레드 중 하나를 깨운다.

void
cond_signal (struct condition *cond, struct lock *lock UNUSED) {
	ASSERT (cond != NULL);
	ASSERT (lock != NULL);
	ASSERT (!intr_context ());
	ASSERT (lock_held_by_current_thread (lock));

	if (!list_empty (&cond->waiters))
		list_sort(&cond->waiters, cmp_condvar_priority, NULL);
		sema_up (&list_entry (list_pop_front (&cond->waiters),
					struct semaphore_elem, elem)->semaphore);
}

 

조건 변수의 대기자 목록에 있는 두 개의 세마포어에서 객체를 가져와 두 스레드의 우선순위를 비교하여 첫 번째 스레드가 더 높다면, true 두 번째 스레드가 더 높다면 false를 반환한다.

bool
cmp_condvar_priority(const struct list_elem *a, const struct list_elem  *b, void *aux UNUSED) {
	struct semaphore_elem *sema_elem_a = list_entry(a, struct semaphore_elem, elem);
	struct semaphore_elem *sema_elem_b = list_entry(b, struct semaphore_elem, elem);

	struct semaphore sema_a = sema_elem_a->semaphore;
	struct semaphore sema_b = sema_elem_b->semaphore;

	struct thread *t_a = list_entry(list_begin(&sema_a.waiters), struct thread, elem);
	struct thread *t_b = list_entry(list_begin(&sema_b.waiters), struct thread, elem);
	
	return t_a->priority > t_b->priority;
}

 

extra 문제인 MLFQS 빼고 전부 pass되었다.

master branch에 project1만 취급하는 코드가 있음

 

GitHub - yunsejin/PintOS_RE: PintOS_Project1,2,3

PintOS_Project1,2,3. Contribute to yunsejin/PintOS_RE development by creating an account on GitHub.

github.com

 

728x90
728x90

컨텍스트 스위칭(Context Switching)은 운영 체제의 멀티태스킹과 밀접하게 연관된 중요한 개념이다. 컴퓨터에서 여러 프로세스 또는 스레드가 동시에 실행되는 것처럼 보이게 하는 메커니즘으로, 실제로는 CPU가 빠르게 여러 작업 사이를 전환하면서 각각의 작업에 조금씩 처리 시간을 할당한다.

 

 

1. 실행 중인 프로세스의 현재 상태(컨텍스트)를 PCB 형태로 저장해야 한다. 이 컨텍스트에는 PID, 프로세스의 상태(우선순위 등), 프로세스 카운터(PC), 레지스터 세트, CPU 스케줄링 정보, 메모리 상태 등이 포함된다. 이 정보는 프로세스의 컨텍스트를 구성하며, 프로세스가 다시 CPU에서 실행을 계속할 수 있도록 필요한 모든 정보를 포함한다.

 

2. 스케줄러가 다음에 실행할 프로세스를 결정한다. 이 결정은 운영 체제의 스케줄링 알고리즘에 따라 달라진다.

 

3. 이전에 저장된 다음 프로세스의 컨텍스트를 복원한다. 이 과정에서 프로세스 카운터, 레지스터, 메모리 상태 등이 CPU에 로드된다.

 

4. 복원된 컨텍스트를 바탕으로 새로운 프로세스(또는 이전에 중단되었던 프로세스)가 실행을 시작한다.

 

컨텍스트 스위칭은 자원을 소모하는 작업이다. 컨텍스트를 저장하고 복원하는 데에 시간이 걸리며, 이로 인해 CPU 사용률이 실제 작업 처리에 사용되는 것보다 낮아질 수 있다. 또한, 캐시 메모리가 무효화되는 경우(캐시 콜드 미스)가 발생할 수 있어, 성능 저하를 초래할 수 있다.

 

프로세스 대신 스레드를 사용하면 컨텍스트 스위칭 비용을 줄일 수 있다. 스레드는 같은 프로세스 내에서 메모리와 자원을 공유하기 때문에 컨텍스트 스위칭 시 저장해야 할 정보가 적다. 운영 체제의 스케줄링 알고리즘 선택도 성능에 영향을 준다. 예를 들어, 시분할(Time-sharing) 시스템에서는 라운드 로빈(Round Robin) 스케줄링이 자주 사용된다.

 

비선점형 커널은 한 프로세스가 종료될 때까지 점유하기 때문에 경쟁상황이 발생하지 않는다.

선점형 커널은 계속해서 컨텍스트 스위칭이 일어나기 때문에 경쟁상황이 발생할 수 있는데 이를 피하기 위해 임계구역이라는 것을 만들어줘야 한다.

임계구역이란 파일의 입출력, 공유 데이터 등 원자적으로 실행할 필요가 있는 명령문 또는 코드의 일부 영역이다.

 

Race condition(경쟁 상황)은 두 개 이상의 프로세스가 공통자원을 병행적으로 읽거나 쓰는 동작을 할 때, 공유 데이터에 대한 접근이 어떤 순서에 따라 이루어 졌는지에 따라 그 실행 결과가 같지 않고 달라지는 상황을 의미한다.

경쟁상황은 불 충분한 동기화, 비 원자적인 작업, 실행 순서가 잘못 되었을 때 일어난다. 여기서 비원자적인 것은 어느한 단계를 걸쳐서 실행하는 큰 함수와도 같은 것들을 말한다. 원자적, 즉 더 이상 쪼갤 수 없는 작업 단위로 컨텍스트 스위칭이 일어나야한다.

두 개 이상의 프로세스의 Race condition이 일어날 때, 서로 동일한 자원을 두고 경쟁을 하는데 이 경쟁이 끝까지 끝나지 않을 경우를 교착 상태라고 한다. 즉, 두 개의 스레드가 하나의 자원을 놓고 서로 사용하지 않고 경쟁하는 상황이다.

Deadlock(교착 상태)은 임계구역에서 둘 이상의 프로세스가 서로 보유하고 있는 자원을 요구하여, 아무도 자신의 작업을 진행할 수 없게 되는 상태를 말한다.

Starvation(기아 상태)는 하나 이상의 프로세스가 자원에 대한 접근을 무한정 기다려야 하는 상황이다. 이는 특정 프로세스가 다른 프로세스에 비해 우선 순위가 낮거나, 자원 분배 알고리즘에 의해 불리하게 작용할 때 발생할 수 있다. 부족한 자원을 여러 프로세스가 점유하려고 경쟁할 때 발생한다.

Starvation은 SJF, SRT, SPN 처럼 우선순위를 길이로 판단하는 스케줄링을 사용했을 때 일어나며, 이를 방지하기 위해 FIFO같은 우선순위가 아닌 요청 순서대로 처리하는 큐를 사용하는게 좋다.

Deadlock은 시스템이 완전히 멈춰버린 상태로, 아무 진전이 없을 수도 있고 즉시 해결해야 하는 긴급한 문제이고, Starvation은 특정 프로세스가 진행할 수 없는 상태지만, 다른 부분은 여전히 작동가능하여 장기적인 관점에서 해결할 수 있는 문제이다.

 

세마포어(Semaphore): 동시성 프로그래밍에서 공유 자원에 대한 접근을 제어하기 위해 사용되는 변수나 추상 데이터 타입이다. 멀티 스레드나 프로세스 환경에서 여러 스레드 또는 프로세스가 동시에 공유 자원을 사용하려 할 때, 이를 안전하게 조정하기 위한 메커니즘 중 하나이다.

이진 세마포어(Binary Semaphore): 값이 0 또는 1만을 가질 수 있는 세마포어로, 뮤텍스(Mutex)와 유사한 기능을 한다. 이는 공유 자원에 단 하나의 스레드/프로세스만 접근할 수 있게 하여 상호 배제(Mutual Exclusion)를 구현한다.

카운팅 세마포어(Counting Semaphore): 값이 0 이상의 정수를 가질 수 있는 세마포어로, 한 번에 여러 스레드/프로세스가 공유 자원에 접근할 수 있게 한다. 카운팅 세마포어의 값은 동시에 접근할 수 있는 최대 스레드/프로세스 수를 의미한다.

세마포어는 주로 두 가지 기본 연산, wait()(또는 P(), acquire()) 연산과 signal()(또는 V(), release()) 연산을 사용한다.

wait() 연산: 스레드/프로세스가 공유 자원을 사용하기 전에 이 연산을 호출한다. 세마포어의 값이 0보다 크면, 세마포어의 값을 1 감소시키고 실행을 계속한다. 만약 세마포어의 값이 0이면, 공유 자원이 사용 가능해질 때까지 스레드/프로세스를 대기 상태로 만든다.

signal() 연산: 스레드/프로세스가 공유 자원의 사용을 마친 후에 이 연산을 호출한다. 세마포어의 값을 1 증가시키고, 대기 중인 스레드/프로세스 중 하나를 깨워 공유 자원을 사용할 수 있게 한다.

 

import threading
import time
import random

# 세마포어 생성, 동시에 리소스에 접근할 수 있는 스레드의 최대 수를 2로 설정
semaphore = threading.Semaphore(2)

class MyThread(threading.Thread):

    def run(self):
        # 세마포어 획득(리소스 접근)
        with semaphore:
            print(f"{self.name} is now sleeping")
            time.sleep(random.randint(1, 5))
            print(f"{self.name} is finished")

# 스레드 객체 생성
threads = []
for i in range(4):
    threads.append(MyThread())

# 모든 스레드 시작
for t in threads:
    t.start()

# 모든 스레드의 종료를 기다림
for t in threads:
    t.join()

뮤텍스(Mutex, Mutual Exclusion Object)

동시성 프로그래밍에서 공유 자원에 대한 동시 접근을 방지하기 위해 사용되는 동기화 메커니즘이다. 뮤텍스는 한 번에 하나의 스레드만 공유 자원을 사용할 수 있도록 보장함으로써, 데이터의 일관성과 무결성을 유지한다.

스레드가 공유 자원을 사용하기 전에 뮤텍스를 잠그거나 획득한다. 이 시점에서 해당 뮤텍스는 잠긴 상태가 되며, 다른 스레드는 그 자원을 사용할 수 없다.

뮤텍스를 성공적으로 잠근 스레드는 공유 자원을 안전하게 사용할 수 있다. 스레드가 자원 사용을 마치면 뮤텍스를 해제하거나 방출한다. 이로써 다른 스레드가 해당 자원에 접근할 수 있는 기회를 얻는다.

 

import threading
import time
import random

mutex = threading.Lock()

class ThreadOne(threading.Thread):

    def run(self):

        global mutex
		# 뮤텍스를 획득
        mutex.acquire()  

        print("The first thread is now sleeping")

        time.sleep(random.randint(1, 5))  # 1초에서 5초 사이의 랜덤한 시간 동안 대기

        print("First thread is finished")
        
        # 작업이 끝나면 뮤텍스를 해제
        mutex.release()  

class ThreadTwo(threading.Thread):

    def run(self):
    
        global mutex
        
		# 첫 번째 스레드가 뮤텍스를 해제할 때까지 대기
        mutex.acquire()  

        print("The second thread is now sleeping")
        
		# 1초에서 5초 사이의 랜덤한 시간 동안 대기
        time.sleep(random.randint(1, 5))  

        print("Second thread is finished")
        
		# 작업이 끝나면 뮤텍스를 해제
        mutex.release()  

t1 = ThreadOne()
t2 = ThreadTwo()

t1.start()
t2.start()

t1.join()  # 첫 번째 스레드가 종료될 때까지 메인 스레드 대기
t2.join()  # 두 번째 스레드가 종료될 때까지 메인 스레드 대기

 

728x90

'CS > 운영체제' 카테고리의 다른 글

하이퍼바이저, 애뮬레이션, QEMU  (0) 2024.03.26
Pint OS_Project 1 구현  (1) 2024.03.26
CPU Scheduling, 4BSD, nice  (1) 2024.03.25
32bit and 64bit, RAX, CPU vs GPU  (1) 2024.03.24
ECF, 시스템 콜과 인터럽트 그리고 시그널  (0) 2024.03.22
728x90

CPU 스케줄링은 시스템의 성능과 효율성을 최적화하기 위해 여러 프로세스 또는 스레드에 CPU 사용 시간을 어떻게 할당할지 결정하는 과정이다. 이 과정에서 스케줄링 알고리즘을 사용하여 작업의 실행 순서를 결정한다.

 

FIFO(FCFS)는 먼저 도착한 프로세스를 먼저 처리한다. 프로세스가 준비 큐에 도착한 순서대로 CPU를 할당받는다.

프로세스 ID 도착 시간 실행 시간
P1 0 4
P2 1 3
P3 2 2

 

  1. P1은 시간 0에 도착하여 바로 실행을 시작하고, 4시간 동안 실행된다. (0-4)
  2. P2는 시간 1에 도착하지만, P1이 실행 중이므로 대기해야 하며, P1이 완료된 시간 4에 실행을 시작하여 3시간 동안 실행된다. (4-7)
  3. P3는 시간 2에 도착하지만, P1과 P2의 실행을 대기해야 하며, P2가 완료된 시간 7에 실행을 시작하여 2시간 동안 실행된다. (7-9)

SJF(Shortest Job First)는 실행 시간이 가장 짧은 프로세스에게 먼저 CPU를 할당한다. SJF는 비선점형과 선점형(SRTF) 두 가지 변형이 있다.

프로세스 ID 도착 시간 실행 시간
P1 0 6
P2 1 5
P3 2 4

 

  1. 시간 0에 P1이 도착하고, 다른 프로세스가 없으므로 P1이 실행을 시작한다.
  2. 시간 1에 P2가 도착하지만, P1이 실행 중이므로 P2는 대기해야 한다.
  3. 시간 2에 P3가 도착하지만, P1이 여전히 실행 중이므로 P3도 대기한다.
  4. 시간 6에 P1의 실행이 완료되며, 이 시점에서 대기 중인 P2와 P3 중에서 실행 시간이 가장 짧은 P3(실행 시간 4)이 다음으로 실행된다.
  5. 시간 10에 P3의 실행이 완료되고, 마지막으로 P2(실행 시간 5)가 실행된다.

RR(Round Robin)은 프로세스에게 동일한 크기의 시간 할당량(타임 슬라이스)을 순차적으로 할당한다. 타임 슬라이스가 지나면 프로세스는 준비 큐의 끝으로 이동하고, CPU는 다음 프로세스에게 할당된다.

프로세스ID 도착 시간 실행 시간
P1 0 6
P2 2 2
P3 4 4

 

위 표에서 타임 슬라이스가 2일 때,

  1. 시간 0~2: P1이 먼저 시작하여 처음 2초 동안 실행된다.
  2. 시간 2~4: P1의 타임 슬라이스가 끝나고 P2가 도착했으므로, P2가 다음 2초 동안 실행된다.
  3. 시간 4~6: P2가 종료되고, P3가 도착했으므로 P3가 다음 2초 동안 실행된다.
  4. 시간 6~8: P1이 다시 실행되어 남은 2초 동안 실행된다.(총 실행 시간 6초 중 4초가 남았으므로 2초만 실행)
  5. 시간 8~10: P3가 다시 실행되어 다음 2초 동안 실행된다.(남은 시간 2초)
  6. 시간 10~12: P3의 남은 2초가 실행되어 종료된다.

우선순위 스케줄링(Priority Scheduling)은 각 프로세스에 우선순위를 할당하고, 가장 높은 우선순위를 가진 프로세스에게 먼저 CPU를 할당한다. 이 알고리즘도 비선점형과 선점형이 있다.

프로세스 ID 도착 시간 실행 시간 우선순위
P1 0 6 3
P2 2 2 1
P3 4 4 2
  • 시간 0~2: P1이 가장 먼저 도착하므로 실행을 시작한다. 우선순위는 3이다.
  • 시간 2~4: P2가 도착한다. P2의 우선순위는 1로, 가장 높다. 따라서, P1의 실행이 중단되고 P2가 실행을 시작한다. P2는 실행 시간이 2초이므로 이 기간 동안 완료된다.
  • 시간 4~6: P2의 실행이 완료된 후, P1이 중단되었던 지점부터 실행을 재개한다. 그러나 바로 P3가 도착한다. P3의 우선순위는 2로, 실행 중인 P1보다 높다. 따라서, P1의 실행이 다시 중단되고 P3가 실행을 시작한다.
  • 시간 4~8: P3은 실행 시간이 4초이므로, 이 기간 동안 실행되어 완료된다.
  • 시간 8~14: P3의 실행이 완료된 후, 중단되었던 P1이 남은 실행 시간을 가지고 실행을 재개한다. P1은 남은 4초 동안 실행되어 완료된다.

다단계 큐(Multilevel Queue)스케줄링은 준비 큐를 여러 개의 별도 큐로 분할하고, 각 큐는 자신만의 스케줄링 알고리즘을 가진다. 프로세스는 그들의 특성(예: 우선순위, 프로세스 타입 등)에 따라 적절한 큐에 배치된다.

프로세스 ID 도착 시간 실행 시간
P1 0 6 Q2
P2 2 2 Q1
P3 4 4 Q2

두 개의 큐 (Q1>Q2)일 때,

  1. 시간 0~2: P1이 Q2에 있으며, 시스템에 다른 프로세스가 없기 때문에 P1이 실행을 시작한다.
  2. 시간 2~4: P2가 도착하고 Q1에 배치된다. Q1이 Q2보다 우선순위가 높기 때문에, P1의 실행이 중단되고 P2가 실행을 시작한다.
  3. 시간 4~6: P2의 실행이 완료된다. P1의 남은 실행 시간은 4초이다. 이때 P3가 도착하지만, P1이 먼저 도착했으므로 P1이 계속해서 실행된다.
  4. 시간 6~10: P1의 실행이 완료된다. 이제 P3가 실행을 시작한다.
  5. 시간 10~14: P3의 실행이 완료된다.

다단계 피드백 큐(Multilevel Feedback Queue)스케줄링은 다단계 큐 스케줄링을 확장한 형태로, 프로세스가 다른 큐로 이동할 수 있게 한다. 이는 프로세스의 실행 행위에 따라 동적으로 우선순위를 조정할 수 있게 한다.

프로세스 ID 도착 시간 실행 시간
P1 0 6 Q1
P2 2 2 Q1
P3 4 4 Q1

두 개의 큐(Q1>Q2), 타임 슬라이스 2일때, 

  1. 시간 0~2: P1이 처음 2초 동안 실행된다.
  2. 시간 2~4: P1의 타임 슬라이스가 종료되고, P2가 도착했으므로 P2가 다음 2초 동안 실행된다. 이 시점에서 P2는 완료된다.
  3. 시간 4~6: P2가 완료되고, P3가 도착했으므로 P3가 다음 2초 동안 실행된다.
  4. 시간 6~8: P3의 첫 번째 타임 슬라이스가 종료되고, P1이 다시 실행을 시작한다. P1은 두 번째 타임 슬라이스인 2초 동안 실행된다.
  5. 시간 8~10: P1의 두 번째 타임 슬라이스가 종료되고, P3가 다시 실행을 시작한다. P3는 나머지 2초 동안 실행되어 완료된다.
  6. 시간 10~12: P1이 마지막 2초 동안 실행되어 완료된다.

4BSD(4th Berkeley Software Distribution)는 유닉스 운영 체제의 한 버전으로, 버클리 캘리포니아 대학교에서 개발되었다. 이 운영 체제는 많은 혁신적인 기능을 도입하여 유닉스 기반 시스템의 발전에 큰 영향을 미쳤다. 4BSD 스케줄러는 특히 시분할 시스템에 적합한 CPU 스케줄링 알고리즘을 제공하며, 시스템의 반응 시간과 사용자와의 상호 작용에 초점을 맞춘다. 이 스케줄러는 프로세스의 우선순위를 동적으로 조정하여, 인터랙티브한 작업과 배경 작업 사이의 균형을 잘 맞출 수 있다.

 

nice는 유닉스 및 유닉스 계열 운영 체제에서 프로세스의 스케줄링 우선순위를 조정하기 위해 사용되는 명령어이다. 프로세스에 'nice 값'을 할당함으로써, 시스템은 해당 프로세스의 우선순위를 조정한다. nice 값이 낮을수록 프로세스는 더 높은 우선순위를 갖게 되어 CPU 시간을 더 많이 받는다. 반대로, nice 값이 높을수록 프로세스의 우선순위는 낮아지고, CPU 시간을 더 적게 할당받게 된다. 이를 통해 시스템 관리자나 사용자는 중요도가 낮은 작업에 더 낮은 우선순위를 지정하여 중요한 작업이 더 많은 시스템 자원을 사용할 수 있도록 할 수 있다.

 

728x90
728x90

프록시 서버는 인터넷 사용 시 '중계자'로서 중요한 역할을 한다. 이를 통해 온라인 활동은 보다 안전하고 효율적으로 이루어진다. 프록시 서버에는 주로 두 가지 종류가 있다.

 

 

포워드 프록시는 사용자의 온라인 신원을 숨기는 역할을 한다. 인터넷에 접속할 때 직접 서버로 연결하지 않고 프록시 서버를 거쳐서, 사용자의 IP 주소와 같은 개인 정보의 노출을 막는다. 이는 마치 온라인에서 가면을 쓰고 다니는 것과 같아 사용자를 보호한다.

 

 

리버스 프록시는 서버 측에서 사용되며, 외부의 요청을 받아 내부 서버로 전달하는 역할을 한다. 이를 통해 보안을 강화하고, 여러 서버가 요청을 효율적으로 처리할 수 있도록 돕는다. 실제 서버들은 내부망에 숨어 있으며, 외부에서는 리버스 프록시를 통해서만 접근할 수 있다.

 

프록시 체이닝은 여러 프록시 서버를 연속적으로 사용하는 기술로, 사용자의 IP 주소를 더욱 효과적으로 숨길 수 있다. 해외 프록시를 포함하여 여러 단계의 프록시를 거치면 추적이 더 어려워진다.

 

프록시 서버는 다음과 같은 주요 기능을 제공한다.

  • 방화벽: 외부의 위협으로부터 내부 네트워크를 보호한다.
  • 익명화: 사용자의 신원을 숨기고 익명으로 인터넷을 사용할 수 있도록 한다. 프록시는 요청에서 식별 정보를 제거하여 이를 가능하게 한다.
  • 캐시: 자주 접속하는 웹 사이트의 데이터를 저장해 놓고, 다음 접속 시 빠르게 로드할 수 있도록 도와준다.

proxy.c(main)

int main(int argc,char **argv)
{
    int listenfd,connfd;
    socklen_t  clientlen;
    char hostname[MAXLINE],port[MAXLINE];
    struct sockaddr_storage clientaddr;
    if(argc != 2){
        fprintf(stderr,"usage :%s <port> \n",argv[0]);
        exit(1);
    }
    listenfd = Open_listenfd(argv[1]);
    while(1){
        clientlen = sizeof(clientaddr);
        connfd = Accept(listenfd,(SA *)&clientaddr,&clientlen);
        Getnameinfo((SA*)&clientaddr,clientlen,hostname,MAXLINE,port,MAXLINE,0);
        printf("Accepted connection from (%s %s).\n",hostname,port);
        doit(connfd);
        Close(connfd);
    }
    return 0;
}

 

1. Open_listenfd 함수를 호출해 주어진 포트 번호에 대한 리슨 소켓을 열고, 클라이언트의 연결 요청을 기다릴 준비를 한다.

 

2. 프로그램은 무한히 실행되며, while(1) 루프 안에서 Accept 함수를 사용하여 클라이언트의 연결 요청을 기다리고 수락한다. 연결 요청이 들어오면, 클라이언트와 통신하기 위한 새로운 소켓(connfd)을 얻는다.

 

3. 연결된 클라이언트의 IP 주소와 포트 번호를 얻기 위해 Getnameinfo 함수를 사용한다. 이 정보는 연결이 수락되었음을 나타내는 메시지에 사용된다.

 

4. doit 함수를 호출하여 클라이언트로부터 받은 요청을 처리한다.

 

5. 클라이언트의 요청을 처리한 후, Close 함수를 사용하여 클라이언트와의 연결을 닫는다. 이는 서버가 사용한 리소스를 해제하고 다음 연결 요청을 준비하는 단계이다.

 

proxy.c(doit)

void doit(int connfd){
  int end_serverfd;
  char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
  char endserver_http_header[MAXLINE];
  char hostname[MAXLINE], path[MAXLINE];
  int port;
  rio_t rio,rio_endserver;
  Rio_readinitb( & rio, connfd);
  Rio_readlineb( & rio, buf, MAXLINE);
  printf("Request headers:\n");
  printf("%s", buf);
  sscanf(buf, "%s %s %s", method, uri, version);
  if (strcasecmp(method, "GET")) {
    printf("Error code: 501\n");
    return;
  }
  parse_uri(uri,hostname,path,&port);
  build_http_header(endserver_http_header,hostname,path,port,&rio);
  end_serverfd = connect_endServer(hostname,port,endserver_http_header);
  if(end_serverfd<0){
    printf("connection failed\n");
    return;
  }
  Rio_readinitb(&rio_endserver,end_serverfd);
  Rio_writen(end_serverfd,endserver_http_header,strlen(endserver_http_header));
  size_t n;
  while((n=Rio_readlineb(&rio_endserver,buf,MAXLINE))!=0){
    printf("Proxy received %d bytes,then send\n",n);
    Rio_writen(connfd,buf,n);
  }
  Close(end_serverfd);
}

 

1. 필요한 변수를 초기화하고, 클라이언트 연결 파일 디스크립터(connfd)를 사용하여 클라이언트로부터 HTTP 요청 헤더를 읽는다.

 

2. 요청에서 HTTP 메소드(GET 등)를 추출하고, 이 메소드가 GET이 아닐 경우 오류 코드 501(Not Implemented)를 출력하고 함수에서 빠져나온다.

 

3. 요청된 URI에서 호스트 이름, 경로(path), 포트를 추출한다. 이 정보는 엔드 서버로의 연결 설정에 필요하다.

 

4. 엔드 서버로 전송할 새로운 HTTP 헤더를 구성한다. 이 과정에서 호스트 이름, 경로, 포트 정보 등을 사용하여 헤더를 작성한다.

 

5. 추출된 호스트 이름과 포트를 사용하여 엔드 서버에 연결을 시도하고, 연결에 성공하면 엔드 서버와의 통신을 위한 새로운 파일 디스크립터(end_serverfd)를 얻는다. 연결 실패 시 오류 메시지를 출력하고 함수에서 빠져나온다.

 

6. 구성한 HTTP 헤더를 엔드 서버로 전송하고 엔드 서버로부터 응답을 받아 클라이언트에게 전달한다. 이 과정에서 프록시 서버는 엔드 서버로부터 받은 데이터를 그대로 클라이언트에게 전송한다.

 

7. 엔드 서버와의 연결을 종료한다.

 

proxy.c(build_http_header)

void build_http_header(char *http_header,char *hostname,char *path,int port,rio_t *client_rio){
  char buf[MAXLINE],request_hdr[MAXLINE],other_hdr[MAXLINE],host_hdr[MAXLINE];
  sprintf(request_hdr,requestlint_hdr_format,path);
  while(Rio_readlineb(client_rio,buf,MAXLINE)>0){
    if(strcmp(buf,endof_hdr)==0) break;
    if(!strncasecmp(buf,host_key,strlen(host_key))){
      strcpy(host_hdr,buf);
      continue;
    }
    if(!strncasecmp(buf,connection_key,strlen(connection_key))&&
    !strncasecmp(buf,proxy_connection_key,strlen(proxy_connection_key))&&
    !strncasecmp(buf,user_agent_key,strlen(user_agent_key))){
      strcat(other_hdr,buf);
    }
  }
  if(strlen(host_hdr)==0){
    sprintf(host_hdr,host_hdr_format,hostname);
  }
  sprintf(http_header,"%s%s%s%s%s%s%s",request_hdr,host_hdr,conn_hdr,prox_hdr,user_agent_hdr,other_hdr,endof_hdr);
  return;
}

 

1. 필요한 헤더 문자열을 저장할 버퍼들을 초기화한다.

 

2. 클라이언트로부터 받은 URI의 경로(path) 정보를 사용하여 HTTP 요청 라인을 생성한다. 예를 들어, GET /path HTTP/1.1과 같은 형태이다.

 

3. 함수를 사용하여 클라이언트로부터 한 줄씩 헤더를 읽는다. 헤더의 끝을 나타내는 빈 줄을 만날 때까지 이 작업을 반복한다.

 

4. 헤더를 처리하고 처리할 때 생성한 요청 라인, Host 헤더, 필수 헤더(Connection, Proxy-Connection, User-Agent), 그리고 다른 헤더들을 하나의 HTTP 요청 헤더로 조합한다.

 

5. 최종적으로 조합된 HTTP 요청 헤더를 http_header에 저장하여 함수를 종료한다.

 

위 코드는 해당 URL yunsejin/proxy 브랜치에 있다.

 

GitHub - yunsejin/Webproxy-lab

Contribute to yunsejin/Webproxy-lab development by creating an account on GitHub.

github.com

728x90

'서버' 카테고리의 다른 글

Express.js Clean Architecture 구성하기  (0) 2024.04.12
echo server, tiny server 구현  (2) 2024.03.24
Socket, Redirection, Pipe  (1) 2024.03.23
Parsing, Caching, Filtering, Load Balancing, MTU, NAT  (1) 2024.03.23
728x90

Echo server는 클라이언트로부터 받은 메시지를 그대로 돌려보내는 간단한 서버다. 네트워크 연결, 소켓 프로그래밍, 서버-클라이언트 아키텍처 등을 이해하기 위한 교육용 또는 테스트용으로 주로 사용된다.

 

echoserveri.c

#include "csapp.h"

void echo(int connfd);

int main(int argc, char **argv)
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    char client_hostname[MAXLINE], client_port[MAXLINE];

    if (argc != 2) {
    fprintf(stderr, "usage: %s <port>\n", argv[0]);
    exit(0);
    }

    listenfd = Open_listenfd(argv[1]);
    while (1) {
    clientlen = sizeof(struct sockaddr_storage);
    connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
        Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE,
                    client_port, MAXLINE, 0);
        printf("Connected to (%s, %s)\n", client_hostname, client_port);
    echo(connfd);
    Close(connfd);
    }
    exit(0);
}

 

1. 프로그램은 실행할 때 하나의 인자(포트 번호)를 받아야 한다. 인자의 개수(argc)가 2가 아니면(프로그램 이름 포함), 사용법을 출력하고 프로그램을 종료한다.

 

2. Open_listenfd 함수는 제공된 포트 번호에 대해 듣기(listen) 상태의 소켓을 열고, 이 소켓의 파일 기술자를 반환한다. 이 함수는 socket(), bind(), listen() 시스템 호출을 캡슐화하여 사용하기 쉽게 만든 것이다.

 

3. 무한 루프 안에서, 서버는 Accept 함수를 통해 클라이언트의 연결 요청을 기다린다. 연결이 수립되면, 클라이언트의 주소 정보를 가져오기 위해 Getnameinfo 함수를 사용한다. 이 정보는 클라이언트의 호스트 이름과 포트 번호를 문자열로 변환한다.

 

4. 클라이언트로부터 받은 데이터를 처리하고, 그 데이터를 클라이언트에게 다시 전송하는 echo 함수를 호출한다. echo 함수의 구현은 제공되지 않았으나, 일반적으로 클라이언트로부터 데이터를 읽고(read), 동일한 데이터를 클라이언트로 다시 쓰는(write) 작업을 수행한다.

 

echoclient.c

#include "csapp.h"

int main(int argc, char **argv)
{
    int clientfd;
    char *host, *port, buf[MAXLINE];
    rio_t rio;

    if (argc != 3) {
    fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
    exit(0);
    }
    host = argv[1];
    port = argv[2];

    clientfd = Open_clientfd(host, port);
    Rio_readinitb(&rio, clientfd);

    while (Fgets(buf, MAXLINE, stdin) != NULL) {
    Rio_writen(clientfd, buf, strlen(buf));
    Rio_readlineb(&rio, buf, MAXLINE);
    Fputs(buf, stdout);
    }
    Close(clientfd); //line:netp:echoclient:close
    exit(0);
}

 

1. 프로그램은 실행할 때 두 개의 인자(호스트와 포트)를 받아야 한다. 인자의 개수(argc)가 3이 아니면 사용법을 출력하고 프로그램을 종료한다.

 

2. Open_clientfd 함수를 사용해 주어진 호스트와 포트에 대한 연결을 시도하고, 연결된 소켓의 파일 기술자를 반환한다. 이 함수는 socket(), connect() 등의 시스템 호출을 캡슐화한다.

 

3. Rio_readinitb 함수를 사용해 robust I/O (RIO) 패키지를 초기화한다. 이 패키지는 네트워크 I/O를 위한 더 안정적이고 편리한 인터페이스를 제공한다.

 

4. Fgets, Fputs를 이용한 루프는 EOF(End Of File)를 입력할 때까지 (예: 터미널에서 Ctrl+D, C를 누를 때) 계속된다.

echoserver 테스트

 

Tiny server는 극도로 가벼운 웹 서버를 의미한다. 리소스가 제한된 환경에서 작동하거나, 간단한 웹 기반 애플리케이션을 호스팅하는 데 사용될 수 있다. 가볍다는 특성 때문에 빠른 성능을 요구하는 작은 규모의 프로젝트나 임베디드 시스템에서 유용하다.

 

tiny.c(main)

int main(int argc, char **argv)
{
  int listenfd, connfd;
  char hostname[MAXLINE], port[MAXLINE];
  socklen_t clientlen;
  struct sockaddr_storage clientaddr;

  /* Check command line args */
  if (argc != 2)
  {
    fprintf(stderr, "usage: %s <port>\n", argv[0]);
    exit(1);
  }

  listenfd = Open_listenfd(argv[1]);
  while (1)
  {
    clientlen = sizeof(clientaddr);
    connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); // line:netp:tiny:accept
    Getnameinfo((SA *)&clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0);
    printf("Accepted connection from (%s, %s)\n", hostname, port);
    doit(connfd);  // line:netp:tiny:doit
    Close(connfd); // line:netp:tiny:close
  }
}

 

프로그램은 정확히 하나의 명령줄 인자(서버가 리슨할 포트 번호)를 기대한다. 만약 제대로 된 인자가 제공되지 않으면, 사용법을 출력하고 프로그램을 종료한다.

 

Open_listenfd 함수를 호출하여 리슨 소켓을 생성하고, 제공된 포트 번호에 바인드한다. 이 함수는 내부적으로 socket(), bind(), listen() 시스템 호출을 수행한다.

 

tiny.c(parse_uri)

int parse_uri(char *uri, char *filename, char *cgiargs)
{
  char *ptr;

  if (!strstr(uri, "cgi-bin"))
  { /* Static content */             // line:netp:parseuri:isstatic
    strcpy(cgiargs, "");             // line:netp:parseuri:clearcgi
    strcpy(filename, ".");           // line:netp:parseuri:beginconvert1
    strcat(filename, uri);           // line:netp:parseuri:endconvert1
    if (uri[strlen(uri) - 1] == '/') // line:netp:parseuri:slashcheck
      strcat(filename, "adder.html"); // line:netp:parseuri:appenddefault
    return 1;
  }
  else
  { /* Dynamic content */  // line:netp:parseuri:isdynamic
    ptr = index(uri, '?'); // line:netp:parseuri:beginextract
    if (ptr)
    {
      strcpy(cgiargs, ptr + 1);
      *ptr = '\0';
    }
    else
      strcpy(cgiargs, ""); // line:netp:parseuri:endextract
    strcpy(filename, "."); // line:netp:parseuri:beginconvert2
    strcat(filename, uri); // line:netp:parseuri:endconvert2
    return 0;
  }
}

 

정적 콘텐츠 처리:

1. strstr 함수를 사용하여 URI에 "cgi-bin" 문자열이 포함되어 있는지 확인한다. 포함되어 있지 않다면, 요청은 정적 콘텐츠로 간주된다.

 

2. cgiargs를 빈 문자열로 설정한다. 이는 정적 콘텐츠 요청에서 CGI 인자가 필요 없음을 의미한다.

 

3. filename에 현재 디렉토리를 나타내는 "."을 먼저 복사하고, 그 뒤에 URI를 이어 붙인다. 이렇게 함으로써 서버의 로컬 파일 시스템 상에서 해당 파일의 경로를 생성한다.

 

4. URI가 슬래시(/)로 끝난다면, 이는 디렉토리에 대한 요청으로 간주되며, 기본 파일 이름(예: "adder.html")을 filename에 추가한다.

 

동적 콘텐츠 처리:

1. URI에 "cgi-bin"이 포함되어 있다면, 요청은 동적 콘텐츠로 간주된다.

 

2. index 함수를 사용하여 URI 내의 '?' 문자를 찾는다. 이 문자는 CGI 인자의 시작을 나타낸다.

 

3. '?' 문자가 존재한다면, 이를 기준으로 문자열을 나눈다. '?' 뒤의 문자열은 CGI 인자로, cgiargs에 복사된다. '?' 문자 자체는 '\0'(null 문자)로 대체되어, URI 문자열을 종료시킨다.

 

4. filename에 현재 디렉토리를 나타내는 "."을 복사하고, 변경된 URI(인자가 제거된 상태)를 이어 붙인다.

 

tiny.c(clienterror)

void clienterror(int fd, char *cause, char *errnum, char *shortmsg, char *longmsg){
  char buf[MAXLINE], body[MAXBUF];

  /* Build the HTTP response body*/
  sprintf(body, "<html><title>Tiny Error</title>");
  sprintf(body, "%s<body bgcolor=""ffffff"">\r\n", body);
  sprintf(body, "%s%s: %s\r\n", body, errnum, shortmsg);
  sprintf(body, "%s<p>%s: %s\r\n", body, longmsg, cause);
  sprintf(body, "%s<hr><em>The Tiny Web Server</em>\r\n", body);

  /* Print the HTTP response */
  sprintf(buf, "HTTP/1.1 %s %s\r\n", errnum, shortmsg);
  Rio_writen(fd, buf, strlen(buf));
  sprintf(buf, "Content-type: text/html\r\n");
  Rio_writen(fd, buf, strlen(buf));
  sprintf(buf, "Content-length: %d\r\n\r\n", (int)strlen(body));

  Rio_writen(fd, buf, strlen(buf));
  Rio_writen(fd, body, strlen(body));
}
/* $end clienterror */
void echo(int connfd)
{
  size_t n;
  char buf[MAXLINE];
  rio_t rio;

  Rio_readinitb(&rio, connfd);
  while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
  {
    if (strcmp(buf, "\r\n") == 0)
      break;
    Rio_writen(connfd, buf, n);
  }
}

 

sprintf를 사용해 HTML 형식의 오류 메시지를 body 버퍼에 작성한다. 오류 제목, 배경색, HTTP 상태 코드, 짧은 오류 메시지, 긴 오류 메시지, 오류 원인이 포함된다.

 

오류 상태 코드와 짧은 메시지를 포함하는 HTTP 응답의 시작 라인을 buf 버퍼에 작성한다. 컨텐츠 타입(text/html)을 지정하고, 계산된 응답 본문의 길이를 사용해 Content-length 헤더를 작성한다.

 

adder.c

#include "csapp.h"

int main(void) {
  char *buf, *p;
  char arg1[MAXLINE], arg2[MAXLINE], content[MAXLINE];
  int n1=0, n2=0;

  /* Extract the two arguments */
if ((buf = getenv("QUERY_STRING")) != NULL) {
  p = strchr(buf, '&');
  *p = '\0';
  sscanf(buf, "first=%d", &n1);
  sscanf(p+1, "second=%d", &n2);
}

  /* Make the response body */
  sprintf(content, "QUERY_STRING=%s", buf);
  sprintf(content, "Welcome to add.com: ");
  sprintf(content, "%sTHE Internet addition portal. \r\n<p>", content);
  sprintf(content, "%sThe answer is: %d + %d = %d\r\n<p>", content, n1, n2, n1 + n2);
  sprintf(content, "%sThanks for visiting!\r\n", content);

  /* Generate the HTTP response */
  printf("Connection: close\r\n");
  printf("Content-length: %d\r\n", (int)strlen(content));
  printf("Content-type: text/html\r\n\r\n");
  printf("%s", content);
  fflush(stdout);

  exit(0);
}

 

1. getenv("QUERY_STRING")를 통해 환경변수에서 쿼리 문자열(QUERY_STRING)을 가져온다. 이 문자열은 웹 브라우저(또는 다른 클라이언트)에서 입력한 데이터를 포함하며, first=숫자1&second=숫자2 형식으로 되어 있다.

 

2. 쿼리 문자열에서 & 문자를 찾아 두 부분으로 나눈다(first=숫자1second=숫자2). 이후, sscanf를 사용해 각 부분에서 숫자를 추출하고, 정수 변수 n1, n2에 저장한다.

 

3. 응답 본문을 구성하는 여러 sprintf 호출을 통해 HTML 형식의 응답을 만든다. 이 응답은 방문자에게 환영 메시지, 계산된 결과, 그리고 방문에 대한 감사 메시지를 포함한다.

 

4. printf를 사용해 HTTP 헤더를 출력한다. 여기에는 연결 종료(Connection: close), 컨텐츠 길이(Content-length), 컨텐츠 타입(Content-type: text/html)이 포함된다. 마지막으로, 만들어진 HTML 컨텐츠 본문을 출력한다.

 

5. fflush(stdout)를 호출해 출력 버퍼를 비우고, exit(0)을 호출해 프로그램을 정상적으로 종료한다.

 

이 코드는 예전에 구현한 코드이고 지금 보니, adder.c 코드에는 두 가지 문제점이 있다.

 

1. 현재 코드에는 sprintf(content, "QUERY_STRING=%s", buf);라는 라인이 있으나, 이후에 content에 다른 문자열을 쓸 때마다 이 내용을 덮어쓰게 되므로, 실제로는 이 내용이 응답에 포함되지 않는다. 적절한 응답 메시지 생성을 위해 sprintf 대신 strcat을 사용하거나, sprintf를 사용할 때 content에 계속해서 추가하는 방식으로 바꿔야 한다.

 

2. *p = '\0'; 이 부분에서 pNULL인 경우(즉, 쿼리 문자열에 &가 없는 경우)를 처리하지 않고 있다. 이 경우 p를 참조하려고 하면 프로그램이 비정상적으로 종료될 수 있다. 따라서 pNULL이 아닌지 확인하는 조건문이 필요하다.

 

위의 모든 코드는 yunsejin/tiny에 있다.

 

GitHub - yunsejin/Webproxy-lab

Contribute to yunsejin/Webproxy-lab development by creating an account on GitHub.

github.com

 

728x90

'서버' 카테고리의 다른 글

Express.js Clean Architecture 구성하기  (0) 2024.04.12
Proxy 서버 구현  (1) 2024.03.24
Socket, Redirection, Pipe  (1) 2024.03.23
Parsing, Caching, Filtering, Load Balancing, MTU, NAT  (1) 2024.03.23
728x90

32비트 시스템은 한 번에 32비트(4바이트)의 데이터를 처리할 수 있다. 메모리 주소도 32비트로 표현되어, 최대 4GB(2의 32승 바이트)의 RAM을 직접적으로 접근할 수 있다. 32비트 운영 체제와 응용 프로그램은 주로 오래된 컴퓨터나 임베디드 시스템에서 사용된다.

 

32비트 시스템에서는 에서는 대규모 데이터 세트를 처리하거나 고해상도 그래픽을 사용하는 최신 게임과 응용 프로그램의 요구 사항을 충족시키기 어렵다.

 

64비트 시스템은 한 번에 64비트(8바이트)의 데이터를 처리한다. 이로 인해 메모리 주소 공간이 대폭 확장되어, 이론적으로는 16엑사바이트(약 1.8 x 10^19 바이트)까지의 메모리를 지원한다. 실제로는 시스템에 따라 이보다 적은 양의 메모리를 사용할 수 있지만, 현대의 대부분의 애플리케이션과 운영 체제는 64비트를 기반으로 한다.

 

64비트 시스템은 더 많은 메모리를 효율적으로 사용할 수 있으며, 더 큰 데이터 단위를 처리할 수 있다. 이는 특히 대용량 데이터를 다루는 애플리케이션에서 성능의 향상을 의미한다.

 

x86과 x86-64(또는 AMD64, Intel 64로도 알려짐) 아키텍처는 컴퓨터의 CPU 설계를 의미한다. 이들은 프로세서가 데이터를 처리하고 메모리에 접근하는 방식을 정의한다.

 

x86 아키텍처는 32비트 기반이고 978년 인텔이 개발한 Intel 8086 마이크로프로세서에서 유래했다. 최대 4GB 메모리 접근 가능하고 오래된 시스템, 임베디드 시스템, 저성능 요구 사항을 가진 애플리케이션에 적합하다.

 

x86-64 아키텍처는 64비트 확장 버전이고, 2000년대 초 AMD에 의해 처음 소개됨(AMD64), 인텔은 나중에 유사한 기술(EtM64T, 후에 Intel 64로 재명명)을 도입했다. 이론적으로 최대 16엑사바이트(2의 64승 바이트)까지 메모리 접근 가능, 실제로는 시스템에 따라 제한된다. 기존 x86 아키텍처 기반의 소프트웨어를 지원하면서, 더 큰 메모리와 향상된 성능 제공한다. 현대의 대부분 컴퓨터, 고성능 서버, 게임, 고급 계산 작업에 사용된다.

 

RAX는 x86-64아키텍처의 범용 레지스터이다. x86-64는 32비트인 x86 아키텍처의 확장버전이다. RAX의 R은 64를 의미하고 AX는 어큐뮬레이터 레지스터를 뜻한다. 기존의 x86 아키텍처의 범용 레지스터 EAX, EBX, ECX 등 하위 32비트를 사용함과 동시에 확장시킨게 RAX, RBX, RCX 이다.

RAX를 제외한 RBX(Base), RCX(Count), RDX(Data), RSI(Source Index), RDI(Destination Index)같은 이런 범용 레지스터들의 역할은 함수 호출시 인자전달을 하거나 로컬 변수를 저장하거나 스택 포인터를 관리하는 등의 역할을 한다.

AX 레지스터는 주로 산술 연산, 데이터 이동, 입/출력 명령 등 다양한 목적으로 사용된다. 산술 연산 및 논리 연산 명령어의 결과는 종종 RAX 레지스터에 저장됨과 동시에 연산의 결과값 또한 저장된다.

상위 8비트 AH와 하위 8비트 AL이 합쳐서16비트 AX가 되고, 확장(Extension)이 32비트의 EAX 이다. 이 EAX의 확장이 64비트의 RAX(Register Accumulator Extended)이다.

시스템 호출을 수행할 때, RAX는 호출되는 시스템 호출의 번호를 저장하는 데 사용된다. 시스템 호출이 완료된 후, RAX는 반환값을 담는 데 사용되며, 프로그램이 예외 상황을 만났을 때, RAX는 예외 처리 루틴에서 중요한 정보를 담는데 사용될 수 있다. 또 돌아가야할 리턴 값을 저장할 때 사용하기도 한다. 컴파일러는 RAX를 자주 사용되는 값의 저장 및 연산을 사용하여 성능을 최적화한다.

 

CPU(Central Processing Unit)컴퓨터의 '두뇌'로, 기본적인 산술, 제어, 입력/출력(I/O) 작업과 같은 명령어들을 처리한다. CPU의 32비트와 64비트는 주로 메모리 주소 지정 능력과 데이터 처리 능력에서 차이를 보인다.

 

32비트 CPU메모리 주소 공간이 4GB로 제한된다. 이는 32비트 시스템이 한 번에 처리할 수 있는 주소 범위가 2^32, 즉 4GB까지라는 의미이다. 오래된 시스템이나 가벼운 애플리케이션에 적합하다.

 

64비트 CPU는 씬 더 큰 메모리 주소 공간(2^64)을 지원한다. 이론적으로는 16EB(엑사바이트)까지 지원하지만 실제로는 시스템에 따라 제한될 수 있다. 더 큰 데이터 단위를 한 번에 처리할 수 있어, 복잡한 계산과 대용량 데이터 처리에 더 효율적이다. 최신 운영 체제와 애플리케이션은 대부분 64비트 아키텍처를 사용한다.

 

GPU(Graphics Processing Unit)는 복잡한 그래픽 처리와 대량의 병렬 처리 작업에 특화된 프로세서이다. GPU의 32비트와 64비트는 주로 연산 처리 능력에서 차이를 볼 수 있지만, GPU에서는 이보다는 병렬 처리 능력, 코어의 수, 메모리 대역폭 등이 더 중요한 성능 지표가 된다.

 

CPU 사용이 적합한 경우:

  • 일반적인 컴퓨팅 작업: 웹 브라우징, 문서 작업, 기본적인 소프트웨어 사용 등.
  • 직렬 처리 작업: 한 번에 한 작업씩 처리해야 하는 복잡한 로직이나 계산이 필요한 경우. 예를 들어, 알고리즘 처리, 서버 측 애플리케이션 등.
  • 다양한 종류의 명령어 처리가 필요한 경우: CPU는 다양한 종류의 작업을 처리할 수 있는 광범위한 명령어 세트를 가지고 있다.
  • 멀티태스킹: 여러 프로그램을 동시에 실행하는 환경에서는 CPU가 각각의 프로세스를 관리하고 조정하는 데 중요한 역할을 한다.

GPU 사용이 적합한 경우:

  • 그래픽 처리: 비디오 게임, 3D 모델링, VR(가상 현실) 등 고도의 그래픽 처리가 필요한 작업.
  • 병렬 처리 작업: 동일한 연산을 대량의 데이터에 적용해야 하는 경우. 예를 들어, 이미지나 비디오 처리, 딥러닝, 복잡한 과학적 계산 등.
  • 데이터 분석 및 기계 학습: 대규모 데이터 세트에 대한 복잡한 계산을 빠르게 수행할 수 있어, 빅 데이터 분석, 인공 지능 개발 등에 유용하다.
  • 고성능 컴퓨팅(HPC) 작업: 과학적 시뮬레이션, 기상 모델링, 유전자 연구 등과 같이 막대한 양의 계산이 필요한 경우.
728x90
728x90

리다이렉션(Redirection)명령어의 출력을 터미널이 아닌 다른 곳으로 보내는 기능이다. 일반적으로는 파일로의 출력이나, 파일로부터의 입력을 처리하는 데 사용된다.

 

사용의 예로, 소켓을 통해 수신된 데이터를 파일로 저장하거나, 파일의 데이터를 소켓을 통해 전송할 때 리다이렉션을 사용할 수 있다.

 

> : 명령어의 출력을 파일로 전송한다. 이미 존재하는 파일인 경우 내용을 덮어쓴다.

예: ls > filelist.txt는 현재 디렉토리의 파일 목록을 filelist.txt 파일에 저장한다.

 

>> : 명령어의 출력을 파일에 추가한다. 파일이 존재하지 않는 경우 새로 생성한다.

예: echo "새로운 내용" >> filelist.txt는 "새로운 내용"을 filelist.txt 파일의 끝에 추가한다.

 

< : 파일의 내용을 명령어의 입력으로 사용한다.

예: sort < unsorted.txt는 unsorted.txt 파일의 내용을 정렬하여 출력한다.

 

파이프(Pipe)는 한 명령어의 출력을 다른 명령어의 입력으로 직접 연결한다. 이를 통해 여러 명령어를 연결하여 복잡한 처리를 한 줄의 명령어로 수행할 수 있다.

 

네트워크를 통해 전송된 데이터를 파이프를 사용해 여러 단계의 처리 과정을 거친 후 최종적으로 원하는 결과를 얻을 수 있다. 예를 들어, 네트워크로부터 수신된 데이터를 파싱하고, 필터링하고, 변환하는 등의 작업을 파이프라인을 구성하여 처리할 수 있다.

 

| : 앞의 명령어 출력을 뒤의 명령어의 입력으로 전달한다.

예: ls | sort는 현재 디렉토리의 파일 목록을 정렬하여 출력한다.

+예: cat filelist.txt | grep "특정 문자열"은 filelist.txt 파일에서 "특정 문자열"을 포함하는 라인만을 검색하여 출력한다.

 

소켓(Socket)네트워크를 통한 데이터 송수신을 위한 인터페이스이다. 소켓을 사용하면, 프로그램은 네트워크 상의 다른 시스템과 데이터를 주고받을 수 있다. 리다이렉션과 파이프는 로컬 시스템 내에서 데이터의 흐름을 제어하는 데 사용되는 반면, 소켓은 네트워크를 통해 떨어진 위치에 있는 시스템 간의 데이터 흐름을 제어한다.

 

서버와 클라이언트 간의 소켓 통신은 네트워크 통신의 기본적인 모델 중 하나로, 데이터를 교환하기 위한 연결을 설정하고 유지하는 방식이다. 소켓은 네트워크 상에서 데이터를 송수신하기 위한 엔드포인트로, IP 주소와 포트 번호로 정의된다.

 

서버:

  1. 소켓 생성: 서버는 socket() 함수를 호출하여 소켓을 생성한다.
  2. 소켓 바인딩: 생성된 소켓에 IP 주소와 포트 번호를 할당하기 위해 bind() 함수를 사용한다. 이는 서버의 주소로 사용된다.
  3. 리스닝: listen() 함수를 호출하여 서버 소켓이 클라이언트의 연결 요청을 대기하도록 설정한다.
  4. 연결 수락: 클라이언트로부터의 연결 요청이 들어오면, accept() 함수를 호출하여 연결을 수락한다. 이 함수는 새로운 소켓을 반환하며, 이 소켓을 통해 클라이언트와 통신한다.

클라이언트:

  1. 소켓 생성: 클라이언트도 socket() 함수를 호출하여 소켓을 생성한다.
  2. 서버에 연결: connect() 함수를 사용하여 서버의 IP 주소와 포트 번호로 설정된 서버 소켓에 연결을 시도한다.

데이터 송수신:

  • 데이터 전송: 연결이 설정되면, 서버와 클라이언트는 send() 또는 recv() 함수(또는 write(), read() 함수)를 사용하여 데이터를 송수신한다.
  • 통신 종료: 데이터 송수신이 완료되면, close() 함수를 호출하여 소켓 연결을 종료한다.

 

728x90

'서버' 카테고리의 다른 글

Express.js Clean Architecture 구성하기  (0) 2024.04.12
Proxy 서버 구현  (1) 2024.03.24
echo server, tiny server 구현  (2) 2024.03.24
Parsing, Caching, Filtering, Load Balancing, MTU, NAT  (1) 2024.03.23
728x90

파싱(Parsing)은 주어진 데이터(예: 웹 페이지, 파일, 메시지 등)를 분석하여 그 구조를 이해하고, 필요한 정보를 추출하거나, 다른 형태로 변환하는 과정을 말한다. 웹에서는 URL, HTML, JSON, XML 등의 데이터를 분석하여 웹 페이지를 렌더링하거나, API 응답을 처리하기 위해 파싱을 사용한다. 예를 들어, 프록시 서버는 HTTP 요청의 URI를 파싱하여 목적지 서버, 경로, 파라미터 등을 분석한다.

 

캐싱(Caching)은 데이터나 결과물을 임시 저장하는 기술로, 빠른 데이터 접근을 가능하게 하여 성능을 향상시킨다. 웹 캐싱은 자주 요청되는 웹 페이지, 이미지, 기타 파일을 저장해두고, 같은 요청이 들어올 때마다 빠르게 제공하여 서버의 부하를 줄이고 사용자 경험을 개선한다. 프록시 서버나 브라우저 자체에 캐싱 기능이 있을 수 있다.

 

필터링(Filtering)은 특정 조건에 따라 데이터나 트래픽을 걸러내는 과정을 말한다. 웹 필터링은 부적절한 웹사이트 접근을 차단하거나, 특정 유형의 인터넷 트래픽(예: 광고)을 제한하기 위해 사용된다. 기업이나 학교에서는 보안, 생산성 향상, 네트워크 사용 규제 목적으로 필터링을 사용한다.

 

로드 밸런싱(Load Balancing)은 네트워크 트래픽이나 요청을 여러 서버나 리소스에 균등하게 분배하여, 어느 한 곳에 과부하가 걸리지 않도록 하는 기술이다. 이를 통해 시스템의 가용성과 내구성을 높이고, 사용자에게 일관된 서비스 품질을 제공할 수 있다. 로드 밸런서는 여러 서버 사이에서 클라이언트의 요청을 적절히 분배하는 역할을 한다.

 

MTU(Maximum Transmission Unit)네트워크를 통해 전송될 수 있는 최대 데이터 패킷 크기를 말한다. MTU는 네트워크 인터페이스나 프로토콜마다 다를 수 있으며, 이는 특정 네트워크 링크를 통해 한 번에 전송할 수 있는 최대 바이트 수를 지정한다. MTU의 크기는 네트워크의 효율성과 성능에 영향을 미칠 수 있다.

 

 

너무 작은 MTU는 네트워크 오버헤드를 증가시키고, 너무 큰 MTU는 패킷 손실률을 높일 수 있다. 적절한 MTU 설정은 네트워크 성능을 최적화하는 데 중요하다.

다른 네트워크 세그먼트 간에 MTU 크기가 다를 경우, 데이터가 올바르게 전송되기 위해서는 MTU 크기를 조정하거나 패킷을 분할하여야 한다. 이 과정을 MTU 발견이라고 한다.

TCP/IP 네트워크는 MTU 발견 메커니즘을 사용하여, 데이터가 전송되는 경로의 최소 MTU 값을 자동으로 찾을 수 있다. 이를 통해 패킷 분할이 필요한 경우 최적의 크기로 조정할 수 있다.

일반적으로 TCP헤더와 IP헤더는 각각 20바이트를 차지한다. 스트림 데이터를 1460씩 잘라서, 각각 TCP와 IP헤더를 붙이면 패킷이 1500byte가 된다.

작은 데이터를 보낼 때마다 패킷을 보내면 패킷이 너무 낭비다. 그래서 버퍼에 데이터를 최대한 채워서 보내야 하는데, 이 때 쓰는 알고리즘이 nagle(네이글) 알고리즘이라 한다.

Network Address Translation (NAT)

인터넷 프로토콜(IP) 주소를 다른 주소로 변환하는 과정이다. 이 기술은 주로 IP 주소의 부족 문제를 해결하고, 네트워크 보안을 강화하기 위해 사용된다. NAT는 일반적으로 라우터나 방화벽과 같은 네트워크 장비에서 구현된다.

 

NAT을 사용하면 하나의 공인 IP 주소를 여러 개의 사설 IP 주소와 매핑하여 사용할 수 있다. 이를 통해 인터넷에 연결된 장치들이 공인 IP 주소를 공유하여 사용할 수 있게 된다.

NAT를 통해 내부 네트워크의 구조와 IP 주소를 외부에 숨길 수 있다. 이는 내부 네트워크에 대한 무단 접근을 어렵게 만들어 네트워크 보안을 강화한다.

NAT를 사용하면 내부 네트워크의 IP 주소 체계를 인터넷의 IP 주소 체계와 독립적으로 운영할 수 있다. 이는 IP 주소의 재할당 및 네트워크 구성 변경을 용이하게 한다.

 

++ARP와 RARP

ARP : 네트워크 상의 장치의 IP 주소를 해당 장치의 물리적 MAC 주소로 변환하는 것이다. 이는 IP 네트워크 계층에서 전송 계층으로의 데이터 전송을 가능하게 한다.

RARP : 주요 목적은 네트워크 상의 장치의 물리적 MAC 주소를 IP 주소로 변환하는 것이다. 이는 주로 부팅 과정에서 디스크리스 스테이션(디스크 드라이브가 없는 컴퓨터)이나 네트워크 상의 장치가 자신의 IP 주소를 알아내는 데 사용된다.

728x90

'서버' 카테고리의 다른 글

Express.js Clean Architecture 구성하기  (0) 2024.04.12
Proxy 서버 구현  (1) 2024.03.24
echo server, tiny server 구현  (2) 2024.03.24
Socket, Redirection, Pipe  (1) 2024.03.23

+ Recent posts