LastMod:

시작하기 전

  • 해당 논문은 초기 kqueue()가 개발된 당위성, 구현 범위, 원리 및 설계에 대해 다룹니다.
  • CS에서 통용되는 용어는 음차 표기 하였습니다. (ex. signal → 시그널)
  • 현대의 kqueue()는 더 이상 해시테이블을 사용하지 않으며, 링 버퍼와 레드블랙트리를 사용합니다.
  • select()poll()의 대체 뿐만 아니라, 다양한 커널 이벤트를 감지하기 위한 제네릭 메커니즘을 제공합니다. (해당 논문에는 select()poll()의 대체 측면을 주로 서술하였지만, 시그널, 파일변경, 프로세스 포크 등의 이벤트 또한 감지가 가능합니다.)
  • 소켓은 레벨 트리거로 동작하는 반면, kqueue()는 이벤트를 엣지 트리거 방식(이벤트가 발생한 순간에만 트리깅)으로 감지하고 동작합니다.
  • 현재는 논문에 소개된 이벤트와 플래그보다 훨씬 더 다양한 것들을 지원하니, 좀 더 적합하게 사용하기 위해서는 man 페이지를 참고해주세요.
  • 7. Performance8. Related Work 는 번역 목적인 kqueue()의 원리와 동떨어져 있다고 생각되어 번역하지 않았습니다.

0. Abstract

UNIX 플랫폼에서 실행되는 애플리케이션은 소켓이나 기타 디스크립터에서 어떤 활동이 발생할 때 알림을 받아야 하며, 이 알림은 일반적으로 select() 또는 poll() 시스템 호출을 통해 이루어집니다. 그러나 이러한 호출의 성능은 디스크립터 수가 증가함에 따라 잘 확장되지 않는 것으로 나타났습니다. 또한 이러한 인터페이스는 애플리케이션이 관심을 가질 수 있는 다른 잠재적으로 흥미로운 활동(시그널, 파일 시스템 변경, AIO 완료 등)을 처리할 수 없다는 점에서 제한적입니다.

이 논문에서는 애플리케이션이 광범위한 이벤트 소스 중에서 선택하고 확장 가능하고 효율적인 방식으로 이러한 소스의 활동에 대한 알림을 받을 수 있는 일반적인 이벤트 전달 메커니즘을 제시합니다. 이 메커니즘은 애플리케이션 인터페이스를 변경하지 않고도 향후 이벤트 소스를 포함하도록 확장할 수 있습니다.

1. Introduction

애플리케이션은 애플리케이션 외부의 이벤트나 활동에 대한 응답으로 작업을 수행하고 이후 어떤 방식으로 전달되는 이벤트 중심인 경우가 많습니다. 따라서 애플리케이션의 성능은 종종 이러한 이벤트를 얼마나 효율적으로 감지하고 대응할 수 있는지에 따라 달라집니다. FreeBSD는 파일 기술자에 대한 활동을 감지하기 위한 두 가지 시스템 호출, 즉 poll()select()를 제공합니다. 그러나 이 두 호출은 이벤트를 모니터링하는 디스크립터의 수가 많아질수록 확장성이 떨어집니다. 수천 개의 디스크립터를 처리하려는 대용량 서버에서는 이러한 호출이 병목 현상이 발생하여 성능이 저하될 수 있습니다.

애플리케이션이 관심을 가질 수 있는 이벤트 집합은 열린 파일 디스크립터의 활동에만 국한되지 않습니다. 애플리케이션은 비동기 I/O(aio) 요청이 완료되는 시점, 애플리케이션에 신호가 전달되는 시점, 파일 시스템의 파일이 어떤 방식으로 변경되는 시점 또는 프로세스가 종료되는 시점도 알고 싶을 수 있습니다. 시그널 전달은 제한적이고 비용이 많이 들며, 나열된 다른 이벤트는 비효율적인 폴링 모델을 필요로 하기 때문에 현재로서는 이 중 어느 것도 효율적으로 처리되지 않습니다. 또한 이러한 이벤트를 수집하는 데 poll() 또는 select()를 사용할 수 없기 때문에 여러 알림 인터페이스를 사용해야 하므로 코드 복잡성이 증가합니다.

이 논문에서는 애플리케이션이 특정 이벤트에 대한 관심을 등록한 다음, 나중에 해당 이벤트의 알림을 효율적으로 수집할 수 있는 새로운 메커니즘을 제시합니다. 이 메커니즘이 다루는 이벤트 세트에는 위에서 설명한 이벤트뿐만 아니라 API를 수정하지 않고도 예상치 못한 이벤트 소스로 확장할 수 있는 것으로 나타났습니다. 이 백서의 나머지 부분은 다음과 같이 구성되어 있습니다: 섹션 2에서는 poll()select()의 중심 병목 현상이 어디에 있는지 살펴보고, 섹션 3에서는 설계 목표를 설명하며, 섹션 4에서는 새로운 메커니즘의 API를 소개합니다. 섹션 5에서는 새로운 API를 사용하는 방법과 유용한 프로그래밍 예제를 제공하고, 커널 구현은 섹션 6에서 설명합니다. 일부 애플리케이션에 대한 성능 측정은 섹션 7에서 확인할 수 있습니다. 섹션 8에서는 관련 작업에 대해 논의하고 섹션 9에서 요약으로 논문을 마무리합니다.

2. Problem

poll()select() 인터페이스는 애플리케이션이 호출할 때마다 모니터링할 디스크립터의 전체 목록을 전달해야 한다는 결함이 있습니다. 이는 시스템이 사용자/커널 경계를 가로질러 두 개의 메모리 복사본을 수행하도록 하여 다른 활동에 사용할 수 있는 메모리 대역폭의 양을 줄이는 즉각적인 결과를 초래합니다. 수천 개의 디스크립터가 포함된 대규모 리스트의 경우, 실제 경험에 따르면 일반적으로 수백 개만 실제로 활동을 수행하므로 복사본의 95%가 불필요한 것으로 나타났습니다.

반환 시 애플리케이션은 커널이 활동 중인 것으로 표시한 디스크립터를 찾기 위해 전체 목록을 살펴봐야 합니다. 커널은 어떤 디스크립터가 활성화되어 있는지 알고 있기 때문에 애플리케이션은 시스템이 이미 알고 있는 정보를 다시 계산해야 하므로 작업이 중복됩니다. 커널이 활성화된 것으로 알고 있는 디스크립터 리스트를 단순히 다시 전달하도록 하는 것이 더 효율적일 수 있습니다. 리스트를 순회하는 것은 \( O(N) \) 작업이며, \( N \)이 커지면 잘 확장되지 않습니다.

커널 내에서도 상황은 이상적이지 않습니다. 디스크립터 리스트를 저장할 공간을 찾아야 합니다. 큰 리스트의 경우 malloc()을 호출하여 이 작업을 수행하며, 반환하기 전에 해당 영역을 차례로 비워야 합니다. 복사가 수행된 후 커널은 모든 항목을 검사하여 디스크립터에 보류 중인 작업이 있는지 확인해야 합니다. 커널이 현재 스캔에서 활성 디스크립터를 찾지 못하면 디스크립터의 selinfo 항목을 업데이트하고, 이 정보는 디스크립터에서 활동을 기다리는 동안 tsleep()을 호출하는 경우 프로세스를 깨우는 데 사용됩니다. 프로세스가 깨어난 후에는 리스트를 다시 스캔하여 현재 활성화된 디스크립터를 찾습니다.

이는 poll 또는 select가 실제로 sleep인 경우 디스크립터 리스트를 3번 통과하게 되는데, 한 번은 보류 중인 이벤트를 찾고 선택 정보를 기록하기 위해 리스트를 순회하고, 두 번째는 활동을 통해 깨어난 디스크립터를 찾고, 세 번째는 사용자 공간에서 사용자가 커널에 의해 활성으로 표시된 디스크립터를 찾기 위해 리스트를 순회하는 과정입니다.

이러한 문제는 poll()select()가 설계상 상태 비저장형이라는 사실에서 비롯됩니다. 즉, 커널은 시스템 호출 사이에 애플리케이션이 관심 있는 항목에 대한 기록을 보관하지 않고 매번 다시 계산해야 한다는 것입니다.

커널에 어떤 상태도 유지하지 않기로 한 설계 결정은 현재 구현의 주요 비효율성으로 이어집니다. 커널이 애플리케이션이 관심 있는 디스크립터를 정확히 추적하고 활성화된 디스크립터의 하위 집합만 반환할 수 있다면 오버헤드의 상당 부분을 제거할 수 있습니다.

3. Design Goals

대체 기능을 설계할 때 가장 큰 목표는 수천 개에 이르는 많은 수의 디스크립터를 수용할 수 있는 효율적이고 확장 가능한 시스템을 만드는 것이었습니다. 두 번째 목표는 시스템을 유연하게 만드는 것이었습니다. UNIX 기반 시스템에는 전통적으로 이벤트 알림을 위한 강력한 기능이 부족했습니다. pollselect 인터페이스는 소켓 및 파이프 디스크립터로 제한되어 있으며, 사용자는 파일 생성이나 삭제와 같은 다른 유형의 이벤트를 기다릴 수 없습니다. 다른 이벤트는 사용자가 다른 인터페이스를 사용해야 합니다. 특히 신호 이벤트에 대한 알림을 받으려면 siginfofamily를 사용해야 하고, AIO 호출이 완료되었는지 확인하려면 aio_wait을 호출해야 합니다.

또 다른 목표는 인터페이스를 쉽게 이해할 수 있을 정도로 단순하게 유지하고, 최소한의 변경만으로 poll() 또는 select() 기반 애플리케이션을 새 API로 변환할 수 있도록 하는 것이었습니다. 새 인터페이스가 근본적으로 다르면 새 API를 활용할 수 있는 레거시 애플리케이션의 수정을 근본적으로 막을 수 있다는 점을 인식했습니다.

애플리케이션에 반환되는 양 정보를 단순히 이벤트가 발생했다는 사실 이상으로 확장하는 것도 바람직한 것으로 간주되었습니다. 읽기 가능한 소켓의 경우, 사용자는 여러 번의 read() 호출을 피하기 위해 소켓 버퍼에 실제로 대기 중인 바이트 수를 알고 싶을 수 있습니다. 수신 소켓의 경우, 애플리케이션은 제공된 로드에 적응하기 위해 수신 백로그의 크기를 확인할 수 있습니다. 새로운 기능을 설계할 때 더 많은 정보를 제공한다는 목표를 염두에 두었습니다.

또한 이 메커니즘은 조용히 실패하거나 사용자에게 일관되지 않은 상태를 반환하지 않아야 한다는 점에서 안정적이어야 합니다. 이 목표는 오버플로우가 발생할 수 있으므로 고정된 크기의 리스트가 없어야 하며, 메모리 부족으로 인한 이벤트 손실을 방지하기 위해 메모리 할당은 활동이 발생할 때가 아니라 시스템 호출 시점에 이루어져야 한다는 것을 의미합니다.

한 소켓에 여러 개의 네트워크 패킷이 도착하는 경우를 예로 들어보겠습니다. 들어오는 각 패킷을 개별 이벤트로 간주하여 각 패킷에 대해 하나의 이벤트를 기록할 수 있습니다. 그러나 들어오는 패킷의 수는 본질적으로 무제한인 반면 시스템의 메모리 양은 유한하기 때문에 이벤트가 손실되지 않는다는 보장을 제공할 수 없습니다.

위 시나리오의 결과는 여러 패킷이 단일 이벤트로 합쳐지는 것입니다. 애플리케이션에 전달되는 이벤트는 모니터링 중인 이벤트 소스에서 발생한 여러 활동과 일치할 수 있습니다.

또한, 패킷에 \( N \) 바이트가 포함되어 도착하고, 이벤트 알림을 받은 애플리케이션이 소켓에서 \( R \) 바이트(이때, \( R<N \) )를 읽는다고 가정해 보겠습니다. 이벤트는 도착하는 패킷을 기준으로 정의되기 때문에 다음에 이벤트 API를 호출할 때 소켓 버퍼에 아직 보류 중인 ( \(N-R \) ) 바이트에 대한 알림이 없을 것입니다. 따라서 애플리케이션은 실수로 데이터를 잃지 않도록 하기 위해 추가 부기 작업을 수행해야 합니다.

실수로 데이터를 잃지 않도록 하기 위해 애플리케이션이 추가 기록을 수행해야 합니다. 애플리케이션에 부과되는 이러한 추가 부담은 단순한 인터페이스를 제공하려는 목표와 상충되므로 다음과 같은 설계 결정으로 이어집니다. 이벤트는 일반적으로 ‘엣지 트리거’가 아닌 ‘레벨 트리거’로 간주됩니다. 이를 다른 말로 표현하면, 이벤트 소스에서 활동이 실제로 감지되는 시점이 아니라 지정된 조건이 유지되는 한 이벤트가 보고된다고 할 수 있습니다. 주어진 조건은 “버퍼에 읽지 않은 데이터가 있음”과 같이 간단할 수도 있고, 더 복잡할 수도 있습니다. 이 접근 방식은 위에서 설명한 시나리오를 처리하며, 애플리케이션이 버퍼에서 부분 읽기를 수행하면서도 다음에 API를 호출할 때 이벤트에 대한 알림을 받을 수 있습니다. 이는 poll()select()가 제공하는 기존 시맨틱에 해당합니다.

최종 설계 기준은 이벤트가 해당되는 경우에만 보고되어야 한다는 점에서 API가 정확해야 한다는 것이었습니다. 패킷이 소켓에 도착하여 이벤트를 생성하는 경우를 생각해 봅시다. 그러나 애플리케이션이 이 보류 중인 이벤트에 대한 알림을 받기 전에 소켓에서 close()를 수행합니다. 소켓이 더 이상 열려 있지 않으므로 이벤트는 더 이상 관련이 없으므로 애플리케이션에 전달되어서는 안 됩니다. 또한 이벤트가 파일 디스크립터에 의해 식별되고 동일한 ID로 다른 디스크립터가 생성되는 경우, 잘못된 디스크립터에 대한 잘못된 알림 가능성을 방지하기 위해 이벤트를 제거해야 합니다.

정확성 요건은 애플리케이션이 API에 관심을 등록하기 전에 이벤트 소스가 이벤트를 생성하는 기존 조건에도 적용되어야 합니다. 이렇게 하면 애플리케이션이 소켓에 관심을 등록할 때 데이터가 소켓 버퍼에 대기 중일 수 있는 경쟁 조건을 제거할 수 있습니다. 메커니즘은 보류 중인 데이터가 “레벨 트리거” 요건을 충족하는지 인식하고 이 정보를 기반으로 이벤트를 생성해야 합니다.

마지막으로, API의 마지막 설계 목표는 라이브러리가 메인 프로그램과 충돌할 염려 없이 메커니즘을 사용할 수 있어야 한다는 것입니다. 이를 통해 API를 사용하는 타사 코드를 충돌 없이 애플리케이션에 연결할 수 있습니다. 표면적으로는 명백해 보이지만 몇 가지 반대의 예가 존재합니다. 프로세스 내에서 시그널에는 하나의 시그널 핸들러만 등록될 수 있으므로 라이브러리 코드는 일반적으로 시그널을 사용할 수 없습니다. X-윈도우 애플리케이션은 하나의 이벤트 루프만 허용합니다. 기존의 select()poll() 호출은 상태가 없으므로 이 문제가 없지만, 일부 상태를 커널로 이동하는 새로운 API는 프로세스당 여러 이벤트 알림 채널을 가질 수 있어야 합니다.

4. Kqueue API

#include <sys/types.h>
#include <sys/event.h>
#include <sys/time.h>

int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges,
		    struct kevent *eventlist, int nevents, const struct timespec *timeout);

struct kevent {
   uintptr_t       ident;          /* identifier for this event */
   int16_t         filter;         /* filter for event */
   uint16_t        flags;          /* general flags */
   uint32_t        fflags;         /* filter-specific flags */
   intptr_t        data;           /* filter-specific data */
   void            *udata;         /* opaque user data identifier */
};

EV_SET(&kev, ident, filter, flags, fflags, data, udata) // init kevent struct

kqueue API는 그림 1(역 : 위에 있는 코드 블록과 같음)에 설명된 두 가지 새로운 시스템 호출을 도입합니다. 첫 번째는 애플리케이션이 관심 있는 이벤트를 등록하고 커널에서 이벤트를 검색하는 알림 채널 또는 대기열인 새로운 kqueue를 생성합니다. kqueue()에서 반환된 값은 일반 fd로 취급되며, poll(), select()로 전달되거나 다른 kqueue에 등록될 수도 있습니다.

두 번째 호출(kevent())은 애플리케이션에서 kqueue에 새 이벤트를 등록하고 보류 중인 이벤트를 검색하는 데 모두 사용됩니다. 등록 및 검색 프로세스를 결합하면 필요한 시스템 호출 횟수가 줄어듭니다. kqueue에 적용해야 하는 변경 사항은 changelist에 제공되며, 반환된 모든 이벤트는 이벤트가 허용하는 최대 크기까지 이벤트 목록에 배치됩니다. kevent() 호출에 의해 eventlist에 실제로 배치된 항목의 수가 반환됩니다. timeout 매개 변수는 poll()과 동일한 방식으로 작동합니다. 0 값 구조체는 잠자기 상태 없이 보류 중인 이벤트를 확인하고, NULL 값은 깨어나거나 이벤트가 준비될 때까지 차단합니다. 애플리케이션은 필요에 따라 nchange 또는 nevents에 0 값을 전달하여 등록 및 검색 호출을 분리하도록 선택할 수 있습니다.

이벤트는 구조체 kevent를 통해 애플리케이션에 의해 시스템에 등록되며, 이벤트는 시스템 내에서 <fd, ident, filter> 튜플에 의해 고유하게 식별됩니다. 실제로 이것은 주어진 큐에 대해 하나의 <ident, filter> 쌍만 있을 수 있다는 것을 의미합니다.

filter 매개변수는 이벤트 소스에서 활동이 있을 때 실행되는 작은 커널 코드의 식별자이며, 이벤트를 애플리케이션에 반환해야 하는지 여부를 결정하는 역할을 합니다. ident, fflagsdata 필드의 해석은 이벤트를 표현하는 데 사용되는 필터에 따라 달라집니다. 현재 필터 목록과 해당 인수는 kqueue filters 섹션에 나와 있습니다.

flag 필드는 kevent가 시스템에 등록될 때 어떤 조치를 취해야 하는지 표현하는 데 사용되며, 반환 시 필터 독립적인 상태 정보를 반환하는 데도 사용됩니다. 유효한 플래그 비트는 그림 2에 나와 있습니다. (역 : 그림2는 아래에 있음)

udata 필드는 커널에서 변경되지 않은 상태로 전달되며 어떤 방식으로도 사용되지 않습니다. 이 필드의 사용법은 전적으로 애플리케이션에 따라 다르며, 함수 디스패치 루틴을 효율적으로 구현하거나 이벤트 구조에 애플리케이션 식별자를 추가하기 위한 방법으로 제공됩니다.

kevent flags

  • EV_ADD : kqueue에 이벤트를 추가합니다.
  • EV_ENABLE : 이벤트가 트리거 되면, kevent()가 그것을 반환하도록 허용합니다.
  • EV_DISABLE : 이벤트를 비활성화 하여, kevent()가 반환하지 않도록 합니다. 필터는 비활성화 되지 않습니다.
  • EV_DELETE : kqueue에서 이벤트를 제거합니다. 연관된 파일 디스크립터는 자동으로 닫힙니다.
  • EV_CLEAR :사용자가 이벤트를 가져가면, 해당 이벤트의 상태를 초기화합니다. 이는 현재 상태 대신 상태 전환을 보고하는 필터에 유용하며, 일부 필터는 이 플래그를 자동으로 설정합니다.
  • EV_ONESHOT : 이벤트가 트리거되는 필터의 첫 발생만 반환하도록 합니다. 사용자가 kqueue에서 이벤트를 가져가면 삭제됩니다.
  • EV_EOF : 필터별 EOF 컨디션을 표기하기 위해 이 플래그를 설정합니다.
  • EV_ERROR : changelist를 처리하는 도중 에러가 발생하면 이 플래그가 설정됩니다.

4.1 : Kqueue filters

이벤트가 발생했는지 여부를 결정하고 사용자에게 다시 전달할 추가 정보를 기록할 수도 있는 필터의 개념을 기반으로 설계된 kqueue 시스템의 설계는 필터의 개념을 기반으로 합니다. 이벤트 구조의 특정 필드에 대한 해석은 사용 중인 필터에 따라 달라집니다. 현재 구현에는 대부분의 목적에 적합한 몇 가지 범용 이벤트 필터가 포함되어 있습니다. 필터의 종류는 다음과 같습니다.

  • EVFILT_READ
  • EVFILT_WRITE
  • EVFILT_AIO
  • EVFILT_VNODE
  • EVFILT_PROC
  • EVFILT_SIGNAL

READWRITE 필터는 모든 파일 디스크립터에서 작동하도록 되어 있으며, ident 필드에는 디스크립터 번호가 포함되어 있습니다. 이러한 필터는 읽을 준비가 된 데이터가 있거나 애플리케이션이 블로킹 없이 쓸 수 있는 경우 반환한다는 점에서 poll() 또는 select()의 동작을 매우 유사하게 반영합니다. 필터에 해당하는 커널 함수는 디스크립터 유형에 따라 다르므로 구현은 사용 중인 각 디스크립터 유형의 요구 사항에 맞게 조정됩니다. 일반적으로 읽을 준비가 된(또는 쓸 수 있는) 데이터의 양은 이벤트 구조 내의 데이터 필드에 반환되며, 애플리케이션은 이 정보를 원하는 방식으로 자유롭게 사용할 수 있습니다. 기본 디스크립터가 EOF 개념을 지원하는 경우 애플리케이션이 읽을 수 있는 데이터가 아직 남아 있는지 여부에 관계없이 감지되는 즉시 플래그 워드 구조에 EV_EOF 플래그가 설정됩니다.

예를 들어, 소켓 디스크립터의 읽기 필터는 소켓 버퍼에 SO_LOWAT 표시보다 큰 데이터가 있거나 소켓이 종료되어 더 이상 데이터를 수신할 수 없을 때 트리거됩니다. 이 필터는 소켓 버퍼에 대기 중인 바이트 수를 반환하고 종료 케이스에 대한 EOF 플래그를 설정합니다. 이는 애플리케이션이 이벤트를 처리하는 동안 사용할 수 있는 추가 정보를 제공합니다. 소켓이 종료될 때 EOF가 명시적으로 반환되므로, 애플리케이션은 더 이상 EOF 조건을 발견하기 위해 read()를 추가로 호출할 필요가 없습니다.

비동기 I/O(aio) 기능을 사용하는 kqueue-인식이 없는 응용 프로그램은 aio_read() 또는 aio_write()를 실행하여 I/O 요청을 시작합니다. 그런 다음 요청은 응용 프로그램과 독립적으로 진행되며, 요청이 완료되었는지 확인하기 위해 aio_error()를 반복적으로 호출한 다음 최종적으로 aio_return()을 호출하여 요청의 완료 상태를 수집해야 합니다. AIO 필터는 이 폴링 모델을 대체하여 사용자가 I/O 요청이 발행될 때 지정된 kqueue에 aio 요청을 등록할 수 있도록 하고, aio_error()가 성공적으로 반환될 때와 동일한 조건에서 이벤트가 반환되도록 합니다. 이를 통해 애플리케이션은 aio_read() 호출을 실행하고 메인 이벤트 루프를 진행한 다음, 해당 이벤트에 해당하는 이벤트가 kqueue에서 반환되면 aio_return()을 호출할 수 있으므로 이 과정에서 여러 시스템 호출을 절약할 수 있습니다.

SIGNAL 필터는 일반 신호 처리 기계와 함께 작동하여 대체 시그널 전달 방법을 제공하기 위한 것입니다. 식별 필드는 시그널 번호로 해석되며, 반환 시 데이터 필드에는 시그널이 애플리케이션에 전송된 빈도의 카운트가 포함됩니다. 이 필터는 애플리케이션이 이벤트 알림을 수신한 후 해당 상태(시그널 발생 횟수)를 지움으로써 내부적으로 EV_CLEAR 플래그를 사용합니다.

VNODE 필터는 사용자가 파일 시스템 내에서 발생하는 변경 사항에 대한 관심을 등록할 수 있도록 하기 위한 것입니다. 따라서 ident 필드에는 열린 파일 또는 디렉터리에 해당하는 디스크립터가 포함되어야 합니다. fflags 필드는 애플리케이션이 등록 시 관심 있는 디스크립터에 대한 작업을 지정하고 반환 시 어떤 작업이 발생했는지 지정하는 데 사용됩니다. 가능한 작업은 다음과 같습니다:

  • NOTE_DELETE
  • NOTE_WRITE
  • NOTE_EXTEND
  • NOTE_ATTRIB
  • NOTE_LINK
  • NOTE_RENAME

이는 파일 시스템이 파일에 대해 수행하는 작업에 해당하므로 여기서는 설명하지 않습니다. 여러 작업이 발생한 경우 반환된 이벤트에서 이러한 노트가 함께 OR로 묶일 수 있습니다. (예: 파일이 작성된 후 이름이 변경된 경우) 마지막 범용 필터는 PROC 필터로, 프로세스 변경을 감지합니다. 이 필터의 경우 ident 필드는 프로세스 식별자로 해석됩니다. 이 필터는 여러 유형의 이벤트를 감시할 수 있으며, 이 필터를 제어하는 fflag는 그림 3에 나와 있습니다.

evfilter flags

  • NOTE_EXIT : 프로세스가 종료됨
  • NOTE_FORT : 프로세스가 fork()를 호출함
  • NOTE_EXEC : 프로세스가 execve() 등의 호출을 통해 새 프로세스를 실행함.
  • NOTE_TRACK : fork() 호출 흐름을 따라갑니다. 부모 프로세스는 이 플래그를 설정하며, 자식 프로세스는 data 필드에 부모 PID를, fflags에는 NOTE_CHILD를 담습니다.
  • NOTE_CHILD : fork()를 통해 발생하여 추적되는 자식 프로세스 입니다.
  • NOTE_TRACKERR : 리소스 제한 등의 이유로, 자식 프로세스의 이벤트를 추적할 수 없을 때 설정됩니다.

5. Usage and Examples

Kqueue는 사용자에게 주의가 필요한 이벤트를 효율적으로 알리는 동시에 해당 이벤트에 대한 정보를 최대한 많이 제공함으로써 poll()select()로 인해 발생하는 오버헤드를 줄이도록 설계되었습니다. 그러나 kqueue는 poll()을 대체할 수 있도록 설계된 것이 아니므로 시스템의 이점을 최대한 활용하려면 기존 애플리케이션을 재작성하여 kqueue가 제공하는 고유한 인터페이스를 활용할 수 있도록 해야 합니다.

폴링을 중심으로 구축된 기존 애플리케이션은 모든 활성 디스크립터를 포함하는 단일 구조를 가지며, 애플리케이션이 중앙 이벤트 루프를 통과할 때마다 커널에 전달됩니다. kqueue-인식 애플리케이션은 전체 목록을 전달하는 대신 활성 디스크립터 목록의 변경 사항을 커널에 알려야 합니다. 이는 활성 디스크립터 목록이 업데이트될 때마다 kevent()를 호출하거나 디스크립터 변경 목록을 구축한 다음 다음에 이벤트 루프가 호출될 때 이 목록을 커널에 전달하여 수행할 수 있습니다. 후자의 접근 방식은 시스템 호출 횟수를 줄이므로 더 나은 성능을 제공합니다.

앞의 kqueue에 대한 API 섹션은 언뜻 복잡해 보일 수 있지만, 대부분의 복잡성은 여러 이벤트 소스와 여러 필터가 있다는 사실에서 비롯됩니다. 읽기/쓰기 이벤트만 원하는 프로그램은 실제로는 매우 간단합니다. 다음 페이지의 예제에서는 poll()을 사용하는 프로그램을 kqueue()를 사용하도록 쉽게 변환하는 방법을 설명하고 다른 필터의 사용법을 보여주는 몇 가지 코드 조각도 제시합니다.

그림 4의 코드는 poll() 시스템 호출의 일반적인 사용법을 보여 주며, 그림 5의 코드는 동일한 코드를 한 줄씩 변환하여 kqueue를 사용한 것입니다. 물론 이것은 단순화된 예시이지만, 두 호출 간의 매핑은 매우 간단합니다. 변환의 주요 걸림돌은 pollfd 또는 이벤트 구조가 포함된 배열을 변경하는 update_fd에 해당하는 함수가 없다는 점일 수 있습니다.

새 이벤트를 등록하기 전에 udata 필드를 올바른 함수로 초기화하면 그림 6과 같이 디스패치 루프를 훨씬 더 단순화할 수 있습니다.

그림 7에는 애플리케이션에 시그널 이벤트를 전달하는 방법을 보여주는 코드 조각이 포함되어 있습니다. NULL 시그널 핸들러를 설정하는 signal() 호출을 주목하세요. 이 호출 이전에 시그널에 대한 기본 동작은 프로세스를 종료하는 것입니다. 시그널을 무시한다는 것은 단순히 시그널이 프로세스에 전달된 후 시그널 핸들러가 호출되지 않는다는 것을 의미합니다.

그림 8은 ufs 파일 시스템의 파일에 해당하는 디스크립터에서 지정된 변경 사항을 모니터링하는 코드를 보여줍니다. 이 플래그가 없으면 이벤트가 반복적으로 반환되므로 이벤트가 반환된 후 이벤트를 리셋하는 EV_CLEAR를 사용하는 것에 유의하세요.

PROC 필터의 동작은 아래 예제를 통해 가장 잘 설명됩니다. PROC 필터는 애플리케이션이 볼 수 있는 시스템의 모든 프로세스에 첨부할 수 있으며, 그 하위 프로세스에만 국한되지 않습니다. 이 필터는 권한이 있는 프로세스에 첨부될 수 있으며, 모든 정보는 ps를 통해 얻을 수 있으므로 보안에 영향을 미치지 않습니다. ‘보다’라는 용어는 특정 프로세스 그룹을 서로 격리하는 FreeBSD의 jail 코드(역 : 컨테이너 기술에 쓰이는 cgroups 등이 이에 속함)에 고유합니다.

프로세스 필터에서 FORK 플래그가 설정된 경우 각 fork()에 대해 단일 알림이 있습니다. TRACK 플래그가 설정된 경우 필터는 실제로 새 knote를 생성하고 등록하며, 이 knote는 다시 새 프로세스에 연결됩니다. 이 새 knote는 즉시 활성화되고 CHILD 플래그가 설정되어 즉시 활성화됩니다.

포크 기능은 프로세스의 실행을 추적하기 위해 추가되었습니다. 예를 들어, 프로세스 A에 대해 플래그(FORK, TRACK, EXEC, EXIT)가 있는 EVFILT_PROC 필터가 등록되어 있고, 이 프로세스가 두 개의 자식 프로세스인 B와 C를 포크한다고 가정합니다. 프로세스 C는 즉시 다른 프로세스 D를 포크하고, 이 프로세스는 exec()를 호출하여 다른 프로그램을 실행한 다음 종료합니다. 이 시점에서 애플리케이션이 kevent()를 호출하면 4개의 이벤트가 대기 중인 것을 발견하게 됩니다:

ident: A, fflags: FORK
ident: B, fflags: CHILD                data: A
ident: C, fflags: CHILD, FORK          data: A
ident: D, fflags: CHILD, EXEC, EXIT    data: C

자식에 연결된 knote는 부모와 자식 프로세스 ID 간의 매핑을 반환하는 역할을 담당합니다.

figure45 figure67 figure8

6. Implementation

Kqueue 시스템에서 활동의 초점은 애플리케이션에서 볼 수 있는 이벤트 구조에 직접적으로 대응하는 knote라는 데이터 구조를 중심으로 합니다. knote는 모니터링 중인 데이터 구조, 활동을 평가하는 데 사용되는 필터, 해당 필터가 속한 kqueue, 다른 knote에 대한 링크를 하나로 묶습니다. 또 다른 주요 데이터 구조는 애플리케이션에 전달할 준비가 된 knote를 포함하는 큐를 제공하고 애플리케이션이 관심을 등록한 이벤트에 해당하는 knote를 추적하는 두 가지 용도로 사용되는 kqueue 자체입니다. 이러한 목표는 kqueue에 연결된 세 가지 하위 데이터 구조를 사용하여 달성됩니다:

  1. 큐 자체에 대한 리스트로, 이전에 활성으로 표시된 knote들이 포함되어 있습니다.
  2. ident 필드가 설명자와 일치하지 않는 매듭점을 조회하는 데 사용되는 작은 해시 테이블입니다.
  3. 디스크립터를 기준으로 인덱싱된 단일 링크 목록의 선형 배열로, 프로세스의 열린 파일 테이블과 정확히 동일한 방식으로 할당됩니다.

해시 테이블과 배열은 느리게 할당되며, 배열은 보이는 가장 큰 파일 디스크립터에 따라 필요에 따라 확장됩니다. kqueue는 등록된 모든 knote를 기록해야 애플리케이션에 의해 kq가 닫힐 때 이를 소멸시킬 수 있습니다. 또한 디스크립터 배열은 애플리케이션이 특정 파일 디스크립터를 닫을 때 해당 디스크립터에 해당하는 모든 knote를 삭제하기 위해 사용됩니다. 데이터 구조 사이의 링크의 예는 아래와 같습니다.

figure9:imple

6.1 Registration

처음에 애플리케이션은 kqueue()를 호출하여 새 kqueue(이후 kq라고 함)를 할당합니다. 여기에는 새 디스크립터인 kqueue 구조체를 할당하고 열린 파일 테이블에 이 구조체에 대한 항목을 할당하는 작업이 포함됩니다. 배열과 해시 테이블을 위한 공간은 이때 초기화되지 않습니다. 그런 다음 애플리케이션은 적용해야 할 변경 목록에 대한 포인터를 전달하여 kevent()를 호출합니다. 변경 목록의 이벤트는 커널에 청크 단위로 복사된 다음 각 이벤트가 kqueue_register()로 전달되어 kq에 입력됩니다. kqueue_register() 함수는 쌍을 사용하여 kq에 연결된 일치하는 knote를 조회합니다. 매듭점이 발견되지 않으면 EV_ADD 플래그가 설정된 경우 새 knote가 할당될 수 있습니다. 전달된 이벤트 구조에서 knote를 초기화한 다음, filter_attach 루틴(아래에 자세히 설명되어 있음)을 호출하여 이벤트 소스에 knote를 연결합니다. 그 후, 새 knotekq 내의 배열 또는 해시 테이블에 연결됩니다. 변경 목록을 처리하는 동안 오류가 발생하면 오류를 일으킨 이벤트가 이벤트 목록으로 복사되어 애플리케이션으로 반환됩니다. 전체 변경 목록이 처리된 후에야 애플리케이션에 대한 이벤트를 큐에 대기시키기 위해 kqueue_scan()이 호출됩니다. 이 루틴의 작동은 6.4 Delivery 섹션에 자세히 설명되어 있습니다.

6.2 Filters

각 필터는 세 가지 기능으로 구성된 벡터를 제공합니다: {attach, detach, filter} . attach 루틴은 모니터링 중인 이벤트를 수신하는 구조 내의 링크드 리스트에 knote를 첨부하는 역할을 하며, detach 루틴은 이 리스트에서 knote를 제거하는 데 사용됩니다. 이러한 루틴은 각 데이터 구조마다 잠금 요구 사항과 부착 지점의 위치가 다르기 때문에 필요합니다. filter 루틴은 이벤트 소스에서 활동이 있을 때 호출되며, 활동이 애플리케이션에 이벤트를 보고할 수 있는 조건을 충족하는지 여부를 결정하는 역할을 담당합니다. 조건의 세부 사항은 필터 내에 인코딩되어 있으므로 사용되는 필터에 따라 다르지만 일반적으로 버퍼에 데이터가 있는지 또는 오류가 관찰되었는지 여부와 같은 특정 상태에 해당합니다. 필터는 이벤트를 애플리케이션에 전달해야 하는지 여부를 나타내는 부울 값을 반환해야 합니다. 또한 knote 내에서 fflag와 데이터 값을 조작하여 선택하면 몇 가지 “부작용”을 수행할 수도 있습니다. 이러한 부작용은 단순히 filter 루틴이 호출된 횟수를 기록하거나 filter가 추가 정보를 사용자 공간에 복사하는 것 등 다양합니다.

세 가지 루틴 모두 이벤트 소스를 조작하는 데 필요한 정보를 완전히 캡슐화합니다. 이 knote가 활성화되어야 하는지 여부를 filter에 묻는 것 외에는 kqueue 시스템의 다른 어떤 코드도 활동의 출처나 이벤트가 무엇을 나타내는지 알지 못합니다. 이 간단한 캡슐화 덕분에 새 filter를 추가하는 것만으로 시스템을 다른 이벤트 소스로 확장할 수 있습니다.

6.3 Activity on Event Source

활동이 발생하면(패킷 도착, 파일 수정, 프로세스 종료) 일반적으로 데이터 구조가 응답으로 수정됩니다. 이런 일이 발생하는 코드 경로 내에 kqueue 시스템에 대한 훅이 배치되며, 이는 knote() 호출의 형태를 취합니다. 이 함수는 필터에 대한 선택적 힌트와 함께 knote의 링크드 리스트(여기서는 klist라고 부릅니다)를 인수로 받습니다. 그런 다음 knote() 함수는 각 knote마다 필터 루틴을 호출하여 klist를 탐색합니다. knote에는 연결된 데이터 구조에 대한 참조가 포함되어 있으므로 필터는 이벤트를 보고해야 하는지 여부를 결정할 때 데이터 구조를 검사하도록 선택할 수 있습니다. 힌트는 필터가 검사하는 자료 구조에 없을 수 있는 추가 정보를 전달하기 위해 사용됩니다. 필터가 이벤트를 반환해야 한다고 결정하면 true 값을 반환하고 knote() 루틴은 해당 knote를 해당 kqueue의 활성 목록 끝 부분에 연결하여 애플리케이션이 검색할 수 있도록 합니다. knote가 이미 활성 목록에 있으면 아무 조치도 취하지 않지만 필터가 활동을 기록할 기회를 제공하기 위해 필터에 대한 호출이 발생합니다.

6.4 Delivery

kqueue_scan()이 호출되면 활성 목록의 끝에 특수 knote 마커를 추가하여 수행해야 할 작업의 양을 제한하고, 목록을 걷는 동안 이 마커가 큐에서 해제되면 스캔이 완료되었음을 나타냅니다. 그러면 활성 목록에서 knote가 제거되고 플래그 필드에서 EV_ONESHOT 플래그가 확인됩니다. 이 플래그가 설정되어 있지 않으면 쿼리 힌트와 함께 필터가 다시 호출되어 이벤트가 여전히 유효한지 확인하고 정확성을 보장할 수 있는 기회를 제공합니다. 이 경우의 근거는 소켓에 데이터가 도착하여 knote가 큐에 대기 중이지만 애플리케이션이 이벤트를 호출하기 전에 read()를 호출하고 소켓 버퍼를 비우는 경우입니다. knote가 여전히 큐에 대기 중이었다면 애플리케이션에 빈 버퍼를 읽으라는 이벤트가 반환될 것입니다. 이벤트가 큐에 대기열에 추가될 때 필터로 확인하면 정보가 최신 상태인지 확인할 수 있습니다. 또한 보류 중인 이벤트가 EV_DISABLE을 통해 비활성화되면 활성 큐에서 제거되는 것이 이 시점까지 지연된다는 점에 주목할 필요가 있습니다. 그런 다음 매듭의 정보가 이벤트 목록 내의 이벤트 구조에 복사되어 애플리케이션으로 반환됩니다. EV_ONESHOT이 설정되어 있으면 knote가 삭제되고 kq에서 제거됩니다. 그렇지 않으면 필터가 이벤트가 아직 활성 상태이고 EV_CLEAR가 설정되지 않았음을 나타내면 knote가 활성 목록의 끝에 다시 배치됩니다. knote는 이제 스캔을 종료하는 마커 뒤에 있기 때문에 다음 스캔까지 다시 검사되지 않습니다. 마커가 대기열에서 해제되거나 이벤트 목록에 더 이상 공간이 없을 때까지 작업이 계속되며, 이 경우 마커가 강제로 대기열에서 해제되고 루틴이 반환됩니다.

6.5 Micellaneous Notes

일반 파일 디스크립터는 kqueue를 참조하기 때문에, 일반적으로 디스크립터에서 수행할 수 있는 모든 연산에 참여할 수 있습니다. 애플리케이션은 select(), poll(), close() 또는 kqueue를 참조하는 이벤트를 생성할 수 있으며, 이러한 경우 활성 리스트에 큐에 대기 중인 knote가 있을 때 이벤트가 전달됩니다. 다른 kqueue에서 kqueue를 모니터링하는 기능을 사용하면 애플리케이션이 먼저 서비스할 kqueue를 선택하여 우선 순위 계층을 구현할 수 있습니다.

현재 구현은 새 자식이 rfork(RFFDG)를 통해 부모와 파일 테이블을 공유하지 않는 한 자식에게 kqueue 디스크립터를 전달하지 않습니다. 이것은 구현상의 세부 사항으로 볼 수 있습니다. 이 문제를 해결하려면 fork() 시점에 모든 knote 구조체의 복사본을 만들거나 쓰기 시 복사본으로 표시하는 것이 포함됩니다. knote는 링크드 리스트를 통해 모니터링 중인 자료 구조에 연결되는데, 이는 selinfo 구조 내에서 단일 pid를 기록하는 poll()select()의 동작과 대조적입니다. 이는 knote가 구현되는 방식에 따른 자연스러운 결과일 수도 있지만, kqueue 시스템이 선택 충돌에 취약하지 않다는 것을 의미하기도 합니다.

knote가 활성 리스트에서 큐에 대기 중이므로 해당 큐에서 잠자고 있는 프로세스만 깨어납니다.

힌트는 유형에 관계없이 리스트의 모든 필터에 전달되므로 단일 klist에 여러 이벤트 유형이 포함된 경우 힌트가 필터에 대한 활동을 고유하게 식별할 수 있도록 주의해야 합니다. PROC 및 SIGNAL 필터에서 이러한 예시를 볼 수 있습니다. 이러한 필터는 프로세스 구조에 매달려 있는 동일한 klist를 공유하며, 힌트 값은 활동이 시그널과 관련이 있는지 또는 프로세스와 관련이 있는지 결정하는 데 사용됩니다. 시스템에 제출되는 각 이벤트는 커널 공간으로 복사되고, 큐에 대기 중인 이벤트는 사용자 공간의 이벤트 리스트에 다시 복사됩니다. 복사 오버헤드가 약간 더 추가되지만, 커널이 사용자 공간에 유지되는 제어 블록의 상태를 직접 업데이트하는 AIO 스타일 솔루션보다 이 접근 방식이 선호되었습니다. 그 이유는 사용자가 실수로 해제하여 재사용할 수 있는 사용자 공간의 위치에 커널이 직접 쓰지 못하도록 하면 사용자가 애플리케이션에서 버그를 찾고 해결하는 것이 더 쉬워지기 때문입니다. 애플리케이션이 커널에 이벤트를 제출하고 추가 상태를 유지하지 않음으로써 “실행 후 잊어버림”을 선택할 수 있기 때문에 추가적인 이점이 있는 것으로 밝혀졌습니다.

9. Conclusion

많은 수의 이벤트를 처리하는 애플리케이션은 이벤트 알림 및 전달의 효율성에 따라 달라집니다. 이 백서에서는 일반적이고 확장 가능한 이벤트 알림 기능에 대한 설계 기준과 대체 API를 제시했습니다. 이 API는 FreeBSD에서 구현되었으며 2000년 4월에 메인 CVS 트리에 커밋되었습니다.

전반적으로 시스템은 기대에 부응하는 성능을 발휘하며, 이전에 select() 또는 poll()이 병목 현상을 일으켰던 애플리케이션은 kqueue를 사용함으로써 성능 향상을 경험했습니다. 필자는 이 시스템이 웹 서버, 웹 프록시 서버, irc 데몬, 넷뉴스 전송, 메일 서버 등 여러 주요 애플리케이션에서 사용되고 있는 것을 알고 있습니다.

이 문서에서 설명하는 구현은 OpenBSD에서 채택되었으며 NetBSD에도 도입되는 중이므로 API는 단일 운영 체제에 국한되지 않습니다. 이 백서의 측정은 주로 소켓 디스크립터에 집중되어 있지만, 다른 필터도 성능 향상을 제공합니다.

FreeBSD의 tail -f 명령은 이전에는 파일이 변경되었는지 확인하기 위해 1/4초마다 파일을 통계 처리하는 방식으로 구현되었습니다. 이 폴링 방식을 kq VNODE 필터로 대체하면 kqueue 이벤트 알림을 지원하는 기본 파일 시스템에서 오버헤드를 줄이면서 동일한 기능을 제공할 수 있습니다. AIO 필터는 AIO 요청이 완료되면 애플리케이션에 알림을 보내는 데 사용되므로 기본 디스패치 루프가 poll, aio_error, aio_suspend 호출의 조합 대신 단일 이벤트 호출로 단순화될 수 있습니다.

DNS 확인자 라이브러리 루틴(res_*)은 내부적으로 select()를 사용하여 네임서버의 응답을 기다리도록 명령합니다. 메일 전달을 위해 포스트픽스를 사용하는 FreeBSD 프로젝트의 부하가 많은 이메일 탐색기에서 시스템은 매우 많은 수의 select 충돌을 보고 있었고, 이로 인해 select()를 사용하는 모든 프로세스가 깨어나게 되었습니다. 리졸버 라이브러리를 kqueue를 사용하도록 변경한 것은 라이브러리 루틴 내에서 개인 kqueue를 사용한 성공적인 사례였으며, 선택 충돌을 제거하여 성능 향상을 가져왔습니다.

필자는 여러 이벤트 소스를 처리할 수 있는 다른 UNIX 시스템이나 추가 소스를 처리하기 위해 간단하게 확장할 수 있는 시스템을 알지 못합니다. 원래 구현이 출시된 이후 이 시스템은 장치 계층까지 확장되어 이제 장치별 이벤트도 처리할 수 있습니다. 이 기능을 위해 시스템에서 핫스왑 가능한 디바이스의 변경 사항을 사용자에게 알려주는 디바이스 관리자 애플리케이션이 계획되어 있습니다. 추가 중인 또 다른 필터로는 또한 사용자가 kqueue 필터를 사용하여 기록할 감사 이벤트(audit trail event)를 선택적으로 선택하도록 함으로써 고성능 커널 감사 추적 기능을 구현할 수 있습니다.

부록 & 참고자료

논문 원본

kevent: Generic event handling mechanism. [LWN.net]

man kevent(2)

man kqueue(9)

knote(9) - OpenBSD manual pages

[네이버클라우드 기술&경험] IO Multiplexing (IO 멀티플렉싱) 기본 개념부터 심화까지 -2부-

Leave a comment