Zig가 새로 도입한 Io 인터페이스는 동시성을 다루기 위한 추상화 계층이다. 그 구현체 가운데 하나인 std.Io.Threaded는 io_uring 같은 최신 비동기 API에 기대지 않고, 이름 그대로 "그냥 스레드를 쓰는" 평범한 방식을 택한다. 그런데도 이 구현이 주목받는 이유는 하나다. 블로킹 시스템 콜을 그대로 쓰면서도 진행 중인 작업을 확실하게 취소(cancelation)할 수 있기 때문이다. 이는 오랫동안 많은 언어와 런타임이 제대로 풀지 못한 문제다.
병렬성과 동시성은 다르다
먼저 개념을 구분할 필요가 있다. 병렬성(parallelism)은 결정적이고 선언적이다. 문제를 서로 독립적인 파티션으로 나누는 방법을 기술하고, 파티션 하나를 처리하는 함수만 작성하면, 나머지는 플랫폼이 책임진다. 즉 파티션이 실제로 경쟁 상태 없이 독립적인지 검증하고, 전부 처리한 뒤 제어권을 돌려주는 것이 플랫폼의 몫이다.
반면 동시성(concurrency)에는 취소가 본질적으로 따라붙는다. 두 개의 비동기 계산이 동시에 진행될 때, 어느 순간 한쪽이 다른 쪽 계산이 더 이상 필요 없다는 사실을 알게 되고, 그 계산을 능동적으로 중단시켜야 하는 시점이 온다. 이때 상대가 끝나기를 기다리는 것으로는 해결되지 않는 경우가 많다. 애초에 취소하려는 이유가, 그 계산이 영영 오지 않을 메시지를 기다리는 등 스스로 완료될 수 없는 상태에 빠졌기 때문일 때가 많기 때문이다.
블로킹 시스템 콜이라는 벽
문제의 핵심은 시스템 콜에 있다. 루프를 도는 코드라면 중간에 플래그를 확인해 빠져나오게 만들기 쉽다. 하지만 스레드가 커널 내부의 시스템 콜 안에서 블로킹되어 있으면, 일반적인 프로그래밍 언어 API로는 그 스레드를 깨울 방법이 없다. 표준 OS 스레드와 블로킹 API를 쓰면서도 어떤 작업이든 안정적으로 취소할 수 있다면 이상적일 텐데, std.Io.Threaded가 바로 그것을 제공한다.
POSIX에서 이 동작은 다소 편법적이다. 커널이 블로킹 시스템 콜을 취소하는 우회 경로를 시그널(signal) 형태로 제공하기 때문이다. 스레드가 커널에서 블로킹된 상태에서 시그널이 전달되면, 스레드가 깨어나고 시스템 콜은 EINTR를 반환한다. 관행적으로는 이 경우 시스템 콜을 다시 시도하며 루프를 돌지만, 반드시 그래야 하는 것은 아니다. 다만 시그널 자체는 취소 메커니즘이 아니다. 시그널 전달은 본질적으로 경쟁적이어서, 문제의 시스템 콜이 시작되기 전이나 끝난 후에 도착할 수도 있고, 취소와 무관한 시그널이 시스템 콜을 중단시킬 수도 있다.
그래서 실제 프로토콜은 공유 메모리의 플래그를 함께 쓴다. 취소를 요청하는 스레드가 공유 메모리에 취소 요청 플래그를 세우고, 취소가 확인(플래그가 다른 값으로 바뀜)될 때까지 대상 스레드에 반복적으로 시그널을 보낸다. 취소 대상 스레드는 시스템 콜에서 EINTR를 받으면 플래그 값을 확인해, 시스템 콜을 재시도하거나 취소를 확인한 뒤 되감기(unwinding)를 시작한다. 사용자 코드 입장에서 취소 요청은 error.Canceled로 구체화된다. 여기서 취소는 뜻밖의 성공이어서 에러가 아닌 것이 아니라, 반대로 에러가 곧 취소에 페이로드가 더해진 것이라는 관점을 취한다.
기존 접근과의 차이
다른 플랫폼과 비교하면 설계의 의미가 분명해진다. Windows에는 NtCancelSynchronousIoFile이라는 훨씬 직접적인 방법이 있다. 파이버, IO Completion Port, Job 오브젝트까지 고려하면 NT 계열이 유닉스보다 동시성에 대해 더 잘 정돈된 그림을 가지고 있다는 평가도 가능하다. Java의 스레드 인터럽트 메커니즘은 겉보기에 비슷하지만 시스템 콜 인터럽트를 지원하지 못한다. IOException과 InterruptedException이 서로 무관한 체크 예외로 나뉘어 있어 IO 함수가 인터럽트 대상이 되지 못하기 때문이다. Zig의 reader/writer 인터페이스는 에러를 완전히 타입 소거(type erase)하기에 취소를 지원하지만, 그만큼 올바르게 다루려면 flush를 잊지 않는 것 외에도 추가적인 주의가 필요하다.
pthread_cancel 역시 시그널과 플래그를 결합한 유사한 기계 장치를 쓴다. 그러나 언어 수준의 취소(try, defer)와 통합되지 않아 취소 이후의 정리 작업이 번거롭고 느리다. 게다가 스레드 전체를 파기해 버리는데, 스레드 생성이 여전히 느리고 시스템이 허용하는 스레드 수 상한이 대체로 낮은 현실을 감안하면 이는 부담이다. 동시성을 둘러싼 상당수의 고충은, 그것이 커널과 런타임과 언어 사이의 경계 지대에 걸쳐 있다는 사실에서 비롯된다. CPU 관점에서 동시성은 사실상 환상에 가깝고, 그 환상의 작성 책임이 여러 층에 흩어져 있다.
스레드 풀과 의도의 구분
Zig의 Io는 스레드 파기 문제를 인터페이스 수준에서 "동시에 실행될 수 있음"과 "반드시 동시에 실행되어야 함"을 분리하는 방식으로 풀어낸다. io.async와 io.concurrent라는 이름으로 실제 벌어지는 일을 명시함으로써, 무엇이 일어나는지 이해하기 쉬워지고 시그니처도 더 정밀해진다. concurrent는 항상 실패 가능(fallible)한 반면 async는 결코 실패하지 않는다. concurrent는 스레드 풀을 기반으로 하되 풀이 고갈된 경우에만 새 스레드를 생성하는 방식으로 폴백한다. 이는 C++의 std::launch 정책과 비슷한 효과를 낸다.
실무자 입장에서 이 설계가 던지는 메시지는 분명하다. 화려한 신기술 없이 익숙한 블로킹 API와 OS 스레드만으로도, 언어 차원의 통합을 갖춘 신뢰할 수 있는 취소가 가능하다는 점이다. 다만 이는 아직 진화 중인 Zig 표준 라이브러리의 일부이며, 타입 소거된 에러 처리나 flush 누락처럼 사용자가 직접 감당해야 하는 주의 사항이 남아 있다. 취소를 언어 설계의 일급 시민으로 끌어올리려는 시도가 실제 코드에서 어떤 함정을 남기는지는, 이 인터페이스가 더 널리 쓰이면서 검증될 부분이다.
TTJ 코딩클래스 정규반
월급 외 수입,
코딩으로 만들 수 있습니다
17가지 수익 모델을 직접 실습하고, 1,300만원 상당의 자동화 도구와 소스코드를 받아가세요.
"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"
실제 수강생 후기- 비전공자도 6개월이면 첫 수익
- 20년 경력 개발자 직강
- 자동화 프로그램 + 소스코드 제공