코어당 스레드(Thread-per-core)
(without.boats)- Rust async 런타임 논쟁은 “코어당 OS 스레드” 여부보다, 작업 훔치기(work-stealing) 로 부하를 맞출지 share-nothing으로 데이터 이동을 줄일지의 선택에 가까움
- async Rust는 이미 사용자 공간 동시성 처리와 비동기 I/O라는 thread-per-core의 핵심 조건을 충족하며, 남는 쟁점은 코어 간 상태 공유를 얼마나 피할 수 있느냐임
- 작업 훔치기는 스레드 유휴 시간을 줄여 꼬리 지연시간(tail latency)과 CPU 활용률을 개선하려 하지만, Rust에서는 yield 지점을 넘는 상태가 스레드 안전해야 해
Send제약이 따라옴 - share-nothing은 데이터를 특정 코어의 캐시에 머물게 해 지연시간을 낮출 수 있지만, 키-값 저장소처럼 상태 분할이 쉬운 경우와 트랜잭션·원자적 변경이 필요한 경우의 난이도가 크게 다름
- Enberg 논문은 share-nothing과 shared-state를 비교하지만 양쪽 모두 작업 훔치기를 쓰지 않으므로, 그 결과를 Rust의 작업 훔치기 실행기 비판으로 바로 연결하기는 어려움
Rust async 런타임 논쟁의 핵심
- Rust 커뮤니티에서는 tokio 같은 주요 async 런타임이 기본으로 멀티스레드 실행기를 쓰고, 여러 태스크를 작업 훔치기로 동적으로 균형화하는 선택을 두고 논쟁이 이어짐
- 일부 사용자는 이 기본값 때문에
Send + 'static또는Send + Sync + 'static제약이 코드 작성 경험을 해친다고 비판함 - 단일 스레드 서버를 선호하는 경우도 있지만, 여기서는 Rust 시스템이 하나 이상의 CPU 코어 시간을 쓰려는 상황을 전제로 함
- 대안으로 제시되는 “thread-per-core”는 더 빠르고 구현도 쉽다고 기대되지만, 성능과 구현 용이성을 동시에 얻기는 어려움
“thread-per-core”라는 이름이 만드는 혼동
- 기존 멀티스레드 async 실행기도 넓은 의미에서는 thread-per-core에 해당함
- 코어당 OS 스레드를 만들고
- 코어 수보다 훨씬 많은 태스크를 그 스레드들 위에 스케줄링함
- Pekka Enberg는 thread-per-core를 세 가지 아이디어의 결합으로 정리함
- 비싼 커널 스레드 대신 사용자 공간에서 동시성을 처리함
- 코어별 스레드가 블로킹되지 않도록 비동기 I/O를 사용함
- 동기화 비용과 CPU 캐시 간 데이터 이동을 없애기 위해 데이터를 CPU 코어 사이에 분할함
- Enberg는 높은 처리량 시스템을 만들기 위해 앞의 두 가지가 중요하지만, 세 번째는 매우 큰 멀티코어 머신에서만 필요할 수 있다고 봄
- async Rust를 사용한다면 사용자 공간 동시성과 비동기 I/O라는 앞의 두 조건은 이미 충족됨
- 따라서 논쟁의 중심은 thread-per-core 자체가 아니라, 작업 훔치기와 share-nothing 중 어떤 최적화를 택하느냐에 있음
작업 훔치기의 목적과 비용
- 작업 훔치기는 모든 스레드가 계속 할 일을 갖도록 만들어 꼬리 지연시간을 줄이는 최적화임
- 실제 시스템에서는 태스크마다 필요한 작업량이 다름
- 한 HTTP 요청이 다른 요청보다 훨씬 많은 작업을 요구할 수 있음
- 처음에 스레드별 작업을 균형 있게 나눠도, 예측 불가능한 태스크 차이 때문에 시간이 지나면 작업량이 달라질 수 있음
- 최대 부하에서는 어떤 스레드는 처리 가능한 양보다 많은 작업을 받고, 다른 스레드는 유휴 상태로 남을 수 있음
- tokio, async-std, smol은 모두 꼬리 지연시간 감소와 CPU 활용률 개선을 목표로 작업 훔치기를 구현함
- 비용은 태스크가 한 스레드에서 멈춘 뒤 다른 스레드에서 다시 시작될 수 있다는 점임
- yield 지점을 넘겨 사용되는 상태는 스레드 안전해야 함
- Rust API에서는 future가
Send여야 하는 형태로 나타남 - 시스템 상태를 명확히 파악하지 못하면 어떤 방식으로
Send를 보장할지 결정하기 어려움
- 상태가 다른 스레드로 이동하면 동기화 비용과 캐시 미스가 생기며, 각 CPU가 자기 상태만 다루는 share-nothing 원칙과 충돌함
share-nothing의 성능 논리
- share-nothing은 데이터를 여러 코어가 공유하는 느린 캐시가 아니라 특정 CPU 코어의 더 빠른 캐시에 머물게 해 꼬리 지연시간을 줄이려는 설계임
- Enberg의 논문 The Impact of Thread-Per-Core Architecture on Application Tail Latency는 share-nothing 키-값 저장소와 shared-state 구조의 memcached를 벤치마크해 꼬리 지연시간 개선을 보임
- Rust 커뮤니티에서 이 논문이 “71% 성능 개선”처럼 단순 인용되는 방식은 얕고 도움이 되지 않음
- Enberg의 키-값 저장소는 share-nothing 구조를 위해 상태와 연결을 나눔
- 해시 함수로 keyspace를 여러 스레드에 분할함
SO_REUSEPORT로 들어오는 TCP 연결을 스레드 사이에 분할함- 연결을 관리하는 스레드에서 해당 keyspace를 관리하는 스레드로 요청을 메시지 전달 채널로 라우팅함
- memcached는 모든 스레드가 분할된 keyspace의 소유권을 공유하고, 각 파티션은 mutex로 보호함
- Enberg의 결과는 mutex보다 채널을 쓰는 구조가 더 낮은 꼬리 지연시간을 달성할 수 있음을 보임
- 같은 파티션이 반복 접근되며 한 코어의 캐시에 머무르기 때문에 캐시 미스가 줄어드는 것으로 볼 수 있음
- 다만 고급 커널 기능과 신중하게 계획된 구조로 데이터 이동을 피하는 설계가, 데이터를 mutex 안에 감싸는 방식보다 구현이 더 쉽다고 보기는 어려움
상태 분할이 쉬운 경우와 어려운 경우
- 키-값 저장소는 애플리케이션 상태를 여러 스레드에 나누기 쉬워 share-nothing에 잘 맞는 사례임
- 더 복잡한 애플리케이션에서 여러 파티션의 상태를 트랜잭션 방식이나 원자적으로 변경해야 한다면, 올바르게 구현하기 위해 훨씬 더 많은 주의가 필요함
- share-nothing 옹호는 과거 eventual consistency 데이터베이스를 둘러싼 과열과 닮은 면이 있음
- 성능은 좋아질 수 있음
- 대신 데이터 불일치에서 생기는 버그를 피하려면 신중한 설계가 필요함
Enberg 논문과 Rust 작업 훔치기 논쟁의 거리
- Enberg의 구현과 memcached는 모두 작업 훔치기를 사용하지 않음
- 이 때문에 논문의 핵심 성능 결과를 Rust의 작업 훔치기 아키텍처와 직접 연결하기 어려움
- Enberg의 구조에 작업 훔치기를 추가하면 데이터 이동이 어느 정도 늘 수 있지만, CPU 활용률을 높이는 방식이 될 가능성도 있음
- memcached에 작업 훔치기를 추가하는 경우에는 도움이 되지 않을 이유를 상상하기 어렵다고 봄
- 논문 구현은 keyspace의 균형 분할과
SO_REUSEPORT로 작업을 미리 고르게 나누도록 설계됨 - 실제 환경에서는 동적 불균형이 나타날 수 있음
- hot key는 더 많은 읽기와 쓰기를 받아 해당 keyspace를 관리하는 스레드의 작업량을 늘림
- 일부 연결은 다른 연결보다 더 많은 요청을 수행해 해당 연결을 관리하는 스레드의 작업량을 키움
- 논문의 벤치마크는 각 연결이 무작위 키에 대해 일정한 작업량을 수행해 이런 불균형 조건을 재현하지 않은 것으로 이해됨
- hot key를 추가 파티션에 캐싱하는 방식처럼 share-nothing 시스템 안에서도 불균형을 완화하는 설계가 가능함
- 일부 태스크는 상태 이동을 피하기 위해 특정 코어에 고정하면서, 다른 형태의 작업 훔치기를 최적화로 사용할 수도 있음
실무적 결론
- CPU 캐시 사이의 데이터 이동을 피하도록 시스템을 신중하게 설계하면 그렇게 하지 않는 경우보다 더 나은 성능을 얻을 수 있음
- 하지만
Send제약을 generics에 추가하는 일을 가장 큰 불만으로 삼는 수준이라면, 그런 종류의 세밀한 성능 엔지니어링을 하고 있다고 보기 어려움 - 어차피 shared state를 사용할 시스템이라면, 부하 상황에서 작업 훔치기가 CPU 활용률을 개선하지 않는다고 보기는 어려움
댓글과 토론
Hacker News 의견들
-
개인적으로 이 글은 큰 그림을 놓치고 세부에 매달린다는 느낌이 듦
논쟁의 핵심은 코어당 스레드 작업 훔치기 실행기가 아니라, Rust에서 async/await가 그걸 위한 좋은 추상화인지 여부임
async 코드를 많이 쓸수록 추상화가 새고, 상대로 프로그래밍하기 어렵다고 느낌
사람들이 원하는 대안 동시성 모델은 작업 훔치기 실행기 위의 스택 있는 코루틴과 채널을 통한 구조적 동시성임
누군가 그걸 구현해 async/await와 future 기반 모델과 비교하기 전까지는 생산적인 논의가 어렵고, async를 싫어하는 사람은 피할 것이며Send + Sync + 'static을 신경 쓰지 않는 사람은 계속 쓸 것임- 그렇다면 정말 C10M 규모가 필요한 경우가 아니라면, 그냥 연결당 스레드를 쓰고 더 복잡한 걸 안 쓰면 되지 않나 싶음
그런 규모가 필요한 일은 대부분의 경우 아주 드물 것 같음
이런 글들은 “커널 스레드는 비싸다”라고만 하고 그게 본질적으로 참인 것처럼 지나가지만, 실제로는 그렇지 않음
작업이 무작업 태스크만 계속 생성하는 게 아니라면 “진짜 스레드”의 오버헤드는 작을 가능성이 높고, 대신 얻는 단순성은 엄청남 - async/await 대 스택 있는 코루틴 논쟁이 더 흥미롭다고 보지만, 이 글의 논쟁은 그게 아님
withoutboats가 다룬 인용문은 링크된 글에서 기본 다중 스레드와 작업 훔치기를 구체적으로 비판한 부분임
[1] https://www.reddit.com/r/rust/comments/16p47f1/the_state_of_... - 세상에는 사람들이 동의하지 않는 주제가 여러 개 있음
이 글은 내가 쓰길 바랐던 논쟁이 아니라 다른 논쟁을 다룬 것뿐임 - async/await를 채널과 함께 쓰면서, async 함수 인자로는 불변 참조나 복사 타입만 넘기고, 변경 가능한 공유 타입은 채널로 통신하도록 제한할 수 있음
변경 가능한 타입을 소유하고 채널로 통신하는 Erlang식 서버를 만들 수도 있고,Arc로 밀고 나갈 수도 있음
Rust는 둘 다 할 수 있는 힘을 줌 - 내가 이해하기로는 Rust의 기존 async 지원 위에서 채널을 쓰는 걸 막는 건 없음
스택 있는 코루틴은 그 지점이면 그냥 별도 스레드를 쓰면 되기 때문에 별 의미가 없어 보임
- 그렇다면 정말 C10M 규모가 필요한 경우가 아니라면, 그냥 연결당 스레드를 쓰고 더 복잡한 걸 안 쓰면 되지 않나 싶음
-
약 15년 전 코어당 스레드 모델이 처음 해결하려던 원래 문제는 범용 다중 코어 서버에서 계산 확장성과 효율을 얻는 것이었음
일부 주장과 달리 코어당 스레드는 명시적으로 CPU 중심 작업 부하를 최적화하기 위한 것이었고, 나중에 더 정교한 I/O 처리가 필요하긴 했지만 고처리량 I/O 중심 작업에도 탁월하다는 게 드러남
이런 글을 보면 코어당 스레드 아키텍처가 도입될 때 있었던 여러 소프트웨어 설계 실수를 빠르게 반복하는 것처럼 보임
공정하게 말하면 코어당 스레드 관련 컴퓨터 과학은 주로 HPC에서 비롯되어 문서화가 부족함
이 글은 코어당 스레드 아키텍처의 까다로운 문제인 코어 간 작업 균형에 초점을 둠
기본 모델은 데이터/부하의 푸시/풀 네 가지이고, 작업 훔치기는 본질적으로 부하 풀(load pull) 모델임
이 방식은 거의 쓸 일이 없을 때, 즉 실제 문제에서는 드문 자연스러운 균형이 있을 때만 오버헤드가 낮음
코어 간 동적 부하 치우침이 흔한 더 흥미로운 작업 부하에서는 조율 오버헤드 때문에 작업 훔치기가 성능 병목이 됨
그래도 이해하기 쉬워서 맞는 작업 부하에는 여전히 쓰이지만, 일반화는 잘 안 됨
글에서 언급하지 않은 드문 작업 부하 중에는 아마 최선인 경우도 있음
요즘 가장 힘을 얻는 모델은 데이터 푸시(data push) 로 보이는데, 덜 직관적이지만 스레드 간 조율이 훨씬 적게 필요함
이 모델도 맞지 않는 작업 부하가 있지만, 흔한 대부분의 작업 부하에는 잘 일반화됨
코어당 스레드 아키텍처는 계속 남을 것임. 확장성과 효율에서는 이길 수 없음
다만 현대적이고 관용적인 코어당 스레드 설계가 어떤 모습인지에 대해 대부분의 소프트웨어 엔지니어가 직관이 부족하고, 이 주제를 깊이 다루는 글이나 논문이 적어 더 나빠짐- Rust async 프레임워크가 작업 훔치기를 쓰는 주된 이유는 프레임워크 차원에서 켜기 쉽고 많은 애플리케이션, 특히 이상적으로 설계되지 않은 애플리케이션에서 성능을 개선하기 때문으로 보임
프로필의 자기소개와 이 설명을 보면, 그런 애플리케이션이 주로 다루는 종류는 아닐 것 같음
관련 문헌 링크가 있으면 보고 싶음 - 데이터 푸시라는 아이디어가 무엇인지 궁금함
- Rust async 프레임워크가 작업 훔치기를 쓰는 주된 이유는 프레임워크 차원에서 켜기 쉽고 많은 애플리케이션, 특히 이상적으로 설계되지 않은 애플리케이션에서 성능을 개선하기 때문으로 보임
-
“I/O 중심이라는 말은 실제로 Rust로 작성했을 때 단일 코어를 포화시킬 만큼 작업이 많지 않다는 뜻이다. 그렇다면 당연히 단일 스레드 시스템을 작성하라”는 문장에 공감함
내가 쓰는 애플리케이션 상당수는 배경에서 이벤트에 반응하는 데몬이고, 단일 스레드로 만들면Arc와Mutex오버헤드를 없앨 수 있음
이 오버헤드는 그 시점에 대부분 문법적 부담에 가깝지만, 디버깅과 유지보수가 쉬워짐
필요한 것에만 비용을 낸다는 점이 Rust에서 좋아하는 부분임
이 글이 응답하는 원래 글은 tokio와 다른 async 라이브러리들이 단순한 단일 스레드 아키텍처로 돌아가기 어렵게 만든다고 비판함
과장이 있긴 하지만 대체로 그 비판에 동의함
고처리량 애플리케이션에 더 좋다는 이유로 기본값을 더 복잡하게 만드는 건 Rust의 이상과 반대처럼 보임- 그런 서비스를 작성해 봤지만, 그런 걸 I/O 중심이라고 부르지는 않았을 것임
처리량에 묶인 게 아니라 대부분 유휴 상태로 있다가 작업이 생기면 시스템 자원 사용을 줄이기 위해 빠르게 끝내려는 형태임
가끔 거대한 작업 폭주가 생기고 그때 지연 시간이 매우 중요하지 않은 이상, 스레드를 하나 넘게 쓰는 건 이득 없이 복잡성과 오버헤드만 더함 - 맞음. 동시성을 원하는 모든 게 웹 서버는 아님
운영체제에서는 모든 시스템 서비스가 IPC 요청을 동시에 처리해야 하지만, 대부분은 전체 CPU 소비를 줄이기 위해 단일 스레드로 처리함
4코어 장치에서 수십 개 서비스를 코어당 스레드로 만들면 CPU와 RAM 낭비임 - tokio는 정말 필요할 때 쓸 수 있는 단일 스레드 실행기를 지원하고, 어렵지도 않음
tokio API에서는LocalSet이라고 부름
https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html... - 같은 인용문에 대해 쓰려던 참이었음
단일 CPU의 단일 스레드로 충분하더라도 여전히 동시성은 원할 수 있음 Arc와Mutex대신Rc와RefCell을 쓰게 될 텐데, 코드 측면에서는 비슷하게 복잡하고 장황하지 않나 싶음
효율이 더 낮다는 건 이해하지만, 설명한 경우라면 원자적 연산 몇 개의 추가 비용은 어차피 무시할 만하지 않을까 싶음
- 그런 서비스를 작성해 봤지만, 그런 걸 I/O 중심이라고 부르지는 않았을 것임
-
인용문에 대해 말하자면, “Rust async 프로그래밍의 원죄는 기본값을 다중 스레드로 만든 것이다…
Send + 'static, 더 나쁘면Send + Sync + 'static이 Rust를 쓰는 즐거움을 죽인다”는 표현은 너무 멜로드라마 같다는 데 동의함
Send + Sync를 없애도 그렇게 큰 차이가 난다고 보지는 않음
가장 거슬리는 건'static이고, 그건 작업 훔치기 때문이 아님
원하는 건 스코프가 있는 동시성임
예를 들면 <https://github.com/tokio-rs/tokio/issues/2596> 같은 것임
지금 Rust async에서 또 정말 싫은 건 계측 부족임
회사에서 일부 태스크가 그냥 멈춰 버리는 프로덕션 문제가 있는데,gdb; thread apply all bt에 해당하는 걸 하고 싶음
적어도 <https://github.com/tokio-rs/tokio/issues/5638>가 들어오길 기대함
현재도 실험적으로 존재하지만 내 경험으로는 가끔 패닉이 남
오늘 실제로 PR을 써서 SIGTERM 시 실험 버전을 써 보려 함
어차피 종료 중이라 크래시가 나도 상관없다는 판단임
이 불만들 중 어느 것도 작업 훔치기를 없앤다고 해결되지 않음
더 나열해도 작업 훔치기를 제거하는 건 거의 아무것도 돕지 않을 것임- Java가 많은 비판을 받지만, 이 부분은 25년쯤 전부터 그냥 잘 됐고 문제 해결에 엄청나게 유용했음
디버거도 필요 없고 JVM에 SIGQUIT을 보내면 모든 스레드의 스택 추적을 stderr로 덤프하고 계속 실행함
각 스레드가 어떤 락을 잡고 있거나 기다리는지도 포함됨
다른 언어를 쓸 때마다 이 기능이 그리움
프로덕션에서 임시 프로파일링에도 쓸 수 있음
스냅샷을 여러 번 뜬 뒤grep/sed/sort/uniq로 핫스팟을 찾으면 됨 - 이런 문제를 완화하려고 회사에서는 어떤 태스크가 실행 중이고 각 태스크가 현재 어떤 파일/줄 번호에 있는지 주기적으로 로깅해 주는 라이브러리를 씀
모든await지점 앞에r.set_location(file!(), line!());를 수동으로 뿌려야 하지만, 시스템이 왜 멈춘 것처럼 보이는지 설명하는 데 여러 번 도움 됨
[1] https://github.com/antialize/tokio-tasks/blob/main/src/run_t...에는set_location()이 있고,task.rs에는list_tasks()가 있음
- Java가 많은 비판을 받지만, 이 부분은 25년쯤 전부터 그냥 잘 됐고 문제 해결에 엄청나게 유용했음
-
이 문제에는 정답이 없고, 전부 사용 사례에 달려 있음
결국 I/O 중심과 CPU 중심 작업 부하, 그리고 캐시 축출이나 락 경합 같은 것이 얼마나 나쁘게 영향을 주는지의 문제임
외부 데이터베이스와 통신하고 중간에 가벼운 비즈니스 로직이 있는 HTTP 서버를 공유 가상 서버에 올리는 거라면 작업 훔치기와 스레드 재사용이 직관적으로는 말이 됨
물론 항상 벤치마크해야 함
반대로 데이터베이스나 비슷한 시스템을 만들고 있고, 부하 하에서 높은 동시성과 많은 문맥 전환이 캐시 축출과 경합을 곳곳에서 일으킨다면 고생하게 됨
이때는 코어당 스레드가 매우 타당하고, async 프레임워크 자체가 의미 없을 수도 있음
무엇이 “더 낫다”는 독단적 정답은 없음
애플리케이션을 프로파일링해야 함
전에도 말했지만, Rust 전체의 초점이 웹 서비스 개발의 대규모 유입으로 왜곡되고 있다고 느낌
Rust가 그런 작업에 맞는 언어인지 아직 확신하지 못하지만, 그들에게는 꽤 괜찮게 작동하는 듯하니 그건 그렇다 침
다만 언어에 대한 공개 논의와 전면에 밀려 나오는 크레이트들이 지금 전반적으로 이 편향을 반영함
이 포럼의 소프트웨어 엔지니어 다수의 편향이기도 함- async가 Rust의 다른 부분에 영향을 주는 문제는 별로 보지 못했음
게임 엔진, 암호화 라이브러리, 커널, 명령줄 도구, 컴파일러 등 시스템 애플리케이션은 async를 건드리지 않고도 성공적으로 만들어지고 있음
큰 암호화 라이브러리를 유지보수하고 있는데, async 쪽 일에는 전혀 영향받지 않았음 - 웹 개발자 유입에 동의함
그 결과로 Rust의 시스템 프로그래밍 유용성이 망가진다면 아쉬울 것임 - 웹 앱은 오히려 코어당 스레드에 이상적이라고 생각했음
애플리케이션 자체는 요청 바깥의 상태가 거의 없고, 소켓 리스너와 데이터베이스 연결은 스레드별로 나눌 수 있음
남아 있는 상태도 요청 간 대부분 정적일 가능성이 높아 캐시 무효화가 자주 일어나지 않음
공유 상태가 없으니 소유권 처리도 쉬워야 함 - Rust 커뮤니티가 시장과 인지도를 얻기 위해 그런 개발자들을 꽤 적극적으로 끌어들였기 때문에 이런 일이 생겼다고 봄
그게 좋다 나쁘다를 말하려는 건 아니지만, 이제 Rust는 품질이 제각각인 웹 관련 라이브러리와 프레임워크의 끝없는 흐름을 안고 살아야 함
그리고 핵심 기반 라이브러리와 크레이트 상당수가 async 우선 접근을 택했기 때문에 async는 계속 논의 주제로 남을 것임
이제는 일반 개발자가 프로젝트의 주요 방침으로 async를 쓰지 않겠다고 하지 않는 한, 비즈니스 문제를 위한 평범한 동기 코드를 쓰기 어려운 수준이 됨
- async가 Rust의 다른 부분에 영향을 주는 문제는 별로 보지 못했음
-
실행기 스레드 사이에서 태스크 이동을 허용하기 위해
Send경계를 요구하는 건 Rust async 시스템 자체의 명확한 결함이라고 봄
async Drop의 근본 문제와 함께 스코프 있는 API 구현을 막기 때문임
스레드와 마찬가지로 spawn이나 채널을 통한 데이터 전송 같은 함수에만Send경계가 있으면 충분해야 함
공유하지 않는 접근은 대개 이 결함을 숨기기 위한 우회책 이상이 아님
태스크를 특정 스레드/코어에 선택적으로 고정하는 건 장점이 있고 어떤 상황에서는 정말 유용하지만, 그건 더 세밀한 논의이며Send와 관련된 async 사용자 불만과는 별로 관계가 없음 -
좋은 글이고 제목만 보지 말고 더 읽어 보길 권함
가장 마음에 든 문장은 “가장 큰 불만이 어떤 제네릭에Send경계를 추가하는 것이라면, 그런 사람이 그런 수준의 엔지니어링을 하고 있다고 믿기 어렵다”였음
편집: “duped”의 댓글에 전적으로 동의함
이 논의의 더 큰 맥락을 몰랐기 때문에 이 문장을 너무 성급하게 인용했을 수 있음 -
“시스템 상태를 제대로 보지 못하는 사람들은 future가
Send가 되도록 보장하는 최선의 방법을 찾기 어려울 수 있다”는 대목은 좀 오만하게 들리지 않나 싶음
'static수명과Send/Sync제약 문제는 개발자들에게 널리 와닿고, 그들이 멍청하다는 인상은 받지 못했음- 앞서 한 말을 다시 가리키는 것임
사람들은 작업 훔치기를 안 하는 게 더 쉽고 더 빠르다고 말함
내 주장은 둘 중 하나라는 것임
공유하지 않는 방식이 더 빠르려면, 공유 상태 아키텍처를 스레드 안전하게 만드는 것보다 쉽지 않은 방식으로 코드를 설계해야 함
다음 문단에는 “느리다”에 대응되는 평행 문장이 있음
병렬·동시 Rust를 컴파일되게 만드는 데 어려움을 겪는 사람들이 멍청하다고 생각하지는 않음
다만 우리가 만들어 준 API가 삶을 망친 것처럼 행동하는 건 좋아하지 않음 - “어렵다”가 따옴표 안에 있는 건 반드시 더 어렵지는 않기 때문임
어차피 해야 할 일이라면 더 어려운 게 아님
“사람들이 사소한 작업에 과민반응한다”기보다는, async가 언젠가 풀어야 할 문제를 더 일찍 풀게 만든다는 쪽임
빌림 검사기와 비슷한 느낌임
때로는 과하게 제한적이지만, 때로는 모든 게 괜찮다고 가정할 때 고려하지 않은 모서리 사례가 실제로 있었던 것임 - 개인적으로도 내 상태가
Send인지 아닌지 이해하는 데 때때로 확실히 어려움을 겪음
그래서 인용한 문장이 와닿음
- 앞서 한 말을 다시 가리키는 것임
-
이건 큰 그림을 놓치고 세부에 매달리는 글임
모든 프로그램에서 최선의 성능을 얻는 항상 맞는 방법은 없음
얼마든지 논쟁할 수 있지만 코어당 스레드의 장단점은 전형적인 “상황에 따라 다름”임
문제는 애초에async사용 자체가 성급한 최적화라는 데 있음
Rust 프로그램의 99%는 redis나 linkerd 같은 게 아님
Python이나 Ruby로 써도 충분히 빠른 CLI 도구나 웹 앱임
그런데 왜 커뮤니티는 블로킹 I/O Rust를 버리고 모든 것이 async가 되었으며, 개발자들은 기본값처럼 모든 것에#[tokio::main]을 붙이고 있는가 싶음- 한 가지 이유는, 코어당 스레드 성능으로 충분하다면 애초에 Rust를 쓰고 싶지 않을 가능성이 높기 때문일 수 있음
속도를 내주는 대신 프로그래밍 경험이 더 좋은 언어가 있고, Python이 그런 예임
Rust를 쓰고 싶다면 아마 추가 성능이 필요할 것이고, 이미 덜 편한 언어를 받아들인 만큼 더 나은 성능을 위해 덜 편한 스타일도 받아들일 수 있음
- 한 가지 이유는, 코어당 스레드 성능으로 충분하다면 애초에 Rust를 쓰고 싶지 않을 가능성이 높기 때문일 수 있음
-
Rust를 써 본 적은 없지만 불만은 이해됨
필요하지도 않고 CPU 여유가 많은 상황에서는 단일 요청의 종단 간 지연 시간을 오히려 늦출 수 있는 부하 재분배를 위해, 상태가 스레드 사이에서 옮겨질 수 있도록 코드를 특별한 방식으로 써야 한다면 짜증 날 것 같음
이전 가능한 상태가 기본값이고 거의 깨지지 않는 플랫폼이라면 이런 접근이 말이 될 수 있겠지만, Rust에서는 그렇지 않은 듯함
궁금한 건 사용감임
그냥 “Send라는 마법 주문을 추가하지 않으면 코드가 컴파일되지 않는다”에 가까운지, 아니면 “작업 훔치기 중 상태가 망가지면서 간헐적이고 디버깅하기 어려운 방식으로 실패한다”에 가까운지 궁금함- 대략 이런 코드를 쓴다고 해보자
Server가 있고serve에서 메시지를 읽은 뒤 각 메시지 핸들러를 새 태스크로spawn(async move { ... })하는 식임
처음에는 모든 게 잘 동작함
그러다 어느 날do_this구현을 바꿔서this의 타입이 더 이상Send가 아니게 되면,spawn(...)이 익명async move { }스코프가 만든 타입이Send가 아니라서 안 된다는 험악한 컴파일 오류를 받게 됨
이유가 꼭 명확하지도 않고 오류 메시지도 도움이 안 됨
this가Send가 아니면do_that(arg).await의.await를 가로질러 잡고 있을 수 없음
각.await는 future가 양보하고 실행기에 의해 다른 스레드에 스케줄될 수 있는 실행 지점이기 때문임
타입을Send로 만들 수 있으면 괜찮음
하지만 그럴 수 없는 경우도 충분히 있고, 그러면 future 스케줄링을spawn_local같은 것으로 바꿔야 함
이걸 호출하려면 보일러플레이트를 꽤 추가해야 할 수도 있음
이것이Send의 문제임
단순히 타입 애너테이션을 추가하는 문제가 아니라, 타입이Send를 구현하는지 항상 명확하지 않기 때문에 코드에 미묘하게 스며들어 나중에 비명확한 방식으로 깨질 수 있음 - 전반적으로 컴파일러가
Send와Sync여부를 감지함
프로그램을 스레드 안전하게 작성하면 문제가 없음
핵심은 이거임
사람들은 Tokio async가 여기저기Send와Sync를 요구해서 어렵다고 불평하지만, 실제로는 어떤 종류든 안전한 동시성 코드를 작성하는 것 자체가 어려움
직관적이지 않고, 문제는 async가 자동으로 “처리해 주는” 것처럼 느껴지게 만든다는 데 있음
하지만 실제로는 그렇지 않음
자신이 뭘 하는지 알아야 하고, 컴파일러는 여기서 도와줄 뿐임
코어당 스레드로 가면 어떤 상황에서는Send필요를 숨길 수 있지만, 전부는 아님
그리고 장기적으로는 아키텍처 측면에서 되돌아와 물 수 있음 - 코드는 필요한 곳마다 모든 것이
Send/Sync가 아니면 컴파일되지 않음
틀릴 수도 있지만, 이를 달성하는 게으른 방법은 보통 공유될 수 있는 것들을Arc나Mutex로 감싸는 것임 - 코어당 스레드를 기본값으로 삼는 것에 반대하는 논리는 단순하게 만들 수 있다고 봄
CPU 중심이면 작업 훔치기가 대부분의 경우 더 나을 것임
I/O 중심이면 코어당 스레드가 더 잘 작동할 수 있지만, 어차피 CPU 여유가 충분하므로 성능은 크게 중요하지 않음
내 생각에는 작업 훔치기가 언어 API에 넣을 더 나은 기본값임
- 대략 이런 코드를 쓴다고 해보자