소켓 프로그래밍 - 블로킹, 논 블로킹, 비동기, epoll, IOCP, IO Uring

1. 들어가며

 

게임을 만들며 게임 서버가 필요한 로직이 들어간다면 Photon, 프라우드넷 등 다양한 네트워크 엔진을 고려하게 됩니다. 모두 유용한 도구이고 특히 프라우드넷의 경우 마비노기 영웅전과 같은 대규모 온라인 게임에도 사용되었다는 점에서 검증은 충분히 된 편이라고 생각합니다. 저의 경우 Photon library를 과거 프로젝트에서 활용했었는데 사용하기 꽤 직관적인 편이었습니다.

 

하지만 보다 근본적으로 게임 서버 프로그래밍을 하고자 한다면 소켓 프로그래밍을 피할 수는 없습니다. 기존 웹 서버에서 사용하는 프레임워크와 서버를 얹어서 구축하기엔 실시간 게임에 사용하기에 성능이 현저히 부족할 수 밖에 없습니다.

또한 웹에서도 요즘 Spring Web flux 등 비동기 프레임워크를 사용하거나 gRPC, QUIC 등의 프로토콜을 이해하는데 소켓 프로그래밍 경험이 있으면 훨씬 수월합니다. 동기, 비동기, 블로킹, 논블로킹을 설명하는 많은 인터넷 예제들을 보면 현실을 예시로 많이 들고 있습니다. (특히 커피를 사서 대기줄을 서는가, 진동벨을 받는가가 유독 많은 것 같습니다..!)이해하기 수월하고 직관적이라고 생각합니다. 하지만 저는 실제 코드를 보면서 어떤 상황에서 블로킹이고 아닌지 이해하는 편이 더 좋다고 생각합니다. 프로그래머에게 실제 예시란 역시 코드라고 생각합니다 :)

 

오늘은 소켓 프로그래밍 의사 코드를 보면서 동기, 비동기, 블로킹, 논 블로킹 개념을 익히고 더불어 소켓 프로그래밍의 기초도 익혀보도록 하겠습니다.

 

우선 소켓의 개념에 대해서는 다음 블로그 글을 참조해주세요!

https://recipes4dev.tistory.com/153

 

소켓 프로그래밍. (Socket Programming)

1. 소켓(Socket) 만약 네트워크와 관련된 프로젝트를 진행하면서, 사용자(User)의 관점이 아닌, 개발자(Developer)의 관점에서 네트워크를 다뤄본 경험이 있다면, "소켓(Socket)"이라는 용어가 아주 낯설

recipes4dev.tistory.com

2. 블로킹 소켓

 

블로킹 소켓이란 말 그대로 I/O 작업이 일어나는 동안 waitable 상태를 유지하는 것을 말합니다. 예를 들어 파일에서 쓰기 함수를 호출했다면 그 쓰기가 완전히 끝날 때까지 (실제로는 RAM의 기록까지만) waitable 상태를 유지합니다. 그리고 그 쓰기가 끝나고 나서야 다음 명령어를 실행합니다. 즉 블로킹이 걸려 있는 동안 CPU 연산은 진행되지 않습니다.

 

조금 더 정확히 표현하면 블로킹 된 스레드에 대한 연산이 진행되지 않습니다. 해당 스레드는 sleep 상태로 가고 다른 스레드가 있다면 context switch 됩니다. 그리고 다른 스레드들이 cpu 자원을 활용할 것입니다.
blocking 상태에 대한 설명 (Linux)

 

 

 

소켓도 마찬가지로 수신할 수 있는 데이터가 생길 때까지 waitable 상태를 유지합니다. 데이터를 수신할 함수를 호출했으나, 상대방 컴퓨터에서 아무런 데이터를 보내지 않는다면 영원히 블로킹이 유지될 것입니다.

 

블로킹 I/O

 

 

2-1 실제 시나리오

 

-  송신

main() {

    s = socket(TCP);
    
    // 빈 포트가 없을 경우 이미 다른 곳에서 점유한 포트라고 하더라도 그것을 공유
    s.bind(any_port);
    
    // connect(): TCP 연결이 완료될 때까지 "블로킹"을 유지하다가
    // 상대방이 연결을 수락하면 함수는 리턴
    s.connect("55.66.77.88:5959");
    
    // OS에서 상대방 컴퓨터로 데이터를 전송하는 처리가 완료되면 리턴
    // 함수가 리턴했다고 상대방이 데이터를 수신했다는 말이 아님
    s.send("hello");
    
    s.close();
}

 

그럼 실제 소켓을 사용할 때 어떤 일이 일어나는지 살펴볼까요? socket을 생성하고 데이터를 보내는 send 함수를 호출하면 즉시 return 됩니다. 어라? 아까 분명 blocking 소켓이란 송신이 완료될 때가지 블로킹이 된다고 했는데 뭔가 이상하지 않나요?

이는 socket buffer의 존재 때문입니다. 소켓 각각은 송신 버퍼와 수신 버퍼를 가지고 있습니다. 이 버퍼는 바이트 배열 큐라고 보시면 됩니다. 그래서 여러분들이 socket을 통해 send(data)를 호출하면 일단 송신 버퍼에 채워집니다. 그리고 잠시 후 통신 선로를 통해 점차적으로 빠져나가게 됩니다. 따라서 송신 버퍼는 뭔가가 채워지더라도 곧 빈 상태가 됩니다.

그럼 블로킹은 언제 발생할까요? 이 송신 버퍼가 가득찼을 때 비로소 블로킹이 발생합니다. 보통 송신 버퍼는 수천 byte는 담을 수 있기 때문에 hello 정도로는 blocking이 걸릴 일이 없습니다.

 

- 수신

main() {
    s = socket(TCP);
    s.bind(5959);
    s.listen();
    // TCP 연결이 들어올 때까지 블로킹
    s2 = s.accept();
    while(true) {
    		// 수신할 수 있는 데이터가 없으면 블로킹 발생
            // 수신할 수 있는 데이터가 있을 때까지 블로킹 유지
        r = s2.recv();
        if(r.length <= 0)
            break;
        print(r);
    }
    s2.close();
}

 

수신의 경우 조금 다릅니다. 수신할 수 있는 데이터가 없으면 블로킹이 일어납니다. 즉 송신 버퍼의 경우 완전히 차면, 수신 버퍼의 경우 완전히 비어 있으면 블로킹이 일어나는 것입니다. 단 완전히 비어있다는 것과 수신된 데이터 크기가 0 바이트라는 것은 다릅니다. 수신된 데이터가 0이라는 것은 상대방이 연결을 끝냈음을 의미합니다.

 

그럼 수신 버퍼가 가득 차면 어떤 현상이 벌어질까요? 예를 들어 수신 함수가 수신 버퍼에서 데이터를 꺼내는 속도가 운영체제가 수신 버퍼의 데이터를 채우는 속도보다 느리면 어떻게 될까요?

 

TCP의 경우 연결이 계속 유지된 채로 송신 함수 측이 계속 블로킹됩니다. 즉 통신되는 것은 없고 연결만 계속 살아있게 됩니다.   이에 따라 TCP의 경우 보내는 데이터 양이 받을 수 있는 데이터 양보다 많을 경우 송신자 측 운영체제가 알아서 송신량을 줄입니다. 반면 UDP의 경우 데이터그램 유실이 발생하게 됩니다. UDP는 속도 제한 없이 송신해버리면 주변 네트워킹에서 경쟁이 밀려 두절되기까지도 합니다.

 

3. 논블로킹 소켓

논 블로킹 I/O

하지만 위의 블로킹 소켓엔 한계가 있습니다. 예를 들어 네트워킹 해야 하는 대상이 여러 개인 서버 입장에서 그 수가 아주 크다면 어떻게 할까요? 그만큼 스레드를 만들어 데이터를 주고 받으면 될까요? 스레드 당 호출 스택이 1MB 정도 되는데 한계가 있을 수 밖에 없습니다. 웹처럼 서버가 stateless하고 스케일 아웃이 비교적 자유롭다면 낫겠지만 성능적인 측면도 무시할 수 없습니다.  예를 들어 스레드 간 데이터 송수신 처리를 하다보면 빈번히 sleep이 일어날텐데 context switch에 의한 오버헤드가 생길 것입니다.

 

운영체제들은 보통 이를 위해 소켓 함수가 블로킹 되지 않도록 하는 API를 제공합니다. 이를 논블로킹 소켓이라고 합니다.

사용법은 다음과 같습니다.

 

1. 소켓을 논블롯 소켓으로 모드를 전환합니다.
2. 논블록 소켓에 대해 평소처럼 송신, 수신, 연결과 관련된 함수를 호출합니다.
3. 논블록 소켓은 무조건 이 함수 호출에 대해 즉시 리턴합니다. 리턴 값은 '성공' 혹은 'would block' 오류 둘 중에 하나입니다. (would block이란 블로킹 걸려야할 상황이지만 걸리지 않았다는 뜻입니다.)

 

 

 

우선 송신 측 코드입니다.

void NonBlockSocketOperation()
{
	s = socket(TCP);
	...;
	s.connect(...);
	// 논블록 소켓으로 변경
	s.SetNonBlocking(true);

	while (true)
	{
		r = s.send(dest, data);
		if (r == EWOULDBOCK)
		{
			// 블로킹 걸릴 상황이었다. 송신을 안 했다.
			continue;
		}

		if (r == OK)
		{
			// 보내기 성공에 대한 처리
		}
		else
		{
			// 보내기 실패에 대한 처리
		}
	}
}

 

위에서 wouldblock이 return 되는 상황은 말 그대로 "블로킹 될 상황이었는데 너에게 알려줄게!"라는 뜻입니다. 사실 블로킹 소켓을 떠올리보시면 " wouldblock"이 호출되는 상황은 아무것도 하지 않았음을 의미합니다. (송신, 상대 수신 버퍼가 가득찬 상황 등) 즉 송신 함수 호출을 나중에 다시 해줘야 합니다.

 

이번엔 수신 측 코드입니다. 논블록 소켓을 이용하면 한 스레드에서 여러 소켓을 한꺼번에 다룰 수 있습니다. 루프를 돌면서 소켓 여러개에 대해 수신 함수를 호출할 때 블로킹 소켓이라면? 수신할 데이터를 다 받아놓은 상태가 아닌 소켓에서 매번 블로킹이 난무하여 매우 비효율적일 것입니다. 하지만 논블로킹이라면 wouldblock을 즉시 return 하기에 효율적인 처리가 가능합니다.

List<Socket> sockets;

void NonBlockSocketOperation()
{
	while (true)
	{
		foreach(s in sockets) // 각 소켓에 대해
		{
			// 논블록 수신. 오류 코드와 수신된 데이터를 받는다.
			(result, data) = s.recive();
			if (data.length > 0) // 잘 수신했으면
			{
				print(data); // 출력
			}
			else if (result != EWOULDBLOCK)
			{
				// would block이 아니면 오류가 난 것이므로
				// 필요한 처리를 한다.
				// ...
			}
			// cpu 폭주 
		}
	}
}

 

아직 위 코드엔 문제가 남아 있습니다. 해당 스레드는 소켓이 would block인 상태에서는 계속해서 루프를 돕니다. 이 루프틑 CPU를 쉬지 않게 만드므로 사용량 폭주를 만들어 비효율을 초래합니다.

 

이런 문제를 해결해주는 것이 select(), poll() 입니다.

이는 다음의 기능을 합니다.

1. 소켓들 중 하나가 would block의 상태에서 변화가 일어나면, 그 상황을 알려줌.
2. 그것을 알려주기 전까진 blocking
- 소켓 리스트를 입력한다.
- 리스트의 소켓 중 하나라도 I/O 처리를 할 수 있는 것이 생기는 순간까지 블로킹
- 블로킹이 끝나면 어떤 소켓이 I/O 처리를 할 수 있는지 알려줌.
- 블로킹은 시간 지정이 가능.

 

다음과 같이 구현해볼 수 있습니다.

List(Socket) sockets;

void NonBlockSocketoperation()
{
	while (true)
	{
		// 100밀리초까지 대기
		// 1개라도 I/O 처리를 할 수 있는 상태가 되면
		// 그 전에라도 리턴
		select(socket, 100ms);

		foreach(s in sockets)
		{
			//논블록 수신
			(result, data) = s.receive();
			if (data.length > 0)
			{
				print(data);
			}
			else if (result != EWOULDBLOCK)
			{
				//소켓 오류 처리를 한다.
			}
		}
	}
}

 

4. 비동기 I/O

그동안 논블로킹 소켓에 대해 알아보았습니다. 요약하자면 다음과 같은 장점이 있다고 할 수 있습니다.

스레드 블로킹이 없으므로 중도 취소 같은 통제가 가능
스레드 개수보다 많은 소켓을 다룰 수 있다.
스레드가 적으므로 연산량과 호출 스택 메모리를 낭비하지 않을 수 있다.

 

하지만 다음과 같은 한계를 보입니다.,

 

1. would block 상태일 경우 재시도 호출 낭비가 발생한다.
2. 소켓 I/O 함수를 호출할 때 입력하는 데이터 블록에 대해 복사 연산이 발생한다.
3. send()나 receive()는 재시도 호출 api가 일관되지 않는다.

 

- 1번 단점

TCP의 경우 버퍼에 1바이트라도 비어 있거나 수신할 데이터가 있다면 I/O 가능 상태가 됩니다. 이 상태에서 send, recv를 호출한다면 would block이 생기지 않고 잘 채워집니다.

하지만 UDP의 send의 경우 조금 다릅니다. (receive의 경우 동일합니다.) UDP는 일부만 보낼 수 없으므로 would block이 발생합니다. I/O 가능이므로 재시도는 계속 하지만 would block이 지속되면서 CPU 낭비가 됩니다.

- 2번 단점

소켓 송수신 함수에 들어가는 데이터 블럭을 성공적으로 실행하면 메모리 복사 연산이 발생합니다. 고성능 서버에서는 이 복사 연산도 무시할 수 없는 성능 저하입니다.

 

이와 같은 것들을 해결해주는 것이 비동기 I/O (윈도우에서는 Overlapped I/O) 입니다. 이는 재시도용 호출 낭비 문제와 데이터 블록 복사 부하 문제를 모두 해결해줍니다.

 

void OverlapedSocketOperation()
{
	var overlapeedSendStatus;
	
	(result, length) = s.OverlappedSend(
		data,
		overlappedSendStatus);

	if (length > 0)
	{

	}
	else if (result == WSA_IO_PENDING)
	{
		// Overlapped I/O가 진행 중
		while (true)
		{
			(result, length) = GetOverlappedresult(s, overlappedSendStatus);

			if (length > 0)
			{
				// 보내기 성공
			}
			else
			{
				// I/O pedding 중
			}
		}
	}
}

 

Overlapped I/O 함수는 즉시 리턴되지만, 운영체제로 해당 I/O실행이 별도로 동시간대에 진행되는 상태라는 특징을 가집니다.

운영체제는 소켓 함수에 인자로 들어갔더 데이터 블록을 백그라운드에서 액세스합니다.

즉, 운영체제가 마음대로 데이터를 액세스 하기 때문에 중첩된이라는 의미의 overlapped라는 이름이 붙은 것입니다.

따라서 overlapped I/O 전용 함수가 비동기로 하는 일이 완료될 때까지 소켓 api에 인자로 넘긴 데이터 블록을 제거하거나 내용을 변경해서는 안 됩니다.

완료 여부의 경우 Overlapped I/O 전용 함수의 인자인 Overlapped Status 구조체로 알 수 있습니다.

 

Overlapped I/O는 다음과 같은 장점을 가집니다.

소켓 I/O 함수 함수 호출 후 would block 값인 경우 재시도 호출 낭비가 없다.
소켓 I/O 함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산을 없앨 수 있다.
send, receive, connect, accept 함수를 한 번 호출하면 이에 대한 완료 신호는 딱 한 번만 오기 때문에 결과물이 깔끔하다.
논블록 소켓 I/O 실행 상태 Overlapped I/O 실행 상태
송신 버퍼에 1바이트라도 여유 공간이 있으면 송신 가능 송신이 진행 중이고 완료가 아직 안 되어 있으면 송신 대기중
수신 버퍼에 1바이트라도 여유 공간이 있으면 수신 가능 수신이 진행 중이고 완료가 아직 안 되어 있으면 송신 대기중
이를 통칭 I/O 가능이라고 합니다. 이를 통칭 I/O 완료 대기 중, I/O 실행 중.
I/O를 일단 시도한 뒤 실패하면 가능을 기다린 후 재시도. I/O를 시행(무조건 성공)한 뒤 완료를 기다린다.
소켓들에 대해 select를 호출 후 루프를 돌며 I/O 시도 각 Overlapped Status 객체에 대해 완료 상태인지 파악.

 

어쨌든 소켓 개수에 비례해 루프를 돌기 때문에 성능적으로 꺼림직함은 남아 있습니다. 소켓이 엄청나게 많을 때 이러한 루프없이 끝낼 수는 없을까요?

 

이럴 때 등장한 것이 epoll과 IOCP 입니다.

 

 

5. epoll

 

epoll이란 소켓이 I/O 가능 상태가 되면 이를 감지해서 사용자에게 알림을 해주는 역할을 합니다. 

 

그림과 함께 동작 원리를 살펴볼까요?

 

 

소켓이 I/O 가능이 되는 순간 epoll은 이 상황을 epoll 안에 내장된 queue에 push 합니다.

사용자는 이 이벤트 정보를 pop하여 어떤 소켓이 I/O 가능한지 알 수 있습니다.

이로 소켓이 많다고 하더라도 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 = events.userPtr;
	// 수신? 송신?
	type = event.type;
	if (type == ReceiveEvent)
	{
		(result, data) = s.recv();
		if (data.length > 0)
		{
			// 수신된 데이터를 처리한다.
			Process(userPtr, s, data);
		}
	}
}

 

event를 돌며 I/O 가능인 소켓에서만 루프를 돌고 있는 걸 볼 수 있습니다.

 

하지만 현실과 이상은 다르다고... 현실에서는 송신 버퍼가 빈 공간이 없는 순간은 짧습니다. 대부분 송신 가능 상태에 있습니다. 이러한 특징 때문에 필요 이상의 루프를 돌게 됩니다. 이 문제를 해결하려면 레벨 트리거 대신 에지 트리거를 사용해야 합니다.

 

 

 

 

레벨 트리거는 소켓이 I/O가 가능하다는 것을 말합니다.입력 버퍼에 데이터가 남아 있는 동안 계속해서 이벤트가 등록됩니다. 참고로 select 모델은 레벨 트리거 방식으로 동작한다.

 

에지 트리거는 소켓이 I/O 가능이 아니었는데 이제 I/O 가능이 되었다를 의미합니다. 즉 레벨 트리거는 I/O 가능인 이상 epoll에서 항상 꺼내어지지만 에지 트리거는 가능이 아니었다가 가능으로 변하는 순간에만 꺼내어 집니다. 이러한 특성 때문에, 일단 입력과 관련해서 이벤트가 발생하면, 입력버퍼에 저장된 데이터 전부를 읽어 들여야 합니다. 따라서 앞서 설명한 다음 내용을 기반으로 입력버퍼가 비어있는지 확인하는 과정을 거쳐야 합니다.

 

입력버퍼에 저장된 데이터를 전부를 읽어 들이지 않으면 남은 데이터를 영원히 꺼내지 못 할 수도 있습니다.

 

단 다음의 사항을 주의해야 합니다.

 

1. I/O 호출을 한 번만 하지 말고 would block이 발생할 때까지 반복해야 합니다.

2. 소켓은 논블록으로 미리 설정되어 있어야 합니다.

 

또한, 엣지트리거는 소켓을 넌-블로킹 모드로 만드는 이유는 read & wrtie 함수의 호출은 데이터 분량에 따라서 IO로 인한 서버를 오랜 시간 멈추는 상황으로까지 이어지게 할 수 있습니다. 때문에 엣지 트리거 방식에서는 반드시 넌-블로킹 소켓을 기반으로 read & wrtie 함수를 호출해야 합니다.

엣지트리거의 가장 강력한 장점은 데이터의 수신과 데이터가 처리되는 시점을 분리할 수 있다는 점입니다.

즉 엣지 트리거가 좋은 성능을 발휘할 확률이 상대적으로 높습니다!

 

foreach(event in events)
{
	s = event.socket;
	// 위 epoll, add에 들어갔던 값을 얻는다.
	userPtr = events.userPtr;
	// 수신? 송신?
	type = event.type;
	if (type == ReceiveEvent)
	{
		while (true)
		{
			(result, data) = s.recv();
			if (data.length > 0)
			{
				// 수신된 데이터를 처리한다.
				Process(userPtr, s, data);
			}
			if (result == EWOULDBLOCK)
				break;
		}
		
	}
}

6. IOCP

 

IOCP는 윈도우에서의 논블록 소켓을 대량으로, 효율적으로 처리해주는 API 입니다.

 

IOCP는 소켓의 Overlapped I/O가 완료되면 이를 감지해서 사용자에게 알려주는 역할을 합니다. 특정 소켓이 완료되는 순간 IOCP는 내장된 queue에 push 합니다.

 

epoll과 몹시 유사한 것을 알 수 있습니다. 하지만 결정적인 차이가 있는데, epoll은 I/O 가능인 것을 알려주지만, IOCP의 경우 I/O 완료인 것을 알려준다는 점입니다. 

 

구현 코드는 다음과 같습니다.

 

iocp = new iocp()
foreach(s in sockets)
{
	iocp.add(s, GetUserPtr(s));
	s.OverlappedReceive(data[s], receiveOverlapped[s]);
}

events = iocp.wait(100ms);

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]);

		// 추가로 I/O를 계속 하고 싶으면 Overlapped I/O를 또 걸면 됨
		s.OverlappedReceive(data[s], receiveOverlapped[s])
	}
}

 

IOCP와 epoll의 차이는 다음과 같습니다. 

구분 IOCP epoll
블로킹을 없애는 수단 overlapped I/O 논블록 소켓
블로킹 없는 처리 순서 1. overlapped I/O를 건다
2. 완료 신호를 꺼낸다.
3. 완료 신호에 대한 나머지 처리를 한다.
4. 끝나고 나서 다시 Overlapped I/O를 건다.
1. I/O 이벤트를 꺼낸다.
2. 꺼낸 이벤트에 대응하는 소켓애 대한 논블록 I/O를 실행한다.
지원 플랫폼 Window Linux

 

epoll은 reactor 패턴을 따르므로 지금 당장 작업해야할 내용이 있는지 아닌지 확인하고 있다면 그에 따른 명령을 수행합니다. 이들은 작업할 것이 있는지 확인 후, 있다면 어떤 작업을 해야하는지 분석하고, 이를 처리합니다.

 

반면 IOCP는 proactor 패턴으로 매번 작업이 완료됐는지 아닌지 확인하며 동작하지 않고, 작업을 시켜놓은 후 그 작업이 완료되면 그 완료에 대한 처리를 수행합니다. 

 

즉 epoll의 어떤 이벤트 발생이란 "IO 작업할 데이터가 있다"이고 proactor란 어떤 이벤트의 발생이란 "IO작업이 완료되었다."를 말합니다. proactor는 OS의 지원이 필요합니다. OS가 내부적으로 비동기적인 IO 작업을 완료한 후 그게 완료되면 어플리케이션에게 IO 작업이 완료되었음을 알려줍니다. 예를 들어 java의 NIO는 어플리케이션 단에서의 proactor를 흉내내는 방식입니다.

7. IO_Uring, Registered I/O

 

사실 위에서 설명드린 IOCP, epoll은 게임 서버에서 많이 사용되고 있는 방법입니다. 실제로도 완성도도 높고 좋은 기술이라고 생각합니다. 하지만 1993년에 처음 등장했습니다. epoll도 2002년에 등장했습니다. 지금 2024년이 되었는데 그동안 어떤 기술도 나오지 않았을리가..없죠..! http도 HTTP/3, QUIC가 나오고 있는 상황인데요 :)

Linux 계열은 IO_Uring, 윈도우 계열은 Registered I/O가 각각 있습니다.

 

- IO_Uring

우선 IO_Uring부터 살펴볼까요?

2019년에 추가된 IO_Uring은 Linux 커널 인터페이스입니다. 원래는 파일 I/O를 위해 설계되었지만 네트워크 소켓 작업도 지원하게 되었습니다. 

 

IO_Uring의 경우 epoll과 다르게 readiness model이 아닌 completion model을 기반으로 만들어졌습니다. 즉 linux에서도 IOCP처럼 completion model 기반으로 사용할 수 있습니다. 이에 더해 ring buffer를 통해 syscall 수를 최소화할 수 있습니다. 

 

readiness model의 가장 큰 장점은 버퍼를 미리 할당할 필요가 없다는 것입니다. 이는 데이터가 OS로부터, 혹은 OS에서 복사될 수 있을 때까지 IO 시작부터 keep-alive로 유지되어야 하는 메모리가 없음을 의미합니다. 이렇게 하면 메모리 사용량이 줄어들고, 코드가 더 단순해지며(버퍼 관리가 단순화되므로) 동일한 스레드의 여러 IO가 버퍼를 공유할 수 있습니다.

완료 기반 IO에는 버퍼의 적극적인 할당이 필요하지만 OS에서 복사하지 않고 사용자 메모리에 데이터를 직접 쓸 수 있는 zero-copy 접근 방식이 허용됩니다.

 

- Ring Buffer

 

IO_Uring은 user-space와 kernel-space 사이에 공유된 두 개의 ring buffer를 사용합니다.

 

1. Submission Queue :  user-space 에서 생성된 I/O 요청을 kernel에 전달
2. Completion Queue : 완료된 I/O 작업들을 kernel에서 user-space로 전달

 

각 queue는 head, tail, ring, 그리고 배열로 구성되어 있습니다. 사용자는 tail에 새로운 항목을 추가하고 kernel은 head를 통해 항목을 가져옵니다. 이러한 설계를 통해 kernel은 queue의 상태를 확인하지 않고도 요청을 처리할 수 있습니다.

 

IO_Uring의 작동 방식

 

1. 먼저 사용자 공간에서 io_uring_setup 시스템 콜을 통해 인스턴스를 초기화한 후 필요한 큐 크기를 지정합니다.

2. 사용자는 io_uring_register 시스템 콜을 통해 파일 설명자, 이벤트, buffer 등을 등록합니다.

3. 이후에는 사용자가 io_uring_enter 시스템 콜을 통해 I/O 작업을 submit 합니다. 이는 submission queue에 submission queue entry로 들어가게 됩니다.

4. 커널은 이 submission queue entry를 검사하고 해당 I/O 작업을 수행합니다. 완료되면 completion queue에 completion queue entry로서 추가됩니다. 

5. 마지막으로 사용자는 다시 io_uring_enter를 호출하여 completion queue에서 완료된 작업을 가져옵니다.

 

IO_Uring은 이러한 과정을 통해 system call을 최소화하여 비싼 시스템 콜 자체를 줄이고 user-space와 kernel 사이의 context swtich 모두 최소화할 수 있습니다!

 

참고 자료(링크에 예제 코드도 있습니다 :) : https://smileostrich.tistory.com/entry/What-is-IOuring-Inside-IOuring

 

What is IO_uring? (Inside IO_uring)

예전에 SSD, HDD에 대한 글을 살펴본 것 처럼, I/O 처리 방식은 시스템 성능에 큰 영향을 미칩니다. Coding for SSDs – Part 1: Introduction and Table of Contents | Code Capsule Translations: This article was translated to Simpli

smileostrich.tistory.com

https://www.scylladb.com/2020/05/05/how-io_uring-and-ebpf-will-revolutionize-programming-in-linux/

https://stackoverflow.com/questions/71673488/does-iocp-and-io-uring-read-write-asynchronous

 

 

- Registered I/O

 

registered I/O는 window server 2012에서 등장한 기술입니다. IOCP에 비해 CPU 사용량이 약 2배 낮고 context switch가 6배 정도 적은 것이 특징입니다.

 

IOCP의 경우 하나의 I/O operation마다 버퍼 영역에 대한 lock, unlock을 요구하며 하나의 I/O operation 마다 system call을 호출합니다. RIO는 엄청난 수의 작은 패킷 처리를 위해 최적화되었습니다.

 

이를 위해 I/O에 사용할 고정 크기의 버퍼를 등록합니다. 

- 물리 메모리에 필요한 buffer를 항상 pin 해놓기 때문에 lock, unlock이 없습니다.

-  메모리 사용량과 cpu 사용량 간의 trade-off라 할 수 있습니다.

 

다만 Gigabit 네트워크 상의 throughput의 경우 IOCP과도 별 차이가 없었다고 합니다. 복잡한 코딩 방법과 MS에 대한 플랫폼 종속으로 인해 권하지 않는 의견도 있습니다 :)