본문 바로가기

CS/네트워크

[네트워크 개념 복습] L7에서의 버퍼(링버퍼)

개요 

https://basaeng.tistory.com/81

 

[네트워크 개념 복습] 소켓 알아보기

개요소켓은 L4계층의 데이터를 윗계층과 주고받을 수 있는 통로와 같습니다. L5부터는 커널이 아닌 유저모드의 영역이므로 이를 연결한다고도 볼 수 있습니다.https://basaeng.tistory.com/80 [네트워크

basaeng.tistory.com

이전에 알아보았듯이 L4와 애플리케이션 단이 소통하기 위해 사용하는 소켓은 L7에서 L4로 데이터를 전달할 때는 L4의 송신버퍼로 전달하고, L4에서 L7으로 데이터를 전달할 때는 L4의 수신버퍼에서 L7으로 데이터를 전달한다.

 

이 때 L7에서 일어날 수 있는 일에 대해 생각해보자.

 


L7에서의 L4로의 데이터 송신 시 상황

기본적으로 논블로킹 소켓을 사용해 통신한다고 가정하겠습니다.

send호출 시에는 만약 데이터를 일부분 넣을 수 있다면 그만큼만 넣고 반환하며, 만약 아예 넣을 수 없다면 WSAEWOULDBLOCK으로 상태를 확인할 수 있었습니다.

 

그렇다면 보내지 못한 데이터를 어떻게 할까요? 

데이터가 모두 들어가는 경우에만 넣겠다는 생각도 할 수 있지만, L4송신버퍼의 크기를 알 방법도 없고, 기본적으로 TCP는 스트림이기 때문에 바람직하지도 않습니다.

 

따라서 넣지 못한 데이터를 위해서 애플리케이션 단에서 넣지 못한 데이터만큼은 별도로 보관하여 가지고 있어야합니다.

 

이를 위해 L7에서도 별도로 송신버퍼를 만들 필요성이 생깁니다.

 

추가적으로 로직 상에서 네트워크로 보낼 데이터가 많다면 매번 send를 호출하지 않고, L7버퍼에 모았다가 한번에 send를 호출하는 것도 성능상에 도움이 됩니다.

send는 커널의 송신버퍼에 데이터를 넣는 것이기 때문에 system call을 통해 커널 모드 전환이 일어나기 때문입니다.


L7에서의 L4로의 데이터 수신 시 상황

recv호출 시에는 받을 수 있는 데이터가 1byte라도 있으면 값을 반환합니다. (아예 없다면 WSAEWOULDBLOCK)

그런데 TCP데이터는 stream이기 때문에 받은 데이터가 완성되어있음을 보장할 수 없습니다. 

 

다만 TCP의 신뢰성 덕분에 하나의 연결 사이에서는 데이터의 순서가 섞여서 반환될 걱정은 없습니다.

 

그렇다면 완성되지 못한 데이터를 보관해놓았다가 추후에 나머지 데이터를 받으면 이전의 데이터와 함께 처리해야할 것입니다.

 

결론적으로 이를 위해 L7에서 별도로 수신버퍼를 만들 필요성이 생깁니다.


선형 버퍼 대신 원형 버퍼(링버퍼)

가장 처음에 생각해볼 수 있는 것은 버퍼의 자료구조 형태입니다.

송수신 시에 항상 먼저 들어온 데이터를 보내고 먼저 들어온 데이터를 처리해야하기 때문에 버퍼는 FIFO구조를 가지는 Queue를 사용해야할 것입니다.

 

다만 queue 자료구조를 직접 사용한다는 것은 속도적으로도 말이 안되고, 조각난 패킷이 완성되었는지 확인하기 위해서 임의의 접근이 필요할 수도 있습니다. 

 

결론적으로 미리 만들어진 배열 형태에서 queue처럼 데이터를 사용하는 것이 가장 이상적이라고 볼 수 있습니다.

(이 때 배열의 크기는 수신 버퍼의 크기는 클라이언트가 보낼 패킷 중 가장 크기가 큰 패킷보다는 커야겠습니다.)

 

그런데 만약에 배열을 그대로 선형으로 사용한다면, 이번 루프에서 처리하지 못한 데이터는 맨앞으로 카피해서 가져와야 합니다.

그래야 다음 루프에서 다시 맨앞부터 작업할테니까요

 

루프마다 이러한 작업을 굳이 하지 않기 위해서는 원형버퍼(링버퍼)를 사용하면 됩니다. 

다만 front, rear를 관리하는 등의 약간의 수고는 있겠습니다.


링버퍼 간단하게 구현해보기

링버퍼를 구현하기 위해서 필요한 요소들에 대해서 생각해봅시다.

 

먼저 circular queue이므로 dequeue의 위치가될 front와 enqueue의 위치가 될 rear index가 필요할 것입니다.

추가적으로 버퍼의 전체크기를 나타내는 capacity 변수도 필요합니다.

 

free size와 use size

함수로는 기본적으로 링버퍼에서 사용가능한 크기와 사용하고 있는 크기를 알아야합니다.

이번에는 사용 가능한 크기를 free size, 사용 중인 크기를 use size라고 하겠습니다.

 

또한 중요한 점은 free size를 계산할 때 실제 사용가능한 크기에서 -1을 한 값으로 계산해야 한다는 점입니다.

이는 버퍼에서 하나의 공간을 남겨놓는다는 것인데 기본적으로는 front == rear인 상태가 full인지 empty인지 구분하기 어렵다는 것이 첫번째이고 만약 구분을 위해 새로 변수를 bool is_full과 같이 사용한다면, 멀티스레드 환경에서 동기화 해야하는 변수가 하나 더 생기기 때문에 지양해야 하는 것도 있습니다.

 

이 때 함수는 아래와 같이 표현할 수 있습니다. (RingBuffer라는 클래스를 만들고 그 아래 함수를 구현했습니다.)

int RingBuffer::GetFreeSize()
{
	if (_rear >= _front)
		return _capacity - (_rear - _front) - 1;
	else
		return _front - _rear - 1;
}

int RingBuffer::GetUseSize()
{
	if (_rear >= _front)
		return _rear - _front;
	else
		return _capacity - (_front - _rear);
}

 

rear >= front인 상황은 특정 데이터가 원형 경계에 없는 상황이고 else 인 경우는 있는 상황입니다.(wrap-around) (물론 비어있는 경우는 둘다 가능함)

 

 

 

direct enqueue size와 direct dequeue size

원형 버퍼이기 때문에 구현해야 할 사항이 하나 더 있습니다.

 

당연하게도 원형버퍼의 끝과 끝은 메모리가 연속적이지 않기 때문에 넣어야 할, 혹은 빼야 할 데이터가 배열의 끝에 걸쳐있다면 두번에 거쳐서 작업을 수행해야 합니다. 

 

이를 편하게 하기 위해 현재 rear부터 즉시 넣을 수 있는 크기 direct enqueue size와, rear부터 즉시 뺄 수 있는 크기 direct dequeue size를  (필수는 아니지만 구현이 편해집니다)

 

int RingBuffer::DirectEnqueueSize()
{
	if (_rear >= _front)
	{
		int right = _capacity - _rear;
		if (_front == 0)
			return right - 1;
		else
			return right;
	}
	else
	{
		return _front - _rear - 1;
	}
}

int RingBuffer::DirectDequeueSize()
{
	if (_rear >= _front)
	{
		return _rear - _front;
	}
	else
	{
		return _capacity - _front;
	}
}

 

enqueue size를 구할 때는 만약 front가 0이라면 모든 공간이 꽉 찰 수 있기 때문에 임의로 -1을 합니다.

 

MoveFront, MoveRear, GetFrontBufferPtr(), GetRearBufferPtr() 

enqueue, dequeue에서 사용할 다른 함수들입니다.

// read idx
int RingBuffer::MoveFront(int size)
{
	_front = (_front + size) % _capacity;
	return _front;
}

// write idx
int RingBuffer::MoveRear(int size)
{
	_rear = (_rear + size) % _capacity;
	return _rear;
}

char* RingBuffer::GetFrontBufferPtr()
{
	return _buffer + _front;
}

char* RingBuffer::GetRearBufferPtr()
{
	return _buffer + _rear;
}

 

enqueue와 dequeue

enqueue와 dequeue는 코드를 먼저 보고 설명해보겠습니다.

int RingBuffer::Enqueue(const char* data, int size)
{
	if (GetFreeSize() < size) return 0;

	int directEnqueueSize = DirectEnqueueSize();

	if (directEnqueueSize >= size)
	{
		memcpy_s(GetRearBufferPtr(), size, data, size);
	}
	else
	{
		memcpy_s(GetRearBufferPtr(), directEnqueueSize, data, directEnqueueSize);
		memcpy_s(_buffer, size - directEnqueueSize, data + directEnqueueSize, size - directEnqueueSize);
	}
	MoveRear(size);
	return size;

}

int RingBuffer::Dequeue(char* dest, int size)
{
	if (GetUseSize() < size) return 0;

	int directDequeueSize = DirectDequeueSize();

	if (directDequeueSize >= size)
	{
		memcpy_s(dest, size, GetFrontBufferPtr(), size);
	}
	else
	{
		memcpy_s(dest, directDequeueSize, GetFrontBufferPtr(), directDequeueSize);
		memcpy_s(dest + directDequeueSize, size - directDequeueSize, _buffer, size - directDequeueSize);
	}


	MoveFront(size);
	return size;
}

enqueue는 먼저 넣으려고 한 크기와 남은 크기를 비교합니다.

그리고 바로 넣을 수 있다면 한번에 넣고 아니라면 두번에 나눠 넣습니다.

그리고 rear를 움직이고 넣은 크기를 return합니다.

 

dequeue를 한다면, 뺄 수 있는 크기와 뺄 크기를 비교합니다.

그리고 바로 뺄 수 있다면 빼고 아니라면 두번에 나눠서 뺍니다.

그리고  front를 움직이고 뺀 크기를 return합니다.

 

사실 앞에 사이즈를 비교하는 것은 우리 로직에서는 딱히 의미가 없기도 한게, L4에서 데이터를 꺼낼 때 free size보다 많은 데이터를 꺼내지 않을 것이고, L4로 데이터를 보낼 때 L7의 버퍼의 use size보다 많은 데이터를 enqueue할일이 없게 코드를 만들 것이기 때문입니다.


전송 데이터 규약

완성된 데이터임을 확인하기 위해서는 데이터 자체에 사용자가 정의한 공통된 헤더가 필요할 것 입니다. 

 

가장 기본적인 헤더의 요소로는

1. 메세지 타입

2. 메세지 크기

가 있어야 데이터 처리가 가능합니다.

 

메세지의 크기를 알아야 온전하게 메세지가 도착했는지 알 수 있고

타입을 알아야 온전하게 도착한 메세지를 구별해 적절한 처리를 할 수 있을 것입니다.


추가적인 고려할 점

데이터가 완성되었을 때 데이터를 꺼내야하기 때문에, enqueue하기 전에 먼저 데이터가 완성되었는지 확인해야합니다.

 

위에서 언급했듯이 TCP이기 때문에 데이터는 순서대로 들어와 데이터가 섞일 일은 없고, 완성된 데이터는 다음 프레임으로 넘기지 않고 처리할 것이기 때문에 이번에 받은 데이터 중 마지막 데이터만 조각날 가능성이 있습니다.

 

완성된 데이터는 별도의 핸들러를 통해 처리하면 됩니다.

 

또한 데이터를 처리하고 만약 보내야할 데이터가 생긴다면 차곡차곡 송신버퍼에 쌓고 한꺼번에 보내 성능을 높여야 합니다.

다만 구현에 따라 이번 프레임에서 버퍼에 넣었지만 다음 버퍼에서 처리하게 될 수 있습니다.