목표
지금까지 다뤘던 TCP, UDP 서버는 동시에 여러 클라이언트를 처리할 수 없습니다.
이 글에서는 서버의 복수 동시접속 처리 방법을 알아보도록 하겠습니다.
Socket Options
본론으로 가기 전에 Socket의 옵션을 변경할 수 있는 몇 가지 메서드들을 보고 가겠습니다.
Socket의 옵션 확인 및 변경
int getsockopt(SOCKET s, int level, int optname, char * optval, int *optlen);
int setsockopt(SOCKET s, int level, int optname, char * optval, int optlen);
Socket library 구현은 socket의 동작 방식을 변경할 수 있는 옵션을 포함합니다.
위 함수는 이 옵션 값을 읽어오거나 쓸 수 있는 함수들입니다.
SO_REUSEADDR
int on = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind()는 특정 L4 타입에 대해서 <IP, Port>쌍이 사용 중이 아닐 경우만 가능합니다.
OS는 한 번 bind 했던 것을 바로 사용 가능한 것으로 해제 하지 않을 수 있습니다.
이는 서버를 리부팅했는데 bind()를 실패하는 경우가 생길 수 있음을 의미합니다.
이유로는 TCP 같은 경우 이미 다른 클라이언트하고 통신을 한 적이 있을 수 있습니다.
예를 들어 1000번 포트로 어떤 데이터가 지금 날아오고 있는 중일 수 있습니다.
1000번 포트를 bind()하고 서버를 리부팅하게 되면 그 리부팅한 프로그램이 바로 다시 1000번 포트를 그대로
쓸 수 있으면 좋겠지만 TCP에서 이미 다른 통신을 한 적이 있으면 데이터가 날아오고 있는 중일 수 있습니다.
그런 경우 다른 프로그램이 떴을 때 자기가 모르는 트래픽을 받을 수 있습니다.
이 때문에 bind()를 바로 못하게 막습니다.
그러나 같은 프로그램이 뜨는 경우 보안상 다른 트래픽을 받거나 전혀 모르는 트래픽을 받으면 프로그램이 오동작할 수도 있는데
그걸 감안하더라도 서버 프로그램을 바로 리부팅 하고 싶을 때 사용하는 것이 SO_REUSEADDR입니다.
SO_REUSEADDR는 이미 bind 된 것을 다시 bind() 할 수 있게 합니다.
다른 프로세스가 있는데 이 작업을 하는 것은 문제가 있을 수 있으나,
같은 서버가 리부팅되어 다시 bind() 하지 못할 때는 유용합니다.
SO_RESUEADDR 관련 코멘트
Q) 이미 bind() 한 프로세스가 떠 있는데, 두 번째 프로세스가 똑같은 IP, port를 bind() 한다고 해보겠습니다.
이제 두 프로세스가 모두 bind()를 하고 있는 상황이 되었습니다.
이 경우 어떤 쪽이 새 TCP 연결을 받게 될까요?
A) 알 수 없습니다.
앞서 설명한 것처럼 실무적으로 SO_REUSEADDR은 서버를 리부팅했을 때
일시적으로 bind()에 실패하는 것을 완화하기 위함이지
이처럼 복수개의 서버가 같이 bind 하는 것이 목적이 아닙니다.
따라서 서버가 이미 떠 있는데
SO_REUSEADDR로 같은 포트를 bind 하는 것은 피해야 합니다.
TCP_NODELAY
int on = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
Nagle's algorithm은 작은 데이터를 바로바로 보내지 않고 잠깐 기다리면서 모아뒀다가 합쳐서 보내게 됩니다.
이 때문에 latency를 증가시킬 수 있습니다.
Interactive 한 app의 경우 이는 문제가 됩니다.
(예: RPG에서 캐릭터의 움직임 패킷은 작은 패킷이 자주 발생하게 됩니다.)
이 때문에 응용에 따라 Nagle's algorithm을 끌 필요가 있습니다.
Socket이 사용 중인 L3/L4 주소 얻어내기
내 주소 얻어내기 (UDP, TCP)
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
unsigned int sin_size = sizeof(sin);
if(getsockname(sock, (struct sockaddr *) &sin, &sin_size) == 0) {
cout << "My address: " << inet_ntoa(sin.sin_addr) << endl;
cout << "My port: " << ntohs(sin.sin_port) << endl;
}
상대방 주소 얻어내기 (TCP)
struct sockaddr_in sin;
memset(&sin, 0, sizeof(sin));
unsigned int sin_size = sizeof(sin);
if(getpeername(sock, (struct sockaddr *) &sin, &sin_size) == 0) {
cout << "Your address: " << inet_ntoa(sin.sin_addr) << endl;
cout << "Your port: " << ntohs(sin.sin_port) << endl;
}
서버의 복수 동시접속 처리
서버는 동시에 여러 클라이언트를 서비스할 수 있어야 합니다.
이는 복수개의 Active Socket에서 recv()/send()를 할 수 있어야 됨을 의미합니다.
또한 그러면서도 Passive Socket에서 새로 들어오는 연결을 accept() 해야 합니다.
그런데 지금까지 다룬 accept()/connect()/recv()/send()는 blocking 됩니다.
이를 처리하기 위한 방법은 세 가지가 있습니다.
- 멀티 쓰레딩
- 소켓 별로 처리 스레드를 두게 합니다.
- 단점: 스레드의 개수가 너무 많아집니다. 스레드는 무거운 자원이므로 성능 문제가 생깁니다.
- Non-blocking I/O
- 소켓은 기본적으로 blocking이고, 이 때문에 앞의 함수들이 blocking 된 것입니다.
이를 non-blocking 소켓으로 변경합니다. - 단점: non-blocking이라고 하더라도 주기적으로 함수는 호출해 줘야 됩니다.
만일 클라이언트가 1만 개라면 소켓 1만 개에 대해서 함수를 호출하게 됩니다.
- 소켓은 기본적으로 blocking이고, 이 때문에 앞의 함수들이 blocking 된 것입니다.
- I/O Multiplexing
- 소켓의 읽기/쓰기/에러 이벤트를 커널로부터 통지받고,
이벤트가 있는 경우만 함수를 호출합니다.
- 소켓의 읽기/쓰기/에러 이벤트를 커널로부터 통지받고,
I/O Multiplexing
다양한 것을 묶는 것을 multiplexing이라고 합니다.
현재 우리는 네트워크에 대한 input/output을 묶고 있기 때문에 I/O Multiplexing이라고 합니다
select() 함수
예를 들어 프로그램 10개가 로그 파일을 계속 생성을 하고 있습니다.
그러면 로그가 계속 추가돼서 파일이 계속 증가할 것입니다.
그런데 프로그램 10개를 동시에 읽으면서 그걸 합치는 프로그램을 만들고 싶습니다.
어느 한쪽에서 읽는 것이 계속 blocking 되는 것이 아닌 10개 파일에 대해서도
어느 파일이 데이터가 쓰이는지 상관없이 데이터가 있을 때만 깨워서 처리할 수 있게끔 하는 것이 I/O Multiplexing입니다.
이에 대한 대표적인 함수가 select()입니다.
일반 상용 서버에서는 select()를 써서 만들지는 않습니다.
select()는 POSIX에 정의되어 있는 가장 기초가 되는 함수입니다. (Linux, Windows 모두 사용 가능)
int select(
int nfds, // 제일 큰 소켓 번호에 1 더한 숫자
fd_set *readfds, // 읽기 이벤트 체크할 소켓 집합 및 그 결과
fd_set *writefds, // 쓰기 이벤트 체크할 소켓 집합 및 그 결과
fd_set *exceptfds, // 예외 이벤트 체크할 소켓 집합 및 그 결과
const timeval *timeout // 이벤트 기다릴 시간 값
);
select() 함수의 fd_set
소켓들의 집합을 지정하기 위해 typedef 되어있는 구조체입니다. 내부적으로 bit array를 관리합니다.
세부 내용을 알 필요 없이 변수 선언 후에는 다음의 매크로 함수를 이용합니다.
아래 코드는 다음 변수가 선언되었음을 가정합니다: fd_set set;
- 초기화: FD_ZERO(&set)
- 소켓을 집합에 추가: FD_SET(sock, &set)
- 소켓이 집합에 있는지 확인: FD_ISSET(sock, &set)
- 소켓을 집합에서 삭제: FD_CLR(sock, &set)
select() 이벤트 종류 및 예시 코드
fd_set rset, wset;
FD_ZERO(&rset);
FD_ZERO(&wset);
select(maxFd+1, &rset, &wset, NULL, NULL);
listen 중인 passive socket에 새 입력이 있는지 여부는 읽기 이벤트입니다.
FD_SET(passiveSock, &rset);
Active socket에서 데이터가 들어왔는지 여부는 읽기 이벤트입니다.
FD_SET(activeSock, &rset);
소켓에 데이터를 쓸 수 있는지 여부는 쓰기 이벤트입니다.
FD_SET(activeSock, &wset);
select() 함수의 fd_set bit array
fd_set 변수 선언하면 내부적으로 bit array가 생깁니다.
FD_ZERO()를 하면 이 array를 0으로 채우게 됩니다.
FD_SET(1), FD_SET(4)를 하면 1번 소켓 디스크립터와 4번 소켓 디스크립터에 해당하는 비트 array를 켜게 됩니다.
그리고 select()를 진행하게 됩니다.
select() 후 1번 소켓 이벤트가 발생했으면 4번 소켓 디스크립터에 해당하는 비트 array를 꺼줍니다.
1번이 켜져 있는지 확인할 때는 FD_ISSET(1), 4번이 켜져 있는지 확인할 때는 FD_ISSET(4)를 하게 됩니다.
정리하면 fd_set을 선언하고 0으로 초기화하고 SET을 한 후 읽어야 될 것을 진행 후 select()를 부른 다음에
그 결과에 대해서 ISSET으로 확인하는 것입니다.
select()의 timeout 시간
select(maxFd+1, &rset, &wset, &eset, NULL);
NULL을 지정하면 어떤 이벤트든 발생할 때까지 blocking 합니다.
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 100;
select(maxFd+1, &rset, &wset, &eset, &tv);
그 외에는 timeval 구조체 지정값에 따라 다릅니다.
예를 들어 클라이언트가 10개가 있습니다. 클라이언트 10개를 순차적으로 한 번씩 한 번씩 체크를 하면
어떤 클라이언트가 blocking이 되면 뒷부분에 데이터가 와도 읽을 수가 없습니다.
그러나 select()는 10개 중에 아무거나 올 때까지 기다린다는 뜻입니다.
위 코드는 명시적으로 구조체에 시간을 지정하는 코드입니다.
100ms동안 기다려서 read, write, exception이 발생하면 알려주고
발생하지 않더라도 100ms 종료하면 바로 리턴해주라는 의미입니다.
select()의 한계
- 다룰 수 있는 소켓 숫자 제한
- fd_set 은 소켓들의 이벤트 여부를 나타내기 위해 배열을 사용합니다.
- 이는 배열의 크기가 정해져 있다는 뜻입니다.
- Windows에서는 64개 정도, Linux는 1024 정도입니다.
- 이벤트 발생 여부 체크를 위한 loop
- 여전히 각 소켓에 대해서 loop로 체크를 해줘야 합니다.
select()의 대안
- 이벤트 발생한 것을 배열에서 빼는 방식이 아니라 list 형태로 제공하는 것을 씁니다.
- Linux: epoll
- IO를 비동기적으로 처리하고 결과를 알려주는 방식을 이용한다.
- Window: IOCP
Blocking Call vs. Non-blocking Call
Blocking Call
프로세스가 어떤 작업을 위해 OS에 기능을 요청했는데(= System call 호출) OS가 바로 이를 처리할 수 없을 때 thread를 "sleep" 또는 "blocked" 상태로 변경하게 됩니다. OS는 요청된 작업을 다 처리한 후 thread를 깨웁니다.
Thread를 이렇게 sleep 상태로 만들 수 있는 함수를 "blocking 함수"라고 하고,
그런 상태로 만들 수 있는 I/O 작업을 "blocking I/O"라고 합니다.
Blocking I/O는 thread를 sleep 시키므로 CPU를 소모하지 않습니다.
대신 그 순간에 thread는 아무 작업도 할 수 없게 되어 throughput은 떨어집니다.
Non-blocking Call
Thread를 sleep 시키지 않는 함수를 "Non-blocking 함수",
이런 I/O 작업을 "Non-blocking I/O" 라고 부릅니다.
이때 단순히 "시간이 오래 걸리는지 아닌지"로 판단하는 것이 아니라,
thread를 sleep 시키는지가 중요합니다.
설령 해당 함수가 내부에서 복잡하게 연산을 해서 시간이 좀 걸리더라도 thread는 sleep 하지 않았으므로
blocking 함수가 아니라 non-blocking 함수입니다.
Non-blocking I/O는 CPU 사용률을 올리게 됩니다.
Polling & Busy Wait
Non-blocking 함수 중에서는 내부적으로 loop를 돌면서 작업이 끝났는지를 체크하는 함수들이 있습니다.
이때 "작업이 끝났는지 체크"하는 것을 polling이라고 부릅니다.
그리고 이렇게 loop을 돌면서 기다리는 것을
CPU를 사용하면서 기다린다고 해서 busy wait이라고 부릅니다.
주의: Polling 이 blocking 작업일 경우 loop을 돌면서 기다리는 것도 blocking입니다.
따라서 polling 자체는 busy wait임을 이미하지 않습니다.
Non-blocking polling으로 loop을 돌면서 기다리는 경우가 busy wait이 됩니다.
CPU 소모 여부가 중요합니다.
적정 CPU 사용률
CPU사용률은 어떤 작업을 하고 있는지로 판단하는 것이 적절합니다.
CPU 사용률이 높더라도 의미 있는 작업을 하면서 throughput이 높다면 그건 적절한 것이고,
의미 없이 낭비하면서 busy wait 하는 것은 부적절합니다.
반대로 busy wait 없이 처리할 작업이 없어서 CPU 사용률이 낮은 건 좋지만,
처리할 작업이 있는데도 계속 thread가 sleep 되는 것은 throughput 관점에서 "서버가 밀린다"라는 느낌을 주게 됩니다.
따라서 코드 분석을 통해서 의미 없이 CPU를 낭비하지 않고, 의미 없이 sleep 하지 않게 구현하는 것이 중요합니다.
서버는 통상 실무적으로는 대략 50% ~ 70% CPU 사용률 정도를 목표로 합니다.
'CS' 카테고리의 다른 글
[OS] Multi-Programming, Multi-Tasking (0) | 2023.12.10 |
---|---|
[OS] CPU utilization, CPU load (1) | 2023.12.10 |
[Network] TCP Socket Programming - 데이터 수신하기 (1) | 2023.10.22 |
[Network] TCP Socket Programming - 데이터 전송하기 (1) | 2023.10.22 |
[Network] Protobuf, JSON (0) | 2023.10.22 |