해당 내용은 게임 서버 프로그래밍교과서의 내용을 참고했습니다.
해당 설명에 사용된 코드는 모두 의사 코드입니다.

블로킹 소켓

블로킹

디바이스에 처리 요청을 걸어 놓고 응답을 대기하는 함수를 호출할 때 스레드에서 발생하는 대기현상
블로킹이 발생하는 스레드에서는 CPU연산을 하지 않는다.

스레드에서는 디스크에 접근 할 시에 블로킹이 된다.
소켓에서는 네트워크 수신을 하는 함수를 호출할 시 응답이 올 때 까지 블로킹된다.

네트워크 연결 및 송신

TCP통신은 일대일 통신만 허락한다.

main()
{
	s=socket(TCP);					//1
    s.bind(any_port);				//2
    s.connect("55.66.77.88:5959");	//3
    s.send("hello");				//4
    s.close();						//5
}

첫번째 줄은 TCP 소켓 s를 생성한다.

두번째 줄은 65535개의 포트 중에서 사용 가능한 빈 포트를 차지한다.
만약 빈 포트가 없을 경우 다른 곳에서 점유한 포트를 공유한다.

세번째 줄은 상대방과의 TCP통신을 시도한다.
이는 블로킹이 발생하는데, 상대방이 연락을 수락하거나, 거절하거나, 존재하지 않는 경우 리턴을 한다.

네번째 줄은 데이터를 전송한다.
자기 컴퓨터의 운영체제에서 상대방 컴퓨터로 데이터를 전송하는 처리가 완료되면 리턴한다.
하지만 위의 코드는 상대방이 수신하기 전에 즉시 리턴한다.

다섯번째 줄은 TCP연결을 해제한다.

블로킹과 소켓 버퍼

송신 버퍼

일련의 바이트 배열, 크기는 고정되어 있으나 마음대로 변경할 수 있다.
FIFO형태로 작동하며, send를 호출하면 일단 송신 버퍼에 채워진다.
송신 버퍼는 queue처럼 작동하며, send()함수에 들어간 내용을 들어온 순서대로 보낸다.
만약 송신 버퍼가 가득 차면 블로킹이 발생하지만, 그 외의 경우에는 블로킹이 발생하지 않는다.

네트워크 연결받기 및 수신

main()
{
	s=socket(TCP);			//1
    s.bind(5959);			//2
    s.listen();				//3
    s2=s.accept();			//4
    print(getpeeraddr(s2));	//5
    while(true)
    {
    	r=s2.recv();		//6
        if(r.length<=0)		//7
        	break;
        print(r);
    }
    s2.close();				//8
}
  1. TCP소켓을 생성한다.
  2. 5959번 포트를 점유한다. 하지만 5959번이 점유중인 경우 실패한다.
  3. TCP연결을 받기 시작한다. 이는 바로 리턴되기 때문에 블로킹이 걸리지 않는다.
  4. TCP연결이 들어올 때 까지 기다린다. 상대방이 연결하면 리턴한다. 리스닝 소켓은 연결을 수락하는 역할만 한다. 리턴하면서 새로운 소켓의 핸들을 반환한다.
  5. 상대방의 주소를 출력할 수 있다.
  6. 새로운 소켓에서 데이터 통신을 한다. recv()는 수신된 데이터를 리턴하지만, 수신할 수 있는 데이터가 없으면 블로킹이 일어난다.
  7. 받은 데이터 크기가 0바이트라면 상대방이 tcp연결을 끊었다는 뜻이다.
    연결 끊어짐 등 오류가 발생하면 recv()는 음수를 반환한다.
  8. 소켓의 연결이 끊어졌으므로 소켓을 닫는다.

수신 버퍼

데이터가 수신되는 것이 있을 때 마다 채운다.
꽉 차면 더 이상 데이터를 받지 않는다.
소켓에서 데이터를 수신하는 함수를 호출하면 수신 버퍼에 있는 데이터를 꺼내올 수 있다.
수신 버퍼가 비어 있으면 데이터를 수신하는 함수는 블로킹이 일어난다.

수신 버퍼가 가득 차면 발생하는 현상

수신 함수인 recv()는 1바이트라도 수신할 수 있으면 즉시 리턴한다.

수신 버퍼에서 데이터를 꺼내는 속도가 수신 버퍼의 데이터를 채우는 속도보다 느리다면?

TCP

수신 버퍼에 남은 공간이 없을때 까지 완전히 채워진다. 수신 버퍼가 꽉 차면, 데이터를 보내는 쪽에서 송신 함수인 send()가 블로킹 된다.
TCP연결은 유지된 채 TCP통신은 멈춘다.

UDP

데이터그램이 최소 1개 도착해 있으면 즉시 리턴한다.

UDP의 데이터그램이 수신하는 측에 도착하지만, 수신 버퍼 안에 데이터그램을 담을 공간이 없으면 데이터는 버려진다.
송신함수의 블로킹이 발생하지 않는다.

라우터에서 초당 처리할 수 있는 패킷양을 넘어 패킷이 라우터에 도착한다면?

TCP

송신자가 보내는 데이터양이 많을 때, 송신자 쪽 운영체제가 알아서 초당 송신량을 줄인다.

UDP

UDP를 속도 제한 없이 마구 송신하면 해당 데이터를 처리하느라 이외의 곳에서 들어오는 데이터를 처리하지 못할 수 있다. 다른 네트워크들은 네트워크 경쟁에서 밀리며, 이 때문에 주변의 네트워킹이 두절되기도 한다. 이는 혼잡현상이라 한다.

논블록 소켓

블로킹 소켓이란 지금까지 해 온 UDP, TCP를 의미한다.

만약 네트워킹을 해야 하는 대상이 여럿이라면?

네트워킹 대상 개수만큼 스레드를 만들어 통신하는 방법이 있다.
네트워킹 대상이 많지 않으면 큰 문제는 없지만, 스레드가 1000개면, 적어도 1기가바이트가 필요하다. 또한, 스레드 간의 컨텍스트 스위치가 빈번히 일어나므로 자원 낭비가 발생한다.

소켓 송신 함수에서 계속해서 송신을 하는데 수신하는 쪽에서 송신 속도를 따라가지 못한다면 송신 쪽 소켓 버퍼가 가득 찰 것이다.송신 버퍼에 빈 공간이 없으면 TCP통신은 블로킹이 발생한다.

하지만 대부분의 운영체제에서는 소켓 함수가 블로킹되지 않게 하는 API를 제공한다. 이를 논블록 소켓이라 한다.
사용하는 방법은

  1. 소켓을 논블록 소켓으로 모드를 전환한다.
  2. 논블록 소켓에 대해 평소처럼 송신, 수신, 연결과 관련된 함수를 호출한다.
  3. 논블록 소켓은 해당 함수에 항상 리턴하고, 리턴 값은 '성공'혹은 'would block'오류 둘 중 하나이다.

would block은 "블로킹 걸렸어야 할 상황인데 말이지...하지만 자네는 운이 좋아. 블로킹에 걸리지 않았잖아?"라는 의미이다.

논블록 send()

void NonBlockSocketOperation()
{
	s=socket(TCP);
    ...;
    s.connect(...);
    s.SetNonBlocking(true);//논블록 방식으로 변경
    while(true)
    {
    	r=s.send(dest, data);	//->1
        if(r==EWUOLDBLOCK)
        {
        	continue;
        }
        
        if(r==OK)
        {
        	//보내기 성공에 대한 처리
        }
        else
        {
        	//보내기 실패에 대한 처리
        }
    }
}
  1. 블로킹 걸릴 상황이었지만 걸리지 않았다. 이는 아무 것도 하지 않았음을 의미하므로 나중에 다시 송신 함수를 호출해야 한다.

논블록 recv()

논블록 소켓을 이용하면 한 스레드에서 여러 소켓을 한꺼번에 다룰 수 있다.
루프를 돌면서 소켓 100개에 대한 수신 함수를 한다고 가정할 때, 모든 소켓이 수신할 데이터를 받아놓은 상태는 아니다. 따라서 논블록 소켓으로 처리하면 블로킹이 난무하는 문제가 사라진다.

논블록 소켓이 수신할 데이터가 있으면 데이터를 꺼내서 처리한다. 하지만 만약 수신할 데이터가 없다면 would block오류를 리턴할 뿐, 즉시 리턴한다. 따라서 많은 수의 소켓을 지연 시간 없이 처리할 수 있다.

논블록 connect()

하지만 connect()함수를 논블로킹으로 실행되었을때 would block이 리턴된다면 0바이트 송신으로 현재 상태를 확인해볼 수 있다.

  1. 0바이트 송신이 성공하면 TCP연결이 되었다는 의미
  2. ENOTCONN을 리턴하면 TCP연결이 진행 중임.
  3. 기타 코드가 나오면 TCP연결 시도가 실패한 것.

하지만 while(true)문으로 이를 계속 테스트한다면, 블로킹 소켓에서는 블로킹이 걸려 CPU사용량을 높이지 않지만, 논블록 소켓은 CPU사용량을 100%로 만든다.
게임 클라이언트에게는 큰 문제가 없지만 서버에서는 그렇지 않다.

select()혹은 poll()

select(sockers, 100ms);

select는 socket에 I/O처리가 가능한 소켓이 하나라도 있을 경우 즉시 리턴한다.
그렇지 않으면 100ms동안 블로킹 한다.

I/O처리가 가능하다는 것은 해당 소켓에 소켓 함수를 호출하면 would block이 아닌 다른 결과가 나온다는 것을 의미한다.

논블록 accept

블로킹 모드의 경우 리스닝 소켓에 대해 accept()를 호출하면 블로킹이 걸린다.
TCP연결이 들어오면 리턴을 하는데, accept()의 리턴 값은 TCP연결에 대한 소켓 핸들이다.

논블록 소켓의 경우 TCP연결이 아직 들어오지 않았으면 accept()는 블로킹 대신 would block오류를 준다.
따라서 select()를 이용하여 I/O가 감지되면 accept()함수를 호출하면 된다.

논블록의 장점

  • 스레드 블로킹이 없으므로 중도 취소 같은 통제가 가능하다.
  • 스레드 개수가 1개이거나 적어도 소켓을 여러 개 다룰 수 있다.
  • 스레드 개수가 적거나 1개이므로 연산량이 낭비되지 않는다. 호출 스택 메모리도 낭비되지 않는다.

논블록의 단점

  • 소켓I/O함수가 리턴한 코드가 would block인 경우 재시도 호출 낭비가 발생한다.
  • 소켓I/O함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산이 발생한다.
  • connect() 함수는 재시도 호출을 하지 않지만, send()함수나 receive()함수는 재시도 호출해야 하는 API가 일관되지 않는다.

CPU낭비

TCP소켓에서 송신 버퍼에 1바이트라도 비어 있으면 I/O가능이 된다. 이러한 상태에서 send()함수를 호출하면 1바이트만 송신 버퍼에 채워지고, 성공적으로 리턴한다.
receive() 역시 1바이트만 들어 있어도 수신 버퍼에 있는 것을 꺼내 오고 성공적으로 리턴한다.

하지만 UDP에서는 UDP소켓에 1바이트라도 비어 있으면 I/O가능이지만, 보내려는 데이터가 5바이트라면, UDP는 일부분만 보낼 수 없으므로 would block이 발생한다. 여전히 UDP송신 버퍼에는 1바이트의 공간이 남아 있어 I/O가능이지만 UDP send()를 하지 못한 채 헛발질만 계속한다.

소켓 함수 내부의 데이터 복사 부하

소켓 송수신 함수에 들어가는 데이터 블록 인자를 성공적으로 실행하면 프로세스 내 데이터 블록을 운영체제 커널 내 소켓 버퍼에 복사한다. 따라서 고성능의 서버를 개발할 때는 이러한 복사 연산도 중요하다.
이를 해결하는 방법은 Overlapped 또는 Asynchronous I/O를 이용하는 방법이다.

Overlapped I/O

  1. 소켓에 대해 Overlapped 액세스를 건다.
  2. Overlapped 액세스가 성공했는지 확인한 후 성공했으면 결과값을 얻어 와서 나머지를 처리한다.

Overlapped I/O함수는 즉시 리턴되지만, 운영체제로 해당 I/O실행이 별도로 동시간대에 진행되는 상태다.
따라서 호출한 Overlapped I/O함수가 비동기로 하는 일이 완료될때 까지는 소켓 API에 인자로 넘긴 데이터 블록을 제거하거나 내용을 변경해서는 안된다.

Overlapped I/O는 윈도우 전용으로 작동하는 함수로, 리눅스나 다른 운영체제에서는 찾기 힘들다.

epoll

소켓이 I/O가능 상태가 되면 이를 감지해서 사용자에게 알림을 해 주는 역할.
논블록 소켓을 대량으로 가지고 있을 때 효율적으로 처리해 주는 API

IOCP(I/O Completion Port)

소켓의 Overlapped I/O가 완료되면 이를 감지해서 사용자에게 알려 주는 역할
사용자는 IOCP에서 I/O가 완료되었음을 알려 주는 신호를 꺼낼 수 있음.
소켓 수가 많더라도 이 중에서 I/O가 완료된 것들만 IOCP를 이용해서 바로 얻을 수 있다.

profile
코린이

0개의 댓글