I/O 처리 시스템은 크게 Blocking / Non-Blocking으로 나눌수 있다. 그 중에 Non-blocking은 비동기와 엮이며 다시 polling과 submission으로 나눌 수 있다.

우선 Blocking / Non-Blocking이 뭔지를 생각해 봐야 한다. Blocking은 어떤 함수가 실행되면 종료될 때 까지 기다려야 하는 것을 말한다. 대부분의 함수가 이에 해당한다. 예를 들어, 팩토리얼 함수 Fac(1000) 을 실행했다면 1000번째 팩토리얼 값을 찾을 때 까지 기다려야 할 것이다. 그런데 Non-Blocking은 다르다. 함수가 끝나든 말든 다음 줄이 실행된다. 내부에서 쓰래드를 새로 만들든, 그냥 내뱉든 어쨋든 함수 자체는 그 멈춤이 없다. 함수 실행 즉시 결과가 반환된다.

어플리케이션 수준에서 I/O를 다루면 Blocking이 맞다고 느껴질 수 있다. read() 함수를 호출하면 어쨋든 데이터가 나와야 한다는 것이다. 그러나 이렇게 되면 소켓 수 마다 Control-flow가 필요하다. 즉, 소켓 수 = 쓰레드 수가 된다.

이것을 해결하기 위해 poll (select, epoll, kqueue)등이 만들어졌다. 소켓이 “읽을 수 있는 상태”인지를 매번 검사한다. 10만개의 소켓이 있어도 “1, 80, 3001번째 소켓만 읽으면 된다”라는 신호를 받을 수 있다. 그러면 버퍼에 데이터가 존재한다는것이 자연스래 증명되며 read() 함수 실행시에 즉시 값이 나온다. 그렇기 때문에 1개의 쓰래드로도 수많은 소켓을 관리할 수 있다. (물론 실제로는 O_NON_BLOCKING을 쓸 것이다)

그러나, 요즘에 들어서는 이것 마저 성능상 부담이라고 여겨진다. 그래서 나온것이 io_uring이다. 일종의 배열(링 버퍼)에 작업 정보를 달아 놓는다. 굳이 “10만개의 소캣중에 뭐를 읽을 수 있나?” 조회하지 않는다. 대신 “A소켓에 데이터가 들어오면 FUNC를 실행해줘~” 라고 걸어놓는다. 데이터를 전송할 때도 “버퍼가 있나?” 체크하지 않는다. 대신 “가능할 때 BUF를 보내줘”라고 적어 둔다. 일종의 Fire-And-Forget이라고 할 수 있다.

사실 커널 입장에서는 네트워크 처리는 비동기이다. TCP 처리에 Lock이 필요할 수도 있다. 이때를 제외하고는 전부 비동기이다. 데이터를 네트워크로 전송 하는것은 커널 입장에서는 NIC에 패킷을 전달하는것이 끝이다. 데이터를 수신하는것은 NIC로 부터 패킷을 받아 적절한 버퍼를 채우고 쓰래드를 깨우는게 끝이다. 사실 커널 단에서는 비동기로 처리된다. 그러나 프로그램 단에서는 이 사이가 멈춰져 있기 때문에 “동기” 처럼 보인다.

C++을 이용한 간단 채팅 프로그램 코드