Zig의 std.Io.Threaded가 블로킹 시스템 호출을 사용하면서도 취소를 완전히 지원하는 방식을 살펴봅니다.
2026년 8월 6일
std.Io.Threaded는 동시성을 가능하게 하는 Zig의 새로운 Io 인터페이스 구현체 중 하나입니다. 이는 지루한, “그냥 스레드를 사용하라”는 구현체입니다. 하지만 개인적으로는 멋지다고 생각합니다. 제가 오랫동안 하고 싶었던 이 이상한 일을 하는데, 제가 아는 한 누구도 제대로 하고 있지 않으며, 가능하다고 생각했던 것보다 더 잘 구현했습니다.
Io.Threaded는 블로킹 시스템 호출을 사용하며 취소를 완전히 지원합니다.
@tedinski의 말을 인용하면,
이 정의는 옳다고 생각하지만, 곧바로 유용한 직관을 제공하지는 않습니다. 동시성은 상태 변환기와 같은 것인가요? 네, 당연히 그렇지만, 실제로 그것을 어떻게 프로그래밍할지에 대해서는 별로 밝혀 주지 않습니다.
직관을 위해 저는 다음 두 가지 판별 기준을 좋아합니다. 첫째, 병렬성은 결정적이거나 “선언적”입니다.
use rayon::prelude::*;
fn sum_of_squares(input: &[i32]) -> i32 {
input.par_iter()
.map(|i| i * i)
.sum()
}
문제를 독립적인 분할로 나누는 방법을 기술하고, 한 번에 하나의 분할을 처리하는 함수를 구현합니다. 분할이 올바른지(경쟁 상태가 없는지) 검증하고, 모든 분할을 처리하며, 그 작업이 끝나면 제어권을 돌려주는 것은 플랫폼의 일입니다.
둘째, 동시성에는 예외 없이 취소가 수반됩니다. 두 비동기 계산이 동시에 일어날 때마다, 한 계산이 다른 계산이 더는 필요 없다는 사실을 인지하고 그것을 능동적으로 취소해야 하는 순간이 옵니다. 일반적으로 다른 계산이 완료될 때까지 그냥 기다릴 수는 없습니다. 애초에 그것을 취소하려는 이유가 바로 그것이 완료될 수 없다는 사실을 알게 되었기 때문인 경우가 많습니다(예를 들어, 절대 받지 못할 메시지를 기다리고 있을 수 있습니다).
그리고 이것이 다음 방식의 문제입니다.
물론 문제가 더 있습니다. 가장 큰 문제는 아주 많은 스레드를 생성할 수는 있지만, 그렇게 하려면 흔히 시스템 전체 설정을 바꿔야 하며, 대부분의 애플리케이션에서는 이는 받아들일 수 없다는 점입니다. 하지만 취소가 없으면 언젠가는 반드시 벽에 부딪힙니다. 문제는 시스템 호출입니다. 반복문 안의 코드라면 다음과 같은 처리는 충분히 쉽습니다.
while (true) {
if (is_canceled()) return error.Canceld; /// Easy!
...
}
하지만 스레드는 대신 커널 내부의 시스템 호출에서 블로킹되며, 프로그래밍 언어 API는 일반적으로 이를 해제할 방법을 제공하지 않습니다.
const read_size = try read(fd, buffer); // ???
표준 운영체제 스레드와 블로킹 API를 그대로 사용하고, io_uring 같은 새로운 멋진 기능은 피하면서도, 어떤 작업이든 안정적으로 취소할 수 있다면 멋지지 않을까요? 바로 그것이 Zig의 std.Io.Threaded가 제공하는 것입니다.
POSIX에서 이것이 작동하는 방식은 조금 기묘합니다. 커널은 실제로 블로킹 시스템 호출을 취소하는 우회적인 방법, 즉 시그널을 제공합니다. 스레드가 커널에서 블로킹된 상태일 때 해당 스레드로 시그널이 전달되면, 스레드는 깨어나고 시스템 호출은 EINTR을 반환합니다. 이런 경우에는 그냥 반복하여 시스템 호출을 재시도하는 것이 관례이지만, 반드시 그럴 필요는 없습니다.
시그널만으로는 취소 메커니즘이 아닙니다. 스레드에 시그널을 보내는 일에는 본질적으로 경쟁 조건이 있습니다. 관련 시스템 호출이 시작되기 전에 시그널이 전달될 수도 있고, 끝난 뒤에 전달될 수도 있습니다. 반대로 취소와 무관한 시그널에 의해 시스템 호출이 중단될 수도 있습니다.
실제 프로토콜에서는 취소하는 스레드가 공유 메모리의 플래그를 설정해 취소를 요청하고, 취소가 확인될 때까지(공유 메모리 플래그의 다른 값) 반복해서 취소 대상에 시그널을 보냅니다. 취소될 가능성이 있는 스레드는 시스템 호출에서 EINTR을 받으면 플래그 값을 확인하고, 시스템 호출을 재시도하거나 취소를 확인한 뒤 스택 해제를 시작합니다. 프로토콜의 두 절반은 signalCanceledSyscall 및 예를 들어 fileReadPositionalPosix를 참고하세요.
사용자 측에서 취소 요청은 error.Canceled로 구체화됩니다. 기능으로서의 오류 관리는 취소, 분기, 보고의 조합이며, Zig는 앞의 두 가지를 구현합니다. 취소가 오류가 아닌 이유는 그것이 우연한 성공이기 때문이 아니라, 반대로 오류란 페이로드가 추가된 취소이기 때문입니다.
Windows에는 훨씬 직접적인 NtCancelSynchronousIoFile이 있습니다. 이름이 마음에 듭니다! 일반적으로 파이버, IO Completion Ports, Job 객체, 그리고 이것을 보면 NT는 Unix보다 동시성에 대해 더 잘 숙고한 설계를 갖춘 듯합니다.
Java에는 비슷해 보이는 스레드 인터럽트 메커니즘이 있습니다. 결정적으로, 이것은 시스템 호출 인터럽트를 지원하지 않습니다. IOException과 InterruptedException은 모두 검사 예외이고 서로 관련이 없으므로, IO 함수는 인터럽트할 수 없습니다. Zig에서는 리더와 라이터 인터페이스가 오류를 완전히 타입 소거하므로 취소를 지원합니다. 다만 이를 올바르게 처리하려면 일반적인 플러시를 잊지 말 것에 더해 약간의 추가 주의가 필요합니다.
pthread_cancel은 비슷한 시그널+플래그 장치를 구현합니다. 그러나 언어 수준의 취소(try, defer)와 통합되지 않으므로, 취소 후 정리가 번거롭고 느립니다. 더 일반적으로, 동시성을 둘러싼 많은 불안은 동시성이 정확히 커널, 런타임, 언어 사이의 황혼 지대에 놓여 있다는 사실에서 비롯됩니다. CPU에는 (인터럽트를 제외하면) 동시성이 거의 없고, 이는 여러 주체가 함께 만들어 낸 환상입니다. 보통은 언어가 이 문제를 다루기에 더 잘 갖춰져 있지만, 전통적으로는 커널과 libc가 이를 처리해 왔으며, 이는 언어 설계에 악영향을 미쳤습니다.
pthread_cancel의 또 다른 문제는 스레드 전체를 종료한다는 점입니다. 스레드가 저렴하다면 이는 괜찮은 일이었을 것입니다. 하지만 스레드 생성은 여전히 느리고, 설정된 시스템 스레드 수 제한은 일반적으로 낮기 때문에 운영체제 스레드를 풀링하는 것이 대개 좋은 생각입니다. Zig의 Io는 인터페이스 수준에서 “동시에 실행될 수 있음”과 “동시에 실행되어야 함”을 분리해 이 문제를 독창적으로 해결합니다.
https://kristoff.it/blog/asynchrony-is-not-concurrency/
이는 std::launch``policy와 비슷한 효과를 냅니다(가지고 있다면 effective modern C++의 항목 36). 일어나고 있는 일을 이름으로 명시함으로써(io.async 대 io.concurrent), Zig는 실제 진행 상황을 이해하기 쉽게 만들고 더 정확한 시그니처도 제공합니다(concurrent는 항상 실패할 수 있고, async는 절대 그렇지 않습니다). 물론 concurrent는 스레드 풀을 기반으로 하며, 풀이 소진될 때만 새 스레드를 생성하는 방식으로 대체합니다.