난 운영체제가 제일 재밌고 좋다. 최고👍

세마포어와 뮤텍스를 본격적으로 얘기하기에 앞서서 두 방법이 나오게 된 문제상황인 Race ConditionCritical-Section을 먼저 소개한다.

Race condition

두 개 이상의 프로세스가 공유 데이터에 대해서 동시에 접근하고 조작하려하는 상황을 의미한다. 즉 Race라는 뜻 그대로 하나의 자원을 놓고 서로 경쟁하려는 상태이다.

이 공유된 데이터의 마지막 값은 어느 프로세스가 마지막으로 끝내는지에 따라 달려있다. 만약 interleaved execution(서로 번갈가면서 명령어 수행시) 데이터의 무결성이 침해받는다. → 불확실성

이것을 예방하기 위해서, 동시에 진행되는 프로세스는 반드시 동기화가 되어야 한다.

Critical-Section

이러한 문제를 임계구역(Critical-Section) 문제라고 한다. 임계구역이란 동일한 자원에 대해서 접근하는 영역을 의미한다.
해결하기 위해서는, 한 프로세스가 임계구역에 진입해 작업을 진행할때, 다른 프로세스들은 임계구역에 진입하는 것을 허락하지 않는것이다.

동기화를 해결하는 방법은 세가지 필수적인 조건을 맞춰야 한다.

  1. 상호배제(Mutual Exclusion)
    • 어떠한 프로세스 혹은 스레드가 임계구역에서 작업을 진행하면, 다른 프로세스 혹은 스레드는 임계구역에 진입할 수 없다.
  2. 진행(Progess)
    • 무기한으로 연기되는것을 막기 위해서 필요하다.
    • 만약, 다른 프로세스가 임계구역에서 작업을 하고 있지 않다면, 다른 진입을 원하는 프로세스의 진입은 지체되어서는 안된다.
  3. 한계가 있는 대기 (Bounded Waiting)
    • 과도한 방지를 막기 위해서 필요하다.
    • 프로세스가 임계구역에 들어가기 위해서 요청하고 대기하는 시간은 한정되어 있어야 한다. 즉, 무기한적으로 대기되는것을 막기 위해서 필요하다.

문제에 대한 개괄적인 해결방식

두 개의 프로세스만 있다고 가정했을때, 임계구역에 진입가능했는지 체크하고, 임계구역을 빠져나올때는 진입가능성을 열어준다.
이 때 프로세스들은 동기화를 위한 데이터를 공유해야 한다.

뮤텍스

CS를 가진 스레드 혹은 프로세스들의 실행시간(Running Time)이 서로 겹치지 않고 각각 단독으로 실행될 수 있게끔 하는 기술이다.
일반적으로 한 프로세스에 의해 소유될 수 있는 Key 객체를 기반으로 작동되며, 이 객체를 소유한 스레드 혹은 프로세스만이 공유자원에 접근할 수 있다.

뮤텍스를 구현하는 방식에는 세가지 정도가 있는데, 모두 각각의 장단점이 존재한다.

Swap turn

  • 첫번째 해결방식, Swap turn
    • 공유 변수 turn 을 두고, 서로 입장 가능한지 while로 체크한다.
    • 상호배제는 만족하지만, 진행도는 만족하지 못한다.
    • 두 프로세스는 무조건 in-turn 방식(왔다갔다)으로만 진입할 수 있다.
      • 만약 P0→P1 순서대로 들어갔다고 해보자. 이때 P1이 추가적인 진입을 원하더라도 P0의 차례이기 때문에 P1은 진입 할 수 없고, 진행도를 만족하지 못한다.
    • 즉, P1이 CS에 더 자주 접근해야하는 시스템이라고 해보자. 그럴때는 P0에 의존적이다. 두번 연속으로 CS 진입이 불가능하기 때문이다.Dekker's Algorithm
  • // P0 **do { while (turn != 0) ; /* My turn? */ critical section turn = 1; /* Now it’s your turn */ remainder section } while (1);** // P1 **do { while (turn != 1) ; /* My turn? */ critical section turn = 0; /* Now it’s your turn */ remainder section } while (1);**
  • 두번째 해결방식,데커 알고리즘
    • 만약 flag[i]==true 이면, Pi는 CS에 진입할 수 있다.
    • 첫번째 Swap turn 방식의 문제점인, 연속적인 진입이 불가능한 점을 해결한다.
    • 상호배제는 만족하지만, 진행도는 만족하지 못한다.
    • 만약 flag[i]=true 일때 컨텍스트 스위칭이 일어나면, Pj가 생각하기에 Pi가 들어가있다고 생각할것이다. 실제로는 그렇지 않은데.
      • 그러면 둘다 CS에 진입하지 못하고 기다려야 한다. 다음 컨텍스트 스위칭이 일어날때(선점식일때)까지 기다려야 한다.
      • flag[i]=true 로 바꾸는 저 부분이 유저 코드이기 때문에 중간에 인터럽트의 방해를 받을 수 있는 non-atomic이기 때문이다.Peterson's algorithm
  • boolean flag[2] 초기에는 flag[i]=flag[j]=flase // 아무도 CS안에 있지 않다. // Pi do { flag[i] = true; /* Pretend I am in */ while (flag[j]) ; /* Is he also in? then wait*/ critical section flag [i] = false; /* I am out now*/ remainder section } while (1);
  • 세번째 해결방식 (피터슨 알고리즘)
    • 1번과 2번의 공유 변수를 조합한 방식이다.
    • flag[j] 변수를 활용해서, swap-turn은 해결했다. 또 turn 문제를 이용해서 무조건 둘 중 하나는 CS에 들어갈 수 있음을 보장한다.
    • 상호배제,진행도,한정된 대기시간이라는 두 프로세스간의 해결책에서의 조건을 모두 만족한다.
    • 여러 프로세스로 확장가능하다. 하지만, while loop에서 CPU는 계속 체크해줘야 하는 busy waiting 문제가 여전히 남아있다. CPU cycle을 낭비하고 있는것이다.
  • do { flag [i]= true; /* My intention is to enter …. */ turn = j; /* Set to his turn */ while (flag [j] and turn == j) ; /* wait only if …*/ critical section flag [i] = false; remainder section } while (1);

HW 지원

이렇게 해결책에도 여전히 문제점이 남아있기 때문에, 많은 시스템들은 하드웨어의 지원을 받는다.

  • I/O가 없을때 CS 진입 후 선점적인 스케쥴링을 방지.
  • 유니프로세서 시스템에선 인터럽트를 꺼버린다. 왜냐면 timeout이 있기 때문에. 하지만 대부분의 멀티프로세서 관점에서는 불리하다.
  • 현대의 대부분의 시스템들은 원자적인(atomic) 하드웨어 명령어를 지원받는다.
    • TAS(test and set)
    • CAS(compare and swap)
      • 원자적으로 두 변수를 바꾼다.
      • key=true일때 lock이 false면, 진입해버린다. key=true인데 lock이 true면 계속 기다린다.
      • 이때 key는 lock과 달리 지역변수라 컨텍스트스위칭에 안전하다.

세마포어

뮤텍스와 비슷하게 동기화 기법이지만, 뮤텍스는 동시접근을 막으려는 측면이 강하고,
세마포어는 접근 순서 동기화에 조금 더 관련이 있다.

근본적으로 wait()signal() 을 이용해서 제어를 하는 방식으로 세마포어를 여러개를 둠으로써 CS에 접근 가능한 task의 개수를 조절한다.
개수가 1개이면 binary semaphore로 mutex 처럼 작동을 한다.

P(s) // atmoic 해야함
func {
    while S<=0 do nothing;
    S--;
}
V(s) // atomic 해야함
func {
    S++;
}

이렇게 구현하면 세마포어를 이용한 뮤텍스가 된다.물론 busy waiting 문제가 여전히 발생을 한다.
즉, 특정 프로세스가 CS를 많이 차지하면 다른 프로세스는 P에서 계속 기다려야 한다.

이런 방식은 여전히 싱글프로세서 시스템에서 자원의 낭비라는 관점에서 치명적이다.

Block/Wakeup 방식

Block/Wakeup 방식은 busy waiting의 방식처럼 계속 기다리는 것이 아니라,
기다려야 할때만 대기줄에 줄만 세워놓고 block을 당하게 한다. 그 후 자리가 생기면, 즉 순서가 돌아오면 block 당한것들부터 차례로 깨운다.

typedef struct {
int value; /* semaphore */
struct process *L; /*process wait queue*/
} semaphore;

void P(S){
    S.value--;
    if (S.value<0){
        add this into S.L
        block()
    }
}

void V(S){
    S.value++;
    if S.value<=0 {
        remove P from S.L
        wakeup(P)
    }
}

이런식으로 세마포어에 링크드 리스트를 구현해서 세마포어를 완성한다.
process wait queue는 링크드 리스트로 프로세스의 PCB에 대한 정보를 가지고 있다.

  • block() : 커널이 현재 프로세스를 중지시키고, 그 프로세스의 PCB를 세마포어 안에 있는 대기 큐에 넣는다.
  • wakeup(p) : block되었던 프로세스 P의 작업을 다시 하게 한다.

이 때 S.value 의 절댓값은 현재 block 되어 있는 프로세스의 개수와 동일하다.

이전의 busy waiting과 block wakeup 방식을 비교해보자.

  • CS 파트가 작으면
    • 기다리는데 드는 시간보다, block-wakeup하는데 드는 오버헤드가 더 클 수 있음.
  • CS 파트가 길면
    • busy waiting이 길어지기 때문에 block-wakeup이 더 좋을수 있다.

임계영역이 유저 공유 데이터에서만 생길까?

  1. 인터럽트 핸들러와 커널중간에 인터럽트가 와서 인터럽트 핸들러 내에서 count 값을 올리면, 그 뒤의 count 결과는 보장할 수 없다.
  2. count 라는 커널 변수가 있다고 해보자. count 의 값을 올리는 과정은 load하고,increase하고,store를 하는 것인데,
  3. 커널이 시스템 콜을 수행중일때, 컨텍스트 스위칭이 일어나면아래와 같은 상황일때 count의 값을 보장 할 수 있는가?이 문제에서 중요한 점은 프로세스간의 데이터 공유가 아니더라도 임계영역문제가 커널 단에서 분명히 발생할 수 있다는 점이다.
  4. 유닉스에서는 이를 방지하기 위해서 커널이 작업을 수행하고 있다면(시스템 콜 처리) 선점식 스케쥴링이 일어나지 않는다.
  5. 두 프로세스간은 공유되는 데이터가 없다하더라도, 시스템 콜을 수행할때 커널 데이터는 공유될 수 있다.
  6. 멀티프로세서 시스템
    • 만약 두개의 CPU에서 한쪽은 count를 내리고, 한쪽은 카운트를 내린다고 해보자. 어느 값을 저장해야 하는가?
      • 각각의 CPU 이기 때문에 인터럽트로 통제할 수 없다.
    • 이럴때는 커널 자체가 하나의 큰 CS가 된다
      • 커널 자체 접근을 한 CPU만 할 수 있게 하거나
      • 커널내의 공유 변수들을 따로 세마포어로 둔다. 그러면, 큰 CS가 여러개의 나뉘어진 CS가 된다.

이진 세마포어 vs 카운팅 세마포어 비교

  • 이진 세마포어
    • 정수값이 0과 1를 오가는 세마포어다. lock과 비슷하게 0은 사용중이고 1은 자유로운 상태이다.
    • 같은 시간 안에 오직 한개의 프로세스나 스레드가 CS에 접근하는 것을 허용한다.
    • CS안에 두개의 프로세스나 스레드가 동시접근이 불가능하기 때문에 상호배제는 만족한다.
    • 단지 정수적인 lock에 불과하니, 제한된 대기를 보장할수는 없다.즉, 기아가 발생할 수 있다.
  • 카운팅 세마포어
    • 카운터라는 여러개의 값을 가질 수 있는 세마포어다.
    • 값의 범위는 0에서부터 N까지며, N은 CS로 입장을 허용하는 프로세스 혹은 스레드의 개수이다.
    • 한번에 둘 이상이 CS에 접근가능하기 때문에 상호배제를 보장하지는 않는다.
    • 기아현상을 피하기 위해서 큐를 구성하고 있는데, 이 큐가 있기 때문에 언젠가는 접근할 수 있다. 즉 제한된 대기 상태를 보장한다.
    • 특정하고 1보다 큰 개수의 한정된 자원을 배분할때 사용 될 수 있다.

세마포어와 뮤텍스 차이점은?

  • 세마포어는 뮤텍스가 될 수 있지만 뮤텍스는 세마포어가 될 수 없다.
  • 예를 들어 Casting을 한다고 보면 (뮤텍스)세마포어 –> 가능하지만 (세마포어)뮤텍스 –> 불가능하다.
  • 세마포어는 소유할 수 없는 반면 뮤텍스는 소유할 수 있고 소유자가 이에 책임을 진다.
  • 뮤텍스는 1개만 동기화가 되지만 세마포어는 하나 이상을 동기화 할 수 있다. → 즉 임계영역에 접근가능한 수가 원천적으로 1개이냐 , 그 이상이냐의 차이다.
변기가 하나뿐인 화장실에서는
앞의 사람이 볼일을 마치고 나가야 다음 사람이 들어갈 수 있다.
이렇게 한번에 오직 하나만 처리할 수 있는 대상에 사용하는 것이 뮤텍스이다.

변기가 세개인 화장실에서는 동시에 세 사람이 볼일을 볼 수 있고
이 세 사람 중 아무나 한명이 나오면 다음 사람이 볼일을 볼 수 있다.
이렇게 동시에 제한된 수의 여러 처리가 가능하면 세마포어이다.

만약 변기 세개짜리 화장실의 각 변기에 대해 뮤텍스를 사용한다면
대기중인 사람은 각 변기 앞에 줄을 서는 것이고
이렇게 되면 옆 칸이 비어도 들어가지 못하게 된다.

만약 변기 세개를 묶어서 뮤텍스를 사용한다면
변기 수에 관계없이 무조건 한명만 사용할 수 있게 된다.

이 예에서 변기는 동기화 대상, 사람은 그 동기화 대상에 접근하는 쓰레드를 나타낸다.

뮤텍스와 세마포어의 목적은 특정 동기화 대상이 이미 특정 쓰레드에 의해 사용중일 경우
다른 쓰레드가 해당 동기화 대상에 접근하는 것을 제한하는 것으로 동일하지만
관리하는 동기화 대상이 몇개 인가에 따라 차이가 생기게 되는 것이다.

뮤텍스 vs 이진 세마포어

  • 뮤텍스
    • 근본적으로 공유하는 자원에 대한 보호의 목적으로 쓰인다.
    • 뮤텍스는 lock을 잠근 task가 보유하는 관점이다.
    • 그렇기에 오직 그 task만이 해제할 수 있다. lock을 보유하지 않은 task가 접근할시 에러가 발생한다.
    • 일반적으로 이진 세마포어보다 더 가볍고 단순하기에 CS에 대한 보호의 용도로 더 많이 사용된다.
  • 이진 세마포어
    • 신호를 주는(Signalling) 메커니즘에 더 가깝다. "나는 끝났으니, 이제 너의 작업을 해도 된다"
    • wait를 호출한 프로세스나 스레드가 아니여도 해제할 수 있다.
    • 물론 이것을 이용해서도 공유 자원을 보호하는데 사용할수는 있지만, task의 순서 동기화의 측면이 더 강하다.
    • 일반적으로 뮤텍스에 비해 오버헤드가 크다.

스핀락과 busy waiting의 사용처

do{
    acquire()
    CS
    release()
} while(true)

func acquire(){
    while(!availbale){
        //busy waiting}
    }
    available=false;
}

위의 방식은 available 이라는 변수를 둠으로써 락이 걸려있지 않다면 가져온뒤 CS로 진입하고 그렇지 않으면 계속 시도하는 방식을 쓰고 있다.

이럴때 acquire과 release는 atomic해야한다. 이런 방식의 가장 큰 문제점은 busy waiting을 해야한다는 점이다. 한 프로세스가 CS에 접근하고 다른 프로세스가 acquire 함수를 호출하면, 계속 acquire 안에서 계속적으로 루프를 돌아야 한다. 이런 방식의 구현을 스핀락(spinlock)이라고 하는데 다른 프로레스가 락이 풀리기를 기다리면서 계속 "돌고있기" 때문이다.

이러한 연속적인 루프는 싱글프로세서 시스템에서 명백한 문제인데, busy waiting 방식에서 다른 CPU가 더 잘 사용할 수 있는 자원을 대기하는데만 사용하고 있기 때문이다. 내가 기다리고 있다는 건 다시 말하면 나혼자만 CPU를 할당받고, 다른 프로세스(락을 풀어줄수 있는)는 돌아가고 있지 않기 때문에 기다릴 이유가 없는것이다.

그러면 스핀락을 사용하는 busy-waiting 방식은 전혀 쓸모가 없는것일까?
그렇지 않다.
이에 관해서 Operating System Concepts(공룡책)의 설명을 보자.

Spinlocks do have an advantage, however, in that no context switch is required when a process must wait on a lock, and a
context switch may take considerable time. In certain circumstances on multicore systems, spinlocks are in fact the preferable choice for locking. If a lock is
to be held for a short duration, one thread can “spin” on one processing core while another thread performs its critical section on another core. On modern multicore computing systems, spinlocks are widely used in many operating systems.

야매로 번역을 해보자면,

하지만, 스핀락도 장점이 있다. 락이 빨리 풀릴거라고 예상되는 시스템에서 스핀락은 아주 유용하다.
컨텍스트 스위칭을 하는 비용보다 스핀락으로 busy waiting을 하는게 더 이득일 수 있기 때문이다.
이러한 장점때문에 멀티프로세서 시스템에서 한 쓰레드는 락에 대해서 계속 스핀하고, 다른 쓰레드는 다른 프로세서에서 임계영역에 대한 접근을 할 수 있다.

이게 무슨말이냐면, 원래 락이 빨리 풀리는 시스템에서는, 다른 프로세서에 있는 스레드가 락을 해제한다면, 내 프로세서에서는 컨텍스트 스위칭을 할 필요없이 바로 스핀락 하다가 들어가버리면 되기 때문이다.

만약, 내가 지금 spin 한다고 해서 바로 컨텍스트 스위칭을 해버린다면, 다른 프로세서가 락을 풀어도 내 프로세서는 다시 프로세스로 스케쥴링될때까지 기다리고, 컨텍스트 스위칭을 한 후 접근해야하기 때문이다.
즉, 원래 락이 빨리 풀리는 시스템에선 컨텍스트 스위칭의 비용이 spin lock의 대기시간의 비용보다 더 비쌀수 있기 때문이다.

TCP

신뢰성 있는 데이터 전송을 지원하는 연결 지향형 전송계층 프로토콜로 3 way handshake로 연결을 수립하고 , 4 way handshake로 연결을 해제한다.

  • 혼잡 제어, 흐름 제어, 오류 제어등을 통해 신뢰성을 보장하지만 이것때문에 UDP와 비교해서 속도가 느리다.
  • SEQ 넘버ACK 넘버를 통해서 데이터 흐름 속에서 데이터들의 순서를 파악할 수 있기 때문에 신뢰성이 높아진다.
  • 인터넷은 신뢰성을 갖추지 못하기 때문에 종단간의 신뢰성 있는 Byte Stream을 전송하도록 설계되었다.
  • 연결을 유지하며 1:1 통신을 한다. 그렇기에 브로드캐스팅과 같은 시스템에선 적합하지 않다.

TCP 연결 과정

TCP는 통신을 시작하기전에 연결 수립이 필수적이다. 이것을 통해서 상대방과의 세션을 수립하여 통신의 정확성을 획득할 수 있다.

클라이언트는 서버에 접속을 요청하고 서버는 요청을 수락하는 ack와 flag가 설정된 패킷을 클라이언트에게 발송한다.

  1. 클라이언트가 서버에 접속을 요청하는 SYN(a)이 설정된 패킷을 보낸다. 이때 클라이언트는 SYN_SENT
  2. 서버가 클라이언트의 요청인 SYN(a)를 받고 클라이언트에게 요청을 수락한다는 ACK(a+1)과 SYN(b)이 설정된 패킷을 보낸다. 서버는 SYN_RCVD 상태
  3. 클라이언트는 서버의 수락 요청에 대한 응답인 ACK(a+1)과 SYN(b) 패킷을 받고, ACK(b+1)을 서버로 보내면 연결이 성립된다. 클라이언트는 ESTABLISHED 상태가 되고, 서버는 ACK을 받으면 ESTABLISHED 상태가 된다.

이 과정에서 SYN이나 ACK가 dropped 된다면, 재전송(retransmission)이 일어난다. 만약 3번 과정에 ACK가 drop된다면 그 다음에 데이터를 보낼때 ACK도 같이 담아서 보내면서(piggy-backing) 연결을 수립한다.

2 way handshake으로 부족한가?

부족하다. TCP는 양방향성 connection이기 때문에, 양쪽의 통신 수립이 필수적이다. 클라이언트와 서버가 있다고 했을때, 클라이언트가 서버에게 SYN을 보내고, 서버가 ACK와 SYN만 보낸다고 하면, 서버 입장에선 제대로 연결되었는지 확인이 불가능하다. 서버의 ACK와 SYN에 대한 클라이언트의 응답인 ACK가 필요하니 3 way handshaking을 해야한다.

💡 초기에 클라이언트에서 보내는 SYN 넘버는 ISN이라는 난수를 생성한다. 그 이유는 이전의 seq와 순차적인 정보를 전송하면, 서버가 판단하기에 이전 연결에서 보내는 정보라고 인식 할 수 있기 때문이다.

💡 SYN flooding attack 이라는 공격이 있는데, 간단히 서버에 대해서 SYN 패킷만 무수히 보내는 것을 의미한다. 이것이 왜 유용하냐면, SYN 패킷을 보내면, 서버는 연결을 수립하는 과정이라고 생각해서 SYN/ACK 패킷을 보내고 ACK를 기다리는 half-open 상태에 돌입한다. 이 상태에서 서버는 클라이언트에 대한 정보를 가지고 있어야 하기때문에, 접속자(혹은 접속을 시도하려는) 사람의 정보를 저장하는 Backlog Queue가 꽉 차게 되고(정상적으로 연결이 수립되면 삭제하지만 그렇지 않으니) Backlog Queue가 꽉 차게 되어서 다른 연결 요청 정보 저장이 불가능해서 정상 Client들이 연결을 수립할 수 없게 된다.

해결 방법으로는 Backlog Queue를 늘리는 것이 있지만 임시적인 해결책이다. 또 한가지 방법 중 하나는 방화벽에서 동일 클라이언트 IP에 대한 SYN 임계치를 설정해두는 방법이다.

TCP 연결 해제 과정

4 way handshake를 통해 TCP는 연결을 해제한다.

  1. 클라이언트가 서버에게 연결을 종료하겠다는 FIN 패킷을 보낸다.
  2. 서버는 클라이언트의 요청에 대한 응답으로 ACK 패킷을 보낸다.
  3. 서버는 자신의 통신이 끝날때까지 기다린다 . CLOSE_WAIT 단계다. → Socket Hang UP 에러 발생가능
  4. 처리해야할 통신이 모두 끝났다면 FIN 패킷을 보낸다.
  5. 클라이언트가 FIN에 대한 응답으로 ACK 패킷을 보낸다.
  6. 클라이언트의 ACK 패킷을 받은 서버는 소켓을 닫는다.
  7. 클라이언트가 서버로부터 앚기 받지 않은 데이터가 있을 수 있으니 기다리는 과정을 거친다. TIME_WAIT 단계다.

💡 [에러 발생]
클라이언트에서 FIN 패킷 전송 후 ACK 패킷을 기다리는 FIN_WAIT1과 서버의 ACK 패킷을 받은 후 FIN 패킷을 기다리는 FIN_WAIT2 에러 발생으로 인해 Time out이 되면 스스로 연결을 종료한다.
그러나, CLOSE_WAIT은 Application이 close()를 적절하게 처리하지 못하면 CLOSE_WAIT 상태로 계속 기다리게 되어 Socket Hang Up 에러가 발생할 수 있다.

TCP 상태 다이어그램

  • CLOSED, LISTEN, SYN-SENT, SYN-RECEIVED, ESTABLISHED, FIN-WAIT-1, FIN-WAIT-2, TIME-WAIT, CLOSING, CLOSE-WAIT, LAST-ACK 등 상태가 존재한다.
  • CLOSED : 연결이 존재하지 않을 때
  • LISTEN : 서버에서 SYN이 오는 것을 기다리는 상태.
  • SYN-SENT: SYN을 보내고 ACK을 기다리는 상태(클라이언트)
  • SYN-RCVD: SYN을 받고 SYN+ACK을 보낸상태(서버)
  • ESTABLISHED: ACK을 받게 되면 서버 클라이언트 각각 이 상태로 들어섬
  • FIN-WAIT-1: FIN을 클라이언트 쪽에서 보냈을 떄 ACK을 기다리는 상태
  • FIN-WAIT-2: 첫번째 FIN에 대한 ACK을 받고 다음 FIN이 날라 올 때까지 기다리는 상태
  • CLOSE-WAIT: 첫번째 FIN을 받고 ACK을 보낸 뒤 보낼 데이터들을 보내고 ACK을 받는 상태
  • LAST-ACK: 전송이 완료되고 서버쪽에서는 FIN을 보내고 ACK을 기다리는 상태
  • TIME-WAIT: FIN이 오고 ACK을 보내준 뒤 일정 시간동안 기다림
  • CLOSING: 클라이언트 쪽은 TIME-WAIT이 종료 된 후, 서버쪽은 ACK이 도착한 후 종료된 상태를 의미한다.

TCP의 신뢰성을 위한 방법들

흐름 제어

송신측과 수신측의 데이터 처리 속도 차이를 해결하기 위한 기법이다.

수신측이 너무 지나치게 많은 패킷을 받지 않도록 조절하는것이 목적이고, 기본적인 개념은 수신측이 송신측에게 자신의 상태를 피드백한다는 것이다.

  • 수신측이 송신측보다 데이터 속도가 빠르면 문제 없지만, 느릴때 문제가 생김.
  • 수신측의 제한된 저장용량를 초과한 이후,즉 버퍼 오버 플로우 후에 도착하는 데이터는 손실될 가능성이 있으며, 손실되면 불필요한 응답과 데이터 전송이 빈번하게 오고감.
  • 이를 해결하기 위해서 송신 측의 데이터 전송량을 수신측에 따라서 조절해야한다.
  • 해결 방법
    • Stop and Wait : 매번 전송한 패킷에 대해 확인 응답을 받아야만 그 다음 패킷을 전송하는 방법. 그렇기에 비효율적이다.
      • Timeout을 설정하고, 그 Timeout 안에 ACK를 받지 못하면 실패한것으로 간주, 같은 프레임을 재전송한다.
      • 만약 이 Timeout이 너무 짧으면, ACK와 상관없이 계속 다시 보낼것이다.
    • Sliding Window : Stop and wait의 비효율성을 개선한 기법으로, 수신측에서 설정한 window 크기 만큼 송신측에서 확인 응답 없이 세그먼트를 전송할 수 있게하는 방식이다. (윈도우 크기 = 가장 최근 ACK로 응답한 프레임 수 - 이전에 ACK 프레임을 보낸 프레임 수)
      • 3 way handshaking을 할 때 송신측이 수신측의 window size에 맞게 자신의 window size를 조절한다. TCP 헤더안에 있으니..
      • 수신측에서 설정한 윈도우 크기만큼의 세그먼트를 확인 응답없이 보낼 수 있게 하여 동적으로 흐름을 제어하는 방식.
      • 송신측이 ACK 를 수신하지 않았더라도 여러개의 패킷들을 보낼 수 있다. (window size만큼 한번에 보내는방식)
      • 0,1,2,3,4,5,6가 윈도우 사이즈에 맞을때, 0,1을 보내면 윈도우는 2,3,4,5,6 으로 변한다.
      • ACK를 받게 된다면, 0,1이 정상적으로 수신되었음을 알게되고 윈도우는 옆으로 두칸 이동해(slide) 2,3,4,5,6,7,8로 변한다.

혼잡 제어

송신측의 데이터 전달과 네트워크의 데이터 처리 속도 차이를 해결하기 위한 기법.

  • 송신측이 네트워크를 통해서 데이터를 전달할때, 라우터가 만약 데이터가 너무 몰려서(혼잡할때) 모든 데이터를 처리할 수 없게 되고,
  • 송신 측은 다시 데이터를 보내고, 혼잡을 가중시켜서 오버플로우나 데이터 손실을 일으키게 된다.
  • 그 과정에서 혼잡을 피하기 위해서 강제로 송신측의 데이터 전송속도를 줄이는데, 이것을 혼잡제어라 한다.
  • 해결방법
    • AIMD (Additive Increase / Multiplicative Decrease)
      • 처음에는 패킷을 하나씩 보내고, 문제없이 도착하면 window size를 1씩 증가시켜가며(additive increase) 전송하는 방법
      • 만약 패킷 전송에 실패하거나 일정 시간(timeout)을 넘으면, window size를 절반으로 감소시킨다.
      • 이 방식은 fair 한데, 여러 개의 호스트가 네트워크름 공유할때, 처음 보내는 쪽이 1부터 보내기에 불리하지만 나중에는 평형한 상태를 맞추기 때문.
      • 하지만 초기에 1씩 보내기때문에 높은 대역폭을 충분히 사용하지 못하고 , 혼잡상황을 미리 감지 못해 혼잡이 일어나야지만 대역폭을 줄이는 방식.
    • Slow Start
      • AIMD 방식이 전송 속도를 올리는데 있어서 오래걸리는 단점(1씩 증가하기 때문에)이 있기에 이것을 개선하는 방식.
      • AIMD와 마찬가지로 1개의 패킷을 보내면서 시작하지만, 문제없이 도착하면 window size를 2배로 늘려준다.
      • 혼잡현상이 발생하면, window size를 1로 떨어트림.
      • 처음에는 네트워크에서 혼잡을 유발하는 window size를 알 수 없지만, 한번 혼잡현상이 발생하면 최대 수용량에 대한 유추가 가능해지기에, 그 window size의 절반까지는 이전처럼 2배로 증가하되, 그 이후부터는 완만하게 1씩 증가시키는 방식을 택함.
      • 임계값(ssthresh)단계에서 부터는 혼잡회피(Congestion Avoidance)를 사용한다.
    • 혼잡 회피 (Congestion Avoidance)
      • window size가 임계값에 도달한 이후부터는 조만간 혼잡이 발생해 데이터 손실 발생 확률이 커지기 때문에 조심스럽게 접근한다.
      • window size를 linear하게 1씩 증가시키는 방법을 사용.
      • 만약 혼잡이 발생했을 경우에, window size를 1로 줄이고, 임계값을 손실 발생했을때의 window size(W)의 절반인 W/2로 줄임.
    • Fast Retransmit (빠른 재전송)
      • 수신측은 순서대로 잘 도착한 마지막 패킷의 다음 번호를 ACK로 보내기 때문에, 중간에 손실이 있을시에는 중복된 ACK를 받게 된다.
      • 이것을 감지하면, 문제가 되는 순번의 패킷을(SR) 재전송 해줄수 있다.(혹은 문제 지점부터 싹 다시보내거나,GBN)
      • 3 ACK Duplicated, 즉 송신 측이 3번 이상 중복된 승인 번호를 받으면 혼잡이라고 인식한다.
      • 이때는 설정된 타임아웃이 지나지 않아도 해당 패킷을 재전송할 수 있다. (GBN or SR)
      • 이 방법으로 설정한 타임아웃 시간이 지나서야 대처하는 기존의 방법보다 낭비되는 시간을 줄일 수 있다.
    • Fast Recovery (빠른 회복)
      • TCP Tahoe : 초기 버전으로 타임아웃이 되거나 3 dup ack가 발생시 W를 1로 줄이고 slow start로 들어간다.
      • TCP Reno : 개선 버전이다. Tahoe가 무조건 W를 1로 줄이기에 대역폭을 온전히 사용 못할 수 있다. 그렇기에 3 Dup ACK는 조금 덜한 혼잡이라고 본다.
        • 3 Dup ACK : 현재 W의 절반으로 W를 줄이고, 1씩 linear하게 증가시킨다. 이때 임계점을 현재 W의 절반으로 설정한다.
        • Timeout : Tahoe와 마찬가지로 W를 1로 줄이고 slow start 한다. 임계점을 수정하지 않는다

오류 제어

기본적으로 TCP는 ARQ(Automatic Repeat Request), 재전송 기반 오류제어를 사용한다. 뭔가 오류가 생기면, 송신측이 수신측에게 해당 데이터를 다시 전송하는 방법이다.

그렇지만 이 재전송이라는 작업 자체가 했던 일을 다시 해야하는 비효율적인 방법이기 때문에, 이 재전송 과정을 최대한 줄일 수 있는 방법을 사용한다.

오류가 발생했다는것은 어떻게 알까?

  • NACK(부정 응답) : 더 명확해보이지만, 수신측에서 ACK/NACK를 선택하는 추가적인 과정이 필요하다.
  • ACK가 오지 않음 : 수신측이 아예 데이터를 받지 못해 ACK를 못 보내거나, 수신 측이 제대로 응답했지만 ACK가 유실(lose)되는 경우
  • 중복된 ACK
    • 송신측이 SEQ 2를 가지는 데이터를 이미 보냈는데도, 수신 측에서 이번엔 2번을 보내달라고 하면 문제가 생겼음을 알 수 있다.
    • 중복된 ACK 한 두번으로는 판단하지 않고, 3번 정도 받았을때 오류로 판단. 그 이유는 TCP가 중복된 ACK를 받앗을때 패킷 로스인지, 세그먼트들의 순서가 reordering 되는건지 명확히 알 수 없기 때문에 3번까지 보는것. 순서가 뒤집히는 이유는 TCP 아래 계층에서 라우팅 하는 단계에서 다른 경로를 택할수도 있기 때문.
      • 해결 방법
        • Stop and wait : 흐름 제어때도 나왔엇지만, 제대로 받았다는 응답이 올때까지 대기하고 그 다음 데이터를 보내는 방식.
          • 아주 기본적으로 오류 제어가 가능하다.
          • 하지만 흐름 제어에 sliding window 방법을 사용하다면 데이터를 연속적으로 보내기 위함인데, 이런 방식으로 오류 제어를 사용하면, 그 이점을 잃는다.
        • Go Back N (GBN)
          • 데이터를 연속적으로 보내다가, 어느 데이터부터 오류가 발생했는지 검사하는 방식.
          • 데이터를 연속적으로 보낸 후 한개의 ACK 혹은 NACK(오류 제어에선 설명의 간단함을 위해서 NACK로 사용)만으로 오류 파악이 가능하니, 슬라이딩 윈도우와 찰떡 (sliding window가 네트워크 대역폭의 이용률을 높이기 위해서 다른 프레임을 보내기전에 ACK를 요구하지 않는데 )
          • 만약 4번 데이터에서 에러를 감지하면, 수신측은 4번 데이터 이후 받은 모든 데이터를 폐기후 송신측에 알리는 방식이다.

수신자의 버퍼관리가 간단하다. 결국에 수신자도 상위 계층에 데이터를 전달하는 입장인데, 순서가 잘못 넘어온 데이터에 대해서(n+1만 오고, n은 오지 않음) 버퍼링을 할 필요가 없기 때문이다.

      •  
왜 데이터들을 다 폐기할까?
  • 송신측은 5번까지 이미 전송을 했지만, 4번 데이터에서 에러가 났다는것을 알았기에 4번으로 되돌아가서(Go Back) 다시 전송해야 한다.
  • ACK이 누적적(cumulative)이다.
  • SR에 비해서 구현이 간단하지만, GBN의 특성상 같은 데이터가 계속 오갈 수 있으니 대역폭을 효율적으로 사용하지 못한다.
  • 또 SR에 비해서 버퍼사이즈를 크게 잡을 수 있는데 시퀀스 넘버가 n비트일때 2^n-1개의 window 사이즈를 가질 수 있다. 왜 1을 빼냐면,
    • Selective Repeat (SR)
      • 오류가 난 데이터만 선택적인 재전송을 의미한다. GBN이 stop and wait에 비하면 효율적이지만, 에러가 발생하면 그 이후 정상적으로 받은 데이터를 모두 폐기처분하니, 재전송해야하는 비효율이 아직 있다.
      • 문제가 있는 데이터만 재전송하니 효율적이고 만능처럼 보이지만, 버퍼에 데이터들이 연속적이지 않다는 문제점이 있다.
      • 위 예시에서 데이터는 0,1,2,3,5 가 들어있다가 4를 재전송 받게 되면, 0,1,2,3,5,4 의 데이터가 들어있다.
      • 기존의 버퍼 안에서 데이터를 정렬할 수 없으니, 별도의 버퍼를 두어서 그 안에서 재정렬을 해줘야 한다.
      • GBN에 비해서 재전송이 적다는 점에서 효율적이지만, 추가적인 로직이 필요해서 복잡하고, 별도의 버퍼를 관리하는데 드는 비용이 있다.
      • ACK이 각각(individual)이다. (사진상으로는 안그런것처럼 보이지만)
      • 시퀀스 넘버가 n비트 일때 2^(n/2)개 이하의 window size로 제한되어 있다. 만약 2^(n/2)이라고 해보자. 그럼 뒤의 절반이 왔을때, 나머지 앞 절반이 안오면 뒤의 절반을 추가 버퍼에 가지고 있을텐데, 안 온 부분에 대해서

UDP

비연결형 서비스를 지원하는 전송계층 프로토콜이다. 사전에 연결 설정을 하지 않은 데이터그램 방식으로 데이터를 전달한다.
별도의 연결을 수립하지 않고 보내기 때문에, 각각의 패킷은 서로 다른 경로를 통해서 전송될 수 있고 각각은 독립적이고 그렇기에 패킷에 순서를 부여하진 않는다.

실제로 UDP 헤더 부분을 보면 아래와 같이 받는 포트와 보내는 포트 정보, UDP 길이, checksum으로만 구성되어 있는 8Byte 구조이다.

  • UDP는 TCP와 달리 흐름제어, 오류제어 , 손상된 세그먼트에 대한 재전송을 하지 않는다.
  • 오직 Checksum 필드를 통해서 최소한의 오류만 검출하기 때문에 신뢰성이 TCP에 비해서 낮은 편이지만, 이 때문에 TCP에 비해서 속도가 빠르다.
  • 그렇기에 신뢰성 보다는 연속성이 중요한 서비스 , 예를 들어 실시간 서비스에서 자주 사용된다.
  • TCP가 소켓을 open해서 통신하는 구조에 반해서 UDP는 오직 IP를 기반으로만 데이터를 전송한다.
  • 흐름제어가 없기 때문에, 패킷이 제대로 전송되었는지, 오류가 없는지는 확인할 수 없다.

또 대표적으로 UDP를 사용하는 서비스가 DNS인데(모든 DNS가 UDP만 쓰는것은 아니다.) 아래 이유로 사용된다.

  • 3way handshake와 같은 연결 수립 과정이 없기 때문에, 연결 설정에 드는 비용이 없어 빠르다.
  • 연결 상태를 유지하는 TCP의 특성상 버퍼에 대한 정보,혼잡제어 정보, seq,ack 번호와 같은 정보를 저장해야하기에 UDP가 더 많은 클라이언트를 수용할 수 있다.
  • DNS의 레코드는 일반적으로 작아서 UDP 세그먼트에 어울린다. (50-550 Byte)
  • UDP가 신뢰성이 없긴 하지만, DNS가 더 상위계층(application layer)의 프로토콜이니 만약 응답이 오지 않는 경우에서는 다시 전송하도록 지시할 수 있다.

단, DNS의 응답의 크기가 512Byte 보다 크거나, zone transfer(DNS 레코드를 primary에서 secondary로 옮기는 작업)을 할때에는, 데이터의 정합성 검사를 위해서 TCP를 사용한다.

Stack

선형자료구조의 일종으로 LIFO의 특성을 가지고 있다. 가장 처음 들어간 원소가 가장 나중에 접근 가능 하고, 다시 말해서 호출 시에 가장 최근의 원소에 접근 할 수 있다. O(n)의 공간복잡도를 가진다.

링크드 리스트를 이용한 구현과 배열을 이용한(정확히는 Dynamic Array) 두가지 버전이 있다. 링크드 리스트는 일관적인 시간복잡도 O(1)를 보여주는 대신에 추가적인 메모리 사용량(Node 구조를 유지하는데 드는)과 메모리 할당에 추가적인 비용이 들 수 있다.

  • 조회 : Top에 있는 원소를 조회할때는 O(1), 하지만 특정한 데이터를 찾고자 할때는 O(n)이다.
  • 삽입 : 링크드 리스트를 이용한 구현에서는 단순히 기존의 Top 원소를 새 원소의 next 로 연결시켜주고 head 의 포인터에 새로운 원소를 연결시켜주면 되기 때문에 O(1)이다.
    배열을 이용한 구현에서도 특정 상황(Dynamic array의 팽창)을 제외하고는 O(1)이니 amoritezd\ O(1)이다.
  • 삭제 : 링크드 리스트와 배열 모두 O(1)이다.

활용

  • Stack Usage : JVM stack, 컴파일러에서 문법 체크(matching parentheses)

💡 프로그램의 함수 호출과 실행 순서는 아래와 같으니

스택프레임에 지역변수,매개변수,복귀주소등의 정보를 저장하고

함수의 실행이 끝나면 stack frame의 top을 pop한 후 복귀 주소로 복귀한다
가장 마지막에 호출된 함수를 가장 먼저 처리하고 복귀하는 후입선출의 구조를 처리하기 위해서 스택을 이용해서 관리한다.

Queue

선형자료구조로 FIFO의 특성을 가지고 있다. 먼저 들어간 원소가 가장 먼저 나오는 구조를 가지고 있다. O(n)의 공간복잡도를 가진다.

배열을 이용해 구현할때는 단순 배열보다 원형 구조 (circular queue)를 이용해서 구현한다. 혹은 링크드 리스트를 이용해서 구현할수 도 있는데 이때는 headtail 에 대한 정보를 기록해야 한다.

  • 조회 : 가장 끝에 있는 원소를 조회할때는 O(1).
  • 삽입 : 링크드 리스트를 이용한 구현해서는 O(1). 동적 배열을 이용할때는 amoritezd\ O(1)이다. 원형 배열을 이용할때는 O(1).
  • 삭제 : 모두 O(1).

스택과 큐 모두 배열을 이용하거나 링크드 리스트를 이용해 구현가능한데, 배열을 사용하는 경우에는 Dynamic array의 팽창을 고려해야하기 때문에 최악의 경우에는 느리지만, 전반적인 성능이 좋다. 반대로 링크드 리스트를 이용한 구현에서는 일관된 성능(O(1))을 보여주지만, 메모리 할당에 들어가는 추가적인 비용 때문에 런타임 성능이 낮을 수 있다.

활용

  • Queue Usage : 스케쥴링,키보드 버퍼 → 두 개 모두 데이터 혹은 요청이 입력된 시간 순서대로 처리해야할 필요가 있을 경우에 사용한다.

문제들

  • 두 개의 스택으로 큐를 구현하기
    1. in 에 A,B,C를 차례로 push. 그러면 in 에는 [A,B,C] 의 순서대로 데이터가 쌓일 것이다.
    2. 그 후 in 의 데이터를 각각 pop하면 C,B,A의 순서대로 나올것이다. 그 후 out 으로 push해보자. out : [C,B,A]
    3. 이후 out 의 원소들을 순서대로 pop하면 출력은 A,B,C가 나오고 out: []
  • 생각보다 엄청 간단하다. 두 개의 스택을 각각 inout으로 구분하고, A,B,C 세개의 데이터가 입력으로 주어진다고 해보자. 출력은 A,B,C가 나와야 한다.
  • 스택에서 min을 O(1)에 작동하도록 하기삽입
    1. 만약 스택이 비어있다면 x 를 삽입할때, minEle=x 로 할당한다.
    2. 그렇지 않다면, xminEle 의 대소를 비교해야하는데 두가지 경우가 존재한다.
      1. xminEle 보다 크거나 같다면, 그냥 삽입한다.
      2. minEle 보다 작다면, 2x-minEle 를 스택에 삽입하고, minElex 로 초기화해준다.
      삭제
    3. ymin 보다 크거나 같으면, y 를 그냥 삭제한다. 여전히 최소값은 minEle 다. 그리고 그 값은 스택이 아닌 minELe 에 있다고 보는것이 맞다.
    4. yminEle 보다 작을 경우에, minEle2*minEle-y 로 변경해준다.minEle 보다 작은 원소가 삽입될때 우리는 2x-minEle 를 삽입하는데, 언제나 그것은 x 보다는 작다는 것이다.
    5. 원리
  • 일단 최소 요소를 기록하는 minEle 변수를 stack 내부에 구현한다. 그 후 아래와 같은 사고방식대로 접근한다.

'자료구조' 카테고리의 다른 글

B 트리  (0) 2022.09.18
Array와 Linked List  (0) 2022.09.18
Hash Table에 대해서 완전 자세하게 알아보자.  (0) 2022.09.18
Hash Table에 대해서 완전 자세하게 알아보자.  (0) 2022.04.09

+ Recent posts