io_uring을 사용해서 쓰기 작업을 제출할 때, 메모리 위치가 해제되거나 덮어쓰기 되지 않도록 해야 하는데, io-uring crate API는 Rust의 borrow checker가 이 부분에서 도움을 주지 않고 런타임 체크도 없는 것으로 보임
이런 상황에 대해 쓴 글과 댓글들을 봤는데, 결과적으로 io_uring을 감싼 안전한 Rust 비동기 라이브러리 만들기가 정말 어렵다는 인상임
tokio 팀의 Alice가 최근에는 이 문제를 극복하려는 관심이 크지 않다고 언급한 것도 기억남
지금 성능이 "충분히 좋음" 상태이기 때문임
참고: https://boats.gitlab.io/blog/post/io-uring/
Rust async에 대해 아쉬운 점이 많은데, 그중 하나가 이런 부분임
Rust async는 epoll이 표준이던 시기에 설계됐고 IOCP에는 신경을 거의 안 썼음
동기 syscall에는 이런 문제가 없는 이유는, read 호출 시 버퍼의 가변 참조를 커널에 넘기지만 네이티브 Rust의 ownership/borrow 모델과 잘 맞음
그런데 completion-based IO는 소유권 모델에 제대로 맞추려면, 작업이 완료될 때까지 사용자 코드가 계속 실행되지 않음을 보장해야 하고, 이걸 state machine polling 구조로는 할 수 없음
스레딩 모델이나 green thread 구조가 여기서는 딱 맞음
만약 Rust가 "async 전용 타겟"을 추가했다면 더 나았을 것 같음
Rust 개발진이 비동기 stackless polling 모델에 많은 기대를 걸었는데, 그 결말을 지켜보고 있는 중임
Rust의 borrow checker가 제대로 지원하지 못하는 소유권 모델이 있다고 생각함
임시로 “핫 포테이토 소유권”이라고 부르는데, 버퍼를 잠시 넘겨줬다가 다시 돌려받는 구조임
Rust로 이런 패턴을 안전하게 코드로 짜려니 되게 어렵고 코드가 난잡해짐
tokio팀 Alice의 말과는 달리, 파일 IO 쪽에선 관심이 있음
파일 IO는 이미 spawn_blocking 방식으로 구현해서 io_uring의 동일한 버퍼 이슈를 겪고 있고, io_uring으로 옮기는 건 그리 어렵지 않음
하지만 tokio::net의 기존 API는 io_uring 기반 버퍼 API와 호환되지 않아서, readiness 체크는 할 수 있어도 완전한 지원은 어려움
안전한 io_uring 인터페이스를 만들려면, 링에서 소유한 버퍼를 받아 쓰고, 쓰기를 시작할 때 다시 돌려주는 방식이 가장 적합하다고 생각함
이번 글 정말 재밌게 읽었음
성능 테스트가 기대되지만, 작성자가 벤치마크보다 먼저 코드를 깔끔하게 정비하겠다고 한 점이 인상 깊었음
벤치마크만 중시하는 이 시대에 이렇게 고민하는 사람이 있다는 게 신선함
11살 무렵 데이터베이스를 구축하려는 시도에서 cgi-bin을 접했고, 그게 요청마다 새 프로세스를 띄우던 방식이었다는 걸 이제야 깨달음
sendfile이 대형 게임 포럼에서 데모 다운로드를 동시에 처리할 때 게임체인저였고, Netflix의 40ms 감소 사례나 GTA 5의 70% 로딩 타임 단축 등 결과를 보며 더 임팩트 있는 엔지니어링이 숨겨져 있다고 느낌
관련 링크: Common Gateway Interface, Netflix 40ms 사례, GTA Online 로딩 단축
CGI뿐 아니라 옛날 CERN, Apache 계열 HTTP 세션은 서버 전체를 포크해서 동작시켰음
시간이 지나면서 나아졌지만, Apache의 구성 방식 때문에 nginx처럼 애초에 이벤트 기반 I/O로 설계된 경량 서버가 큰 인기를 얻게 됨
sendfile의 효율성에는 회의적임
90년대 말에 유행하긴 했지만 실제로는 성능 이득이 미미하다고 생각함
대부분의 클라우드 워크로드 오케스트레이터(CloudRun, GKE, EKS, 로컬 Docker 등)는 io_uring을 기본적으로 비활성화함
이 부분이 개선되지 않으면 당분간은 io_uring이 매우 한정된 기술로 남을 것 같음
왜 그들은 io_uring을 비활성화 하는 걸까라는 의문이 듬
이런 상황이라면 다시 셀프 호스팅으로 돌아가야 함
정말 재미있게 읽었음
벤치마크를 기다릴 테니 천천히 해도 되고, 벤치마크보다 먼저 코드 정리를 중시하는 저자의 마인드가 너무 인상적임
요즘은 벤치마크 점수에 올인하는 프로젝트가 많은데, 이런 사고방식이 정말 신선하고 존경스러움
ktls나 io_uring이 이렇게까지 다양하게 쓰일 수 있는지는 몰랐음
현 시점의 비동기 처리 상황은 아래와 같음
Rust: Futures, Pin, Waker, async runtime, Send/Sync bounds, async 트레이트 오브젝트 등 다양한 개념 이해 필요
C++20: coroutines
Go: goroutines
Java21+: 가상 스레드
C++ 코루틴은 Pin이 해결하는 문제를 피하려고 힙 할당을 사용함
이건 C++가 추구하는 "제로 오버헤드" 원칙에서 크게 벗어난 부분임
Rust가 미래에도 async 트레이트를 도입하는 데 시간이 오래 걸렸던 이유도 Rust는 futures를 힙 할당하지 않기 때문임
퍼포먼스/이식성 대 복잡성의 트레이드오프가 각자 프로젝트에 따라 가치가 달라질 수 있음
Send/Sync 관련 제약은 다른 언어에도 여전히 의미가 있고, 해당 제약이 없으면 미묘하게 잘못된 코드를 더 쉽게 쓸 수 있음
"충분히 괜찮은" 수준의 Rust 코드를 쓴다면, 그리고 남이 만들어놓은 mid-level 프리미티브를 사용한다면, 굳이 저런 개념을 전부 알 필요는 없음
Rust는 저런 개념을 이해하지 않으면 아예 컴파일이 안되도록 강제함
Go는 goroutine이 비동기가 아니고, 채널을 이해하지 않으면 goroutine을 이해할 수 없음
Go의 채널 구현은 독특해서 경계 사례의 동작이 상식적으로 예측이 잘 안 됨
Go는 깊이 이해 안 해도 코딩이 되니, 장단점이 있음
"저렴한 스레드"는 비동기와 동일하지 않음
tarweb(블로그에 나온 서버)은 io_uring 기반 이벤트 루프의 싱글 스레드 구조로, CPU 코어당 하나씩 스레드를 두는 아이디어임
"대규모 동시성의 현주소"보단 "저렴한 스레드의 현주소"가 더 맞는 말일 듯함
cheap thread와 async loop의 가장 큰 차별점은 reasoning이 쉬운 것임
단점도 있는데, 각 스레드는 가볍긴 해도 스택 크기를 필요로 함
이 프로젝트가 정말 멋지고 오래 전부터 비슷한 걸 구상해왔기 때문에 누군가 구현해서 기쁨
BPF도 rust로 작성하려면 Aya를 추천함 Aya 프로젝트 Github
kTLS의 현재 상태가 궁금함
얼마 전 Cilium 개발자에게 물어보니, Thomas Graf는 기대한다고 했지만 실제로는 많은 리눅스 배포판에서 커널 지원이 부족해 기본 활성화는 아직 멀었다고 함
아쉽긴 한데, 활성화가 얼마나 어려운지도 궁금함
커널을 커스텀해야 하는 건지, 아니면 런타임에서 바로 켤 수 있는 건지
FreeBSD는 13버전부터 커널/openssl에 kTLS가 들어갔고, sysctl (kern.ipc.tls.enable=1)로 런타임 토글이 가능함
FreeBSD-15에서는 기본값이 활성화로 바뀌고, Netflix에서는 거의 10년 동안 트래픽 암호화에 kTLS를 써옴
kTLS는 전반적으로 나쁜 아이디어처럼 느껴짐
한 코어당 하나의 스레드 구조가 타임 슬라이스 기반 시스템에서 맞는지 의문임
내 경험상으로는 "오버서브스크라이빙" 방식(코어 수보다 스레드를 더 두는 것)이 실제 벽 시계 시간 상의 이득을 줌
프리엠티브 스케줄링이 없을 때나 한 코어당 하나가 더 맞을 듯함
물론 그럼 Unix 얘기는 아님
낮은 지연과 높은 처리량을 원한다면 코어를 격리해서 스레드를 고정시키는 방법이 효과적임
이런 방식은 Linux에서 잘 작동하고, 트레이딩 시스템 등에서는 비효율을 감수하고서라도 많이 씀
코어들이 대부분 유휴 상태로 spin하고 실제로는 일이 없지만, latency와 throughput에서는 최적임
thread-per-core 구조의 함정은 "편리한 부분만 가져다 쓰자"라고 착각하는 거임
사실상 올인하거나 안 쓰거나 둘 중 하나임
반쪽짜리 구현은 전혀 효율이 나지 않음
다만 올바르게 설계하면 거의 모든 상황에서 효율이 높음
TPC 설계 노하우(코어 간 로드밸런싱 등)을 잘 아는 개발자는 드묾
thread-per-core에서 "CPU 바운드"일 때만 효율적임
이 서버 프로젝트처럼 대부분의 작업이 비동기적이고 이벤트 기반일 때, 서버는 거의 I/O나 syscall 대기 없이 다음 요청으로 이동해서 이론적으로는 코어당 하나의 스레드가 정확한 구조임
하지만 현실 세계에선 이런 이상적 상황이 거의 없으니 무조건 nproc 스레드로 제한하는 건 위험하다는 점을 명심해야 함
io_uring에서는 한 코어당 사용자 스레드 하나만 두는 것도 나쁜 선택이 아니라고 보임
커널에서 쓰레드 풀로 동작하기 때문임
Hacker News 의견
io_uring을 사용해서 쓰기 작업을 제출할 때, 메모리 위치가 해제되거나 덮어쓰기 되지 않도록 해야 하는데, io-uring crate API는 Rust의 borrow checker가 이 부분에서 도움을 주지 않고 런타임 체크도 없는 것으로 보임
이런 상황에 대해 쓴 글과 댓글들을 봤는데, 결과적으로 io_uring을 감싼 안전한 Rust 비동기 라이브러리 만들기가 정말 어렵다는 인상임
tokio 팀의 Alice가 최근에는 이 문제를 극복하려는 관심이 크지 않다고 언급한 것도 기억남
지금 성능이 "충분히 좋음" 상태이기 때문임
참고: https://boats.gitlab.io/blog/post/io-uring/
Rust async에 대해 아쉬운 점이 많은데, 그중 하나가 이런 부분임
Rust async는 epoll이 표준이던 시기에 설계됐고 IOCP에는 신경을 거의 안 썼음
동기 syscall에는 이런 문제가 없는 이유는, read 호출 시 버퍼의 가변 참조를 커널에 넘기지만 네이티브 Rust의 ownership/borrow 모델과 잘 맞음
그런데 completion-based IO는 소유권 모델에 제대로 맞추려면, 작업이 완료될 때까지 사용자 코드가 계속 실행되지 않음을 보장해야 하고, 이걸 state machine polling 구조로는 할 수 없음
스레딩 모델이나 green thread 구조가 여기서는 딱 맞음
만약 Rust가 "async 전용 타겟"을 추가했다면 더 나았을 것 같음
Rust 개발진이 비동기 stackless polling 모델에 많은 기대를 걸었는데, 그 결말을 지켜보고 있는 중임
Rust의 borrow checker가 제대로 지원하지 못하는 소유권 모델이 있다고 생각함
임시로 “핫 포테이토 소유권”이라고 부르는데, 버퍼를 잠시 넘겨줬다가 다시 돌려받는 구조임
Rust로 이런 패턴을 안전하게 코드로 짜려니 되게 어렵고 코드가 난잡해짐
tokio팀 Alice의 말과는 달리, 파일 IO 쪽에선 관심이 있음
파일 IO는 이미 spawn_blocking 방식으로 구현해서 io_uring의 동일한 버퍼 이슈를 겪고 있고, io_uring으로 옮기는 건 그리 어렵지 않음
하지만 tokio::net의 기존 API는 io_uring 기반 버퍼 API와 호환되지 않아서, readiness 체크는 할 수 있어도 완전한 지원은 어려움
안전한 io_uring 인터페이스를 만들려면, 링에서 소유한 버퍼를 받아 쓰고, 쓰기를 시작할 때 다시 돌려주는 방식이 가장 적합하다고 생각함
꼭 모든 것을 borrows로 표현할 필요 없음
Slab 같은 데이터 구조를 사용하면 cancel safe하게 만들 수 있음
참고: https://github.com/steelcake/io2
이번 글 정말 재밌게 읽었음
성능 테스트가 기대되지만, 작성자가 벤치마크보다 먼저 코드를 깔끔하게 정비하겠다고 한 점이 인상 깊었음
벤치마크만 중시하는 이 시대에 이렇게 고민하는 사람이 있다는 게 신선함
11살 무렵 데이터베이스를 구축하려는 시도에서 cgi-bin을 접했고, 그게 요청마다 새 프로세스를 띄우던 방식이었다는 걸 이제야 깨달음
sendfile이 대형 게임 포럼에서 데모 다운로드를 동시에 처리할 때 게임체인저였고, Netflix의 40ms 감소 사례나 GTA 5의 70% 로딩 타임 단축 등 결과를 보며 더 임팩트 있는 엔지니어링이 숨겨져 있다고 느낌
관련 링크: Common Gateway Interface, Netflix 40ms 사례, GTA Online 로딩 단축
CGI뿐 아니라 옛날 CERN, Apache 계열 HTTP 세션은 서버 전체를 포크해서 동작시켰음
시간이 지나면서 나아졌지만, Apache의 구성 방식 때문에 nginx처럼 애초에 이벤트 기반 I/O로 설계된 경량 서버가 큰 인기를 얻게 됨
sendfile의 효율성에는 회의적임
90년대 말에 유행하긴 했지만 실제로는 성능 이득이 미미하다고 생각함
대부분의 클라우드 워크로드 오케스트레이터(CloudRun, GKE, EKS, 로컬 Docker 등)는 io_uring을 기본적으로 비활성화함
이 부분이 개선되지 않으면 당분간은 io_uring이 매우 한정된 기술로 남을 것 같음
왜 그들은 io_uring을 비활성화 하는 걸까라는 의문이 듬
이런 상황이라면 다시 셀프 호스팅으로 돌아가야 함
정말 재미있게 읽었음
벤치마크를 기다릴 테니 천천히 해도 되고, 벤치마크보다 먼저 코드 정리를 중시하는 저자의 마인드가 너무 인상적임
요즘은 벤치마크 점수에 올인하는 프로젝트가 많은데, 이런 사고방식이 정말 신선하고 존경스러움
ktls나 io_uring이 이렇게까지 다양하게 쓰일 수 있는지는 몰랐음
현 시점의 비동기 처리 상황은 아래와 같음
Rust: Futures, Pin, Waker, async runtime, Send/Sync bounds, async 트레이트 오브젝트 등 다양한 개념 이해 필요
C++20: coroutines
Go: goroutines
Java21+: 가상 스레드
C++ 코루틴은 Pin이 해결하는 문제를 피하려고 힙 할당을 사용함
이건 C++가 추구하는 "제로 오버헤드" 원칙에서 크게 벗어난 부분임
Rust가 미래에도 async 트레이트를 도입하는 데 시간이 오래 걸렸던 이유도 Rust는 futures를 힙 할당하지 않기 때문임
퍼포먼스/이식성 대 복잡성의 트레이드오프가 각자 프로젝트에 따라 가치가 달라질 수 있음
Send/Sync 관련 제약은 다른 언어에도 여전히 의미가 있고, 해당 제약이 없으면 미묘하게 잘못된 코드를 더 쉽게 쓸 수 있음
"충분히 괜찮은" 수준의 Rust 코드를 쓴다면, 그리고 남이 만들어놓은 mid-level 프리미티브를 사용한다면, 굳이 저런 개념을 전부 알 필요는 없음
Rust는 저런 개념을 이해하지 않으면 아예 컴파일이 안되도록 강제함
Go는 goroutine이 비동기가 아니고, 채널을 이해하지 않으면 goroutine을 이해할 수 없음
Go의 채널 구현은 독특해서 경계 사례의 동작이 상식적으로 예측이 잘 안 됨
Go는 깊이 이해 안 해도 코딩이 되니, 장단점이 있음
"저렴한 스레드"는 비동기와 동일하지 않음
tarweb(블로그에 나온 서버)은 io_uring 기반 이벤트 루프의 싱글 스레드 구조로, CPU 코어당 하나씩 스레드를 두는 아이디어임
"대규모 동시성의 현주소"보단 "저렴한 스레드의 현주소"가 더 맞는 말일 듯함
cheap thread와 async loop의 가장 큰 차별점은 reasoning이 쉬운 것임
단점도 있는데, 각 스레드는 가볍긴 해도 스택 크기를 필요로 함
kTLS가 확실히 발전임
나도 몇 년 전에 진짜로 요청당 syscall이 0인 서버를 만들어서 블로그에 글로 남김 (https://wjwh.eu/posts/2021-10-01-no-syscall-server-iouring.html)
다만 항상 busy-looping을 해야 한다는 단점이 있음
io_uring은 최근 몇 년간 정말 인상적인 속도로 발전해왔음
이 프로젝트가 정말 멋지고 오래 전부터 비슷한 걸 구상해왔기 때문에 누군가 구현해서 기쁨
BPF도 rust로 작성하려면 Aya를 추천함
Aya 프로젝트 Github
kTLS의 현재 상태가 궁금함
얼마 전 Cilium 개발자에게 물어보니, Thomas Graf는 기대한다고 했지만 실제로는 많은 리눅스 배포판에서 커널 지원이 부족해 기본 활성화는 아직 멀었다고 함
아쉽긴 한데, 활성화가 얼마나 어려운지도 궁금함
커널을 커스텀해야 하는 건지, 아니면 런타임에서 바로 켤 수 있는 건지
FreeBSD는 13버전부터 커널/openssl에 kTLS가 들어갔고, sysctl (kern.ipc.tls.enable=1)로 런타임 토글이 가능함
FreeBSD-15에서는 기본값이 활성화로 바뀌고, Netflix에서는 거의 10년 동안 트래픽 암호화에 kTLS를 써옴
kTLS는 전반적으로 나쁜 아이디어처럼 느껴짐
한 코어당 하나의 스레드 구조가 타임 슬라이스 기반 시스템에서 맞는지 의문임
내 경험상으로는 "오버서브스크라이빙" 방식(코어 수보다 스레드를 더 두는 것)이 실제 벽 시계 시간 상의 이득을 줌
프리엠티브 스케줄링이 없을 때나 한 코어당 하나가 더 맞을 듯함
물론 그럼 Unix 얘기는 아님
낮은 지연과 높은 처리량을 원한다면 코어를 격리해서 스레드를 고정시키는 방법이 효과적임
이런 방식은 Linux에서 잘 작동하고, 트레이딩 시스템 등에서는 비효율을 감수하고서라도 많이 씀
코어들이 대부분 유휴 상태로 spin하고 실제로는 일이 없지만, latency와 throughput에서는 최적임
thread-per-core 구조의 함정은 "편리한 부분만 가져다 쓰자"라고 착각하는 거임
사실상 올인하거나 안 쓰거나 둘 중 하나임
반쪽짜리 구현은 전혀 효율이 나지 않음
다만 올바르게 설계하면 거의 모든 상황에서 효율이 높음
TPC 설계 노하우(코어 간 로드밸런싱 등)을 잘 아는 개발자는 드묾
thread-per-core에서 "CPU 바운드"일 때만 효율적임
이 서버 프로젝트처럼 대부분의 작업이 비동기적이고 이벤트 기반일 때, 서버는 거의 I/O나 syscall 대기 없이 다음 요청으로 이동해서 이론적으로는 코어당 하나의 스레드가 정확한 구조임
하지만 현실 세계에선 이런 이상적 상황이 거의 없으니 무조건 nproc 스레드로 제한하는 건 위험하다는 점을 명심해야 함
io_uring에서는 한 코어당 사용자 스레드 하나만 두는 것도 나쁜 선택이 아니라고 보임
커널에서 쓰레드 풀로 동작하기 때문임
DPDK와 같이 커널을 완전히 우회하는 스타일도 보고 싶음
논문 링크: https://www.usenix.org/system/files/atc23-zhu-lingjun.pdf