티스토리 뷰
필자는 항상 웹서버만 개발해오다가 이번에 게임서버를 만드는 프로젝트를 맡아서 개발중인데, Socket 에 대해 항상 어려운 부분이 많고 Netty가 이미 로우 레벨의 네트워크를 잘 구현해놓았기 때문에 로우 레벨에서 어떻게 작동하는지 알기 어려웠다. 그래서 이 글을 통해서 소켓에 대해 이해하기 어려운 부분을 간략하게 설명하고 이해해 볼려고 한다.
소켓 프로그래밍
- 네트워크 프로그래밍 에서 가장 많이 사용하는 것이
소켓
이다. 기본적으로파일 핸들
과 비슷하다. - 우리는 디스크에 데이터를 기록하거나 책을 읽어 들일 때
파일 핸들
을 사용한다. 이처럼 네트워크로 데이터를 전송하거나 받을 때소켓 핸들
을 사용한다.
소켓 핸들 방식
온라인 게임 프로그래밍에서 소켓은 파일 핸들
방식과 다르다.
- 게임 서버에서는 다루어야 하는 소켓 개수가 많다. TCP를 이용해서 통신하는 경우 클라이언트 개수만큼 소켓이 있어야한다.
- 파일 핸들을 하는 동안 쓰레드가 대기하는 일이 없어야 한다. 소켓을 이용해서 읽기/쓰기 를 하는 함수를 호출 했는데 즉시 리턴하지 않으면 이들을 호출한 메인 스레드는 사용자 입장에서 일시정지 하는 것 처럼 보일 수 있다.
- 위와 같은 이유 때문에 소켓은 보통
비동기 입출력(async I/O)
상태로 다룬다. - 소켓을 비동기 입출력으로 다루는 방식은
논블로킹 소켓
과Overlapped I/O
방식이 있다. - 위 두 방식을 진보시킨
epoll
과I/O Completion Port(IOCP)
방식이 많이 활용된다.
epoll
-> select의 단점을 보완하여 리눅스 환경에서 사용할 수 있도록 만든 I/O 이다.전체 파일 디스크립터에 대한 반복문을 사용하지 않고 커널에게 정보를 요청하는 함수를 호출 할 때 마다 전체 관찰 대상에 대한 정보를 넘기지도 않는다.
블로킹 소켓
디바이스에 처리 요청을 걸어 놓고 응답을 대기하는 함수를 호출 할 때 스레드
에서 발생하기는 대기현상
을 블로킹
이라 한다.
파일 핸들에 대한 대기 현상도 동일하게 블로킹
이라 한다.
소켓이나 파일 핸들에 대한 함수를 호출한 후 블로킹이 발생하는 스레드 에서는 CPU 연산을 하지 않는다.
즉 CPU 사용량이 0% 가 된다. 쓰레드는 waitable state 인 상태이다.
- 스레드가 waitable 상태일 동안 파일이나 소켓의 실제 처리는
디바이스
에서 한다. - 파일에 기록을 하는 함수가 호출했다면 기록하려는 데이터가 디스크에 완전히 기록될 때 까지 waitable(대기) 상태를 유지한다.
- 파일에서 읽기를 하는 함수를 호출했다면 디스크 읽기가 완전히 끝날 때 까지 waitable 상태를 유지한다.
- 파일 읽기 쓰기가 완전히 끝나면 waitable 상태는 다시 running 상태로 바뀌고 함수는 리턴하며 다음을 실행한다.
소켓
도 마찬가지 이다. 스레드에서 네트워크 수신을 하는 함수를 호출하면 수신할 수 있는 데이터가 생길 때 까지 대기
상태 즉 블로킹
이 발생한다.
데이터를 수신할 함수를 호출했으나 상대방 컴퓨터에 아무런 데이터를 보내지 않으면 영원히 블로킹이 발생한다.
네트워크 연결 및 송신
TCP는 연결 지향형 프로토콜 이다. 1:1 통신만 허락하며 TCP소켓 1개는 오직 EndPoint 1개하고만 통신한다.
s = socket(TCP)
s.bind(port)
s.connect("192.168.1.1:12055")
s.send("hello")
s.close();
- 위 코드는 먼저 TCP 소켓 핸들을 생성한다.
- 자신의 소켓 포트를 바인딩한다.
- 연결할 상대방의 Endpoint 를 TCP연결을 시도한다.
- send()는 상대 EndPoint 로 데이터를 전송한다.
- close()는 소켓을 닫는다. TCP소켓도 닫으면서 연결도 해제된다.
블로킹과 소켓 버퍼
소켓은 각각 송신버퍼(send buffer)
와 수신버퍼(receive buffer)
를 하나씩 가지고 있다.
송신 버퍼는 일련의 바이트 배열
이라고 보면 된다. 송신 버퍼의 크기는 고정되어 있으나 마음대로 크기를 변경할 수 있다.
송신 버퍼는 큐와 마찬가지로 FIFO(First-in First Out, 선입선출) 형태로 작동한다.
- send()를 호출하면 데이터는 송신 버퍼에 채워진다.
- 통신 선로를 통해 빠져나간다. 큐와 동일하다.
- send(DEFG)를 호출하면
- 만약 send(HI)를 호출하며 이 함수는 즉시 리턴했다. 그런데 소켓 버퍼가 가득찬 상태가 되었으면 아래와 같은 그림이 나온다.
네트워크 연결 받기 및 수신
s = socket(TCP);
s.bind(5959);
s.listen();
s2 = s.accept();
print(getpeeraddr(s2));
while (true)
{
r = s2.recv();
if (r.length <= 0)
break;
print(r);
}
s2.close();
- TCP 소켓을 생선한다.
- 5959 포트를 바인딩 한다.
- TCP 연결을 받는 역할을 시작하여 리스닝 소켓이 된다.
- TCP 연결이 들어올 때 까지 기다린다. 상대방 컴퓨터가 해당 5959포트로 TCP 연결을 하면 리턴한다. 리턴하면서 새로운 TCP소켓의 핸들을 준다. 새로운 TCP 소켓은 5959포트 이외에 다른 포트를 사용한다. 이 소켓은 연결을 수락하는 역할만 한다.
- accept 함수에서 받은 새로운 소켓 핸들을 이용해 통신한다.
- 새로운 소켓에서 데이터를 수신한다. recv()는 수신된 데이터를 리턴한다.
- 소켓에서 연결 돌발 끊어짐 등 오류 발생하면 recv()는 음수를 리턴한다.
수신 버퍼
송신 버퍼와 비슷하다. 단지 작동 순서가 거꾸로 된점을 제외하면
송신 버퍼
는 사용자가 push()를 하고 운영체제가 pop()을 한다.
수신 버퍼
는 운영체제가 push()를 하고 사용자가 pop()을 한다.
- 수신 버퍼 안에는 데이터가 수신되는 것이 있을 때 마다 채워준다. 즉 방치하면 꽉 차게된다.
- 수신 버퍼가 완전히 비어 있으면 데이터를 수신하는 함수는 블로킹이 일어 난다.
수신 버퍼가 가득차면 발생하는 현상
TCP
TCP 수신 함수인 recv()는 1바이트 라도 수신할 수 있으면 즉시 리턴한다. 이외에는 1바이트 라도 채워질 때 까지 블로킹한다.
반대로 수신 함수가 수신 버퍼에서 데이터를 꺼내는 속도가 운영체제가 수신 버퍼의 데이터를 채우는 속도보다 느리면 어떻게 되는지 보자.
- TCP는 수신 버퍼에 남은 공간이 하나도 없을 때 까지 완전히 채워진다.
- 수신 버퍼가 꽉 차면 TCP로 데이터를 보내는 쪽에서 송신 함수인 send()가 블로킹 된다.
- 극단적으로 이 상태에서 TCP recv()를 전혀하지 않으면 send()도 계속 블로킹 상태를 유지한다. 이상태면 통신은 전혀없으며 TCP 연결만 살아있다.
정리하면, TCP 송신 함수로 송신 버퍼에 데이터를 쌓는 속도보다 수신 함수로 수신 버퍼에서 데이터를 꺼내는 속도가 느리다고 해서 TCP 연결은 끊어지지 않는다.
단지 실제 송신 속도가 느린 쪽에 맞추어 동작할 뿐이다.
UDP
UDP 송신 함수로 송신 버퍼에 데이터를 쌓는 속도 보다 수신 함수로 수신 버퍼에서 데이터를 꺼내는 속도가 느리면,
데이터그램 유실이 발생한다. 결국 받는 쪽 에서는 일부 데이터그램을 놓치는 결과를 초래한다.
TCP는 송신자가 초당 보내는 데이터양이 수신자가 초당 수신할 수 있는 데이터 양 보다 많을 때, 송신자 측 운영체제가 알아서
송신량을 줄인다.
따라서 송신자와 수신자 사이의 다른 네트워킹 경쟁에서 밀리지 않는다.
UDP에는 이러한 제어 기능이 없다. 따라서 UDP를 속도 제한 없이 마구 송신하면 주변의 네트워킹이 경쟁에서 밀린다.
이 때문에 주변의 네트워킹이 두절되기도 한다.
이러한 형상을혼잡 현상
이라고 한다.
논블록 소켓
네트워킹 대상이 많아 지고 스레드 수가 늘어나면 각 스레드가 데이터 송수신 처리를 하려면 컨텍스트 스위치가 대량 발생하고 이는 자원낭비로 이어진다.
소켓 송신 버퍼에 빈 공간이 없으면 블로킹이 발생하며, 조금이라도 빈 공간이 생기면 블로킹이 끝나고 소켓함수는 리턴한다.
논 블록 소켓을 사용하는 방법은
- 소켓을 논블록 소켓 모드로 전환
- 평소처럼 송신, 수신 연결과 관련된 함수를 호출
- 논블록 소켓은 무조건 이 함수 호출에 대해 즉시 리턴, 리턴값은
성공
혹은would block
둘중 하나이다.
would block
이란 블로킹 걸렸어야 할 상황인데 블로킹이 안걸렸어 라는 의미이다.
논블록 소켓을 사용하는 코드로 변경
void NonBlockSocketOperation()
{
s = socket(TCP);
...;
s.connect(...);
// 논블록 소켓으로 변경
s.SetNonBlocking(true);
while (true)
{
// ➊
r = s.send(dest, data);
if (r = = EWOULDBLOCK)
{
// 블로킹 걸릴 상황이었다. 송신을 안 했다.
continue;
}
if (r = = OK)
{
// 보내기 성공에 대한 처리
}
else
{
// 보내기 실패에 대한 처리
}
// ➋
}
}
논블록 소켓으로 블로킹이 난무하는 문제를 처리하는 코드
List<Socket> sockets;
void NonBlockSocketOperation()
{
foreach(s in sockets) // 각 소켓에 대해
{
// 논블록 수신. 오류 코드와 수신된 데이터를 받는다.
(result, data) = s.receive();
if (data.length > 0) // 잘 수신했으면
{
print(data); // 출력
}
else if (result != EWOULDBLOCK)
{
// would block이 아니면 오류가 난 것이므로
// 필요한 처리를 한다.
...;
}
}
}
- 루프를 돌면서 논블록을 수신한다. 수신 데이터가 있으면 꺼내서 처리한다.
- 수신 데이터가 없으면 would block 코드를 리턴할 뿐 수신 함수는 즉시 리턴한다.
TCP 접속 역할을 하는 connect() 함수를 논블로킹으로 쓸때는
만약 논블로킹 연결 함수가 would block을 리턴한 후에는 would block이 끝났는지 알고자 다른 방법을 사용하는 것이 좋다.
0바이트 송신
이라는 요령이 있다.- TCP는 스트림 기반 프로토콜 이기 때문에 0바이트를 보내는 것은 사실상 아무것도 하지 않는 것이다.
- 즉 0바이트를 보내려는 시도를 하면 TCP소켓이 현재 어떤 상태인지 알수 있따.
- 0바이트 송신 함수가
성공
을 리턴하면 연결 되었다는 의미 - ENOTCONN을 리턴하면 TCP 연결이 진행중 이라는 것.
- 기타 오류 코드가 나오면 연결 시도가 실패
- 0바이트 송신 함수가
void NonBlockSocketOperation()
{
result = s.connect();
if (result = = EWOULDBLOCK)
{
while (true)
{
byte emptyData[0]; // 길이 0인 배열
result = s.send(emptyData);
if (result = = OK)
{
// 연결 성공 처리
}
else if (result = = ENOTCONN)
{
// 연결이 아직 진행 중이다.
}
else
{
// 연결 실패 처리
}
}
}
}
epoll
epoll 은 소켓이 I/O 가능 상태가 되면 이를 감지해서 사용자에게 알림을 해주는 역할을 한다.
리눅스에서 사용
- 위 소켓 123 중 I/O 가능이 되는 순간 epoll 은 이 상황을 epoll안에 내장된 큐에 푸시한다.
- epoll에서 이러한 이벤트 정보를 pop 할 수 있다.
- 즉 소켓이 몇만개 이더라도 이중에 I/O 가능이 된 것들만 epoll을 이용해서 얻을 수 있다.
코드로 알아보자
epoll = new epoll(); // ➊
foreach(s in sockets)
{
epoll.add(s, GetUserPtr(s)); // ➋
}
events = epoll.wait(100ms); // ➌
foreach(event in events) // ➍
{
s = event.socket; // ➎
// 위 epoll.add에 들어갔던 값을 얻는다.
userPtr = event.userPtr;
// 수신? 송신?
type = event.type;
if (type = = ReceiveEvent)
{
(result, data) = s.recv();
if (data.length > 0)
{
// 수신된 데이터를 처리한다.
Process(userPtr, s, data);
}
}
}
- epoll 객체를 만든다.
- 여러 소켓을 epoll에 추가한다. 추가된 소켓은 I/O 가능 이벤트는 epoll로 감지 할 수 있다.
- 모든 소켓에 대한 select() 대신 epoll에서 이벤트를 꺼내오는 함수를 호출한다. 모든 소켓에 대한 epoll에서 이벤트를 꺼내온다.
- 이벤트가 가리키는 소켓 객체와 데이터를 꺼내온다.
- 처리한다.
만약 이렇게 select()를 썻다면 모든 소켓에서 루프를 돌아야 한다. 하지만 epoll을 쓰면 I/O 가능인 상태의 소켓에서만 루프를 돌면 된다.
현실에서는 소켓 송신 버퍼가 빈 공간이 없는 순간을 유지하는 시간이 상대적으로 짧아 거의 대부분은 송신 가능이다.
이러한 특징 때문에 지금까지 설명한 방식으로 만들면 필요 이상의 루프를 돌아야 해서 불필요한 CPU 연산 낭비가 일어난다.
이 문제를 해결하려면 레벨 트리거
대신 에지 트리거
를 써야한다.
레벨 트리거 는 소켓이 I/O 가능하다 를 의미한다.
에지 트리거 는 I/O 가능이 아니었다가 가능으로 변하는 순간에만 꺼내어 진다.
에지트리거를 사용할 때 조심해야 할 점이 있다.
예를 들어 epoll 에서 소켓 S1 에 대한 에지 트리거 이벤트를 꺼냇다고 가정하자. 그리고 이벤트 종류가 receive라고 한다.
receive()를 실행후 데이터를 꺼낸다. 근데 S1은 UDP 소켓이고 이미 데이터그램이 2개가 있다. receive()로 꺼낸 데이터그램은 1개이므로 아직 1개가 남아있다.
S1은 아까도 수신 가능이었지만 receive()함수를 호출한 후에도 여전히 수신가능이다. 즉 I/O 가능에 변화가 있는 것은 아니다.
이때 epoll은 S1의 이제 트리거만 인식하도록 해놓았다면, epoll은 아무것도 알려주지 않고 남은 데이터그램 1개는 영원히 꺼내지 못한다.
- 위와 같은 상황으로 에지 트리거를 쓸때는 다음 사항을 주의하자.
- I/O 호출을 한 번만 하지 말고 would block 이 발생할 때 까지 반복한다.
- 소켓은 논블록 으로 설정되어 있어야 한다.
- epoll은 connect() 와 accept()에서도 I/O 가능 이벤트를 받을 수 있다. connect()는 send 이벤트와 동일하게, 그리고 accept()는 receive 이벤트와 같이 취급된다.
- 따라서 리스닝 소켓에 대한 receive 이벤트를 받을때는 accept()를 호출하면 새 TCP 연결의 소켓을 얻을 수 있다.
... foreach(event in events) // ➍ { s = event.socket; // ➎ // 위 epoll.add에 들어갔던 값을 얻는다. userPtr = event.userPtr; // 수신? 송신? type = event.type; if (type = = ReceiveEvent) { if (s가 리스닝 소켓이면) { s2 = s.accept(); } else { s.recv(); } } }
- 따라서 리스닝 소켓에 대한 receive 이벤트를 받을때는 accept()를 호출하면 새 TCP 연결의 소켓을 얻을 수 있다.
IOCP
epoll은 논블록 소켓
을 대량으로 갖고 있을 때 효율적으로 처리해주는 API이다.
IOCP는 Overlapped I/O를 다루는 운영체제
에서 대응한 것이다.
- IOCP는 소켓의 Overlapped I/O 가 완료되면 이를 감지해서 사용자에게 알려주는 역할을 한다.
- 위 그림을 보면 소켓 2가 완료되는 순간 IOCP는 내장된 큐에 푸시한다.
- 그리고 사용자는 ICOP에서 I/O가 완료되었음을 알려주는
완료신호(completion even)
를 꺼낼수 있다. - 소켓 개수가 1만개라 하더라도 이중에 I/O가 완료된 것들만 IOCP를 이용해서 바로 얻을수 있기 때문에 모든 소켓에서 루프를 돌지 않아도 된다.
Overlapped I/O와 마찬가지로 ICOP도 윈도에서만 사용가능.
iocp = new iocp(); // ➊ IOCP 객체를 만든다.
foreach(s in sockets)
{
iocp.add(s, GetUserPtr(s)); // ➋ 소켓에 대응하는 원하는 정수 값을 IOCP에 추가
s.OverlappedReceive(data[s],
receiveOverlapped[s]); // ➏
}
events = iocp.wait(100ms); // ➌ 모든 Overlapped status 객체에 대한 GetOverlappedResult 대신 IOCP에서 완료 신호를
꺼내오는 함수를 호출. 원하는 시간까지 블로킹, 그 전에 이벤트가 생기면 즉시 리턴
foreach(event in events) // ➍
{
// ➎
// 위 iocp.add에 들어갔던 값을 얻는다.
userPtr = event.userPtr;
ov = event.overlappedPtr;
s = GetSocketFromUserPtr(userPtr);
if (ov = = receiveOverlapped[s])
{
// overlapped receive가 성공했으니,
// 받은 데이터를 처리한다.
Process(s, userPtr, data[s]);
s.OverlappedReceive(data[s],
receiveOverlapped[s]); // ➏
}
}
- 위에서 본
epoll
가 비슷하다. 차이점이 있다면epoll은 I/O 가능
IOCP는 I/O완료 된 것을 알려준다
IOCP의 복잡한 기능 중 하나가 Accept 처리 이다.
- IOCP에 listen socket L을 추가 했다면 L 에서 TCP연결을 받을 경우 이에 대한 완료 신호가 IOCP에 추가된다.
- 단 사전에 이미 AcceptEx로 Overlapped I/O를 건 상태여야 한다.
- L에 대한 이벤트를 얻어왔지만, 앞서 Overlapped accept 처럼 SO_UPDATE_ACCEPT_CONTEXT와 관련된 처리를 해 주어야 새 TCP소켓 핸들을 얻어올 수 있다.
epoll vs IOCP
IOCP는 epoll보다 스레드 풀을 쉽게 구현할 수 있다.
- epoll은 I/O여부와 상관없이 I/O가능 이벤트가 온다.
- 그런데 한 epoll에 대해 여러 스레드가 동시에 이벤트 발생을 기다리면 어떻게 될까?
- epoll가 연동된 소켓 하나가 UDP 데이터 2개를 수신 큐에 가지고 있다고 가정하면
- epoll 이벤트는 같은 소켓에 대해 두 스레드에서 동시에 꺼내진다.
- 그러면 각 스레드는 같은 소켓에 대해
UDP 논블록 수신을
하며이때 데이터 순서는 알기가 어려워진다
- 순서를 안다고 해도 두 스레드가 동시에 같은 일을 하므로 어느것을 먼저 처리할지 교통정리 로직이 필요하다.
- 그런데 한 epoll에 대해 여러 스레드가 동시에 이벤트 발생을 기다리면 어떻게 될까?
- IOCP는 위 같은 문제 해결 가능
- 위 그림과 같이 어떤 소켓에 대해 Overlapped I/O를 하지 않는 이상 그 소켓에 대한 완료 신호는 발생하지 않는다.
- 즉 소켓 하나에 대한 완료 신호를 스레드 하나만 처리할 수 있게 보장된다.
- 이러한 특징 덕분에 IOCP 하나를 여러 스레드가 기다리도록 구현하기 쉽다.
- 많은 소켓에 대한 I/O 처리를 동시다발적으로 수행시, 여러 스레드가 완료 신호 처리를 골고루 나누어 처리가능하다.
epoll에서 스레드 풀링 구현
- 스레드 개수만큼 epoll 객체를 둔다.
- 각 스레드는 자기만의 epoll을 처리한다.
여러 스레드가 있찌만, 한 소켓의 이벤트는 지정된 한 스레드에서만 발생한다.
소켓들이 여러 스레드 중 하나에 배정되기 때문에 아예 스레드 풀링이 없는 것 보다는 낫다.
IOCP 와 epoll의 장단점
epoll을 쓰는 리눅스에서는 TCP 소켓으로 수신을 한 후에 데이터 수신을 하려면 소켓 수신함수(recv)를 이어서 호출해 주어야 한다.
IOCP는 윈도 서버에서 연결 받기와 수신을 소켓 함수 호출 한번으로 끝 낼 수 있다.
마무리
이렇게 Socket 에 대해 요약해서 정리하였고, 아마 많은 분들이 Netty를 사용할 텐데 Netty 도 내부적으로 epoll 방식을 사용하여 처리한다고 한다.
다음은 Netty에 대해 정리하고 내부 구현코드를 조금씩 뜯어보면서 정리해볼 예정이다.
- Total
- Today
- Yesterday
- 다운로드
- 논블로킹
- database
- oauth2
- spring
- java
- db
- GIS
- DispatcherServlet
- github
- 인덱스
- TCP
- Index
- 스프링
- jpa
- Excel
- spring boot
- mysql
- 공간쿼리
- thread
- R-Tree
- lock
- spring mvc
- 쓰레드
- Spring Security
- 영속성 컨텍스트
- 네트워크
- jenkins
- 데이터베이스
- 비동기
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |