[RTMP] half-close와 소켓의 옵션

7 minute read

소켓의 우아한 연결 종료, half-close

지금까지 소켓을 닫는 과정은 아래와 같이 진행했습니다.

close(serv_sock);

close()를 호출하는 것은 완전 종료를 의미합니다. 완전종료는 데이터 전송과 데이터 수신 모두 불가능함을 말합니다. 따라서 한쪽에서 일방적으로 소켓 연결을 끊는 것은 우아하지 않습니다.

입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재합니다. 소캣 생성시에 자동으로 생성됩니다. 특이점은 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 되고, 입력버퍼에 남아있는 데이터는 소멸되어버립니다.

이전 포스팅에서 위와 같은 언급을 했었는데, 소켓 종료가 어떻게 진행되는지 제대로 알아보겠습니다.

일방적인 종료와 Half-Close

A와 B가 통신을 하다가 A가 close()를 호출하면 B가 보냈고 A가 받아야하는 데이터를 A가 받을 수 없게 됩니다. A의 입력버퍼에 있는 데이터가 소멸되어버리기 때문입니다. Half-Close는 전송 또는 수신 둘 중 하나만 가능하도록 일부를 종료하는 것을 의미합니다. 말 그대로 스트림의 반만 닫는 것입니다.

소켓과 스트림Stream

소켓을 통해서 두 호스트가 연결되면, 그 다음부터는 상호간에 데이터를 송수신이 가능한 상태가 됩니다. 이러한 상태를 stream이 형성된 상태라고 합니다. stream은 물의 흐름을 의미하는데, 물의 흐름은 한쪽 방향으로만 형성됩니다. 따라서 소켓 스트림 역시 양방향 데이터의 이동이 가능하기 위해서는 두 개의 스트림이 필요합니다.

때문에 두 호스트간에 소켓이 연결되면, 각 호스트 별로 입력 스트림과 출력 스트림이 형성됩니다. 물론 한 호스트의 출력 스트림은 다른 호스트의 입력스트림으로 이어집니다. Half close라는 것은 둘 중 하나의 stream만 끊는 것이고, 리눅스의 close() 호출은 두 가지 스트림을 동시에 끊어서 우아하지 못합니다.

우아한 종료를 위한shutdown() 함수

#include <sys/socket.h>

int shutdown(int sock, int howto);
/*
return 성공시 0, 실패시 -1
sock : 종료한소켓의 fd 전달
howto : 종료 방법에 대한 정보 전달
    - SHUT_RD : 입력 스트림 종료
    - SHUT_WR : 출력 스트림 종료
    - SHUT_RDWR : 입출력 스트림 종료
*/
  • SHUT_RD를 전달하면 입력스트림이 종료됩니다. 데이터가 입력 버퍼에 전달되더라도 지워지고 입력관련 함수의 호출도 불가능한 상태가 됩니다
  • SHUT_WR를 전달하면 출력스트림이 종료됩니다. 더이상의 데이터를 전송하는 것이 불가능해지지만 아직 출력버퍼에 데이터가 남아있으면 해당데이터는 모두 목적지로 전송됩니다.

Half-close가 필요한 이유

클라이언트가 서버에게 연결 요청을 하고, 서버는 클라이언트에게 데이터를 보내줍니다. 그리고 클라이언트는 데이터를 모두 받으면 Thank You라는 메시지를 보내고 연결을 끊는다고 가정해보겠습니다.

서버가 클라이언트에게 데이터를 전송하면 클라이언트는 read()를 호출합니다. 그런데 read()를 언제까지 호출해야하는 어떻게 알 수 있을까요? 지금까지의 예제에서는 클라이언트가 보낸 크기만큼 다시 읽거나(echo server), 연산 결과를 받는 것이 프로토콜로 정해져있었습니다.(op server) 하지만 크기를 알지 못하는 파일을 다운 받을 때는 데이터의 끝을 알 수 있는 방법이 필요합니다. 이 때 EOF(End Of File)을 서버가 클라에게 전송함으로써 데이터의 끝을 알립니다.

서버는 출력스틀미을 종료함으로써 클라이언트에게 EOF를 보낼 수 있습니다. 클라이언트는 EOF의 수신을 함수의 반환값을 통해서 확인할 수 있습니다.

  1. 클라 : 연결요청
  2. 서버 : 파일 데이터
  3. 서버 : EOF
  4. 클라 : Thank you

소켓의 옵션과 입출력 버퍼의 크기

소켓의 다양한 옵션

지금까지는 소켓의 기본 옵션으로 사용했었습니다. RTMP 서버에서 소켓을 효과적으로 사용하기 위해서는 어떤 옵션을 수정해야하는지 알아보겠습니다. 옵션은 엄청 많기 때문에 관심있는 부분만 개념적으로 정리하고 필요할 때는 찾아서 쓰면 됩니다. 대신 소켓의 옵션 중 이런 것이 있다라는 것은 알고 있어야합니다.

#include <sys/socket.h>

/**
 * @param sock 소켓의 파일 디스크립터 전달
 * @param level 옵션의 프로토콜 레벨 전달
 * @param optname 옵션의 이름 전달
 * @param optval 확인결과를 저장하기 위한 버퍼의 주소 값 전달/
 * @param optlen optval로 전달된 주소 값의 버퍼크기를 담고 있는 변수의 주소값. 함수호출이 완료되면 네 번째 인자를 통해 반환된 옵션 정보의 크기가 바이트 단위로 계산되어 저장된다.
*/

int getsocketopt(int sock, int level, int optname, void* optval, socklen_t *optlen);
int setsocketopt(int sock, int level, int optname, const void* optval, socklen_t optlen);

소켓 타입 정보 확인

소켓의 타입정보를 확인하기 위한 옵션 SO_TYPE는 확인만 가능하고 변경이 불가능한 대표적인 옵션입니다. 아래의 내용은 소켓의 타입 정보를 얻어서 출력합니다. TCP이라면 SOCKET_STREAM의 상수 값인 1을 출력합니다.

int tcp_sock, udp_sock;
int sock_type;
socklen_t optlen;
int state;

optlen=sizeof(sock_type);
tcp_sock=socket(PF_INET, SOCK_STREAM, 0);
udp_sock=socket(PF_INET, SOCK_DGRAM, 0);	
printf("SOCK_STREAM: %d \n", SOCK_STREAM); // 1
printf("SOCK_DGRAM: %d \n", SOCK_DGRAM); // 2

state=getsockopt(tcp_sock, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
if(state)
	error_handling("getsockopt() error!");
printf("Socket type one: %d \n", sock_type); // 1

state=getsockopt(udp_sock, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
if(state)
	error_handling("getsockopt() error!"); // 2
printf("Socket type two: %d \n", sock_type);
return 0;

소켓 입출력 버퍼 크기 확인

소켓의 입출력 버퍼의 크기를 확인하는 예제입니다. SO_SNDBUF는 출력버퍼의 크기를,SO_RCVBUF는 입력버퍼의 크기를 가져올 때 사용합니다.

int sock;  
int snd_buf, rcv_buf, state;
socklen_t len;

sock=socket(PF_INET, SOCK_STREAM, 0);	
len=sizeof(snd_buf);
state=getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, &len);
if(state)
	error_handling("getsockopt() error");

len=sizeof(rcv_buf);
state=getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if(state)
	error_handling("getsockopt() error");

printf("Input buffer size: %d \n", rcv_buf);
printf("Outupt buffer size: %d \n", snd_buf);
return 0;

소켓의 입출력 버퍼 크기 설정

입출력 버퍼의 크기를 변경하는 예제입니다. 입출력버퍼의 크기를 각각 3KB로 설정하도록 전달을 했습니다. TCP 내부적으로 요청을 들어줄지는 내부 프로토콜을 통해서 결정됩니다.

int sock;
int snd_buf=1024*3, rcv_buf=1024*3;
int state;
socklen_t len;

sock=socket(PF_INET, SOCK_STREAM, 0);
state=setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, sizeof(rcv_buf));
if(state)
	error_handling("setsockopt() error!");

state=setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, sizeof(snd_buf));
if(state)
	error_handling("setsockopt() error!");

len=sizeof(snd_buf);
state=getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void*)&snd_buf, &len);
if(state)
	error_handling("getsockopt() error!");

len=sizeof(rcv_buf);
state=getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void*)&rcv_buf, &len);
if(state)
	error_handling("getsockopt() error!");

printf("Input buffer size: %d \n", rcv_buf);
printf("Output buffer size: %d \n", snd_buf);
return 0;
/*
$ gcc set_buf.c -o setbuf
$ gcc get_buf.c -o getbuf
$ ./setbuf
Input buffer size: 2000 
Output buffer size: 2048 
*/

소켓 주소할당 에러

클라이언트와 서버가 연결된 상태에서 서버 측 콘솔에서 ctrl+C를 통해서 서버프로그램을 강제 종료할 때는 생각해보겠습니다. 이는 서버가 클라이언트 측으로 먼저 FIN 메시지를 전달하는 상황의 연출을 위한 것입니다. 그런데 이렇게 서버를 종료하고 나면 서버의 재실행에 문제가 생깁니다. 동일한 port번호를 기준으로 서버를 다시 실행하면 bind error가 출력됩니다. 클라이언트가 먼저 FIN을 보내면 괜찮고 서버가 먼저 보내면 bind error가 나옵니다.

이전에 ‘TCP의 내부 동작원리3: 상대 소켓과의 연결종료(Four-way handshaking)’에서 아래의 내용을 본 적이 있습니다.

상호간에 FIN 메시지를 주고 받으면 연결이 종료되는데 이 과정이 네 단계를 거쳐서 진행됩니다.  

1. A: 연결 끊고싶습니다. [FIN] SEQ 5000, ACK -
2. B: 잠시만요 [ACK] SEQ 7500, ACK 5001
3. B: 저도 끊을 준비가 됐습니다. 끊으세요. [FIN] SEQ 7501, ACK 5001
4. A: 네 연결 끊겠습니다. [ACK] SEQ 5001, ACK 7502

4번에서 A가 마지막으로 연결을 끊겠다는 ACK을 보냅니다. B가 ACK을 받으면 B의 연결은 종료가 됩니다. 혹시 B가 마지막으로 ACK을 받지 못했다면 B는 3번 FIN을 다시 보냅니다. 따라서 A가 4번을 보내고 바로 연결을 끊으면 4번 ACK이 B에게 전달되지 않았을 때 B가 FIN을 종료한 A에게 계속 보내기 때문에 문제가 생길 수 있습니다.

따라서 먼저 연결 종료 의사를 보낸 쪽은 4번 ACK을 전송한 뒤 ACK이 전송될 일정 시간을 기다린 뒤(TIME-WAIT) 연결을 종료합니다.

유용하지만 불편한 이 TIME-WAIT를 끄기 위해서는 다음과 같이 설정합니다.

int option;
optlen=sizeof(option);
option=TRUE;	
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, &option, optlen);

TCP_NODELAY, Nagle 알고리즘, SENDER 입장에서의 DELAY

Nagle 알고리즘은 TCP상에서 적용되는 매우 단순한 알고리즘으로써, 이의 적용 여부에 따른 데이터 송수신 방식의 차이는 다음과 같습니다.

  • Nagle ON
    1. A -> B : data ‘N’
    2. A <- B : ACK ‘N;
    3. A -> B : data ‘agle’
    4. A <- B : ACK ‘agle;
  • Nagle OFF
    1. A -> B : data ‘N’,’a’,’g’,’l’,’e’를 한단어씩 일정한 주기로 ACK을 받음과 상관없이 보냄
    2. A <- B : ACK ‘N’,’a’,’g’,’l’,’e’를 받을 때마다 ACK을 보냄

즉, Nagle알고리즘은 앞서 전송한 데이터의 ACK메시지를 받아야만 다음 데이터를 전송하는 알고리즘입니다. 기본적으로 TCP 소켓은 Nagle 알고리즘을 적용해서 데이터를 송수신합니다. 때문에 ACK을 수시날 때까지 최대한 버퍼링을 해서 데이터를 전송합니다.

  1. 서버가 ‘N’을 출력버퍼로 전송. 출력버퍼는 기다리고 있는 ACK이 없으므로 바로 전달
  2. 서버가 ‘agle’을 출력버버로 전송. 출력버퍼는 ACK ‘N’을 받은 이후에 ‘agle’을 전송

    총 4개의 패킷이 사용됨.

Nagle OFF의 경우 각 문자마다 전송과 ACK이 사용된다.

총 10개의 패킷이 사용됨.

Nagle 알고리즘을 좀 더 깊게보면, 보내야하는 데이터가 MSS(Maximum Segment Size, ~ 1.4KB)보다 작은 경우는 앞선 데이터의 ACK을 받야아먄 다음 데이터를 보냅니다. MSS를 넘는 데이터는 즉시 전송을 합니다. Nagle 알고리즘을 끄는 것이 효율적인 경우의 예로는 대용량 파일 전송 시스템과 멀티플레이어 게임 서버의 경우입니다.

대용량 파일의 전송의 경우, 이미 로컬에 존재하는 데이터를 출력버퍼로 밀어 넣는 작업은 시간이 걸리지 않기 때문에 Nagle알고리즘을 적용하지 않아도(출력버퍼로 밀어넣는 작업은 MSS 제한 크기를 넘는 속도로 진행되기 때문에) 출력버퍼를 거의 꽉 채운 상태에서 패킷을 전송합니다. 따라서 패킷의 수가 크게 증가하지도 않고 전송속도도 놀랍게 향상됩니다.

또한 멀티플레이어 게임의 경우 다수의 유저가 접속해서 전체 처리량은 높지만 각 사용자가 보내는 데이터는 크지 않습니다. 따라서 각 사용자의 정보가 Nagle 알고리즘에 적용되면 버퍼링이 되어서 게임 데이터가 실시간으로 전송되지 않는 성능 이슈가 있을 수 있습니다.

  • Nagle ON : 일반적인 경우로 네트워크의 효율적인 사용.
  • Nagle OFF : 속도 향상에 이점. 패킷 트래픽 증가. 데이터의 특징을 정확히 판단해야 합니다.

RTMP에 대해서 생각해보곘습니다. 송출자는 실시간으로 데이터를 생성해서 RTMP 서버로 전달을 합니다. 그런데 실시간 비디오 정보는 MSS의 크기를 넘는 속도로 데이터가 생성되기 때문에 Nagle 알고리즘을 적용해도 성능문제가 생기지 않습니다. 따라서 비디오 스트리밍 서버의 경우 MSS보다 큰 데이터를 빨리 생성하기 때문에 Nagle 알고리즘을 켜고 사용해도 됩니다. 혹시 데이터 생성이 느려지면 그 때만 Nagle알고리즘이 적용될 것이기 때문입니다.

결론적으로 말하면 데이터 생성량이 적은 TCP 앱의 경우 Nagle 알고리즘이 성능 저하의 범인일 수 있습니다. 하지만 RTMP와 같은 스트리밍서버에는 Nagle알고리즘이 성능 문제를 일으키지 않습니다.

TCP Delayed Acknowledgment, RECEIVER 입장에서의 DELAY

데이터를 전송받은 입장에서 ACK을 무수히 많이 보내는 오버헤드를 줄이기 위해서 일정시간 동안 ACK을 모았다가 한 번에 전송하는 것을 말합니다. 보통 수백ms를 기다립니다. 송신자는 ACK을 받을 때까지 작은 데이터들을 출력버퍼에 쌓아둡니다.

RTMP relay 서버는 데이터를 받아서 전송하는 입장이기 때문에 Nagle과 Delayed Acknowledgment를 모두 검토해야합니다. 정리하자면 Nagle알고리즘이 defulat로 ON이지만 성능에 영향을 미치지 않습니다. 오히려 데이터 생성의 속도가 느려질 때를 대비해서 켜두는 것이 좋습니다. 그리고 Delayed Acknowledgment를 하기 위해서는 default인 TCP_QUICKACK를 disable해야하지만, Delayed Acknowledgment는 트래픽을 줄이기 위함이기 때문에 적용하지 않아도 될 것으로 생각됩니다. 결론은 Default로 사용해도 된다!입니다.

Reference

Tags:

Categories:

Updated: