블로킹 I/O에서 io_uring과 kqueue까지 살펴본 뒤, 시스템 호출 비용을 줄이기 위한 배치 처리와 콜백 기반 디스패처를 통해 전통적인 이벤트 루프 형태의 I/O 추상화를 만들어본다.
URL: https://tigerbeetle.com/blog/2022-11-23-a-friendly-abstraction-over-iouring-and-kqueue/
I/O와 성능에 관한 한 편의 이야기를 해보자. 블로킹 I/O에서 시작해 io_uring과 kqueue를 탐험한 다음, 어딘가 익숙한 소프트웨어와 매우 비슷한 이벤트 루프를 손에 쥐고 돌아가 보겠다.
이 글은 Software You Can Love Milan ’22에서의 King의 발표를 변주한 것이다.
파일에서 읽고 싶다면 보통 open()을 호출한 다음 파일에서 바이트 버퍼를 채울 때까지 read()를 필요한 만큼 여러 번 호출한다. 반대 방향으로는, 모두 써질 때까지 write()를 필요한 만큼 반복 호출한다. TCP 클라이언트도 비슷하지만, open() 대신 먼저 socket()을 호출하고 서버로 connect()한다. 재밌는 stuff다.
하지만 현실 세계에서는 파일 디스크립터로부터 원하는 것을 언제나 즉시 전부 읽을 수는 없다. 또한 파일 디스크립터에 원하는 것을 언제나 즉시 전부 쓸 수도 없다.
파일 디스크립터를 논블로킹 모드로 전환하면 요청한 데이터가 아직 없을 때 호출이 블록되지 않게 할 수 있다. 하지만 시스템 호출은 여전히 비싸다. 컨텍스트 스위치와 캐시 미스를 유발한다. 사실 네트워크와 디스크는 너무 빨라져서, 이런 비용이 실제 I/O 수행 비용에 근접하기 시작할 수 있다. 파일 디스크립터가 읽기/쓰기를 할 수 없는 동안에는 read나 write 시스템 호출을 계속 재시도하며 시간을 낭비하고 싶지 않다.
그래서 Linux에서는 io_uring으로, FreeBSD/macOS에서는 kqueue로 전환한다. (epoll/select 사용자 세대는 건너뛰겠다.) 이 API들은 “준비 상태(readiness)”—즉 파일 디스크립터가 읽기 또는 쓰기를 할 준비가 되었는지—를 커널에 요청으로 제출할 수 있게 해준다. 준비 상태 요청은 배치로(큐라고도 한다) 보낼 수 있다. 제출된 각 요청에 대해 하나씩 생성되는 완료(completion) 이벤트는 별도의 큐에서 얻을 수 있다.
이렇게 I/O를 배치 처리할 수 있는 능력은, 여러 연결된 클라이언트의 읽기/쓰기를 멀티플렉싱하려는 TCP 서버에서 특히 중요하다.
하지만 io_uring에서는 한 걸음 더 나아갈 수 있다. 준비 상태 이벤트가 발생한 뒤 userland에서 read()나 write()를 호출해야 하는 대신, 커널이 제공된 버퍼에 대해 read()나 write()를 직접 수행하도록 요청할 수 있다. 이렇게 하면 거의 모든 I/O가 커널에서 이루어지며, 시스템 호출 오버헤드를 상각(amortize)할 수 있다.
io_uring이나 kqueue를 처음 본다면 예제가 보고 싶을 것이다! 다음 코드를 보자. 단순하고 최소한이며, 프로덕션용은 아닌 TCP 에코 서버다.
const std = @import("std");
const os = std.os;
const linux = os.linux;
const allocator = std.heap.page_allocator;
const State = enum{ accept, recv, send };
const Socket = struct {
handle: os.socket_t,
buffer: [1024]u8,
state: State,
};
pub fn main() !void {
const entries = 32;
const flags = 0;
var ring = try linux.IO_Uring.init(entries, flags);
defer ring.deinit();
var server: Socket = undefined;
server.handle = try os.socket(os.AF.INET, os.SOCK.STREAM, os.IPPROTO.TCP);
defer os.closeSocket(server.handle);
const port = 12345;
var addr = std.net.Address.initIp4(.{127, 0, 0, 1}, port);
var addr_len: os.socklen_t = addr.getOsSockLen();
try os.setsockopt(server.handle, os.SOL.SOCKET, os.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
try os.bind(server.handle, &addr.any, addr_len);
const backlog = 128;
try os.listen(server.handle, backlog);
server.state = .accept;
_ = try ring.accept(@ptrToInt(&server), server.handle, &addr.any, &addr_len, 0);
while (true) {
_ = try ring.submit_and_wait(1);
while (ring.cq_ready() > 0) {
const cqe = try ring.copy_cqe();
var client = @intToPtr(*Socket, @intCast(usize, cqe.user_data));
if (cqe.res < 0) std.debug.panic("{}({}): {}", .{
client.state,
client.handle,
@intToEnum(os.E, -cqe.res),
});
switch (client.state) {
.accept => {
client = try allocator.create(Socket);
client.handle = @intCast(os.socket_t, cqe.res);
client.state = .recv;
_ = try ring.recv(@ptrToInt(client), client.handle, .{.buffer = &client.buffer}, 0);
_ = try ring.accept(@ptrToInt(&server), server.handle, &addr.any, &addr_len, 0);
},
.recv => {
const read = @intCast(usize, cqe.res);
client.state = .send;
_ = try ring.send(@ptrToInt(client), client.handle, client.buffer[0..read], 0);
},
.send => {
os.closeSocket(client.handle);
allocator.destroy(client);
},
}
}
}
}
훌륭하고 최소한의 예제다. 하지만 이 코드는 io_uring 동작을 비즈니스 로직(여기서는 요청과 응답 사이에서 데이터를 에코하는 처리)에 직접 결합한다는 점에 주목하자. 이런 작은 예제에서는 괜찮다. 그러나 큰 애플리케이션에서는 코드베이스 한 곳에서만이 아니라 곳곳에서 I/O를 하고 싶을 수 있다. 이 단일 루프에 계속 비즈니스 로직을 추가하고 싶지 않을 수도 있다.
대신, I/O를 스케줄링하고 이벤트가 완료되었을 때 호출될 콜백(때로는 어떤 애플리케이션 컨텍스트도 함께)을 전달할 수 있길 원할 것이다.
인터페이스는 다음처럼 생겼을 수 있다:
io_dispatch.dispatch({
// 모든 이벤트 타입에 대해 관련 필드를 담는 큰 struct/union
}, my_callback);
좋다! 이제 비즈니스 로직은 코드베이스 어디에 있든 I/O를 스케줄하고 처리할 수 있다.
내부적으로는 실행 중인 커널에 따라 io_uring 또는 kqueue를 사용할지 결정할 수 있다. dispatch는 이러한 개별 호출을 io_uring 또는 kqueue를 통해 배치로 묶어 시스템 호출을 상각할 수도 있다. 애플리케이션은 더 이상 세부사항을 알 필요가 없다.
추가로, 이 래퍼를 이용해 준비 상태 이벤트에 대해 생각하는 것을 멈추고 I/O “완료”만 생각할 수 있다. 즉, read 이벤트를 dispatch하면 io_uring 구현은 실제로 커널에게 버퍼로 데이터를 읽어오라고 요청한다. 반면 kqueue 구현은 “read 준비 상태” 이벤트를 보내고, userland로 돌아와 실제 read를 수행한 다음 콜백을 호출한다.
그리고 마지막으로, 중앙 디스패처가 생겼으니 이제 모든 가능한 제출/완료 이벤트에 대해 switch를 도는 루프에서 스파게티 코드를 만들 필요가 없다.
io_uring이나 kqueue를 호출할 때마다, 우리는 이벤트 요청을 제출하는 것과 완료 이벤트를 폴링하는 것을 동시에 수행한다. io_uring과 kqueue API는 이 두 동작을 같은 시스템 호출 안에서 함께 묶어둔다.
요청을 io_uring 또는 kqueue와 동기화하기 위해, 요청을 제출하고 완료 이벤트를 폴링하는 flush 함수를 만들겠다. (다음 섹션에서는 중앙 dispatch의 사용자가 완료 이벤트를 어떻게 알게 되는지 이야기하겠다.)
flush를 더 편리하게 만들기 위해, 가능한 한 많은 요청을 제출하고(그리고 가능한 한 많은 완료 이벤트를 처리하는) 깔끔한 래퍼를 만들겠다. 실수로 무기한 블록되는 것을 피하기 위해 시간 제한도 도입한다. 이 래퍼를 run_for_ns라고 부르자.
마지막으로 사용자가 일반적인 프로그램 실행과 독립적으로 이 run_for_ns 함수를 호출하는 루프를 설정하도록 한다.
이제 전통적인 이벤트 루프다.
아마 위 API에서 콜백을 전달한 것을 보았을 것이다. 아이디어는 요청한 I/O가 완료된 후 콜백이 호출되어야 한다는 것이다. 하지만 질문이 남는다: 제출 큐와 완료 큐 사이에서 이 콜백을 어떻게 추적할까?
다행히 io_uring과 kqueue 이벤트에는 사용자 데이터(user data) 필드가 있다. 사용자 데이터 필드는 커널 입장에서는 불투명(opaque)하다. 제출된 이벤트가 완료되면, 커널은 제출 이벤트의 사용자 데이터 값을 담아 완료 이벤트를 userland로 되돌려 보낸다.
콜백의 포인터를 정수로 캐스팅해 user data 필드에 넣어서 콜백을 저장할 수 있다. 요청된 이벤트의 완료가 올라오면, user data 필드의 정수에서 콜백 포인터로 다시 캐스팅한다. 그리고 콜백을 호출한다.
앞에서 설명했듯이 io_dispatch.dispatch용 struct는 다양한 종류의 I/O 이벤트와 그 인자를 다루느라 꽤 커질 수 있다. 각 이벤트 타입별 래퍼 함수를 만들면 API를 더 표현력 있게 만들 수 있다.
그래서 read 함수를 스케줄하고 싶다면 다음처럼 호출할 수 있다:
io_dispatch.read(fd, &buf, nBytesToRead, callback);
write도 마찬가지로:
io_dispatch.write(fd, buf, nBytesToWrite, callback);
한 가지 더 신경 써야 할 것은, io_uring이나 kqueue에 넘기는 배치의 크기는 고정되어 있다는 점이다(엄밀히 말하면 kqueue는 어떤 배치 크기도 허용하지만, 그렇게 하면 불필요한 할당을 유발할 수 있다). 그래서 I/O 추상화 위에 자체 큐를 만들어, io_uring 또는 kqueue에 즉시 제출할 수 없었던 요청들을 추적하겠다.
이 API를 단순하게 유지하려면 큐의 각 엔트리마다 할당할 수도 있다. 또는
io_dispatch.X호출을 약간 수정하여, 콜백을 포함한 모든 요청 컨텍스트를 담을 수 있는 침투형 연결 리스트(intrusive linked list)에 쓸 수 있는 struct를 받게 할 수도 있다. 후자는 TigerBeetle에서 우리가 하는 방식이다.
다르게 말하면: 코드가 io_dispatch를 호출할 때마다, 요청된 이벤트를 io_uring 또는 kqueue에 즉시 제출하려고 시도한다. 하지만 자리가 없으면 오버플로 큐에 이벤트를 저장한다.
오버플로 큐는 언젠가 처리되어야 한다. 그래서(위의 콜백과 컨텍스트에서 설명한) flush 함수를 업데이트해서, io_uring 또는 kqueue에 배치를 제출하기 전에 오버플로 큐에서 가능한 많은 이벤트를 끌어오도록 한다.
이제 Node.js가 사용하는 I/O 라이브러리인 libuv와 비슷한 무언가를 만들었다. 그리고 눈을 가늘게 뜨고 보면, 기본적으로 TigerBeetle의 I/O 라이브러리다! (흥미롭게도 TigerBeetle의 I/O 코드는 Bun에 채택되었다! 오픈소스 만세!)
TigerBeetle의 I/O 라이브러리에서 (kqueue를 사용하는) Darwin 버전이 (io_uring을 사용하는) Linux 버전과 어떻게 다른지 살펴보자. 언급했듯이 Darwin 구현의 완전한 send 호출은( kqueue를 통해) 파일 디스크립터의 준비 상태를 기다린다. 준비가 되면 실제 send 호출은 userland에서 수행된다:
pub fn send(
self: *IO,
comptime Context: type,
context: Context,
comptime callback: fn (
context: Context,
completion: *Completion,
result: SendError!usize,
) void,
completion: *Completion,
socket: os.socket_t,
buffer: []const u8,
) void {
self.submit(
context,
callback,
completion,
.send,
.{
.socket = socket,
.buf = buffer.ptr,
.len = @intCast(u32, buffer_limit(buffer.len)),
},
struct {
fn do_operation(op: anytype) SendError!usize {
return os.send(op.socket, op.buf[0..op.len], 0);
}
},
);
}
이를 Linux 버전 (io_uring 사용)과 비교해보자. 여기서는 커널이 모든 것을 처리하므로 userland에는 send 시스템 호출이 없다:
pub fn send(
self: *IO,
comptime Context: type,
context: Context,
comptime callback: fn (
context: Context,
completion: *Completion,
result: SendError!usize,
) void,
completion: *Completion,
socket: os.socket_t,
buffer: []const u8,
) void {
completion.* = .{
.io = self,
.context = context,
.callback = struct {
fn wrapper(ctx: ?*anyopaque, comp: *Completion, res: *const anyopaque) void {
callback(
@intToPtr(Context, @ptrToInt(ctx)),
comp,
@intToPtr(*const SendError!usize, @ptrToInt(res)).*,
);
}
}.wrapper,
.operation = .{
.send = .{
.socket = socket,
.buffer = buffer,
},
},
};
// 가능하면 즉시 submission을 채우고, 아니면 overflow buffer에 추가
self.enqueue(completion);
}
비슷하게, 이벤트 처리를 위한 flush도 Linux와 macOS에서 살펴보자. 공개 API로서 사용자가 호출해야 하는 run_for_ns도 Linux와 macOS에서 비교해보자. 그리고 마지막으로, 이 모든 것을 실제로 돌리는 run_for_ns를 호출하는 루프는 src/main.zig에 있다.
여기까지 와서 이런 생각을 할 수도 있다 — Windows를 위한 크로스플랫폼 지원은 어떨까? 좋은 소식은 Windows도 io_uring과 유사한 “완료 기반” 시스템을 갖고 있다는 것이다. 다만 배치 처리는 없으며 IOCP라고 부른다. 덤으로, TigerBeetle은 그 위에도 같은 I/O 추상화를 제공한다! 하지만 이 글에서는 Linux와 macOS만 다루는 것으로 충분하다. :)
이 블로그 글과 TigerBeetle 모두에서, 우리는 단일 스레드 이벤트 루프를 구현했다. user space에서 I/O 코드를 단일 스레드로 유지하는 것은 유익하다(커널에서 I/O 처리가 단일 스레드인지 여부는 우리가 신경 쓸 바가 아니다). 코드가 가장 단순하고, embarrassingly parallel하지 않은 워크로드에 가장 좋다. 또한 결정성(determinism)에도 가장 좋다. TigerBeetle의 설계에서 결정성은 필수인데, 이것이 결정적 시뮬레이션 테스팅(Deterministic Simulation Testing)을 가능하게 해주기 때문이다.
하지만 다른 워크로드에는 다른 아키텍처도 타당하다.
많은 웹 서버처럼 embarrassingly parallel인 워크로드의 경우, 각 스레드가 자기 큐를 갖는 다중 스레드를 사용할 수도 있다. 최적의 조건에서는 이 아키텍처가 가능한 최고 I/O 처리량을 제공한다.
하지만 각 스레드가 자기 큐를 갖는다면, 특정 스레드에 불균등한 작업이 스케줄될 경우 개별 스레드가 기아(starvation) 상태가 될 수 있다. 작업량이 동적으로 변하는 경우 더 나은 아키텍처는 단일 큐를 두고, 그 큐에 올라온 작업을 여러 워커 스레드가 처리하는 방식이다.
이걸 분리해서 여러분도 사용할 수 있게 할지도 모른다. Zig로 작성되어 있어서 C API를 쉽게 노출할 수 있다. C FFI(즉, 모든 언어)에 기반한 어떤 언어라도 잘 연동될 것이다. 우리 GitHub를 지켜봐 달라. :)
추가 자료: